From f961dd5e0b4fef25d8e95fb21814cb09b507bfc2 Mon Sep 17 00:00:00 2001 From: matthew44-mappable <155086725+matthew44-mappable@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:42:58 +0300 Subject: [PATCH] Add control of rotation and tilt to the map (#1) Add controls: * MMapRotateControl - the control for rotating the map * MMapTiltControl - the control to tilt the map * MMapRotateTiltControl - a combined control for tilting and rotating the map --- example/common.css | 26 ++ example/rotate-tilt-controls/common.js | 23 ++ example/rotate-tilt-controls/react.html | 67 ++++ example/rotate-tilt-controls/vanilla.html | 45 +++ example/rotate-tilt-controls/vue.html | 61 ++++ package-lock.json | 285 +++++++++++++++++- package.json | 3 +- .../MMapButtonExample.test.ts | 42 --- src/MMapButtonExample/MMapButtonExample.ts | 33 -- .../MMapButtonExample.test.ts.snap | 139 --------- src/controls/MMapRotateControl/index.css | 33 ++ src/controls/MMapRotateControl/index.ts | 154 ++++++++++ src/controls/MMapRotateControl/vue/index.ts | 11 + .../MMapRotateControl.css | 53 ++++ .../MMapRotateControl.ts | 120 ++++++++ .../MMapRotateTiltControl/MMapTiltControl.css | 36 +++ .../MMapRotateTiltControl/MMapTiltControl.ts | 116 +++++++ src/controls/MMapRotateTiltControl/index.ts | 59 ++++ .../MMapRotateTiltControl/vue/index.ts | 11 + src/controls/MMapTiltControl/index.css | 72 +++++ src/controls/MMapTiltControl/index.ts | 197 ++++++++++++ .../MMapTiltControl/tilt-indicator.svg | 3 + .../MMapTiltControl/tilt-indicator_active.svg | 3 + src/controls/MMapTiltControl/vue/index.ts | 11 + src/controls/assets/mappable-compass.svg | 1 + .../assets/mappable-compass_hover.svg | 1 + src/controls/index.ts | 3 + src/controls/utils/angle-utils.test.ts | 69 +++++ src/controls/utils/angle-utils.ts | 52 ++++ src/index.ts | 2 +- 30 files changed, 1508 insertions(+), 223 deletions(-) create mode 100644 example/common.css create mode 100644 example/rotate-tilt-controls/common.js create mode 100644 example/rotate-tilt-controls/react.html create mode 100644 example/rotate-tilt-controls/vanilla.html create mode 100644 example/rotate-tilt-controls/vue.html delete mode 100644 src/MMapButtonExample/MMapButtonExample.test.ts delete mode 100644 src/MMapButtonExample/MMapButtonExample.ts delete mode 100644 src/MMapButtonExample/__snapshots__/MMapButtonExample.test.ts.snap create mode 100644 src/controls/MMapRotateControl/index.css create mode 100644 src/controls/MMapRotateControl/index.ts create mode 100644 src/controls/MMapRotateControl/vue/index.ts create mode 100644 src/controls/MMapRotateTiltControl/MMapRotateControl.css create mode 100644 src/controls/MMapRotateTiltControl/MMapRotateControl.ts create mode 100644 src/controls/MMapRotateTiltControl/MMapTiltControl.css create mode 100644 src/controls/MMapRotateTiltControl/MMapTiltControl.ts create mode 100644 src/controls/MMapRotateTiltControl/index.ts create mode 100644 src/controls/MMapRotateTiltControl/vue/index.ts create mode 100644 src/controls/MMapTiltControl/index.css create mode 100644 src/controls/MMapTiltControl/index.ts create mode 100644 src/controls/MMapTiltControl/tilt-indicator.svg create mode 100644 src/controls/MMapTiltControl/tilt-indicator_active.svg create mode 100644 src/controls/MMapTiltControl/vue/index.ts create mode 100644 src/controls/assets/mappable-compass.svg create mode 100644 src/controls/assets/mappable-compass_hover.svg create mode 100644 src/controls/index.ts create mode 100644 src/controls/utils/angle-utils.test.ts create mode 100644 src/controls/utils/angle-utils.ts diff --git a/example/common.css b/example/common.css new file mode 100644 index 0000000..772cccd --- /dev/null +++ b/example/common.css @@ -0,0 +1,26 @@ +html, +body, +#app { + padding: 0; + margin: 0; + width: 100%; + height: 100%; + font-family: Arial, sans-serif; + font-size: 16px; +} + +#app { + background-color: #f5f5f5; +} + +.content { + width: 1024px; + margin: 0 auto; +} + +.version { + font-size: 14px; + padding: 10px 0; + color: #999; + text-align: right; +} diff --git a/example/rotate-tilt-controls/common.js b/example/rotate-tilt-controls/common.js new file mode 100644 index 0000000..ed46311 --- /dev/null +++ b/example/rotate-tilt-controls/common.js @@ -0,0 +1,23 @@ +mappable.import.loaders.unshift(async (pkg) => { + if (!pkg.startsWith('@mappable-world/mappable-default-ui-theme')) { + return; + } + + if (location.href.includes('localhost')) { + await mappable.import.script(`/dist/index.js`); + } else { + await mappable.import.script(`https://unpkg.com/${pkg}/dist/index.js`); + } + // @ts-ignore + return window['@mappable-world/mappable-default-ui-theme']; +}); + +const BOUNDS = [ + [54.58311, 25.9985], + [56.30248, 24.47889] +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const LOCATION = {bounds: BOUNDS}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ENABLED_BEHAVIORS = ['drag', 'scrollZoom', 'dblClick', 'mouseTilt', 'mouseRotate']; diff --git a/example/rotate-tilt-controls/react.html b/example/rotate-tilt-controls/react.html new file mode 100644 index 0000000..8afbf92 --- /dev/null +++ b/example/rotate-tilt-controls/react.html @@ -0,0 +1,67 @@ + + + + React rotate tilt control example @mappable-world/mappable-default-ui-theme + + + + + + + + + + + + + +
+ + diff --git a/example/rotate-tilt-controls/vanilla.html b/example/rotate-tilt-controls/vanilla.html new file mode 100644 index 0000000..e9046c7 --- /dev/null +++ b/example/rotate-tilt-controls/vanilla.html @@ -0,0 +1,45 @@ + + + + Vanilla rotate tilt control example @mappable-world/mappable-default-ui-theme + + + + + + + + + +
+ + diff --git a/example/rotate-tilt-controls/vue.html b/example/rotate-tilt-controls/vue.html new file mode 100644 index 0000000..1d84105 --- /dev/null +++ b/example/rotate-tilt-controls/vue.html @@ -0,0 +1,61 @@ + + + + Vue rotate tilt control example @mappable-world/mappable-default-ui-theme + + + + + + + + + + + + +
+ + diff --git a/package-lock.json b/package-lock.json index 5f758a8..6cf72e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2", "devDependencies": { "@mappable-world/mappable-cli": "^0.0.32", - "@mappable-world/mappable-types": "^0.0.15", + "@mappable-world/mappable-types": "^0.0.16", "@types/got": "9.6.12", "@types/jest": "29.5.3", "@types/jsdom": "21.1.1", @@ -31,6 +31,7 @@ "jsdom": "22.1.0", "prettier": "3.0.0", "style-loader": "3.3.3", + "svgo": "^3.2.0", "terser-webpack-plugin": "5.3.9", "ts-jest": "29.1.1", "ts-loader": "9.4.4", @@ -1275,9 +1276,9 @@ } }, "node_modules/@mappable-world/mappable-types": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@mappable-world/mappable-types/-/mappable-types-0.0.15.tgz", - "integrity": "sha512-mcFDPGUdmzABEpnsnTmgSoU0YCLgAyqf4l/TvgZk1yXGOh14ESpwA9iHfzrRECl8eIK3i0jsuXsxY2+idkIepA==", + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@mappable-world/mappable-types/-/mappable-types-0.0.16.tgz", + "integrity": "sha512-jvj5BSLWHf8cMZ3Lw+oPN5zveT3ksZBtcYFU+4bsKVfsh3xYtg8k6BQO5sagc4dwW8Ar2YtOYYen2pi420gjtw==", "dev": true, "peerDependencies": { "@types/react": "16-18", @@ -1388,6 +1389,15 @@ "node": ">= 10" } }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3311,6 +3321,19 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -3335,6 +3358,39 @@ "node": ">=4" } }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true + }, "node_modules/cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -6269,6 +6325,12 @@ "node": ">= 16" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -8119,6 +8181,99 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svgo": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz", + "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==", + "dev": true, + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/svgo/node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -10134,9 +10289,9 @@ } }, "@mappable-world/mappable-types": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@mappable-world/mappable-types/-/mappable-types-0.0.15.tgz", - "integrity": "sha512-mcFDPGUdmzABEpnsnTmgSoU0YCLgAyqf4l/TvgZk1yXGOh14ESpwA9iHfzrRECl8eIK3i0jsuXsxY2+idkIepA==", + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@mappable-world/mappable-types/-/mappable-types-0.0.16.tgz", + "integrity": "sha512-jvj5BSLWHf8cMZ3Lw+oPN5zveT3ksZBtcYFU+4bsKVfsh3xYtg8k6BQO5sagc4dwW8Ar2YtOYYen2pi420gjtw==", "dev": true }, "@nodelib/fs.scandir": { @@ -10210,6 +10365,12 @@ "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "dev": true }, + "@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -11761,6 +11922,16 @@ "nth-check": "^2.0.1" } }, + "css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "requires": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + } + }, "css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -11773,6 +11944,33 @@ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true }, + "csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "requires": { + "css-tree": "~2.2.0" + }, + "dependencies": { + "css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "requires": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + } + }, + "mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true + } + } + }, "cssstyle": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", @@ -13957,6 +14155,12 @@ "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", "dev": true }, + "mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -15320,6 +15524,73 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "svgo": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz", + "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==", + "dev": true, + "requires": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + } + } + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 05d4bb9..7175062 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@mappable-world/mappable-cli": "^0.0.32", - "@mappable-world/mappable-types": "^0.0.15", + "@mappable-world/mappable-types": "^0.0.16", "@types/got": "9.6.12", "@types/jest": "29.5.3", "@types/jsdom": "21.1.1", @@ -40,6 +40,7 @@ "jsdom": "22.1.0", "prettier": "3.0.0", "style-loader": "3.3.3", + "svgo": "^3.2.0", "terser-webpack-plugin": "5.3.9", "ts-jest": "29.1.1", "ts-loader": "9.4.4", diff --git a/src/MMapButtonExample/MMapButtonExample.test.ts b/src/MMapButtonExample/MMapButtonExample.test.ts deleted file mode 100644 index 9da65d6..0000000 --- a/src/MMapButtonExample/MMapButtonExample.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type {LngLatBounds} from '@mappable-world/mappable-types/common/types'; -import {MMap} from '@mappable-world/mappable-types'; -import {MMapButtonExample} from './MMapButtonExample'; - -describe('MMap smoke test', () => { - const BOUNDS: LngLatBounds = [ - [54.58311, 25.9985], - [56.30248, 24.47889] - ]; - - const LOCATION = {bounds: BOUNDS}; - - let container: HTMLElement, map: MMap; - beforeEach(() => { - container = document.createElement('div'); - Object.assign(container.style, {width: `643px`, height: `856px`}); - document.body.appendChild(container); - map = new mappable.MMap(container, {location: LOCATION}, [new mappable.MMapDefaultSchemeLayer({})]); - }); - - afterEach(() => { - map.destroy(); - container.remove(); - }); - - it('should make map', () => { - map.addChild( - new mappable.MMapControls({position: 'bottom'}, [ - new MMapButtonExample({ - text: 'Some text', - className: 'user-class', - onClick: () => { - console.log('Click!'); - } - }) - ]) - ); - - const tree = domToJson(map.container); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/src/MMapButtonExample/MMapButtonExample.ts b/src/MMapButtonExample/MMapButtonExample.ts deleted file mode 100644 index f5ca132..0000000 --- a/src/MMapButtonExample/MMapButtonExample.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {MMapControlButton, MMapControlButtonProps} from '@mappable-world/mappable-types'; - -interface MMapButtonExampleProps extends MMapControlButtonProps { - className?: string; -} - -export class MMapButtonExample extends mappable.MMapComplexEntity { - private _button?: MMapControlButton; - private _element: HTMLElement = document.createElement('div'); - - constructor(props: MMapButtonExampleProps) { - super(props); - this._element.classList.add(this._props.className); - } - - protected _onAttach() { - this._button = new mappable.MMapControlButton({...this._props, element: this._element}); - this.addChild(this._button); - } - - protected _onUpdate(props: Partial, oldProps: MMapButtonExampleProps) { - this._button.update(props); - - if (props.className !== undefined) { - this._element.classList.remove(oldProps.className); - this._element.classList.add(props.className); - } - } - - protected _onDetach() { - this.removeChild(this._button); - } -} diff --git a/src/MMapButtonExample/__snapshots__/MMapButtonExample.test.ts.snap b/src/MMapButtonExample/__snapshots__/MMapButtonExample.test.ts.snap deleted file mode 100644 index ca62012..0000000 --- a/src/MMapButtonExample/__snapshots__/MMapButtonExample.test.ts.snap +++ /dev/null @@ -1,139 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MMap smoke test should make map 1`] = ` -{ - "attributes": { - "class": "mappable--map-container", - }, - "children": [ - { - "attributes": { - "class": "mappable--map", - "style": "width: 643px; height: 856px;", - }, - "children": [ - { - "attributes": { - "class": "mappable--main-engine-container", - }, - "children": [ - { - "attributes": {}, - "children": [ - { - "attributes": { - "class": "mappable--layer mappable--tile-layer", - "style": "perspective: 1597.3177456394797px; opacity: 1;", - }, - "children": [ - { - "attributes": { - "class": "mappable--tile-layer__container", - "data-z": "9", - "style": "transform: - rotateX(0.00000rad) - translate3d(0px, 0px, 0px) - rotateZ(0.00000rad) translate3d(0px, 0px, 0px) - ; z-index: 9;", - }, - "nodeName": "mappable", - }, - ], - "nodeName": "mappable", - }, - ], - "nodeName": "mappable", - }, - ], - "nodeName": "mappable", - }, - { - "attributes": { - "class": "mappable--top-engine-container", - }, - "children": [ - { - "attributes": {}, - "nodeName": "mappable", - }, - ], - "nodeName": "mappable", - }, - ], - "nodeName": "mappable", - }, - { - "attributes": { - "class": "mappable--controls mappable--controls_bottom mappable--controls_center mappable--controls_horizontal", - }, - "children": [ - { - "attributes": { - "class": "mappable--control mappable--control__background", - }, - "children": [ - { - "attributes": { - "class": "mappable--button mappable--control-button", - }, - "children": [ - "Some text", - ], - "nodeName": "button", - }, - ], - "nodeName": "mappable", - }, - ], - "nodeName": "mappable", - }, - { - "attributes": { - "class": "mappable--map-copyrights mappable--map-copyrights_bottom mappable--map-copyrights_right", - }, - "children": [ - { - "attributes": { - "class": "mappable--map-copyrights__container", - }, - "children": [ - { - "attributes": { - "class": "mappable--map-copyrights__text", - }, - "nodeName": "span", - }, - { - "attributes": { - "class": "mappable--map-copyrights__user-agreements", - "href": "https://mappable.world/legal/tos", - "target": "_blank", - }, - "children": [ - "User Agreement", - ], - "nodeName": "a", - }, - ], - "nodeName": "div", - }, - { - "attributes": { - "class": "mappable--icon-logo-en-black mappable--map-copyrights__logo", - "style": "width: 80px; height: 20px;", - }, - "nodeName": "span", - }, - { - "attributes": { - "class": "mappable--map-copyrights__scale", - }, - "nodeName": "div", - }, - ], - "nodeName": "div", - }, - ], - "nodeName": "mappable", -} -`; diff --git a/src/controls/MMapRotateControl/index.css b/src/controls/MMapRotateControl/index.css new file mode 100644 index 0000000..f53cade --- /dev/null +++ b/src/controls/MMapRotateControl/index.css @@ -0,0 +1,33 @@ +.mappable--rotate-control { + position: relative; + + display: flex; + justify-content: center; + align-items: center; + + box-sizing: border-box; + width: 56px; + height: 56px; + + font-size: 19px; + cursor: pointer; + user-select: none; + + color: #34374a; + border-radius: 50%; + background-color: #fff; + background-image: url('../assets/mappable-compass.svg?inline'); + background-repeat: no-repeat; + background-position: center; + background-size: calc(56px - 4px * 2); /* 4px as border width */ + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2); +} + +.mappable--rotate-control:hover { + color: #050d33; + background-image: url('../assets/mappable-compass_hover.svg?inline'); + + transition: + color, + background-image 0.4s; +} diff --git a/src/controls/MMapRotateControl/index.ts b/src/controls/MMapRotateControl/index.ts new file mode 100644 index 0000000..5c83b70 --- /dev/null +++ b/src/controls/MMapRotateControl/index.ts @@ -0,0 +1,154 @@ +import type {EasingFunctionDescription, MMapControl, MMapListener} from '@mappable-world/mappable-types'; +import {MMapCameraRequest} from '@mappable-world/mappable-types/imperative/MMap'; +import {Position, getDeltaAzimuth, toggleRotate} from '../utils/angle-utils'; +import {MMapRotateControlVuefyOptions} from './vue'; + +import './index.css'; + +/** + * MMapRotateControl props + */ +export type MMapRotateControlProps = { + /** Easing function for map location animation */ + easing?: EasingFunctionDescription; + /** Map location animate duration */ + duration?: number; +}; +const defaultProps = Object.freeze({duration: 200}); +type DefaultProps = typeof defaultProps; + +/** + * Display rotate control on a map. + * + * @example + * ```javascript + * const controls = new MMapControls({position: 'right'}); + * const {MMapRotateControl} = await mappable.import('@mappable-world/mappable-controls@0.0.1'); + * const rotateControl = new MMapRotateControl({}); + * controls.addChild(rotateControl); + * map.addChild(controls); + * ``` + */ +export class MMapRotateControl extends mappable.MMapComplexEntity { + static defaultProps = defaultProps; + static [mappable.optionsKeyVuefy] = MMapRotateControlVuefyOptions; + private _control!: MMapControl; + private _rotateControl!: InternalRotateControl; + + protected __implGetDefaultProps() { + return MMapRotateControl.defaultProps; + } + + constructor(props: MMapRotateControlProps) { + super(props); + } + + protected _onAttach(): void { + this._control = new mappable.MMapControl({transparent: true}); + this._rotateControl = new InternalRotateControl(this._props); + + this._control.addChild(this._rotateControl); + this.addChild(this._control); + } + + protected _onUpdate(): void { + this._rotateControl.update(this._props); + } +} + +const ROTATE_CONTROL_CLASS = 'mappable--rotate-control'; + +export class InternalRotateControl extends mappable.MMapComplexEntity { + private _listener!: MMapListener; + + private _element?: HTMLElement; + private _domDetach: () => void; + private _isClick = false; + private _controlCenterPosition?: Position; + private _startMovePosition?: Position; + private _startAzimuth?: number; + + protected _onAttach(): void { + this._listener = new mappable.MMapListener({ + onUpdate: (event) => this._onMapUpdate(event.camera) + }); + this.addChild(this._listener); + + this._element = document.createElement('mappable'); + this._element.textContent = 'N'; + this._element.classList.add(ROTATE_CONTROL_CLASS); + this._element.addEventListener('click', this._toggleMapRotate); + this._element.addEventListener('mousedown', this._onRotateStart); + + this._domDetach = mappable.useDomContext(this, this._element, null); + } + + protected _onDetach(): void { + this._element?.removeEventListener('click', this._toggleMapRotate); + this._element?.removeEventListener('mousedown', this._onRotateStart); + this._domDetach?.(); + this._domDetach = undefined; + } + + private _onMapUpdate({azimuth}: MMapCameraRequest): void { + if (!this._element) { + return; + } + this._element.style.transform = `rotateZ(${azimuth}rad)`; + } + + private _toggleMapRotate = (): void => { + if (!this.root || !this._isClick) { + return; + } + const {duration, easing} = this._props; + let targetAzimuth = toggleRotate(this.root.azimuth); + this.root.setCamera({azimuth: targetAzimuth, duration, easing}); + }; + + private _onRotateStart = (event: MouseEvent) => { + const isLeftClick = event.button === 0; + if (!isLeftClick || !this._element) { + return; + } + this._isClick = true; + + const {x, y, height, width} = this._element.getBoundingClientRect(); + + this._controlCenterPosition = { + x: x + width / 2, + y: y + height / 2 + }; + this._startMovePosition = { + x: event.clientX, + y: event.clientY + }; + this._startAzimuth = this.root?.azimuth; + this._addRotateEventListeners(); + }; + + private _onRotateMove = (event: MouseEvent) => { + if (!this._controlCenterPosition || !this._startMovePosition || this._startAzimuth === undefined) { + return; + } + const deltaAzimuth = getDeltaAzimuth(this._startMovePosition, this._controlCenterPosition, { + x: event.pageX, + y: event.pageY + }); + this._isClick = false; + this.root?.setCamera({azimuth: this._startAzimuth + deltaAzimuth}); + }; + + private _onRotateEnd = () => { + this._removeRotateEventListeners(); + }; + + private _addRotateEventListeners = (): void => { + window.addEventListener('mousemove', this._onRotateMove); + window.addEventListener('mouseup', this._onRotateEnd); + }; + private _removeRotateEventListeners = (): void => { + window.removeEventListener('mousemove', this._onRotateMove); + window.removeEventListener('mouseup', this._onRotateEnd); + }; +} diff --git a/src/controls/MMapRotateControl/vue/index.ts b/src/controls/MMapRotateControl/vue/index.ts new file mode 100644 index 0000000..0827ee3 --- /dev/null +++ b/src/controls/MMapRotateControl/vue/index.ts @@ -0,0 +1,11 @@ +import {EasingFunctionDescription} from '@mappable-world/mappable-types'; +import type {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy'; +import type TVue from '@vue/runtime-core'; +import {MMapRotateControl} from '..'; + +export const MMapRotateControlVuefyOptions: CustomVuefyOptions = { + props: { + easing: [Function, String, Object] as TVue.PropType, + duration: Number + } +}; diff --git a/src/controls/MMapRotateTiltControl/MMapRotateControl.css b/src/controls/MMapRotateTiltControl/MMapRotateControl.css new file mode 100644 index 0000000..a9fbe90 --- /dev/null +++ b/src/controls/MMapRotateTiltControl/MMapRotateControl.css @@ -0,0 +1,53 @@ +.mappable--rotate-tilt_rotate { + position: relative; + + display: flex; + justify-content: center; + align-items: center; + + width: 56px; + height: 56px; + + user-select: none; +} + +.mappable--rotate-tilt_rotate__ring { + position: absolute; + + box-sizing: border-box; + width: 100%; + height: 100%; + + cursor: pointer; + pointer-events: all; + + border: 10px solid #fff; + border-radius: 50%; + box-shadow: + inset 0 2px 6px 0 rgba(0, 0, 0, 0.2), + 0 2px 6px 0 rgba(0, 0, 0, 0.2); +} + +.mappable--rotate-tilt_rotate__ring::before { + position: absolute; + + width: 56px; + height: 56px; + + content: ''; + + border-radius: 50%; + background: url('../assets/mappable-compass.svg?inline') center no-repeat; + + transform: translate(-10px, -10px); /* 10px as border width */ +} + +.mappable--rotate-tilt_rotate__ring:hover::before { + background: url('../assets/mappable-compass_hover.svg?inline') center no-repeat; + + transition: background 0.4s; +} + +.mappable--rotate-tilt_rotate__container { + z-index: 1; +} diff --git a/src/controls/MMapRotateTiltControl/MMapRotateControl.ts b/src/controls/MMapRotateTiltControl/MMapRotateControl.ts new file mode 100644 index 0000000..0c45166 --- /dev/null +++ b/src/controls/MMapRotateTiltControl/MMapRotateControl.ts @@ -0,0 +1,120 @@ +import type {MMapListener} from '@mappable-world/mappable-types'; +import {MMapCameraRequest} from '@mappable-world/mappable-types/imperative/MMap'; +import type {MMapRotateTiltControlProps} from '.'; +import {Position, getDeltaAzimuth, toggleRotate} from '../utils/angle-utils'; +import './MMapRotateControl.css'; + +const ROTATE_CONTROL_CLASS = 'mappable--rotate-tilt_rotate'; +const ROTATE_RING_CLASS = 'mappable--rotate-tilt_rotate__ring'; +const ROTATE_CONTAINER_CLASS = 'mappable--rotate-tilt_rotate__container'; + +export class MMapRotateControl extends mappable.MMapGroupEntity { + private _element?: HTMLElement; + private _containerElement?: HTMLElement; + private _ringElement?: HTMLElement; + private _domDetach?: () => void; + + private _listener!: MMapListener; + private _isClick: boolean = false; + private _controlCenterPosition?: Position; + private _startMovePosition?: Position; + private _startAzimuth?: number; + + constructor(props: MMapRotateTiltControlProps) { + super(props); + } + + protected _onAttach(): void { + this._listener = new mappable.MMapListener({ + onUpdate: (event) => this._onMapUpdate(event.camera) + }); + this.addChild(this._listener); + + this._element = document.createElement('mappable'); + this._element.classList.add(ROTATE_CONTROL_CLASS); + + this._containerElement = document.createElement('mappable'); + this._containerElement.classList.add(ROTATE_CONTAINER_CLASS); + + this._ringElement = document.createElement('mappable'); + this._ringElement.classList.add(ROTATE_RING_CLASS); + this._ringElement.addEventListener('click', this._toggleMapRotate); + this._ringElement.addEventListener('mousedown', this._onRotateStart); + + this._element.appendChild(this._ringElement); + this._element.appendChild(this._containerElement); + + this._domDetach = mappable.useDomContext(this, this._element, this._containerElement); + } + + protected _onDetach(): void { + this._ringElement?.removeEventListener('click', this._toggleMapRotate); + this._ringElement?.removeEventListener('mousedown', this._onRotateStart); + this._domDetach?.(); + this._domDetach = undefined; + this._element = undefined; + } + + private _toggleMapRotate = (): void => { + if (!this.root || !this._isClick) { + return; + } + const {duration, easing} = this._props; + let targetAzimuth = toggleRotate(this.root.azimuth); + this.root.setCamera({azimuth: targetAzimuth, duration, easing}); + }; + + private _onRotateStart = (event: MouseEvent) => { + const isLeftClick = event.button === 0; + if (!isLeftClick || !this._element) { + return; + } + this._isClick = true; + + const {x, y, height, width} = this._element.getBoundingClientRect(); + + this._controlCenterPosition = { + x: x + width / 2, + y: y + height / 2 + }; + this._startMovePosition = { + x: event.clientX, + y: event.clientY + }; + this._startAzimuth = this.root?.azimuth; + this._addRotateEventListeners(); + }; + + private _onRotateMove = (event: MouseEvent) => { + if (!this._controlCenterPosition || !this._startMovePosition || this._startAzimuth === undefined) { + return; + } + const deltaAzimuth = getDeltaAzimuth(this._startMovePosition, this._controlCenterPosition, { + x: event.pageX, + y: event.pageY + }); + this._isClick = false; + this.root?.setCamera({azimuth: this._startAzimuth + deltaAzimuth}); + }; + + private _onRotateEnd = () => { + this._removeRotateEventListeners(); + }; + + private _addRotateEventListeners = (): void => { + window.addEventListener('mousemove', this._onRotateMove); + window.addEventListener('mouseup', this._onRotateEnd); + }; + + private _removeRotateEventListeners = (): void => { + window.removeEventListener('mousemove', this._onRotateMove); + window.removeEventListener('mouseup', this._onRotateEnd); + }; + + private _onMapUpdate({azimuth}: MMapCameraRequest): void { + if (this._ringElement === undefined) { + return; + } + this._ringElement.style.transform = `rotateZ(${azimuth}rad)`; + } +} diff --git a/src/controls/MMapRotateTiltControl/MMapTiltControl.css b/src/controls/MMapRotateTiltControl/MMapTiltControl.css new file mode 100644 index 0000000..b2201b5 --- /dev/null +++ b/src/controls/MMapRotateTiltControl/MMapTiltControl.css @@ -0,0 +1,36 @@ +.mappable--rotate-tilt_tilt { + display: flex; + justify-content: center; + align-items: center; + + width: 32px; + height: 32px; + + font-size: 16px; + cursor: pointer; + user-select: none; + pointer-events: all; + + color: #4d4d4d; + border-radius: 50%; + background-color: #fff; +} + +.mappable--rotate-tilt_tilt:hover { + color: #050d33; +} + +.mappable--rotate-tilt_tilt__tilted { + color: #fff; + background-color: #196dff; + + transition: background-color 0.4s; +} + +.mappable--rotate-tilt_tilt__tilted:hover { + color: #fff; +} + +.mappable--rotate-tilt_tilt__in-action { + cursor: grabbing; +} diff --git a/src/controls/MMapRotateTiltControl/MMapTiltControl.ts b/src/controls/MMapRotateTiltControl/MMapTiltControl.ts new file mode 100644 index 0000000..f2c482d --- /dev/null +++ b/src/controls/MMapRotateTiltControl/MMapTiltControl.ts @@ -0,0 +1,116 @@ +import type {MMapListener} from '@mappable-world/mappable-types'; +import {MMapCameraRequest} from '@mappable-world/mappable-types/imperative/MMap'; +import type {MMapRotateTiltControlProps} from '.'; +import {CLICK_TOLERANCE_PX, Position, degToRad, radToDeg, toggleTilt} from '../utils/angle-utils'; +import './MMapTiltControl.css'; + +const TILT_CONTROL_CLASS = 'mappable--rotate-tilt_tilt'; +const TILT_CONTROL_IN_ACTION_CLASS = 'mappable--rotate-tilt_tilt__in-action'; +const TILT_CONTROL_TILTED_CLASS = 'mappable--rotate-tilt_tilt__tilted'; + +export class MMapTiltControl extends mappable.MMapComplexEntity { + private _element?: HTMLElement; + private _domDetach?: () => void; + + private _listener!: MMapListener; + private _startTilt?: number; + private _startMovePosition?: Position; + private _isClick: boolean = false; + + constructor(props: MMapRotateTiltControlProps) { + super(props); + } + + protected _onAttach(): void { + this._listener = new mappable.MMapListener({ + onUpdate: (event) => this._onMapUpdate(event.camera) + }); + this.addChild(this._listener); + + this._element = document.createElement('mappable'); + this._element.classList.add(TILT_CONTROL_CLASS); + const {tilt, tiltRange} = this.root; + this._element.textContent = tilt === tiltRange.min ? '3D' : '2D'; + this._element.addEventListener('click', this._toggleMapTilt); + this._element.addEventListener('mousedown', this._onTiltStart); + + this._domDetach = mappable.useDomContext(this, this._element, null); + } + + protected _onDetach(): void { + this._element?.removeEventListener('click', this._toggleMapTilt); + this._element?.removeEventListener('mousedown', this._onTiltStart); + this._domDetach?.(); + this._domDetach = undefined; + this._element = undefined; + } + + private _toggleMapTilt = (): void => { + if (!this.root || !this._isClick) { + return; + } + const {duration, easing} = this._props; + const { + tilt, + tiltRange: {max, min} + } = this.root; + const targetTiltDeg = toggleTilt(radToDeg(tilt), min, max); + this.root.setCamera({tilt: degToRad(targetTiltDeg), duration, easing}); + }; + + private _onTiltStart = (event: MouseEvent) => { + const isLeftClick = event.button === 0; + if (!isLeftClick) { + return; + } + this._isClick = true; + this._startTilt = this.root?.tilt; + this._startMovePosition = { + x: event.clientX, + y: event.clientY + }; + this._element?.classList.toggle(TILT_CONTROL_IN_ACTION_CLASS, true); + this._addTiltEventListeners(); + }; + + private _onTiltMove = (event: MouseEvent) => { + if (!this._startMovePosition || this._startTilt === undefined || !this.root) { + return; + } + const delta = this._startMovePosition.y - event.clientY; + + if (Math.abs(delta) < CLICK_TOLERANCE_PX) { + return; + } + const deltaTilt = (Math.PI * delta) / this.root.size.y; + this._isClick = false; + this.root.setCamera({tilt: this._startTilt + deltaTilt}); + }; + + private _onTiltEnd = () => { + this._element?.classList.toggle(TILT_CONTROL_IN_ACTION_CLASS, false); + this._removeTiltEventListeners(); + }; + + private _onMapUpdate({tilt: radTilt}: MMapCameraRequest): void { + if (this._element === undefined) { + return; + } + const degTilt = radToDeg(radTilt ?? 0); + const isMinTilt = Math.round(degTilt) === this.root.tiltRange.min; + + this._element.style.transform = `rotateX(${degTilt}deg)`; + this._element.textContent = isMinTilt ? '3D' : '2D'; + this._element.classList.toggle(TILT_CONTROL_TILTED_CLASS, !isMinTilt); + } + + private _addTiltEventListeners(): void { + window.addEventListener('mousemove', this._onTiltMove); + window.addEventListener('mouseup', this._onTiltEnd); + } + + private _removeTiltEventListeners(): void { + window.removeEventListener('mousemove', this._onTiltMove); + window.removeEventListener('mouseup', this._onTiltEnd); + } +} diff --git a/src/controls/MMapRotateTiltControl/index.ts b/src/controls/MMapRotateTiltControl/index.ts new file mode 100644 index 0000000..4d98dee --- /dev/null +++ b/src/controls/MMapRotateTiltControl/index.ts @@ -0,0 +1,59 @@ +import type {EasingFunctionDescription, MMapControl} from '@mappable-world/mappable-types'; +import {MMapRotateControl} from './MMapRotateControl'; +import {MMapTiltControl} from './MMapTiltControl'; +import {MMapRotateTiltControlVuefyOptions} from './vue'; + +/** + * MMapRotateTiltControl props + */ +export type MMapRotateTiltControlProps = { + /** Easing function for map location animation */ + easing?: EasingFunctionDescription; + /** Map location animate duration */ + duration?: number; +}; +const defaultProps = Object.freeze({duration: 200}); +type DefaultProps = typeof defaultProps; + +/** + * Display tilt and rotation controls on a map. + * + * @example + * ```javascript + * const controls = new MMapControls({position: 'right'}); + * const {MMapRotateTiltControl} = await mappable.import('@mappable-world/mappable-controls@0.0.1'); + * const rotateTiltControl = new MMapRotateTiltControl({}); + * controls.addChild(rotateTiltControl); + * map.addChild(controls); + * ``` + */ +export class MMapRotateTiltControl extends mappable.MMapComplexEntity { + static defaultProps = defaultProps; + static [mappable.optionsKeyVuefy] = MMapRotateTiltControlVuefyOptions; + + private _rotateControl!: MMapRotateControl; + private _tiltControl!: MMapTiltControl; + private _control!: MMapControl; + + protected __implGetDefaultProps() { + return MMapRotateTiltControl.defaultProps; + } + + constructor(props: MMapRotateTiltControlProps) { + super(props); + } + + protected _onAttach(): void { + this._control = new mappable.MMapControl({transparent: true}); + this._rotateControl = new MMapRotateControl(this._props); + this._tiltControl = new MMapTiltControl(this._props); + + this._rotateControl.addChild(this._tiltControl); + this._control.addChild(this._rotateControl); + this.addChild(this._control); + } + protected _onUpdate(): void { + this._rotateControl.update(this._props); + this._tiltControl.update(this._props); + } +} diff --git a/src/controls/MMapRotateTiltControl/vue/index.ts b/src/controls/MMapRotateTiltControl/vue/index.ts new file mode 100644 index 0000000..7f298df --- /dev/null +++ b/src/controls/MMapRotateTiltControl/vue/index.ts @@ -0,0 +1,11 @@ +import {EasingFunctionDescription} from '@mappable-world/mappable-types'; +import type {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy'; +import type TVue from '@vue/runtime-core'; +import {MMapRotateTiltControl} from '..'; + +export const MMapRotateTiltControlVuefyOptions: CustomVuefyOptions = { + props: { + easing: [Function, String, Object] as TVue.PropType, + duration: Number + } +}; diff --git a/src/controls/MMapTiltControl/index.css b/src/controls/MMapTiltControl/index.css new file mode 100644 index 0000000..8b7b119 --- /dev/null +++ b/src/controls/MMapTiltControl/index.css @@ -0,0 +1,72 @@ +.mappable--tilt { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + width: 48px; + height: 48px; + + font-size: 19px; + cursor: pointer; + user-select: none; + + color: #34374a; + border-radius: 12px; + background-color: #fff; + box-shadow: 0 2px 6px 0 rgb(0 0 0 / 20%); +} + +.mappable--tilt:hover { + color: #050d33; +} + +.mappable--tilt_active { + color: #2e4ce5; + + transition: color 0.4s; +} + +.mappable--tilt_active:hover { + color: #2e4ce5; +} + +.mappable--tilt_indicator_in { + width: 8px; + height: 7px; + + background: url('./tilt-indicator.svg?inline') center no-repeat; +} + +.mappable--tilt_indicator_out { + width: 8px; + height: 7px; + + background: url('./tilt-indicator.svg?inline') center no-repeat; + + transform: rotate(180deg); +} + +.mappable--tilt_indicator__active { + background: url('./tilt-indicator_active.svg?inline') center no-repeat; + + transition: background 0.4s; +} + +.mappable--tilt_label { + display: flex; + justify-content: center; + align-items: center; + + width: 24px; + height: 24px; + padding: 2px 0; +} + +.mappable--tilt-control__in-action { + cursor: grabbing; +} + +.hide-indicator { + display: none; +} diff --git a/src/controls/MMapTiltControl/index.ts b/src/controls/MMapTiltControl/index.ts new file mode 100644 index 0000000..d580275 --- /dev/null +++ b/src/controls/MMapTiltControl/index.ts @@ -0,0 +1,197 @@ +import type {EasingFunctionDescription, MMapControl, MMapListener} from '@mappable-world/mappable-types'; +import {MMapCameraRequest} from '@mappable-world/mappable-types/imperative/MMap'; +import {CLICK_TOLERANCE_PX, Position, degToRad, radToDeg, toggleTilt} from '../utils/angle-utils'; +import {MMapTiltControlVuefyOptions} from './vue'; + +import './index.css'; + +/** + * MMapTiltControl props + */ +export type MMapTiltControlProps = { + /** Easing function for map location animation */ + easing?: EasingFunctionDescription; + /** Map location animate duration */ + duration?: number; +}; +const defaultProps = Object.freeze({duration: 200}); +type DefaultProps = typeof defaultProps; + +/** + * Display tilt control on a map. + * + * @example + * ```javascript + * const controls = new MMapControls({position: 'right'}); + * const {MMapTiltControl} = await mappable.import('@mappable-world/mappable-controls@0.0.1'); + * const tiltControl = new MMapTiltControl({}); + * controls.addChild(tiltControl); + * map.addChild(controls); + * ``` + */ +export class MMapTiltControl extends mappable.MMapComplexEntity { + static defaultProps = defaultProps; + static [mappable.optionsKeyVuefy] = MMapTiltControlVuefyOptions; + private _control!: MMapControl; + private _tiltControl!: InternalTiltControl; + + constructor(props: MMapTiltControlProps) { + super(props); + } + + protected __implGetDefaultProps() { + return MMapTiltControl.defaultProps; + } + + protected _onAttach(): void { + this._control = new mappable.MMapControl({transparent: true}); + this._tiltControl = new InternalTiltControl(this._props); + + this._control.addChild(this._tiltControl); + this.addChild(this._control); + } + + protected _onUpdate(): void { + this._tiltControl.update(this._props); + } +} + +const TILT_CONTROL_CLASS = 'mappable--tilt'; +const TILT_CONTROL_ACTIVE_CLASS = 'mappable--tilt_active'; +const TILT_LABEL_CLASS = 'mappable--tilt_label'; +const TILT_CONTROL_IN_ACTION_CLASS = 'mappable--tilt-control__in-action'; +const TILT_INDICATOR_IN_CLASS = 'mappable--tilt_indicator_in'; +const TILT_INDICATOR_OUT_CLASS = 'mappable--tilt_indicator_out'; +const TILT_INDICATOR_ACTIVE_CLASS = 'mappable--tilt_indicator__active'; +const HIDE_INDICATOR_CLASS = 'hide-indicator'; + +class InternalTiltControl extends mappable.MMapComplexEntity { + private _listener!: MMapListener; + + private _element?: HTMLElement; + private _label?: HTMLElement; + private _tiltIn?: HTMLElement; + private _tiltOut?: HTMLElement; + private _domDetach?: () => void; + + private _startTilt?: number; + private _startMovePosition?: Position; + private _isClick: boolean = false; + private _prevTilt?: number; + + protected _onAttach(): void { + this._listener = new mappable.MMapListener({ + onUpdate: (event) => this._onMapUpdate(event.camera) + }); + this.addChild(this._listener); + + this._element = document.createElement('mappable'); + this._label = document.createElement('mappable'); + this._tiltIn = document.createElement('mappable'); + this._tiltOut = document.createElement('mappable'); + + this._element.classList.add(TILT_CONTROL_CLASS); + this._label.classList.add(TILT_LABEL_CLASS); + const {tilt, tiltRange} = this.root; + this._label.textContent = tilt === tiltRange.min ? '3D' : '2D'; + this._tiltIn.classList.add(TILT_INDICATOR_IN_CLASS, HIDE_INDICATOR_CLASS); + this._tiltOut.classList.add(TILT_INDICATOR_OUT_CLASS, HIDE_INDICATOR_CLASS); + + this._element.appendChild(this._tiltIn); + this._element.appendChild(this._label); + this._element.appendChild(this._tiltOut); + + this._element.addEventListener('click', this._toggleMapTilt); + this._element.addEventListener('mousedown', this._onTiltStart); + + this._domDetach = mappable.useDomContext(this, this._element, null); + } + + protected _onDetach(): void { + this._domDetach?.(); + this._domDetach = undefined; + this._element?.removeEventListener('click', this._toggleMapTilt); + this._element?.removeEventListener('mousedown', this._onTiltStart); + } + + private _toggleMapTilt = (): void => { + if (!this.root || !this._isClick) { + return; + } + const {duration, easing} = this._props; + const { + tilt, + tiltRange: {min, max} + } = this.root; + const targetTiltDeg = toggleTilt(radToDeg(tilt), min, max); + this.root.setCamera({tilt: degToRad(targetTiltDeg), duration, easing}); + }; + + private _onTiltStart = (event: MouseEvent) => { + const isLeftClick = event.button === 0; + if (!isLeftClick) { + return; + } + this._isClick = true; + this._startTilt = this.root?.tilt; + this._prevTilt = this.root?.tilt; + this._startMovePosition = { + x: event.clientX, + y: event.clientY + }; + this._element?.classList.toggle(TILT_CONTROL_IN_ACTION_CLASS, true); + this._addTiltEventListeners(); + }; + + private _onTiltMove = (event: MouseEvent) => { + if (!this._startMovePosition || this._startTilt === undefined || this._prevTilt === undefined || !this.root) { + return; + } + const delta = this._startMovePosition.y - event.clientY; + + if (Math.abs(delta) < CLICK_TOLERANCE_PX) { + return; + } + const deltaTilt = (Math.PI * delta) / this.root.size.y; + const currentTilt = this._startTilt + deltaTilt; + this._isClick = false; + this._tiltIn?.classList.remove(HIDE_INDICATOR_CLASS); + this._tiltOut?.classList.remove(HIDE_INDICATOR_CLASS); + + const tiltDiff = currentTilt - this._prevTilt; + if (tiltDiff !== 0) { + this._tiltOut?.classList.toggle(TILT_INDICATOR_ACTIVE_CLASS, tiltDiff < 0); + this._tiltIn?.classList.toggle(TILT_INDICATOR_ACTIVE_CLASS, tiltDiff > 0); + } + + this.root.setCamera({tilt: currentTilt}); + this._prevTilt = currentTilt; + }; + + private _onTiltEnd = () => { + this._tiltIn?.classList.add(HIDE_INDICATOR_CLASS); + this._tiltOut?.classList.add(HIDE_INDICATOR_CLASS); + this._element?.classList.toggle(TILT_CONTROL_IN_ACTION_CLASS, false); + this._removeTiltEventListeners(); + }; + + private _onMapUpdate({tilt: radTilt}: MMapCameraRequest): void { + if (!this._element || !this._label) { + return; + } + const degTilt = radToDeg(radTilt ?? 0); + const isMinTilt = Math.round(degTilt) === this.root.tiltRange.min; + this._label.textContent = isMinTilt ? '3D' : '2D'; + this._element.classList.toggle(TILT_CONTROL_ACTIVE_CLASS, !isMinTilt); + } + + private _addTiltEventListeners(): void { + window.addEventListener('mousemove', this._onTiltMove); + window.addEventListener('mouseup', this._onTiltEnd); + } + + private _removeTiltEventListeners(): void { + window.removeEventListener('mousemove', this._onTiltMove); + window.removeEventListener('mouseup', this._onTiltEnd); + } +} diff --git a/src/controls/MMapTiltControl/tilt-indicator.svg b/src/controls/MMapTiltControl/tilt-indicator.svg new file mode 100644 index 0000000..a9465bc --- /dev/null +++ b/src/controls/MMapTiltControl/tilt-indicator.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/controls/MMapTiltControl/tilt-indicator_active.svg b/src/controls/MMapTiltControl/tilt-indicator_active.svg new file mode 100644 index 0000000..bfd4e34 --- /dev/null +++ b/src/controls/MMapTiltControl/tilt-indicator_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/controls/MMapTiltControl/vue/index.ts b/src/controls/MMapTiltControl/vue/index.ts new file mode 100644 index 0000000..1f2095c --- /dev/null +++ b/src/controls/MMapTiltControl/vue/index.ts @@ -0,0 +1,11 @@ +import {EasingFunctionDescription} from '@mappable-world/mappable-types'; +import type {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy'; +import type TVue from '@vue/runtime-core'; +import {MMapTiltControl} from '..'; + +export const MMapTiltControlVuefyOptions: CustomVuefyOptions = { + props: { + easing: [Function, String, Object] as TVue.PropType, + duration: Number + } +}; diff --git a/src/controls/assets/mappable-compass.svg b/src/controls/assets/mappable-compass.svg new file mode 100644 index 0000000..fa73748 --- /dev/null +++ b/src/controls/assets/mappable-compass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/controls/assets/mappable-compass_hover.svg b/src/controls/assets/mappable-compass_hover.svg new file mode 100644 index 0000000..bb6ca9f --- /dev/null +++ b/src/controls/assets/mappable-compass_hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/controls/index.ts b/src/controls/index.ts new file mode 100644 index 0000000..8a4aae5 --- /dev/null +++ b/src/controls/index.ts @@ -0,0 +1,3 @@ +export {MMapRotateControl, MMapRotateControlProps} from './MMapRotateControl'; +export {MMapTiltControl, MMapTiltControlProps} from './MMapTiltControl'; +export {MMapRotateTiltControl, MMapRotateTiltControlProps} from './MMapRotateTiltControl'; diff --git a/src/controls/utils/angle-utils.test.ts b/src/controls/utils/angle-utils.test.ts new file mode 100644 index 0000000..5139fa9 --- /dev/null +++ b/src/controls/utils/angle-utils.test.ts @@ -0,0 +1,69 @@ +import {Position, degToRad, getDeltaAzimuth, radToDeg, toggleRotate, toggleTilt} from './angle-utils'; + +describe('angle-utils functions', () => { + describe('toggleTilt', () => { + const MAX_TILT = 45; + const MIN_TILT = 0; + + it('min tilt', () => { + expect(toggleTilt(MIN_TILT, MIN_TILT, MAX_TILT)).toBe(MAX_TILT); + }); + it('max tilt', () => { + expect(toggleTilt(MAX_TILT, MIN_TILT, MAX_TILT)).toBe(MIN_TILT); + }); + it('mid tilt', () => { + expect(toggleTilt(15, MIN_TILT, MAX_TILT)).toBe(MIN_TILT); + }); + }); + describe('toggleRotate', () => { + it('0 deg', () => { + expect(toggleRotate(0)).toBeCloseTo(-Math.PI / 4); + }); + it('> Pi', () => { + expect(toggleRotate(Math.PI * 1.5)).toBeCloseTo(Math.PI * 2); + }); + it('< Pi', () => { + expect(toggleRotate(Math.PI * 0.5)).toBeCloseTo(0); + }); + }); + describe('radToDeg', () => { + it('0 rad to deg', () => { + expect(radToDeg(0)).toBeCloseTo(0); + }); + it('Pi rad to deg', () => { + expect(radToDeg(Math.PI)).toBeCloseTo(180); + }); + it('2Pi rad to deg', () => { + expect(radToDeg(Math.PI * 2)).toBeCloseTo(360); + }); + it('Pi/2 rad to deg', () => { + expect(radToDeg(Math.PI / 2)).toBeCloseTo(90); + }); + }); + describe('degToRad', () => { + it('0 deg to rad', () => { + expect(degToRad(0)).toBeCloseTo(0); + }); + it('180 deg to rad', () => { + expect(degToRad(180)).toBeCloseTo(Math.PI); + }); + it('360 deg to rad', () => { + expect(degToRad(360)).toBeCloseTo(Math.PI * 2); + }); + it('90 deg to rad', () => { + expect(degToRad(90)).toBeCloseTo(Math.PI / 2); + }); + }); + describe('getDeltaAzimuth', () => { + const startPosition: Position = {x: 0, y: 1}; + const controlPosition: Position = {x: 0, y: 0}; + it('rotate to -90 deg', () => { + const eventPagePosition: Position = {x: -1, y: 0}; + expect(getDeltaAzimuth(startPosition, controlPosition, eventPagePosition)).toBeCloseTo(Math.PI / 2); + }); + it('rotate to 90 deg', () => { + const eventPagePosition: Position = {x: 1, y: 0}; + expect(getDeltaAzimuth(startPosition, controlPosition, eventPagePosition)).toBeCloseTo(-Math.PI / 2); + }); + }); +}); diff --git a/src/controls/utils/angle-utils.ts b/src/controls/utils/angle-utils.ts new file mode 100644 index 0000000..bcb0540 --- /dev/null +++ b/src/controls/utils/angle-utils.ts @@ -0,0 +1,52 @@ +export interface Position { + x: number; + y: number; +} + +export const DEG_TO_RAD = Math.PI / 180; +export const RAD_TO_DEG = 180 / Math.PI; +export const CLICK_TOLERANCE_PX = 3; + +/** + * Resets the tilt value to the min or max value + * @param tilt - current tilt in degree + * @param min - min tilt in degree + * @param max - max tilt in degree + * @returns reset tilt value + */ +export const toggleTilt = (tilt: number, min: number, max: number): number => { + return Math.round(tilt) === min ? max : min; +}; + +/** + * Resets the azimuth value + * @param azimuth - current tilt in rad + * @returns reset azimuth value in rad + */ +export const toggleRotate = (azimuth: number): number => { + if (azimuth === 0) { + return -Math.PI / 4; + } + return azimuth < Math.PI ? 0 : Math.PI * 2; +}; + +/** + * Calculates the azimuth change by mouse movement + * @param startPosition - position of the starting of the mouse movement + * @param controlPosition - position of the azimuth change control + * @param eventPagePosition - current mouse position + * @returns delta azimuth value + */ +export const getDeltaAzimuth = ( + startPosition: Position, + controlPosition: Position, + eventPagePosition: Position +): number => { + return ( + Math.atan2(eventPagePosition.y - controlPosition.y, eventPagePosition.x - controlPosition.x) - + Math.atan2(startPosition.y - controlPosition.y, startPosition.x - controlPosition.x) + ); +}; + +export const radToDeg = (rad: number): number => rad * RAD_TO_DEG; +export const degToRad = (deg: number): number => deg * DEG_TO_RAD; diff --git a/src/index.ts b/src/index.ts index 50bf320..995436d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export * from './MMapButtonExample/MMapButtonExample'; +export * from './controls';