├── .eslintrc.json ├── .fixpackrc ├── .github ├── stale.yml └── workflows │ ├── conventional-pr-title.yml │ ├── cypress.yml │ └── lint.yml ├── .gitignore ├── .husky ├── commit-msg ├── post-checkout ├── post-merge ├── post-rebase └── pre-commit ├── .lintstagedrc.json ├── .npmignore ├── .nvmrc ├── .prettierrc.json ├── .stylelintrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api.html ├── commitlint.config.js ├── config └── tsconfig-build.json ├── cypress.config.ts ├── cypress └── e2e │ ├── control │ ├── cad.spec.js │ ├── difference.spec.js │ ├── draw.spec.js │ ├── intersection.spec.js │ ├── modify.spec.js │ └── union.spec.js │ └── ole.spec.js ├── img ├── buffer.svg ├── cad.svg ├── difference.svg ├── draw_line.svg ├── draw_point.svg ├── draw_polygon.svg ├── intersection.svg ├── modify_geometry.svg ├── modify_geometry2.svg ├── rotate.svg ├── rotate_map.svg └── union.svg ├── index.html ├── jsdoc_conf.json ├── package.json ├── pull_request_template.md ├── renovate.json ├── src ├── control │ ├── buffer.js │ ├── cad.js │ ├── control.js │ ├── difference.js │ ├── draw.js │ ├── index.js │ ├── intersection.js │ ├── modify.js │ ├── rotate.js │ ├── toolbar.js │ ├── topology.js │ └── union.js ├── editor.js ├── editor.test.js ├── event │ ├── delete-event.js │ ├── index.js │ ├── move-event.js │ └── snap-event.js ├── helper │ ├── constants.js │ ├── getDistance.js │ ├── getEquationOfLine.js │ ├── getIntersectedLinesAndPoint.js │ ├── getIntersectedLinesAndPoint.test.js │ ├── getProjectedPoint.js │ ├── getShiftedMultiPoint.js │ ├── index.js │ ├── isSameLines.js │ ├── isSameLines.test.js │ └── parser.js ├── index.js ├── interaction │ ├── delete.js │ ├── index.js │ ├── move.js │ ├── selectmodify.js │ └── selectmove.js └── service │ ├── index.js │ ├── local-storage.js │ ├── service.js │ └── storage.js ├── style ├── ole.css └── style.css ├── tasks └── prepare-package.mjs ├── vitest.config.mjs └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "cypress/globals": true, 4 | "node": true, 5 | "browser": true, 6 | "es6": true 7 | }, 8 | "extends": ["eslint-config-airbnb-base", "prettier"], 9 | "parserOptions": { 10 | "ecmaVersion": 2022 11 | }, 12 | "plugins": ["cypress", "prettier"], 13 | "rules": { 14 | "prettier/prettier": "error" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.fixpackrc: -------------------------------------------------------------------------------- 1 | { 2 | "sortToTop": [ 3 | "name", 4 | "license", 5 | "description", 6 | "version", 7 | "author", 8 | "homepage", 9 | "private", 10 | "type", 11 | "main", 12 | "module", 13 | "files", 14 | "exports", 15 | "proxy", 16 | "dependencies", 17 | "peerDependencies", 18 | "devDependencies", 19 | "resolutions", 20 | "scripts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: > 18 | This issue has been automatically closed because it has not had 19 | recent activity. Feel free to reopen it. Thank you 20 | for your contributions. 21 | -------------------------------------------------------------------------------- /.github/workflows/conventional-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | main: 15 | name: Validate PR title 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v5 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | container: 9 | image: cypress/browsers:node-20.9.0-chrome-118.0.5993.88-1-ff-118.0.2-edge-118.0.2088.46-1 10 | options: --user 1001 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - run: yarn install --frozen-lockfile 15 | - run: yarn cypress info 16 | - run: node --version 17 | - run: node -p 'os.cpus()' 18 | - run: yarn cy:test 19 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - uses: actions/setup-node@v3 17 | with: 18 | cache: yarn 19 | node-version: latest 20 | 21 | - name: Install dependencies 22 | run: yarn install --frozen-lockfile 23 | 24 | - name: Run linting 25 | run: yarn lint 26 | 27 | - name: Run testing 28 | run: yarn test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | build 4 | doc 5 | yarn-error.log 6 | cypress/videos 7 | cypress/support 8 | .vscode 9 | .eslintcache -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn install --frozen-lockfile 5 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn install --frozen-lockfile 5 | -------------------------------------------------------------------------------- /.husky/post-rebase: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn install --frozen-lockfile 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | if git grep -n -E \"5cc87b12d7c5370001c1d656\(5\)\"; 5 | then 6 | echo \"Remove private geOps API keys !!!!\"; 7 | exit 1; 8 | else 9 | CI=true npx lint-staged 10 | fi; 11 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "(src|__mocks__)/**/*.js": ["eslint --fix", "prettier --write"], 3 | "package.json": ["yarn fixpack"], 4 | "src/**/*.{css,scss}": ["stylelint --fix"] 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | doc 2 | package-lock.json 3 | yarn.lock 4 | yarn-error.log 5 | cypress/videos 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [2.4.5](https://github.com/geops/openlayers-editor/compare/v2.4.4...v2.4.5) (2024-09-04) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * avoid js error when map is not set when activate/deactivate is called ([c808a36](https://github.com/geops/openlayers-editor/commit/c808a36a6eaa5704f650adfd4b6176b0c7fc96cd)) 11 | * improve cursor behavior on pointer down and up in modify control ([5eb32b9](https://github.com/geops/openlayers-editor/commit/5eb32b9be4f4cc23dc4a17c67f459c55072f5fcf)) 12 | * use viewport to set cursor style ([62a427d](https://github.com/geops/openlayers-editor/commit/62a427d8830b1641aeec7d3a03f4e266cae3eac8)) 13 | 14 | ### [2.4.4](https://github.com/geops/openlayers-editor/compare/v2.4.3...v2.4.4) (2024-08-20) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * add new option cursorStyleHandler to change cursor style value ([ceb4bff](https://github.com/geops/openlayers-editor/commit/ceb4bff19ce9adee53e94b25d2ef13204504a7f0)) 20 | * display points of all linear rings of a polygon ([027b52b](https://github.com/geops/openlayers-editor/commit/027b52bb8e99aab81de84e4ae4db28155e86a910)) 21 | * throttle the cursor style change in modify control ([5175746](https://github.com/geops/openlayers-editor/commit/5175746aff4ab34171b8765df26df6e6debec14d)) 22 | 23 | ### [2.4.3](https://github.com/geops/openlayers-editor/compare/v2.4.2...v2.4.3) (2024-06-25) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * hide topology errors in line hit detection & optimizations ([ae85670](https://github.com/geops/openlayers-editor/commit/ae856703dd11a99bd2fe35ede6977ce50670831b)) 29 | 30 | ### [2.4.2](https://github.com/geops/openlayers-editor/compare/v2.4.1...v2.4.2) (2024-05-14) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * fix: listen only when possible ([5a2c042](https://github.com/geops/openlayers-editor/commit/5a2c042cf2fa54cc7f4e56432eb2191f1d034aac)) 36 | * fix: unlisten only when possible ([b1029294](https://github.com/geops/openlayers-editor/commit/b1029294f84e9b2c0c7c409a17b4cdf32cf91ac2)) 37 | 38 | ### [2.4.1](https://github.com/geops/openlayers-editor/compare/v2.4.0...v2.4.1) (2024-05-14) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * fix editor.remove and add a test ([1427d6e](https://github.com/geops/openlayers-editor/commit/1427d6e25edab0088ff327de98d4d45efb414b5c)) 44 | 45 | ## [2.4.0](https://github.com/geops/openlayers-editor/compare/v2.3.0...v2.4.0) (2024-05-10) 46 | 47 | 48 | ### Features 49 | 50 | * add editor#removeControl ([#266](https://github.com/geops/openlayers-editor/issues/266)) ([cd4c777](https://github.com/geops/openlayers-editor/commit/cd4c7772cf9be101a00d2c226a564de815732b49)) 51 | * add extentFilter property in CAD control ([aa160a4](https://github.com/geops/openlayers-editor/commit/aa160a437f368799f6f31557aa2445b83afda22e)) 52 | * add line filter for reducing line clutter ([#265](https://github.com/geops/openlayers-editor/issues/265)) ([842e836](https://github.com/geops/openlayers-editor/commit/842e836b44a9faf63a03a11affd5930dc5522e55)) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * cleanup map events unmounting cad control ([#264](https://github.com/geops/openlayers-editor/issues/264)) ([3907615](https://github.com/geops/openlayers-editor/commit/390761571f8146c62d4dc85ef4255210a4b592ec)) 58 | 59 | ## [2.3.0](https://github.com/geops/openlayers-editor/compare/v2.2.1...v2.3.0) (2024-05-10) 60 | 61 | 62 | ### Features 63 | 64 | * allow to modifiy circle geometry ([#270](https://github.com/geops/openlayers-editor/issues/270)) ([18deb75](https://github.com/geops/openlayers-editor/commit/18deb75c672c4d25e659598e7dd87386b24ffe73)) 65 | 66 | ### [2.2.1](https://github.com/geops/openlayers-editor/compare/v2.2.0...v2.2.1) (2024-03-05) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * update JSDoc comments to improve TypeScript definitions ([#269](https://github.com/geops/openlayers-editor/issues/269)) ([769c1bc](https://github.com/geops/openlayers-editor/commit/769c1bc48f426310cece9a9b0444edd6c77895b3)) 72 | 73 | ## [2.2.0](https://github.com/geops/openlayers-editor/compare/v2.1.2...v2.2.0) (2023-05-04) 74 | 75 | 76 | ### Features 77 | 78 | * support OpenLayers 7.x support 79 | 80 | * add TypeScript definitions ([#256](https://github.com/geops/openlayers-editor/issues/256)) ([f6db2f6](https://github.com/geops/openlayers-editor/commit/f6db2f6ae37b21a7919428841ce421c07882772f)) 81 | 82 | * add more snap lines in the CAD control. Configurable via showOrthoLines and showSegmentLines properties 83 | 84 | ### Bug Fixes 85 | 86 | * use JSDoc for param documentation ([51f8445](https://github.com/geops/openlayers-editor/commit/51f84459fb07529456f6b94c0faf136f63e9221a)) 87 | 88 | ### [2.1.2](https://github.com/geops/openlayers-editor/compare/v2.1.1...v2.1.2) (2022-08-11) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * **DrawControl:** add drawInteractionOptions for DrawControl ([#239](https://github.com/geops/openlayers-editor/issues/239)) ([e1ceb7c](https://github.com/geops/openlayers-editor/commit/e1ceb7c0a62c36658d231236be84773890d2e609)) 94 | 95 | ### [2.1.1](https://github.com/geops/openlayers-editor/compare/v2.1.0...v2.1.1) (2022-08-10) 96 | 97 | 98 | ### Bug Fixes 99 | 100 | * **CAD:** snap enabled on edit feature vertices, CAD support for rotated maps ([#234](https://github.com/geops/openlayers-editor/issues/234)) ([0d706af](https://github.com/geops/openlayers-editor/commit/0d706af65bce2759e61dbae5d9ca359940251573)) 101 | 102 | ## [2.1.0](https://github.com/geops/openlayers-editor/compare/v2.0.1...v2.1.0) (2022-06-20) 103 | 104 | 105 | ### Features 106 | 107 | * use conventional-pr-title for PR title validation ([#227](https://github.com/geops/openlayers-editor/issues/227)) ([607e5ec](https://github.com/geops/openlayers-editor/commit/607e5ec3d4aa4849f4b6b7e7acc827728f70a36c)) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * **demo:** Update style URL ([#222](https://github.com/geops/openlayers-editor/issues/222)) ([63da714](https://github.com/geops/openlayers-editor/commit/63da714cce7ad7660b99218be153644e1f193d01)) 113 | 114 | ### [2.0.1](https://github.com/geops/openlayers-editor/compare/v2.0.0...v2.0.1) (2021-01-26) 115 | 116 | 117 | ### Bug Fixes 118 | 119 | * **cad:** allow a snap distance of 0 ([#220](https://github.com/geops/openlayers-editor/issues/220)) ([8eebfaf](https://github.com/geops/openlayers-editor/commit/8eebfafc8eadbdb3d1d4686b75b6c7075d3ff15c)) 120 | 121 | ## [2.0.0](https://github.com/geops/openlayers-editor/compare/v1.4.0-beta.1...v2.0.0) (2020-11-18) 122 | 123 | 124 | ### ⚠ BREAKING CHANGES 125 | 126 | * **modify:** the ole.control.modify is completely refactored (check 127 | docs) 128 | 129 | * **modify:** added selectFilter to getFeatureAtPixel() ([007ad16](https://github.com/geops/openlayers-editor/commit/007ad162872444c7b8e2eb8e39a5f52009caa317)) 130 | 131 | ## [1.2.0](https://github.com/geops/openlayers-editor/compare/v1.1.6...v1.2.0) (2020-11-12) 132 | 133 | 134 | ### Features 135 | 136 | * **version:** Installed standard-version to improve changelog ([00466c5](https://github.com/geops/openlayers-editor/commit/00466c56f0695bb62115159c1918704668d6266d)) 137 | * **version:** using standard-version and conventional-commits for version and release management, switched to yarn package manager ([ef321f9](https://github.com/geops/openlayers-editor/commit/ef321f9434501c398d6269c695c8aa4a3ff0cb7d)) 138 | 139 | ## [1.2.0-beta.1](https://github.com/geops/openlayers-editor/compare/v1.2.0-beta.0...v1.2.0-beta.1) (2020-11-11) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * **index:** fix demo page ([7cfab51](https://github.com/geops/openlayers-editor/commit/7cfab511f62de9bafb1945ac4b18a5fc7a495b38)) 145 | * remove unecessary properties ([24b2357](https://github.com/geops/openlayers-editor/commit/24b23571ecc2d2342fe53d840df10f3da8ba029e)) 146 | * use singleclick instead of click to avoid bugs ([37e1d9d](https://github.com/geops/openlayers-editor/commit/37e1d9d6b5889071184fb1dc996c300cefdf629a)) 147 | * **index:** fix demo page ([ea49c0a](https://github.com/geops/openlayers-editor/commit/ea49c0ad4dfaa8d230d742bd63a54a6f9b860677)) 148 | * **modify:** add a high zIndex to default modify style ([f3584b1](https://github.com/geops/openlayers-editor/commit/f3584b17ef915e2362f1fdca7563ee492af1234a)) 149 | * **modify:** use click instead of singleclick ([ecea48c](https://github.com/geops/openlayers-editor/commit/ecea48c4b94f64625289d4966bc1bcc9bfe5bf39)) 150 | * **selectmove:** add a default style with a high zIndex to selectmove interaction ([2222aaa](https://github.com/geops/openlayers-editor/commit/2222aaac49c93163077d4a0914118755a5b742d4)) 151 | 152 | ## [1.2.0-beta.0](https://github.com/geops/openlayers-editor/compare/v1.1.6...v1.2.0-beta.0) (2020-11-11) 153 | 154 | 155 | ### Features 156 | 157 | * **version:** Installed standard-version to improve changelog ([00466c5](https://github.com/geops/openlayers-editor/commit/00466c56f0695bb62115159c1918704668d6266d)) 158 | * **version:** using standard-version and conventional-commits for version and release management, switched to yarn package manager ([ef321f9](https://github.com/geops/openlayers-editor/commit/ef321f9434501c398d6269c695c8aa4a3ff0cb7d)) 159 | 160 | ### [1.1.8-beta.6](https://github.com/geops/openlayers-editor/compare/v1.1.8-beta.2...v1.1.8-beta.6) (2020-11-09) 161 | 162 | 163 | ### Features 164 | 165 | * **version:** Installed standard-version to improve changelog ([00466c5](https://github.com/geops/openlayers-editor/commit/00466c56f0695bb62115159c1918704668d6266d)) 166 | 167 | 168 | ### Bug Fixes 169 | 170 | * **version:** added yarn release script ([a62a548](https://github.com/geops/openlayers-editor/commit/a62a548b3e22b6008c5df69e0531b8759528b1db)) 171 | 172 | ## 0.0.3 - 2019-03-11 173 | ### Added 174 | - Support for Internet Explorer >= 10 has been added. 175 | - Support for ol 5.3.0. 176 | - Add style options to some controls. 177 | 178 | ### Changed 179 | - Move JSTS to peer dependency to fix broken builds and reduce build size. 180 | 181 | ## 0.0.1 - 2018-03-20 182 | ### Added 183 | - Using [JSTS](https://github.com/bjornharrtell/jsts) for topology operations. 184 | - Implemented control for buffering geometries. 185 | - Added a generic topology control. 186 | - Derived controls from topology control for creating a union, an intersection or a difference of geometries. 187 | - Added tests using [Cypress](https://cypress.io/) 188 | 189 | ### Changed 190 | - Using [Neutrino](https://neutrino.js.org/) for build and development process. 191 | - Switched from PNG to SVG images for control icons. 192 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, geOps 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Openlayers Editor 2 | 3 | ![npm](https://img.shields.io/npm/v/ole) 4 | ![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg) 5 | ![code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square) 6 | ![unit tests](https://github.com/geops/openlayers-editor/actions/workflows/lint.yml/badge.svg) 7 | ![e2e tests](https://github.com/geops/openlayers-editor/actions/workflows/cypress.yml/badge.svg) 8 | ![Deploy](https://vercelbadge.vercel.app/api/geops/openlayers-editor) 9 | 10 | OpenLayers Editor (OLE) is based on [OpenLayers](https://openlayers.org/) and provides a set of controls for extended editing of spatial data. 11 | 12 | Contributions are welcome! Feel free to add more controls and to extend the current functionality. 13 | Additionally, the build process is currently very basic and could be optimized. 14 | Translations would be nice, too. 15 | 16 | ## Features 17 | 18 | - CAD tool for geometry alignment 19 | - Drawing line, point and polygon features 20 | - Moving and rotating geometries 21 | - Modifying geometries 22 | - Deleting geometries 23 | - Topology operations using [JSTS](https://github.com/bjornharrtell/jsts): buffer, union, intersection, difference 24 | - Toolbar for activating and deactivating controls 25 | 26 | ## Demo 27 | 28 | For a demo, visit [https://openlayers-editor.geops.com](https://openlayers-editor.geops.com). 29 | 30 | ## Dependencies 31 | 32 | - node & npm 33 | 34 | ## Getting started 35 | 36 | - Clone this repository 37 | - Install: `yarn install` 38 | - Build: `yarn build` 39 | - Run: `yarn start` 40 | - Open your browser and visit [http://localhost:8080](http://localhost:8080) 41 | 42 | ## Usage 43 | 44 | ```html 45 | 46 | 47 | ``` 48 | 49 | ```js 50 | var editor = new ole.Editor(map); 51 | 52 | var cad = new ole.control.CAD({ 53 | source: editLayer.getSource() 54 | }); 55 | 56 | var draw = new ole.control.Draw({ 57 | source: editLayer.getSource() 58 | }); 59 | 60 | editor.addControls([draw, cad]); 61 | 62 | ``` 63 | 64 | ### Versions and Changelog 65 | 66 | This repo uses [standard-version](https://github.com/conventional-changelog/standard-version/) for release versioning and changelog management. Therefore updates should be committed using [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) messages: 67 | 68 | ```text 69 | 70 | [optional scope]: 71 | 72 | [optional body] 73 | 74 | [optional footer(s)] 75 | ``` 76 | 77 | The commit contains the following structural elements, to communicate intent to the consumers of your library: 78 | 79 | 1. fix: a commit of the type fix patches a bug in your codebase (this correlates with PATCH in semantic versioning). 80 | 2. feat: a commit of the type feat introduces a new feature to the codebase (this correlates with MINOR in semantic versioning). 81 | 3. BREAKING CHANGE: a commit that has a footer BREAKING CHANGE:, or appends a ! after the type/scope, introduces a breaking API change (correlating with MAJOR in semantic versioning). A BREAKING CHANGE can be part of commits of any type. 82 | 4. types other than fix: and feat: are allowed, for example @commitlint/config-conventional (based on the the Angular convention) recommends build:, chore:, ci:, docs:, style:, refactor:, perf:, test:, and others. 83 | 5. footers other than BREAKING CHANGE: may be provided and follow a convention similar to git trailer format. 84 | 85 | Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in semantic versioning (unless they include a BREAKING CHANGE). A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays. 86 | 87 | ## Contributing 88 | 89 | All PRs are welcome and will be reviewed soon or later. Please make sure to follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. 90 | -------------------------------------------------------------------------------- /api.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenLayers Editor 4 | 5 | 6 | 7 | 8 |
9 | 24 | 25 | 30 |
31 | 32 | 33 | 34 | 54 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /config/tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | "allowJs": true /* Allow javascript files to be compiled. */, 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true /* Generates corresponding '.d.ts' file. */, 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": false /* Generates corresponding '.map' file. */, 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "../build" /* Redirect output structure to the directory. */, 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | "importHelpers": false /* Import emit helpers from 'tslib'. */, 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": false /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | "strictNullChecks": true /* Enable strict null checks. */, 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": false /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | "inlineSources": false /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */, 55 | "skipLibCheck": true 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | }, 60 | "include": ["../src/**/*.js"], 61 | "exclude": [] 62 | } 63 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | projectId: 'f88nv7', 5 | e2e: { 6 | baseUrl: 'http://localhost:8000', 7 | specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', 8 | supportFile: false, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /cypress/e2e/control/cad.spec.js: -------------------------------------------------------------------------------- 1 | const FORCE = { force: true }; 2 | 3 | const coordToFixed = (coordArray, decimals) => { 4 | const arr = [ 5 | parseFloat(coordArray[0].toFixed(decimals)), 6 | parseFloat(coordArray[1].toFixed(decimals)), 7 | ]; 8 | return arr; 9 | }; 10 | 11 | describe('CAD control', () => { 12 | beforeEach(() => { 13 | cy.visit('/'); 14 | 15 | // Draw point (click on map canvas container at x: 500 and y: 500) 16 | cy.get('[title="Draw Point"]').click(); 17 | cy.get('.ol-overlaycontainer').click(500, 500, FORCE); 18 | }); 19 | 20 | it.only('should not snap new points when CAD deactivated', () => { 21 | cy.window().then((win) => { 22 | // Draw new point (click on map canvas container at x: 507 and y: 500) 23 | cy.get('.ol-overlaycontainer') 24 | .click(507, 500, FORCE) 25 | .then(() => { 26 | const newPoint = win.editLayer.getSource().getFeatures()[1]; 27 | // New point should not have additional snapping distance in coordinate 28 | expect( 29 | JSON.stringify(newPoint.getGeometry().getCoordinates()), 30 | ).to.equal( 31 | JSON.stringify(win.map.getCoordinateFromPixel([507, 500])), 32 | ); 33 | }); 34 | }); 35 | }); 36 | 37 | it('should snap new points to CAD point with CAD active', () => { 38 | cy.window().then((win) => { 39 | // Activate CAD control (click on toolbar) 40 | cy.get('.ole-control-cad').click(); 41 | // Draw new point (click on map canvas container at x: 507 and y: 500) 42 | cy.get('.ol-overlaycontainer') 43 | .click(507, 500, FORCE) 44 | .then(() => { 45 | const snapDistance = win.cad.properties.snapPointDist; 46 | const newPoint = win.editLayer.getSource().getFeatures()[1]; 47 | // New point should have added snapping distance (use toFixed to ignore micro differences) 48 | expect( 49 | JSON.stringify( 50 | coordToFixed(newPoint.getGeometry().getCoordinates(), 5), 51 | ), 52 | ).to.equal( 53 | JSON.stringify( 54 | coordToFixed( 55 | win.map.getCoordinateFromPixel([500 + snapDistance, 500]), 56 | 5, 57 | ), 58 | ), 59 | ); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /cypress/e2e/control/difference.spec.js: -------------------------------------------------------------------------------- 1 | const FORCE = { force: true }; 2 | 3 | describe('Difference control', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | 7 | // Draw polygon (click on map container, double click to finish drawing) 8 | cy.get('[title="Draw Polygon"]').click(); 9 | cy.get('.ol-overlaycontainer').click(500, 200, FORCE); 10 | cy.get('.ol-overlaycontainer').click(600, 400, FORCE); 11 | cy.get('.ol-overlaycontainer').dblclick(400, 400, FORCE); 12 | 13 | // Draw overlapping polygon (click on map container, double click to finish drawing) 14 | cy.get('.ol-overlaycontainer').click(600, 200, FORCE); 15 | cy.get('.ol-overlaycontainer').click(550, 350, FORCE); 16 | cy.get('.ol-overlaycontainer').dblclick(400, 300, FORCE); 17 | }); 18 | 19 | it('should subtract overlapping polygons and result in the correct multipolygon', () => { 20 | cy.window().then((win) => { 21 | // Activate union tool (click on toolbar) 22 | cy.get('.ole-control-difference') 23 | .click() 24 | .then(() => { 25 | // Click on map canvas to select polygon for subtraction 26 | cy.get('.ol-overlaycontainer').click(500, 210, FORCE); 27 | }) 28 | .then(() => { 29 | cy.wait(1000); // Wait to avoid zoom on map due to load races 30 | // Click on map canvas to select polygon to subtract 31 | cy.get('.ol-overlaycontainer').click(580, 220, FORCE); 32 | cy.wait(1000).then(() => { 33 | const united = win.editLayer.getSource().getFeatures()[0]; 34 | // Should result in a multipolygon (thus have two coordinate arrays) 35 | expect(united.getGeometry().getCoordinates().length).to.equal(2); 36 | // First polygon should result in a triangle (3 nodes, 4 coordinates) 37 | expect(united.getGeometry().getCoordinates()[0][0].length).to.equal( 38 | 4, 39 | ); 40 | // Second polygon should have 5 nodes (6 coordinates) 41 | expect(united.getGeometry().getCoordinates()[1][0].length).to.equal( 42 | 6, 43 | ); 44 | }); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /cypress/e2e/control/draw.spec.js: -------------------------------------------------------------------------------- 1 | describe('Draw control', () => { 2 | beforeEach(() => { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('should show draw control for points', () => { 7 | cy.get('.ole-control-draw') 8 | .first() 9 | .should('have.attr', 'title', 'Draw Point'); 10 | 11 | cy.get('.ole-control-draw').first().click(); 12 | cy.get('.ol-viewport').click('center'); 13 | cy.window().then((win) => 14 | expect(win.editLayer.getSource().getFeatures().length).to.eq(1), 15 | ); 16 | }); 17 | 18 | it('should show draw control for lines', () => { 19 | cy.get('.ole-control-draw') 20 | .eq(1) 21 | .should('have.attr', 'title', 'Draw LineString'); 22 | }); 23 | 24 | it('should show draw control for polygons', () => { 25 | cy.get('.ole-control-draw') 26 | .last() 27 | .should('have.attr', 'title', 'Draw Polygon'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /cypress/e2e/control/intersection.spec.js: -------------------------------------------------------------------------------- 1 | const FORCE = { force: true }; 2 | 3 | describe('Intersect control', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | 7 | // Draw polygon (click on map container, double click to finish drawing) 8 | cy.get('[title="Draw Polygon"]').click(); 9 | cy.get('.ol-overlaycontainer').click(500, 200, FORCE); 10 | cy.get('.ol-overlaycontainer').click(600, 400, FORCE); 11 | cy.get('.ol-overlaycontainer').dblclick(400, 400, FORCE); 12 | 13 | // Draw overlapping polygon (click on map container, double click to finish drawing) 14 | cy.get('.ol-overlaycontainer').click(600, 200, FORCE); 15 | cy.get('.ol-overlaycontainer').click(550, 350, FORCE); 16 | cy.get('.ol-overlaycontainer').dblclick(400, 300, FORCE); 17 | }); 18 | 19 | it('should intersect two overlapping polygons resulting in one with correct nodes', () => { 20 | cy.window().then((win) => { 21 | // Activate union tool (click on toolbar) 22 | cy.get('.ole-control-intersection') 23 | .click() 24 | .then(() => { 25 | // Click on map canvas to select polygon for intersection 26 | cy.get('.ol-overlaycontainer').click(500, 210, FORCE); 27 | }) 28 | .then(() => { 29 | cy.wait(1000); // Wait to avoid zoom on map due to load races 30 | // Click on map canvas to select overlapping polygon 31 | cy.get('.ol-overlaycontainer').click(580, 220, FORCE); 32 | cy.wait(1000).then(() => { 33 | // New (united) polygon should have 5 nodes (6 coordinates) 34 | const united = win.editLayer.getSource().getFeatures()[0]; 35 | expect(united.getGeometry().getCoordinates()[0].length).to.equal(6); 36 | }); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /cypress/e2e/control/modify.spec.js: -------------------------------------------------------------------------------- 1 | const FORCE = { force: true }; 2 | 3 | describe('ModifyControl', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | 7 | // Draw polygon (click on map container, double click to finish drawing) 8 | cy.get('[title="Draw Polygon"]').click(); 9 | cy.get('.ol-overlaycontainer').click(100, 100, FORCE); 10 | cy.get('.ol-overlaycontainer').click(100, 150, FORCE); 11 | cy.get('.ol-overlaycontainer').click(150, 170, FORCE); 12 | cy.get('.ol-overlaycontainer').dblclick(200, 100, FORCE); 13 | 14 | // Draw line (click on map container, double click to finish drawing) 15 | cy.get('[title="Draw LineString"]').click(); 16 | cy.get('.ol-overlaycontainer').click(400, 350, FORCE); 17 | cy.get('.ol-overlaycontainer').click(270, 344, FORCE); 18 | cy.get('.ol-overlaycontainer').dblclick(200, 450, FORCE); 19 | }); 20 | 21 | it('should correctly handle node deletion', () => { 22 | cy.window().then((win) => { 23 | // Spy on selectModify.addFeatureLayerAssociation_, called when a feature is selected 24 | const omitFeatureSelectSpy = cy.spy( 25 | win.modify.selectModify, 26 | 'addFeatureLayerAssociation_', 27 | ); 28 | let selectedFeaturesArray = []; 29 | // Select Modify Control (click on toolbar) 30 | cy.get('.ole-control-modify').click(); 31 | // Select polygon (double click polygon in map canvas container to start modifying) 32 | cy.get('.ol-viewport') 33 | .dblclick(100, 100, FORCE) 34 | .then(() => { 35 | selectedFeaturesArray = win.modify.selectModify 36 | .getFeatures() 37 | .getArray(); 38 | // Check if only one feature is selected 39 | expect(selectedFeaturesArray.length).to.equal(1); 40 | // Verify the polygon has 4 nodes (5 coordinates) 41 | expect( 42 | selectedFeaturesArray[0].getGeometry().getCoordinates()[0].length, 43 | ).to.equal(5); 44 | }); 45 | // Click & delete a node (click on map canvas at node pixel) 46 | // Click & delete a node (click on map canvas at node pixel) 47 | cy.get('.ol-viewport') 48 | .click(102, 152) 49 | .then(() => { 50 | // singleclick event needs a timeout period. 51 | cy.wait(400).then(() => { 52 | selectedFeaturesArray = win.modify.selectModify 53 | .getFeatures() 54 | .getArray(); 55 | // Verify one polygon node was deleted on click (3 nodes, 4 coordinates) 56 | expect( 57 | selectedFeaturesArray[0].getGeometry().getCoordinates()[0].length, 58 | ).to.equal(4); 59 | }); 60 | }); 61 | 62 | // Click another node (click on map canvas at node pixel) 63 | cy.get('.ol-viewport') 64 | .click(100, 100, FORCE) 65 | .then(() => { 66 | // singleclick event needs a timeout period. 67 | cy.wait(400).then(() => { 68 | // Verify no further node was deleted on click (because polygon minimum number nodes is 3) 69 | expect( 70 | selectedFeaturesArray[0].getGeometry().getCoordinates()[0].length, 71 | ).to.equal(4); 72 | // Check that no features from the overlay are mistakenly selected 73 | const toTest = omitFeatureSelectSpy.withArgs( 74 | omitFeatureSelectSpy.args[0][0], 75 | null, 76 | ); 77 | // eslint-disable-next-line no-unused-expressions 78 | expect(toTest).to.not.be.called; 79 | }); 80 | }); 81 | 82 | // Select line (double click line in map canvas container to start modifying) 83 | cy.get('.ol-viewport') 84 | .dblclick(270, 344, FORCE) 85 | .then(() => { 86 | selectedFeaturesArray = win.modify.selectModify 87 | .getFeatures() 88 | .getArray(); 89 | // Check if only one feature is selected 90 | expect(selectedFeaturesArray.length).to.equal(1); 91 | // Verify the line has 3 nodes (3 coordinates) 92 | expect( 93 | selectedFeaturesArray[0].getGeometry().getCoordinates().length, 94 | ).to.equal(3); 95 | }); 96 | 97 | // Click & delete a node (click on map canvas at node pixel) 98 | cy.get('.ol-viewport') 99 | .click(270, 344, FORCE) 100 | .then(() => { 101 | // singleclick event needs a timeout period. 102 | cy.wait(400).then(() => { 103 | // Verify one line node was deleted on click (2 nodes, 2 coordinates) 104 | expect( 105 | selectedFeaturesArray[0].getGeometry().getCoordinates().length, 106 | ).to.equal(2); 107 | }); 108 | }); 109 | 110 | // Click another node (click on map canvas at node pixel) 111 | cy.get('.ol-viewport') 112 | .click(400, 350, FORCE) 113 | .then(() => { 114 | // Verify no further node was deleted on click (because polygon minimum number nodes is 2) 115 | expect( 116 | selectedFeaturesArray[0].getGeometry().getCoordinates().length, 117 | ).to.equal(2); 118 | // Check that no features from the overlay are mistakenly selected 119 | // eslint-disable-next-line no-unused-expressions 120 | expect( 121 | omitFeatureSelectSpy.withArgs( 122 | omitFeatureSelectSpy.args[0][0], 123 | null, 124 | ), 125 | ).to.not.be.called; 126 | }); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /cypress/e2e/control/union.spec.js: -------------------------------------------------------------------------------- 1 | const FORCE = { force: true }; 2 | 3 | describe('Union control', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | 7 | // Draw polygon (click on map container, double click to finish drawing) 8 | cy.get('[title="Draw Polygon"]').click(); 9 | cy.get('.ol-overlaycontainer').click(500, 200, FORCE); 10 | cy.get('.ol-overlaycontainer').click(600, 400, FORCE); 11 | cy.get('.ol-overlaycontainer').dblclick(400, 400, FORCE); 12 | 13 | // Draw overlapping polygon (click on map container, double click to finish drawing) 14 | cy.get('.ol-overlaycontainer').click(600, 200, FORCE); 15 | cy.get('.ol-overlaycontainer').click(550, 350, FORCE); 16 | cy.get('.ol-overlaycontainer').dblclick(400, 300, FORCE); 17 | }); 18 | 19 | it('should unite two overlapping polygons to one polygon with correct nodes', () => { 20 | cy.window().then((win) => { 21 | // Activate union tool (click on toolbar) 22 | cy.get('.ole-control-union') 23 | .click() 24 | .then(() => { 25 | // Click on map canvas to select polygon for unison 26 | cy.get('.ol-overlaycontainer').click(500, 210, FORCE); 27 | }) 28 | .then(() => { 29 | cy.wait(1000); // Wait to avoid zoom on map due to load races 30 | // Click on map canvas to select overlapping polygon 31 | cy.get('.ol-overlaycontainer').click(580, 220, FORCE); 32 | cy.wait(1000).then(() => { 33 | // New (united) polygon should have 9 nodes (10 coordinates) 34 | const united = win.editLayer.getSource().getFeatures()[0]; 35 | expect(united.getGeometry().getCoordinates()[0].length).to.equal( 36 | 10, 37 | ); 38 | }); 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /cypress/e2e/ole.spec.js: -------------------------------------------------------------------------------- 1 | describe('OLE', () => { 2 | beforeEach(() => { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('should initialize OLE toolbar', () => { 7 | cy.get('#ole-toolbar').should('exist'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /img/buffer.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/cad.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/difference.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/draw_line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/draw_point.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/draw_polygon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/intersection.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/modify_geometry.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/modify_geometry2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/rotate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/rotate_map.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/union.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenLayers Editor 4 | 5 | 6 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 37 |
38 | 43 | 78 |
79 | 214 | 215 | 216 | -------------------------------------------------------------------------------- /jsdoc_conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": true 4 | }, 5 | "source": { 6 | "includePattern": ".+\\.js(doc|x)?$", 7 | "excludePattern": "(^|\\/|\\\\)_" 8 | }, 9 | "plugins": ["node_modules/jsdoc-export-default-interop/dist/index"], 10 | "templates": { 11 | "cleverLinks": false, 12 | "monospaceLinks": false, 13 | "default": { 14 | "outputSourceFiles": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ole", 3 | "license": "BSD-2-Clause", 4 | "description": "OpenLayers Editor", 5 | "version": "2.4.5", 6 | "main": "build/index.js", 7 | "dependencies": {}, 8 | "peerDependencies": { 9 | "jsts": ">=2", 10 | "lodash.throttle": ">=4", 11 | "ol": ">=7" 12 | }, 13 | "devDependencies": { 14 | "@commitlint/cli": "19.5.0", 15 | "@commitlint/config-conventional": "19.5.0", 16 | "cypress": "13.14.2", 17 | "esbuild": "0.23.1", 18 | "eslint": "8", 19 | "eslint-config-airbnb-base": "15.0.0", 20 | "eslint-config-prettier": "9.1.0", 21 | "eslint-plugin-cypress": "3.5.0", 22 | "eslint-plugin-import": "2.30.0", 23 | "eslint-plugin-prettier": "5.2.1", 24 | "fixpack": "4.0.0", 25 | "happy-dom": "^15.7.4", 26 | "husky": "9.1.6", 27 | "is-ci": "3.0.1", 28 | "jsdoc": "4.0.3", 29 | "jsdoc-export-default-interop": "0.3.1", 30 | "jsts": "2.11.3", 31 | "lint-staged": "15.2.10", 32 | "lodash.throttle": "4.1.1", 33 | "ol": "^10.1.0", 34 | "prettier": "3.3.3", 35 | "shx": "0.3.4", 36 | "standard-version": "9.5.0", 37 | "start-server-and-test": "2.0.8", 38 | "stylelint": "16.9.0", 39 | "stylelint-config-standard": "36.0.1", 40 | "typescript": "5.6.2", 41 | "vitest": "^2.1.1" 42 | }, 43 | "scripts": { 44 | "build": "shx rm -rf build && tsc --project config/tsconfig-build.json && esbuild build/index.js --bundle --global-name=ole --loader:.svg=dataurl --minify --outfile=build/bundle.js", 45 | "cy:open": "cypress open", 46 | "cy:run": "cypress run --browser chrome", 47 | "cy:test": "start-server-and-test start http://127.0.0.1:8000 cy:run", 48 | "doc": "jsdoc -p -r -c jsdoc_conf.json src -d doc README.md && shx cp build/bundle.js index.js", 49 | "fixpack": "fixpack", 50 | "format": "prettier --write 'cypress/integration/*.js' 'src/**/*.js' && eslint 'src/**/*.js' --fix && stylelint 'style/**/*.css' 'src/**/*.css' 'src/**/*.scss' --fix", 51 | "lint": "ESLINT_USE_FLAT_CONFIG=false eslint 'cypress/e2e/**/*.js' 'src/**/*.js' && stylelint 'style/**/*.css' 'src/**/*.css' 'src/**/*.scss'", 52 | "prepare": "is-ci || husky", 53 | "publish:beta": "yarn release -- --prerelease beta --skip.changelog && yarn build && git push origin HEAD && git push --tags && yarn publish --tag beta", 54 | "publish:beta:dryrun": "yarn release -- --prerelease beta --dry-run --skip.changelog", 55 | "publish:public": "yarn release && yarn build && git push origin HEAD && git push --tags && yarn publish", 56 | "publish:public:dryrun": "yarn release --dry-run", 57 | "release": "standard-version", 58 | "start": "esbuild src/index.js --bundle --global-name=ole --loader:.svg=dataurl --minify --outfile=index.js --serve=localhost:8000 --servedir=. --sourcemap --watch=forever", 59 | "test": "vitest", 60 | "up": "yarn upgrade-interactive --latest" 61 | }, 62 | "keywords": [ 63 | "Editor", 64 | "OpenLayers" 65 | ], 66 | "packageManager": "yarn@1.22.19+sha256.732620bac8b1690d507274f025f3c6cfdc3627a84d9642e38a07452cc00e0f2e", 67 | "repository": "github:geops/openlayers-editor" 68 | } 69 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | # How to 2 | 3 | 4 | 5 | # Others 6 | 7 | 8 | 9 | - [ ] It's not a hack or at least an unauthorized hack :). 10 | - [ ] The images added are optimized. 11 | - [ ] Everything in ticket description has been fixed. 12 | - [ ] The author of the MR has made its own review before assigning the reviewer. 13 | - [ ] The title is formatted as a [conventional-commit](https://www.conventionalcommits.org/) message. 14 | - [ ] The title contains `WIP:` if it's necessary. 15 | - [ ] Labels applied. if it's a release? a hotfix? 16 | - [ ] Tests added. 17 | 18 | 19 | IMPORTANT: Squash commits before or on merge to prevent every small commit being written into the change log. The Pull Request title will be written as message for the new commit containing the squashed commits and there fore needs to be in conventional-commit format 20 | 21 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/control/buffer.js: -------------------------------------------------------------------------------- 1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser'; 2 | import { BufferOp } from 'jsts/org/locationtech/jts/operation/buffer'; 3 | import LinearRing from 'ol/geom/LinearRing'; 4 | import { 5 | Point, 6 | LineString, 7 | Polygon, 8 | MultiPoint, 9 | MultiLineString, 10 | MultiPolygon, 11 | } from 'ol/geom'; 12 | import Select from 'ol/interaction/Select'; 13 | import Control from './control'; 14 | import bufferSVG from '../../img/buffer.svg'; 15 | 16 | /** 17 | * Control for creating buffers. 18 | * @extends {Control} 19 | * @alias ole.BufferControl 20 | */ 21 | class BufferControl extends Control { 22 | /** 23 | * @inheritdoc 24 | * @param {Object} [options] Control options. 25 | * @param {number} [options.hitTolerance] Select tolerance in pixels 26 | * (default is 10) 27 | * @param {boolean} [options.multi] Allow selection of multiple geometries 28 | * (default is false). 29 | * @param {ol.style.Style.StyleLike} [options.style] Style used when a feature is selected. 30 | */ 31 | constructor(options) { 32 | super({ 33 | title: 'Buffer geometry', 34 | className: 'ole-control-buffer', 35 | image: bufferSVG, 36 | buffer: 50, 37 | ...options, 38 | }); 39 | 40 | /** 41 | * @type {ol.interaction.Select} 42 | * @private 43 | */ 44 | this.selectInteraction = new Select({ 45 | layers: this.layerFilter, 46 | hitTolerance: 47 | options.hitTolerance === undefined ? 10 : options.hitTolerance, 48 | multi: typeof options.multi === 'undefined' ? true : options.multi, 49 | style: options.style, 50 | }); 51 | } 52 | 53 | /** 54 | * @inheritdoc 55 | */ 56 | getDialogTemplate() { 57 | return ` 58 | 63 | 64 | `; 65 | } 66 | 67 | /** 68 | * Apply a buffer for seleted features. 69 | * @param {Number} width Buffer width in map units. 70 | */ 71 | buffer(width) { 72 | const parser = new OL3Parser(); 73 | parser.inject( 74 | Point, 75 | LineString, 76 | LinearRing, 77 | Polygon, 78 | MultiPoint, 79 | MultiLineString, 80 | MultiPolygon, 81 | ); 82 | 83 | const features = this.selectInteraction.getFeatures().getArray(); 84 | for (let i = 0; i < features.length; i += 1) { 85 | const jstsGeom = parser.read(features[i].getGeometry()); 86 | const bo = new BufferOp(jstsGeom); 87 | const buffered = bo.getResultGeometry(width); 88 | features[i].setGeometry(parser.write(buffered)); 89 | } 90 | } 91 | 92 | /** 93 | * @inheritdoc 94 | */ 95 | activate() { 96 | this.map?.addInteraction(this.selectInteraction); 97 | super.activate(); 98 | 99 | document.getElementById('buffer-width')?.addEventListener('change', (e) => { 100 | this.setProperties({ buffer: e.target.value }); 101 | }); 102 | 103 | document.getElementById('buffer-btn')?.addEventListener('click', () => { 104 | const input = document.getElementById('buffer-width'); 105 | const width = parseInt(input.value, 10); 106 | 107 | if (width) { 108 | this.buffer(width); 109 | } 110 | }); 111 | } 112 | 113 | /** 114 | * @inheritdoc 115 | */ 116 | deactivate() { 117 | this.map?.removeInteraction(this.selectInteraction); 118 | super.deactivate(); 119 | } 120 | } 121 | 122 | export default BufferControl; 123 | -------------------------------------------------------------------------------- /src/control/cad.js: -------------------------------------------------------------------------------- 1 | import { Style, Stroke } from 'ol/style'; 2 | import { Point, LineString, Polygon, MultiPoint, Circle } from 'ol/geom'; 3 | import Feature from 'ol/Feature'; 4 | import Vector from 'ol/layer/Vector'; 5 | import VectorSource from 'ol/source/Vector'; 6 | import { Pointer, Snap } from 'ol/interaction'; 7 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay'; 8 | import { getUid } from 'ol/util'; 9 | import Control from './control'; 10 | import cadSVG from '../../img/cad.svg'; 11 | import { SnapEvent, SnapEventType } from '../event'; 12 | import { 13 | parser, 14 | getProjectedPoint, 15 | getEquationOfLine, 16 | getShiftedMultiPoint, 17 | getIntersectedLinesAndPoint, 18 | isSameLines, 19 | defaultSnapStyles, 20 | VH_LINE_KEY, 21 | SNAP_POINT_KEY, 22 | SNAP_FEATURE_TYPE_PROPERTY, 23 | SEGMENT_LINE_KEY, 24 | ORTHO_LINE_KEY, 25 | CUSTOM_LINE_KEY, 26 | } from '../helper'; 27 | 28 | /** 29 | * Control with snapping functionality for geometry alignment. 30 | * @extends {Control} 31 | * @alias ole.CadControl 32 | */ 33 | class CadControl extends Control { 34 | /** 35 | * @param {Object} [options] Tool options. 36 | * @param {Function} [options.drawCustomSnapLines] Allow to draw more snapping lines using selected coordinates. 37 | * @param {Function} [options.filter] Returns an array containing the features 38 | * to include for CAD (takes the source as a single argument). 39 | * @param {Function} [options.extentFilter] An optional spatial filter for the features to snap with. Returns an ol.Extent which will be used by the source.getFeaturesinExtent method. 40 | * @param {Function} [options.lineFilter] An optional filter for the generated snapping lines 41 | * array (takes the lines and cursor coordinate as arguments and returns the new line array) 42 | * @param {Number} [options.nbClosestFeatures] Number of features to use for snapping (closest first). Default is 5. 43 | * @param {Number} [options.snapTolerance] Snap tolerance in pixel 44 | * for snap lines. Default is 10. 45 | * @param {Boolean} [options.showSnapLines] Whether to show 46 | * snap lines (default is true). 47 | * @param {Boolean} [options.showSnapPoints] Whether to show 48 | * snap points around the closest feature. 49 | * @param {Boolean} [options.showOrthoLines] Whether to show 50 | * snap lines that arae perpendicular to segment (default is true). 51 | * @param {Boolean} [options.showSegmentLines] Whether to show 52 | * snap lines that extends a segment (default is true). 53 | * @param {Boolean} [options.showVerticalAndHorizontalLines] Whether to show vertical 54 | * and horizontal lines for each snappable point (default is true). 55 | * @param {Boolean} [options.snapLinesOrder] Define order of display of snap lines, 56 | * must be an array containing the following values 'ortho', 'segment', 'vh'. Default is ['ortho', 'segment', 'vh', 'custom']. 57 | * @param {Number} [options.snapPointDist] Distance of the 58 | * snap points (default is 30). 59 | * @param {Boolean} [options.useMapUnits] Whether to use map units 60 | * as measurement for point snapping. Default is false (pixel are used). 61 | * @param {ol.VectorSource} [options.source] The vector source to retrieve the snappable features from. 62 | * @param {ol.style.Style.StyleLike} [options.snapStyle] Style used for the snap layer. 63 | * @param {ol.style.Style.StyleLike} [options.linesStyle] Style used for the lines layer. 64 | * 65 | */ 66 | constructor(options = {}) { 67 | super({ 68 | title: 'CAD control', 69 | className: 'ole-control-cad', 70 | image: cadSVG, 71 | showSnapPoints: true, 72 | showSnapLines: false, 73 | showOrthoLines: true, 74 | showSegmentLines: true, 75 | showVerticalAndHorizontalLines: true, 76 | snapPointDist: 10, 77 | snapLinesOrder: ['ortho', 'segment', 'vh'], 78 | ...options, 79 | }); 80 | 81 | /** 82 | * Interaction for handling move events. 83 | * @type {ol.interaction.Pointer} 84 | * @private 85 | */ 86 | this.pointerInteraction = new Pointer({ 87 | handleMoveEvent: this.onMove.bind(this), 88 | }); 89 | 90 | /** 91 | * Layer for drawing snapping geometries. 92 | * @type {ol.layer.Vector} 93 | * @private 94 | */ 95 | this.snapLayer = new Vector({ 96 | source: new VectorSource(), 97 | style: options.snapStyle || [ 98 | defaultSnapStyles[VH_LINE_KEY], 99 | defaultSnapStyles[SNAP_POINT_KEY], 100 | ], 101 | }); 102 | 103 | /** 104 | * Layer for colored lines indicating 105 | * intersection point between snapping lines. 106 | * @type {ol.layer.Vector} 107 | * @private 108 | */ 109 | this.linesLayer = new Vector({ 110 | source: new VectorSource(), 111 | style: options.linesStyle || [ 112 | new Style({ 113 | stroke: new Stroke({ 114 | width: 1, 115 | lineDash: [5, 10], 116 | color: '#FF530D', 117 | }), 118 | }), 119 | ], 120 | }); 121 | 122 | /** 123 | * Function to draw more snapping lines. 124 | * @type {Function} 125 | * @private 126 | */ 127 | this.drawCustomSnapLines = options.drawCustomSnapLines; 128 | 129 | /** 130 | * Number of features to use for snapping (closest first). Default is 5. 131 | * @type {Number} 132 | * @private 133 | */ 134 | this.nbClosestFeatures = 135 | options.nbClosestFeatures === undefined ? 5 : options.nbClosestFeatures; 136 | 137 | /** 138 | * Snap tolerance in pixel. 139 | * @type {Number} 140 | * @private 141 | */ 142 | this.snapTolerance = 143 | options.snapTolerance === undefined ? 10 : options.snapTolerance; 144 | 145 | /** 146 | * Filter the features to snap with. 147 | * @type {Function} 148 | * @private 149 | */ 150 | this.filter = options.filter || (() => true); 151 | 152 | /** 153 | * Filter the features spatially. 154 | */ 155 | this.extentFilter = 156 | options.extentFilter || 157 | (() => [-Infinity, -Infinity, Infinity, Infinity]); 158 | 159 | /** 160 | * Filter the generated line list 161 | */ 162 | this.lineFilter = options.lineFilter; 163 | 164 | /** 165 | * Interaction for snapping 166 | * @type {ol.interaction.Snap} 167 | * @private 168 | */ 169 | this.snapInteraction = new Snap({ 170 | pixelTolerance: this.snapTolerance, 171 | source: this.snapLayer.getSource(), 172 | }); 173 | 174 | this.standalone = false; 175 | 176 | this.handleInteractionAdd = this.handleInteractionAdd.bind(this); 177 | } 178 | 179 | /** 180 | * @inheritdoc 181 | */ 182 | getDialogTemplate() { 183 | const distLabel = this.properties.useMapUnits ? 'map units' : 'px'; 184 | 185 | return ` 186 |
187 | 193 | 194 |
195 |
196 | 202 | 203 | 205 |
206 | `; 207 | } 208 | 209 | handleInteractionAdd(evt) { 210 | const pos = evt.target.getArray().indexOf(this.snapInteraction); 211 | 212 | if ( 213 | this.snapInteraction.getActive() && 214 | pos > -1 && 215 | pos !== evt.target.getLength() - 1 216 | ) { 217 | this.deactivate(true); 218 | this.activate(true); 219 | } 220 | } 221 | 222 | /** 223 | * @inheritdoc 224 | */ 225 | setMap(map) { 226 | if (this.map) { 227 | this.map.getInteractions().un('add', this.handleInteractionAdd); 228 | } 229 | 230 | super.setMap(map); 231 | 232 | // Ensure that the snap interaction is at the last position 233 | // as it must be the first to handle the pointermove event. 234 | if (this.map) { 235 | this.map.getInteractions().on('add', this.handleInteractionAdd); 236 | } 237 | } 238 | 239 | /** 240 | * Handle move event. 241 | * @private 242 | * @param {ol.MapBrowserEvent} evt Move event. 243 | */ 244 | onMove(evt) { 245 | const features = this.getClosestFeatures( 246 | evt.coordinate, 247 | this.nbClosestFeatures, 248 | ); 249 | 250 | this.linesLayer.getSource().clear(); 251 | this.snapLayer.getSource().clear(); 252 | 253 | this.pointerInteraction.dispatchEvent( 254 | new SnapEvent(SnapEventType.SNAP, features.length ? features : null, evt), 255 | ); 256 | 257 | if (this.properties.showSnapLines) { 258 | this.drawSnapLines(evt.coordinate, features); 259 | } 260 | 261 | if (this.properties.showSnapPoints && features.length) { 262 | this.drawSnapPoints(evt.coordinate, features[0]); 263 | } 264 | } 265 | 266 | /** 267 | * Returns a list of the {num} closest features 268 | * to a given coordinate. 269 | * @private 270 | * @param {ol.Coordinate} coordinate Coordinate. 271 | * @param {Number} nbFeatures Number of features to search. 272 | * @returns {Array.} List of closest features. 273 | */ 274 | getClosestFeatures(coordinate, nbFeatures = 1) { 275 | const editFeature = this.editor.getEditFeature(); 276 | const drawFeature = this.editor.getDrawFeature(); 277 | const currentFeatures = [editFeature, drawFeature].filter((f) => !!f); 278 | 279 | const cacheDist = {}; 280 | const dist = (f) => { 281 | const uid = getUid(f); 282 | if (!cacheDist[uid]) { 283 | const cCoord = f.getGeometry().getClosestPoint(coordinate); 284 | const dx = cCoord[0] - coordinate[0]; 285 | const dy = cCoord[1] - coordinate[1]; 286 | cacheDist[uid] = dx * dx + dy * dy; 287 | } 288 | return cacheDist[uid]; 289 | }; 290 | const sortByDistance = (a, b) => dist(a) - dist(b); 291 | 292 | let features = this.source 293 | .getFeaturesInExtent(this.extentFilter()) 294 | .filter( 295 | (feature) => this.filter(feature) && !currentFeatures.includes(feature), 296 | ) 297 | .sort(sortByDistance) 298 | .slice(0, nbFeatures); 299 | 300 | // When using showSnapPoints, return all features except edit/draw features 301 | if (this.properties.showSnapPoints) { 302 | return features; 303 | } 304 | 305 | // When using showSnapLines, return all features but edit/draw features are 306 | // cloned to remove the node at the mouse position. 307 | currentFeatures.filter(this.filter).forEach((feature) => { 308 | const geom = feature.getGeometry(); 309 | 310 | if (!(geom instanceof Circle) && !(geom instanceof Point)) { 311 | const snapGeom = getShiftedMultiPoint(geom, coordinate); 312 | const isPolygon = geom instanceof Polygon; 313 | const snapFeature = feature.clone(); 314 | snapFeature 315 | .getGeometry() 316 | .setCoordinates( 317 | isPolygon ? [snapGeom.getCoordinates()] : snapGeom.getCoordinates(), 318 | ); 319 | features = [snapFeature, ...features]; 320 | } 321 | }); 322 | 323 | return features; 324 | } 325 | 326 | /** 327 | * Returns an extent array, considers the map rotation. 328 | * @private 329 | * @param {ol.Geometry} geometry An OL geometry. 330 | * @returns {Array.} extent array. 331 | */ 332 | getRotatedExtent(geometry, coordinate) { 333 | const coordinates = 334 | geometry instanceof Polygon 335 | ? geometry.getCoordinates()[0] 336 | : geometry.getCoordinates(); 337 | 338 | if (!coordinates.length) { 339 | // Polygons initially return a geometry with an empty coordinate array, so we need to catch it 340 | return [coordinate]; 341 | } 342 | 343 | // Get the extreme X and Y using pixel values so the rotation is considered 344 | const xMin = coordinates.reduce((finalMin, coord) => { 345 | const pixelCurrent = this.map.getPixelFromCoordinate(coord); 346 | const pixelFinal = this.map.getPixelFromCoordinate( 347 | finalMin || coordinates[0], 348 | ); 349 | return pixelCurrent[0] <= pixelFinal[0] ? coord : finalMin; 350 | }); 351 | const xMax = coordinates.reduce((finalMax, coord) => { 352 | const pixelCurrent = this.map.getPixelFromCoordinate(coord); 353 | const pixelFinal = this.map.getPixelFromCoordinate( 354 | finalMax || coordinates[0], 355 | ); 356 | return pixelCurrent[0] >= pixelFinal[0] ? coord : finalMax; 357 | }); 358 | const yMin = coordinates.reduce((finalMin, coord) => { 359 | const pixelCurrent = this.map.getPixelFromCoordinate(coord); 360 | const pixelFinal = this.map.getPixelFromCoordinate( 361 | finalMin || coordinates[0], 362 | ); 363 | return pixelCurrent[1] <= pixelFinal[1] ? coord : finalMin; 364 | }); 365 | const yMax = coordinates.reduce((finalMax, coord) => { 366 | const pixelCurrent = this.map.getPixelFromCoordinate(coord); 367 | const pixelFinal = this.map.getPixelFromCoordinate( 368 | finalMax || coordinates[0], 369 | ); 370 | return pixelCurrent[1] >= pixelFinal[1] ? coord : finalMax; 371 | }); 372 | 373 | // Create four infinite lines through the extremes X and Y and rotate them 374 | const minVertLine = new LineString([ 375 | [xMin[0], -20037508.342789], 376 | [xMin[0], 20037508.342789], 377 | ]); 378 | minVertLine.rotate(this.map.getView().getRotation(), xMin); 379 | const maxVertLine = new LineString([ 380 | [xMax[0], -20037508.342789], 381 | [xMax[0], 20037508.342789], 382 | ]); 383 | maxVertLine.rotate(this.map.getView().getRotation(), xMax); 384 | const minHoriLine = new LineString([ 385 | [-20037508.342789, yMin[1]], 386 | [20037508.342789, yMin[1]], 387 | ]); 388 | minHoriLine.rotate(this.map.getView().getRotation(), yMin); 389 | const maxHoriLine = new LineString([ 390 | [-20037508.342789, yMax[1]], 391 | [20037508.342789, yMax[1]], 392 | ]); 393 | maxHoriLine.rotate(this.map.getView().getRotation(), yMax); 394 | 395 | // Use intersection points of the four lines to get the extent 396 | const intersectTopLeft = OverlayOp.intersection( 397 | parser.read(minVertLine), 398 | parser.read(minHoriLine), 399 | ); 400 | const intersectBottomLeft = OverlayOp.intersection( 401 | parser.read(minVertLine), 402 | parser.read(maxHoriLine), 403 | ); 404 | const intersectTopRight = OverlayOp.intersection( 405 | parser.read(maxVertLine), 406 | parser.read(minHoriLine), 407 | ); 408 | const intersectBottomRight = OverlayOp.intersection( 409 | parser.read(maxVertLine), 410 | parser.read(maxHoriLine), 411 | ); 412 | 413 | return [ 414 | [intersectTopLeft.getCoordinate().x, intersectTopLeft.getCoordinate().y], 415 | [ 416 | intersectBottomLeft.getCoordinate().x, 417 | intersectBottomLeft.getCoordinate().y, 418 | ], 419 | [ 420 | intersectTopRight.getCoordinate().x, 421 | intersectTopRight.getCoordinate().y, 422 | ], 423 | [ 424 | intersectBottomRight.getCoordinate().x, 425 | intersectBottomRight.getCoordinate().y, 426 | ], 427 | ]; 428 | } 429 | 430 | // Calculate lines that are vertical or horizontal to a coordinate. 431 | getVerticalAndHorizontalLines(coordinate, snapCoords) { 432 | // Draw snaplines when cursor vertically or horizontally aligns with a snap feature. 433 | // We draw only on vertical and one horizontal line to avoid crowded lines when polygons or lines have a lot of coordinates. 434 | const halfTol = this.snapTolerance / 2; 435 | const doubleTol = this.snapTolerance * 2; 436 | const mousePx = this.map.getPixelFromCoordinate(coordinate); 437 | const [mouseX, mouseY] = mousePx; 438 | let vLine; 439 | let hLine; 440 | let closerDistanceWithVLine = Infinity; 441 | let closerDistanceWithHLine = Infinity; 442 | for (let i = 0; i < snapCoords.length; i += 1) { 443 | const snapCoord = snapCoords[i]; 444 | const snapPx = this.map.getPixelFromCoordinate(snapCoords[i]); 445 | const [snapX, snapY] = snapPx; 446 | const drawVLine = mouseX > snapX - halfTol && mouseX < snapX + halfTol; 447 | const drawHLine = mouseY > snapY - halfTol && mouseY < snapY + halfTol; 448 | 449 | const distanceWithVLine = Math.abs(mouseX - snapX); 450 | const distanceWithHLine = Math.abs(mouseY - snapY); 451 | 452 | if ( 453 | (drawVLine && distanceWithVLine > closerDistanceWithVLine) || 454 | (drawHLine && distanceWithHLine > closerDistanceWithHLine) 455 | ) { 456 | // eslint-disable-next-line no-continue 457 | continue; 458 | } 459 | 460 | let newPt; 461 | 462 | if (drawVLine) { 463 | closerDistanceWithVLine = distanceWithVLine; 464 | const newY = mouseY + (mouseY < snapY ? -doubleTol : doubleTol); 465 | newPt = this.map.getCoordinateFromPixel([snapX, newY]); 466 | } else if (drawHLine) { 467 | closerDistanceWithHLine = distanceWithHLine; 468 | const newX = mouseX + (mouseX < snapX ? -doubleTol : doubleTol); 469 | newPt = this.map.getCoordinateFromPixel([newX, snapY]); 470 | } 471 | 472 | if (newPt) { 473 | const lineCoords = [newPt, snapCoord]; 474 | const geom = new LineString(lineCoords); 475 | const feature = new Feature(geom); 476 | 477 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, VH_LINE_KEY); 478 | 479 | if (drawVLine) { 480 | vLine = feature; 481 | } 482 | 483 | if (drawHLine) { 484 | hLine = feature; 485 | } 486 | } 487 | } 488 | 489 | const lines = []; 490 | 491 | if (hLine) { 492 | lines.push(hLine); 493 | } 494 | 495 | if (vLine && vLine !== hLine) { 496 | lines.push(vLine); 497 | } 498 | 499 | return lines; 500 | } 501 | 502 | /** 503 | * For each segment, we calculate lines that extends it. 504 | */ 505 | getSegmentLines(coordinate, snapCoords, snapCoordsBefore) { 506 | const mousePx = this.map.getPixelFromCoordinate(coordinate); 507 | const doubleTol = this.snapTolerance * 2; 508 | const [mouseX, mouseY] = mousePx; 509 | const lines = []; 510 | 511 | for (let i = 0; i < snapCoords.length; i += 1) { 512 | if (!snapCoordsBefore[i]) { 513 | // eslint-disable-next-line no-continue 514 | continue; 515 | } 516 | const snapCoordBefore = snapCoordsBefore[i]; 517 | const snapCoord = snapCoords[i]; 518 | const snapPxBefore = this.map.getPixelFromCoordinate(snapCoordBefore); 519 | const snapPx = this.map.getPixelFromCoordinate(snapCoord); 520 | 521 | const [snapX] = snapPx; 522 | 523 | // Calculate projected point 524 | const projMousePx = getProjectedPoint(mousePx, snapPxBefore, snapPx); 525 | const [projMouseX, projMouseY] = projMousePx; 526 | const distance = Math.sqrt( 527 | (projMouseX - mouseX) ** 2 + (projMouseY - mouseY) ** 2, 528 | ); 529 | let newPt; 530 | 531 | if (distance <= this.snapTolerance) { 532 | // lineFunc is undefined when it's a vertical line 533 | const lineFunc = getEquationOfLine(snapPxBefore, snapPx); 534 | const newX = projMouseX + (projMouseX < snapX ? -doubleTol : doubleTol); 535 | if (lineFunc) { 536 | newPt = this.map.getCoordinateFromPixel([ 537 | newX, 538 | lineFunc ? lineFunc(newX) : projMouseY, 539 | ]); 540 | } 541 | } 542 | 543 | if (newPt) { 544 | const lineCoords = [snapCoordBefore, snapCoord, newPt]; 545 | const geom = new LineString(lineCoords); 546 | const feature = new Feature(geom); 547 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, SEGMENT_LINE_KEY); 548 | lines.push(feature); 549 | } 550 | } 551 | return lines; 552 | } 553 | 554 | /** 555 | * For each segment, we calculate lines that are perpendicular. 556 | */ 557 | getOrthoLines(coordinate, snapCoords, snapCoordsBefore) { 558 | const mousePx = this.map.getPixelFromCoordinate(coordinate); 559 | const doubleTol = this.snapTolerance * 2; 560 | const [mouseX, mouseY] = mousePx; 561 | const lines = []; 562 | 563 | for (let i = 0; i < snapCoords.length; i += 1) { 564 | if (!snapCoordsBefore[i]) { 565 | // eslint-disable-next-line no-continue 566 | continue; 567 | } 568 | const snapCoordBefore = snapCoordsBefore[i]; 569 | const snapCoord = snapCoords[i]; 570 | const snapPxBefore = this.map.getPixelFromCoordinate(snapCoordBefore); 571 | const snapPx = this.map.getPixelFromCoordinate(snapCoord); 572 | 573 | const orthoLine1 = new LineString([snapPxBefore, snapPx]); 574 | orthoLine1.rotate((90 * Math.PI) / 180, snapPxBefore); 575 | 576 | const orthoLine2 = new LineString([snapPx, snapPxBefore]); 577 | orthoLine2.rotate((90 * Math.PI) / 180, snapPx); 578 | 579 | [orthoLine1, orthoLine2].forEach((line) => { 580 | const [anchorPx, last] = line.getCoordinates(); 581 | const projMousePx = getProjectedPoint(mousePx, anchorPx, last); 582 | const [projMouseX, projMouseY] = projMousePx; 583 | const distance = Math.sqrt( 584 | (projMouseX - mouseX) ** 2 + (projMouseY - mouseY) ** 2, 585 | ); 586 | 587 | let newPt; 588 | if (distance <= this.snapTolerance) { 589 | // lineFunc is undefined when it's a vertical line 590 | const lineFunc = getEquationOfLine(anchorPx, projMousePx); 591 | const newX = 592 | projMouseX + (projMouseX < anchorPx[0] ? -doubleTol : doubleTol); 593 | if (lineFunc) { 594 | newPt = this.map.getCoordinateFromPixel([ 595 | newX, 596 | lineFunc ? lineFunc(newX) : projMouseY, 597 | ]); 598 | } 599 | } 600 | 601 | if (newPt) { 602 | const coords = [this.map.getCoordinateFromPixel(anchorPx), newPt]; 603 | const geom = new LineString(coords); 604 | const feature = new Feature(geom); 605 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, ORTHO_LINE_KEY); 606 | lines.push(feature); 607 | } 608 | }); 609 | } 610 | return lines; 611 | } 612 | 613 | /** 614 | * Draws snap lines by building the extent for 615 | * a pair of features. 616 | * @private 617 | * @param {ol.Coordinate} coordinate Mouse pointer coordinate. 618 | * @param {Array.} features List of features. 619 | */ 620 | drawSnapLines(coordinate, features) { 621 | // First get all snap points: neighbouring feature vertices and extent corners 622 | const snapCoordsBefore = []; // store the direct before point in the coordinate array 623 | const snapCoords = []; 624 | const snapCoordsAfter = []; // store the direct next point in the coordinate array 625 | 626 | for (let i = 0; i < features.length; i += 1) { 627 | const geom = features[i].getGeometry(); 628 | let featureCoord = geom.getCoordinates(); 629 | 630 | if (!featureCoord && geom instanceof Circle) { 631 | featureCoord = geom.getCenter(); 632 | } 633 | 634 | // Polygons initially return a geometry with an empty coordinate array, so we need to catch it 635 | if (featureCoord?.length) { 636 | if (geom instanceof Point || geom instanceof Circle) { 637 | snapCoordsBefore.push(); 638 | snapCoords.push(featureCoord); 639 | snapCoordsAfter.push(); 640 | } else { 641 | // Add feature vertices 642 | // eslint-disable-next-line no-lonely-if 643 | if (geom instanceof LineString) { 644 | for (let j = 0; j < featureCoord.length; j += 1) { 645 | snapCoordsBefore.push(featureCoord[j - 1]); 646 | snapCoords.push(featureCoord[j]); 647 | snapCoordsAfter.push(featureCoord[j + 1]); 648 | } 649 | } else if (geom instanceof Polygon) { 650 | for (let j = 0; j < featureCoord[0].length; j += 1) { 651 | snapCoordsBefore.push(featureCoord[0][j - 1]); 652 | snapCoords.push(featureCoord[0][j]); 653 | snapCoordsAfter.push(featureCoord[0][j + 1]); 654 | } 655 | } 656 | 657 | // Add extent vertices 658 | // const coords = this.getRotatedExtent(geom, coordinate); 659 | // for (let j = 0; j < coords.length; j += 1) { 660 | // snapCoordsBefore.push(); 661 | // snapCoords.push(coords[j]); 662 | // snapCoordsNext.push(); 663 | // } 664 | } 665 | } 666 | } 667 | 668 | const { 669 | showVerticalAndHorizontalLines, 670 | showOrthoLines, 671 | showSegmentLines, 672 | snapLinesOrder, 673 | } = this.properties; 674 | 675 | let lines = []; 676 | const helpLinesOrdered = []; 677 | const helpLines = { 678 | [ORTHO_LINE_KEY]: [], 679 | [SEGMENT_LINE_KEY]: [], 680 | [VH_LINE_KEY]: [], 681 | [CUSTOM_LINE_KEY]: [], 682 | }; 683 | 684 | if (showOrthoLines) { 685 | helpLines[ORTHO_LINE_KEY] = 686 | this.getOrthoLines(coordinate, snapCoords, snapCoordsBefore) || []; 687 | } 688 | 689 | if (showSegmentLines) { 690 | helpLines[SEGMENT_LINE_KEY] = 691 | this.getSegmentLines(coordinate, snapCoords, snapCoordsBefore) || []; 692 | } 693 | 694 | if (showVerticalAndHorizontalLines) { 695 | helpLines[VH_LINE_KEY] = 696 | this.getVerticalAndHorizontalLines(coordinate, snapCoords) || []; 697 | } 698 | 699 | // Add custom lines 700 | if (this.drawCustomSnapLines) { 701 | helpLines[CUSTOM_LINE_KEY] = 702 | this.drawCustomSnapLines( 703 | coordinate, 704 | snapCoords, 705 | snapCoordsBefore, 706 | snapCoordsAfter, 707 | ) || []; 708 | } 709 | 710 | // Add help lines in a defined order. 711 | snapLinesOrder.forEach((lineType) => { 712 | helpLinesOrdered.push(...(helpLines[lineType] || [])); 713 | }); 714 | 715 | // Remove duplicated lines, comparing their equation using pixels. 716 | helpLinesOrdered.forEach((lineA) => { 717 | if ( 718 | !lines.length || 719 | !lines.find((lineB) => isSameLines(lineA, lineB, this.map)) 720 | ) { 721 | lines.push(lineA); 722 | } 723 | }); 724 | 725 | if (this.lineFilter) { 726 | lines = this.lineFilter(lines, coordinate); 727 | } 728 | 729 | // We snap on intersections of lines (distance < this.snapTolerance) or on all the help lines. 730 | const intersectFeatures = getIntersectedLinesAndPoint( 731 | coordinate, 732 | lines, 733 | this.map, 734 | this.snapTolerance, 735 | ); 736 | 737 | if (intersectFeatures?.length) { 738 | intersectFeatures.forEach((feature) => { 739 | if (feature.getGeometry().getType() === 'Point') { 740 | this.snapLayer.getSource().addFeature(feature); 741 | } else { 742 | this.linesLayer.getSource().addFeature(feature); 743 | } 744 | }); 745 | } else { 746 | this.snapLayer.getSource().addFeatures(lines); 747 | } 748 | } 749 | 750 | /** 751 | * Adds snap points to the snapping layer. 752 | * @private 753 | * @param {ol.Coordinate} coordinate cursor coordinate. 754 | * @param {ol.eaturee} feature Feature to draw the snap points for. 755 | */ 756 | drawSnapPoints(coordinate, feature) { 757 | const featCoord = feature.getGeometry().getClosestPoint(coordinate); 758 | 759 | const px = this.map.getPixelFromCoordinate(featCoord); 760 | let snapCoords = []; 761 | 762 | if (this.properties.useMapUnits) { 763 | snapCoords = [ 764 | [featCoord[0] - this.properties.snapPointDist, featCoord[1]], 765 | [featCoord[0] + this.properties.snapPointDist, featCoord[1]], 766 | [featCoord[0], featCoord[1] - this.properties.snapPointDist], 767 | [featCoord[0], featCoord[1] + this.properties.snapPointDist], 768 | ]; 769 | } else { 770 | const snapPx = [ 771 | [px[0] - this.properties.snapPointDist, px[1]], 772 | [px[0] + this.properties.snapPointDist, px[1]], 773 | [px[0], px[1] - this.properties.snapPointDist], 774 | [px[0], px[1] + this.properties.snapPointDist], 775 | ]; 776 | 777 | for (let j = 0; j < snapPx.length; j += 1) { 778 | snapCoords.push(this.map.getCoordinateFromPixel(snapPx[j])); 779 | } 780 | } 781 | 782 | const snapGeom = new MultiPoint(snapCoords); 783 | this.snapLayer.getSource().addFeature(new Feature(snapGeom)); 784 | } 785 | 786 | /** 787 | * @inheritdoc 788 | */ 789 | activate(silent) { 790 | super.activate(silent); 791 | this.snapLayer.setMap(this.map); 792 | this.linesLayer.setMap(this.map); 793 | this.map?.addInteraction(this.pointerInteraction); 794 | this.map?.addInteraction(this.snapInteraction); 795 | 796 | document.getElementById('aux-cb')?.addEventListener('change', (evt) => { 797 | this.setProperties({ 798 | showSnapLines: evt.target.checked, 799 | showSnapPoints: !evt.target.checked, 800 | }); 801 | }); 802 | 803 | document.getElementById('dist-cb')?.addEventListener('change', (evt) => { 804 | this.setProperties({ 805 | showSnapPoints: evt.target.checked, 806 | showSnapLines: !evt.target.checked, 807 | }); 808 | }); 809 | 810 | document.getElementById('width-input')?.addEventListener('keyup', (evt) => { 811 | const snapPointDist = parseFloat(evt.target.value); 812 | if (!Number.isNaN(snapPointDist)) { 813 | this.setProperties({ snapPointDist }); 814 | } 815 | }); 816 | } 817 | 818 | /** 819 | * @inheritdoc 820 | */ 821 | deactivate(silent) { 822 | super.deactivate(silent); 823 | this.snapLayer.setMap(null); 824 | this.linesLayer.setMap(null); 825 | this.map?.removeInteraction(this.pointerInteraction); 826 | this.map?.removeInteraction(this.snapInteraction); 827 | } 828 | } 829 | 830 | export default CadControl; 831 | -------------------------------------------------------------------------------- /src/control/control.js: -------------------------------------------------------------------------------- 1 | import OLControl from 'ol/control/Control'; 2 | import VectorSource from 'ol/source/Vector'; 3 | /** 4 | * OLE control base class. 5 | * @extends ol.control.Control 6 | * @alias ole.Control 7 | */ 8 | class Control extends OLControl { 9 | /** 10 | * @inheritdoc 11 | * @param {Object} options Control options. 12 | * @param {HTMLElement} options.element Element which to substitute button. Set to null if you don't want to display an html element. 13 | * @param {string} options.className Name of the control's HTML class. 14 | * @param {string} options.title Title of the control toolbar button. 15 | * @param {Image} options.image Control toolbar image. 16 | * @param {HTMLElement} [options.dialogTarget] Specify a target if you want 17 | * the dialog div used by the control to be rendered outside of the map's viewport. Set tio null if you don't want to display the dialog of a control. 18 | * @param {ol.source.Vector} [options.source] Vector source holding 19 | * edit features. If undefined, options.features must be passed. 20 | * @param {ol.Collection} [options.features] Collection of 21 | * edit features. If undefined, options.source must be set. 22 | * @param {function} [options.layerFilter] Filter editable layer. 23 | */ 24 | constructor(options) { 25 | let button = null; 26 | if (options.element !== null && !options.element) { 27 | button = document.createElement('button'); 28 | button.className = `ole-control ${options.className}`; 29 | } 30 | 31 | super({ 32 | element: 33 | options.element === null 34 | ? document.createElement('div') // An element must be define otherwise ol complains, when we add control 35 | : options.element || button, 36 | }); 37 | 38 | /** 39 | * Specify a target if you want the dialog div used by the 40 | * control to be rendered outside of the map's viewport. 41 | * @type {HTMLElement} 42 | * @private 43 | */ 44 | this.dialogTarget = options.dialogTarget; 45 | 46 | /** 47 | * Control properties. 48 | * @type {object} 49 | * @private 50 | */ 51 | this.properties = { ...options }; 52 | 53 | /** 54 | * Html class name of the control button. 55 | * @type {string} 56 | * @private 57 | */ 58 | this.className = options.className; 59 | 60 | /** 61 | * Control title. 62 | * @type {string} 63 | * @private 64 | */ 65 | this.title = options.title; 66 | 67 | if (button) { 68 | const img = document.createElement('img'); 69 | img.src = options.image; 70 | 71 | button.appendChild(img); 72 | button.title = this.title; 73 | 74 | button.addEventListener('click', this.onClick.bind(this)); 75 | } 76 | 77 | /** 78 | * Source with edit features. 79 | * @type {ol.source.Vector} 80 | * @private 81 | */ 82 | this.source = 83 | options.source || 84 | new VectorSource({ 85 | features: options.features, 86 | }); 87 | 88 | /** 89 | * Filter editable layer. Used by select interactions instead of 90 | * the old source property. 91 | * @type {function} 92 | * @private 93 | */ 94 | this.layerFilter = 95 | options.layerFilter || 96 | ((layer) => !this.source || (layer && layer.getSource() === this.source)); 97 | 98 | /** 99 | * ole.Editor instance. 100 | * @type {ole.Editor} 101 | * @private 102 | */ 103 | this.editor = null; 104 | 105 | /** 106 | * @type {Boolean} 107 | * @private 108 | */ 109 | this.standalone = true; 110 | } 111 | 112 | /** 113 | * Returns the control's element. 114 | * @returns {Element} the control element. 115 | */ 116 | getElement() { 117 | return this.element; 118 | } 119 | 120 | /** 121 | * Click handler for the control element. 122 | * @private 123 | */ 124 | onClick() { 125 | if (this.active) { 126 | this.deactivate(); 127 | } else { 128 | this.activate(); 129 | } 130 | } 131 | 132 | /** 133 | * Sets the map of the control. 134 | * @protected 135 | * @param {ol.Map} map The map object. 136 | */ 137 | setMap(map) { 138 | this.map = map; 139 | super.setMap(this.map); 140 | } 141 | 142 | /** 143 | * Introduce the control to it's editor. 144 | * @param {ole.Editor} editor OLE Editor. 145 | * @protected 146 | */ 147 | setEditor(editor) { 148 | this.editor = editor; 149 | } 150 | 151 | /** 152 | * Activate the control 153 | */ 154 | activate(silent) { 155 | this.active = true; 156 | if (this.element) { 157 | this.element.className += ' active'; 158 | } 159 | 160 | if (!silent) { 161 | this.dispatchEvent({ 162 | type: 'change:active', 163 | target: this, 164 | detail: { control: this }, 165 | }); 166 | } 167 | 168 | this.openDialog(); 169 | } 170 | 171 | /** 172 | * Dectivate the control 173 | * @param {boolean} [silent] Do not trigger an event. 174 | */ 175 | deactivate(silent) { 176 | this.active = false; 177 | if (this.element) { 178 | this.element.classList.remove('active'); 179 | } 180 | 181 | if (!silent) { 182 | this.dispatchEvent({ 183 | type: 'change:active', 184 | target: this, 185 | detail: { control: this }, 186 | }); 187 | } 188 | 189 | this.closeDialog(); 190 | } 191 | 192 | /** 193 | * Returns the active state of the control. 194 | * @returns {Boolean} Active state. 195 | */ 196 | getActive() { 197 | return this.active; 198 | } 199 | 200 | /** 201 | * Open the control's dialog (if defined). 202 | */ 203 | openDialog() { 204 | this.closeDialog(); 205 | if (this.dialogTarget !== null && this.getDialogTemplate) { 206 | this.dialogDiv = document.createElement('div'); 207 | 208 | this.dialogDiv.innerHTML = ` 209 |
210 | ${this.getDialogTemplate()} 211 |
212 | `; 213 | (this.dialogTarget || this.map.getTargetElement()).appendChild( 214 | this.dialogDiv, 215 | ); 216 | } 217 | } 218 | 219 | /** 220 | * Closes the control dialog. 221 | * @private 222 | */ 223 | closeDialog() { 224 | if (this.dialogDiv) { 225 | (this.dialogTarget || this.map.getTargetElement()).removeChild( 226 | this.dialogDiv, 227 | ); 228 | this.dialogDiv = null; 229 | } 230 | } 231 | 232 | /** 233 | * Set properties. 234 | * @param {object} properties New control properties. 235 | * @param {boolean} [silent] If true, no propertychange event is triggered. 236 | */ 237 | setProperties(properties, silent) { 238 | this.properties = { ...this.properties, ...properties }; 239 | 240 | if (!silent) { 241 | this.dispatchEvent({ 242 | type: 'propertychange', 243 | target: this, 244 | detail: { properties: this.properties, control: this }, 245 | }); 246 | } 247 | } 248 | 249 | /** 250 | * Return properties. 251 | * @returns {object} Copy of control properties. 252 | */ 253 | getProperties() { 254 | return { ...this.properties }; 255 | } 256 | } 257 | 258 | export default Control; 259 | -------------------------------------------------------------------------------- /src/control/difference.js: -------------------------------------------------------------------------------- 1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser'; 2 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay'; 3 | import LinearRing from 'ol/geom/LinearRing'; 4 | import { 5 | Point, 6 | LineString, 7 | Polygon, 8 | MultiPoint, 9 | MultiLineString, 10 | MultiPolygon, 11 | } from 'ol/geom'; 12 | import TopologyControl from './topology'; 13 | import diffSVG from '../../img/difference.svg'; 14 | 15 | /** 16 | * Control for creating a difference of geometries. 17 | * @extends {Control} 18 | * @alias ole.Difference 19 | */ 20 | class Difference extends TopologyControl { 21 | /** 22 | * @inheritdoc 23 | * @param {Object} [options] Control options. 24 | * @param {number} [options.hitTolerance] Select tolerance in pixels 25 | * (default is 10) 26 | */ 27 | constructor(options) { 28 | super({ 29 | title: 'Difference', 30 | className: 'ole-control-difference', 31 | image: diffSVG, 32 | ...options, 33 | }); 34 | } 35 | 36 | /** 37 | * Apply a difference operation for given features. 38 | * @param {Array.} features Features. 39 | */ 40 | applyTopologyOperation(features) { 41 | super.applyTopologyOperation(features); 42 | 43 | if (features.length < 2) { 44 | return; 45 | } 46 | 47 | const parser = new OL3Parser(); 48 | parser.inject( 49 | Point, 50 | LineString, 51 | LinearRing, 52 | Polygon, 53 | MultiPoint, 54 | MultiLineString, 55 | MultiPolygon, 56 | ); 57 | 58 | for (let i = 1; i < features.length; i += 1) { 59 | const geom = parser.read(features[0].getGeometry()); 60 | const otherGeom = parser.read(features[i].getGeometry()); 61 | const diffGeom = OverlayOp.difference(geom, otherGeom); 62 | features[0].setGeometry(parser.write(diffGeom)); 63 | features[i].setGeometry(null); 64 | } 65 | } 66 | } 67 | 68 | export default Difference; 69 | -------------------------------------------------------------------------------- /src/control/draw.js: -------------------------------------------------------------------------------- 1 | import { Draw } from 'ol/interaction'; 2 | import Control from './control'; 3 | import drawPointSVG from '../../img/draw_point.svg'; 4 | import drawPolygonSVG from '../../img/draw_polygon.svg'; 5 | import drawLineSVG from '../../img/draw_line.svg'; 6 | 7 | /** 8 | * Control for drawing features. 9 | * @extends {Control} 10 | * @alias ole.DrawControl 11 | */ 12 | class DrawControl extends Control { 13 | /** 14 | * @param {Object} [options] Tool options. 15 | * @param {string} [options.type] Geometry type ('Point', 'LineString', 'Polygon', 16 | * 'MultiPoint', 'MultiLineString', 'MultiPolygon' or 'Circle'). 17 | * Default is 'Point'. 18 | * @param {Object} [options.drawInteractionOptions] Options for the Draw interaction (ol/interaction/Draw). 19 | * @param {ol.style.Style.StyleLike} [options.style] Style used for the draw interaction. 20 | */ 21 | constructor(options) { 22 | let image = null; 23 | 24 | switch (options?.type) { 25 | case 'Polygon': 26 | image = drawPolygonSVG; 27 | break; 28 | case 'LineString': 29 | image = drawLineSVG; 30 | break; 31 | default: 32 | image = drawPointSVG; 33 | } 34 | 35 | super({ 36 | title: `Draw ${options?.type || 'Point'}`, 37 | className: 'ole-control-draw', 38 | image, 39 | ...(options || {}), 40 | }); 41 | 42 | /** 43 | * @type {ol.interaction.Draw} 44 | */ 45 | this.drawInteraction = new Draw({ 46 | type: options?.type || 'Point', 47 | features: options?.features, 48 | source: options?.source, 49 | style: options?.style, 50 | stopClick: true, 51 | ...(options?.drawInteractionOptions || {}), 52 | }); 53 | 54 | this.drawInteraction.on('drawstart', (evt) => { 55 | this.editor.setDrawFeature(evt.feature); 56 | }); 57 | 58 | this.drawInteraction.on('drawend', () => { 59 | this.editor.setDrawFeature(); 60 | }); 61 | } 62 | 63 | /** 64 | * @inheritdoc 65 | */ 66 | activate() { 67 | this.map?.addInteraction(this.drawInteraction); 68 | super.activate(); 69 | } 70 | 71 | /** 72 | * @inheritdoc 73 | */ 74 | deactivate(silent) { 75 | this.map?.removeInteraction(this.drawInteraction); 76 | super.deactivate(silent); 77 | } 78 | } 79 | 80 | export default DrawControl; 81 | -------------------------------------------------------------------------------- /src/control/index.js: -------------------------------------------------------------------------------- 1 | export { default as Control } from './control'; 2 | export { default as CAD } from './cad'; 3 | export { default as Rotate } from './rotate'; 4 | export { default as Draw } from './draw'; 5 | export { default as Modify } from './modify'; 6 | export { default as Buffer } from './buffer'; 7 | export { default as Union } from './union'; 8 | export { default as Intersection } from './intersection'; 9 | export { default as Difference } from './difference'; 10 | export { default as Toolbar } from './toolbar'; 11 | -------------------------------------------------------------------------------- /src/control/intersection.js: -------------------------------------------------------------------------------- 1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser'; 2 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay'; 3 | import LinearRing from 'ol/geom/LinearRing'; 4 | import { 5 | Point, 6 | LineString, 7 | Polygon, 8 | MultiPoint, 9 | MultiLineString, 10 | MultiPolygon, 11 | } from 'ol/geom'; 12 | import TopologyControl from './topology'; 13 | import intersectionSVG from '../../img/intersection.svg'; 14 | 15 | /** 16 | * Control for intersection geometries. 17 | * @extends {Control} 18 | * @alias ole.Intersection 19 | */ 20 | class Intersection extends TopologyControl { 21 | /** 22 | * @inheritdoc 23 | * @param {Object} [options] Control options. 24 | * @param {number} [options.hitTolerance] Select tolerance in pixels 25 | * (default is 10) 26 | */ 27 | constructor(options) { 28 | super({ 29 | title: 'Intersection', 30 | className: 'ole-control-intersection', 31 | image: intersectionSVG, 32 | ...options, 33 | }); 34 | } 35 | 36 | /** 37 | * Intersect given features. 38 | * @param {Array.} features Features to inersect. 39 | */ 40 | applyTopologyOperation(features) { 41 | super.applyTopologyOperation(features); 42 | 43 | if (features.length < 2) { 44 | return; 45 | } 46 | 47 | const parser = new OL3Parser(); 48 | parser.inject( 49 | Point, 50 | LineString, 51 | LinearRing, 52 | Polygon, 53 | MultiPoint, 54 | MultiLineString, 55 | MultiPolygon, 56 | ); 57 | 58 | for (let i = 1; i < features.length; i += 1) { 59 | const geom = parser.read(features[0].getGeometry()); 60 | const otherGeom = parser.read(features[i].getGeometry()); 61 | const intersectGeom = OverlayOp.intersection(geom, otherGeom); 62 | features[0].setGeometry(parser.write(intersectGeom)); 63 | features[i].setGeometry(null); 64 | } 65 | } 66 | } 67 | 68 | export default Intersection; 69 | -------------------------------------------------------------------------------- /src/control/modify.js: -------------------------------------------------------------------------------- 1 | import { Modify, Interaction } from 'ol/interaction'; 2 | import { singleClick } from 'ol/events/condition'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import throttle from 'lodash.throttle'; 5 | import { unByKey } from 'ol/Observable'; 6 | import Control from './control'; 7 | import image from '../../img/modify_geometry2.svg'; 8 | import SelectMove from '../interaction/selectmove'; 9 | import SelectModify from '../interaction/selectmodify'; 10 | import Move from '../interaction/move'; 11 | import Delete from '../interaction/delete'; 12 | 13 | /** 14 | * Control for modifying geometries. 15 | * @extends {Control} 16 | * @alias ole.ModifyControl 17 | */ 18 | class ModifyControl extends Control { 19 | /** 20 | * @param {Object} [options] Tool options. 21 | * @param {number} [options.hitTolerance=5] Select tolerance in pixels. 22 | * @param {ol.Collection} [options.features] Destination for drawing. 23 | * @param {ol.source.Vector} [options.source] Destination for drawing. 24 | * @param {Object} [options.selectMoveOptions] Options for the select interaction used to move features. 25 | * @param {Object} [options.selectModifyOptions] Options for the select interaction used to modify features. 26 | * @param {Object} [options.moveInteractionOptions] Options for the move interaction. 27 | * @param {Object} [options.modifyInteractionOptions] Options for the modify interaction. 28 | * @param {Object} [options.deleteInteractionOptions] Options for the delete interaction. 29 | * @param {Object} [options.deselectInteractionOptions] Options for the deselect interaction. Default: features are deselected on click on map. 30 | * @param {Function} [options.cursorStyleHandler] Options to override default cursor styling behavior. 31 | */ 32 | constructor(options = {}) { 33 | super({ 34 | title: 'Modify geometry', 35 | className: 'ole-control-modify', 36 | image, 37 | ...options, 38 | }); 39 | 40 | /** 41 | * Buffer around the coordintate clicked in pixels. 42 | * @type {number} 43 | * @private 44 | */ 45 | this.hitTolerance = 46 | options.hitTolerance === undefined ? 5 : options.hitTolerance; 47 | 48 | /** 49 | * Filter function to determine which features are elligible for selection. 50 | * By default we exclude features on unmanaged layers(for ex: nodes to delete). 51 | * @type {function(ol.Feature, ol.layer.Layer)} 52 | * @private 53 | */ 54 | this.selectFilter = 55 | options.selectFilter || 56 | ((feature, layer) => { 57 | if (layer && this.layerFilter) { 58 | return this.layerFilter(layer); 59 | } 60 | return !!layer; 61 | }); 62 | 63 | /** 64 | * 65 | * Return features elligible for selection on specific pixel. 66 | * @type {function(ol.events.MapBrowserEvent)} 67 | * @private 68 | */ 69 | this.getFeatureAtPixel = this.getFeatureAtPixel.bind(this); 70 | 71 | /* Cursor management */ 72 | this.previousCursor = null; 73 | this.cursorTimeout = null; 74 | this.cursorHandlerThrottled = throttle(this.cursorHandler.bind(this), 150, { 75 | leading: true, 76 | }); 77 | this.cursorStyleHandler = 78 | options?.cursorStyleHandler || ((cursorStyle) => cursorStyle); 79 | 80 | /* Interactions */ 81 | this.createSelectMoveInteraction(options.selectMoveOptions); 82 | this.createSelectModifyInteraction(options.selectModifyOptions); 83 | this.createModifyInteraction(options.modifyInteractionOptions); 84 | this.createMoveInteraction(options.moveInteractionOptions); 85 | this.createDeleteInteraction(options.deleteInteractionOptions); 86 | this.createDeselectInteraction(options.deselectInteractionOptions); 87 | } 88 | 89 | /** 90 | * Create the interaction used to select feature to move. 91 | * @param {*} options 92 | * @private 93 | */ 94 | createSelectMoveInteraction(options = {}) { 95 | /** 96 | * Select interaction to move features. 97 | * @type {ol.interaction.Select} 98 | * @private 99 | */ 100 | this.selectMove = new SelectMove({ 101 | filter: (feature, layer) => { 102 | // If the feature is already selected by modify interaction ignore the selection. 103 | if (this.isSelectedByModify(feature)) { 104 | return false; 105 | } 106 | return this.selectFilter(feature, layer); 107 | }, 108 | hitTolerance: this.hitTolerance, 109 | ...options, 110 | }); 111 | 112 | this.selectMove.getFeatures().on('add', () => { 113 | this.selectModify.getFeatures().clear(); 114 | this.moveInteraction.setActive(true); 115 | this.deleteInteraction.setFeatures(this.selectMove.getFeatures()); 116 | }); 117 | 118 | this.selectMove.getFeatures().on('remove', () => { 119 | // Deactive interaction when the select array is empty 120 | if (this.selectMove.getFeatures().getLength() === 0) { 121 | this.moveInteraction.setActive(false); 122 | this.deleteInteraction.setFeatures(); 123 | } 124 | }); 125 | this.selectMove.setActive(false); 126 | } 127 | 128 | /** 129 | * Create the interaction used to select feature to modify. 130 | * @param {*} options 131 | * @private 132 | */ 133 | createSelectModifyInteraction(options = {}) { 134 | /** 135 | * Select interaction to modify features. 136 | * @type {ol.interaction.Select} 137 | */ 138 | this.selectModify = new SelectModify({ 139 | filter: this.selectFilter, 140 | hitTolerance: this.hitTolerance, 141 | ...options, 142 | }); 143 | 144 | this.selectModify.getFeatures().on('add', () => { 145 | this.selectMove.getFeatures().clear(); 146 | this.modifyInteraction.setActive(true); 147 | this.deleteInteraction.setFeatures(this.selectModify.getFeatures()); 148 | }); 149 | 150 | this.selectModify.getFeatures().on('remove', () => { 151 | // Deactive interaction when the select array is empty 152 | if (this.selectModify.getFeatures().getLength() === 0) { 153 | this.modifyInteraction.setActive(false); 154 | this.deleteInteraction.setFeatures(); 155 | } 156 | }); 157 | this.selectModify.setActive(false); 158 | } 159 | 160 | /** 161 | * Create the interaction used to move feature. 162 | * @param {*} options 163 | * @private 164 | */ 165 | createMoveInteraction(options = {}) { 166 | /** 167 | * @type {ole.interaction.Move} 168 | * @private 169 | */ 170 | this.moveInteraction = new Move({ 171 | features: this.selectMove.getFeatures(), 172 | ...options, 173 | }); 174 | 175 | this.moveInteraction.on('movestart', (evt) => { 176 | this.editor.setEditFeature(evt.feature); 177 | this.isMoving = true; 178 | }); 179 | 180 | this.moveInteraction.on('moveend', () => { 181 | this.editor.setEditFeature(); 182 | this.isMoving = false; 183 | }); 184 | this.moveInteraction.setActive(false); 185 | } 186 | 187 | /** 188 | * Create the interaction used to modify vertexes of features. 189 | * @param {*} options 190 | * @private 191 | */ 192 | createModifyInteraction(options = {}) { 193 | /** 194 | * @type {ol.interaction.Modify} 195 | * @private 196 | */ 197 | this.modifyInteraction = new Modify({ 198 | features: this.selectModify.getFeatures(), 199 | deleteCondition: singleClick, 200 | ...options, 201 | }); 202 | 203 | this.modifyInteraction.on('modifystart', (evt) => { 204 | this.editor.setEditFeature(evt.features.item(0)); 205 | this.isModifying = true; 206 | }); 207 | 208 | this.modifyInteraction.on('modifyend', () => { 209 | this.editor.setEditFeature(); 210 | this.isModifying = false; 211 | }); 212 | this.modifyInteraction.setActive(false); 213 | } 214 | 215 | /** 216 | * Create the interaction used to delete selected features. 217 | * @param {*} options 218 | * @private 219 | */ 220 | createDeleteInteraction(options = {}) { 221 | /** 222 | * @type {ol.interaction.Delete} 223 | * @private 224 | */ 225 | this.deleteInteraction = new Delete({ source: this.source, ...options }); 226 | 227 | this.deleteInteraction.on('delete', () => { 228 | this.changeCursor(null); 229 | }); 230 | this.deleteInteraction.setActive(false); 231 | } 232 | 233 | /** 234 | * Create the interaction used to deselected features when we click on the map. 235 | * @param {*} options 236 | * @private 237 | */ 238 | createDeselectInteraction(options = {}) { 239 | // it's important that this condition was the same as the selectModify's 240 | // deleteCondition to avoid the selection of the feature under the node to delete. 241 | const condition = options.condition || singleClick; 242 | 243 | /** 244 | * @type {ol.interaction.Interaction} 245 | * @private 246 | */ 247 | this.deselectInteraction = new Interaction({ 248 | handleEvent: (mapBrowserEvent) => { 249 | if (!condition(mapBrowserEvent)) { 250 | return true; 251 | } 252 | const onFeature = this.getFeatureAtPixel(mapBrowserEvent.pixel); 253 | const onVertex = this.isHoverVertexFeatureAtPixel( 254 | mapBrowserEvent.pixel, 255 | ); 256 | 257 | if (!onVertex && !onFeature) { 258 | // Default: Clear selection on click outside features. 259 | this.selectMove.getFeatures().clear(); 260 | this.selectModify.getFeatures().clear(); 261 | return false; 262 | } 263 | return true; 264 | }, 265 | }); 266 | this.deselectInteraction.setActive(false); 267 | } 268 | 269 | /** 270 | * Get a selectable feature at a pixel. 271 | * @param {*} pixel 272 | */ 273 | getFeatureAtPixel(pixel) { 274 | const feature = this.map.forEachFeatureAtPixel( 275 | pixel, 276 | (feat, layer) => { 277 | if (this.selectFilter(feat, layer)) { 278 | return feat; 279 | } 280 | return null; 281 | }, 282 | { 283 | hitTolerance: this.hitTolerance, 284 | layerFilter: this.layerFilter, 285 | }, 286 | ); 287 | return feature; 288 | } 289 | 290 | /** 291 | * Detect if a vertex is hovered. 292 | * @param {*} pixel 293 | */ 294 | isHoverVertexFeatureAtPixel(pixel) { 295 | let isHoverVertex = false; 296 | this.map.forEachFeatureAtPixel( 297 | pixel, 298 | (feat, layer) => { 299 | if (!layer) { 300 | isHoverVertex = true; 301 | return true; 302 | } 303 | return false; 304 | }, 305 | { 306 | hitTolerance: this.hitTolerance, 307 | }, 308 | ); 309 | return isHoverVertex; 310 | } 311 | 312 | isSelectedByMove(feature) { 313 | return this.selectMove.getFeatures().getArray().indexOf(feature) !== -1; 314 | } 315 | 316 | isSelectedByModify(feature) { 317 | return this.selectModify.getFeatures().getArray().indexOf(feature) !== -1; 318 | } 319 | 320 | /** 321 | * Handle the move event of the move interaction. 322 | * @param {ol.MapBrowserEvent} evt Event. 323 | * @private 324 | */ 325 | cursorHandler(evt) { 326 | if (evt.dragging || this.isMoving || this.isModifying) { 327 | this.changeCursor('grabbing'); 328 | return; 329 | } 330 | 331 | const feature = this.getFeatureAtPixel(evt.pixel); 332 | if (!feature) { 333 | this.changeCursor(this.previousCursor); 334 | this.previousCursor = null; 335 | return; 336 | } 337 | 338 | if (this.isSelectedByMove(feature)) { 339 | this.changeCursor('grab'); 340 | } else if (this.isSelectedByModify(feature)) { 341 | if (this.isHoverVertexFeatureAtPixel(evt.pixel)) { 342 | this.changeCursor('grab'); 343 | } else { 344 | this.changeCursor(this.previousCursor); 345 | } 346 | } else { 347 | // Feature available for selection. 348 | this.changeCursor('pointer'); 349 | } 350 | } 351 | 352 | /** 353 | * Change cursor style. 354 | * @param {string} cursor New cursor name. 355 | * @private 356 | */ 357 | changeCursor(cursor) { 358 | if (!this.getActive()) { 359 | return; 360 | } 361 | const newCursor = this.cursorStyleHandler(cursor); 362 | const element = this.map.getViewport(); 363 | if ( 364 | (element.style.cursor || newCursor) && 365 | element.style.cursor !== newCursor 366 | ) { 367 | if (this.previousCursor === null) { 368 | this.previousCursor = element.style.cursor; 369 | } 370 | element.style.cursor = newCursor; 371 | } 372 | } 373 | 374 | setMap(map) { 375 | if (this.map) { 376 | this.map.removeInteraction(this.modifyInteraction); 377 | this.map.removeInteraction(this.moveInteraction); 378 | this.map.removeInteraction(this.selectMove); 379 | this.map.removeInteraction(this.selectModify); 380 | this.map.removeInteraction(this.deleteInteraction); 381 | this.map.removeInteraction(this.deselectInteraction); 382 | this.removeListeners(); 383 | } 384 | super.setMap(map); 385 | if (this.getActive()) { 386 | this.addListeners(); 387 | } 388 | this.map?.addInteraction(this.deselectInteraction); 389 | this.map?.addInteraction(this.deleteInteraction); 390 | this.map?.addInteraction(this.selectModify); 391 | // For the default behvior it's very important to add selectMove after selectModify. 392 | // It will avoid single/dbleclick mess. 393 | this.map?.addInteraction(this.selectMove); 394 | this.map?.addInteraction(this.moveInteraction); 395 | this.map?.addInteraction(this.modifyInteraction); 396 | } 397 | 398 | /** 399 | * Add others listeners on the map than interactions. 400 | * @param {*} evt 401 | * @private 402 | */ 403 | addListeners() { 404 | this.removeListeners(); 405 | this.cursorListenerKeys = [ 406 | this.map?.on('pointerdown', (evt) => { 407 | const element = evt.map.getViewport(); 408 | if (element?.style?.cursor === 'grab') { 409 | this.changeCursor('grabbing'); 410 | } 411 | }), 412 | this.map?.on('pointermove', this.cursorHandlerThrottled), 413 | this.map?.on('pointerup', (evt) => { 414 | const element = evt.map.getViewport(); 415 | if (element?.style?.cursor === 'grabbing') { 416 | this.changeCursor('grab'); 417 | } 418 | }), 419 | ]; 420 | } 421 | 422 | /** 423 | * Remove others listeners on the map than interactions. 424 | * @param {*} evt 425 | * @private 426 | */ 427 | removeListeners() { 428 | unByKey(this.cursorListenerKeys); 429 | } 430 | 431 | /** 432 | * @inheritdoc 433 | */ 434 | activate() { 435 | super.activate(); 436 | this.deselectInteraction.setActive(true); 437 | this.deleteInteraction.setActive(true); 438 | this.selectModify.setActive(true); 439 | // For the default behavior it's very important to add selectMove after selectModify. 440 | // It will avoid single/dbleclick mess. 441 | this.selectMove.setActive(true); 442 | this.addListeners(); 443 | } 444 | 445 | /** 446 | * @inheritdoc 447 | */ 448 | deactivate(silent) { 449 | this.removeListeners(); 450 | this.selectMove.getFeatures().clear(); 451 | this.selectModify.getFeatures().clear(); 452 | this.deselectInteraction.setActive(false); 453 | this.deleteInteraction.setActive(false); 454 | this.selectModify.setActive(false); 455 | this.selectMove.setActive(false); 456 | super.deactivate(silent); 457 | } 458 | } 459 | 460 | export default ModifyControl; 461 | -------------------------------------------------------------------------------- /src/control/rotate.js: -------------------------------------------------------------------------------- 1 | import { Style, Icon } from 'ol/style'; 2 | import Point from 'ol/geom/Point'; 3 | import Vector from 'ol/layer/Vector'; 4 | import VectorSource from 'ol/source/Vector'; 5 | import Pointer from 'ol/interaction/Pointer'; 6 | import Control from './control'; 7 | import rotateSVG from '../../img/rotate.svg'; 8 | import rotateMapSVG from '../../img/rotate_map.svg'; 9 | 10 | /** 11 | * Tool with for rotating geometries. 12 | * @extends {Control} 13 | * @alias ole.RotateControl 14 | */ 15 | class RotateControl extends Control { 16 | /** 17 | * @inheritdoc 18 | * @param {Object} [options] Control options. 19 | * @param {string} [options.rotateAttribute] Name of a feature attribute 20 | * that is used for storing the rotation in rad. 21 | * @param {ol.style.Style.StyleLike} [options.style] Style used for the rotation layer. 22 | */ 23 | constructor(options) { 24 | super({ 25 | title: 'Rotate', 26 | className: 'icon-rotate', 27 | image: rotateSVG, 28 | ...options, 29 | }); 30 | 31 | /** 32 | * @type {ol.interaction.Pointer} 33 | * @private 34 | */ 35 | this.pointerInteraction = new Pointer({ 36 | handleDownEvent: this.onDown.bind(this), 37 | handleDragEvent: this.onDrag.bind(this), 38 | handleUpEvent: this.onUp.bind(this), 39 | }); 40 | 41 | /** 42 | * @type {string} 43 | * @private 44 | */ 45 | this.rotateAttribute = options.rotateAttribute || 'ole_rotation'; 46 | 47 | /** 48 | * Layer for rotation feature. 49 | * @type {ol.layer.Vector} 50 | * @private 51 | */ 52 | this.rotateLayer = new Vector({ 53 | source: new VectorSource(), 54 | style: 55 | options.style || 56 | ((f) => { 57 | const rotation = f.get(this.rotateAttribute); 58 | return [ 59 | new Style({ 60 | geometry: new Point(this.center), 61 | image: new Icon({ 62 | rotation, 63 | src: rotateMapSVG, 64 | }), 65 | }), 66 | ]; 67 | }), 68 | }); 69 | } 70 | 71 | /** 72 | * Handle a pointer down event. 73 | * @param {ol.MapBrowserEvent} event Down event 74 | * @private 75 | */ 76 | onDown(evt) { 77 | this.dragging = false; 78 | this.feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => { 79 | if (this.source.getFeatures().indexOf(f) > -1) { 80 | return f; 81 | } 82 | 83 | return null; 84 | }); 85 | 86 | if (this.center && this.feature) { 87 | this.feature.set( 88 | this.rotateAttribute, 89 | this.feature.get(this.rotateAttribute) || 0, 90 | ); 91 | 92 | // rotation between clicked coordinate and feature center 93 | this.initialRotation = 94 | Math.atan2( 95 | evt.coordinate[1] - this.center[1], 96 | evt.coordinate[0] - this.center[0], 97 | ) + this.feature.get(this.rotateAttribute); 98 | } 99 | 100 | if (this.feature) { 101 | return true; 102 | } 103 | 104 | return false; 105 | } 106 | 107 | /** 108 | * Handle a pointer drag event. 109 | * @param {ol.MapBrowserEvent} event Down event 110 | * @private 111 | */ 112 | onDrag(evt) { 113 | this.dragging = true; 114 | 115 | if (this.feature && this.center) { 116 | const rotation = Math.atan2( 117 | evt.coordinate[1] - this.center[1], 118 | evt.coordinate[0] - this.center[0], 119 | ); 120 | 121 | const rotationDiff = this.initialRotation - rotation; 122 | const geomRotation = 123 | rotationDiff - this.feature.get(this.rotateAttribute); 124 | 125 | this.feature.getGeometry().rotate(-geomRotation, this.center); 126 | this.rotateFeature.getGeometry().rotate(-geomRotation, this.center); 127 | 128 | this.feature.set(this.rotateAttribute, rotationDiff); 129 | this.rotateFeature.set(this.rotateAttribute, rotationDiff); 130 | } 131 | } 132 | 133 | /** 134 | * Handle a pointer up event. 135 | * @param {ol.MapBrowserEvent} event Down event 136 | * @private 137 | */ 138 | onUp(evt) { 139 | if (!this.dragging) { 140 | if (this.feature) { 141 | this.rotateFeature = this.feature; 142 | this.center = evt.coordinate; 143 | this.rotateLayer.getSource().clear(); 144 | this.rotateLayer.getSource().addFeature(this.rotateFeature); 145 | } else { 146 | this.rotateLayer.getSource().clear(); 147 | } 148 | } 149 | } 150 | 151 | /** 152 | * @inheritdoc 153 | */ 154 | activate() { 155 | this.map?.addInteraction(this.pointerInteraction); 156 | this.rotateLayer.setMap(this.map); 157 | super.activate(); 158 | } 159 | 160 | /** 161 | * @inheritdoc 162 | */ 163 | deactivate(silent) { 164 | this.rotateLayer.getSource().clear(); 165 | this.rotateLayer.setMap(null); 166 | this.map?.removeInteraction(this.pointerInteraction); 167 | super.deactivate(silent); 168 | } 169 | } 170 | 171 | export default RotateControl; 172 | -------------------------------------------------------------------------------- /src/control/toolbar.js: -------------------------------------------------------------------------------- 1 | import Control from 'ol/control/Control'; 2 | 3 | /** 4 | * The editor's toolbar. 5 | * @class 6 | * @alias ole.Toolbar 7 | */ 8 | class Toolbar extends Control { 9 | /** 10 | * Constructor. 11 | * @param {ol.Map} map The map object. 12 | * @param {ol.Collection.} controls Controls 13 | * @param {HTMLElement} [options.target] Specify a target if you want 14 | * the control to be rendered outside of the map's viewport. 15 | */ 16 | constructor(map, controls, target) { 17 | const element = document.createElement('div'); 18 | element.setAttribute('id', 'ole-toolbar'); 19 | 20 | super({ 21 | element: target || element, 22 | }); 23 | 24 | /** 25 | * @private 26 | * @type {ol.Collection.} 27 | */ 28 | this.controls = controls; 29 | 30 | /** 31 | * @private 32 | * @type {ol.Map} 33 | */ 34 | this.map = map; 35 | 36 | if (!target) { 37 | this.map.getTargetElement().appendChild(this.element); 38 | } 39 | 40 | this.load(); 41 | this.controls.on('change:length', this.load.bind(this)); 42 | } 43 | 44 | /** 45 | * Load the toolbar. 46 | * @private 47 | */ 48 | load() { 49 | for (let i = 0; i < this.controls.getLength(); i += 1) { 50 | const btn = this.controls.item(i).getElement(); 51 | if (this.element && btn) { 52 | this.element.appendChild(btn); 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * Destroy the toolbar. 59 | * @private 60 | */ 61 | destroy() { 62 | for (let i = 0; i < this.controls.getLength(); i += 1) { 63 | const btn = this.controls.item(i).getElement(); 64 | if (this.element && btn) { 65 | this.element.removeChild(btn); 66 | } 67 | } 68 | } 69 | } 70 | 71 | export default Toolbar; 72 | -------------------------------------------------------------------------------- /src/control/topology.js: -------------------------------------------------------------------------------- 1 | import Select from 'ol/interaction/Select'; 2 | import Control from './control'; 3 | import delSVG from '../../img/buffer.svg'; 4 | 5 | /** 6 | * Control for deleting geometries. 7 | * @extends {Control} 8 | * @alias ole.TopologyControl 9 | */ 10 | class TopologyControl extends Control { 11 | /** 12 | * @inheritdoc 13 | * @param {Object} [options] Control options. 14 | * @param {number} [options.hitTolerance] Select tolerance in pixels 15 | * (default is 10) 16 | * @param {ol.style.Style.StyleLike} [options.style] Style used when a feature is selected. 17 | */ 18 | constructor(options) { 19 | super({ 20 | title: 'TopoloyOp', 21 | className: 'ole-control-topology', 22 | image: delSVG, 23 | ...options, 24 | }); 25 | 26 | /** 27 | * @type {ol.interaction.Select} 28 | * @private 29 | */ 30 | this.selectInteraction = new Select({ 31 | toggleCondition: () => true, 32 | layers: this.layerFilter, 33 | hitTolerance: 34 | options.hitTolerance === undefined ? 10 : options.hitTolerance, 35 | multi: true, 36 | style: options.style, 37 | }); 38 | 39 | this.selectInteraction.on('select', () => { 40 | const feats = this.selectInteraction.getFeatures(); 41 | 42 | try { 43 | this.applyTopologyOperation(feats.getArray()); 44 | } catch (ex) { 45 | // eslint-disable-next-line no-console 46 | console.error('Unable to process features.'); 47 | feats.clear(); 48 | } 49 | }); 50 | } 51 | 52 | /** 53 | * Apply a topology operation for given features. 54 | * @param {Array.} features Features. 55 | */ 56 | applyTopologyOperation(features) { 57 | this.topologyFeatures = features; 58 | } 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | activate() { 64 | this.map?.addInteraction(this.selectInteraction); 65 | this.addedFeatures = []; 66 | super.activate(); 67 | } 68 | 69 | /** 70 | * @inheritdoc 71 | */ 72 | deactivate(silent) { 73 | this.addedFeatures = []; 74 | this.map?.removeInteraction(this.selectInteraction); 75 | super.deactivate(silent); 76 | } 77 | } 78 | 79 | export default TopologyControl; 80 | -------------------------------------------------------------------------------- /src/control/union.js: -------------------------------------------------------------------------------- 1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser'; 2 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay'; 3 | import LinearRing from 'ol/geom/LinearRing'; 4 | import { 5 | Point, 6 | LineString, 7 | Polygon, 8 | MultiPoint, 9 | MultiLineString, 10 | MultiPolygon, 11 | } from 'ol/geom'; 12 | import TopologyControl from './topology'; 13 | import unionSVG from '../../img/union.svg'; 14 | 15 | /** 16 | * Control for creating a union of geometries. 17 | * @extends {Control} 18 | * @alias ole.Union 19 | */ 20 | class Union extends TopologyControl { 21 | /** 22 | * @inheritdoc 23 | * @param {Object} [options] Control options. 24 | * @param {number} [options.hitTolerance] Select tolerance in pixels 25 | * (default is 10) 26 | */ 27 | constructor(options) { 28 | super({ 29 | title: 'Union', 30 | className: 'ole-control-union', 31 | image: unionSVG, 32 | ...options, 33 | }); 34 | } 35 | 36 | /** 37 | * Apply a union for given features. 38 | * @param {Array.} features Features to union. 39 | */ 40 | applyTopologyOperation(features) { 41 | super.applyTopologyOperation(features); 42 | const parser = new OL3Parser(); 43 | parser.inject( 44 | Point, 45 | LineString, 46 | LinearRing, 47 | Polygon, 48 | MultiPoint, 49 | MultiLineString, 50 | MultiPolygon, 51 | ); 52 | 53 | for (let i = 1; i < features.length; i += 1) { 54 | const geom = parser.read(features[0].getGeometry()); 55 | const otherGeom = parser.read(features[i].getGeometry()); 56 | const unionGeom = OverlayOp.union(geom, otherGeom); 57 | features[0].setGeometry(parser.write(unionGeom)); 58 | features[i].setGeometry(null); 59 | } 60 | } 61 | } 62 | 63 | export default Union; 64 | -------------------------------------------------------------------------------- /src/editor.js: -------------------------------------------------------------------------------- 1 | import Collection from 'ol/Collection'; 2 | import BaseObject from 'ol/Object'; 3 | import Toolbar from './control/toolbar'; 4 | 5 | /** 6 | * Core component of OLE. 7 | * All controls are added to this class. 8 | */ 9 | class Editor extends BaseObject { 10 | /** 11 | * Initialization of the editor. 12 | * @param {ol.Map} map The map object. 13 | * @param {Object} [options] Editor options. 14 | * @param {Boolean} [options.showToolbar] Whether to show the toolbar. 15 | * @param {HTMLElement} [options.target] Specify a target if you want 16 | * the toolbar to be rendered outside of the map's viewport. 17 | */ 18 | constructor(map, opts) { 19 | super(); 20 | /** 21 | * @private 22 | * @type {ol.Map} 23 | */ 24 | this.map = map; 25 | 26 | /** 27 | * @private 28 | * @type {ol.Collection} 29 | */ 30 | this.controls = new Collection(); 31 | 32 | /** 33 | * @private 34 | * @type {ol.Collection} 35 | */ 36 | this.activeControls = new Collection(); 37 | 38 | /** 39 | * @private 40 | * @type {ol.Collection} 41 | */ 42 | this.services = new Collection(); 43 | 44 | /** 45 | * @private 46 | * @type {Object} 47 | */ 48 | this.options = opts || {}; 49 | 50 | /** 51 | * Feature that is currently edited. 52 | * @private 53 | * @type {ol.Feature} 54 | */ 55 | this.editFeature = null; 56 | 57 | if (typeof this.options.showToolbar === 'undefined') { 58 | this.options.showToolbar = true; 59 | } 60 | 61 | if (this.options.showToolbar) { 62 | this.toolbar = new Toolbar(this.map, this.controls, this.options.target); 63 | } 64 | 65 | this.activeStateChange = this.activeStateChange.bind(this); 66 | } 67 | 68 | /** 69 | * Adds a new control to the editor. 70 | * @param {ole.Control} control The control. 71 | */ 72 | addControl(control) { 73 | control.setMap(this.map); 74 | control.setEditor(this); 75 | control.addEventListener('change:active', this.activeStateChange); 76 | this.controls.push(control); 77 | } 78 | 79 | /** 80 | * Remove a control from the editor 81 | * @param {ole.Control} control The control. 82 | */ 83 | removeControl(control) { 84 | control.deactivate(); 85 | this.controls.remove(control); 86 | control.removeEventListener('change:active', this.activeStateChange); 87 | control.setEditor(); 88 | control.setMap(); 89 | } 90 | 91 | /** 92 | * Adds a service to the editor. 93 | */ 94 | addService(service) { 95 | service.setMap(this.map); 96 | service.setEditor(this); 97 | service.activate(); 98 | this.services.push(service); 99 | } 100 | 101 | /** 102 | * Adds a collection of controls to the editor. 103 | * @param {ol.Collection} controls Collection of controls. 104 | */ 105 | addControls(controls) { 106 | const ctrls = 107 | controls instanceof Collection ? controls : new Collection(controls); 108 | 109 | for (let i = 0; i < ctrls.getLength(); i += 1) { 110 | this.addControl(ctrls.item(i)); 111 | } 112 | } 113 | 114 | /** 115 | * Removes the editor from the map. 116 | */ 117 | remove() { 118 | const controls = [...this.controls.getArray()]; 119 | controls.forEach((control) => { 120 | this.removeControl(control); 121 | }); 122 | if (this.toolbar) { 123 | this.toolbar.destroy(); 124 | } 125 | } 126 | 127 | /** 128 | * Returns a list of ctive controls. 129 | * @returns {ol.Collection.} Active controls. 130 | */ 131 | getControls() { 132 | return this.controls; 133 | } 134 | 135 | /** 136 | * Returns a list of active controls. 137 | * @returns {ol.Collection.} Active controls. 138 | */ 139 | getActiveControls() { 140 | return this.activeControls; 141 | } 142 | 143 | /** 144 | * Sets an instance of the feature that is edited. 145 | * Some controls need information about the feature 146 | * that is currently edited (e.g. for not snapping on them). 147 | * @param {ol.Feature|null} feature The editfeature (or null if none) 148 | * @protected 149 | */ 150 | setEditFeature(feature) { 151 | if (feature !== this.editFeature) { 152 | this.editFeature = feature; 153 | } 154 | } 155 | 156 | /** 157 | * Returns the feature that is currently edited. 158 | * @returns {ol.Feature|null} The edit feature. 159 | */ 160 | getEditFeature() { 161 | return this.editFeature; 162 | } 163 | 164 | /** 165 | * Sets an instance of the feature that is being drawn. 166 | * Some controls need information about the feature 167 | * that is currently being drawn (e.g. for not snapping on them). 168 | * @param {ol.Feature|null} feature The drawFeature (or null if none). 169 | * @protected 170 | */ 171 | setDrawFeature(feature) { 172 | if (feature !== this.drawFeature) { 173 | this.drawFeature = feature; 174 | } 175 | } 176 | 177 | /** 178 | * Returns the feature that is currently being drawn. 179 | * @returns {ol.Feature|null} The drawFeature. 180 | */ 181 | getDrawFeature() { 182 | return this.drawFeature; 183 | } 184 | 185 | /** 186 | * Controls use this function for triggering activity state changes. 187 | * @param {ol.control.Control} control Control. 188 | * @private 189 | */ 190 | activeStateChange(evt) { 191 | const ctrl = evt.detail.control; 192 | // Deactivate other controls that are not standalone 193 | if (ctrl.getActive() && ctrl.standalone) { 194 | for (let i = 0; i < this.controls.getLength(); i += 1) { 195 | const otherCtrl = this.controls.item(i); 196 | if ( 197 | otherCtrl !== ctrl && 198 | otherCtrl.getActive() && 199 | otherCtrl.standalone 200 | ) { 201 | otherCtrl.deactivate(); 202 | this.activeControls.remove(otherCtrl); 203 | } 204 | } 205 | } 206 | 207 | if (ctrl.getActive()) { 208 | this.activeControls.push(ctrl); 209 | } else { 210 | this.activeControls.remove(ctrl); 211 | } 212 | } 213 | 214 | get editFeature() { 215 | return this.get('editFeature'); 216 | } 217 | 218 | set editFeature(feature) { 219 | this.set('editFeature', feature); 220 | } 221 | 222 | get drawFeature() { 223 | return this.get('drawFeature'); 224 | } 225 | 226 | set drawFeature(feature) { 227 | this.set('drawFeature', feature); 228 | } 229 | } 230 | 231 | export default Editor; 232 | -------------------------------------------------------------------------------- /src/editor.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { expect, test, describe, beforeEach } from 'vitest'; 3 | import Map from 'ol/Map'; 4 | import Editor from './editor'; 5 | import CAD from './control/cad'; 6 | import ModifyControl from './control/modify'; 7 | 8 | describe('editor', () => { 9 | let map; 10 | let editor; 11 | let cad; 12 | let modify; 13 | 14 | beforeEach(() => { 15 | // In the test we use pixel as coordinates. 16 | map = new Map({ 17 | target: document.createElement('div'), 18 | }); 19 | editor = new Editor(map); 20 | cad = new CAD(); 21 | modify = new ModifyControl(); 22 | }); 23 | 24 | test('adds a control', () => { 25 | editor.addControl(cad); 26 | expect(editor.controls.getArray()[0]).toBe(cad); 27 | expect(editor.activeControls.getLength()).toBe(0); 28 | expect(cad.map).toBe(map); 29 | expect(cad.editor).toBe(editor); 30 | expect(cad.getActive()).toBe(); 31 | 32 | cad.activate(); 33 | expect(cad.getActive()).toBe(true); 34 | expect(editor.activeControls.getArray()[0]).toBe(cad); 35 | }); 36 | 37 | test('removes a control', () => { 38 | editor.addControl(cad); 39 | cad.activate(); 40 | expect(cad.getActive()).toBe(true); 41 | expect(editor.controls.getArray()[0]).toBe(cad); 42 | expect(editor.activeControls.getArray()[0]).toBe(cad); 43 | editor.removeControl(cad); 44 | expect(editor.controls.getLength()).toBe(0); 45 | expect(editor.activeControls.getLength()).toBe(0); 46 | expect(cad.map).toBe(); 47 | expect(cad.editor).toBe(); 48 | expect(cad.getActive()).toBe(false); 49 | }); 50 | 51 | test('is removed', () => { 52 | editor.addControl(modify); 53 | editor.addControl(cad); 54 | modify.activate(); 55 | expect(modify.getActive()).toBe(true); 56 | expect(editor.controls.getArray()[0]).toBe(modify); 57 | expect(editor.controls.getArray()[1]).toBe(cad); 58 | expect(editor.activeControls.getArray()[0]).toBe(modify); 59 | expect(editor.activeControls.getArray()[0]).toBe(modify); 60 | editor.remove(); 61 | expect(editor.controls.getLength()).toBe(0); 62 | expect(editor.activeControls.getLength()).toBe(0); 63 | expect(modify.map).toBe(); 64 | expect(modify.editor).toBe(); 65 | expect(modify.getActive()).toBe(false); 66 | expect(cad.map).toBe(); 67 | expect(cad.editor).toBe(); 68 | expect(cad.getActive()).toBe(false); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/event/delete-event.js: -------------------------------------------------------------------------------- 1 | import Event from 'ol/events/Event'; 2 | 3 | /** 4 | * Enum for delete event type. 5 | * @enum {string} DeleteEventType DELETE 6 | * @ignore 7 | */ 8 | export const DeleteEventType = { 9 | /** 10 | * Triggered upon feature(s) is(are) deleted. 11 | * @type {string} 12 | */ 13 | DELETE: 'delete', 14 | }; 15 | 16 | /** 17 | * Events emitted by the snap interaction of cad control instances are 18 | * instances of this type. 19 | * @ignore 20 | */ 21 | export default class DeleteEvent extends Event { 22 | /** 23 | * @inheritdoc 24 | * @param {DeleteEventType} type Type. 25 | * @param {Feature} feature The feature snapped. 26 | * @param {MapBrowserPointerEvent} mapBrowserPointerEvent 27 | * @ignore 28 | */ 29 | constructor(type, features, mapBrowserPointerEvent) { 30 | super(type); 31 | 32 | /** 33 | * The features being deleted. 34 | * @type {Features} 35 | */ 36 | this.features = features; 37 | 38 | /** 39 | * @type {MapBrowserPointerEvent} 40 | */ 41 | this.mapBrowserEvent = mapBrowserPointerEvent; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/event/index.js: -------------------------------------------------------------------------------- 1 | export { default as DeleteEvent } from './delete-event'; 2 | export { default as MoveEvent } from './move-event'; 3 | export { default as SnapEvent } from './snap-event'; 4 | export * from './snap-event'; 5 | export * from './move-event'; 6 | export * from './delete-event'; 7 | -------------------------------------------------------------------------------- /src/event/move-event.js: -------------------------------------------------------------------------------- 1 | import Event from 'ol/events/Event'; 2 | 3 | /** 4 | * @enum {string} MoveEventType 5 | * @ignore 6 | */ 7 | export const MoveEventType = { 8 | /** 9 | * Triggered upon feature move start 10 | */ 11 | MOVESTART: 'movestart', 12 | 13 | /** 14 | * Triggered upon feature move end 15 | */ 16 | MOVEEND: 'moveend', 17 | }; 18 | 19 | /** 20 | * Events emitted by the move interaction of modify control instances are 21 | * instances of this type. 22 | * @ignore 23 | */ 24 | export default class MoveEvent extends Event { 25 | /** 26 | * @inheritdoc 27 | * @param {MoveEventType} type Type. 28 | * @param {Feature} feature The feature moved. 29 | * @param {MapBrowserPointerEvent} mapBrowserPointerEvent 30 | * @ignore 31 | */ 32 | constructor(type, feature, mapBrowserPointerEvent) { 33 | super(type); 34 | 35 | /** 36 | * The features being modified. 37 | * @type {Feature} 38 | */ 39 | this.feature = feature; 40 | 41 | /** 42 | * @type {MapBrowserPointerEvent} 43 | */ 44 | this.mapBrowserEvent = mapBrowserPointerEvent; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/event/snap-event.js: -------------------------------------------------------------------------------- 1 | import Event from 'ol/events/Event'; 2 | 3 | /** 4 | * Enum for snap event type. 5 | * @enum {string} SnapEventType SNAP 6 | * @ignore 7 | */ 8 | export const SnapEventType = { 9 | /** 10 | * Triggered upon feature is snapped. 11 | * @type {string} 12 | */ 13 | SNAP: 'snap', 14 | }; 15 | 16 | /** 17 | * Events emitted by the snap interaction of cad control instances are 18 | * instances of this type. 19 | * @ignore 20 | */ 21 | export default class SnapEvent extends Event { 22 | /** 23 | * @inheritdoc 24 | * @param {SnapEventType} type Type. 25 | * @param {Feature} feature The feature snapped. 26 | * @param {MapBrowserPointerEvent} mapBrowserPointerEvent 27 | * @ignore 28 | */ 29 | constructor(type, features, mapBrowserPointerEvent) { 30 | super(type); 31 | 32 | /** 33 | * The features being snapped. 34 | * @type {Features} 35 | */ 36 | this.features = features; 37 | 38 | /** 39 | * @type {MapBrowserPointerEvent} 40 | */ 41 | this.mapBrowserEvent = mapBrowserPointerEvent; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/helper/constants.js: -------------------------------------------------------------------------------- 1 | import { Fill, RegularShape, Stroke, Style } from 'ol/style'; 2 | 3 | export const ORTHO_LINE_KEY = 'ortho'; 4 | export const SEGMENT_LINE_KEY = 'segment'; 5 | export const VH_LINE_KEY = 'vh'; 6 | export const CUSTOM_LINE_KEY = 'custom'; 7 | export const SNAP_POINT_KEY = 'point'; 8 | export const SNAP_FEATURE_TYPE_PROPERTY = 'ole.snap.feature.type'; 9 | 10 | export const defaultSnapStyles = { 11 | [ORTHO_LINE_KEY]: new Style({ 12 | stroke: new Stroke({ 13 | width: 1, 14 | color: 'purple', 15 | lineDash: [5, 10], 16 | }), 17 | }), 18 | [SEGMENT_LINE_KEY]: new Style({ 19 | stroke: new Stroke({ 20 | width: 1, 21 | color: 'orange', 22 | lineDash: [5, 10], 23 | }), 24 | }), 25 | [VH_LINE_KEY]: new Style({ 26 | stroke: new Stroke({ 27 | width: 1, 28 | lineDash: [5, 10], 29 | color: '#618496', 30 | }), 31 | }), 32 | [SNAP_POINT_KEY]: new Style({ 33 | image: new RegularShape({ 34 | fill: new Fill({ 35 | color: '#E8841F', 36 | }), 37 | stroke: new Stroke({ 38 | width: 1, 39 | color: '#618496', 40 | }), 41 | points: 4, 42 | radius: 5, 43 | radius2: 0, 44 | angle: Math.PI / 4, 45 | }), 46 | }), 47 | }; 48 | -------------------------------------------------------------------------------- /src/helper/getDistance.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the distance between 2 coordinates of a plan. 3 | * 4 | * @param {Array} coordA 5 | * @param {Array} coordB 6 | * @returns number 7 | */ 8 | const getDistance = (coordA, coordB) => { 9 | const [xA, yA] = coordA; 10 | const [xB, yB] = coordB; 11 | return Math.sqrt((xB - xA) ** 2 + (yB - yA) ** 2); 12 | }; 13 | 14 | export default getDistance; 15 | -------------------------------------------------------------------------------- /src/helper/getEquationOfLine.js: -------------------------------------------------------------------------------- 1 | // Get the equation "y = mx + b" of line containing A and B 2 | // where m = (yB-yA)/(xB-xA) 3 | // an b = yB - mXB; 4 | const getEquationOfLine = (coordA, coordB) => { 5 | const [xA, yA] = coordA; 6 | const [xB, yB] = coordB; 7 | if (xB - xA === 0) { 8 | // No division by 0 9 | return null; 10 | } 11 | const m = (yB - yA) / (xB - xA); 12 | const b = yB - m * xB; 13 | return (x) => m * x + b; 14 | }; 15 | 16 | export default getEquationOfLine; 17 | -------------------------------------------------------------------------------- /src/helper/getIntersectedLinesAndPoint.js: -------------------------------------------------------------------------------- 1 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay'; 2 | import { Feature } from 'ol'; 3 | import { Point } from 'ol/geom'; 4 | import { SNAP_FEATURE_TYPE_PROPERTY, SNAP_POINT_KEY } from './constants'; 5 | import getDistance from './getDistance'; 6 | import isSameLines from './isSameLines'; 7 | import parser from './parser'; 8 | 9 | // Find lines that intersects and calculate the intersection point. 10 | // Return only point (and corresponding lines) that are distant from the mouse coordinate < snapTolerance 11 | const getIntersectedLinesAndPoint = (coordinate, lines, map, snapTolerance) => { 12 | const liness = []; 13 | const points = []; 14 | const isAlreadyIntersected = []; 15 | const isPointAlreadyExist = {}; 16 | const mousePx = map.getPixelFromCoordinate(coordinate); 17 | 18 | const parsedLines = lines.map((line) => [ 19 | line, 20 | parser.read(line.getGeometry()), 21 | ]); 22 | parsedLines.forEach(([lineA, parsedLineA]) => { 23 | parsedLines.forEach(([lineB, parsedLineB]) => { 24 | if (lineA === lineB || isSameLines(lineA, lineB, map)) { 25 | return; 26 | } 27 | 28 | let intersections; 29 | try { 30 | intersections = OverlayOp.intersection(parsedLineA, parsedLineB); 31 | } catch (e) { 32 | return; // The OverlayOp will sometimes error with topology errors for certain lines 33 | } 34 | 35 | const coord = intersections?.getCoordinates()[0]; 36 | if (coord) { 37 | intersections.getCoordinates().forEach(({ x, y }) => { 38 | if ( 39 | getDistance(map.getPixelFromCoordinate([x, y]), mousePx) <= 40 | snapTolerance 41 | ) { 42 | // Add lines only when the intersecting point is valid for snapping 43 | if (!isAlreadyIntersected.includes(lineA)) { 44 | liness.push(lineA); 45 | isAlreadyIntersected.push(lineA); 46 | } 47 | 48 | if (!isAlreadyIntersected.includes(lineB)) { 49 | liness.push(lineB); 50 | isAlreadyIntersected.push(lineB); 51 | } 52 | 53 | if (!isPointAlreadyExist[`${x}${y}`]) { 54 | isPointAlreadyExist[`${x}${y}`] = true; 55 | const feature = new Feature(new Point([x, y])); 56 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, SNAP_POINT_KEY); 57 | points.push(feature); 58 | } 59 | } 60 | }); 61 | } 62 | }); 63 | }); 64 | 65 | return [...liness, ...points]; 66 | }; 67 | 68 | export default getIntersectedLinesAndPoint; 69 | -------------------------------------------------------------------------------- /src/helper/getIntersectedLinesAndPoint.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { expect, test, describe, beforeEach } from 'vitest'; 3 | import LineString from 'ol/geom/LineString'; 4 | import Feature from 'ol/Feature'; 5 | import getIntersectedLinesAndPoint from './getIntersectedLinesAndPoint'; 6 | 7 | describe('getIntersectedLinesAndPoint', () => { 8 | let map; 9 | 10 | beforeEach(() => { 11 | // In the test we use pixel as coordinates. 12 | map = { 13 | getPixelFromCoordinate: (coord) => coord, 14 | }; 15 | }); 16 | 17 | test('returns empty array because lines are not intersected', () => { 18 | const line1 = new Feature( 19 | new LineString([ 20 | [0, 0], 21 | [1, 1], 22 | ]), 23 | ); 24 | const line2 = new Feature( 25 | new LineString([ 26 | [3, 4], 27 | [5, 7], 28 | ]), 29 | ); 30 | 31 | const intersectedLines = getIntersectedLinesAndPoint( 32 | [0, 0], 33 | [line1, line2], 34 | map, 35 | 0, 36 | ); 37 | 38 | expect(intersectedLines).toEqual([]); 39 | }); 40 | 41 | test('returns empty array because the tolerance is not big enough', () => { 42 | const line1 = new Feature( 43 | new LineString([ 44 | [0, 0], 45 | [1, 1], 46 | ]), 47 | ); 48 | const line2 = new Feature( 49 | new LineString([ 50 | [0, 1], 51 | [1, 0], 52 | ]), 53 | ); 54 | 55 | const intersectedLines = getIntersectedLinesAndPoint( 56 | [0, 0], 57 | [line1, line2], 58 | map, 59 | 0, 60 | ); 61 | 62 | expect(intersectedLines).toEqual([]); 63 | }); 64 | 65 | test('returns intersected lines and the intersection point', () => { 66 | const line1 = new Feature( 67 | new LineString([ 68 | [0, 0], 69 | [1, 1], 70 | ]), 71 | ); 72 | const line2 = new Feature( 73 | new LineString([ 74 | [0, 1], 75 | [1, 0], 76 | ]), 77 | ); 78 | 79 | const intersectedLines = getIntersectedLinesAndPoint( 80 | [0, 0], 81 | [line1, line2], 82 | map, 83 | 1, 84 | ); 85 | expect(intersectedLines[0]).toBe(line1); 86 | expect(intersectedLines[1]).toBe(line2); 87 | expect(intersectedLines[2].getGeometry().getCoordinates()).toEqual([ 88 | 0.5, 0.5, 89 | ]); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/helper/getProjectedPoint.js: -------------------------------------------------------------------------------- 1 | const dotProduct = (e1, e2) => e1[0] * e2[0] + e1[1] * e2[1]; 2 | 3 | /** 4 | * Get projected point P' of P on line e1. Faster version. 5 | * @return projected point p. 6 | * This code comes from section 5 of http://www.sunshine2k.de/coding/java/PointOnLine/PointOnLine.html. 7 | * The dotProduct function had a bug in the html page. It's fixed here. 8 | */ 9 | const getProjectedPoint = (p, v1, v2) => { 10 | // get dot product of e1, e2 11 | const e1 = [v2[0] - v1[0], v2[1] - v1[1]]; 12 | const e2 = [p[0] - v1[0], p[1] - v1[1]]; 13 | const valDp = dotProduct(e1, e2); 14 | 15 | // get squared length of e1 16 | const len2 = e1[0] * e1[0] + e1[1] * e1[1]; 17 | const res = [v1[0] + (valDp * e1[0]) / len2, v1[1] + (valDp * e1[1]) / len2]; 18 | return res; 19 | }; 20 | export default getProjectedPoint; 21 | -------------------------------------------------------------------------------- /src/helper/getShiftedMultiPoint.js: -------------------------------------------------------------------------------- 1 | import { MultiPoint } from 'ol/geom'; 2 | 3 | /** 4 | * Removes the closest node to a given coordinate from a given geometry. 5 | * @private 6 | * @param {ol.Geometry} geometry An openlayers geometry. 7 | * @param {ol.Coordinate} coordinate Coordinate. 8 | * @returns {ol.Geometry.MultiPoint} An openlayers MultiPoint geometry. 9 | */ 10 | const getShiftedMultipoint = (geometry, coordinate) => { 11 | // Include all but the closest vertex to the coordinate (e.g. at mouse position) 12 | // to prevent snapping on mouse cursor node 13 | const isPolygon = geometry.getType() === 'Polygon'; 14 | const shiftedMultipoint = new MultiPoint( 15 | isPolygon ? geometry.getCoordinates()[0] : geometry.getCoordinates(), 16 | ); 17 | 18 | const drawNodeCoordinate = shiftedMultipoint.getClosestPoint(coordinate); 19 | 20 | // Exclude the node being modified 21 | shiftedMultipoint.setCoordinates( 22 | shiftedMultipoint 23 | .getCoordinates() 24 | .filter((coord) => coord.toString() !== drawNodeCoordinate.toString()), 25 | ); 26 | 27 | return shiftedMultipoint; 28 | }; 29 | 30 | export default getShiftedMultipoint; 31 | -------------------------------------------------------------------------------- /src/helper/index.js: -------------------------------------------------------------------------------- 1 | export { default as getEquationOfLine } from './getEquationOfLine'; 2 | export { default as getIntersectedLinesAndPoint } from './getIntersectedLinesAndPoint'; 3 | export { default as getProjectedPoint } from './getProjectedPoint'; 4 | export { default as getShiftedMultiPoint } from './getShiftedMultiPoint'; 5 | export { default as isSameLines } from './isSameLines'; 6 | export { default as parser } from './parser'; 7 | export * from './constants'; 8 | -------------------------------------------------------------------------------- /src/helper/isSameLines.js: -------------------------------------------------------------------------------- 1 | // We consider 2 lines identical when 2 lines have the same equation when the use their pixel values not coordinate. 2 | // Using the coordinate the calculation is falsy because of some rounding. 3 | 4 | import getEquationOfLine from './getEquationOfLine'; 5 | 6 | // This function compares only 2 lines of 2 coordinates. 7 | const isSameLines = (lineA, lineB, map) => { 8 | const geomA = lineA.getGeometry(); 9 | const firstPxA = map.getPixelFromCoordinate(geomA.getFirstCoordinate()); 10 | const lastPxA = map.getPixelFromCoordinate(geomA.getLastCoordinate()); 11 | const lineFuncA = getEquationOfLine(firstPxA, lastPxA); 12 | 13 | const geomB = lineB.getGeometry(); 14 | const firstPxB = map.getPixelFromCoordinate(geomB.getFirstCoordinate()); 15 | const lastPxB = map.getPixelFromCoordinate(geomB.getLastCoordinate()); 16 | const lineFuncB = getEquationOfLine(firstPxB, lastPxB); 17 | 18 | // We compare with toFixed(2) becaus eof rounding issues converting pixel to coordinate. 19 | if ( 20 | lineFuncA && 21 | lineFuncB && 22 | lineFuncA(-350).toFixed(2) === lineFuncB(-350).toFixed(2) && 23 | lineFuncA(7800).toFixed(2) === lineFuncB(7800).toFixed(2) 24 | ) { 25 | return true; 26 | } 27 | 28 | // 2 are vertical lines 29 | if (!lineFuncA && !lineFuncB && firstPxA[0] && firstPxB[0]) { 30 | return true; 31 | } 32 | 33 | return false; 34 | }; 35 | 36 | export default isSameLines; 37 | -------------------------------------------------------------------------------- /src/helper/isSameLines.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { expect, test, describe, beforeEach } from 'vitest'; 3 | import LineString from 'ol/geom/LineString'; 4 | import Feature from 'ol/Feature'; 5 | import isSameLines from './isSameLines'; 6 | 7 | describe('isSameLines', () => { 8 | let map; 9 | 10 | beforeEach(() => { 11 | // In the test we use pixel as coordinates. 12 | map = { 13 | getPixelFromCoordinate: (coord) => coord, 14 | }; 15 | }); 16 | 17 | test('returns false', () => { 18 | const line1 = new Feature( 19 | new LineString([ 20 | [0, 0], 21 | [1, 1], 22 | ]), 23 | ); 24 | const line2 = new Feature( 25 | new LineString([ 26 | [3, 4], 27 | [5, 7], 28 | ]), 29 | ); 30 | 31 | const isSameLine = isSameLines(line1, line2, map); 32 | expect(isSameLine).toBe(false); 33 | }); 34 | 35 | test('returns true', () => { 36 | const line1 = new Feature( 37 | new LineString([ 38 | [0, 0], 39 | [1, 1], 40 | ]), 41 | ); 42 | const line2 = new Feature( 43 | new LineString([ 44 | [2, 2], 45 | [3, 3], 46 | ]), 47 | ); 48 | 49 | const isSameLine = isSameLines(line1, line2, map); 50 | expect(isSameLine).toBe(true); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/helper/parser.js: -------------------------------------------------------------------------------- 1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser'; 2 | import { LineString, MultiPoint, Point, Polygon } from 'ol/geom'; 3 | 4 | // Create a JSTS parser for OpenLayers geometry. 5 | const parser = new OL3Parser(); 6 | parser.inject(Point, LineString, Polygon, MultiPoint); 7 | 8 | export default parser; 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Editor } from './editor'; 2 | export * as control from './control'; 3 | export * as service from './service'; 4 | export * as interaction from './interaction'; 5 | export * as helper from './helper'; 6 | -------------------------------------------------------------------------------- /src/interaction/delete.js: -------------------------------------------------------------------------------- 1 | import Interaction from 'ol/interaction/Interaction'; 2 | import EventType from 'ol/events/EventType'; 3 | import { noModifierKeys, targetNotEditable } from 'ol/events/condition'; 4 | import DeleteEvent, { DeleteEventType } from '../event/delete-event'; 5 | 6 | class Delete extends Interaction { 7 | constructor(options = {}) { 8 | super(options); 9 | 10 | this.source = options.source; 11 | 12 | this.features = options.features; 13 | 14 | this.condition = 15 | options.condition || 16 | ((mapBrowserEvent) => { 17 | const bool = 18 | noModifierKeys(mapBrowserEvent) && 19 | targetNotEditable(mapBrowserEvent) && 20 | (mapBrowserEvent.originalEvent.keyCode === 46 || 21 | mapBrowserEvent.originalEvent.keyCode === 8); 22 | return bool; 23 | }); 24 | } 25 | 26 | setFeatures(features) { 27 | this.features = features; 28 | } 29 | 30 | handleEvent(mapBrowserEvent) { 31 | let stopEvent = false; 32 | if ( 33 | (mapBrowserEvent.type === EventType.KEYDOWN || 34 | mapBrowserEvent.type === EventType.KEYPRESS) && 35 | this.condition(mapBrowserEvent) && 36 | this.features 37 | ) { 38 | // Loop delete through selected features array 39 | this.features 40 | .getArray() 41 | .forEach((feature) => this.source.removeFeature(feature)); 42 | 43 | this.dispatchEvent( 44 | new DeleteEvent(DeleteEventType.DELETE, this.features, mapBrowserEvent), 45 | ); 46 | 47 | // Clean select's collection 48 | this.features.clear(); 49 | stopEvent = true; 50 | } 51 | return !stopEvent; 52 | } 53 | } 54 | 55 | export default Delete; 56 | -------------------------------------------------------------------------------- /src/interaction/index.js: -------------------------------------------------------------------------------- 1 | export { default as Delete } from './delete'; 2 | export { default as SelectMove } from './selectmove'; 3 | export { default as SelectModify } from './selectmodify'; 4 | export { default as Move } from './move'; 5 | -------------------------------------------------------------------------------- /src/interaction/move.js: -------------------------------------------------------------------------------- 1 | import Pointer from 'ol/interaction/Pointer'; 2 | import Point from 'ol/geom/Point'; 3 | import { getCenter } from 'ol/extent'; 4 | import MoveEvent, { MoveEventType } from '../event/move-event'; 5 | 6 | class Move extends Pointer { 7 | constructor(options) { 8 | super(); 9 | this.features = options.features; 10 | } 11 | 12 | /** 13 | * Handle the down event of the move interaction. 14 | * @param {ol.MapBrowserEvent} evt Event. 15 | * @private 16 | */ 17 | handleDownEvent(evt) { 18 | [this.featureToMove] = evt.map.getFeaturesAtPixel(evt.pixel); 19 | if ( 20 | !this.featureToMove || 21 | !this.features.getArray().includes(this.featureToMove) 22 | ) { 23 | return false; 24 | } 25 | 26 | if (this.featureToMove.getGeometry() instanceof Point) { 27 | const extent = this.featureToMove.getGeometry().getExtent(); 28 | this.coordinate = getCenter(extent); 29 | } else { 30 | this.coordinate = evt.coordinate; 31 | } 32 | this.isMoving = true; 33 | this.dispatchEvent( 34 | new MoveEvent(MoveEventType.MOVESTART, this.featureToMove, evt), 35 | ); 36 | 37 | return true; 38 | } 39 | 40 | /** 41 | * Handle the drag event of the move interaction. 42 | * @param {ol.MapBrowserEvent} evt Event. 43 | * @private 44 | */ 45 | handleDragEvent(evt) { 46 | const deltaX = evt.coordinate[0] - this.coordinate[0]; 47 | const deltaY = evt.coordinate[1] - this.coordinate[1]; 48 | 49 | this.featureToMove.getGeometry().translate(deltaX, deltaY); 50 | this.coordinate = evt.coordinate; 51 | } 52 | 53 | /** 54 | * Handle the up event of the pointer interaction. 55 | * @param {ol.MapBrowserEvent} evt Event. 56 | * @private 57 | */ 58 | handleUpEvent(evt) { 59 | this.dispatchEvent( 60 | new MoveEvent(MoveEventType.MOVEEND, this.featureToMove, evt), 61 | ); 62 | this.coordinate = null; 63 | this.isMoving = false; 64 | this.featureToMove = null; 65 | return false; 66 | } 67 | } 68 | 69 | export default Move; 70 | -------------------------------------------------------------------------------- /src/interaction/selectmodify.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import Select from 'ol/interaction/Select'; 3 | import { doubleClick } from 'ol/events/condition'; 4 | import { Circle, Style, Fill, Stroke } from 'ol/style'; 5 | import GeometryCollection from 'ol/geom/GeometryCollection'; 6 | import { MultiPoint } from 'ol/geom'; 7 | 8 | // Default style on modifying geometries 9 | const selectModifyStyle = new Style({ 10 | zIndex: 10000, // Always on top 11 | image: new Circle({ 12 | radius: 5, 13 | fill: new Fill({ 14 | color: '#05A0FF', 15 | }), 16 | stroke: new Stroke({ color: '#05A0FF', width: 2 }), 17 | }), 18 | stroke: new Stroke({ 19 | color: '#05A0FF', 20 | width: 3, 21 | }), 22 | fill: new Fill({ 23 | color: 'rgba(255,255,255,0.4)', 24 | }), 25 | geometry: (f) => { 26 | const coordinates = []; 27 | const geometry = f.getGeometry(); 28 | let geometries = [geometry]; 29 | if (geometry.getType() === 'GeometryCollection') { 30 | geometries = geometry.getGeometriesArrayRecursive(); 31 | } 32 | 33 | // At this point geometries doesn't contains any GeometryCollections. 34 | geometries.forEach((geom) => { 35 | let multiGeometries = [geom]; 36 | if (geom.getType() === 'MultiLineString') { 37 | multiGeometries = geom.getLineStrings(); 38 | } else if (geom.getType() === 'MultiPolygon') { 39 | multiGeometries = geom.getPolygons(); 40 | } else if (geom.getType() === 'MultiPoint') { 41 | multiGeometries = geom.getPoints(); 42 | } 43 | // At this point multiGeometries contains only single geometry. 44 | multiGeometries.forEach((geomm) => { 45 | if (geomm.getType() === 'Polygon') { 46 | geomm.getLinearRings().forEach((ring) => { 47 | coordinates.push(...ring.getCoordinates()); 48 | }); 49 | } else if (geomm.getType() === 'LineString') { 50 | coordinates.push(...geomm.getCoordinates()); 51 | } else if (geomm.getType() === 'Point') { 52 | coordinates.push(geomm.getCoordinates()); 53 | } 54 | }); 55 | }); 56 | return new GeometryCollection([ 57 | f.getGeometry(), 58 | new MultiPoint(coordinates), 59 | ]); 60 | }, 61 | }); 62 | 63 | /** 64 | * Select features for modification by a Modify interaction. 65 | * 66 | * Default behavior: 67 | * - Double click on the feature to select one feature. 68 | * - Click on the map to deselect all features. 69 | */ 70 | class SelectModify extends Select { 71 | /** 72 | * @param {Options=} options Options. 73 | * @ignore 74 | */ 75 | constructor(options) { 76 | super({ 77 | condition: doubleClick, 78 | style: selectModifyStyle, 79 | ...options, 80 | }); 81 | } 82 | 83 | // We redefine the handle method to avoid propagation of double click to the map. 84 | handleEvent(mapBrowserEvent) { 85 | if (!this.condition_(mapBrowserEvent)) { 86 | return true; 87 | } 88 | const add = this.addCondition_(mapBrowserEvent); 89 | const remove = this.removeCondition_(mapBrowserEvent); 90 | const toggle = this.toggleCondition_(mapBrowserEvent); 91 | const { map } = mapBrowserEvent; 92 | const set = !add && !remove && !toggle; 93 | if (set) { 94 | let isEvtOnSelectableFeature = false; 95 | map.forEachFeatureAtPixel( 96 | mapBrowserEvent.pixel, 97 | (feature, layer) => { 98 | if (this.filter_(feature, layer)) { 99 | isEvtOnSelectableFeature = true; 100 | } 101 | }, 102 | { 103 | layerFilter: this.layerFilter_, 104 | hitTolerance: this.hitTolerance_, 105 | }, 106 | ); 107 | 108 | if (isEvtOnSelectableFeature) { 109 | // if a feature is about to be selected or unselected we stop event propagation. 110 | super.handleEvent(mapBrowserEvent); 111 | return false; 112 | } 113 | } 114 | 115 | return super.handleEvent(mapBrowserEvent); 116 | } 117 | } 118 | 119 | export default SelectModify; 120 | -------------------------------------------------------------------------------- /src/interaction/selectmove.js: -------------------------------------------------------------------------------- 1 | import Select from 'ol/interaction/Select'; 2 | import { Circle, Style, Fill, Stroke } from 'ol/style'; 3 | import { singleClick } from 'ol/events/condition'; 4 | 5 | // Default style on moving geometries 6 | const selectMoveStyle = new Style({ 7 | zIndex: 10000, // Always on top 8 | image: new Circle({ 9 | radius: 5, 10 | fill: new Fill({ 11 | color: '#05A0FF', 12 | }), 13 | stroke: new Stroke({ color: '#05A0FF', width: 2 }), 14 | }), 15 | stroke: new Stroke({ 16 | color: '#05A0FF', 17 | width: 3, 18 | }), 19 | fill: new Fill({ 20 | color: 'rgba(255,255,255,0.4)', 21 | }), 22 | }); 23 | 24 | /** 25 | * Select features for modification by a Move interaction. 26 | * 27 | * Default behavior: 28 | * - Single click on the feature to select one feature. 29 | */ 30 | class SelectMove extends Select { 31 | /** 32 | * @param {Options=} options Options. 33 | * @ignore 34 | */ 35 | constructor(options) { 36 | super({ 37 | condition: singleClick, 38 | style: selectMoveStyle, 39 | ...options, 40 | }); 41 | } 42 | } 43 | 44 | export default SelectMove; 45 | -------------------------------------------------------------------------------- /src/service/index.js: -------------------------------------------------------------------------------- 1 | export { default as LocalStorage } from './local-storage'; 2 | export { default as Storage } from './storage'; 3 | -------------------------------------------------------------------------------- /src/service/local-storage.js: -------------------------------------------------------------------------------- 1 | import Storage from './storage'; 2 | 3 | /** 4 | * OLE LocalStorage. 5 | * Saves control properties to the browser's localStorage. 6 | * @alias ole.service.LocalStorage 7 | */ 8 | export default class LocalStorage extends Storage { 9 | /** 10 | * @inheritdoc 11 | */ 12 | storeProperties(controlName, properties) { 13 | const props = super.storeProperties(controlName, properties); 14 | window.localStorage.setItem(controlName, JSON.stringify(props)); 15 | } 16 | 17 | /** 18 | * @inheritdoc 19 | */ 20 | restoreProperties() { 21 | for (let i = 0; i < this.controls.length; i += 1) { 22 | const controlName = this.controls[i].getProperties().title; 23 | const props = window.localStorage.getItem(controlName); 24 | 25 | if (props) { 26 | this.controls[i].setProperties(JSON.parse(props), true); 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * @inheritdoc 33 | */ 34 | storeActiveControls() { 35 | const activeControlNames = super.storeActiveControls(); 36 | window.localStorage.setItem('active', JSON.stringify(activeControlNames)); 37 | } 38 | 39 | /** 40 | * @inheritdoc 41 | */ 42 | restoreActiveControls() { 43 | let activeControlNames = window.localStorage.getItem('active'); 44 | activeControlNames = activeControlNames 45 | ? JSON.parse(activeControlNames) 46 | : []; 47 | 48 | if (!activeControlNames.length) { 49 | return; 50 | } 51 | 52 | for (let i = 0; i < this.controls.length; i += 1) { 53 | const controlName = this.controls[i].getProperties().title; 54 | 55 | if (activeControlNames.indexOf(controlName) > -1) { 56 | this.controls[i].activate(); 57 | } else { 58 | this.controls[i].deactivate(); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/service/service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * OLE service base class. 3 | * @alias ole.Service 4 | */ 5 | export default class Service { 6 | constructor() { 7 | this.active = false; 8 | 9 | /** 10 | * @type {ole.Editor} 11 | * @private 12 | */ 13 | this.editor = null; 14 | 15 | /** 16 | * @type {ol.Map} 17 | * @private 18 | */ 19 | this.map = null; 20 | } 21 | 22 | /** 23 | * Activate the service. 24 | * @priavte 25 | */ 26 | activate() { 27 | this.active = true; 28 | } 29 | 30 | /** 31 | * Deactivate the service. 32 | * @priavte 33 | */ 34 | deactivate() { 35 | this.active = false; 36 | } 37 | 38 | /** 39 | * Set the service's editor instance. 40 | * @param {ole.Editor} editor Editor instance. 41 | */ 42 | setEditor(editor) { 43 | this.editor = editor; 44 | } 45 | 46 | /** 47 | * Set the service's map. 48 | * @param {ol.Map} map Map object. 49 | */ 50 | setMap(map) { 51 | this.map = map; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/service/storage.js: -------------------------------------------------------------------------------- 1 | import Service from './service'; 2 | 3 | /** 4 | * OLE storage service. 5 | * Base class for storage services, 6 | * such as LocalStorage, PermalinkStorage, CookieStorage. 7 | * @alias ole.service.Storage 8 | */ 9 | export default class Storage extends Service { 10 | /** 11 | * Saves control properties. 12 | * @param {object} [options] Service options 13 | * @param {array.} [controls] List of controls. 14 | * If undefined, all controls of the editor are used. 15 | */ 16 | constructor(optOptions) { 17 | const options = optOptions || {}; 18 | super(); 19 | 20 | /** 21 | * List of service controls 22 | * @type {array.} 23 | * @private 24 | */ 25 | this.controls = options.controls; 26 | 27 | /** 28 | * List of properties keys to ignore. 29 | * @type {array.} 30 | */ 31 | this.ignoreKeys = ['title', 'image', 'className']; 32 | } 33 | 34 | /** 35 | * @inheritdoc 36 | */ 37 | activate() { 38 | super.activate(); 39 | this.controls = this.controls || this.editor.getControls().getArray(); 40 | this.restoreProperties(); 41 | this.restoreActiveControls(); 42 | 43 | this.controls.forEach((control) => { 44 | control.addEventListener('propertychange', (evt) => { 45 | this.storeProperties( 46 | evt.detail.control.getProperties().title, 47 | evt.detail.properties, 48 | ); 49 | }); 50 | 51 | control.addEventListener('change:active', () => { 52 | this.storeActiveControls(); 53 | }); 54 | }); 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | */ 60 | deactivate() { 61 | super.deactivate(); 62 | 63 | this.controls.forEach((control) => { 64 | control.removeEventListener('propertychange'); 65 | }); 66 | } 67 | 68 | /** 69 | * Store control properties. 70 | * @param {string} controlName Name of the control. 71 | * @param {object} properties Control properties. 72 | */ 73 | storeProperties(controlName, properties) { 74 | const storageProps = {}; 75 | const propKeys = Object.keys(properties); 76 | 77 | for (let i = 0; i < propKeys.length; i += 1) { 78 | const key = propKeys[i]; 79 | if ( 80 | this.ignoreKeys.indexOf(key) === -1 && 81 | !(properties[key] instanceof Object) 82 | ) { 83 | storageProps[key] = properties[key]; 84 | } 85 | } 86 | 87 | return storageProps; 88 | } 89 | 90 | /** 91 | * Restore the control properties. 92 | */ 93 | // eslint-disable-next-line class-methods-use-this 94 | restoreProperties() { 95 | // to be implemented by child class 96 | } 97 | 98 | /** 99 | * Store the active state of controls. 100 | */ 101 | storeActiveControls() { 102 | const activeControls = this.editor.getActiveControls(); 103 | return activeControls.getArray().map((c) => c.getProperties().title); 104 | } 105 | 106 | /** 107 | * Restore the active state of the controls. 108 | */ 109 | // eslint-disable-next-line class-methods-use-this 110 | restoreActiveControls() { 111 | // to be implemented by child class 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /style/ole.css: -------------------------------------------------------------------------------- 1 | #ole-toolbar { 2 | position: absolute; 3 | right: 20px; 4 | top: 20px; 5 | } 6 | 7 | /* shadow */ 8 | #ole-toolbar button.ole-control, 9 | .ole-dialog { 10 | box-shadow: 0 3px 3px 0 rgb(0 0 0 / 20%); 11 | } 12 | 13 | /* buttons */ 14 | #ole-toolbar button.ole-control { 15 | background: #fafafa; 16 | border: 0; 17 | color: #999; 18 | cursor: pointer; 19 | font-size: 14px; 20 | line-height: 36px; 21 | height: 45px; 22 | transition: all 0.3s ease-out; 23 | padding: 5px; 24 | } 25 | 26 | #ole-toolbar button.ole-control:first-child { 27 | border-radius: 4px 0 0 4px; 28 | } 29 | 30 | #ole-toolbar button.ole-control:last-child { 31 | border-radius: 0 4px 4px 0; 32 | } 33 | 34 | #ole-toolbar button.ole-control:hover { 35 | color: #5c5c5c; 36 | } 37 | 38 | #ole-toolbar button.ole-control:focus { 39 | outline: 0; 40 | } 41 | 42 | #ole-toolbar button.ole-control.active { 43 | box-shadow: 0 4px 4px 0 rgb(0 0 0 / 30%); 44 | color: #5c5c5c; 45 | filter: brightness(90%); 46 | } 47 | 48 | #ole-toolbar button.ole-control img { 49 | height: 35px; 50 | } 51 | 52 | /* dialog */ 53 | .ole-dialog { 54 | background: #fafafa; 55 | border-radius: 4px; 56 | right: 20px; 57 | padding: 10px; 58 | position: absolute; 59 | text-align: left; 60 | top: 75px; 61 | width: 330px; 62 | z-index: 2; 63 | } 64 | 65 | /* font */ 66 | #ole-toolbar, 67 | .ole-dialog { 68 | font-family: Arial, sans-serif; 69 | font-size: 14px; 70 | } 71 | 72 | #width-input { 73 | width: 50px; 74 | } 75 | -------------------------------------------------------------------------------- /style/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | margin: 0; 3 | padding: 0; 4 | height: 100%; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | padding: 0; 10 | height: 100%; 11 | } 12 | 13 | a { 14 | text-decoration: none; 15 | color: #61849c; 16 | } 17 | 18 | a:hover { 19 | text-decoration: underline; 20 | } 21 | 22 | a.active, 23 | a.visited { 24 | font-weight: bold; 25 | color: #61849c; 26 | } 27 | 28 | #header { 29 | border-bottom: 2px solid #61849c; 30 | color: #61849c; 31 | height: 20px; 32 | padding: 20px; 33 | text-align: right; 34 | } 35 | 36 | #header nav { 37 | display: flex; 38 | justify-content: space-between; 39 | align-items: center; 40 | } 41 | 42 | #links { 43 | display: flex; 44 | gap: 40px; 45 | padding: 0 40px; 46 | } 47 | 48 | #map { 49 | position: relative; 50 | height: 100%; 51 | } 52 | 53 | #app { 54 | font-family: Avenir, Helvetica, Arial, sans-serif; 55 | -webkit-font-smoothing: antialiased; 56 | -moz-osx-font-smoothing: grayscale; 57 | color: #2c3e50; 58 | min-height: 100%; 59 | overflow: hidden; 60 | position: absolute; 61 | width: 100%; 62 | height: 100%; 63 | } 64 | 65 | #brand { 66 | font-size: 1.5em; 67 | font-weight: bold; 68 | } 69 | 70 | #promo { 71 | position: absolute; 72 | bottom: 50px; 73 | background-color: red; 74 | right: -65px; 75 | transform: rotate(-45deg); 76 | z-index: 1; 77 | } 78 | 79 | #promo-text { 80 | border: 2px solid white; 81 | color: white; 82 | font-size: 14px; 83 | font-family: sans-serif; 84 | font-weight: bold; 85 | margin: 2px; 86 | padding: 0 70px; 87 | } 88 | 89 | #promo a:hover { 90 | text-decoration: none; 91 | } 92 | 93 | #copyright { 94 | font-family: Arial, sans-serif; 95 | font-size: 12px; 96 | background-color: rgb(255 255 255); 97 | box-shadow: 1px 2px 4px rgb(0 0 0 / 70%); 98 | position: absolute; 99 | display: flex; 100 | gap: 5px; 101 | padding: 5px; 102 | bottom: 0; 103 | left: 0; 104 | right: 0; 105 | } 106 | -------------------------------------------------------------------------------- /tasks/prepare-package.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'node:fs'; 2 | import { dirname, join } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | const baseDir = dirname(fileURLToPath(import.meta.url)); 6 | 7 | function main() { 8 | const pkg = JSON.parse(readFileSync(join(baseDir, '../package.json'))); 9 | 10 | // write out simplified package.json 11 | pkg.main = 'index.js'; 12 | delete pkg.devDependencies; 13 | delete pkg.scripts; 14 | const data = JSON.stringify(pkg, null, 2); 15 | writeFileSync(join(baseDir, '../build/package.json'), data); 16 | 17 | // copy over license and readme 18 | writeFileSync( 19 | join(baseDir, '../build/LICENSE'), 20 | readFileSync(join(baseDir, '../README.md')), 21 | ); 22 | 23 | writeFileSync( 24 | join(baseDir, '../build/README.md'), 25 | readFileSync(join(baseDir, '../README.md')), 26 | ); 27 | } 28 | 29 | main(); 30 | -------------------------------------------------------------------------------- /vitest.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | // eslint-disable-next-line import/no-unresolved 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | environment: 'happy-dom', 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------