├── .commitlintrc.json ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── on-pull-request.yml │ └── on-push-main.yml ├── .gitignore ├── .husky └── commit-msg ├── .nvmrc ├── .releaserc ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.cjs ├── eslint.config.mjs ├── jest.config.mjs ├── jest.setup.mjs ├── package-lock.json ├── package.json ├── sbom.json ├── src ├── AnimateUtil │ ├── AnimateUtil.spec.ts │ └── AnimateUtil.ts ├── CapabilitiesUtil │ ├── CapabilitiesUtil.spec.ts │ └── CapabilitiesUtil.ts ├── FeatureUtil │ ├── FeatureUtil.spec.ts │ └── FeatureUtil.ts ├── FileUtil │ ├── FileUtil.spec.ts │ ├── FileUtil.ts │ └── federal-states-ger.json ├── GeometryUtil │ ├── GeometryUtil.spec.ts │ ├── GeometryUtil.ts │ └── TestCoords.ts ├── LayerUtil │ ├── InkmapTypes.ts │ ├── LayerUtil.spec.ts │ └── LayerUtil.ts ├── MapUtil │ ├── MapUtil.spec.ts │ └── MapUtil.ts ├── MeasureUtil │ ├── MeasureUtil.spec.ts │ └── MeasureUtil.ts ├── PermalinkUtil │ ├── PermalinkUtil.spec.ts │ └── PermalinkUtil.ts ├── ProjectionUtil │ ├── ProjectionUtil.spec.ts │ └── ProjectionUtil.ts ├── TestUtil.ts ├── WfsFilterUtil │ ├── WfsFilterUtil.spec.ts │ └── WfsFilterUtil.ts ├── declarations.d.ts ├── index.ts └── typeUtils │ ├── typeUtils.spec.ts │ └── typeUtils.ts ├── tasks └── update-gh-pages.js ├── tsconfig.json ├── typedoc.json └── watchBuild.cjs /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/on-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Test successful build of ol-util 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [20.x, 22.x] 12 | 13 | steps: 14 | - name: Checkout sources 🔰 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 🧮 in version ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | 22 | - name: Cache Node.js modules 💾 23 | uses: actions/cache@v4 24 | with: 25 | path: ~/.npm 26 | key: "${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}" 27 | restore-keys: | 28 | ${{ runner.OS }}-node- 29 | ${{ runner.OS }}- 30 | 31 | - name: Install dependencies ⏬ 32 | run: npm ci 33 | 34 | - name: Run tests 🩺 35 | run: npm run check 36 | 37 | - name: Build artifacts 🏗️ 38 | run: npm run build 39 | -------------------------------------------------------------------------------- /.github/workflows/on-push-main.yml: -------------------------------------------------------------------------------- 1 | name: Run coveralls 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | coveralls: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout sources 🔰 14 | uses: actions/checkout@v4 15 | 16 | - name: Use Node.js 22.x 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 22.x 20 | 21 | - name: Cache Node.js modules 💾 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.npm 25 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.OS }}-node- 28 | ${{ runner.OS }}- 29 | 30 | - name: Install dependencies ⏬ 31 | run: npm ci 32 | 33 | - name: Generate coverage 🧪 34 | run: npm test 35 | 36 | - name: Publish to coveralls ⭐ 37 | uses: coverallsapp/github-action@main 38 | with: 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | build: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout sources 🔰 45 | uses: actions/checkout@v4 46 | - name: Setup Node.js 22 🧮 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: 22 50 | 51 | - name: Cache Node.js modules 💾 52 | uses: actions/cache@v4 53 | with: 54 | path: ~/.npm 55 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 56 | restore-keys: | 57 | ${{ runner.OS }}-node- 58 | ${{ runner.OS }}- 59 | 60 | - name: Install dependencies ⏬ 61 | run: npm ci 62 | 63 | - name: Build artifacts 🏗️ 64 | run: npm run build 65 | 66 | - name: Build docs 📔 67 | run: npm run build:docs 68 | 69 | - name: Get the version 📎 70 | run: | 71 | echo "VERSION=$(node -pe "require('./package.json').version")" >> $GITHUB_ENV 72 | 73 | - name: Deploy (v${{ env.VERSION }}) 🚀 74 | uses: JamesIves/github-pages-deploy-action@v4 75 | with: 76 | token: ${{ secrets.GITHUB_TOKEN }} 77 | branch: gh-pages 78 | folder: docs 79 | target-folder: v${{ env.VERSION }} 80 | 81 | - name: Deploy docs (latest) 🚀 82 | uses: JamesIves/github-pages-deploy-action@v4 83 | with: 84 | token: ${{ secrets.GITHUB_TOKEN }} 85 | branch: gh-pages 86 | folder: docs 87 | target-folder: latest 88 | 89 | - name: Release 🚀 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 93 | run: npx semantic-release 94 | 95 | - name: Generate JSON SBOM 🏛️ 96 | uses: CycloneDX/gh-node-module-generatebom@v1 97 | with: 98 | output: sbom.json 99 | 100 | - name: Commit SBOM 👈 101 | run: | 102 | git config --global user.name 'terrestris' 103 | git config --global user.email 'terrestris@users.noreply.github.com' 104 | git diff --quiet || git commit -am "chore: update sbom.json" 105 | git push origin main 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | dist/ 4 | docs/ 5 | coverage/ 6 | 7 | # Ignore IntelliJ iml files 8 | *.iml 9 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | # Disable in CI 2 | [ -n "$CI" ] && exit 0 3 | 4 | npx commitlint --edit 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/changelog", 9 | "@semantic-release/npm", 10 | "@semantic-release/git", 11 | "@semantic-release/github" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "port": 9230, 9 | "runtimeArgs": [ 10 | "--inspect-brk=9230", 11 | "${workspaceRoot}/node_modules/.bin/jest", 12 | "--runInBand", 13 | "--watch" 14 | ], 15 | "runtimeExecutable": null 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [21.3.0](https://github.com/terrestris/ol-util/compare/v21.2.0...v21.3.0) (2025-04-09) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * adds test case for empty array ([33a03ff](https://github.com/terrestris/ol-util/commit/33a03ffbe11a240b81de995e654a54f446bf4f22)) 7 | * use random floating numbers (sorted) for check ([546910d](https://github.com/terrestris/ol-util/commit/546910d37ea8f447a0973f4a5c31e059d4c3ee75)) 8 | 9 | 10 | ### Features 11 | 12 | * add util to calculate center and scale to fit in mapview ([578c70b](https://github.com/terrestris/ol-util/commit/578c70bb2c38661a4f432bfed79ce1b8f6bac7be)) 13 | 14 | # [21.2.0](https://github.com/terrestris/ol-util/compare/v21.1.1...v21.2.0) (2025-04-09) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * **ol:** update ol to v10.5.0 ([88319fc](https://github.com/terrestris/ol-util/commit/88319fc14cc94c76dcbc37c20f354a2a8063c06a)) 20 | 21 | 22 | ### Features 23 | 24 | * adds support for node v22 ([47f323a](https://github.com/terrestris/ol-util/commit/47f323ac945a332156cd7568d7113dad2377c261)) 25 | 26 | ## [21.1.1](https://github.com/terrestris/ol-util/compare/v21.1.0...v21.1.1) (2025-03-25) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * use id property as fallback when getId() is undefined ([ee534d2](https://github.com/terrestris/ol-util/commit/ee534d20f4cb518e625dfb1177123cfe034b7185)) 32 | 33 | # [21.1.0](https://github.com/terrestris/ol-util/compare/v21.0.0...v21.1.0) (2025-03-20) 34 | 35 | 36 | ### Features 37 | 38 | * introduce util to create polygon from an OpenLayers extent ([5583a79](https://github.com/terrestris/ol-util/commit/5583a791a1c21b47a1f1a4a2279287420e3a7308)) 39 | 40 | # [21.0.0](https://github.com/terrestris/ol-util/compare/v20.0.0...v21.0.0) (2024-10-21) 41 | 42 | 43 | ### chore 44 | 45 | * bump eslint to v9 ([17fbc94](https://github.com/terrestris/ol-util/commit/17fbc9496404b7e488ebf29569822292478d78dd)) 46 | 47 | 48 | ### BREAKING CHANGES 49 | 50 | * bump eslint to v9 51 | 52 | # [20.0.0](https://github.com/terrestris/ol-util/compare/v19.0.1...v20.0.0) (2024-09-20) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * specify types path ([a9067ac](https://github.com/terrestris/ol-util/commit/a9067ac5822aa47d3ddf0c69360fca13af3e06e0)) 58 | * update to latest api of ol and turfjs ([ba75f88](https://github.com/terrestris/ol-util/commit/ba75f88c4788bef533f415d6d8df23bc72533dd1)) 59 | 60 | 61 | ### chore 62 | 63 | * update to ol 10 ([6aca6b3](https://github.com/terrestris/ol-util/commit/6aca6b35ad86ac81108a71bcbe6e64f87c12bf85)) 64 | 65 | 66 | ### BREAKING CHANGES 67 | 68 | * required peer dependency for ol is now >=10 69 | 70 | ## [19.0.1](https://github.com/terrestris/ol-util/compare/v19.0.0...v19.0.1) (2024-08-12) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * fixes the function so it accepts packages that do not return an array ([ab08107](https://github.com/terrestris/ol-util/commit/ab08107af852fa1ae01831738e8fdf8b89a47ada)) 76 | 77 | # [19.0.0](https://github.com/terrestris/ol-util/compare/v18.0.1...v19.0.0) (2024-05-24) 78 | 79 | 80 | ### Features 81 | 82 | * update readme ([#1430](https://github.com/terrestris/ol-util/issues/1430)) ([9ed0617](https://github.com/terrestris/ol-util/commit/9ed061732bee41f58d375370a513ca98415e6c5f)) 83 | 84 | 85 | ### BREAKING CHANGES 86 | 87 | * ol-util now produces a ESM build, so downstream 88 | apps need to include it in their bundler when transpiling for 89 | the browser. 90 | 91 | ## [18.0.1](https://github.com/terrestris/ol-util/compare/v18.0.0...v18.0.1) (2024-05-16) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * use WmsLayer type in getExtent function ([ad0b70a](https://github.com/terrestris/ol-util/commit/ad0b70a4906af80b41f0614dd72fa31e8b16b757)) 97 | 98 | # [18.0.0](https://github.com/terrestris/ol-util/compare/v17.0.1...v18.0.0) (2024-05-14) 99 | 100 | 101 | ### Features 102 | 103 | * add wms layer type utils ([adb1ca4](https://github.com/terrestris/ol-util/commit/adb1ca490a43a903ce764cb02d6b493a285fb702)) 104 | * remove duplicate `WMSLayer` type ([fa82fdd](https://github.com/terrestris/ol-util/commit/fa82fddbeeaf0cdc84fecce5e4599156d7d39993)) 105 | 106 | 107 | ### BREAKING CHANGES 108 | 109 | * removes the `WMSLayer` type from `MapUtils`. Use `WmsLayer` from typeUtils instead. 110 | * removes the `LayerUtil.isOlSource(source)` etc. functions in favor of 111 | `instanceof` checks 112 | 113 | ## [17.0.1](https://github.com/terrestris/ol-util/compare/v17.0.0...v17.0.1) (2024-05-10) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * update getExtentForLayer ([8475afa](https://github.com/terrestris/ol-util/commit/8475afa68d52f88fb74334be40ce5bc0cc348fd7)) 119 | 120 | # [17.0.0](https://github.com/terrestris/ol-util/compare/v16.0.0...v17.0.0) (2024-04-02) 121 | 122 | 123 | ### Features 124 | 125 | * update ol peer dependency ([#1359](https://github.com/terrestris/ol-util/issues/1359)) ([48d513d](https://github.com/terrestris/ol-util/commit/48d513d9eb16d026b7302e6b76d0da9f9ea33cf6)) 126 | 127 | 128 | ### BREAKING CHANGES 129 | 130 | * Updates the ol peerDependency to version 9 131 | 132 | # [16.0.0](https://github.com/terrestris/ol-util/compare/v15.0.1...v16.0.0) (2024-03-05) 133 | 134 | 135 | ### Features 136 | 137 | * allow olFilter instances in WfsFilterUtil ([1e01896](https://github.com/terrestris/ol-util/commit/1e0189610cc888fdd20319ee74572868df14a652)) 138 | 139 | 140 | ### BREAKING CHANGES 141 | 142 | * createWfsFilter returs an OLFilter instance / undefined 143 | now 144 | 145 | ## [15.0.1](https://github.com/terrestris/ol-util/compare/v15.0.0...v15.0.1) (2024-03-01) 146 | 147 | 148 | ### Bug Fixes 149 | 150 | * adds required peer dependencies for ts-eslint ([3665caa](https://github.com/terrestris/ol-util/commit/3665caadfa28af2a55902ccd0095473b97ede8a1)) 151 | 152 | # [15.0.0](https://github.com/terrestris/ol-util/compare/v14.0.0...v15.0.0) (2024-01-18) 153 | 154 | 155 | ### chore 156 | 157 | * update to ol 8.2.0 ([1416bc9](https://github.com/terrestris/ol-util/commit/1416bc9db4a715c627ae2d87766a4145dce02f63)) 158 | 159 | 160 | ### BREAKING CHANGES 161 | 162 | * updated peer dependency for ol since some typings become more strict 163 | 164 | # [14.0.0](https://github.com/terrestris/ol-util/compare/v13.0.0...v14.0.0) (2023-10-09) 165 | 166 | 167 | ### Bug Fixes 168 | 169 | * add comment for the decimal precision parameter ([868cd7b](https://github.com/terrestris/ol-util/commit/868cd7b07c592ca942cb228e5f4dd5e7edbfacb7)) 170 | * added getLength decimal precision for non geodesic map ([622c40a](https://github.com/terrestris/ol-util/commit/622c40a28adfe075ceab23b1364a0596d2d71b39)) 171 | * set length decimal precision to function parameters ([eadd2ff](https://github.com/terrestris/ol-util/commit/eadd2ff58acc3f9444542859ef3f787f07bc8eb9)) 172 | 173 | 174 | ### BREAKING CHANGES 175 | 176 | * change default decimal precision to 10^6 177 | * change default decimal precision to 10^6 178 | * change default decimal precision to 10^6 179 | 180 | # [13.0.0](https://github.com/terrestris/ol-util/compare/v12.0.1...v13.0.0) (2023-09-05) 181 | 182 | 183 | ### chore 184 | 185 | * bump ol dependency to 8.x ([8398356](https://github.com/terrestris/ol-util/commit/8398356560fe233055eae31f2bedf4707ba0775d)) 186 | 187 | 188 | ### BREAKING CHANGES 189 | 190 | * set ol peer dependency to version 8 191 | 192 | ## [12.0.1](https://github.com/terrestris/ol-util/compare/v12.0.0...v12.0.1) (2023-09-03) 193 | 194 | 195 | ### Bug Fixes 196 | 197 | * remove no longer available snyk badge ([498af80](https://github.com/terrestris/ol-util/commit/498af802642e363ab609b27d7e15d44c0df49a5b)) 198 | 199 | # [12.0.0](https://github.com/terrestris/ol-util/compare/v11.1.0...v12.0.0) (2023-08-31) 200 | 201 | 202 | ### Bug Fixes 203 | 204 | * calculation of circle area for metrical and spherical units ([7437a54](https://github.com/terrestris/ol-util/commit/7437a5415f2211acecdd62b228ddedd07c840cf0)) 205 | * fix circle area calculation ([59045eb](https://github.com/terrestris/ol-util/commit/59045ebbac54415d93b345b85a2d3f15d041b8c9)) 206 | * fix getArea for circles ([38a95f7](https://github.com/terrestris/ol-util/commit/38a95f7b5611a713b7e8f2ae294e92c84cde1289)) 207 | 208 | 209 | ### chore 210 | 211 | * allow broader version range as peer dependency ([ce0c20a](https://github.com/terrestris/ol-util/commit/ce0c20a35c2bd3c8eb5e3d14a61b9b4ac5f84347)) 212 | * set required node version to v18 ([b028124](https://github.com/terrestris/ol-util/commit/b028124a9414ab26caea4617948b21e9382ee411)) 213 | 214 | 215 | ### BREAKING CHANGES 216 | 217 | * set peer dependency for OpenLayers to ^7 218 | * require node v18 219 | 220 | # [11.1.0](https://github.com/terrestris/ol-util/compare/v11.0.0...v11.1.0) (2023-07-19) 221 | 222 | 223 | ### Bug Fixes 224 | 225 | * linting issues ([4e1698e](https://github.com/terrestris/ol-util/commit/4e1698ea3b6ddce0cbc4517a8c4901f00bfcab7e)) 226 | 227 | 228 | ### Features 229 | 230 | * add formatArea for circles ([4a50106](https://github.com/terrestris/ol-util/commit/4a5010620b505c4ab194febe4e0d2aa7a3e51b51)) 231 | 232 | # [11.0.0](https://github.com/terrestris/ol-util/compare/v10.3.1...v11.0.0) (2023-06-26) 233 | 234 | 235 | ### Bug Fixes 236 | 237 | * adds check for capabilities structure ([a43def5](https://github.com/terrestris/ol-util/commit/a43def5617186b5ba017f36637c9692101b6ad26)) 238 | 239 | 240 | ### Features 241 | 242 | * replace xml2js by fast-xml-parser ([9c69e91](https://github.com/terrestris/ol-util/commit/9c69e91b8bbb610416491157bada62922922a1ae)) 243 | 244 | 245 | ### BREAKING CHANGES 246 | 247 | * the installation of packages timers and stream is not required any more 248 | 249 | ## [10.3.1](https://github.com/terrestris/ol-util/compare/v10.3.0...v10.3.1) (2023-06-20) 250 | 251 | 252 | ### Bug Fixes 253 | 254 | * fix extent determination for getCapabilities requests v1.1.0 or v1.1.1 ([c838b8d](https://github.com/terrestris/ol-util/commit/c838b8d6795c17d312698ce045d4f42d2fe218de)) 255 | * fix instance check ([912b3ab](https://github.com/terrestris/ol-util/commit/912b3ab32e00b7040bc01ec4ef128f0c34b457ac)) 256 | 257 | # [10.3.0](https://github.com/terrestris/ol-util/compare/v10.2.4...v10.3.0) (2023-05-15) 258 | 259 | 260 | ### Features 261 | 262 | * util to set visiblity for a list of layers ([f250e56](https://github.com/terrestris/ol-util/commit/f250e5624e2bdf5fce70a95b186737c590815710)) 263 | 264 | ## [10.2.4](https://github.com/terrestris/ol-util/compare/v10.2.3...v10.2.4) (2023-03-07) 265 | 266 | 267 | ### Bug Fixes 268 | 269 | * fix wfs filter builder ([0f59f14](https://github.com/terrestris/ol-util/commit/0f59f14d7cc8b8b2f782f7377a0089db993489c9)) 270 | 271 | ## [10.2.3](https://github.com/terrestris/ol-util/compare/v10.2.2...v10.2.3) (2023-02-17) 272 | 273 | 274 | ### Bug Fixes 275 | 276 | * moveFeature in AnimateUtil ([c35ca3b](https://github.com/terrestris/ol-util/commit/c35ca3b0ec954495ca0f802b072a93cc88adf316)) 277 | 278 | ## [10.2.2](https://github.com/terrestris/ol-util/compare/v10.2.1...v10.2.2) (2023-02-06) 279 | 280 | 281 | ### Bug Fixes 282 | 283 | * wfs query append ([9358966](https://github.com/terrestris/ol-util/commit/9358966b9197a499b11f62d654c4db583555c819)) 284 | 285 | ## [10.2.1](https://github.com/terrestris/ol-util/compare/v10.2.0...v10.2.1) (2023-01-26) 286 | 287 | 288 | ### Bug Fixes 289 | 290 | * reintroduces the use of propertyNames ([c222326](https://github.com/terrestris/ol-util/commit/c2223264ba71f6c1e897b592502e019006b853fb)) 291 | 292 | # [10.2.0](https://github.com/terrestris/ol-util/compare/v10.1.3...v10.2.0) (2023-01-23) 293 | 294 | 295 | ### Bug Fixes 296 | 297 | * fix semantic release action ([#925](https://github.com/terrestris/ol-util/issues/925)) ([36fef96](https://github.com/terrestris/ol-util/commit/36fef9639b1e7f197aea2491606cc645b39d5927)) 298 | 299 | 300 | ### Features 301 | 302 | * add custom print params for wms ([#923](https://github.com/terrestris/ol-util/issues/923)) ([04dc9fb](https://github.com/terrestris/ol-util/commit/04dc9fb8d35f6a5e3f682afbbef0e21c62fa072f)) 303 | * use node 18 for semantic release ([#924](https://github.com/terrestris/ol-util/issues/924)) ([ecf52b9](https://github.com/terrestris/ol-util/commit/ecf52b9af80c8a7d3fc5804ba819057cd448e063)) 304 | 305 | ## [10.1.3](https://github.com/terrestris/ol-util/compare/v10.1.2...v10.1.3) (2022-12-15) 306 | 307 | 308 | ### Bug Fixes 309 | 310 | * source type detection in production builds ([e1f9595](https://github.com/terrestris/ol-util/commit/e1f9595300bf496d549ca8edbfd9cd169e8c1e2a)) 311 | 312 | ## [10.1.2](https://github.com/terrestris/ol-util/compare/v10.1.1...v10.1.2) (2022-12-13) 313 | 314 | 315 | ### Bug Fixes 316 | 317 | * printing with inkmap decoupled from openlayers version ([2a01705](https://github.com/terrestris/ol-util/commit/2a017051992cfc2aa1b7966f2ed1d2bb76594730)) 318 | 319 | ## [10.1.1](https://github.com/terrestris/ol-util/compare/v10.1.0...v10.1.1) (2022-12-12) 320 | 321 | 322 | ### Bug Fixes 323 | 324 | * adjusts filter in permalink for image layer ([a977ac5](https://github.com/terrestris/ol-util/commit/a977ac50c9a2168543ef9c6dcde3ad6658b76e51)) 325 | 326 | # [10.1.0](https://github.com/terrestris/ol-util/compare/v10.0.1...v10.1.0) (2022-12-12) 327 | 328 | 329 | ### Bug Fixes 330 | 331 | * tests adjusted to legend graphic request param ([fca5780](https://github.com/terrestris/ol-util/commit/fca5780fb50d22a84a3285ec179f0de70ea0cbcb)) 332 | 333 | 334 | ### Features 335 | 336 | * get legend url for wmts from layer property ([589e981](https://github.com/terrestris/ol-util/commit/589e98190bcdf2e89938056aed9a2079b299ade1)) 337 | 338 | ## [10.0.1](https://github.com/terrestris/ol-util/compare/v10.0.0...v10.0.1) (2022-12-09) 339 | 340 | 341 | ### Bug Fixes 342 | 343 | * fix extraction of legend url ([#877](https://github.com/terrestris/ol-util/issues/877)) ([931797e](https://github.com/terrestris/ol-util/commit/931797e751b57f8f251b5652f7ca3f062794aec8)) 344 | * include image layers in permalink ([#878](https://github.com/terrestris/ol-util/issues/878)) ([f0dcf60](https://github.com/terrestris/ol-util/commit/f0dcf60af4dc150a83e1f03742b68e0232dd9ab5)) 345 | 346 | # [10.0.0](https://github.com/terrestris/ol-util/compare/v9.0.0...v10.0.0) (2022-11-15) 347 | 348 | 349 | ### Bug Fixes 350 | 351 | * restore attribute config object for multiple feature types ([776c2f2](https://github.com/terrestris/ol-util/commit/776c2f238c61f405f105919769c2298919888361)) 352 | 353 | 354 | ### BREAKING CHANGES 355 | 356 | * attributeDetails expects a nested object mapping requestable 357 | feature types to their attribute details 358 | 359 | # [9.0.0](https://github.com/terrestris/ol-util/compare/v8.1.0...v9.0.0) (2022-11-14) 360 | 361 | 362 | ### Bug Fixes 363 | 364 | * adaptaions after ol7 upgrade ([15edac9](https://github.com/terrestris/ol-util/commit/15edac9660274ff371dbaebe6359cc444ae722c6)) 365 | * fix import after ol upgrade ([8f07aa6](https://github.com/terrestris/ol-util/commit/8f07aa642e4d3d5abf8a0c31fc0b7f7c239993fa)) 366 | * get rid of unnecessary quotes ([10eb361](https://github.com/terrestris/ol-util/commit/10eb3612353a272a9c24e050776738b35bc8e705)) 367 | 368 | 369 | ### chore 370 | 371 | * upgrade to ol7 ([0c64775](https://github.com/terrestris/ol-util/commit/0c64775b891bc01fe9d3c8363f789778632a4cc7)) 372 | 373 | 374 | ### BREAKING CHANGES 375 | 376 | * set ol peer dependency to 7.1 377 | 378 | # [8.1.0](https://github.com/terrestris/ol-util/compare/v8.0.0...v8.1.0) (2022-10-28) 379 | 380 | 381 | ### Bug Fixes 382 | 383 | * export of compiled sources and location of test assets ([638996a](https://github.com/terrestris/ol-util/commit/638996aaf2e2581033f7748f0a949673260e8ce9)) 384 | * output directory of docs required for build pipeline ([3625867](https://github.com/terrestris/ol-util/commit/3625867e89a5a44e04bfa4b14680e417a377c871)) 385 | * remove uneeded release phase ([626b768](https://github.com/terrestris/ol-util/commit/626b7680304c01e900e75b7f82baae4904034112)) 386 | * set correct default branch ([6c528d1](https://github.com/terrestris/ol-util/commit/6c528d16c2980367dc7718c441a8841f9b2216c9)) 387 | 388 | 389 | ### Features 390 | 391 | * introduce commitlint to use predefined commit message conventions ([1422aa5](https://github.com/terrestris/ol-util/commit/1422aa59c2a87a1474a972ab7b5bafa6b6164a86)) 392 | * introduce semantic-release plugin ([9cbe0c0](https://github.com/terrestris/ol-util/commit/9cbe0c002b1206367c373001dda03206c3f7e3ba)) 393 | 394 | # [8.0.0] 395 | 396 | ### :rotating_light: BREAKING CHANGES :rotating_light: 397 | 398 | * Adds typings for all util functions. This may lead to type conflicts in certain projects 399 | * `CapabilitiesUtil.parseWmsCapabilities(…)` has been removed. Can be replaced by `CapabilitiesUtil.getWmsCapabilities(…)` 400 | * `GeomtryUtil` 401 | * Use `ProjectionLike` (OpenLayers ype) instead of `string` for projections 402 | * `separateGeometries` can either handle simple geometry or geometry array now 403 | * `MapUtil` 404 | * remove `getInteractionsByClass` in MapUtils - Instead: 405 | * set name to interaction and use `getInteractionByName` 406 | * filter interactions using `typeof` in your project 407 | * `ProjectionUtil`: 408 | * Crs definitions are typed now and `defaultProj4CrsDefinitions` moved to an array of `CrsDefinition` 409 | * if custom definitions are used in `initProj4Definitions` these have to migrated in the following way: 410 | ```javascript 411 | { 412 | 'EPSG:25832': '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', 413 | 'EPSG:25833': '+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs' 414 | } 415 | ``` 416 | has to be migrated to 417 | ```typescript 418 | [{ 419 | crsCode: 'EPSG:25832', 420 | definition: '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs' 421 | }, { 422 | crsCode: 'EPSG:25833', 423 | definition: '+proj=utm +zone=33 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'} 424 | }] 425 | ``` 426 | * Crs mappings are typed now and `defaultProj4CrsMappings` moved to an array of `CrsMapping` 427 | * if custom definitions are used in `initProj4DefinitionMappings` these have to migrated in the following way: 428 | ```javascript 429 | { 430 | 'urn:ogc:def:crs:EPSG::25832': 'EPSG:25832', 431 | 'urn:ogc:def:crs:EPSG::25833': 'EPSG:25833' 432 | } 433 | ``` 434 | has to be migrated to 435 | ```typescript 436 | [{ 437 | alias: 'urn:ogc:def:crs:EPSG::25832', 438 | mappedCode: 'EPSG:25832' 439 | }, { 440 | alias: 'urn:ogc:def:crs:EPSG::25833', 441 | mappedCode: 'EPSG:25833' 442 | }] 443 | ``` 444 | * `WfsFilterUtil` has completely been overhauled: 445 | * in contrast to the migrated `WfsFilterUtil`, from now on the search / filter has to be configured using the new type `SearchConfig`. 446 | * For example: a filter creation for an exact search of `my search term` in attribute `name` of feature type `TEST:MYTYPE` looks like this: 447 | ```typescript 448 | const attributeDetails: AttributeDetails [] = [{ 449 | type: 'string', 450 | exactSearch: true, 451 | attributeName: 'name' 452 | }]; 453 | const searchTerm = 'my search term'; 454 | const filter = WfsFilterUtil.createWfsFilter(searchTerm, attributeDetails); 455 | }; 456 | ``` 457 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018-present terrestris GmbH & Co. KG 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 16 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 17 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 18 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 19 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 20 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 21 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 22 | OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ol-util 2 | 3 | [![npm version](https://img.shields.io/npm/v/@terrestris/ol-util.svg?style=flat-square)](https://www.npmjs.com/package/@terrestris/ol-util) 4 | [![GitHub license](https://img.shields.io/github/license/terrestris/ol-util?style=flat-square)](https://github.com/terrestris/ol-util/blob/main/LICENSE) 5 | [![Coverage Status](https://img.shields.io/coverallsCoverage/github/terrestris/ol-util?style=flat-square)](https://coveralls.io/github/terrestris/ol-util?branch=main) 6 | ![GitHub action build](https://img.shields.io/github/actions/workflow/status/terrestris/ol-util/on-push-main.yml?branch=main&style=flat-square) 7 | 8 | 9 | A set of helper classes for working with OpenLayers 10 | 11 | ## Installation 12 | 13 | ```javascript static 14 | npm i @terrestris/ol-util 15 | ``` 16 | 17 | Be aware that ol-util uses a ESM build, so make sure your downstream application's bundler includes it when transpiling. 18 | 19 | ## API Documentation 20 | 21 | * Latest: [https://terrestris.github.io/ol-util/latest/index.html](https://terrestris.github.io/ol-util/latest/index.html) 22 | * Docs for other versions are available via the following format: 23 | * v3.0.0 [https://terrestris.github.io/ol-util/3.0.0/index.html](https://terrestris.github.io/ol-util/3.0.0/index.html) 24 | 25 | ## Development 26 | 27 | `npm run watch:buildto` can be used to inject an updated version of `ol-util` into another project. The script will also watch for further changes. Example usage for [react-geo](https://github.com/terrestris/react-geo): 28 | 29 | ```sh 30 | npm run watch:buildto ../react-geo/node_modules/@terrestris/ol-util 31 | ``` 32 | 33 | ## Software Bill of Materials 34 | 35 | You find the SBOM (Software Bill of Materials) in `sbom.json` at root level of the project. 36 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', { 5 | targets: { 6 | node: 'current' 7 | } 8 | } 9 | ], 10 | '@babel/preset-typescript' 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import globals from 'globals'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import eslintTerrestris from '@terrestris/eslint-config-typescript'; 5 | import eslint from '@eslint/js'; 6 | import tsEslint from 'typescript-eslint'; 7 | import stylisticEslint from '@stylistic/eslint-plugin' 8 | 9 | export default tsEslint.config({ 10 | extends: [ 11 | eslint.configs.recommended, 12 | ...tsEslint.configs.recommended, 13 | ...tsEslint.configs.stylistic, 14 | importPlugin.flatConfigs.recommended 15 | ], 16 | files: [ 17 | '**/*.ts' 18 | ], 19 | ignores: [ 20 | '**/*.spec.ts', 21 | '**/jest/__mocks__/*.ts' 22 | ], 23 | languageOptions: { 24 | ecmaVersion: 2022, 25 | globals: globals.browser, 26 | parserOptions: { 27 | project: true, 28 | tsconfigRootDir: import.meta.dirname 29 | }, 30 | }, 31 | plugins: { 32 | '@stylistic': stylisticEslint 33 | }, 34 | rules: { 35 | ...eslintTerrestris.rules, 36 | '@typescript-eslint/member-ordering': 'off', 37 | '@typescript-eslint/no-empty-object-type': 'off', 38 | '@typescript-eslint/no-unused-vars': 'warn', 39 | '@typescript-eslint/no-inferrable-types': 'off', 40 | 'import/no-unresolved': 'off', 41 | 'import/named': 'off', 42 | 'import/no-named-as-default': 'off', 43 | 'import/order': ['warn', { 44 | groups: [ 45 | 'builtin', 46 | 'external', 47 | 'parent', 48 | 'sibling', 49 | 'index', 50 | 'object' 51 | ], 52 | pathGroups: [{ 53 | pattern: 'react', 54 | group: 'external', 55 | position: 'before' 56 | }, { 57 | pattern: '@terrestris/**', 58 | group: 'external', 59 | position: 'after' 60 | }], 61 | pathGroupsExcludedImportTypes: ['react'], 62 | 'newlines-between': 'always-and-inside-groups', 63 | alphabetize: { 64 | order: 'asc', 65 | caseInsensitive: true 66 | } 67 | }] 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: 'jsdom', 3 | setupFilesAfterEnv: ['/jest.setup.mjs'], 4 | moduleFileExtensions: [ 5 | 'ts', 6 | 'js', 7 | 'json' 8 | ], 9 | transform: { 10 | '^.+\\.js$': '/node_modules/babel-jest', 11 | '^.+\\.ts$': '/node_modules/babel-jest', 12 | '^.+\\.mjs$': '/node_modules/babel-jest' 13 | }, 14 | transformIgnorePatterns: [ 15 | '/node_modules/(?!(ol|@babel|jest-runtime|@terrestris|color-space|color-rgba|color-name|quickselect|' + 16 | 'color-parse|shpjs|filter-obj|split-on-first|decode-uri-component|query-string|geostyler-openlayers-parser|' + 17 | 'geostyler-style))' 18 | ], 19 | testRegex: '/src/.*\\.spec.(ts|js)$', 20 | collectCoverageFrom: [ 21 | 'src/**/*.{ts,js}', 22 | '!src/spec/**/*.{ts,js}' 23 | ], 24 | roots: [ 25 | './src' 26 | ] 27 | }; 28 | -------------------------------------------------------------------------------- /jest.setup.mjs: -------------------------------------------------------------------------------- 1 | import 'jest-canvas-mock'; 2 | 3 | import { 4 | TextDecoder, 5 | TextEncoder 6 | } from 'util'; 7 | 8 | global.fetch = jest.fn(); 9 | 10 | Object.defineProperty(global, 'ResizeObserver', { 11 | writable: true, 12 | value: jest.fn().mockImplementation(() => ({ 13 | observe: jest.fn(), 14 | unobserve: jest.fn(), 15 | disconnect: jest.fn() 16 | })) 17 | }); 18 | 19 | Object.assign(global, { TextDecoder, TextEncoder }); 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@terrestris/ol-util", 3 | "version": "21.3.0", 4 | "description": "A set of helper classes for working with openLayers", 5 | "keywords": [ 6 | "openlayers", 7 | "mapping", 8 | "geo", 9 | "ol" 10 | ], 11 | "homepage": "https://github.com/terrestris/ol-util#readme", 12 | "bugs": { 13 | "url": "https://github.com/terrestris/ol-util/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/terrestris/ol-util.git" 18 | }, 19 | "license": "BSD-2-Clause", 20 | "author": "terrestris GmbH & Co. KG ", 21 | "main": "dist/index.js", 22 | "module": "src/index.ts", 23 | "type": "module", 24 | "browser": "dist/index.js", 25 | "types": "dist/index.d.ts", 26 | "files": [ 27 | "src", 28 | "dist", 29 | "babel.config.js" 30 | ], 31 | "scripts": { 32 | "build": "npm run build:dist", 33 | "build:dist": "rimraf ./dist/* && tsc -p tsconfig.json", 34 | "build:docs": "rimraf build/docs && typedoc ./src/**/*", 35 | "check": "npm run lint && npm run typecheck && npm run test", 36 | "deploy": "NODE_DEBUG=gh-pages node tasks/update-gh-pages.js", 37 | "lint": "eslint -c eslint.config.mjs src/**", 38 | "lint:fix": "eslint -c eslint.config.mjs src/** --fix", 39 | "prepare": "husky", 40 | "test": "jest --maxWorkers=4 --coverage -c jest.config.mjs", 41 | "test:watch": "jest --watchAll -c jest.config.mjs", 42 | "typecheck": "tsc --noEmit -p tsconfig.json", 43 | "watch:buildto": "node watchBuild.cjs" 44 | }, 45 | "dependencies": { 46 | "@mapbox/node-pre-gyp": "^2.0.0", 47 | "@terrestris/base-util": "^3.0.0", 48 | "@turf/turf": "^7.1.0", 49 | "fast-xml-parser": "^5.0.6", 50 | "geostyler-openlayers-parser": "^5.0.0", 51 | "lodash": "^4.17.21", 52 | "polygon-splitter": "^0.0.11", 53 | "proj4": "^2.11.0", 54 | "shpjs": "^6.1.0" 55 | }, 56 | "devDependencies": { 57 | "@babel/core": "^7.24.5", 58 | "@babel/preset-env": "^7.24.5", 59 | "@babel/preset-typescript": "^7.24.1", 60 | "@commitlint/cli": "^19.3.0", 61 | "@commitlint/config-conventional": "^19.2.2", 62 | "@eslint/eslintrc": "^3.1.0", 63 | "@eslint/js": "^9.13.0", 64 | "@mapbox/shp-write": "^0.4.3", 65 | "@semantic-release/changelog": "^6.0.3", 66 | "@semantic-release/commit-analyzer": "^13.0.0", 67 | "@semantic-release/git": "^10.0.1", 68 | "@semantic-release/github": "^11.0.0", 69 | "@semantic-release/npm": "^12.0.1", 70 | "@semantic-release/release-notes-generator": "^14.0.0", 71 | "@stylistic/eslint-plugin": "^4.0.1", 72 | "@terrestris/eslint-config-typescript": "^9.0.0", 73 | "@types/geojson": "^7946.0.14", 74 | "@types/jest": "^29.5.12", 75 | "@types/lodash": "^4.17.7", 76 | "@types/proj4": "^2.5.5", 77 | "@types/shpjs": "^3.4.7", 78 | "@types/url-parse": "^1.4.11", 79 | "@typescript-eslint/eslint-plugin": "^8.10.0", 80 | "@typescript-eslint/parser": "^8.10.0", 81 | "eslint": "^9.13.0", 82 | "eslint-plugin-import": "^2.31.0", 83 | "fs-extra": "11.3.0", 84 | "gh-pages": "^6.1.1", 85 | "globals": "^16.0.0", 86 | "husky": "^9.0.11", 87 | "jest": "^29.7.0", 88 | "jest-canvas-mock": "^2.5.2", 89 | "jest-environment-jsdom": "^29.7.0", 90 | "ol": "^10.5.0", 91 | "rimraf": "^6.0.0", 92 | "semantic-release": "^24.0.0", 93 | "typedoc": "^0.28.0", 94 | "typescript": "^5.6.3", 95 | "typescript-eslint": "^8.10.0", 96 | "watch": "1.0.2", 97 | "whatwg-fetch": "^3.6.20" 98 | }, 99 | "peerDependencies": { 100 | "ol": ">=10" 101 | }, 102 | "engines": { 103 | "node": ">=20", 104 | "npm": ">=9" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/AnimateUtil/AnimateUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | 3 | import AnimateUtil from './AnimateUtil'; 4 | 5 | describe('AnimateUtil', () => { 6 | 7 | describe('Basics', () => { 8 | it('is defined', () => { 9 | expect(AnimateUtil).toBeDefined(); 10 | }); 11 | }); 12 | 13 | describe('Static methods', () => { 14 | describe('#moveFeature', () => { 15 | it('is defined', () => { 16 | expect(AnimateUtil.moveFeature).toBeDefined(); 17 | }); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/AnimateUtil/AnimateUtil.ts: -------------------------------------------------------------------------------- 1 | import _isFunction from 'lodash/isFunction'; 2 | import _isNil from 'lodash/isNil'; 3 | import OlFeature from 'ol/Feature'; 4 | import OlGeometry from 'ol/geom/Geometry'; 5 | import OlLayerVector from 'ol/layer/Vector'; 6 | import OlMap from 'ol/Map'; 7 | import { unByKey } from 'ol/Observable'; 8 | import { getVectorContext } from 'ol/render'; 9 | import OlSourceVector from 'ol/source/Vector'; 10 | import OlStyle from 'ol/style/Style'; 11 | 12 | /** 13 | * This class provides some static methods which might be helpful when working 14 | * with digitize functions to animate features. 15 | * 16 | * @class AnimateUtil 17 | */ 18 | class AnimateUtil { 19 | 20 | /** 21 | * Moves / translates an `OlFeature` to the given `pixel` delta 22 | * in the end with given `duration` in ms, using the given style. 23 | * 24 | * @param {import("ol/Map").default} map An OlMap. 25 | * @param {import("ol/layer/Vector").default} layer A vector layer to receive a 26 | * postrender event. 27 | * @param {import("ol/Feature").default} featureToMove The feature to move. 28 | * @param {number} duration The duration in ms for the moving to complete. 29 | * @param {number} pixel Delta of pixels to move the feature. 30 | * @param {import("ol/style/Style").default} [style] The style to use when moving the feature. 31 | * 32 | * @return {Promise} Promise of the moved feature. 33 | */ 34 | static moveFeature( 35 | map: OlMap, 36 | layer: OlLayerVector, 37 | featureToMove: OlFeature, 38 | duration: number, 39 | pixel: number, 40 | style: OlStyle 41 | ): Promise> { 42 | return new Promise(resolve => { 43 | const geometry = featureToMove.getGeometry(); 44 | if (_isNil(geometry)) { 45 | throw new Error('Feature has no geometry.'); 46 | } 47 | const start = Date.now(); 48 | const resolution = map.getView().getResolution() ?? 0; 49 | const totalDisplacement = pixel * resolution; 50 | const expectedFrames = duration / 1000 * 60; 51 | let actualFrames = 0; 52 | const deltaX = totalDisplacement / expectedFrames; 53 | const deltaY = totalDisplacement / expectedFrames; 54 | 55 | /** 56 | * Moves the feature `pixel` right and `pixel` up. 57 | * @ignore 58 | */ 59 | const listenerKey = layer.on('postrender', (event) => { 60 | const vectorContext = getVectorContext(event); 61 | const frameState = event.frameState; 62 | if (!frameState) { 63 | return; 64 | } 65 | const elapsed = frameState.time - start; 66 | 67 | geometry.translate(deltaX, deltaY); 68 | 69 | if (style) { 70 | if (_isFunction(style)) { 71 | vectorContext.setStyle(style(featureToMove)); 72 | } else { 73 | vectorContext.setStyle(style); 74 | } 75 | } 76 | vectorContext.drawGeometry(geometry); 77 | 78 | if (elapsed > duration || actualFrames >= expectedFrames) { 79 | unByKey(listenerKey); 80 | resolve(featureToMove); 81 | } 82 | // tell OpenLayers to continue postrender animation 83 | frameState.animate = true; 84 | 85 | actualFrames++; 86 | map.render(); 87 | }); 88 | }); 89 | } 90 | } 91 | 92 | export default AnimateUtil; 93 | -------------------------------------------------------------------------------- /src/CapabilitiesUtil/CapabilitiesUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | import OlLayerImage from 'ol/layer/Image'; 3 | import OlSourceImageWMS from 'ol/source/ImageWMS'; 4 | 5 | import { CapabilitiesUtil } from '../index'; 6 | 7 | const layerTitle = 'OpenStreetMap WMS - by terrestris'; 8 | const layerName = 'OSM-WMS'; 9 | // eslint-disable-next-line 10 | const abstract = 'OpenStreetMap WMS, bereitgestellt durch terrestris GmbH und Co. KG. Beschleunigt mit MapProxy (http://mapproxy.org/)'; 11 | const gfiOnlineResource = 'http://ows.terrestris.de/osm/service?'; 12 | const getMapUrl = gfiOnlineResource; 13 | const gfiFormats = [ 14 | 'text/plain', 15 | 'text/html', 16 | 'application/vnd.ogc.gml' 17 | ]; 18 | // eslint-disable-next-line 19 | const glgOnlineResource = 'http://ows.terrestris.de/osm/service?styles=&layer=OSM-WMS&service=WMS&format=image%2Fpng&sld_version=1.1.0&request=GetLegendGraphic&version=1.1.1'; 20 | const queryable = true; 21 | const capVersion = '1.3.0'; 22 | 23 | const capabilitiesObj = { 24 | version: capVersion, 25 | Service: { 26 | Name: 'OGC:WMS', 27 | Title: 'OpenStreetMap WMS' 28 | }, 29 | Capability: { 30 | Request: { 31 | GetCapabilities: { 32 | Format: [ 33 | 'application/vnd.ogc.wms_xml' 34 | ], 35 | DCPType: [{ 36 | HTTP: { 37 | Get: { 38 | OnlineResource: 'http://ows.terrestris.de/osm/service?' 39 | } 40 | } 41 | }] 42 | }, 43 | GetMap: { 44 | Format: [ 45 | 'image/jpeg', 46 | 'image/png' 47 | ], 48 | DCPType: [{ 49 | HTTP: { 50 | Get: { 51 | OnlineResource: getMapUrl 52 | } 53 | } 54 | }] 55 | }, 56 | GetFeatureInfo: { 57 | Format: gfiFormats, 58 | DCPType: [{ 59 | HTTP: { 60 | Get: { 61 | OnlineResource: gfiOnlineResource 62 | } 63 | } 64 | }] 65 | } 66 | }, 67 | Exception: [ 68 | 'application/vnd.ogc.se_xml', 69 | 'application/vnd.ogc.se_inimage', 70 | 'application/vnd.ogc.se_blank' 71 | ], 72 | Layer: { 73 | Layer: [{ 74 | Name: layerName, 75 | Title: layerTitle, 76 | Abstract: abstract, 77 | Attribution: { 78 | Title: '(c) OpenStreetMap contributors', 79 | OnlineResource: 'http://www.openstreetmap.org/copyright' 80 | }, 81 | BoundingBox: [{ 82 | crs: null, 83 | extent: [-20037508.3428, -25819498.5135, 84 | 20037508.3428, 85 | 25819498.5135 86 | ], 87 | res: [ 88 | null, 89 | null 90 | ] 91 | }, 92 | { 93 | crs: null, 94 | extent: [-180, -88, 95 | 180, 96 | 88 97 | ], 98 | res: [ 99 | null, 100 | null 101 | ] 102 | }, 103 | { 104 | crs: null, 105 | extent: [-20037508.3428, -25819498.5135, 106 | 20037508.3428, 107 | 25819498.5135 108 | ], 109 | res: [ 110 | null, 111 | null 112 | ] 113 | } 114 | ], 115 | Style: [{ 116 | Name: 'default', 117 | Title: 'default', 118 | LegendURL: [{ 119 | Format: 'image/png', 120 | OnlineResource: glgOnlineResource, 121 | size: [ 122 | 155, 123 | 344 124 | ] 125 | }] 126 | }], 127 | queryable: queryable, 128 | opaque: false, 129 | noSubsets: false 130 | }] 131 | } 132 | } 133 | }; 134 | 135 | describe('CapabilitiesUtil', () => { 136 | 137 | it('is defined', () => { 138 | expect(CapabilitiesUtil).not.toBeUndefined(); 139 | }); 140 | 141 | describe('Static methods', () => { 142 | 143 | 144 | describe('getLayersFromWmsCapabilities', () => { 145 | it('isDefined', () => { 146 | expect(CapabilitiesUtil.getLayersFromWmsCapabilities).not.toBeUndefined(); 147 | }); 148 | 149 | it('creates layer objects from parsed WMS capabilities', () => { 150 | const parsedLayers = CapabilitiesUtil.getLayersFromWmsCapabilities(capabilitiesObj); 151 | expect(parsedLayers).toHaveLength(1); 152 | const layer = parsedLayers[0]; 153 | expect(layer).toBeInstanceOf(OlLayerImage); 154 | expect(layer.getSource()).toBeInstanceOf(OlSourceImageWMS); 155 | }); 156 | 157 | it('sets layer attributes accordingly', () => { 158 | const parsedLayers = CapabilitiesUtil.getLayersFromWmsCapabilities(capabilitiesObj); 159 | const layer = parsedLayers[0]; 160 | const layerSource = layer.getSource(); 161 | expect(layer.get('title')).toBe(layerTitle); 162 | expect(layer.get('name')).toBe(layerName); 163 | expect(layer.get('abstract')).toBe(abstract); 164 | expect(layer.get('getFeatureInfoUrl')).toBe(gfiOnlineResource); 165 | expect(layer.get('getFeatureInfoFormats')).toEqual(gfiFormats); 166 | expect(layer.get('legendUrl')).toEqual(glgOnlineResource); 167 | expect(layer.get('queryable')).toBe(queryable); 168 | expect(layerSource).toBeDefined(); 169 | expect(layerSource).not.toBe(null); 170 | expect(layerSource?.getUrl()).toBe(getMapUrl); 171 | const attributions = layerSource!.getAttributions(); 172 | expect(attributions).toBeDefined(); 173 | expect(layerSource?.getParams().LAYERS).toBe(layerName); 174 | expect(layerSource?.getParams().VERSION).toBe(capVersion); 175 | }); 176 | 177 | it('applies proxy function if provided', () => { 178 | const proxyFn = jest.fn(); 179 | CapabilitiesUtil.getLayersFromWmsCapabilities(capabilitiesObj, 'name', proxyFn); 180 | expect.assertions(1); 181 | expect(proxyFn).toBeCalledTimes(3); 182 | }); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/CapabilitiesUtil/CapabilitiesUtil.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser'; 2 | import _get from 'lodash/get'; 3 | import _isFunction from 'lodash/isFunction'; 4 | import OlLayerImage from 'ol/layer/Image'; 5 | import OlSourceImageWMS from 'ol/source/ImageWMS'; 6 | 7 | import UrlUtil from '@terrestris/base-util/dist/UrlUtil/UrlUtil'; 8 | 9 | import LayerUtil from '../LayerUtil/LayerUtil'; 10 | import { WmsLayer } from '../typeUtils/typeUtils'; 11 | /** 12 | * Helper class to parse capabilities of WMS layers 13 | * 14 | * @class CapabilitiesUtil 15 | */ 16 | class CapabilitiesUtil { 17 | 18 | /** 19 | * Fetches and parses the WMS Capabilities document for the given URL. 20 | * 21 | * @param {string} capabilitiesUrl Url to WMS capabilities document. 22 | * @param {RequestInit} fetchOpts Optional fetch options to make use of 23 | * while requesting the Capabilities. 24 | * @return {Promise} An object representing the WMS capabilities. 25 | */ 26 | static async getWmsCapabilities(capabilitiesUrl: string, fetchOpts: RequestInit = {}): Promise { 27 | const capabilitiesResponse = await fetch(capabilitiesUrl, fetchOpts); 28 | 29 | if (!capabilitiesResponse.ok) { 30 | throw new Error('Could not get capabilities.'); 31 | } 32 | 33 | const url = new URL(capabilitiesUrl); 34 | const version = url.searchParams.get('VERSION'); 35 | 36 | const capabilitiesText = await capabilitiesResponse.text(); 37 | const parser = new XMLParser({ 38 | ignoreDeclaration: true, 39 | removeNSPrefix: true, 40 | ignoreAttributes: false, 41 | attributeNamePrefix: '', 42 | trimValues: true 43 | }); 44 | const parsed = parser.parse(capabilitiesText); 45 | return version === '1.3.0' ? parsed?.WMS_Capabilities : parsed?.WMT_MS_Capabilities; 46 | } 47 | 48 | /** 49 | * Fetches and parses the WMS Capabilities document for the given layer. 50 | * 51 | * @param {WmsLayer} layer The layer to the get the Capabilites for. 52 | * @param {RequestInit} fetchOpts Optional fetch options to make use of 53 | * while requesting the Capabilities. 54 | * @return {Promise} An object representing the WMS capabilities. 55 | */ 56 | static async getWmsCapabilitiesByLayer( 57 | layer: WmsLayer, 58 | fetchOpts: RequestInit = {} 59 | ): Promise { 60 | const capabilitiesUrl = this.getCapabilitiesUrl(layer); 61 | return await this.getWmsCapabilities(capabilitiesUrl, fetchOpts); 62 | } 63 | 64 | /** 65 | * Returns the Capabilities URL for the given layer. 66 | * 67 | * @param {import("../types").WMSLayer} layer The layer to the get the Capabilities URL for. 68 | * @return {string} The Capabilities URL. 69 | */ 70 | static getCapabilitiesUrl(layer: WmsLayer) { 71 | const layerSource = layer.getSource(); 72 | const layerBaseUrl = LayerUtil.getLayerUrl(layer); 73 | const wmsVersion = layerSource?.getParams()?.VERSION || '1.3.0'; 74 | 75 | return UrlUtil.createValidGetCapabilitiesRequest( 76 | layerBaseUrl, 'WMS', wmsVersion); 77 | } 78 | 79 | /** 80 | * Returns the layers from a parsed WMS GetCapabilities object. 81 | * 82 | * @param {Object} capabilities A capabilities object. 83 | * @param {string} nameField Configure the field which should be set as the 84 | * 'name' property in the openlayers layer. 85 | * @param {(url: string) => string} [proxyFn] Optional proxy function which can be applied to 86 | * `GetMap`, `GetFeatureInfo` and `GetLegendGraphic` 87 | * requests to avoid CORS issues. 88 | * @return {import("ol/layer/Image").default[]} Array of OlLayerImage 89 | */ 90 | static getLayersFromWmsCapabilities( 91 | capabilities: any, 92 | nameField: string = 'Name', 93 | proxyFn?: (proxyUrl: string) => string 94 | ): OlLayerImage[] { 95 | const wmsVersion = _get(capabilities, 'version'); 96 | let layersInCapabilities = _get(capabilities, 'Capability.Layer.Layer'); 97 | const wmsGetMapConfig = _get(capabilities, 'Capability.Request.GetMap'); 98 | const wmsGetFeatureInfoConfig = _get(capabilities, 'Capability.Request.GetFeatureInfo'); 99 | 100 | let getMapUrl: string; 101 | let getFeatureInfoUrl: string; 102 | 103 | if (Array.isArray(wmsGetMapConfig.DCPType) && Array.isArray(wmsGetFeatureInfoConfig.DCPType)) { 104 | getMapUrl = _get(wmsGetMapConfig, 'DCPType[0].HTTP.Get.OnlineResource'); 105 | getFeatureInfoUrl = _get(wmsGetFeatureInfoConfig, 'DCPType[0].HTTP.Get.OnlineResource'); 106 | } else { 107 | getMapUrl = _get(wmsGetMapConfig, 'DCPType.HTTP.Get.OnlineResource.href'); 108 | getFeatureInfoUrl = _get(wmsGetFeatureInfoConfig, 'DCPType.HTTP.Get.OnlineResource.href'); 109 | } 110 | 111 | if (!(layersInCapabilities instanceof Array)) { 112 | layersInCapabilities = [layersInCapabilities]; 113 | } 114 | 115 | return layersInCapabilities.map((layerObj: any) => { 116 | const title = _get(layerObj, 'Attribution.Title'); 117 | const onlineResource = _get(layerObj, 'Attribution.OnlineResource'); 118 | const attributions = [onlineResource ? `${title}` : title]; 119 | const legendUrl = _get(layerObj, 'Style[0].LegendURL[0].OnlineResource'); 120 | 121 | return new OlLayerImage({ 122 | opacity: 1, 123 | properties: { 124 | title: _get(layerObj, 'Title'), 125 | name: _get(layerObj, nameField), 126 | abstract: _get(layerObj, 'Abstract'), 127 | getFeatureInfoUrl: _isFunction(proxyFn) ? proxyFn(getFeatureInfoUrl) : getFeatureInfoUrl, 128 | getFeatureInfoFormats: _get(wmsGetFeatureInfoConfig, 'Format'), 129 | legendUrl: _isFunction(proxyFn) ? proxyFn(legendUrl) : legendUrl, 130 | queryable: _get(layerObj, 'queryable') 131 | }, 132 | source: new OlSourceImageWMS({ 133 | url: _isFunction(proxyFn) ? proxyFn(getMapUrl) : getMapUrl, 134 | attributions: attributions, 135 | params: { 136 | LAYERS: _get(layerObj, 'Name'), 137 | VERSION: wmsVersion 138 | } 139 | }) 140 | }); 141 | }); 142 | } 143 | 144 | } 145 | 146 | export default CapabilitiesUtil; 147 | -------------------------------------------------------------------------------- /src/FeatureUtil/FeatureUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | 3 | import { Coordinate } from 'ol/coordinate'; 4 | import OlFeature from 'ol/Feature'; 5 | import OlGeometry from 'ol/geom/Geometry'; 6 | import OlGeomPoint from 'ol/geom/Point'; 7 | 8 | import { FeatureUtil } from '../index'; 9 | 10 | describe('FeatureUtil', () => { 11 | let coords: Coordinate; 12 | let geom; 13 | let props: { 14 | [key: string]: any; 15 | }; 16 | let feat: OlFeature; 17 | let featId: string; 18 | 19 | beforeEach(() => { 20 | featId = 'BVB.BORUSSIA'; 21 | coords = [1909, 1909]; 22 | geom = new OlGeomPoint(coords); 23 | props = { 24 | name: 'Shinji Kagawa', 25 | address: 'Borsigplatz 9', 26 | city: 'Dortmund', 27 | homepage: 'https://www.bvb.de/', 28 | 'exists-and-is-undefined': undefined, 29 | 'exists-and-is-null': null 30 | }; 31 | feat = new OlFeature({ 32 | geometry: geom 33 | }); 34 | 35 | feat.setProperties(props); 36 | feat.setId(featId); 37 | }); 38 | 39 | describe('Basics', () => { 40 | it('is defined', () => { 41 | expect(FeatureUtil).toBeDefined(); 42 | }); 43 | }); 44 | 45 | describe('Static methods', () => { 46 | describe('#getFeatureTypeName', () => { 47 | it('splits the feature ID at the point character and returns the first part', () => { 48 | let got = FeatureUtil.getFeatureTypeName(feat); 49 | expect(got).toBe(featId.split('.')[0]); 50 | 51 | feat.setId('BVB'); 52 | got = FeatureUtil.getFeatureTypeName(feat); 53 | expect(got).toBe('BVB'); 54 | }); 55 | 56 | it('returns undefined if the ID is not set', () => { 57 | feat.setId(undefined); 58 | let got = FeatureUtil.getFeatureTypeName(feat); 59 | expect(got).toBe(undefined); 60 | }); 61 | }); 62 | 63 | describe('#getFeatureTypeNameFromGetFeatureInfoUrl', () => { 64 | 65 | it('extracts layer name from provided GetFeatureInfo request URL', () => { 66 | 67 | const layerName = 'testLayerName'; 68 | const ns = 'ns'; 69 | const mockUrlUnqualified = `http://mock.de?&REQUEST=GetFeatureInfo&QUERY_LAYERS=${layerName}&TILED=true`; 70 | const mockUrlQualified = `http://mock.de?&REQUEST=GetFeatureInfo&QUERY_LAYERS=${ns}:${layerName}&TILED=true`; 71 | const mockUrlQualified2 = `http://mock.de?&REQUEST=GetFeatureInfo&QUERY_LAYERS=${ns}:${layerName}`; 72 | 73 | const gotUnqualified = FeatureUtil.getFeatureTypeNameFromGetFeatureInfoUrl(mockUrlUnqualified); 74 | const gotQualified = FeatureUtil.getFeatureTypeNameFromGetFeatureInfoUrl(mockUrlQualified); 75 | const gotQualified2 = FeatureUtil.getFeatureTypeNameFromGetFeatureInfoUrl(mockUrlQualified2); 76 | const gotQualifiedSplitted = FeatureUtil.getFeatureTypeNameFromGetFeatureInfoUrl(mockUrlQualified, false); 77 | const gotQualifiedSplitted2 = FeatureUtil.getFeatureTypeNameFromGetFeatureInfoUrl(mockUrlQualified2, false); 78 | 79 | expect(gotUnqualified).toBe(layerName); 80 | expect(gotQualified).toBe(`${ns}:${layerName}`); 81 | expect(gotQualified2).toBe(`${ns}:${layerName}`); 82 | expect(gotQualifiedSplitted).toBe(layerName); 83 | expect(gotQualifiedSplitted2).toBe(layerName); 84 | }); 85 | 86 | it('returns undefined if no match was found', () => { 87 | const notMatchingUrl = 'http://mock.de?&REQUEST=GetFeatureInfo&SOME_PARAMS=some_values'; 88 | const got = FeatureUtil.getFeatureTypeNameFromGetFeatureInfoUrl(notMatchingUrl); 89 | expect(got).toBeUndefined(); 90 | }); 91 | }); 92 | 93 | 94 | describe('#resolveAttributeTemplate', () => { 95 | it('resolves the given template string with the feature attributes', () => { 96 | let template = '{{name}}'; 97 | let got = FeatureUtil.resolveAttributeTemplate(feat, template); 98 | expect(got).toBe(props.name); 99 | 100 | // It's case-insensitive. 101 | template = '{{NAmE}}'; 102 | got = FeatureUtil.resolveAttributeTemplate(feat, template); 103 | expect(got).toBe(props.name); 104 | 105 | // It resolves static and non-static content. 106 | template = 'Contact information: {{name}} {{address}} {{city}}'; 107 | got = FeatureUtil.resolveAttributeTemplate(feat, template); 108 | expect(got).toBe(`Contact information: ${props.name} ${props.address} ${props.city}`); 109 | 110 | // It doesn't harm the template if not attribute placeholder is given. 111 | template = 'No attribute template'; 112 | got = FeatureUtil.resolveAttributeTemplate(feat, template); 113 | expect(got).toBe(template); 114 | }); 115 | 116 | it('can be configured wrt handling in-existent / falsy values', () => { 117 | let template = '{{exists-and-is-undefined}}|{{exists-and-is-null}}|{{key-does-not-exist}}'; 118 | let got = FeatureUtil.resolveAttributeTemplate(feat, template); 119 | expect(got).toBe('undefined|null|n.v.'); 120 | got = FeatureUtil.resolveAttributeTemplate(feat, template, '', (key, val) => {return val ? val : '';}); 121 | expect(got).toBe('||'); 122 | const mockFn = jest.fn(() => {return 'FOO';}); 123 | got = FeatureUtil.resolveAttributeTemplate(feat, template, '', mockFn); 124 | expect(mockFn.mock.calls.length).toBe(2); 125 | const array1: any[] = mockFn.mock.calls[0]; 126 | const array2: any[] = mockFn.mock.calls[1]; 127 | expect(array1).toBeDefined(); 128 | expect(array2).toBeDefined(); 129 | expect(array1[0]).toBe('exists-and-is-undefined'); 130 | expect(array1[1]).toBe(undefined); 131 | expect(array2[0]).toBe('exists-and-is-null'); 132 | expect(array2[1]).toBe(null); 133 | expect(got).toBe('FOO|FOO|'); 134 | }); 135 | 136 | it('wraps an URL occurrence with an tag', () => { 137 | let template = '{{homepage}}'; 138 | let got = FeatureUtil.resolveAttributeTemplate(feat, template); 139 | expect(got).toBe(`${props.homepage}`); 140 | }); 141 | 142 | it('resolves it with a placeholder if attribute could not be found', () => { 143 | let template = '{{notAvailable}}'; 144 | let got = FeatureUtil.resolveAttributeTemplate(feat, template); 145 | expect(got).toBe('n.v.'); 146 | 147 | template = '{{name}} {{notAvailable}}'; 148 | got = FeatureUtil.resolveAttributeTemplate(feat, template); 149 | expect(got).toBe(`${props.name} n.v.`); 150 | 151 | // The placeholder is configurable. 152 | let notFoundPlaceHolder = '【ツ】'; 153 | template = '{{name}} {{notAvailable}}'; 154 | got = FeatureUtil.resolveAttributeTemplate(feat, template, notFoundPlaceHolder); 155 | expect(got).toBe(`${props.name} ${notFoundPlaceHolder}`); 156 | }); 157 | 158 | it('returns the id of the feature if no template is given', () => { 159 | let template = ''; 160 | let got = FeatureUtil.resolveAttributeTemplate(feat, template); 161 | expect(got).toBe(featId); 162 | 163 | got = FeatureUtil.resolveAttributeTemplate(feat, template); 164 | expect(got).toBe(featId); 165 | }); 166 | 167 | it('replaces newline chars with a
tag', () => { 168 | let template = '{{name}} \n {{city}}'; 169 | let got = FeatureUtil.resolveAttributeTemplate(feat, template); 170 | expect(got).toBe(`${props.name}
${props.city}`); 171 | }); 172 | }); 173 | 174 | }); 175 | 176 | }); 177 | -------------------------------------------------------------------------------- /src/FeatureUtil/FeatureUtil.ts: -------------------------------------------------------------------------------- 1 | import _isArray from 'lodash/isArray'; 2 | import _isNil from 'lodash/isNil'; 3 | import _isString from 'lodash/isString'; 4 | import OlFeature from 'ol/Feature'; 5 | import OlGeometry from 'ol/geom/Geometry'; 6 | 7 | import StringUtil from '@terrestris/base-util/dist/StringUtil/StringUtil'; 8 | 9 | /** 10 | * Helper class for working with OpenLayers features. 11 | * 12 | * @class FeatureUtil 13 | */ 14 | class FeatureUtil { 15 | 16 | /** 17 | * Returns the featureType name out of a given feature. It assumes that 18 | * the feature has an ID in the following structure FEATURETYPE.FEATUREID. 19 | * 20 | * @param {import("ol/Feature").default} feature The feature to obtain the featureType 21 | * name from. 22 | * @return {string|undefined} The (unqualified) name of the featureType or undefined if 23 | * the name could not be picked. 24 | */ 25 | static getFeatureTypeName(feature: OlFeature): string | undefined { 26 | const featureId = feature.getId(); 27 | const featureIdParts = _isString(featureId) ? featureId.split('.') : featureId; 28 | return _isArray(featureIdParts) ? featureIdParts[0] : undefined; 29 | } 30 | 31 | /** 32 | * Extracts the featureType name from given GetFeatureInfo URL. 33 | * This method is mostly useful for raster layers which features could have 34 | * no ID set. 35 | * 36 | * @param {string} url GetFeatureInfo URL possibly containing featureType name. 37 | * @param {boolean} qualified Whether the qualified featureType name should be 38 | * returned or not. Default is true. 39 | * 40 | * @return {string|undefined} Obtained featureType name as string. 41 | */ 42 | static getFeatureTypeNameFromGetFeatureInfoUrl(url: string, qualified: boolean = true): string | undefined { 43 | const regex = /query_layers=(.*?)(&|$)/i; 44 | const match = url.match(regex); 45 | let featureTypeName; 46 | if (match && match[1]) { 47 | featureTypeName = decodeURIComponent(match[1]); 48 | if (!qualified && featureTypeName.indexOf(':') > 0) { 49 | featureTypeName = featureTypeName.split(':')[1]; 50 | } 51 | } 52 | return featureTypeName; 53 | } 54 | 55 | /** 56 | * Resolves the given template string with the given feature attributes, e.g. 57 | * the template "Size of area is {{AREA_SIZE}} km²" would be to resolved 58 | * to "Size of area is 1909 km²" (assuming the feature's attribute AREA_SIZE 59 | * really exists). 60 | * 61 | * @param {import("ol/Feature").default} feature The feature to get the attributes from. 62 | * @param {string} template The template string to resolve. 63 | * @param {string} [noValueFoundText] The text to apply, if the templated value 64 | * could not be found, default is to 'n.v.'. 65 | * @param {(key: string, val: string) => string} [valueAdjust] A method that will be called with each 66 | * key/value match, we'll use what this function returns for the actual 67 | * replacement. Optional, defaults to a function which will return the raw 68 | * value it received. This can be used for last minute adjustments before 69 | * replacing happens, e.g. to filter out falsy values or to do number 70 | * formatting and such. 71 | * @param {boolean} leaveAsUrl If set to true, template won't be wrapped into 72 | * -tag and will be returned as URL. Default is false. 73 | * @return {string} The resolved template string. 74 | */ 75 | static resolveAttributeTemplate( 76 | feature: OlFeature, 77 | template: string, 78 | noValueFoundText: string = 'n.v.', 79 | valueAdjust = (key: string, val: any) => val, 80 | leaveAsUrl = false 81 | ) { 82 | const attributeTemplatePrefix = '\\{\\{'; 83 | const attributeTemplateSuffix = '\\}\\}'; 84 | let resolved; 85 | 86 | // Find any character between two braces (including the braces in the result) 87 | const regExp = new RegExp(attributeTemplatePrefix + '(.*?)' + attributeTemplateSuffix, 'g'); 88 | const regExpRes = _isString(template) ? template.match(regExp) : null; 89 | 90 | // If we have a regex result, it means we found a placeholder in the 91 | // template and have to replace the placeholder with its appropriate value. 92 | if (regExpRes) { 93 | // Iterate over all regex match results and find the proper attribute 94 | // for the given placeholder, finally set the desired value to the hover. 95 | // field text 96 | regExpRes.forEach((res) => { 97 | // We count every candidate that is not matching. If this count is equal to 98 | // the object array length, we assume that there is no match at all and 99 | // set the output value to the value of "noValueFoundText". 100 | let noMatchCnt = 0; 101 | 102 | for (const [key, value] of Object.entries(feature.getProperties())) { 103 | // Remove the suffixes and find the matching attribute column. 104 | const attributeName = res.slice(2, res.length - 2); 105 | 106 | if (attributeName.toLowerCase() === key.toLowerCase()) { 107 | template = template.replace(res, valueAdjust(key, value)); 108 | break; 109 | } else { 110 | noMatchCnt++; 111 | } 112 | } 113 | 114 | // No key match found for this feature (e.g. if key not 115 | // present or value is null). 116 | if (noMatchCnt === Object.keys(feature.getProperties()).length) { 117 | template = template.replace(res, noValueFoundText); 118 | } 119 | }); 120 | } 121 | 122 | resolved = template; 123 | 124 | // Fallback if no feature attribute is found. 125 | if (!resolved) { 126 | resolved = `${feature.getId() ?? feature.get('id')}`; 127 | } 128 | 129 | if (!leaveAsUrl) { 130 | // Replace any HTTP url with an element. 131 | resolved = StringUtil.urlify(resolved); 132 | 133 | // Replace all newline breaks with a html
tag. 134 | resolved = resolved.replace(/\n/g, '
'); 135 | } 136 | 137 | return resolved; 138 | } 139 | 140 | /** 141 | * Maps an array of features to an array of geometries. 142 | * 143 | * @param {import("ol/Feature").default[]} features 144 | * @return {import("ol/Geometry").default[]} The geometries of the features 145 | */ 146 | static mapFeaturesToGeometries(features: OlFeature[]): OlGeometry[] { 147 | return features 148 | .filter(feature => !_isNil(feature.getGeometry())) 149 | .map(f => f.getGeometry() as OlGeometry); 150 | } 151 | 152 | } 153 | 154 | export default FeatureUtil; 155 | -------------------------------------------------------------------------------- /src/FileUtil/FileUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | 3 | import shpwrite from '@mapbox/shp-write'; 4 | import OlGeomPoint from 'ol/geom/Point'; 5 | import OlLayerVector from 'ol/layer/Vector'; 6 | import OlMap from 'ol/Map'; 7 | import OlSourceVector from 'ol/source/Vector'; 8 | 9 | import { 10 | FileUtil 11 | } from '../index'; 12 | import TestUtil from '../TestUtil'; 13 | import geoJson from './federal-states-ger.json'; 14 | 15 | const geoJson2: GeoJSON.FeatureCollection = { 16 | type: 'FeatureCollection', 17 | features: [{ 18 | type: 'Feature', 19 | geometry: { 20 | type: 'Point', 21 | coordinates: [47, -11] 22 | }, 23 | properties: { 24 | song: 'If you have ghosts' 25 | } 26 | }] 27 | }; 28 | 29 | describe('FileUtil', () => { 30 | const geoJsonFile = new File([JSON.stringify(geoJson)], 'geo.json', { 31 | type: 'application/json', 32 | lastModified: new Date().getMilliseconds() 33 | }); 34 | 35 | let map: OlMap; 36 | 37 | it('is defined', () => { 38 | expect(FileUtil).not.toBeUndefined(); 39 | }); 40 | 41 | describe('Static methods', () => { 42 | beforeEach(() => { 43 | map = TestUtil.createMap(); 44 | }); 45 | 46 | afterEach(() => { 47 | TestUtil.removeMap(map); 48 | }); 49 | 50 | describe('#addGeojsonLayer', () => { 51 | it('adds a layer from a geojson string', () => { 52 | expect.assertions(2); 53 | return new Promise((resolve) => { 54 | const layers = map.getLayers(); 55 | layers.on('add', (event) => { 56 | const layer = event.element as OlLayerVector; 57 | expect(layers.getLength()).toBe(2); 58 | expect(layer.getSource()?.getFeatures().length).toBe(16); 59 | resolve(true); 60 | }); 61 | FileUtil.addGeojsonLayer(JSON.stringify(geoJson), map); 62 | }); 63 | }); 64 | }); 65 | 66 | describe('#addGeojsonLayerFromFile', () => { 67 | it('reads the geojson file and adds a layer to the map', () => { 68 | expect.assertions(2); 69 | return new Promise((resolve) => { 70 | const layers = map.getLayers(); 71 | layers.on('add', (event) => { 72 | const layer = event.element as OlLayerVector; 73 | expect(layers.getLength()).toBe(2); 74 | expect(layer.getSource()?.getFeatures().length).toBe(16); 75 | resolve(true); 76 | }); 77 | FileUtil.addGeojsonLayerFromFile(geoJsonFile, map); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('#addShpLayerFromFile', () => { 83 | it('reads the shp file and adds a layer to the map', async () => { 84 | const shpBuffer = await shpwrite.zip<'arraybuffer'>(geoJson2, { 85 | outputType: 'arraybuffer', 86 | compression: 'STORE' 87 | }); 88 | 89 | const shpFile = new File([new Blob([shpBuffer])], 'geo.zip', { 90 | type: 'application/zip', 91 | lastModified: new Date().getMilliseconds() 92 | }); 93 | 94 | expect.assertions(6); 95 | 96 | return new Promise((resolve) => { 97 | const layers = map.getLayers(); 98 | layers.on('add', (event) => { 99 | const layer = event.element as OlLayerVector; 100 | expect(layers.getLength()).toBe(2); 101 | 102 | const features = layer.getSource()?.getFeatures(); 103 | expect(features?.length).toBe(1); 104 | const feat = features?.[0]; 105 | const coords = (feat?.getGeometry() as OlGeomPoint)?.getCoordinates(); 106 | const properties = feat?.getProperties() || {}; 107 | 108 | expect(coords[0]).toBe(47); 109 | expect(coords[1]).toBe(-11); 110 | expect('song' in properties).toBe(true); 111 | expect(properties.song).toBe('If you have ghosts'); 112 | resolve(true); 113 | }); 114 | FileUtil.addShpLayerFromFile(shpFile, map); 115 | }); 116 | }); 117 | }); 118 | 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/FileUtil/FileUtil.ts: -------------------------------------------------------------------------------- 1 | import { FeatureCollection } from 'geojson'; 2 | import OlFeature from 'ol/Feature'; 3 | import OlFormatGeoJSON from 'ol/format/GeoJSON'; 4 | import OlLayerVector from 'ol/layer/Vector'; 5 | import OlMap from 'ol/Map'; 6 | import OlSourceVector from 'ol/source/Vector'; 7 | import shp, { FeatureCollectionWithFilename } from 'shpjs'; 8 | 9 | /** 10 | * Helper class for adding layers from various file formats. 11 | * 12 | * @class 13 | */ 14 | export class FileUtil { 15 | 16 | /** 17 | * Adds a new vector layer from a geojson file. 18 | * @param {File} file the file to read the geojson from 19 | * @param {import("ol/Map").default} map the map to add the layer to 20 | */ 21 | static addGeojsonLayerFromFile(file: File, map: OlMap): void { 22 | const reader = new FileReader(); 23 | reader.readAsText(file); 24 | reader.addEventListener('loadend', () => { 25 | const content = reader.result as string; 26 | FileUtil.addGeojsonLayer(content, map); 27 | }); 28 | } 29 | 30 | /** 31 | * Adds a new vector layer from a shape file (zip). 32 | * @param {File} file the file to read the geojson from 33 | * @param {import("ol/Map").default} map the map to add the layer to 34 | */ 35 | static addShpLayerFromFile(file: File, map: OlMap): void { 36 | const reader = new FileReader(); 37 | reader.readAsArrayBuffer(file); 38 | reader.addEventListener('loadend', () => { 39 | const blob = reader.result as ArrayBuffer; 40 | shp(blob).then((json: FeatureCollectionWithFilename | FeatureCollectionWithFilename[]) => { 41 | FileUtil.addGeojsonLayer(json, map); 42 | }); 43 | }); 44 | } 45 | 46 | /** 47 | * Adds a new vector layer from a geojson string. 48 | * @param {string|object} json the geojson string or object 49 | * @param {import("ol/Map").default} map the map to add the layer to 50 | */ 51 | static addGeojsonLayer(json: string | FeatureCollection | FeatureCollection[], map: OlMap) { 52 | const format = new OlFormatGeoJSON(); 53 | const features = format.readFeatures(json) as OlFeature[]; 54 | const layer = new OlLayerVector({ 55 | source: new OlSourceVector({ 56 | features: features 57 | }) 58 | }); 59 | map.addLayer(layer); 60 | } 61 | 62 | } 63 | 64 | export default FileUtil; 65 | -------------------------------------------------------------------------------- /src/GeometryUtil/GeometryUtil.ts: -------------------------------------------------------------------------------- 1 | import buffer from '@turf/buffer'; 2 | import difference from '@turf/difference'; 3 | import intersect from '@turf/intersect'; 4 | import { featureCollection, flatten } from '@turf/turf'; 5 | import union from '@turf/union'; 6 | import { 7 | Feature as GeoJSONFeature, 8 | MultiPolygon as GeoJSONMultiPolygon, 9 | Polygon as GeoJSONPolygon 10 | } from 'geojson'; 11 | import isNil from 'lodash/isNil'; 12 | import { Extent as OlExtent } from 'ol/extent'; 13 | import OlFeature from 'ol/Feature'; 14 | import OlFormatGeoJSON from 'ol/format/GeoJSON'; 15 | import OlGeometry from 'ol/geom/Geometry'; 16 | import OlGeomLineString from 'ol/geom/LineString'; 17 | import OlGeomMultiLineString from 'ol/geom/MultiLineString'; 18 | import OlGeomMultiPoint from 'ol/geom/MultiPoint'; 19 | import OlGeomMultiPolygon from 'ol/geom/MultiPolygon'; 20 | import OlGeomPoint from 'ol/geom/Point'; 21 | import OlGeomPolygon from 'ol/geom/Polygon'; 22 | import { ProjectionLike } from 'ol/proj'; 23 | import polygonSplitter from 'polygon-splitter'; 24 | 25 | /** 26 | * @template {OlGeomGeometry} T 27 | * @param {OlFeature|T} featureOrGeom 28 | */ 29 | function toGeom(featureOrGeom: OlFeature | Geom) { 30 | if (featureOrGeom instanceof OlFeature) { 31 | const geom = featureOrGeom.getGeometry(); 32 | if (geom === undefined) { 33 | throw new Error('Feature has no geometry.'); 34 | } 35 | return geom; 36 | } else { 37 | return featureOrGeom; 38 | } 39 | } 40 | 41 | /** 42 | * Helper class for the geospatial analysis. Makes use of 43 | * [Turf.js](http://turfjs.org/). 44 | * 45 | * @class GeometryUtil 46 | */ 47 | class GeometryUtil { 48 | 49 | /** 50 | * The prefix used to detect multi geometries. 51 | * @ignore 52 | */ 53 | static MULTI_GEOM_PREFIX = 'Multi'; 54 | 55 | /** 56 | * Splits an OlFeature with/or ol.geom.Polygon by an OlFeature with/or ol.geom.LineString 57 | * into an array of instances of OlFeature with/or ol.geom.Polygon. 58 | * If the target polygon (first param) is of type ol.Feature it will return an 59 | * array with ol.Feature. If the target polygon (first param) is of type 60 | * ol.geom.Geometry it will return an array with ol.geom.Geometry. 61 | * 62 | * @param {OlFeature | OlGeomPolygon} polygon The polygon geometry to split. 63 | * @param {OlFeature | OlGeomLineString} line The line geometry to split the polygon 64 | * geometry with. 65 | * @param {import("ol/proj").ProjectionLike} projection The EPSG code of the input features. 66 | * Default is to EPSG:3857. 67 | * @returns {OlFeature[] | OlGeomPolygon[]} An array of instances of OlFeature 68 | * with/or ol.geom.Polygon 69 | */ 70 | static splitByLine( 71 | polygon: OlFeature | OlGeomPolygon, 72 | line: OlFeature, 73 | projection: ProjectionLike = 'EPSG:3857' 74 | ): OlGeomPolygon[] | OlFeature[] { 75 | const returnFeature = polygon instanceof OlFeature; 76 | const geometries = GeometryUtil.splitGeometryByLine(toGeom(polygon), toGeom(line), projection); 77 | if (returnFeature) { 78 | return geometries.map(geom => new OlFeature(geom)); 79 | } else { 80 | return geometries; 81 | } 82 | } 83 | 84 | /** 85 | * Splits an ol.geom.Polygon by an ol.geom.LineString 86 | * into an array of instances of ol.geom.Polygon. 87 | * 88 | * @param {OlGeomPolygon} polygon The polygon geometry to split. 89 | * @param {OlGeomLineString} line The line geometry to split the polygon 90 | * geometry with. 91 | * @param {ProjectionLike} projection The EPSG code of the input features. 92 | * Default is to EPSG:3857. 93 | * @returns {OlGeomPolygon[]} An array of instances of ol.geom.Polygon 94 | */ 95 | static splitGeometryByLine( 96 | polygon: OlGeomPolygon, 97 | line: OlGeomLineString, 98 | projection: ProjectionLike = 'EPSG:3857' 99 | ): OlGeomPolygon[] { 100 | const geoJsonFormat = new OlFormatGeoJSON({ 101 | dataProjection: 'EPSG:4326', 102 | featureProjection: projection 103 | }); 104 | 105 | const polyJson = geoJsonFormat.writeGeometryObject(polygon); 106 | const lineJson = geoJsonFormat.writeGeometryObject(line); 107 | 108 | const result = polygonSplitter(polyJson, lineJson); 109 | 110 | const flattened = flatten(result); 111 | 112 | return flattened.features.map((geojsonFeature: any) => { 113 | return geoJsonFormat.readGeometry(geojsonFeature.geometry) as OlGeomPolygon; 114 | }); 115 | } 116 | 117 | /** 118 | * Adds a buffer to a given geometry. 119 | * 120 | * If the target is of type ol.Feature it will return an ol.Feature. 121 | * If the target is of type ol.geom.Geometry it will return ol.geom.Geometry. 122 | * 123 | * @param {OlGeometry | OlFeature} geometryOrFeature The geometry. 124 | * @param {number} radius The buffer to add in meters. 125 | * @param {string} projection The projection of the input geometry as EPSG code. 126 | * Default is to EPSG:3857. 127 | * 128 | * @returns {OlGeometry | OlFeature} The geometry or feature with the added buffer. 129 | */ 130 | static addBuffer( 131 | geometryOrFeature: OlFeature | OlGeometry, 132 | radius: number = 0, 133 | projection: ProjectionLike = 'EPSG:3857' 134 | ) { 135 | if (geometryOrFeature instanceof OlFeature) { 136 | return new OlFeature(GeometryUtil.addGeometryBuffer(toGeom(geometryOrFeature), radius, projection)); 137 | } else { 138 | return GeometryUtil.addGeometryBuffer(geometryOrFeature, radius, projection); 139 | } 140 | } 141 | 142 | /** 143 | * Adds a buffer to a given geometry. 144 | * 145 | * @param {OlGeometry} geometry The geometry. 146 | * @param {number} radius The buffer to add in meters. 147 | * @param {string} projection The projection of the input geometry as EPSG code. 148 | * Default is to EPSG:3857. 149 | * 150 | * @returns {OlGeometry} The geometry with the added buffer. 151 | */ 152 | static addGeometryBuffer(geometry: OlGeometry, radius: number = 0, projection: ProjectionLike = 'EPSG:3857') { 153 | if (radius === 0) { 154 | return geometry; 155 | } 156 | const geoJsonFormat = new OlFormatGeoJSON({ 157 | dataProjection: 'EPSG:4326', 158 | featureProjection: projection 159 | }); 160 | const geoJson = geoJsonFormat.writeGeometryObject(geometry); 161 | if (geoJson.type === 'GeometryCollection') { 162 | return; 163 | } 164 | const buffered = buffer(geoJson, radius, { 165 | units: 'meters' 166 | }); 167 | return geoJsonFormat.readGeometry(buffered?.geometry); 168 | } 169 | 170 | /** 171 | * Merges multiple geometries into one MultiGeometry. 172 | * 173 | * @param {(OlGeomMultiPoint|OlGeomPoint)[]|(OlGeomMultiPolygon|OlGeomPolygon)[]| 174 | * (OlGeomMultiLineString|OlGeomLineString)[]} geometries An array of ol.geom.geometries; 175 | * @returns {OlGeomMultiPoint|OlGeomMultiPolygon|OlGeomMultiLineString} A Multigeometry. 176 | */ 177 | static mergeGeometries(geometries: Geom[]) { 178 | // split all multi-geometries to simple ones if passed geometries are 179 | // multi-geometries 180 | const separateGeometries = GeometryUtil.separateGeometries(geometries); 181 | 182 | if (separateGeometries[0] instanceof OlGeomPolygon) { 183 | const multiGeom = new OlGeomMultiPolygon([]); 184 | for (const geom of separateGeometries) { 185 | multiGeom.appendPolygon(geom as OlGeomPolygon); 186 | } 187 | return multiGeom; 188 | } else if (separateGeometries[0] instanceof OlGeomLineString) { 189 | const multiGeom = new OlGeomMultiLineString([]); 190 | for (const geom of separateGeometries) { 191 | multiGeom.appendLineString(geom as OlGeomLineString); 192 | } 193 | return multiGeom; 194 | } else { 195 | const multiGeom = new OlGeomMultiPoint([]); 196 | for (const geom of separateGeometries) { 197 | multiGeom.appendPoint(geom as OlGeomPoint); 198 | } 199 | return multiGeom; 200 | } 201 | } 202 | 203 | /** 204 | * Splits an array of geometries (and multi geometries) or a single MultiGeom 205 | * into an array of single geometries. 206 | * 207 | * @param {} geometry An (array of) ol.geom.geometries; 208 | * @returns {(OlGeomPoint|OlGeomLineString|OlGeomPolygon)[]} An array of geometries. 209 | */ 210 | static separateGeometries(geometry: OlGeometry | OlGeometry[]): OlGeometry[] { 211 | if (Array.isArray(geometry)) { 212 | return geometry.flatMap(geom => GeometryUtil.separateGeometries(geom)); 213 | } 214 | if (geometry instanceof OlGeomMultiPolygon) { 215 | return geometry.getPolygons(); 216 | } 217 | if (geometry instanceof OlGeomMultiLineString) { 218 | return geometry.getLineStrings(); 219 | } 220 | if (geometry instanceof OlGeomMultiPoint) { 221 | return geometry.getPoints(); 222 | } 223 | return [geometry]; // Return simple geometry as array 224 | } 225 | 226 | /** 227 | * Takes two or more polygons and returns a combined (Multi-)polygon. 228 | * 229 | * @param {OlFeature[] | OlFeature>[]} inputPolygonalObjects An 230 | * array of ol.Feature or ol.geom.Geometry instances of type (Multi)-Polygon. 231 | * @param {ProjectionLike} projection The projection of the input polygons as EPSG code. 232 | * Default is to EPSG:3857. 233 | * @returns {OlGeomMultiPolygon|OlGeomPolygon|OlFeature} A Feature or Geometry with 234 | * the combined area of the (Multi-)polygons. 235 | */ 236 | static union( 237 | inputPolygonalObjects: OlGeomPolygon[] | OlFeature[], 238 | projection: ProjectionLike = 'EPSG:3857' 239 | ): OlGeomMultiPolygon | OlGeomPolygon | OlFeature { 240 | const geometries = inputPolygonalObjects.map(toGeom) as OlGeomPolygon[] | OlGeomMultiPolygon[]; 241 | const unionGeometry = GeometryUtil.unionGeometries(geometries, projection); 242 | if (inputPolygonalObjects[0] instanceof OlFeature) { 243 | return new OlFeature(unionGeometry); 244 | } else { 245 | return unionGeometry; 246 | } 247 | } 248 | 249 | /** 250 | * Takes two or more polygons and returns a combined (Multi-)polygon. 251 | * 252 | * @param {OlGeomPolygon[]} polygons An array of ol.geom.Geometry instances of type (Multi-)polygon. 253 | * @param {string} projection The projection of the input polygons as EPSG code. 254 | * Default is to EPSG:3857. 255 | * @returns {OlGeomMultiPolygon|OlGeomPolygon} A FGeometry with the combined area of the (Multi-)polygons. 256 | */ 257 | static unionGeometries(polygons: OlGeomPolygon[] | OlGeomMultiPolygon[], projection: ProjectionLike = 'EPSG:3857'): 258 | OlGeomMultiPolygon | OlGeomPolygon 259 | { 260 | const geoJsonFormat = new OlFormatGeoJSON({ 261 | dataProjection: 'EPSG:4326', 262 | featureProjection: projection 263 | }); 264 | 265 | const pp = polygons 266 | .map((p: OlGeomPolygon | OlGeomMultiPolygon) => { 267 | if (p instanceof OlGeomMultiPolygon) { 268 | return geoJsonFormat.writeFeatureObject(new OlFeature(p)) as GeoJSONFeature; 269 | } else { 270 | return geoJsonFormat.writeFeatureObject(new OlFeature(p)) as GeoJSONFeature; 271 | } 272 | }); 273 | 274 | const unionGeometry = union(featureCollection(pp)); 275 | 276 | return (geoJsonFormat.readFeature(unionGeometry) as OlFeature).getGeometry() as OlGeomMultiPolygon | OlGeomPolygon; 277 | } 278 | 279 | /** 280 | * Finds the difference between two polygons by clipping the second polygon from the first. 281 | * 282 | * If both polygons are of type ol.Feature it will return an ol.Feature. 283 | * Else it will return an ol.geom.Geometry. 284 | * 285 | * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature} polygon1 286 | * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature} polygon2 287 | * @param {string} projection The projection of the input polygons as EPSG code. 288 | * Default is to EPSG:3857. 289 | * 290 | * @returns {OlGeomPolygon|OlGeomMultiPolygon|OlFeature} A Feature or geometry 291 | * with the area of polygon1 excluding the area of polygon2. 292 | */ 293 | static difference( 294 | polygon1: OlFeature | OlGeomPolygon, 295 | polygon2: OlFeature | OlGeomPolygon, 296 | projection: ProjectionLike = 'EPSG:3857' 297 | ): OlGeomMultiPolygon | OlGeomPolygon | OlFeature { 298 | const differenceGeometry = GeometryUtil.geometryDifference(toGeom(polygon1), toGeom(polygon2), projection); 299 | if (polygon1 instanceof OlFeature && polygon2 instanceof OlFeature) { 300 | return new OlFeature(differenceGeometry); 301 | } else { 302 | return differenceGeometry; 303 | } 304 | } 305 | 306 | /** 307 | * Finds the difference between two polygons by clipping the second polygon from the first. 308 | * 309 | * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon1 An ol.geom.Geometry 310 | * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon2 An ol.geom.Geometry 311 | * @param {string} projection The projection of the input polygons as EPSG code. 312 | * Default is to EPSG:3857. 313 | * 314 | * @returns {OlGeomPolygon|OlGeomMultiPolygon} A with the area 315 | * of polygon1 excluding the area of polygon2. 316 | */ 317 | static geometryDifference( 318 | polygon1: OlGeomPolygon, 319 | polygon2: OlGeomPolygon, 320 | projection: ProjectionLike = 'EPSG:3857' 321 | ): OlGeomMultiPolygon | OlGeomPolygon { 322 | const geoJsonFormat = new OlFormatGeoJSON>({ 323 | dataProjection: 'EPSG:4326', 324 | featureProjection: projection 325 | }); 326 | const geojson1 = geoJsonFormat.writeFeatureObject(new OlFeature(polygon1)) as GeoJSONFeature; 327 | const geojson2 = geoJsonFormat.writeFeatureObject(new OlFeature(polygon2)) as GeoJSONFeature; 328 | 329 | const coll = featureCollection([geojson1, geojson2]); 330 | 331 | const intersection = difference(coll); 332 | const feature = geoJsonFormat.readFeature(intersection); 333 | return (feature as OlFeature).getGeometry() as OlGeomMultiPolygon | OlGeomPolygon; 334 | } 335 | 336 | /** 337 | * Takes two polygons and finds their intersection. 338 | * 339 | * If both polygons are of type ol.Feature it will return an ol.Feature. 340 | * Else it will return an ol.geom.Geometry. 341 | * 342 | * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature} polygon1 343 | * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature} polygon2 344 | * @param {string} projection The projection of the input polygons as EPSG code. 345 | * Default is to EPSG:3857. 346 | * 347 | * @returns {OlGeomPolygon|OlGeomMultiPolygon|OlFeature|null} A Feature or Geometry 348 | * with the shared area of the two polygons or null if the polygons don't intersect. 349 | */ 350 | static intersection( 351 | polygon1: OlFeature | OlGeomPolygon, 352 | polygon2: OlFeature | OlGeomPolygon, 353 | projection: ProjectionLike = 'EPSG:3857' 354 | ): OlGeomMultiPolygon | OlGeomPolygon | OlFeature | undefined { 355 | const intersectionGeometry = GeometryUtil.geometryIntersection(toGeom(polygon1), toGeom(polygon2), projection); 356 | if (isNil(intersectionGeometry)) { 357 | return undefined; 358 | } 359 | if (polygon1 instanceof OlFeature && polygon2 instanceof OlFeature) { 360 | return new OlFeature(intersectionGeometry); 361 | } else { 362 | return intersectionGeometry; 363 | } 364 | } 365 | 366 | /** 367 | * Takes two polygons and finds their intersection. 368 | * 369 | * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon1 An ol.geom.Geometry 370 | * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon2 An ol.geom.Geometry 371 | * @param {string} projection The projection of the input polygons as EPSG code. 372 | * Default is to EPSG:3857. 373 | * 374 | * @returns {OlGeomPolygon|OlGeomMultiPolygon|null} A Geometry with the 375 | * shared area of the two polygons or null if the polygons don't intersect. 376 | */ 377 | static geometryIntersection( 378 | polygon1: OlGeomPolygon | OlGeomMultiPolygon, 379 | polygon2: OlGeomPolygon | OlGeomMultiPolygon, 380 | projection: ProjectionLike = 'EPSG:3857' 381 | ): OlGeomMultiPolygon | OlGeomPolygon | undefined { 382 | const geoJsonFormat = new OlFormatGeoJSON({ 383 | dataProjection: 'EPSG:4326', 384 | featureProjection: projection 385 | }); 386 | const geojson1 = polygon1 instanceof OlGeomMultiPolygon ? 387 | geoJsonFormat.writeFeatureObject(new OlFeature(polygon1)) as GeoJSONFeature : 388 | geoJsonFormat.writeFeatureObject(new OlFeature(polygon1)) as GeoJSONFeature; 389 | const geojson2 = polygon2 instanceof OlGeomMultiPolygon ? 390 | geoJsonFormat.writeFeatureObject(new OlFeature(polygon2)) as GeoJSONFeature : 391 | geoJsonFormat.writeFeatureObject(new OlFeature(polygon2)) as GeoJSONFeature; 392 | 393 | const intersection = intersect(featureCollection([geojson1, geojson2])); 394 | 395 | if (!intersection) { 396 | return undefined; 397 | } 398 | 399 | const feature = geoJsonFormat.readFeature(intersection); 400 | return (feature as OlFeature).getGeometry() as OlGeomMultiPolygon | OlGeomPolygon; 401 | } 402 | 403 | static getPolygonFromExtent(extent?: OlExtent | null): OlGeomPolygon | undefined { 404 | if (isNil(extent) || extent?.length !== 4) { 405 | return; 406 | } 407 | const [minX, minY, maxX, maxY] = extent; 408 | return new OlGeomPolygon([ 409 | [ 410 | [minX, minY], 411 | [minX, maxY], 412 | [maxX, maxY], 413 | [maxX, minY], 414 | [minX, minY] 415 | ] 416 | ]); 417 | } 418 | } 419 | export default GeometryUtil; 420 | -------------------------------------------------------------------------------- /src/GeometryUtil/TestCoords.ts: -------------------------------------------------------------------------------- 1 | export const boxCoords = [ 2 | [ 3 | [10, 10], 4 | [40, 10], 5 | [40, 40], 6 | [10, 40], 7 | [10, 10] 8 | ] 9 | ]; 10 | export const lineStringCoords = [ 11 | [25, 50], 12 | [25, 0] 13 | ]; 14 | export const splitBoxCoords1 = [[ 15 | [25, 40], 16 | [10, 40], 17 | [10, 10], 18 | [25, 10], 19 | [25, 40] 20 | ]]; 21 | export const splitBoxCoords2 = [[ 22 | [25, 10], 23 | [40, 10], 24 | [40, 40], 25 | [25, 40], 26 | [25, 10] 27 | ]]; 28 | 29 | export const lineStringLFormedCoords = [ 30 | [25, 50], 31 | [25, 25], 32 | [50, 25] 33 | ]; 34 | export const splitBoxLFormedCoords1 = [[ 35 | [25, 40], 36 | [10, 40], 37 | [10, 10], 38 | [40, 10], 39 | [40, 25], 40 | [25, 25], 41 | [25, 40] 42 | ]]; 43 | export const splitBoxLFormedCoords2 = [[ 44 | [40, 25], 45 | [40, 40], 46 | [25, 40], 47 | [25, 25], 48 | [40, 25] 49 | ]]; 50 | 51 | export const uFormedPolygonCoords = [[ 52 | [10, 40], 53 | [20, 40], 54 | [20, 30], 55 | [30, 30], 56 | [30, 40], 57 | [40, 40], 58 | [40, 10], 59 | [10, 10], 60 | [10, 40] 61 | ]]; 62 | export const lineStringCoords2 = [ 63 | [0, 35], 64 | [50, 35] 65 | ]; 66 | export const splitUFormerdCoords1 = [[[10,35],[10,10],[40,10],[40,35],[30,35],[30,30],[20,30],[20,35],[10,35]]]; 67 | export const splitUFormerdCoords2 = [[[40,35],[40,40],[30,40],[30,35],[40,35]]]; 68 | export const splitUFormerdCoords3 = [[[20,35],[20,40],[10,40],[10,35],[20,35]]]; 69 | 70 | export const pointCoords = [13, 37]; 71 | export const bufferedPointCoords = [ 72 | [ 73 | [ 13.002252142190631, 36.999999978725924 ], 74 | [ 13.002208878103968, 37.00035087693419 ], 75 | [ 13.002080726910485, 37.00068829184835 ], 76 | [ 13.001872612405041, 37.000999256540105 ], 77 | [ 13.001592531654072, 37.00127182041798 ], 78 | [ 13.001251247772899, 37.001495508541694 ], 79 | [ 13.000861876340403, 37.00166172423881 ], 80 | [ 13.000439381339582, 37.001764079540486 ], 81 | [ 13, 37.00179864072744 ], 82 | [ 12.99956061866042, 37.001764079540486 ], 83 | [ 12.999138123659597, 37.00166172423881 ], 84 | [ 12.998748752227103, 37.001495508541694 ], 85 | [ 12.99840746834593, 37.00127182041798 ], 86 | [ 12.99812738759496, 37.000999256540105 ], 87 | [ 12.997919273089517, 37.00068829184835 ], 88 | [ 12.997791121896034, 37.00035087693419 ], 89 | [ 12.99774785780937, 36.999999978725924 ], 90 | [ 12.997791142283894, 36.99964908213706 ], 91 | [ 12.997919310761372, 36.99931167183453 ], 92 | [ 12.998127436815611, 36.999000714044584 ], 93 | [ 12.998407521621978, 36.99872815830795 ], 94 | [ 12.998748801447755, 36.998504478325465 ], 95 | [ 12.999138161331453, 36.99833826953016 ], 96 | [ 12.999560639048282, 36.998235918840116 ], 97 | [ 13, 36.99820135927255 ], 98 | [ 13.00043936095172, 36.998235918840116 ], 99 | [ 13.000861838668548, 36.99833826953016 ], 100 | [ 13.001251198552247, 36.998504478325465 ], 101 | [ 13.001592478378024, 36.99872815830795 ], 102 | [ 13.00187256318439, 36.999000714044584 ], 103 | [ 13.00208068923863, 36.99931167183453 ], 104 | [ 13.002208857716107, 36.99964908213706 ], 105 | [ 13.002252142190631, 36.999999978725924 ] 106 | ] 107 | ]; 108 | 109 | export const bufferedBoxCoords = [ 110 | [ 111 | [ 9.998197119361716, 10.000035594367398 ], 112 | [ 9.99821453655676, 9.999704123129588 ], 113 | [ 9.998294090970681, 9.999382949253315 ], 114 | [ 9.998433013738687, 9.999083250078769 ], 115 | [ 9.998626469949038, 9.998815455547884 ], 116 | [ 9.998867726923994, 9.998588885248463 ], 117 | [ 9.999148388531879, 9.998411424101933 ], 118 | [ 9.999458687375643, 9.998289247977606 ], 119 | [ 9.999787824690936, 9.998226608779039 ], 120 | [ 10.000124346128455, 9.998225686479298 ], 121 | [ 39.99987565387154, 9.998225686479302 ], 122 | [ 40.00021217530906, 9.998226608779037 ], 123 | [ 40.00054131262436, 9.998289247977606 ], 124 | [ 40.000851611468114, 9.998411424101933 ], 125 | [ 40.001132273076, 9.998588885248463 ], 126 | [ 40.001373530050955, 9.998815455547888 ], 127 | [ 40.00156698626131, 9.999083250078773 ], 128 | [ 40.00170590902931, 9.999382949253315 ], 129 | [ 40.001785463443234, 9.999704123129588 ], 130 | [ 40.00180288063829, 10.000035594367398 ], 131 | [ 40.00232295733205, 39.99996064861952 ], 132 | [ 40.00229262751348, 40.00033037149533 ], 133 | [ 40.00216290080641, 40.00068577204011 ], 134 | [ 40.0019394001595, 40.00101144181533 ], 135 | [ 40.00163181443618, 40.00129326116642 ], 136 | [ 40.00125347851804, 40.00151901143438 ], 137 | [ 40.00082079524447, 40.00167890477209 ], 138 | [ 40.00035252423828, 40.00176600858138 ], 139 | [ 9.999647475761709, 40.00176600858138 ], 140 | [ 9.999179204755523, 40.00167890477209 ], 141 | [ 9.99874652148196, 40.00151901143438 ], 142 | [ 9.998368185563816, 40.00129326116642 ], 143 | [ 9.99806059984049, 40.00101144181533 ], 144 | [ 9.997837099193585, 40.00068577204011 ], 145 | [ 9.99770737248652, 40.00033037149533 ], 146 | [ 9.997677042667942, 39.99996064861952 ], 147 | [ 9.998197119361716, 10.000035594367398 ] 148 | ] 149 | ]; 150 | 151 | export const bufferedLineStringCoords = [ 152 | [ 153 | [ 24.998257891072754, -7.9051605219184e-9 ], 154 | [ 24.99829136674891, -0.00035090500294483723 ], 155 | [ 24.998390504067196, -0.0006883167545465821 ], 156 | [ 24.99855149308735, -0.0009992767131199265 ], 157 | [ 24.998768146992248, -0.0012718350077722566 ], 158 | [ 24.999032139858944, -0.0014955175482351789 ], 159 | [ 24.999333326624537, -0.0016617285120711141 ], 160 | [ 24.999660132949916, -0.0017640806510699132 ], 161 | [ 25, -0.0017986407274450057 ], 162 | [ 25.000339867050084, -0.0017640806510699132 ], 163 | [ 25.000666673375463, -0.0016617285120711141 ], 164 | [ 25.000967860141056, -0.0014955175482351789 ], 165 | [ 25.001231853007756, -0.0012718350077722566 ], 166 | [ 25.00144850691265, -0.0009992767131199265 ], 167 | [ 25.001609495932804, -0.0006883167545465821 ], 168 | [ 25.00170863325109, -0.00035090500294483723 ], 169 | [ 25.001742108927246, -7.9051605219184e-9 ], 170 | [ 25.002710240366365, 49.99999997634171 ], 171 | [ 25.00265818086059, 50.00035087464064 ], 172 | [ 25.00250396702027, 50.00068828981316 ], 173 | [ 25.002253523556142, 50.00099925489165 ], 174 | [ 25.00191647377371, 50.00127181922573 ], 175 | [ 25.00150576992199, 50.00149550780569 ], 176 | [ 25.00103719550449, 50.0016617238896 ], 177 | [ 25.00052875866947, 50.00176407944973 ], 178 | [ 25, 50.00179864072744 ], 179 | [ 24.99947124133053, 50.00176407944973 ], 180 | [ 24.99896280449551, 50.0016617238896 ], 181 | [ 24.998494230078013, 50.00149550780569 ], 182 | [ 24.99808352622629, 50.00127181922573 ], 183 | [ 24.997746476443858, 50.00099925489165 ], 184 | [ 24.99749603297973, 50.00068828981316 ], 185 | [ 24.99734181913941, 50.00035087464064 ], 186 | [ 24.997289759633635, 49.99999997634171 ], 187 | [ 24.998257891072754, -7.9051605219184e-9 ] 188 | ] 189 | ]; 190 | 191 | export const boxCoords2 = [ 192 | [ 193 | [15, 35], 194 | [35, 35], 195 | [35, 15], 196 | [15, 15], 197 | [15, 35] 198 | ] 199 | ]; 200 | 201 | export const holeCoords = [ 202 | boxCoords[0], 203 | boxCoords2[0] 204 | ]; 205 | 206 | export const bufferedHoleCoords = [ 207 | [ 208 | [ 9.998197119361716, 10.000035594367398 ], 209 | [ 9.99821453655676, 9.999704123129588 ], 210 | [ 9.998294090970681, 9.999382949253315 ], 211 | [ 9.998433013738687, 9.999083250078769 ], 212 | [ 9.998626469949038, 9.998815455547884 ], 213 | [ 9.998867726923994, 9.998588885248463 ], 214 | [ 9.999148388531879, 9.998411424101933 ], 215 | [ 9.999458687375643, 9.998289247977606 ], 216 | [ 9.999787824690936, 9.998226608779039 ], 217 | [ 10.000124346128455, 9.998225686479298 ], 218 | [ 39.99987565387154, 9.998225686479302 ], 219 | [ 40.00021217530906, 9.998226608779037 ], 220 | [ 40.00054131262436, 9.998289247977606 ], 221 | [ 40.000851611468114, 9.998411424101933 ], 222 | [ 40.001132273076, 9.998588885248463 ], 223 | [ 40.001373530050955, 9.998815455547888 ], 224 | [ 40.00156698626131, 9.999083250078773 ], 225 | [ 40.00170590902931, 9.999382949253315 ], 226 | [ 40.001785463443234, 9.999704123129588 ], 227 | [ 40.00180288063829, 10.000035594367398 ], 228 | [ 40.00232295733205, 39.99996064861952 ], 229 | [ 40.00229262751348, 40.00033037149533 ], 230 | [ 40.00216290080641, 40.00068577204011 ], 231 | [ 40.0019394001595, 40.00101144181533 ], 232 | [ 40.00163181443618, 40.00129326116642 ], 233 | [ 40.00125347851804, 40.00151901143438 ], 234 | [ 40.00082079524447, 40.00167890477209 ], 235 | [ 40.00035252423828, 40.00176600858138 ], 236 | [ 9.999647475761709, 40.00176600858138 ], 237 | [ 9.999179204755523, 40.00167890477209 ], 238 | [ 9.99874652148196, 40.00151901143438 ], 239 | [ 9.998368185563816, 40.00129326116642 ], 240 | [ 9.99806059984049, 40.00101144181533 ], 241 | [ 9.997837099193585, 40.00068577204011 ], 242 | [ 9.99770737248652, 40.00033037149533 ], 243 | [ 9.997677042667942, 39.99996064861952 ], 244 | [ 9.998197119361716, 10.000035594367398 ] 245 | ], 246 | [ 247 | [ 15.002222569100889, 34.99835355408961 ], 248 | [ 34.997777430899106, 34.99835355408962 ], 249 | [ 34.998111299821126, 15.001912217796027 ], 250 | [ 15.00188870017887, 15.001912217796027 ], 251 | [ 15.002222569100889, 34.99835355408961 ] 252 | ] 253 | ]; 254 | 255 | export const pointCoords2 = [37, 13]; 256 | export const pointCoords3 = [47, 11]; 257 | export const pointCoords4 = [11, 47]; 258 | export const mergedPointCoordinates = [ 259 | [13, 37], 260 | [37, 13] 261 | ]; 262 | export const mergedPointCoordinates2 = [ 263 | [13, 37], 264 | [37, 13], 265 | [47, 11], 266 | [11, 47] 267 | ]; 268 | 269 | export const boxCoords3 = [[ 270 | [0, 20], 271 | [20, 20], 272 | [20, 0], 273 | [0, 0], 274 | [0, 20] 275 | ]]; 276 | 277 | export const mergedBoxCoords = [ 278 | boxCoords, 279 | boxCoords3 280 | ]; 281 | 282 | export const mergedLineStringCoordinates = [ 283 | lineStringCoords, 284 | lineStringCoords2 285 | ]; 286 | 287 | export const unionedBoxCoordinates = [[ 288 | [ 10, 10 ], 289 | [ 40, 10 ], 290 | [ 40, 40 ], 291 | [ 10, 40 ], 292 | [ 10, 10 ] 293 | ]]; 294 | 295 | export const differenceBoxCoords = [ 296 | [ 297 | [ 10, 10 ], [ 40, 10 ], [ 40, 40 ], [ 10, 40 ], [ 10, 10 ] 298 | ],[ 299 | [ 15, 15 ], [ 15, 35 ], [ 35, 35 ], [ 35, 15 ], [ 15, 15 ] 300 | ] 301 | ]; 302 | 303 | export const intersectionCoords = [ 304 | [ 305 | [ 10, 10 ], [ 20, 10 ], [ 20, 20 ], [ 10, 20 ], [ 10, 10 ] 306 | ] 307 | ]; 308 | 309 | export const boxCoords4 = [[ 310 | [0, 5], 311 | [5, 5], 312 | [5, 0], 313 | [0, 0], 314 | [0, 5] 315 | ]]; 316 | 317 | export const expectedMultiPolygon = [ 318 | [ 319 | [ 320 | [10, 10], 321 | [40, 10], 322 | [40, 40], 323 | [10, 40], 324 | [10, 10] 325 | ] 326 | ], 327 | [ 328 | [ 329 | [15, 35], 330 | [35, 35], 331 | [35, 15], 332 | [15, 15], 333 | [15, 35] 334 | ] 335 | ], 336 | [ 337 | [ 338 | [0, 20], 339 | [20, 20], 340 | [20, 0], 341 | [0, 0], 342 | [0, 20] 343 | ] 344 | ], 345 | [ 346 | [ 347 | [0, 5], 348 | [5, 5], 349 | [5, 0], 350 | [0, 0], 351 | [0, 5] 352 | ] 353 | ] 354 | ]; 355 | 356 | export const holeCoords2 = [ 357 | [ 358 | [10,40], 359 | [40,40], 360 | [40,10], 361 | [-7,10], 362 | [-7,40], 363 | [10,15], 364 | [10,40] 365 | ], 366 | [ 367 | [15,15], 368 | [35,15], 369 | [35,35], 370 | [15,35], 371 | [15,15] 372 | ] 373 | ]; 374 | 375 | export const holeCoords2CutLine = [ 376 | [ 377 | -20, 378 | 25 379 | ], 380 | [ 381 | 50, 382 | 25 383 | ] 384 | ]; 385 | 386 | export const holeCoords2ExpPoly1 = [ 387 | [ 388 | [ -7, 25 ], 389 | [ -7, 10 ], 390 | [ 40, 10 ], 391 | [ 40, 25 ], 392 | [ 35, 25 ], 393 | [ 35, 15 ], 394 | [ 15, 15 ], 395 | [ 15, 25 ], 396 | [ 10, 25 ], 397 | [ 10, 15 ], 398 | [ 3.1999999999999993, 25 ], 399 | [ -7, 25 ] 400 | ] 401 | ]; 402 | 403 | export const holeCoords2ExpPoly2 = [ 404 | [ 405 | [ 40, 25 ], [ 40, 40 ], 406 | [ 10, 40 ], [ 10, 25 ], 407 | [ 15, 25 ], [ 15, 35 ], 408 | [ 35, 35 ], [ 35, 25 ], 409 | [ 40, 25 ] 410 | ]]; 411 | 412 | 413 | export const holeCoords2ExpPoly3 = [ 414 | [ 415 | [ 3.1999999999999993, 25 ], 416 | [ -7, 40 ], 417 | [ -7, 25 ], 418 | [ 3.1999999999999993, 25 ] 419 | ] 420 | ]; 421 | -------------------------------------------------------------------------------- /src/LayerUtil/InkmapTypes.ts: -------------------------------------------------------------------------------- 1 | export interface InkmapWmsLayer { 2 | type: 'WMS'; 3 | url: string; 4 | opacity?: number; 5 | attribution?: string; 6 | layer: string; 7 | tiled?: boolean; 8 | legendUrl?: string; 9 | layerName?: string; 10 | customParams?: any; 11 | } 12 | 13 | export interface InkmapWmtsLayer { 14 | type: 'WMTS'; 15 | url: string; 16 | opacity?: number; 17 | attribution?: string; 18 | layer?: string; 19 | projection?: string; 20 | matrixSet?: string; 21 | tileGrid?: any; 22 | format?: string; 23 | requestEncoding?: string; 24 | legendUrl?: string; 25 | layerName?: string; 26 | } 27 | 28 | export interface InkmapGeoJsonLayer { 29 | type: 'GeoJSON'; 30 | attribution?: string; 31 | style: any; 32 | geojson: any; 33 | legendUrl?: string; 34 | layerName?: string; 35 | } 36 | 37 | export interface InkmapWfsLayer { 38 | type: 'WFS'; 39 | url: string; 40 | attribution?: string; 41 | layer?: string; 42 | projection?: string; 43 | legendUrl?: string; 44 | layerName?: string; 45 | } 46 | 47 | export interface InkmapOsmLayer { 48 | type: 'XYZ'; 49 | url: string; 50 | opacity?: number; 51 | attribution?: string; 52 | layer?: string; 53 | tiled?: boolean; 54 | projection?: string; 55 | matrixSet?: string; 56 | tileGrid?: any; 57 | style?: any; 58 | format?: string; 59 | requestEncoding?: string; 60 | geojson?: any; 61 | legendUrl?: string; 62 | layerName?: string; 63 | } 64 | 65 | export type InkmapLayer = InkmapWmsLayer | InkmapWmtsLayer | InkmapGeoJsonLayer | InkmapWfsLayer | InkmapOsmLayer; 66 | 67 | export interface ScaleBarSpec { 68 | position: 'bottom-left' | 'bottom-right'; 69 | units: string; 70 | } 71 | 72 | export interface InkmapProjectionDefinition { 73 | name: string; 74 | bbox: [number, number, number, number]; 75 | proj4: string; 76 | } 77 | 78 | export interface InkmapPrintSpec { 79 | layers: InkmapLayer[]; 80 | size: [number, number] | [number, number, string]; 81 | center: [number, number]; 82 | dpi: number; 83 | scale: number; 84 | scaleBar: boolean | ScaleBarSpec; 85 | northArrow: boolean | string; 86 | projection: string; 87 | projectionDefinitions?: InkmapProjectionDefinition[]; 88 | attributions: boolean | 'top-left' | 'bottom-left' | 'bottom-right' | 'top-right'; 89 | } 90 | -------------------------------------------------------------------------------- /src/LayerUtil/LayerUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | 3 | import OlLayerImage from 'ol/layer/Image'; 4 | import OlLayerTile from 'ol/layer/Tile'; 5 | import OlSourceImageWMS from 'ol/source/ImageWMS'; 6 | import OlSourceTileWMS from 'ol/source/TileWMS'; 7 | import OlSourceWMTS from 'ol/source/WMTS'; 8 | import OlWMTSTileGrid from 'ol/tilegrid/WMTS'; 9 | 10 | import CapabilitiesUtil from '../CapabilitiesUtil/CapabilitiesUtil'; 11 | import { InkmapWmsLayer, InkmapWmtsLayer } from './InkmapTypes'; 12 | import LayerUtil from './LayerUtil'; 13 | 14 | describe('LayerUtil', () => { 15 | it('is defined', () => { 16 | expect(LayerUtil).not.toBeUndefined(); 17 | }); 18 | 19 | describe('Static methods', () => { 20 | describe('#getLayerUrl', () => { 21 | it('returns the url of a supported layer/source type', () => { 22 | const layer1 = new OlLayerTile({ 23 | source: new OlSourceTileWMS({ 24 | url: 'https://ows.terrestris.de/osm-gray/service?', 25 | params: { 26 | LAYERS: 'OSM-WMS' 27 | } 28 | }), 29 | properties: { 30 | name: 'OSM-WMS' 31 | } 32 | }); 33 | 34 | const url1 = LayerUtil.getLayerUrl(layer1); 35 | 36 | expect(url1).toEqual('https://ows.terrestris.de/osm-gray/service?'); 37 | 38 | const layer2 = new OlLayerImage({ 39 | source: new OlSourceImageWMS({ 40 | url: 'https://ows.terrestris.de/osm-gray/service', 41 | params: { 42 | LAYERS: 'OSM-WMS' 43 | } 44 | }), 45 | properties: { 46 | name: 'OSM-WMS' 47 | } 48 | }); 49 | 50 | const url2 = LayerUtil.getLayerUrl(layer2); 51 | 52 | expect(url2).toEqual('https://ows.terrestris.de/osm-gray/service'); 53 | 54 | const layer3 = new OlLayerTile({ 55 | source: new OlSourceWMTS({ 56 | urls: ['https://ows.terrestris.de/osm-gray/service'], 57 | layer: 'test', 58 | matrixSet: 'test', 59 | tileGrid: new OlWMTSTileGrid({ 60 | matrixIds: [], 61 | resolutions: [], 62 | origin: [19, 9] 63 | }), 64 | style: 'default' 65 | }), 66 | properties: { 67 | name: 'OSM-WMS' 68 | } 69 | }); 70 | 71 | const url3 = LayerUtil.getLayerUrl(layer3); 72 | 73 | expect(url3).toEqual('https://ows.terrestris.de/osm-gray/service'); 74 | }); 75 | }); 76 | 77 | describe('#getExtentForLayer', () => { 78 | it('returns the extent of the given layer for GetCapabilities request version 1.3.0 (multiple layers)', 79 | async () => { 80 | const layer = new OlLayerTile({ 81 | source: new OlSourceTileWMS({ 82 | url: 'https://ows.terrestris.de/osm-gray/service?', 83 | params: { 84 | LAYERS: 'OSM-WMS' 85 | } 86 | }), 87 | properties: { 88 | name: 'OSM-WMS' 89 | } 90 | }); 91 | 92 | const mockImpl = jest.fn(); 93 | mockImpl.mockReturnValue({ 94 | Capability: { 95 | Layer: { 96 | Layer: [{ 97 | Name: 'OSM-WMS', 98 | // eslint-disable-next-line camelcase 99 | EX_GeographicBoundingBox: { 100 | westBoundLongitude: 1, 101 | southBoundLatitude: 2, 102 | eastBoundLongitude: 3, 103 | northBoundLatitude: 4 104 | } 105 | }, { 106 | Name: 'Peter', 107 | // eslint-disable-next-line camelcase 108 | EX_GeographicBoundingBox: { 109 | westBoundLongitude: 5, 110 | southBoundLatitude: 6, 111 | eastBoundLongitude: 7, 112 | northBoundLatitude: 8 113 | } 114 | }] 115 | } 116 | } 117 | }); 118 | const getWmsCapabilitiesByLayerSpy = jest.spyOn(CapabilitiesUtil, 119 | 'getWmsCapabilitiesByLayer').mockImplementation(mockImpl); 120 | 121 | const extent = await LayerUtil.getExtentForLayer(layer); 122 | 123 | expect(extent).toEqual([1, 2, 3, 4]); 124 | 125 | getWmsCapabilitiesByLayerSpy.mockRestore(); 126 | } 127 | ); 128 | 129 | it('returns the extent of the given layer for GetCapabilities request version 1.3.0 (single layer)', 130 | async () => { 131 | const layer = new OlLayerTile({ 132 | source: new OlSourceTileWMS({ 133 | url: 'https://ows.terrestris.de/osm-gray/service?', 134 | params: { 135 | LAYERS: 'OSM-WMS' 136 | } 137 | }), 138 | properties: { 139 | name: 'OSM-WMS' 140 | } 141 | }); 142 | 143 | const mockImpl = jest.fn(); 144 | mockImpl.mockReturnValue({ 145 | Capability: { 146 | Layer: { 147 | Layer: { 148 | Name: 'OSM-WMS', 149 | // eslint-disable-next-line camelcase 150 | EX_GeographicBoundingBox: { 151 | westBoundLongitude: 1, 152 | southBoundLatitude: 2, 153 | eastBoundLongitude: 3, 154 | northBoundLatitude: 4 155 | } 156 | } 157 | } 158 | } 159 | }); 160 | const getWmsCapabilitiesByLayerSpy = jest.spyOn(CapabilitiesUtil, 161 | 'getWmsCapabilitiesByLayer').mockImplementation(mockImpl); 162 | 163 | const extent = await LayerUtil.getExtentForLayer(layer); 164 | 165 | expect(extent).toEqual([1, 2, 3, 4]); 166 | 167 | getWmsCapabilitiesByLayerSpy.mockRestore(); 168 | } 169 | ); 170 | 171 | it('returns the extent of the given layer for GetCapabilities request version 1.1.1', async () => { 172 | const layer = new OlLayerTile({ 173 | source: new OlSourceTileWMS({ 174 | url: 'https://ows.terrestris.de/osm-gray/service?', 175 | params: { 176 | LAYERS: 'OSM-WMS', 177 | VERSION: '1.1.1' 178 | } 179 | }), 180 | properties: { 181 | name: 'OSM-WMS' 182 | } 183 | }); 184 | 185 | const mockImpl = jest.fn(); 186 | mockImpl.mockReturnValue({ 187 | Capability: { 188 | Layer: { 189 | Layer: [{ 190 | Name: 'OSM-WMS', 191 | // eslint-disable-next-line camelcase 192 | LatLonBoundingBox: { 193 | minx: 1, 194 | miny: 2, 195 | maxx: 3, 196 | maxy: 4 197 | } 198 | }] 199 | } 200 | } 201 | }); 202 | const getWmsCapabilitiesByLayerSpy = jest.spyOn(CapabilitiesUtil, 203 | 'getWmsCapabilitiesByLayer').mockImplementation(mockImpl); 204 | 205 | const extent = await LayerUtil.getExtentForLayer(layer); 206 | 207 | expect(extent).toEqual([1, 2, 3, 4]); 208 | 209 | getWmsCapabilitiesByLayerSpy.mockRestore(); 210 | }); 211 | }); 212 | 213 | describe('#mapOlLayerToInkmap', () => { 214 | it('exports WMS tile layer correctly', async () => { 215 | const layer = new OlLayerTile({ 216 | source: new OlSourceTileWMS({ 217 | url: 'https://ows.terrestris.de/osm-gray/service?', 218 | params: { 219 | LAYERS: 'OSM-WMS' 220 | } 221 | }), 222 | properties: { 223 | name: 'OSM-WMS layer' 224 | } 225 | }); 226 | 227 | const result = await LayerUtil.mapOlLayerToInkmap(layer) as InkmapWmsLayer; 228 | expect(result).toBeDefined(); 229 | expect(result.url).toEqual(layer?.getSource()?.getUrls()!.at(0)); 230 | expect(result.layerName).toEqual(layer?.getProperties()?.name); 231 | expect(result.type).toEqual('WMS'); 232 | expect(result.layer).toEqual(layer?.getSource()?.getParams().LAYERS); 233 | }); 234 | 235 | it('exports WMTS layers correctly', async () => { 236 | const layer3 = new OlLayerTile({ 237 | source: new OlSourceWMTS({ 238 | urls: ['https://ows.terrestris.de/osm-gray/service'], 239 | layer: 'test', 240 | matrixSet: 'test', 241 | tileGrid: new OlWMTSTileGrid({ 242 | matrixIds: [], 243 | resolutions: [], 244 | origin: [19, 9] 245 | }), 246 | style: 'default' 247 | }), 248 | properties: { 249 | name: 'OSM-WMS' 250 | } 251 | }); 252 | 253 | const result = await LayerUtil.mapOlLayerToInkmap(layer3) as InkmapWmtsLayer; 254 | expect(result).toBeDefined(); 255 | expect(result.url).toEqual(layer3?.getSource()?.getUrls()!.at(0)); 256 | expect(result.layerName).toEqual(layer3?.getProperties()?.name); 257 | expect(result.type).toEqual('WMTS'); 258 | expect(result.layer).toEqual(layer3?.getSource()?.getLayer()); 259 | }); 260 | }); 261 | 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /src/LayerUtil/LayerUtil.ts: -------------------------------------------------------------------------------- 1 | import OpenLayersParser from 'geostyler-openlayers-parser'; 2 | import { isNil } from 'lodash'; 3 | import _uniq from 'lodash/uniq'; 4 | import { Extent as OlExtent } from 'ol/extent'; 5 | import OlFormatGeoJSON from 'ol/format/GeoJSON'; 6 | import OlLayer from 'ol/layer/Layer'; 7 | import OlLayerVector from 'ol/layer/Vector'; 8 | import OlSourceImageWMS from 'ol/source/ImageWMS'; 9 | import OlSourceOSM from 'ol/source/OSM'; 10 | import OlSourceStadiaMaps from 'ol/source/StadiaMaps'; 11 | import OlSourceTileWMS from 'ol/source/TileWMS'; 12 | import OlSourceVector from 'ol/source/Vector'; 13 | import OlSourceWMTS from 'ol/source/WMTS'; 14 | 15 | import { StyleLike as OlStyleLike } from 'ol/style/Style'; 16 | 17 | import Logger from '@terrestris/base-util/dist/Logger'; 18 | import StringUtil from '@terrestris/base-util/dist/StringUtil/StringUtil'; 19 | 20 | import CapabilitiesUtil from '../CapabilitiesUtil/CapabilitiesUtil'; 21 | import { WmsLayer, WmtsLayer } from '../typeUtils/typeUtils'; 22 | 23 | import { InkmapGeoJsonLayer, InkmapLayer } from './InkmapTypes'; 24 | 25 | /** 26 | * Helper class for layer interaction. 27 | * 28 | * @class LayerUtil 29 | */ 30 | class LayerUtil { 31 | 32 | /** 33 | * Returns the configured URL of the given layer. 34 | * 35 | * @param { WmsLayer | WmtsLayer } layer The layer 36 | * to get the URL from. 37 | * @returns {string} The layer URL. 38 | */ 39 | static getLayerUrl = ( 40 | layer: WmsLayer | WmtsLayer 41 | ): string => { 42 | const layerSource = layer.getSource(); 43 | 44 | if (!layerSource) { 45 | return ''; 46 | } else if (layerSource instanceof OlSourceTileWMS) { 47 | return layerSource.getUrls()?.[0] ?? ''; 48 | } else if (layerSource instanceof OlSourceImageWMS) { 49 | return layerSource.getUrl() ?? ''; 50 | } else { 51 | return layerSource.getUrls()?.[0] ?? ''; 52 | } 53 | }; 54 | 55 | /** 56 | * Returns the extent of the given layer as defined in the 57 | * appropriate Capabilities document. 58 | * 59 | * @param {WmsLayer} layer 60 | * @param {RequestInit} fetchOpts Optional fetch options to make use of 61 | * while requesting the Capabilities. 62 | * @returns {Promise<[number, number, number, number]>} The extent of the layer. 63 | */ 64 | static async getExtentForLayer( 65 | layer: WmsLayer, 66 | fetchOpts: RequestInit = {} 67 | ): Promise { 68 | const capabilities = await CapabilitiesUtil.getWmsCapabilitiesByLayer(layer, fetchOpts); 69 | 70 | let layerNodes = capabilities?.Capability?.Layer?.Layer; 71 | 72 | if (!layerNodes) { 73 | throw new Error('Unexpected format of the Capabilities.'); 74 | } 75 | 76 | if (!Array.isArray(layerNodes)) { 77 | layerNodes = [layerNodes]; 78 | } 79 | 80 | const layerName = layer.getSource()?.getParams().LAYERS; 81 | const version = layer.getSource()?.getParams().VERSION || '1.3.0'; 82 | const layers = layerNodes.filter((l: any) => { 83 | return l.Name === layerName; 84 | }); 85 | 86 | if (!layers || layers.length === 0) { 87 | throw new Error('Could not find the desired layer in the Capabilities.'); 88 | } 89 | 90 | let extent; 91 | 92 | if (version === '1.3.0') { 93 | const { 94 | eastBoundLongitude, 95 | northBoundLatitude, 96 | southBoundLatitude, 97 | westBoundLongitude 98 | } = layers[0].EX_GeographicBoundingBox; 99 | extent = [ 100 | westBoundLongitude, 101 | southBoundLatitude, 102 | eastBoundLongitude, 103 | northBoundLatitude, 104 | ]; 105 | } else if (version === '1.1.0' || version === '1.1.1') { 106 | const { 107 | minx, 108 | miny, 109 | maxx, 110 | maxy 111 | } = layers[0].LatLonBoundingBox; 112 | extent = [minx, miny, maxx, maxy]; 113 | } 114 | 115 | if (!extent || extent?.length !== 4) { 116 | throw new Error('No extent set in the Capabilities.'); 117 | } 118 | 119 | return extent; 120 | } 121 | 122 | /** 123 | * Converts a given OpenLayers layer to an inkmap layer spec. 124 | * 125 | */ 126 | static async mapOlLayerToInkmap( 127 | olLayer: OlLayer 128 | ): Promise { 129 | const source = olLayer.getSource(); 130 | if (!olLayer.getVisible()) { 131 | // do not include invisible layers 132 | return Promise.reject(); 133 | } 134 | const opacity = olLayer.getOpacity(); 135 | const legendUrl = olLayer.get('legendUrl'); 136 | const layerName = olLayer.get('name'); 137 | let time; 138 | if (source instanceof OlSourceTileWMS || source instanceof OlSourceImageWMS) { 139 | time = source.getParams().TIME; 140 | } 141 | 142 | // todo: introduce config object which hold possible additional configurations 143 | const attributionString = LayerUtil.getLayerAttributionsText(olLayer, ' ,', true); 144 | 145 | if (source instanceof OlSourceTileWMS) { 146 | return { 147 | type: 'WMS', 148 | url: source.getUrls()?.[0] ?? '', 149 | opacity, 150 | attribution: attributionString, 151 | layer: source.getParams()?.LAYERS, 152 | tiled: true, 153 | legendUrl, 154 | layerName, 155 | customParams: { 156 | ...(time && { time }) 157 | } 158 | }; 159 | } else if (source instanceof OlSourceImageWMS) { 160 | return { 161 | type: 'WMS', 162 | url: source.getUrl() ?? '', 163 | opacity, 164 | attribution: attributionString, 165 | layer: source.getParams()?.LAYERS, 166 | tiled: false, 167 | legendUrl, 168 | layerName, 169 | customParams: { 170 | ...(time && { time }) 171 | } 172 | }; 173 | } else if (source instanceof OlSourceWMTS) { 174 | const olTileGrid = source.getTileGrid(); 175 | const resolutions = olTileGrid?.getResolutions(); 176 | const matrixIds = resolutions?.map((res: number, idx: number) => idx); 177 | 178 | const tileGrid = { 179 | resolutions: olTileGrid?.getResolutions(), 180 | extent: olTileGrid?.getExtent(), 181 | matrixIds: matrixIds 182 | }; 183 | 184 | return { 185 | type: 'WMTS', 186 | requestEncoding: source.getRequestEncoding(), 187 | url: source.getUrls()?.[0] ?? '', 188 | layer: source.getLayer(), 189 | projection: source.getProjection()?.getCode(), 190 | matrixSet: source.getMatrixSet(), 191 | tileGrid, 192 | format: source.getFormat(), 193 | opacity, 194 | attribution: attributionString, 195 | legendUrl, 196 | layerName 197 | }; 198 | } else if (source instanceof OlSourceOSM) { 199 | return { 200 | type: 'XYZ', 201 | url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png', 202 | opacity, 203 | attribution: '© OpenStreetMap (www.openstreetmap.org)', 204 | tiled: true, 205 | legendUrl, 206 | layerName 207 | }; 208 | } else if (source instanceof OlSourceStadiaMaps) { 209 | const urls = source.getUrls(); 210 | if (isNil(urls)) { 211 | return Promise.reject(); 212 | } 213 | return { 214 | type: 'XYZ', 215 | url: urls[0], 216 | opacity, 217 | attribution: attributionString, 218 | tiled: true, 219 | legendUrl, 220 | layerName 221 | }; 222 | } else if (source instanceof OlSourceVector) { 223 | const geojson = new OlFormatGeoJSON().writeFeaturesObject(source.getFeatures()); 224 | const parser = new OpenLayersParser(); 225 | const geojsonLayerConfig: InkmapGeoJsonLayer = { 226 | type: 'GeoJSON', 227 | geojson, 228 | attribution: attributionString, 229 | style: undefined, 230 | legendUrl, 231 | layerName 232 | }; 233 | 234 | // should be a vector layer since source type is OlSourceVector 235 | const olVectorLayer = olLayer as OlLayerVector; 236 | const olStyle = olVectorLayer.getStyle() as OlStyleLike; 237 | 238 | // todo: support style function / different styles per feature 239 | // const styles = source.getFeatures()?.map(f => f.getStyle()); 240 | 241 | if (olStyle) { 242 | const gsStyle = await parser.readStyle(olStyle); 243 | if (gsStyle.errors) { 244 | Logger.error('Geostyler errors: ', gsStyle.errors); 245 | } 246 | if (gsStyle.warnings) { 247 | Logger.warn('Geostyler warnings: ', gsStyle.warnings); 248 | } 249 | if (gsStyle.unsupportedProperties) { 250 | Logger.warn('Detected unsupported style properties: ', gsStyle.unsupportedProperties); 251 | } 252 | if (gsStyle.output) { 253 | geojsonLayerConfig.style = gsStyle.output; 254 | } 255 | } 256 | return geojsonLayerConfig; 257 | } 258 | return Promise.reject(); 259 | } 260 | 261 | /** 262 | * Returns all attributions as text joined by a separator. 263 | * 264 | * @param {OlLayer} layer The layer to get the attributions from. 265 | * @param {string} separator The separator separating multiple attributions. 266 | * @param {boolean} removeDuplicates Whether to remove duplicated attribution 267 | * strings or not. 268 | * @returns {string} The attributions. 269 | */ 270 | static getLayerAttributionsText = ( 271 | layer: OlLayer, 272 | separator: string = ', ', 273 | removeDuplicates: boolean = false 274 | ): string => { 275 | if (isNil(layer)) { 276 | return ''; 277 | } 278 | const attributionsFn = layer.getSource()?.getAttributions(); 279 | // @ts-expect-error attributionsFn may expect correct ViewStateLayerStateExtent object 280 | let attributions = attributionsFn ? attributionsFn(undefined) : null; 281 | 282 | let attributionString; 283 | if (Array.isArray(attributions)) { 284 | if (removeDuplicates) { 285 | attributions = _uniq(attributions); 286 | } 287 | attributionString = attributions.map(StringUtil.stripHTMLTags).join(separator); 288 | } else { 289 | attributionString = attributions ? StringUtil.stripHTMLTags(attributions) : ''; 290 | } 291 | return attributionString; 292 | }; 293 | } 294 | 295 | export default LayerUtil; 296 | -------------------------------------------------------------------------------- /src/MapUtil/MapUtil.ts: -------------------------------------------------------------------------------- 1 | import { isNil } from 'lodash'; 2 | import findIndex from 'lodash/findIndex'; 3 | import _isFinite from 'lodash/isFinite'; 4 | import _isNil from 'lodash/isNil'; 5 | import _isString from 'lodash/isString'; 6 | import { Coordinate as OlCoordinate } from 'ol/coordinate'; 7 | import { Extent as OlExtent } from 'ol/extent'; 8 | import OlFeature from 'ol/Feature'; 9 | import OlGeometry from 'ol/geom/Geometry'; 10 | import OlGeomGeometryCollection from 'ol/geom/GeometryCollection'; 11 | import OlBaseLayer from 'ol/layer/Base'; 12 | import OlLayerGroup from 'ol/layer/Group'; 13 | import OlLayer from 'ol/layer/Layer'; 14 | import OlMap from 'ol/Map'; 15 | import { toLonLat } from 'ol/proj'; 16 | import { METERS_PER_UNIT, Units } from 'ol/proj/Units'; 17 | import OlSourceTileWMS from 'ol/source/TileWMS'; 18 | import OlSourceWMTS from 'ol/source/WMTS'; 19 | import { getUid } from 'ol/util'; 20 | 21 | import logger from '@terrestris/base-util/dist/Logger'; 22 | import UrlUtil from '@terrestris/base-util/dist/UrlUtil/UrlUtil'; 23 | 24 | import FeatureUtil from '../FeatureUtil/FeatureUtil'; 25 | import LayerUtil from '../LayerUtil/LayerUtil'; 26 | import { isWmsLayer, WmsLayer, WmtsLayer } from '../typeUtils/typeUtils'; 27 | 28 | export interface LayerPositionInfo { 29 | position?: number; 30 | groupLayer?: OlLayerGroup; 31 | } 32 | 33 | /** 34 | * Helper class for the OpenLayers map. 35 | * 36 | * @class 37 | */ 38 | export class MapUtil { 39 | 40 | /** 41 | * Returns all interactions by the given name of a map. 42 | * 43 | * @param {OlMap} map The map to use for lookup. 44 | * @param {string} name The name of the interaction to look for. 45 | * @return The list of result interactions. 46 | */ 47 | static getInteractionsByName(map: OlMap, name: string) { 48 | return map.getInteractions() 49 | .getArray() 50 | .filter(interaction => interaction.get('name') === name); 51 | } 52 | 53 | /** 54 | * Calculates the appropriate map resolution for a given scale in the given 55 | * units. 56 | * 57 | * See: https://gis.stackexchange.com/questions/158435/ 58 | * how-to-get-current-scale-in-openlayers-3 59 | * 60 | * @method 61 | * @param {number|string} scale The input scale to calculate the appropriate 62 | * resolution for. 63 | * @param {Units} units The units to use for calculation (m or degrees). 64 | * @return {number} The calculated resolution. 65 | */ 66 | static getResolutionForScale(scale: number | string, units: Units): number | undefined { 67 | const dpi = 25.4 / 0.28; 68 | let mpu; 69 | if (units === 'm') { 70 | mpu = METERS_PER_UNIT.m; 71 | } else if (units === 'degrees') { 72 | mpu = METERS_PER_UNIT.degrees; 73 | } else { 74 | logger.info('Currently only \'degrees\' and \'m\' units are supported.'); 75 | return undefined; 76 | } 77 | const inchesPerMeter = 39.37; 78 | 79 | return (_isString(scale) ? parseFloat(scale) : scale) / (mpu * inchesPerMeter * dpi); 80 | } 81 | 82 | /** 83 | * Returns the appropriate scale for the given resolution and units. 84 | * 85 | * @method 86 | * @param {number|string} resolution The resolutions to calculate the scale for. 87 | * @param {string} units The units the resolution is based on, typically 88 | * either 'm' or 'degrees'. 89 | * @return {number} The appropriate scale. 90 | */ 91 | static getScaleForResolution(resolution: number | string, units: Units): number | undefined { 92 | const dpi = 25.4 / 0.28; 93 | let mpu; 94 | if (units === 'm') { 95 | mpu = METERS_PER_UNIT.m; 96 | } else if (units === 'degrees') { 97 | mpu = METERS_PER_UNIT.degrees; 98 | } else { 99 | logger.info('Currently only \'degrees\' and \'m\' units are supported.'); 100 | return undefined; 101 | } 102 | const inchesPerMeter = 39.37; 103 | 104 | return (_isString(resolution) ? parseFloat(resolution) : resolution) * mpu * inchesPerMeter * dpi; 105 | } 106 | 107 | /** 108 | * Returns all layers of a collection. Even the hidden ones. 109 | * 110 | * @param {OlMap | OlLayerGroup} collection The collection to get the layers 111 | * from. This can be an ol.layer.Group 112 | * or an ol.Map. 113 | * @param {(olLayer: OlBaseLayer) => boolean} [filter] A filter function that receives the layer. 114 | * If it returns true it will be included in the 115 | * returned layers. 116 | * @return {OlBaseLayer} An array of all Layers. 117 | */ 118 | static getAllLayers( 119 | collection: OlMap | OlLayerGroup, 120 | filter: (olLayer: OlBaseLayer) => boolean = () => true 121 | ): OlBaseLayer[] { 122 | const layers = collection.getLayers().getArray(); 123 | return layers.flatMap((layer: OlBaseLayer) => { 124 | let ll: OlBaseLayer[] = []; 125 | if (layer instanceof OlLayerGroup) { 126 | ll = MapUtil.getAllLayers(layer, filter); 127 | } 128 | if (filter(layer)) { 129 | ll.push(layer); 130 | } 131 | return ll; 132 | }); 133 | } 134 | 135 | /** 136 | * Get a layer by its key (ol_uid). 137 | * 138 | * @param {OlMap} map The map to use for lookup. 139 | * @param olUid 140 | * @return {OlBaseLayer|undefined} The layer. 141 | */ 142 | static getLayerByOlUid = (map: OlMap, olUid: string): OlBaseLayer | undefined => { 143 | return MapUtil.getAllLayers(map, l => olUid === getUid(l).toString())[0]; 144 | }; 145 | 146 | /** 147 | * Returns the layer from the provided map by the given name. 148 | * 149 | * @param {OlMap} map The map to use for lookup. 150 | * @param {string} name The name to get the layer by. 151 | * @return {OlBaseLayer} The result layer or undefined if the layer could not 152 | * be found. 153 | */ 154 | static getLayerByName(map: OlMap, name: string): OlBaseLayer { 155 | return MapUtil.getAllLayers(map, l => l.get('name') === name)[0]; 156 | } 157 | 158 | /** 159 | * Returns the layer from the provided map by the given name 160 | * (parameter LAYERS). 161 | * 162 | * @param {OlMap} map The map to use for lookup. 163 | * @param {string} name The name to get the layer by. 164 | * @return {WmsLayer|undefined} 165 | * The result layer or undefined if the layer could not be found. 166 | */ 167 | static getLayerByNameParam( 168 | map: OlMap, 169 | name: string 170 | ): WmsLayer | undefined { 171 | const layer = MapUtil.getAllLayers(map, l => { 172 | return isWmsLayer(l) && l.getSource()?.getParams().LAYERS === name; 173 | })[0]; 174 | return layer as WmsLayer; 175 | } 176 | 177 | /** 178 | * Returns the layer from the provided map by the given feature. 179 | * 180 | * @param {OlMap} map The map to use for lookup. 181 | * @param {OlFeature} feature The feature to get the layer by. 182 | * @param {string[]} namespaces list of supported GeoServer namespaces. 183 | * @return {OlBaseLayer|undefined} The result layer or undefined if the layer could not 184 | * be found. 185 | */ 186 | static getLayerByFeature(map: OlMap, feature: OlFeature, namespaces: string[]): OlBaseLayer | undefined { 187 | const featureTypeName = FeatureUtil.getFeatureTypeName(feature); 188 | 189 | for (const namespace of namespaces) { 190 | const qualifiedFeatureTypeName = `${namespace}:${featureTypeName}`; 191 | const layer = MapUtil.getLayerByNameParam(map, qualifiedFeatureTypeName); 192 | if (layer) { 193 | return layer; 194 | } 195 | } 196 | 197 | return undefined; 198 | } 199 | 200 | /** 201 | * Returns all layers of the specified layer group recursively. 202 | * 203 | * @param {OlMap} map The map to use for lookup. 204 | * @param {OlLayerGroup} layerGroup The group to flatten. 205 | * @return {OlBaseLayer} The (flattened) layers from the group 206 | */ 207 | static getLayersByGroup(map: OlMap, layerGroup: OlLayerGroup): OlBaseLayer[] { 208 | return layerGroup.getLayers().getArray() 209 | .flatMap((layer: OlBaseLayer) => { 210 | if (layer instanceof OlLayerGroup) { 211 | return MapUtil.getLayersByGroup(map, layer); 212 | } else { 213 | return [layer]; 214 | } 215 | }); 216 | } 217 | 218 | /** 219 | * Returns the list of layers matching the given pair of properties. 220 | * 221 | * @param {OlMap} map The map to use for lookup. 222 | * @param {string} key The property key. 223 | * @param {any} value The property value. 224 | * 225 | * @return {OlBaseLayer[]} The array of matching layers. 226 | */ 227 | static getLayersByProperty(map: OlMap, key: string, value: any): OlBaseLayer[] { 228 | return MapUtil.getAllLayers(map, l => l.get(key) === value); 229 | } 230 | 231 | /** 232 | * Get information about the LayerPosition in the tree. 233 | * 234 | * @param {OlBaseLayer} layer The layer to get the information. 235 | * @param {OlLayerGroup|OlMap} groupLayerOrMap The groupLayer or map 236 | * containing the layer. 237 | * @return {{ 238 | * groupLayer: OlLayerGroup, 239 | * position: number 240 | * }} The groupLayer containing the layer and the position of the layer in the collection. 241 | */ 242 | static getLayerPositionInfo(layer: OlBaseLayer, groupLayerOrMap: OlMap | OlLayerGroup): LayerPositionInfo { 243 | const groupLayer = groupLayerOrMap instanceof OlLayerGroup 244 | ? groupLayerOrMap 245 | : groupLayerOrMap.getLayerGroup(); 246 | const layers = groupLayer.getLayers().getArray(); 247 | let info: LayerPositionInfo = {}; 248 | 249 | if (layers.indexOf(layer) < 0) { 250 | layers.forEach((childLayer) => { 251 | if (childLayer instanceof OlLayerGroup && !info.groupLayer) { 252 | info = MapUtil.getLayerPositionInfo(layer, childLayer); 253 | } 254 | }); 255 | } else { 256 | info.position = layers.indexOf(layer); 257 | info.groupLayer = groupLayer; 258 | } 259 | return info; 260 | } 261 | 262 | /** 263 | * Get the getlegendGraphic url of a layer. Designed for geoserver. 264 | * Currently supported Sources: 265 | * - ol.source.TileWms (with url configured) 266 | * - ol.source.ImageWms (with url configured) 267 | * - ol.source.WMTS (with url configured) 268 | * 269 | * @param {WmsLayer | WmtsLayer} layer The layer that you want to have a legendUrl for. 270 | * @param {Object} extraParams 271 | * @return {string} The getLegendGraphicUrl. 272 | */ 273 | static getLegendGraphicUrl( 274 | layer: WmsLayer | WmtsLayer, 275 | extraParams: Record = {} 276 | ): string { 277 | const source = layer.getSource(); 278 | 279 | if (!source) { 280 | throw new Error('Layer has no source.'); 281 | } 282 | 283 | if (source instanceof OlSourceWMTS) { 284 | return source.get('legendUrl') ? source.get('legendUrl') : ''; 285 | } else { 286 | const url = 287 | (source instanceof OlSourceTileWMS 288 | ? source.getUrls()?.[0] 289 | : source.getUrl()) ?? ''; 290 | const params = { 291 | LAYER: source.getParams().LAYERS, 292 | VERSION: '1.3.0', 293 | SERVICE: 'WMS', 294 | REQUEST: 'GetLegendGraphic', 295 | FORMAT: 'image/png' 296 | }; 297 | 298 | const queryString = UrlUtil.objectToRequestString( 299 | Object.assign(params, extraParams) 300 | ); 301 | 302 | return /\?/.test(url) ? `${url}&${queryString}` : `${url}?${queryString}`; 303 | } 304 | } 305 | 306 | /** 307 | * Checks whether the resolution of the passed map's view lies inside of the 308 | * min- and max-resolution of the passed layer, e.g. whether the layer should 309 | * be displayed at the current map view resolution. 310 | * 311 | * @param {OlBaseLayer} layer The layer to check. 312 | * @param {OlMap} map The map to get the view resolution for comparison 313 | * from. 314 | * @return {boolean} Whether the resolution of the passed map's view lies 315 | * inside of the min- and max-resolution of the passed layer, e.g. whether 316 | * the layer should be displayed at the current map view resolution. Will 317 | * be `false` when no `layer` or no `map` is passed or if the view of the 318 | * map is falsy or does not have a resolution (yet). 319 | */ 320 | static layerInResolutionRange(layer?: OlBaseLayer, map?: OlMap): boolean { 321 | const mapView = map?.getView(); 322 | const currentRes = mapView?.getResolution(); 323 | if (isNil(layer) || !mapView || !currentRes) { 324 | // It is questionable what we should return in this case, I opted for 325 | // false, since we cannot sanely determine a correct answer. 326 | return false; 327 | } 328 | const layerMinRes = layer.getMinResolution(); // default: 0 if unset 329 | const layerMaxRes = layer.getMaxResolution(); // default: Infinity if unset 330 | // minimum resolution is inclusive, maximum resolution exclusive 331 | return currentRes >= layerMinRes && currentRes < layerMaxRes; 332 | } 333 | 334 | 335 | /** 336 | * Rounds a scale number depending on its size. 337 | * 338 | * @param {number} scale The exact scale 339 | * @return {number} The roundedScale 340 | */ 341 | static roundScale(scale: number): number { 342 | if (scale < 100) { 343 | return Math.round(scale); 344 | } 345 | if (scale >= 100 && scale < 10000 ) { 346 | return Math.round(scale / 10) * 10; 347 | } 348 | if (scale >= 10000 && scale < 1000000 ) { 349 | return Math.round(scale / 100) * 100; 350 | } 351 | // scale >= 1000000 352 | return Math.round(scale / 1000) * 1000; 353 | } 354 | 355 | /** 356 | * Returns the appropriate zoom level for the given scale and units. 357 | 358 | * @method 359 | * @param {number} scale Map scale to get the zoom for. 360 | * @param {number[]} resolutions Resolutions array. 361 | * @param {string} units The units the resolutions are based on, typically 362 | * either 'm' or 'degrees'. Default is 'm'. 363 | * 364 | * @return {number} Determined zoom level for the given scale. 365 | */ 366 | static getZoomForScale(scale: number, resolutions: number[], units: Units = 'm'): number { 367 | if (Number.isNaN(Number(scale))) { 368 | return 0; 369 | } 370 | 371 | if (scale < 0) { 372 | return 0; 373 | } 374 | 375 | const calculatedResolution = MapUtil.getResolutionForScale(scale, units); 376 | if (!_isFinite(calculatedResolution)) { 377 | throw new Error('Can not determine unit / scale from map'); 378 | } 379 | const closestVal = resolutions.reduce((prev, curr) => { 380 | return Math.abs(curr - calculatedResolution!) < Math.abs(prev - calculatedResolution!) 381 | ? curr 382 | : prev; 383 | }); 384 | return findIndex(resolutions, function (o) { 385 | return Math.abs(o - closestVal) <= 1e-10; 386 | }); 387 | } 388 | 389 | /** 390 | * Fits the map's view to the extent of the passed features. 391 | * 392 | * @param {OlMap} map The map to get the view from. 393 | * @param {OlFeature[]} features The features to zoom to. 394 | */ 395 | static zoomToFeatures(map: OlMap, features: OlFeature[]) { 396 | const featGeometries = FeatureUtil.mapFeaturesToGeometries(features); 397 | 398 | if (featGeometries.length > 0) { 399 | const geomCollection = new OlGeomGeometryCollection(featGeometries); 400 | map.getView().fit(geomCollection.getExtent()); 401 | } 402 | } 403 | 404 | /** 405 | * Checks if the given layer is visible for the given resolution. 406 | * 407 | * @param {OlBaseLayer} layer The layer. 408 | * @param {number} resolution The resolution of the map 409 | */ 410 | static isInScaleRange(layer: OlBaseLayer, resolution: number) { 411 | return resolution >= layer?.get('minResolution') 412 | && resolution < layer?.get('maxResolution'); 413 | } 414 | 415 | /** 416 | * Converts a given OpenLayers map to an inkmap spec. Only returns options which can be 417 | * derived from a map (center, scale, projection, layers). 418 | * 419 | * @param {OlMap} olMap The ol map. 420 | * 421 | * @return {Promise>} Promise of the inmkap print spec. 422 | */ 423 | static async generatePrintConfig(olMap: OlMap) { 424 | const unit = olMap.getView().getProjection().getUnits() as Units; 425 | const resolution = olMap.getView().getResolution(); 426 | const projection = olMap.getView().getProjection().getCode(); 427 | if (resolution === undefined) { 428 | throw new Error('Can not determine resolution from map'); 429 | } 430 | 431 | const scale = MapUtil.getScaleForResolution(resolution, unit); 432 | const center = olMap?.getView().getCenter(); 433 | if (!unit || !center || !_isFinite(scale)) { 434 | throw new Error('Can not determine unit / scale from map'); 435 | } 436 | const centerLonLat = toLonLat(center, projection); 437 | 438 | const layerPromises = olMap.getAllLayers() 439 | .map(LayerUtil.mapOlLayerToInkmap); 440 | 441 | const responses = await Promise.allSettled(layerPromises); 442 | const layers = responses 443 | .filter(r => r !== null && r.status === 'fulfilled') 444 | .map((l: any) => l.value); 445 | const rejectedLayers = responses 446 | .filter(r => r && r.status === 'rejected'); 447 | rejectedLayers.forEach(r => logger.warn( 448 | 'A layer could not be printed, maybe its invisible or unsupported: ', r)); 449 | // ignore typecheck because responses.filter(l => l !== null) is not recognized properly 450 | return { 451 | layers: layers, 452 | center: centerLonLat, 453 | scale: scale, 454 | projection: projection 455 | }; 456 | } 457 | 458 | /** 459 | * Set visibility for layer having names (if in map) 460 | * @param {OlMap} olMap The OpenLayers map. 461 | * @param {string[]} layerNames An array of layer names (feature type names can also be used) 462 | * @param {boolean} visible if layer should be visible or not 463 | */ 464 | static setVisibilityForLayers(olMap: OlMap, layerNames: string[], visible: boolean) { 465 | if (_isNil(olMap)) { 466 | return; 467 | } 468 | if (_isNil(layerNames) || layerNames.length === 0) { 469 | return; 470 | } 471 | layerNames?.forEach(layerName => { 472 | let layer = MapUtil.getLayerByName(olMap, layerName) as OlLayer; 473 | if (_isNil(layer)) { 474 | layer = MapUtil.getLayerByNameParam(olMap, layerName) as OlLayer; 475 | } 476 | layer?.setVisible(visible); 477 | }); 478 | } 479 | 480 | static calculateScaleAndCenterForExtent(olMap: OlMap, extent: OlExtent): { 481 | center: OlCoordinate; 482 | scale: number; 483 | } | undefined { 484 | if (_isNil(olMap) || _isNil(extent) || extent.length !== 4) { 485 | return; 486 | } 487 | const view = olMap.getView(); 488 | const resolution = view.getResolutionForExtent(extent); 489 | const unit = view.getProjection().getUnits() as Units; 490 | const scale = MapUtil.getScaleForResolution(resolution, unit); 491 | const center: OlCoordinate = [ 492 | (extent[0] + extent[2]) / 2, 493 | (extent[1] + extent[3]) / 2 494 | ]; 495 | if (_isNil(unit) || _isNil(center) || _isNil(scale) || !_isFinite(scale)) { 496 | logger.error('Can not determine unit / scale from map'); 497 | return; 498 | } 499 | return { 500 | center, 501 | scale 502 | }; 503 | } 504 | 505 | } 506 | 507 | export default MapUtil; 508 | -------------------------------------------------------------------------------- /src/MeasureUtil/MeasureUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | 3 | import OlGeomCircle from 'ol/geom/Circle'; 4 | import OlGeomLineString from 'ol/geom/LineString'; 5 | import OlGeomPolygon from 'ol/geom/Polygon'; 6 | import OlMap from 'ol/Map'; 7 | import OlView from 'ol/View'; 8 | 9 | import { 10 | MeasureUtil, 11 | } from '../index'; 12 | import TestUtil from '../TestUtil'; 13 | 14 | describe('MeasureUtil', () => { 15 | 16 | const smallPolyCoords = [ 17 | [0, 0], 18 | [0, 10], 19 | [10, 10], 20 | [10, 0], 21 | [0, 0] 22 | ]; 23 | let map: OlMap; 24 | 25 | describe('Basics', () => { 26 | it('is defined', () => { 27 | expect(MeasureUtil).toBeDefined(); 28 | }); 29 | }); 30 | 31 | describe('Static methods', () => { 32 | 33 | describe('#getLength', () => { 34 | it('is defined', () => { 35 | expect(MeasureUtil.getLength).toBeDefined(); 36 | }); 37 | it('get the length of a line as expected', () => { 38 | const start = [0, 0]; 39 | const end = [0, 100]; 40 | const end2 = [0, 100550]; 41 | 42 | const shortLine = new OlGeomLineString([start, end]); 43 | const longLine = new OlGeomLineString([start, end2]); 44 | 45 | map = TestUtil.createMap(); 46 | 47 | const expectedShortLength = MeasureUtil.getLength(shortLine, map); 48 | const expectedLongLength = MeasureUtil.getLength(longLine, map); 49 | 50 | expect(expectedShortLength).toBeCloseTo(99.88824008937313, 4); 51 | expect(expectedLongLength).toBeCloseTo(100433.46540039503, 4); 52 | 53 | TestUtil.removeMap(map); 54 | }); 55 | }); 56 | 57 | describe('#formatLength', () => { 58 | it('is defined', () => { 59 | expect(MeasureUtil.formatLength).toBeDefined(); 60 | }); 61 | it('formats the length of a line as expected', () => { 62 | const start = [0, 0]; 63 | const end = [0, 100]; 64 | const end2 = [0, 100550]; 65 | const end3 = [0, 0.1]; 66 | 67 | const shortLine = new OlGeomLineString([start, end]); 68 | const longLine = new OlGeomLineString([start, end2]); 69 | const veryShortLine = new OlGeomLineString([start, end3]); 70 | 71 | map = TestUtil.createMap(); 72 | 73 | const expectedShortLength = MeasureUtil.formatLength(shortLine, map, 2); 74 | const expectedLongLength = MeasureUtil.formatLength(longLine, map, 2); 75 | const expectedVeryShortLength = MeasureUtil.formatLength(veryShortLine, map, 2); 76 | 77 | expect(expectedShortLength).toBe('99.89 m'); 78 | expect(expectedLongLength).toBe('100.43 km'); 79 | expect(expectedVeryShortLength).toBe('99.89 mm'); 80 | 81 | TestUtil.removeMap(map); 82 | }); 83 | }); 84 | 85 | describe('#getArea', () => { 86 | it('is defined', () => { 87 | expect(MeasureUtil.getArea).toBeDefined(); 88 | }); 89 | it('get the area of a polygon as expected', () => { 90 | const bigPolyCoords = smallPolyCoords.map(coord => [coord[0] * 100, coord[1] * 100]); 91 | 92 | const smallPoly = new OlGeomPolygon([smallPolyCoords]); 93 | const bigPoly = new OlGeomPolygon([bigPolyCoords]); 94 | 95 | map = TestUtil.createMap(); 96 | 97 | const expectedSmallArea = MeasureUtil.getArea(smallPoly, map); 98 | const expectedBigArea = MeasureUtil.getArea(bigPoly, map); 99 | 100 | expect(expectedSmallArea).toBe(99.7766050826797); 101 | expect(expectedBigArea).toBe(997766.042705488); 102 | 103 | TestUtil.removeMap(map); 104 | }); 105 | }); 106 | 107 | describe('#formatArea', () => { 108 | it('is defined', () => { 109 | expect(MeasureUtil.formatArea).toBeDefined(); 110 | }); 111 | it('formats the area of a polygon as expected', () => { 112 | const bigPolyCoords = smallPolyCoords.map(coord => [coord[0] * 100, coord[1] * 100]); 113 | const verySmallPolyCoords = smallPolyCoords.map(coord => [coord[0] / 100, coord[1] / 100]); 114 | 115 | const smallPoly = new OlGeomPolygon([smallPolyCoords]); 116 | const bigPoly = new OlGeomPolygon([bigPolyCoords]); 117 | const verySmallPoly = new OlGeomPolygon([verySmallPolyCoords]); 118 | 119 | map = TestUtil.createMap(); 120 | 121 | const expectedSmallArea = MeasureUtil.formatArea(smallPoly, map, 2); 122 | const expectedBigArea = MeasureUtil.formatArea(bigPoly, map, 2); 123 | const expectedVerySmallArea = MeasureUtil.formatArea(verySmallPoly, map, 2); 124 | 125 | expect(expectedSmallArea).toBe('99.78 m2'); 126 | expect(expectedBigArea).toBe('1 km2'); 127 | expect(expectedVerySmallArea).toBe('9977.66 mm2'); 128 | 129 | TestUtil.removeMap(map); 130 | }); 131 | }); 132 | 133 | describe('#angle', () => { 134 | it('is defined', () => { 135 | expect(MeasureUtil.angle).toBeDefined(); 136 | }); 137 | it('calculates the angle in deegrees', function() { 138 | const start = [0, 0]; 139 | const ends = [ 140 | [1, 0], // east, 3 o'clock 141 | [1, -1], // south-east, between 4 and 5 o'clock 142 | [0, -1], // south, 6 o'clock 143 | [-1, -1], // south-west, between 7 and 8 o'clock 144 | [-1, 0], // west, 9 o'clock 145 | [-1, 1], // north-west, between 10 and 11 o'clock 146 | [0, 1], // north, 12 o'clock 147 | [1, 1] // north-east, between 1 and 2 o'clock 148 | ]; 149 | const expectedAngles = [ 150 | 180, 151 | 135, 152 | 90, 153 | 45, 154 | 0, 155 | -45, 156 | -90, 157 | -135 158 | ]; 159 | 160 | expect(ends.length).toBe(expectedAngles.length); 161 | 162 | ends.forEach((end, index) => { 163 | const got = MeasureUtil.angle(start, end); 164 | expect(got).toBe(expectedAngles[index]); 165 | }); 166 | }); 167 | }); 168 | 169 | describe('#angle360', () => { 170 | it('is defined', () => { 171 | expect(MeasureUtil.angle360).toBeDefined(); 172 | }); 173 | it('calculates the angle in deegrees ranged from 0° and 360°', function() { 174 | const start = [0, 0]; 175 | const ends = [ 176 | [1, 0], // east, 3 o'clock 177 | [1, -1], // south-east, between 4 and 5 o'clock 178 | [0, -1], // south, 6 o'clock 179 | [-1, -1], // south-west, between 7 and 8 o'clock 180 | [-1, 0], // west, 9 o'clock 181 | [-1, 1], // north-west, between 10 and 11 o'clock 182 | [0, 1], // north, 12 o'clock 183 | [1, 1] // north-east, between 1 and 2 o'clock 184 | ]; 185 | const expectedAngles = [ 186 | 180, 187 | 135, 188 | 90, 189 | 45, 190 | 0, 191 | 315, 192 | 270, 193 | 225 194 | ]; 195 | 196 | expect(ends.length).toBe(expectedAngles.length); 197 | 198 | ends.forEach((end, index) => { 199 | const got = MeasureUtil.angle360(start, end); 200 | expect(got).toBe(expectedAngles[index]); 201 | }); 202 | }); 203 | }); 204 | 205 | describe('#makeClockwise', () => { 206 | it('is defined', () => { 207 | expect(MeasureUtil.makeClockwise).toBeDefined(); 208 | }); 209 | it('returns a clockwised version of an angle', function() { 210 | expect(MeasureUtil.makeClockwise(0)).toBe(360); 211 | expect(MeasureUtil.makeClockwise(45)).toBe(315); 212 | expect(MeasureUtil.makeClockwise(90)).toBe(270); 213 | expect(MeasureUtil.makeClockwise(135)).toBe(225); 214 | expect(MeasureUtil.makeClockwise(180)).toBe(180); 215 | expect(MeasureUtil.makeClockwise(225)).toBe(135); 216 | expect(MeasureUtil.makeClockwise(270)).toBe(90); 217 | expect(MeasureUtil.makeClockwise(315)).toBe(45); 218 | expect(MeasureUtil.makeClockwise(360)).toBe(0); 219 | }); 220 | }); 221 | 222 | describe('#makeZeroDegreesAtNorth', () => { 223 | it('is defined', () => { 224 | expect(MeasureUtil.makeZeroDegreesAtNorth).toBeDefined(); 225 | }); 226 | it('shifts a calculates the angle so 0° is in the north', function() { 227 | const start = [0, 0]; 228 | const ends = [ 229 | [1, 0], // east, 3 o'clock 230 | [1, -1], // south-east, between 4 and 5 o'clock 231 | [0, -1], // south, 6 o'clock 232 | [-1, -1], // south-west, between 7 and 8 o'clock 233 | [-1, 0], // west, 9 o'clock 234 | [-1, 1], // north-west, between 10 and 11 o'clock 235 | [0, 1], // north, 12 o'clock 236 | [1, 1] // north-east, between 1 and 2 o'clock 237 | ]; 238 | const expectedAngles = [ 239 | 270, 240 | 225, 241 | 180, 242 | 135, 243 | 90, 244 | 45, 245 | 360, // also 0 246 | 315 247 | ]; 248 | 249 | expect(ends.length).toBe(expectedAngles.length); 250 | 251 | ends.forEach((end, index) => { 252 | const angle = MeasureUtil.angle360(start, end); 253 | const got = MeasureUtil.makeZeroDegreesAtNorth(angle); 254 | expect(got).toBe(expectedAngles[index]); 255 | }); 256 | }); 257 | }); 258 | 259 | describe('#formatAngle', () => { 260 | it('is defined', () => { 261 | expect(MeasureUtil.formatAngle).toBeDefined(); 262 | }); 263 | it('formats the angle of a multiline as expected', () => { 264 | const start = [0, 0]; 265 | const ends = [ 266 | [1, 0], // east, 3 o'clock 267 | [1, -1], // south-east, between 4 and 5 o'clock 268 | [0, -1], // south, 6 o'clock 269 | [-1, -1], // south-west, between 7 and 8 o'clock 270 | [-1, 0], // west, 9 o'clock 271 | [-1, 1], // north-west, between 10 and 11 o'clock 272 | [0, 1], // north, 12 o'clock 273 | [1, 1] // north-east, between 1 and 2 o'clock 274 | ]; 275 | 276 | const lines = ends.map(end => new OlGeomLineString([start, end])); 277 | 278 | const expectedAngles = [ 279 | '90.00°', 280 | '135.00°', 281 | '180.00°', 282 | '225.00°', 283 | '270.00°', 284 | '315.00°', 285 | '0.00°', // also 0° 286 | '45.00°' 287 | ]; 288 | expect(ends.length).toBe(expectedAngles.length); 289 | expect(lines.length).toBe(expectedAngles.length); 290 | 291 | lines.forEach((line, index) => { 292 | const angle = MeasureUtil.formatAngle(line); 293 | expect(angle).toBe(expectedAngles[index]); 294 | }); 295 | }); 296 | }); 297 | 298 | describe('#getAreaOfCircle - calculates the area of a circle correctly', () => { 299 | it('for metrical units', () => { 300 | const map3857 = TestUtil.createMap({ 301 | view: new OlView({ 302 | projection: 'EPSG:3857' 303 | }), 304 | resolutions: [] 305 | }); 306 | const radius = 100; 307 | const expectedArea = Math.PI * Math.pow(radius, 2); 308 | const circle = new OlGeomCircle([0, 0], radius); 309 | const result = MeasureUtil.getAreaOfCircle(circle, map3857); 310 | expect(result).toBeCloseTo(expectedArea, 6); 311 | }); 312 | 313 | it('for spherical units', () => { 314 | const map4326 = TestUtil.createMap({ 315 | view: new OlView({ 316 | projection: 'EPSG:4326' 317 | }), 318 | resolutions: [] 319 | }); 320 | const radius = 100; 321 | const expectedArea = Math.PI * Math.pow(radius, 2); 322 | const circle = new OlGeomCircle([1, 1], radius); 323 | const result = MeasureUtil.getAreaOfCircle(circle, map4326); 324 | expect(result).toBeCloseTo(expectedArea); 325 | }); 326 | }); 327 | 328 | }); 329 | }); 330 | -------------------------------------------------------------------------------- /src/MeasureUtil/MeasureUtil.ts: -------------------------------------------------------------------------------- 1 | 2 | import _isNil from 'lodash/isNil'; 3 | import OlGeomCircle from 'ol/geom/Circle'; 4 | import OlGeomLineString from 'ol/geom/LineString'; 5 | import OlGeomPolygon from 'ol/geom/Polygon'; 6 | import OlMap from 'ol/Map'; 7 | import { Units } from 'ol/proj/Units'; 8 | import { getArea, getLength } from 'ol/sphere'; 9 | 10 | /** 11 | * This class provides some static methods which might be helpful when working 12 | * with measurements. 13 | * 14 | * @class MeasureUtil 15 | */ 16 | class MeasureUtil { 17 | 18 | /** 19 | * Get the length of a OlGeomLineString. 20 | * 21 | * @param {OlGeomLineString} line The drawn line. 22 | * @param {OlMap} map An OlMap. 23 | * @param {boolean} geodesic Is the measurement geodesic (default is true). 24 | * @param {number} radius Sphere radius. By default, the radius of the earth 25 | * is used (Clarke 1866 Authalic Sphere, 6371008.8). 26 | * @param {number} decimalPrecision Set the decimal precision on length value 27 | * for non-geodesic map (default value 6) 28 | * 29 | * @return {number} The length of line in meters. 30 | */ 31 | static getLength( 32 | line: OlGeomLineString, 33 | map: OlMap, 34 | geodesic: boolean = true, 35 | radius: number = 6371008.8, 36 | decimalPrecision: number = 6 37 | ): number { 38 | const decimalHelper = Math.pow(10, decimalPrecision); 39 | if (geodesic) { 40 | const opts = { 41 | projection: map.getView().getProjection().getCode(), 42 | radius 43 | }; 44 | return getLength(line, opts); 45 | } else { 46 | return Math.round(line.getLength() * decimalHelper) / decimalHelper; 47 | } 48 | } 49 | 50 | /** 51 | * Format length output for the tooltip. 52 | * 53 | * @param {OlGeomLineString} line The drawn line. 54 | * @param {OlMap} map An OlMap. 55 | * @param {number} decimalPlacesInToolTips How many decimal places will be 56 | * allowed for the measure tooltips 57 | * @param {boolean} geodesic Is the measurement geodesic (default is true). 58 | * 59 | * @return {string} The formatted length of the line (units: km, m or mm). 60 | */ 61 | static formatLength( 62 | line: OlGeomLineString, map: OlMap, decimalPlacesInToolTips: number, geodesic = true 63 | ): string { 64 | const decimalHelper = Math.pow(10, decimalPlacesInToolTips); 65 | const length = MeasureUtil.getLength(line, map, geodesic); 66 | let output; 67 | if (length > 1000) { 68 | output = (Math.round(length / 1000 * decimalHelper) / 69 | decimalHelper) + ' km'; 70 | } else if (length > 1) { 71 | output = (Math.round(length * decimalHelper) / decimalHelper) + 72 | ' m'; 73 | } else { 74 | output = (Math.round(length * 1000 * decimalHelper) / decimalHelper) + 75 | ' mm'; 76 | } 77 | return output; 78 | } 79 | 80 | /** 81 | * Get the area of an OlGeomPolygon. 82 | * 83 | * @param {OlGeomPolygon} polygon The drawn polygon. 84 | * @param {OlMap} map An OlMap. 85 | * @param {boolean} geodesic Is the measurement geodesic (default is true). 86 | * @param {number} radius Sphere radius. By default, the radius of the earth 87 | * is used (Clarke 1866 Authalic Sphere, 6371008.8). 88 | * 89 | * @return {number} The area of the polygon in square meter. 90 | */ 91 | static getArea( 92 | polygon: OlGeomPolygon, 93 | map: OlMap, 94 | geodesic: boolean = true, 95 | radius: number = 6371008.8 96 | ): number { 97 | if (geodesic) { 98 | const opts = { 99 | projection: map.getView().getProjection().getCode(), 100 | radius 101 | }; 102 | return getArea(polygon, opts); 103 | } else { 104 | return polygon.getArea(); 105 | } 106 | } 107 | 108 | /** 109 | * Get the estimated area of an OlGeomCircle. 110 | * 111 | * @param {OlGeomCircle} circleGeom The drawn circle. 112 | * @param {OlMap} map An OlMap. 113 | * 114 | * @return {number} The area of the circle in square meter. 115 | */ 116 | static getAreaOfCircle( 117 | circleGeom: OlGeomCircle, 118 | map: OlMap 119 | ): number { 120 | if (_isNil(map.getView().getProjection())) { 121 | return NaN; 122 | } 123 | const sphericalUnits: Units[] = ['radians', 'degrees']; 124 | const projectionUnits = map.getView().getProjection().getUnits(); 125 | const useSpherical = sphericalUnits.includes(projectionUnits); 126 | 127 | if (useSpherical) { 128 | // see https://math.stackexchange.com/questions/1832110/area-of-a-circle-on-sphere 129 | // the radius of the earth - Clarke 1866 authalic Sphere 130 | const earthRadius = 6371008.8; 131 | const radius = circleGeom.getRadius(); 132 | let area = 2.0 * Math.PI * Math.pow(earthRadius, 2); 133 | area *= (1 - Math.cos(radius / earthRadius)); 134 | return area; 135 | } else { 136 | return Math.PI * Math.pow(circleGeom.getRadius(), 2); 137 | } 138 | } 139 | 140 | /** 141 | * Format area output for the tooltip. 142 | * 143 | * @param {OlGeomPolygon | OlGeomCircle} geom The drawn geometry (circle or polygon). 144 | * @param {OlMap} map An OlMap. 145 | * @param {number} decimalPlacesInToolTips How many decimal places will be 146 | * allowed for the measure tooltips. 147 | * @param {boolean} geodesic Is the measurement geodesic. 148 | * 149 | * @return {string} The formatted area of the polygon. 150 | */ 151 | static formatArea( 152 | geom: OlGeomPolygon | OlGeomCircle, 153 | map: OlMap, 154 | decimalPlacesInToolTips: number, 155 | geodesic: boolean = true 156 | ): string { 157 | const decimalHelper = Math.pow(10, decimalPlacesInToolTips); 158 | let area; 159 | if (geom instanceof OlGeomCircle) { 160 | area = MeasureUtil.getAreaOfCircle(geom, map); 161 | } else { 162 | area = MeasureUtil.getArea(geom, map, geodesic); 163 | } 164 | let output; 165 | if (area > 10000) { 166 | output = (Math.round(area / 1000000 * decimalHelper) / 167 | decimalHelper) + ' km2'; 168 | } else if (area > 0.01) { 169 | output = (Math.round(area * decimalHelper) / decimalHelper) + 170 | ' m2'; 171 | } else { 172 | output = (Math.round(area * 1000000 * decimalHelper) / decimalHelper) + 173 | ' mm2'; 174 | } 175 | return output; 176 | } 177 | 178 | /** 179 | * Determine the angle between two coordinates. The angle will be between 180 | * -180° and 180°, with 0° being in the east. The angle will increase 181 | * counter-clockwise. 182 | * 183 | * Inspired by https://stackoverflow.com/a/31136507 184 | * 185 | * @param {Array} start The start coordinates of the line with the 186 | * x-coordinate being at index `0` and y-coordinate being at index `1`. 187 | * @param {Array} end The end coordinates of the line with the 188 | * x-coordinate being at index `0` and y-coordinate being at index `1`. 189 | * 190 | * @return {number} the angle in degrees, ranging from -180° to 180°. 191 | */ 192 | static angle(start: number[], end: number[]): number { 193 | const dx = start[0] - end[0]; 194 | const dy = start[1] - end[1]; 195 | // range (-PI, PI] 196 | let theta = Math.atan2(dy, dx); 197 | // rads to degs, range (-180, 180] 198 | theta *= 180 / Math.PI; 199 | return theta; 200 | } 201 | 202 | /** 203 | * Determine the angle between two coordinates. The angle will be between 204 | * 0° and 360°, with 0° being in the east. The angle will increase 205 | * counter-clockwise. 206 | * 207 | * Inspired by https://stackoverflow.com/a/31136507 208 | * 209 | * @param {Array} start The start coordinates of the line with the 210 | * x-coordinate being at index `0` and y-coordinate being at index `1`. 211 | * @param {Array} end The end coordinates of the line with the 212 | * x-coordinate being at index `0` and y-coordinate being at index `1`. 213 | * 214 | * @return {number} the angle in degrees, ranging from 0° and 360°. 215 | */ 216 | static angle360(start: number[], end: number[]): number { 217 | // range (-180, 180] 218 | let theta = MeasureUtil.angle(start, end); 219 | if (theta < 0) { 220 | // range [0, 360) 221 | theta = 360 + theta; 222 | } 223 | return theta; 224 | } 225 | 226 | /** 227 | * Given an angle between 0° and 360° this angle returns the exact opposite 228 | * of the angle, e.g. for 90° you'll get back 270°. This effectively turns 229 | * the direction of the angle from counter-clockwise to clockwise. 230 | * 231 | * @param {number} angle360 The input angle obtained counter-clockwise. 232 | * 233 | * @return {number} The clockwise angle. 234 | */ 235 | static makeClockwise(angle360: number): number { 236 | return 360 - angle360; 237 | } 238 | 239 | /** 240 | * This methods adds an offset of 90° to an counter-clockwise increasing 241 | * angle of a line so that the origin (0°) lies at the top (in the north). 242 | * 243 | * @param {number} angle360 The input angle obtained counter-clockwise, with 244 | * 0° degrees being in the east. 245 | * 246 | * @return {number} The adjusted angle, with 0° being in the north. 247 | */ 248 | static makeZeroDegreesAtNorth(angle360: number): number { 249 | let corrected = angle360 + 90; 250 | if (corrected > 360) { 251 | corrected = corrected - 360; 252 | } 253 | return corrected; 254 | } 255 | 256 | /** 257 | * Returns the angle of the passed linestring in degrees, with 'N' being the 258 | * 0°-line and the angle increases in clockwise direction. 259 | * 260 | * @param {OlGeomLineString} line The linestring to get the 261 | * angle from. As this line is coming from our internal draw 262 | * interaction, we know that it will only consist of two points. 263 | * @param {number} decimalPlacesInToolTips How many decimal places will be 264 | * allowed for the measure tooltips. 265 | * 266 | * @return {string} The formatted angle of the line. 267 | */ 268 | static formatAngle(line: OlGeomLineString, decimalPlacesInToolTips: number = 2): string { 269 | const coords = line.getCoordinates(); 270 | const numCoords = coords.length; 271 | if (numCoords < 2) { 272 | return ''; 273 | } 274 | 275 | const lastPoint = coords[numCoords - 1]; 276 | const prevPoint = coords[numCoords - 2]; 277 | let angle = MeasureUtil.angle360(prevPoint, lastPoint); 278 | 279 | angle = MeasureUtil.makeZeroDegreesAtNorth(angle); 280 | angle = MeasureUtil.makeClockwise(angle); 281 | 282 | return `${angle.toFixed(decimalPlacesInToolTips)}°`; 283 | } 284 | 285 | } 286 | 287 | export default MeasureUtil; 288 | -------------------------------------------------------------------------------- /src/PermalinkUtil/PermalinkUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | import OlLayerGroup from 'ol/layer/Group'; 3 | import OlLayerTile from 'ol/layer/Tile'; 4 | import OlMap from 'ol/Map'; 5 | import { useGeographic } from 'ol/proj'; 6 | import OlSourceTile from 'ol/source/Tile'; 7 | 8 | import TestUtil from '../TestUtil'; 9 | import { PermalinkUtil } from './PermalinkUtil'; 10 | 11 | let map: OlMap; 12 | 13 | beforeEach(() => { 14 | map = TestUtil.createMap(); 15 | }); 16 | 17 | afterEach(() => { 18 | TestUtil.removeMap(map); 19 | }); 20 | 21 | describe('PermalinkUtil', () => { 22 | 23 | describe('Basic test', () => { 24 | it('is defined', () => { 25 | expect(PermalinkUtil).not.toBeUndefined(); 26 | }); 27 | }); 28 | 29 | describe('Static methods', () => { 30 | 31 | describe('#getLink', () => { 32 | 33 | it('is defined', () => { 34 | expect(PermalinkUtil.getLink).not.toBeUndefined(); 35 | }); 36 | 37 | it('creates a valid permalink', () => { 38 | useGeographic(); 39 | map.getView().setCenter([50, 7]); 40 | map.getView().setZoom(7); 41 | map.addLayer(new OlLayerTile({ 42 | visible: true, 43 | source: new OlSourceTile({ 44 | attributions: '' 45 | }), 46 | properties: { 47 | name: 'peter' 48 | } 49 | })); 50 | map.addLayer(new OlLayerTile({ 51 | visible: false, 52 | source: new OlSourceTile({ 53 | attributions: '' 54 | }), 55 | properties: { 56 | name: 'paul' 57 | } 58 | })); 59 | map.addLayer(new OlLayerTile({ 60 | visible: true, 61 | source: new OlSourceTile({ 62 | attributions: '' 63 | }), 64 | properties: { 65 | name: 'pan' 66 | } 67 | })); 68 | const link = PermalinkUtil.getLink(map); 69 | const url = new URL(link); 70 | const center = url.searchParams.get('center'); 71 | const zoom = url.searchParams.get('zoom'); 72 | const layers = url.searchParams.get('layers'); 73 | 74 | expect(center).toBe('50;7'); 75 | expect(zoom).toBe('7'); 76 | expect(layers).toBe('peter;pan'); 77 | }); 78 | 79 | it('correctly uses optional separator on link creation', () => { 80 | map.getView().setCenter([50, 7]); 81 | map.getView().setZoom(7); 82 | map.addLayer(new OlLayerTile({ 83 | visible: true, 84 | source: new OlSourceTile({ 85 | attributions: '' 86 | }), 87 | properties: { 88 | name: 'peter' 89 | } 90 | })); 91 | map.addLayer(new OlLayerTile({ 92 | visible: true, 93 | source: new OlSourceTile({ 94 | attributions: '' 95 | }), 96 | properties: { 97 | name: 'pan' 98 | } 99 | })); 100 | 101 | const link = PermalinkUtil.getLink(map, '|'); 102 | const url = new URL(link); 103 | const center = url.searchParams.get('center'); 104 | const layers = url.searchParams.get('layers'); 105 | 106 | expect(center).toBe('50|7'); 107 | expect(layers).toBe('peter|pan'); 108 | 109 | }); 110 | }); 111 | 112 | describe('#applyLink', () => { 113 | 114 | it('is defined', () => { 115 | expect(PermalinkUtil.applyLink).not.toBeUndefined(); 116 | }); 117 | 118 | it('applies a given permalink', () => { 119 | map.getLayers().clear(); 120 | map.addLayer(new OlLayerTile({ 121 | visible: false, 122 | source: new OlSourceTile({ 123 | attributions: '' 124 | }), 125 | properties: { 126 | name: 'peter' 127 | } 128 | })); 129 | map.addLayer(new OlLayerTile({ 130 | visible: false, 131 | source: new OlSourceTile({ 132 | attributions: '' 133 | }), 134 | properties: { 135 | name: 'paul' 136 | } 137 | })); 138 | map.addLayer(new OlLayerTile({ 139 | visible: false, 140 | source: new OlSourceTile({ 141 | attributions: '' 142 | }), 143 | properties: { 144 | name: 'pan' 145 | } 146 | })); 147 | 148 | const link = 'http://fake?zoom=3¢er=10;20&layers=peter;pan'; 149 | Object.defineProperty(global.window, 'location', { 150 | value: { 151 | href: link 152 | } 153 | }); 154 | PermalinkUtil.applyLink(map); 155 | 156 | expect(map.getView().getCenter()).toEqual([10, 20]); 157 | expect(map.getView().getZoom()).toBe(3); 158 | const visibles = map.getLayers().getArray() 159 | .filter(l => l.getVisible()) 160 | .map(l => l.get('name')); 161 | expect(visibles).toEqual(['peter', 'pan']); 162 | }); 163 | 164 | it('correctly uses optional separator on link apply', () => { 165 | map.getLayers().clear(); 166 | map.addLayer(new OlLayerTile({ 167 | visible: false, 168 | source: new OlSourceTile({ 169 | attributions: '' 170 | }), 171 | properties: { 172 | name: 'peter' 173 | } 174 | })); 175 | map.addLayer(new OlLayerTile({ 176 | visible: false, 177 | source: new OlSourceTile({ 178 | attributions: '' 179 | }), 180 | properties: { 181 | name: 'paul' 182 | } 183 | })); 184 | map.addLayer(new OlLayerTile({ 185 | visible: false, 186 | source: new OlSourceTile({ 187 | attributions: '' 188 | }), 189 | properties: { 190 | name: 'pan' 191 | } 192 | })); 193 | 194 | const link = 'http://fake?zoom=3¢er=10|20&layers=peter|pan'; 195 | // @ts-ignore 196 | delete global.window.location; 197 | global.window = Object.create(window); 198 | Object.defineProperty(window, 'location', { 199 | value: { 200 | href: link 201 | }, 202 | configurable: true 203 | }); 204 | PermalinkUtil.applyLink(map, '|'); 205 | 206 | expect(map.getView().getCenter()).toEqual([10, 20]); 207 | const visibles = map.getLayers().getArray() 208 | .filter(l => l.getVisible()) 209 | .map(l => l.get('name')); 210 | expect(visibles).toEqual(['peter', 'pan']); 211 | }); 212 | 213 | it('applies visible state to parenting groups', () => { 214 | map.getLayers().clear(); 215 | map.addLayer(new OlLayerGroup({ 216 | visible: false, 217 | layers: [ 218 | new OlLayerTile({ 219 | visible: false, 220 | source: new OlSourceTile({ 221 | attributions: '' 222 | }), 223 | properties: { 224 | name: 'paul' 225 | } 226 | }), 227 | new OlLayerTile({ 228 | visible: false, 229 | source: new OlSourceTile({ 230 | attributions: '' 231 | }), 232 | properties: { 233 | name: 'pan' 234 | } 235 | }) 236 | ], 237 | properties: { 238 | name: 'peter' 239 | } 240 | })); 241 | 242 | const link = 'http://fake?zoom=3¢er=10|20&layers=pan'; 243 | Object.defineProperty(window, 'location', { 244 | value: { 245 | href: link 246 | }, 247 | configurable: true 248 | }); 249 | PermalinkUtil.applyLink(map, '|'); 250 | 251 | const firstLevelVisibles = map.getLayers().getArray() 252 | .filter(l => l.getVisible()) 253 | .map(l => l.get('name')); 254 | expect(firstLevelVisibles).toEqual(['peter']); 255 | 256 | let firstElement = map.getLayers().getArray()[0]; 257 | expect(firstElement).toBeInstanceOf(OlLayerGroup); 258 | const secondLevelVisibles = (firstElement as OlLayerGroup) 259 | .getLayers().getArray().filter(l => l.getVisible()) 260 | .map(l => l.get('name')); 261 | expect(secondLevelVisibles).toEqual(['pan']); 262 | }); 263 | }); 264 | 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /src/PermalinkUtil/PermalinkUtil.ts: -------------------------------------------------------------------------------- 1 | import _isEmpty from 'lodash/isEmpty'; 2 | import _isNil from 'lodash/isNil'; 3 | import { getUid } from 'ol'; 4 | import OlCollection from 'ol/Collection'; 5 | import OlBaseLayer from 'ol/layer/Base'; 6 | import OlLayerGroup from 'ol/layer/Group'; 7 | import OlImageLayer from 'ol/layer/Image'; 8 | import OlTileLayer from 'ol/layer/Tile'; 9 | import OlMap from 'ol/Map'; 10 | 11 | import MapUtil from '../MapUtil/MapUtil'; 12 | 13 | /** 14 | * Helper class for some operations related to permalink function. 15 | * 16 | * @class 17 | */ 18 | export class PermalinkUtil { 19 | 20 | /** 21 | * Creates a permalink based on the given map state. It will contain 22 | * the current view state of the map (center and zoom) as well as 23 | * the current (filtered) list of layers. 24 | * 25 | * @param {OlMap} map The OpenLayers map 26 | * @param {string} separator The separator for the layers list and center 27 | * coordinates in the link. Default is to ';'. 28 | * @param {(layer: OlBaseLayer) => string} identifier Function to generate the identifier of the 29 | * layer in the link. Default is the name 30 | * (given by the associated property) of 31 | * the layer. 32 | * @param {(layer: OlBaseLayer) => boolean} filter Function to filter layers that should be 33 | * added to the link. Default is to add all 34 | * visible layers of type ol/layer/Tile. 35 | * @param {string[]} customAttributes Custom layer attributes which will be saved in the permalink for each layer. 36 | * @return {string} The permalink. 37 | */ 38 | static getLink = ( 39 | map: OlMap, 40 | separator = ';', 41 | identifier = (l: OlBaseLayer) => l?.get('name'), 42 | filter = (l: OlBaseLayer) => !_isNil(l) && 43 | (l instanceof OlTileLayer || l instanceof OlImageLayer) && l.getVisible(), 44 | customAttributes: string[] = [] 45 | ): string => { 46 | const center = map.getView().getCenter()?.join(separator) ?? ''; 47 | const zoom = map.getView().getZoom()?.toString() ?? ''; 48 | const layers = map.getAllLayers(); 49 | const visibleOnes = layers 50 | .filter(filter) 51 | .map(identifier) 52 | .join(separator); 53 | const link = new URL(window.location.href); 54 | 55 | if (customAttributes.length > 0) { 56 | const customLayerAttributes: Record[] = []; 57 | layers.forEach((layer) => { 58 | const config: Record = {}; 59 | customAttributes.forEach((attribute) => { 60 | if (!_isNil(layer.get(attribute))) { 61 | config[attribute] = layer.get(attribute); 62 | } 63 | }); 64 | if (!_isEmpty(config)) { 65 | customLayerAttributes.push(config); 66 | } 67 | }); 68 | const customLayerAttributesString = JSON.stringify(customLayerAttributes); 69 | link.searchParams.set('customLayerAttributes', customLayerAttributesString); 70 | } 71 | 72 | link.searchParams.set('center', center); 73 | link.searchParams.set('zoom', zoom); 74 | link.searchParams.set('layers', visibleOnes); 75 | 76 | return link.href; 77 | }; 78 | 79 | /** 80 | * Applies an existing permalink to the given map. 81 | * 82 | * @param {OlMap} map The OpenLayers map. 83 | * @param {string} separator The separator of the layers list and center 84 | * coordinates in the link. Default is to ';'. 85 | * @param {(layer: OlBaseLayer) => string} identifier Function to generate the identifier of the 86 | * layer in the link. Default is the name 87 | * (given by the associated property) of 88 | * the layer. 89 | * @param {(layer: OlBaseLayer) => boolean} filter Function to filter layers that should be 90 | * handled by the link. Default is to consider all 91 | * current map layers of type ol/layer/Tile. 92 | * @return {string | null} The customLayerAttributes, if defined. Otherwise null. 93 | */ 94 | static applyLink = ( 95 | map: OlMap, 96 | separator: string = ';', 97 | identifier: (layer: OlBaseLayer) => string = l => l?.get('name'), 98 | filter = (layer: OlBaseLayer) => 99 | layer instanceof OlTileLayer || layer instanceof OlImageLayer 100 | ): string | null => { 101 | const url = new URL(window.location.href); 102 | const center = url.searchParams.get('center'); 103 | const zoom = url.searchParams.get('zoom'); 104 | const layers = url.searchParams.get('layers'); 105 | const customLayerAttributes = url.searchParams.get('customLayerAttributes'); 106 | const allLayers = MapUtil.getAllLayers(map); 107 | 108 | if (layers) { 109 | const layersSplitted = layers.split(separator); 110 | allLayers 111 | .filter(filter) 112 | .forEach(l => { 113 | const visible = layersSplitted.includes(identifier(l)); 114 | l.setVisible(visible); 115 | // also make all parent folders / groups visible so 116 | // that the layer becomes visible in map 117 | if (visible) { 118 | PermalinkUtil.setParentsVisible( 119 | map, 120 | map.getLayerGroup().getLayers(), 121 | getUid(l)); 122 | } 123 | }); 124 | } 125 | 126 | if (center) { 127 | map.getView().setCenter([ 128 | parseFloat(center.split(separator)[0]), 129 | parseFloat(center.split(separator)[1]) 130 | ]); 131 | } 132 | 133 | if (zoom) { 134 | map.getView().setZoom(parseInt(zoom, 10)); 135 | } 136 | 137 | if (customLayerAttributes) { 138 | return customLayerAttributes; 139 | } 140 | return null; 141 | }; 142 | 143 | /** 144 | * Search through the given Ol-Collection for the given id and 145 | * set all parenting groups visible. 146 | * @param {OlMap} map The openlayers map 147 | * @param {OlCollection} coll The Openlayers Collection 148 | * @param {string} id Ther layer ol uid to search for 149 | */ 150 | static setParentsVisible = (map: OlMap, coll: OlCollection, id: string) => { 151 | coll.forEach(el => { 152 | if (el instanceof OlLayerGroup) { 153 | const layers = MapUtil.getLayersByGroup(map, el); 154 | if (layers.map(layer => getUid(layer)).includes(id)) { 155 | el.setVisible(true); 156 | } 157 | PermalinkUtil.setParentsVisible(map, el.getLayers(), id); 158 | } 159 | }); 160 | }; 161 | 162 | } 163 | 164 | export default PermalinkUtil; 165 | -------------------------------------------------------------------------------- /src/ProjectionUtil/ProjectionUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | import * as OlProj4 from 'ol/proj/proj4'; 3 | import proj4 from 'proj4'; 4 | 5 | import { 6 | CrsDefinition, 7 | defaultProj4CrsDefinitions, 8 | defaultProj4CrsMappings, 9 | ProjectionUtil 10 | } from './ProjectionUtil'; 11 | import SpyInstance = jest.SpyInstance; 12 | 13 | let registerSpy: SpyInstance; 14 | let defsSpy: SpyInstance; 15 | 16 | beforeEach(() => { 17 | registerSpy = jest.spyOn(OlProj4, 'register').mockImplementation( jest.fn ); 18 | defsSpy = jest.spyOn(proj4, 'defs'); 19 | }); 20 | afterEach(() => { 21 | registerSpy.mockRestore(); 22 | defsSpy.mockRestore(); 23 | }); 24 | 25 | describe('ProjectionUtil', () => { 26 | 27 | const custom: CrsDefinition = { 28 | crsCode: 'EPSG:31468', 29 | definition: '+proj=tmerc +lat_0=0 +lon_0=12 ' + 30 | '+k=1 +x_0=4500000 +y_0=0 +ellps=bessel ' + 31 | '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +units=m ' + 32 | '+no_defs' 33 | }; 34 | 35 | const alreadyThere: CrsDefinition = { 36 | crsCode: 'EPSG:31467', 37 | definition: '+proj=tmerc +lat_0=0 +lon_0=9 ' + 38 | '+k=1 +x_0=3500000 +y_0=0 +ellps=bessel ' + 39 | '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +units=m ' + 40 | '+no_defs' 41 | }; 42 | 43 | describe('Basic test', () => { 44 | it('is defined', () => { 45 | expect(ProjectionUtil).not.toBeUndefined(); 46 | }); 47 | }); 48 | 49 | describe('Static methods', () => { 50 | 51 | describe('#initProj4Definitions', () => { 52 | 53 | it('is defined', () => { 54 | expect(ProjectionUtil.initProj4Definitions).not.toBeUndefined(); 55 | }); 56 | 57 | it('it registers the given CRS definitions in proj4 and ol', () => { 58 | const length = defaultProj4CrsDefinitions.length; 59 | 60 | ProjectionUtil.initProj4Definitions(); 61 | expect(defsSpy).toHaveBeenCalledTimes(length); 62 | expect(OlProj4.register).toHaveBeenCalled(); 63 | }); 64 | 65 | it('additionally registers a custom projection', () => { 66 | const length = defaultProj4CrsDefinitions.length; 67 | 68 | const hasCustomProj = () => proj4('EPSG:31468'); 69 | 70 | expect(hasCustomProj).toThrow(); 71 | ProjectionUtil.initProj4Definitions(custom); 72 | expect(defsSpy).toHaveBeenCalledTimes(length + 1); 73 | expect(OlProj4.register).toHaveBeenCalled(); 74 | expect(hasCustomProj).not.toThrow(); 75 | }); 76 | 77 | it('does not register a custom projection which is already registered', () => { 78 | const length = defaultProj4CrsDefinitions.length; 79 | 80 | ProjectionUtil.initProj4Definitions(alreadyThere); 81 | expect(defsSpy).toHaveBeenCalledTimes(length); 82 | expect(OlProj4.register).toHaveBeenCalled(); 83 | }); 84 | 85 | it('only registers a custom projection, if told so', () => { 86 | ProjectionUtil.initProj4Definitions(custom, false); 87 | expect(defsSpy).toHaveBeenCalledTimes(1); 88 | expect(OlProj4.register).toHaveBeenCalled(); 89 | }); 90 | 91 | it('does not fail when neither custom projections nor defaults', () => { 92 | ProjectionUtil.initProj4Definitions(undefined, false); 93 | expect(defsSpy).toHaveBeenCalledTimes(0); 94 | expect(OlProj4.register).not.toHaveBeenCalled(); 95 | }); 96 | 97 | }); 98 | 99 | describe('#initProj4DefinitionMappings', () => { 100 | 101 | it('is defined', () => { 102 | expect(ProjectionUtil.initProj4DefinitionMappings).not.toBeUndefined(); 103 | }); 104 | 105 | it('registers the given CRS mappings in proj4', () => { 106 | const length = defaultProj4CrsMappings.length; 107 | 108 | ProjectionUtil.initProj4DefinitionMappings(defaultProj4CrsMappings); 109 | expect(defsSpy).toHaveBeenCalledTimes(length * 2); 110 | }); 111 | 112 | it('additionally registers given CRS mappings in proj4', () => { 113 | const length = defaultProj4CrsMappings.length; 114 | 115 | // first register custom: 116 | ProjectionUtil.initProj4DefinitionMappings({ 117 | alias: 'foo', mappedCode: 'EPSG:31467' 118 | }); 119 | expect(defsSpy).toHaveBeenCalledTimes((length + 1) * 2); 120 | }); 121 | 122 | it('registers only given CRS mappings in proj4, if told so', () => { 123 | ProjectionUtil.initProj4DefinitionMappings({ 124 | alias: 'foo', mappedCode: 'EPSG:31467' 125 | }, false); 126 | expect(defsSpy).toHaveBeenCalledTimes(2); 127 | }); 128 | 129 | it('does not fail when neither custom mappings nor defaults', () => { 130 | ProjectionUtil.initProj4DefinitionMappings([], false); 131 | expect(defsSpy).toHaveBeenCalledTimes(0); 132 | }); 133 | 134 | }); 135 | 136 | describe('#toDms', () => { 137 | it('is defined', () => { 138 | expect(ProjectionUtil.toDms).not.toBeUndefined(); 139 | }); 140 | 141 | it('converts geographic coordinates to degree, minutes, decimal seconds (DMS) format', () => { 142 | const degreeVal = 19.0909090909; 143 | const convertedVal = '19° 05\' 27.27\'\''; 144 | expect(ProjectionUtil.toDms(degreeVal)).toBe(convertedVal); 145 | }); 146 | }); 147 | 148 | describe('#toDmm', () => { 149 | it('is defined', () => { 150 | expect(ProjectionUtil.toDmm).not.toBeUndefined(); 151 | }); 152 | 153 | it('converts geographic coordinates to degree, decimal minutes (DMM) format', () => { 154 | const degreeVal = 19.0909090909; 155 | const convertedVal = '19° 05.4545\''; 156 | expect(ProjectionUtil.toDmm(degreeVal)).toBe(convertedVal); 157 | }); 158 | }); 159 | 160 | describe('#zerofill', () => { 161 | it('is defined', () => { 162 | expect(ProjectionUtil.zerofill).not.toBeUndefined(); 163 | }); 164 | 165 | it ('adds leading zero to values less than 10', () => { 166 | const smallValue = 9.123; 167 | const bigValue = 15.456; 168 | const expectedSmallValue = '09.123'; 169 | const expectedBigValue = '15.456'; 170 | expect(ProjectionUtil.zerofill(smallValue)).toBe(expectedSmallValue); 171 | expect(ProjectionUtil.zerofill(bigValue)).toBe(expectedBigValue); 172 | }); 173 | }); 174 | 175 | }); 176 | 177 | }); 178 | -------------------------------------------------------------------------------- /src/ProjectionUtil/ProjectionUtil.ts: -------------------------------------------------------------------------------- 1 | import _isNil from 'lodash'; 2 | import _isEmpty from 'lodash/isEmpty'; 3 | import _isString from 'lodash/isString'; 4 | import { register } from 'ol/proj/proj4'; 5 | import proj4, { ProjectionDefinition } from 'proj4'; 6 | 7 | export interface CrsDefinition { 8 | crsCode: string; 9 | definition: string | ProjectionDefinition; 10 | } 11 | 12 | export interface CrsMapping { 13 | alias: string; 14 | mappedCode: string; 15 | } 16 | 17 | /** 18 | * Default proj4 CRS definitions. 19 | */ 20 | export const defaultProj4CrsDefinitions: CrsDefinition[] = [{ 21 | crsCode: 'EPSG:25832', 22 | definition: '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'} 23 | , { 24 | crsCode: 'EPSG:31466', 25 | // eslint-disable-next-line 26 | definition: '+proj=tmerc +lat_0=0 +lon_0=6 +k=1 +x_0=2500000 +y_0=0 +ellps=bessel +towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +units=m +no_defs' 27 | }, { 28 | crsCode: 'EPSG:31467', 29 | // eslint-disable-next-line 30 | definition: '+proj=tmerc +lat_0=0 +lon_0=9 +k=1 +x_0=3500000 +y_0=0 +ellps=bessel +towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7 +units=m +no_defs' 31 | }]; 32 | 33 | /** 34 | * Default mappings for CRS identifiers (e.g. "urn:ogc:def:crs:EPSG::25832"). 35 | */ 36 | export const defaultProj4CrsMappings: CrsMapping[] = [{ 37 | alias: 'urn:ogc:def:crs:EPSG::3857', 38 | mappedCode: 'EPSG:3857' 39 | }, { 40 | alias: 'urn:ogc:def:crs:EPSG::25832', 41 | mappedCode: 'EPSG:25832' 42 | }, { 43 | alias: 'urn:ogc:def:crs:EPSG::31466', 44 | mappedCode: 'EPSG:31466' 45 | }, { 46 | alias: 'urn:ogc:def:crs:EPSG::31467', 47 | mappedCode: 'EPSG:31467' 48 | }]; 49 | 50 | /** 51 | * Helper class for projection handling. Makes use of 52 | * [Proj4js](http://proj4js.org/). 53 | * 54 | * @class ProjectionUtil 55 | */ 56 | export class ProjectionUtil { 57 | 58 | /** 59 | * Registers custom CRS definitions to the application. 60 | * 61 | * @param { CrsDefinition | CrsDefinition[]} customCrsDefs The custom `proj4` definitions 62 | * which should be registered additionally to default available CRS (s. 63 | * `defaultProj4CrsDefinitions` above) as well. 64 | * Further CRS definitions in proj4 format can be checked under 65 | * http://epsg.io (e.g. http://epsg.io/3426.proj4). 66 | * @param {boolean} registerDefaults Whether the default CRS should be 67 | * registered or not. Default is true. 68 | */ 69 | static initProj4Definitions(customCrsDefs?: CrsDefinition | CrsDefinition[], registerDefaults: boolean = true) { 70 | let proj4CrsDefinitions: CrsDefinition[] = []; 71 | 72 | if (registerDefaults) { 73 | proj4CrsDefinitions = defaultProj4CrsDefinitions; 74 | } 75 | 76 | if (!_isNil(customCrsDefs) || customCrsDefs) { 77 | const crsDefs: CrsDefinition[] = Array.isArray(customCrsDefs) ? 78 | customCrsDefs : [customCrsDefs] as CrsDefinition[]; 79 | crsDefs?.forEach(crsDef => { 80 | if (proj4CrsDefinitions?.findIndex(tCrs => tCrs.crsCode === crsDef?.crsCode) === -1){ 81 | proj4CrsDefinitions.push(crsDef); 82 | } 83 | }); 84 | } 85 | 86 | if (proj4CrsDefinitions?.length > 0) { 87 | proj4CrsDefinitions.forEach(crsDef => proj4.defs(crsDef.crsCode, crsDef.definition)); 88 | register(proj4); 89 | } 90 | } 91 | 92 | /** 93 | * Registers custom CRS mappings to allow automatic CRS detection. Sometimes 94 | * FeatureCollections returned by the GeoServer may be associated with 95 | * CRS identifiers (e.g. "urn:ogc:def:crs:EPSG::25832") that aren't 96 | * supported by `proj4` and `OpenLayers` per default. Add appropriate 97 | * mappings to allow automatic CRS detection by `OpenLayers` here. 98 | * 99 | * @param {CrsMapping | CrsMapping[]} customCrsMappings The custom CRS mappings which will be 100 | * added additionally to the by default available (s. `defaultProj4CrsMappings` 101 | * above). 102 | * @param {boolean} useDefaultMappings Whether the default CRS should be mapped 103 | * as well or not. Default is true. 104 | */ 105 | static initProj4DefinitionMappings(customCrsMappings: CrsMapping | CrsMapping[], useDefaultMappings = true) { 106 | let proj4CrsMappings: CrsMapping[] = []; 107 | 108 | if (useDefaultMappings) { 109 | proj4CrsMappings = defaultProj4CrsMappings; 110 | } 111 | 112 | if (!_isEmpty(customCrsMappings)) { 113 | const crsMappings: CrsMapping[] = Array.isArray(customCrsMappings) ? 114 | customCrsMappings : [customCrsMappings] as CrsMapping[]; 115 | crsMappings?.forEach(crsMapping => { 116 | if (proj4CrsMappings?.findIndex(mapping => mapping.alias === crsMapping?.alias) === -1){ 117 | proj4CrsMappings.push(crsMapping); 118 | } 119 | }); 120 | } 121 | 122 | proj4CrsMappings?.map(crsMapping => { 123 | const projDef = proj4.defs(crsMapping.mappedCode) as proj4.ProjectionDefinition; 124 | proj4.defs(crsMapping.alias, projDef); 125 | }); 126 | 127 | } 128 | 129 | /** 130 | * Converts geographic coordinates given in DDD format like `DD.DDDD°` to 131 | * the degree, minutes, decimal seconds (DMS) format like 132 | * `DDD° MM' SS.SSS"`. 133 | * 134 | * @param {number} value Value to be converted. 135 | * 136 | * @return {string} Converted value. 137 | */ 138 | static toDms(value: number): string { 139 | const deg = Math.floor(value); 140 | const min = Math.floor((value - deg) * 60); 141 | const sec = ((value - deg - min / 60) * 3600); 142 | return `${deg}° ${ProjectionUtil.zerofill(min)}' ${ProjectionUtil.zerofill(sec.toFixed(2))}''`; 143 | } 144 | 145 | /** 146 | * Converts geographic coordinates given in DDD format like `DD.DDDD°` to 147 | * the degree, decimal minutes (DMM) format like `DDD° MM.MMMM`. 148 | * 149 | * @param {number} value Value to be converted. 150 | * 151 | * @return {string} Converted value. 152 | */ 153 | static toDmm(value: number): string { 154 | const deg = Math.floor(value); 155 | const min = ((value - deg) * 60); 156 | return `${deg}° ${ProjectionUtil.zerofill(min.toFixed(4))}'`; 157 | } 158 | 159 | /** 160 | * Adds leading zero to all values less than 10 and returns this new 161 | * zerofilled value as String. Values which are greater than 10 are not 162 | * affected. 163 | * 164 | * @param {number|string} value Value to be zerofilled. 165 | * 166 | * @return {string} converted value with leading zero if necessary. 167 | */ 168 | static zerofill(value: number | string): string { 169 | const asNumber = _isString(value) ? parseFloat(value) : value; 170 | return asNumber < 10 ? `0${asNumber}` : `${asNumber}`; 171 | } 172 | } 173 | 174 | export default ProjectionUtil; 175 | -------------------------------------------------------------------------------- /src/TestUtil.ts: -------------------------------------------------------------------------------- 1 | import _isNil from 'lodash/isNil'; 2 | import OlFeature from 'ol/Feature'; 3 | import OlGeomPoint from 'ol/geom/Point'; 4 | import OlLayerVector from 'ol/layer/Vector'; 5 | import OlMap, { MapOptions } from 'ol/Map'; 6 | import OlMapBrowserEvent from 'ol/MapBrowserEvent'; 7 | import OlSourceVector from 'ol/source/Vector'; 8 | import OlView from 'ol/View'; 9 | 10 | /** 11 | * A set of some useful static helper methods. 12 | * 13 | * @class 14 | */ 15 | export class TestUtil { 16 | 17 | static mapDivId = 'map'; 18 | static mapDivHeight = 256; 19 | static mapDivWidth = 256; 20 | 21 | /** 22 | * Creates and applies a map
element to the body. 23 | * 24 | * @return {HTMLElement} The mounted
element. 25 | */ 26 | static mountMapDiv = () => { 27 | const div = document.createElement('div'); 28 | const style = div.style; 29 | 30 | style.position = 'absolute'; 31 | style.left = '-1000px'; 32 | style.top = '-1000px'; 33 | style.width = TestUtil.mapDivWidth + 'px'; 34 | style.height = TestUtil.mapDivHeight + 'px'; 35 | div.id = TestUtil.mapDivId; 36 | 37 | document.body.appendChild(div); 38 | 39 | return div; 40 | }; 41 | 42 | /** 43 | * Removes the map div element from the body. 44 | */ 45 | static unmountMapDiv = () => { 46 | let div = document.querySelector(`div#${TestUtil.mapDivId}`); 47 | if (!div) { 48 | return; 49 | } 50 | const parent = div.parentNode; 51 | if (parent) { 52 | parent.removeChild(div); 53 | } 54 | div = null; 55 | }; 56 | 57 | /** 58 | * Creates an OpenLayers map. 59 | * 60 | * @param {MapOptions & { resolutions: number[] }} mapOpts Additional options for the map to create. 61 | * @return {OlMap} The ol map. 62 | */ 63 | static createMap = (mapOpts?: MapOptions & { resolutions: number[] }) => { 64 | const source = new OlSourceVector(); 65 | const layer = new OlLayerVector({source: source}); 66 | const targetDiv = TestUtil.mountMapDiv(); 67 | const defaultMapOpts = { 68 | target: targetDiv, 69 | layers: [layer], 70 | view: new OlView({ 71 | center: [829729, 6708850], 72 | resolution: 1, 73 | resolutions: mapOpts?.resolutions 74 | }) 75 | }; 76 | 77 | Object.assign(defaultMapOpts, mapOpts); 78 | const map = new OlMap(defaultMapOpts); 79 | map.renderSync(); 80 | return map; 81 | }; 82 | 83 | /** 84 | * Removes the map. 85 | * 86 | * @param {OlMap} map 87 | */ 88 | static removeMap = (map: OlMap) => { 89 | map?.dispose(); 90 | TestUtil.unmountMapDiv(); 91 | }; 92 | 93 | /** 94 | * Simulates a browser pointer event on the map viewport. 95 | * Origin: https://github.com/openlayers/openlayers/blob/master/test/spec/ol/interaction/draw.test.js#L67 96 | * 97 | * @param {OlMap} map The map to use. 98 | * @param {string} type Event type. 99 | * @param {number} x Horizontal offset from map center. 100 | * @param {number} y Vertical offset from map center. 101 | * @param {boolean} shift Shift key is pressed 102 | * @param {boolean} dragging Whether the map is being dragged or not. 103 | */ 104 | static simulatePointerEvent = (map: OlMap, type: string, x: number, y: number, shift: boolean, dragging: boolean) => { 105 | const viewport = map.getViewport(); 106 | // Calculated in case body has top < 0 (test runner with small window). 107 | const position = viewport.getBoundingClientRect(); 108 | const event = new PointerEvent(type, { 109 | clientX: position.left + x + TestUtil.mapDivWidth / 2, 110 | clientY: position.top + y + TestUtil.mapDivHeight / 2, 111 | shiftKey: shift 112 | }); 113 | map.handleMapBrowserEvent(new OlMapBrowserEvent(type, map, event, dragging)); 114 | }; 115 | 116 | /** 117 | * Creates and returns an empty vector layer. 118 | * 119 | * @param {Object} properties The properties to set. 120 | * @return {OlLayerVector} The layer. 121 | */ 122 | static createVectorLayer = (properties?: Record) => { 123 | const source = new OlSourceVector(); 124 | const layer = new OlLayerVector({source: source}); 125 | 126 | if (!_isNil(properties)) { 127 | layer.setProperties(properties); 128 | } 129 | return layer; 130 | }; 131 | 132 | /** 133 | * Returns a point feature with a random position. 134 | * @type {Object} 135 | */ 136 | static generatePointFeature = ((props = { 137 | ATTR_1: Math.random() * 100, 138 | ATTR_2: 'Borsigplatz 9', 139 | ATTR_3: 'Dortmund' 140 | }) => { 141 | const coords = [ 142 | Math.floor(Math.random() * 180) - 180, 143 | Math.floor(Math.random() * 90) - 90 144 | ]; 145 | const geom = new OlGeomPoint(coords); 146 | const feat = new OlFeature({ 147 | geometry: geom 148 | }); 149 | 150 | feat.setProperties(props); 151 | 152 | return feat; 153 | }); 154 | 155 | } 156 | 157 | export default TestUtil; 158 | -------------------------------------------------------------------------------- /src/WfsFilterUtil/WfsFilterUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest*/ 2 | import EqualTo from 'ol/format/filter/EqualTo'; 3 | import GreaterThanOrEqualTo from 'ol/format/filter/GreaterThanOrEqualTo'; 4 | import IsLike from 'ol/format/filter/IsLike'; 5 | import Or from 'ol/format/filter/Or'; 6 | 7 | import WfsFilterUtil, { AttributeSearchSettings, SearchConfig } from './WfsFilterUtil'; 8 | 9 | describe('WfsFilterUtil', () => { 10 | 11 | const featureType = 'featureType'; 12 | const attrName = 'testAttribute'; 13 | const anotherAttrName = 'anotherTestAttribute'; 14 | 15 | const stringExactFalse: AttributeSearchSettings = { 16 | matchCase: false, 17 | type: 'string', 18 | exactSearch: false 19 | }; 20 | const stringExactTrue: AttributeSearchSettings = { 21 | matchCase: false, 22 | type: 'string', 23 | exactSearch: true 24 | }; 25 | const intExactTrue: AttributeSearchSettings = { 26 | matchCase: false, 27 | type: 'int', 28 | exactSearch: true 29 | }; 30 | const intExactFalse: AttributeSearchSettings = { 31 | matchCase: false, 32 | type: 'int', 33 | exactSearch: false 34 | }; 35 | 36 | const searchConfig: SearchConfig = { 37 | featureNS: 'test', 38 | featureTypes: [featureType], 39 | featurePrefix: 'test', 40 | propertyNames: ['testAttribute', 'anotherTestAttribute'], 41 | attributeDetails: { 42 | featureType: {} 43 | } 44 | }; 45 | 46 | const stringSearchTerm = 'searchMe'; 47 | const digitSearchTerm = '123'; 48 | 49 | describe('Basics', () => { 50 | it('is defined', () => { 51 | expect(WfsFilterUtil).not.toBeUndefined(); 52 | }); 53 | }); 54 | 55 | describe('Static methods', () => { 56 | describe('#createWfsFilter', () => { 57 | 58 | it('is defined', () => { 59 | expect(WfsFilterUtil.createWfsFilter).toBeDefined(); 60 | }); 61 | 62 | it('returns undefined if no search attributes for the provided feature type are found', () => { 63 | const got = WfsFilterUtil.createWfsFilter(featureType, stringSearchTerm, {}); 64 | expect(got).toBeUndefined(); 65 | }); 66 | 67 | it('returns simple LIKE filter if only one attribute is provided and ' + 68 | 'exactSearch flag is false or not given', () => { 69 | searchConfig.attributeDetails.featureType[attrName] = stringExactFalse; 70 | const got = WfsFilterUtil.createWfsFilter(featureType, stringSearchTerm, searchConfig.attributeDetails); 71 | 72 | expect(got?.getTagName()).toBe('PropertyIsLike'); 73 | expect(got).toBeInstanceOf(IsLike); 74 | const isLikeFilter = got as IsLike; 75 | expect(isLikeFilter?.pattern).toEqual(`*${stringSearchTerm}*`); 76 | expect(isLikeFilter?.propertyName).toEqual(attrName); 77 | expect(isLikeFilter?.matchCase).toEqual(stringExactFalse.matchCase); 78 | }); 79 | 80 | it('returns simple EQUALTO filter if only one attribute is provided and exactSearch flag is true', () => { 81 | searchConfig.attributeDetails.featureType[attrName] = stringExactTrue; 82 | const got = WfsFilterUtil.createWfsFilter(featureType, stringSearchTerm, searchConfig.attributeDetails); 83 | expect(got?.getTagName()).toBe('PropertyIsEqualTo'); 84 | expect(got).toBeInstanceOf(EqualTo); 85 | const equalToFilter = got as EqualTo; 86 | expect(equalToFilter?.expression).toEqual(stringSearchTerm); 87 | expect(equalToFilter?.propertyName).toEqual(attrName); 88 | }); 89 | 90 | it('returns simple EQUALTO filter for numeric attributes if exactSearch flag is true', () => { 91 | searchConfig.attributeDetails.featureType[attrName] = intExactTrue; 92 | let got = WfsFilterUtil.createWfsFilter(featureType, digitSearchTerm, searchConfig.attributeDetails); 93 | expect(got?.getTagName()).toBe('PropertyIsEqualTo'); 94 | expect(got).toBeInstanceOf(EqualTo); 95 | const equalToFilter = got as EqualTo; 96 | expect(equalToFilter?.expression).toEqual(digitSearchTerm); 97 | expect(equalToFilter?.propertyName).toEqual(attrName); 98 | }); 99 | 100 | it('returns simple LIKE filter for numeric attributes if exactSearch flag is false', () => { 101 | searchConfig.attributeDetails.featureType[attrName] = intExactFalse; 102 | let got = WfsFilterUtil.createWfsFilter(featureType, digitSearchTerm, searchConfig.attributeDetails); 103 | expect(got?.getTagName()).toBe('PropertyIsLike'); 104 | expect(got).toBeInstanceOf(IsLike); 105 | const isLikeFilter = got as IsLike; 106 | expect(isLikeFilter?.pattern).toEqual(`*${digitSearchTerm}*`); 107 | expect(isLikeFilter?.propertyName).toEqual(attrName); 108 | expect(isLikeFilter?.matchCase).toEqual(stringExactFalse.matchCase); 109 | }); 110 | 111 | it('returns combined OR filter if more than one search attributes are provided', () => { 112 | searchConfig.attributeDetails.featureType[attrName] = stringExactTrue; 113 | searchConfig.attributeDetails.featureType[anotherAttrName] = stringExactFalse; 114 | 115 | const got = WfsFilterUtil.createWfsFilter(featureType, stringSearchTerm, searchConfig.attributeDetails); 116 | expect(got?.getTagName()).toBe('Or'); 117 | expect(got).toBeInstanceOf(Or); 118 | const orFilter = got as Or; 119 | expect(orFilter?.conditions.length).toEqual(2); 120 | }); 121 | }); 122 | 123 | describe('#getCombinedRequests', () => { 124 | it('is defined', () => { 125 | expect(WfsFilterUtil.getCombinedRequests).toBeDefined(); 126 | }); 127 | 128 | it('tries to create WFS filter for each feature type', () => { 129 | const filterSpy = jest.spyOn(WfsFilterUtil, 'createWfsFilter'); 130 | const searchTerm: string = 'peter'; 131 | WfsFilterUtil.getCombinedRequests(searchConfig, searchTerm); 132 | expect(filterSpy).toHaveBeenCalledTimes(searchConfig.featureTypes!.length); 133 | filterSpy.mockRestore(); 134 | }); 135 | 136 | it('creates WFS GetFeature request body containing queries and filter for each feature type', () => { 137 | const filterSpy = jest.spyOn(WfsFilterUtil, 'createWfsFilter'); 138 | const searchTerm: string = 'peter'; 139 | searchConfig.attributeDetails.featureType[attrName] = stringExactFalse; 140 | const got = WfsFilterUtil.getCombinedRequests(searchConfig, searchTerm) as Element; 141 | expect(got?.tagName).toBe('GetFeature'); 142 | expect(got.querySelectorAll('Query').length).toBe(searchConfig.featureTypes!.length); 143 | expect(filterSpy).toHaveBeenCalledTimes(searchConfig.featureTypes!.length); 144 | got.querySelectorAll('Query').forEach(query => { 145 | expect(query.children[2].tagName).toBe('Filter'); 146 | expect(query.children[2].getElementsByTagName('Literal')[0].innerHTML).toBe(`*${searchTerm}*`); 147 | }); 148 | filterSpy.mockRestore(); 149 | }); 150 | 151 | it('use OL filter instance if olFilterOnly property is set to true', () => { 152 | const filterSpy = jest.spyOn(WfsFilterUtil, 'createWfsFilter'); 153 | const searchTerm: string = 'peter'; 154 | const searchTerm2: number = 5; 155 | 156 | const olFilter = new GreaterThanOrEqualTo('testProperty', searchTerm2); 157 | const testConfig: SearchConfig = { 158 | ...searchConfig, 159 | filter: olFilter, 160 | olFilterOnly: true 161 | }; 162 | 163 | const res = WfsFilterUtil.getCombinedRequests(testConfig, searchTerm); 164 | expect(res?.tagName).toBe('GetFeature'); 165 | expect(res?.querySelectorAll('Query').length).toBe(searchConfig.featureTypes!.length); 166 | expect(filterSpy).toHaveBeenCalledTimes(0); 167 | res?.querySelectorAll('Query').forEach(query => { 168 | expect(query.children[2].tagName).toBe('Filter'); 169 | expect(query.children[2].getElementsByTagName('Literal')[0].innerHTML).toEqual(`${searchTerm2}`); 170 | expect(query.getElementsByTagName('PropertyIsGreaterThanOrEqualTo')[0]).toBeDefined(); 171 | }); 172 | filterSpy.mockRestore(); 173 | }); 174 | 175 | it('creates WFS GetFeature request body containing queries and combined filters for each feature type', () => { 176 | const filterSpy = jest.spyOn(WfsFilterUtil, 'createWfsFilter'); 177 | const searchTerm: string = 'peter'; 178 | const searchTerm2: number = 5; 179 | const olFilter = new GreaterThanOrEqualTo('anotherTestAttribute', searchTerm2); 180 | const testConfig: SearchConfig = { 181 | ...searchConfig, 182 | filter: olFilter 183 | }; 184 | testConfig.attributeDetails.featureType[attrName] = stringExactFalse; 185 | const got = WfsFilterUtil.getCombinedRequests(testConfig, searchTerm) as Element; 186 | expect(got?.tagName).toBe('GetFeature'); 187 | expect(got.querySelectorAll('Query').length).toBe(searchConfig.featureTypes!.length); 188 | expect(filterSpy).toHaveBeenCalledTimes(searchConfig.featureTypes!.length); 189 | got.querySelectorAll('Query').forEach(query => { 190 | expect(query.children[2].tagName).toBe('Filter'); 191 | expect(query.children[2].getElementsByTagName('Literal')[0].innerHTML).toBe(`*${searchTerm}*`); 192 | expect(query.children[2].getElementsByTagName('And')[0]).toBeDefined(); 193 | expect( 194 | query.children[2].getElementsByTagName('And')[0]. 195 | getElementsByTagName('PropertyIsGreaterThanOrEqualTo')[0]. 196 | getElementsByTagName('Literal')[0].innerHTML 197 | ).toEqual(`${searchTerm2}`); 198 | }); 199 | filterSpy.mockRestore(); 200 | }); 201 | }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /src/WfsFilterUtil/WfsFilterUtil.ts: -------------------------------------------------------------------------------- 1 | import _isNil from 'lodash/isNil.js'; 2 | import OlFilter from 'ol/format/filter/Filter.js'; 3 | import { and, equalTo, like, or } from 'ol/format/filter.js'; 4 | import OlFormatWFS, { WriteGetFeatureOptions } from 'ol/format/WFS.js'; 5 | 6 | export interface AttributeSearchSettings { 7 | exactSearch?: boolean; 8 | matchCase?: boolean; 9 | type: 'number' | 'int' | 'string'; 10 | } 11 | 12 | /** 13 | * A nested object mapping feature types to an object of their attribute details. 14 | * 15 | * Example: 16 | * ``` 17 | * attributeDetails: { 18 | * featType1: { 19 | * attr1: { 20 | * matchCase: true, 21 | * type: 'number', 22 | * exactSearch: false 23 | * }, 24 | * attr2: { 25 | * matchCase: false, 26 | * type: 'string', 27 | * exactSearch: true 28 | * } 29 | * }, 30 | * featType2: {...} 31 | * } 32 | * ``` 33 | */ 34 | export type AttributeDetails = Record>; 35 | 36 | export interface SearchConfig { 37 | attributeDetails: AttributeDetails; 38 | featureNS: string; 39 | featurePrefix: string; 40 | featureTypes?: string[]; 41 | filter?: OlFilter; 42 | geometryName?: string; 43 | maxFeatures?: number; 44 | olFilterOnly?: boolean; 45 | outputFormat?: string; 46 | propertyNames?: string[]; 47 | srsName?: string; 48 | wfsFormatOptions?: string; 49 | } 50 | 51 | /** 52 | * Helper class for building filters to be used with WFS GetFeature requests. 53 | * 54 | * @class WfsFilterUtil 55 | */ 56 | class WfsFilterUtil { 57 | 58 | /** 59 | * Creates a filter for a given feature type considering configured 60 | * search attributes, mapped features types to an array of attribute details and the 61 | * current search term. 62 | * Currently, supports EQUAL_TO and LIKE filters only, which can be combined with 63 | * OR filter if searchAttributes array contains multiple values though. 64 | * 65 | * @param featureType 66 | * @param {string} searchTerm Search value. 67 | * @param attributeDetails 68 | * attributes that should be searched through. 69 | * @return {OlFilter} Filter to be used with WFS GetFeature requests. 70 | * @private 71 | */ 72 | static createWfsFilter( 73 | featureType: string, 74 | searchTerm: string, 75 | attributeDetails: AttributeDetails 76 | ): OlFilter | undefined { 77 | 78 | const details = attributeDetails[featureType]; 79 | 80 | if (_isNil(details)) { 81 | return undefined; 82 | } 83 | 84 | const attributes = Object.keys(details); 85 | 86 | if (attributes.length === 0) { 87 | return undefined; 88 | } 89 | 90 | const propertyFilters = attributes 91 | .filter(attribute => { 92 | const filterDetails = details[attribute]; 93 | const type = filterDetails.type; 94 | return !(type && (type === 'int' || type === 'number') && searchTerm.match(/[^.\d]/)); 95 | }) 96 | .map(attribute => { 97 | const filterDetails = details[attribute]; 98 | if (filterDetails.exactSearch) { 99 | return equalTo(attribute, searchTerm, filterDetails.exactSearch); 100 | } else { 101 | return like(attribute, 102 | `*${searchTerm}*`, '*', '.', '!', 103 | filterDetails.matchCase ?? false); 104 | } 105 | }); 106 | if (Object.keys(propertyFilters).length > 1) { 107 | return or(...propertyFilters); 108 | } else { 109 | return propertyFilters[0]; 110 | } 111 | } 112 | 113 | /** 114 | * Creates GetFeature request body for all provided featureTypes and 115 | * applies related filter encoding on it. 116 | * 117 | * @param {SearchConfig} searchConfig The search config 118 | * @param {string} searchTerm Search string to be used with filter. 119 | */ 120 | static getCombinedRequests(searchConfig: SearchConfig, searchTerm: string): Element | undefined { 121 | const { 122 | attributeDetails, 123 | featureNS, 124 | featurePrefix, 125 | featureTypes, 126 | filter, 127 | geometryName, 128 | maxFeatures, 129 | olFilterOnly, 130 | outputFormat, 131 | propertyNames, 132 | srsName 133 | } = searchConfig; 134 | 135 | const requests = featureTypes?.map((featureType: string): any => { 136 | let combinedFilter: OlFilter | undefined; 137 | 138 | // existing OlFilter should be applied to attribute 139 | if (olFilterOnly && !_isNil(filter)) { 140 | combinedFilter = filter; 141 | } else { 142 | const attributeFilter = WfsFilterUtil.createWfsFilter(featureType, searchTerm, attributeDetails); 143 | if (!_isNil(filter) && !_isNil(attributeFilter)) { 144 | combinedFilter = and(attributeFilter, filter); 145 | } else { 146 | combinedFilter = attributeFilter; 147 | } 148 | } 149 | 150 | const wfsFormatOpts: WriteGetFeatureOptions = { 151 | featureNS, 152 | featurePrefix, 153 | featureTypes: [featureType], 154 | geometryName, 155 | maxFeatures, 156 | outputFormat, 157 | srsName 158 | }; 159 | 160 | if (!_isNil(propertyNames)) { 161 | wfsFormatOpts.propertyNames = propertyNames; 162 | } 163 | if (!_isNil(combinedFilter)) { 164 | wfsFormatOpts.filter = combinedFilter; 165 | } 166 | 167 | const wfsFormat: OlFormatWFS = new OlFormatWFS(wfsFormatOpts); 168 | return wfsFormat.writeGetFeature(wfsFormatOpts); 169 | }); 170 | 171 | if (_isNil(requests)) { 172 | return undefined; 173 | } 174 | const request = requests[0]; 175 | requests.forEach((req, idx) => { 176 | if (idx !== 0 && req.querySelector('Query')) { 177 | request.appendChild(req.querySelector('Query')); 178 | } 179 | }); 180 | return request; 181 | } 182 | } 183 | 184 | export default WfsFilterUtil; 185 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'polygon-splitter'; 2 | declare module 'shp-write'; 3 | declare module '*.json'; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {default as AnimateUtil} from './AnimateUtil/AnimateUtil'; 2 | export {default as CapabilitiesUtil} from './CapabilitiesUtil/CapabilitiesUtil'; 3 | export {default as FeatureUtil} from './FeatureUtil/FeatureUtil'; 4 | export {default as FileUtil} from './FileUtil/FileUtil'; 5 | export {default as GeometryUtil} from './GeometryUtil/GeometryUtil'; 6 | export {default as LayerUtil} from './LayerUtil/LayerUtil'; 7 | export {default as MapUtil} from './MapUtil/MapUtil'; 8 | export {default as MeasureUtil} from './MeasureUtil/MeasureUtil'; 9 | export {default as PermalinkUtil} from './PermalinkUtil/PermalinkUtil'; 10 | export {default as ProjectionUtil} from './ProjectionUtil/ProjectionUtil'; 11 | export * from './typeUtils/typeUtils'; 12 | export {default as WfsFilterUtil} from './WfsFilterUtil/WfsFilterUtil'; 13 | -------------------------------------------------------------------------------- /src/typeUtils/typeUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import OlBaseLayer from 'ol/layer/Base'; 2 | import OlGraticuleLayer from 'ol/layer/Graticule'; 3 | import OlImageLayer from 'ol/layer/Image'; 4 | import OlLayer from 'ol/layer/Layer'; 5 | import OlTileLayer from 'ol/layer/Tile'; 6 | import OlVectorLayer from 'ol/layer/Vector'; 7 | import OlSourceCluster from 'ol/source/Cluster'; 8 | import OlImageWMS from 'ol/source/ImageWMS'; 9 | import OlTileWMS from 'ol/source/TileWMS'; 10 | import OlSourceVector from 'ol/source/Vector'; 11 | import OlSourceVectorTile from 'ol/source/VectorTile'; 12 | 13 | import { isWmsLayer } from './typeUtils'; 14 | 15 | const getWmsLikeLayers = () => { 16 | return { 17 | 'ol/layer/Layer': new OlLayer({}), 18 | 'ol/layer/Image': new OlImageLayer(), 19 | 'ol/layer/Tile': new OlTileLayer() 20 | }; 21 | }; 22 | 23 | const getWmsSources = () => { 24 | return { 25 | 'ol/source/ImageWMS': new OlImageWMS(), 26 | 'ol/source/TileWMS': new OlTileWMS() 27 | }; 28 | }; 29 | 30 | const getNonWmsLikeLayers = () => { 31 | return { 32 | 'ol/layer/Vector': new OlVectorLayer(), 33 | 'ol/layer/Heatmap': new OlGraticuleLayer() 34 | }; 35 | }; 36 | 37 | const getNonWmsSources = () => { 38 | return { 39 | 'ol/source/Vector': new OlSourceVector(), 40 | 'ol/source/VectorTile': new OlSourceVectorTile({}), 41 | 'ol/source/Cluster': new OlSourceCluster({}) 42 | }; 43 | }; 44 | 45 | 46 | describe('isWmsLayer', () => { 47 | 48 | it('is defined', () => { 49 | expect(isWmsLayer).not.toBeUndefined(); 50 | }); 51 | 52 | it('is a function', () => { 53 | expect(isWmsLayer).toBeInstanceOf(Function); 54 | }); 55 | 56 | it('returns false for an ol base layer', () => { 57 | const layer = new OlBaseLayer({}); 58 | expect(isWmsLayer(layer)).toBe(false); 59 | }); 60 | 61 | describe('Combinations of wms like layers with wms like sources', () => { 62 | const layers = getWmsLikeLayers(); 63 | const sources = getWmsSources(); 64 | Object.keys(layers).forEach(layerClass => { 65 | Object.keys(sources).forEach(sourceClass => { 66 | // @ts-ignore 67 | const layer = layers[layerClass]; 68 | // @ts-ignore 69 | const source = sources[sourceClass]; 70 | layer.setSource(source); 71 | 72 | it(`returns true for ${layerClass} with ${sourceClass}`, () => { 73 | expect(isWmsLayer(layer)).toBe(true); 74 | }); 75 | }); 76 | }); 77 | }); 78 | 79 | describe('Combinations of some non-wms like layers with some non-wms like sources', () => { 80 | const layers = getNonWmsLikeLayers(); 81 | const sources = getNonWmsSources(); 82 | Object.keys(layers).forEach(layerClass => { 83 | Object.keys(sources).forEach(sourceClass => { 84 | // @ts-ignore 85 | const layer = layers[layerClass]; 86 | // @ts-ignore 87 | const source = sources[sourceClass]; 88 | layer.setSource(source); 89 | 90 | it(`returns false for ${layerClass} with ${sourceClass}`, () => { 91 | expect(isWmsLayer(layer)).toBe(false); 92 | }); 93 | }); 94 | }); 95 | }); 96 | 97 | // these fail, but I fear tghey should pass… 98 | // a ol/layer/Heatmap with ol/source/ImageWMS is not a WMS is it? 99 | // 100 | // describe('Combinations of some non-wms like layers with wms like sources', () => { 101 | // const layers = getNonWmsLikeLayers(); 102 | // const sources = getWmsSources(); 103 | // Object.keys(layers).forEach((layerClass) => { 104 | // Object.keys(sources).forEach((sourceClass) => { 105 | // const layer = layers[layerClass]; 106 | // const source = sources[sourceClass]; 107 | // layer.setSource(source); 108 | 109 | // it(`returns false for ${layerClass} with ${sourceClass}`, () => { 110 | // expect(isWmsLayer(layer)).toBe(false); 111 | // }); 112 | // }); 113 | // }); 114 | // }); 115 | 116 | describe('Combinations of some wms like layers with non-wms like sources', () => { 117 | const layers = getWmsLikeLayers(); 118 | const sources = getNonWmsSources(); 119 | Object.keys(layers).forEach((layerClass) => { 120 | Object.keys(sources).forEach((sourceClass) => { 121 | // @ts-ignore 122 | const layer = layers[layerClass]; 123 | // @ts-ignore 124 | const source = sources[sourceClass]; 125 | layer.setSource(source); 126 | 127 | it(`returns false for ${layerClass} with ${sourceClass}`, () => { 128 | expect(isWmsLayer(layer)).toBe(false); 129 | }); 130 | }); 131 | }); 132 | }); 133 | 134 | }); 135 | -------------------------------------------------------------------------------- /src/typeUtils/typeUtils.ts: -------------------------------------------------------------------------------- 1 | import OlBaseLayer from 'ol/layer/Base'; 2 | import OlImageLayer from 'ol/layer/Image'; 3 | import OlLayer from 'ol/layer/Layer'; 4 | import OlTileLayer from 'ol/layer/Tile'; 5 | import OlVectorLayer from 'ol/layer/Vector'; 6 | import OlImageWMS from 'ol/source/ImageWMS'; 7 | import OlTileWMS from 'ol/source/TileWMS'; 8 | import OlSourceVector from 'ol/source/Vector'; 9 | import OlSourceWMTS from 'ol/source/WMTS'; 10 | 11 | export type WmsLayer = OlImageLayer | OlTileLayer | OlLayer; 12 | 13 | export type WmtsLayer = OlTileLayer; 14 | 15 | export type WfsLayer = OlVectorLayer; 16 | 17 | export function isWmsLayer(layer: OlBaseLayer): layer is WmsLayer { 18 | if (layer instanceof OlLayer) { 19 | const source = layer.getSource(); 20 | return source instanceof OlImageWMS || source instanceof OlTileWMS; 21 | } 22 | return false; 23 | } 24 | 25 | export function isWmtsLayer(layer: OlBaseLayer): layer is WmtsLayer { 26 | if (layer instanceof OlLayer) { 27 | const source = layer.getSource(); 28 | return source instanceof OlSourceWMTS; 29 | } 30 | return false; 31 | } 32 | 33 | 34 | export function isWfsLayer(layer: OlLayer): layer is WfsLayer { 35 | return (layer instanceof OlVectorLayer && layer.getSource() instanceof OlSourceVector); 36 | } 37 | -------------------------------------------------------------------------------- /tasks/update-gh-pages.js: -------------------------------------------------------------------------------- 1 | /*eslint-env node*/ 2 | /*eslint-disable no-console */ 3 | 4 | const ghpages = require('gh-pages'); 5 | const url = require('url'); 6 | 7 | const packageDef = require('../package.json'); 8 | 9 | const version = packageDef.version; 10 | const repoUrl = packageDef.repository.url; 11 | const parsedRepoUrl = url.parse(repoUrl); 12 | const gitRepoUrl = `git@${parsedRepoUrl.host}:${parsedRepoUrl.path}`; 13 | 14 | const message = 'Update resources on gh-pages branch'; 15 | 16 | // Publish the current version in the versioned directory. 17 | ghpages.publish('build/docs', { 18 | dest: `${version}`, 19 | message: message, 20 | repo: gitRepoUrl, 21 | add: true 22 | }, function(err) { 23 | if (err) { 24 | console.log(`Error while deploying docs to gh-pages (versioned): ${err.message}`); 25 | } else { 26 | console.log(`Successfully deployed docs to gh-pages (versioned)!`); 27 | 28 | // Publish the current version in the 'latest' directory. 29 | ghpages.publish('build/docs', { 30 | dest: `latest`, 31 | message: message, 32 | repo: gitRepoUrl, 33 | add: false 34 | }, function(err) { 35 | if (err) { 36 | console.log(`Error while deploying docs to gh-pages (latest): ${err.message}`); 37 | } else { 38 | console.log(`Successfully deployed docs to gh-pages (latest)!`); 39 | } 40 | }); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "allowJs": false, 5 | "allowSyntheticDefaultImports": true, 6 | "declaration": true, 7 | "rootDir": "./src/", 8 | "forceConsistentCasingInFileNames": true, 9 | "jsx": "react", 10 | "lib": [ 11 | "es7", 12 | "dom", 13 | "esnext" 14 | ], 15 | "module": "ES2022", 16 | "moduleResolution": "node", 17 | "strict": true, 18 | "strictNullChecks": true, 19 | "noImplicitAny": true, 20 | "noImplicitReturns": true, 21 | "noImplicitThis": true, 22 | "noUnusedLocals": true, 23 | "outDir": "dist", 24 | "resolveJsonModule": true, 25 | "skipLibCheck": true, 26 | "sourceMap": true, 27 | "target": "ES2022" 28 | }, 29 | "include": [ 30 | "src" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "readme": "none" 4 | } 5 | -------------------------------------------------------------------------------- /watchBuild.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable require-jsdoc */ 3 | /* eslint-disable no-console */ 4 | 5 | 'use strict'; 6 | 7 | const fs = require('fs-extra'); 8 | const path = require('path'); 9 | const watch = require('watch'); 10 | const util = require('util'); 11 | const exec = util.promisify(require('child_process').exec); 12 | 13 | const curDir = process.cwd(); 14 | 15 | if (process.argv.length < 3) { 16 | console.log('please specify target path'); 17 | console.log('for example ../react-geo/node_modules/@terrestris/react-geo/'); 18 | process.exit(0); 19 | } 20 | 21 | const sourcePath = path.join(curDir, 'src'); 22 | const distPath = path.join(curDir, 'dist'); 23 | const targetSourcePath = path.join(curDir, process.argv[2], 'src'); 24 | const targetDistPath = path.join(curDir, process.argv[2], 'dist'); 25 | 26 | if (!fs.existsSync(targetSourcePath) || !fs.existsSync(targetDistPath) ) { 27 | throw new Error('target does not exist'); 28 | } 29 | 30 | async function buildAndCopy() { 31 | console.log('run build:dist'); 32 | 33 | try { 34 | const { stdout, stderr} = await exec('npm run build:dist'); 35 | console.log(stdout); 36 | console.log(stderr); 37 | 38 | console.log('copy dist / src'); 39 | await fs.copy(distPath, targetDistPath); 40 | await fs.copy(sourcePath, targetSourcePath); 41 | 42 | console.log('done'); 43 | } catch (error) { 44 | console.log('error'); 45 | const { stdout, stderr } = error; 46 | console.log(stdout); 47 | console.log(stderr); 48 | } 49 | } 50 | 51 | buildAndCopy(); 52 | 53 | let timeout; 54 | 55 | function throttle(callback, time) { 56 | if (!timeout) { 57 | timeout = setTimeout(function () { 58 | timeout = null; 59 | callback(); 60 | }, time); 61 | } 62 | } 63 | 64 | // eslint-disable-next-line no-unused-vars 65 | watch.watchTree(sourcePath, function (f, curr, prev) { 66 | if (typeof f === 'object') { 67 | console.log('watching'); 68 | } else { 69 | throttle(buildAndCopy, 1000); 70 | } 71 | }); 72 | --------------------------------------------------------------------------------