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';