From 488f91d47816a6cf9ab6a8d34d6f6d815c36474c Mon Sep 17 00:00:00 2001
From: matthew44-mappable
<155086725+matthew44-mappable@users.noreply.github.com>
Date: Wed, 10 Apr 2024 17:11:44 +0300
Subject: [PATCH] add zoom and geolocation controls (#4)
Add new controls:
* Zoom control
* Geolocation control
---
example/case-template/vue/index.ts | 2 +-
example/common.css | 26 ---
example/geolocation-control/common.css | 0
example/geolocation-control/common.ts | 8 +
example/geolocation-control/react/index.html | 37 ++++
example/geolocation-control/react/index.tsx | 38 ++++
.../geolocation-control/vanilla/index.html | 35 +++
example/geolocation-control/vanilla/index.ts | 17 ++
example/geolocation-control/vue/index.html | 36 ++++
example/geolocation-control/vue/index.ts | 39 ++++
.../{common.js => common.ts} | 10 +-
example/rotate-tilt-controls/react.html | 67 ------
example/rotate-tilt-controls/react/index.html | 36 ++++
example/rotate-tilt-controls/react/index.tsx | 40 ++++
example/rotate-tilt-controls/vanilla.html | 45 ----
.../rotate-tilt-controls/vanilla/index.html | 34 +++
example/rotate-tilt-controls/vanilla/index.ts | 30 +++
example/rotate-tilt-controls/vue.html | 61 ------
example/rotate-tilt-controls/vue/index.html | 35 +++
example/rotate-tilt-controls/vue/index.ts | 40 ++++
example/zoom-control/common.css | 0
example/zoom-control/common.ts | 8 +
example/zoom-control/react/index.html | 37 ++++
example/zoom-control/react/index.tsx | 39 ++++
example/zoom-control/vanilla/index.html | 35 +++
example/zoom-control/vanilla/index.ts | 18 ++
example/zoom-control/vue/index.html | 36 ++++
example/zoom-control/vue/index.ts | 41 ++++
.../MMapControlSpinner/index.css | 55 +++++
.../MMapControlSpinner/index.ts | 44 ++++
.../geolocation-black.svg | 1 +
.../geolocation-white.svg | 1 +
src/controls/MMapGeolocationControl/index.css | 29 +++
src/controls/MMapGeolocationControl/index.ts | 175 +++++++++++++++
src/controls/MMapGeolocationControl/self.svg | 1 +
.../MMapZoomControl/MMapZoomControl.test.ts | 38 ++++
src/controls/MMapZoomControl/index.css | 41 ++++
src/controls/MMapZoomControl/index.ts | 203 ++++++++++++++++++
.../MMapZoomControl/zoom-in-black.svg | 1 +
.../MMapZoomControl/zoom-in-white.svg | 1 +
.../MMapZoomControl/zoom-out-black.svg | 1 +
.../MMapZoomControl/zoom-out-white.svg | 1 +
src/controls/index.ts | 4 +-
tsconfig.json | 1 +
44 files changed, 1241 insertions(+), 206 deletions(-)
delete mode 100644 example/common.css
create mode 100644 example/geolocation-control/common.css
create mode 100644 example/geolocation-control/common.ts
create mode 100644 example/geolocation-control/react/index.html
create mode 100644 example/geolocation-control/react/index.tsx
create mode 100644 example/geolocation-control/vanilla/index.html
create mode 100644 example/geolocation-control/vanilla/index.ts
create mode 100644 example/geolocation-control/vue/index.html
create mode 100644 example/geolocation-control/vue/index.ts
rename example/rotate-tilt-controls/{common.js => common.ts} (61%)
delete mode 100644 example/rotate-tilt-controls/react.html
create mode 100644 example/rotate-tilt-controls/react/index.html
create mode 100644 example/rotate-tilt-controls/react/index.tsx
delete mode 100644 example/rotate-tilt-controls/vanilla.html
create mode 100644 example/rotate-tilt-controls/vanilla/index.html
create mode 100644 example/rotate-tilt-controls/vanilla/index.ts
delete mode 100644 example/rotate-tilt-controls/vue.html
create mode 100644 example/rotate-tilt-controls/vue/index.html
create mode 100644 example/rotate-tilt-controls/vue/index.ts
create mode 100644 example/zoom-control/common.css
create mode 100644 example/zoom-control/common.ts
create mode 100644 example/zoom-control/react/index.html
create mode 100644 example/zoom-control/react/index.tsx
create mode 100644 example/zoom-control/vanilla/index.html
create mode 100644 example/zoom-control/vanilla/index.ts
create mode 100644 example/zoom-control/vue/index.html
create mode 100644 example/zoom-control/vue/index.ts
create mode 100644 src/controls/MMapGeolocationControl/MMapControlSpinner/index.css
create mode 100644 src/controls/MMapGeolocationControl/MMapControlSpinner/index.ts
create mode 100644 src/controls/MMapGeolocationControl/geolocation-black.svg
create mode 100644 src/controls/MMapGeolocationControl/geolocation-white.svg
create mode 100644 src/controls/MMapGeolocationControl/index.css
create mode 100644 src/controls/MMapGeolocationControl/index.ts
create mode 100644 src/controls/MMapGeolocationControl/self.svg
create mode 100644 src/controls/MMapZoomControl/MMapZoomControl.test.ts
create mode 100644 src/controls/MMapZoomControl/index.css
create mode 100644 src/controls/MMapZoomControl/index.ts
create mode 100644 src/controls/MMapZoomControl/zoom-in-black.svg
create mode 100644 src/controls/MMapZoomControl/zoom-in-white.svg
create mode 100644 src/controls/MMapZoomControl/zoom-out-black.svg
create mode 100644 src/controls/MMapZoomControl/zoom-out-white.svg
diff --git a/example/case-template/vue/index.ts b/example/case-template/vue/index.ts
index 4ec8bed..63d620d 100644
--- a/example/case-template/vue/index.ts
+++ b/example/case-template/vue/index.ts
@@ -23,7 +23,7 @@ async function main() {
MMapButtonExample
},
setup() {
- const refMap = (ref) => {
+ const refMap = (ref: any) => {
window.map = ref?.entity;
};
const onClick = () => alert('Click!');
diff --git a/example/common.css b/example/common.css
deleted file mode 100644
index 772cccd..0000000
--- a/example/common.css
+++ /dev/null
@@ -1,26 +0,0 @@
-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/geolocation-control/common.css b/example/geolocation-control/common.css
new file mode 100644
index 0000000..e69de29
diff --git a/example/geolocation-control/common.ts b/example/geolocation-control/common.ts
new file mode 100644
index 0000000..ca06e2f
--- /dev/null
+++ b/example/geolocation-control/common.ts
@@ -0,0 +1,8 @@
+import type {MMapLocationRequest, LngLatBounds} from '@mappable-world/mappable-types';
+
+const BOUNDS: LngLatBounds = [
+ [54.58311, 25.9985],
+ [56.30248, 24.47889]
+];
+
+export const LOCATION: MMapLocationRequest = {bounds: BOUNDS};
diff --git a/example/geolocation-control/react/index.html b/example/geolocation-control/react/index.html
new file mode 100644
index 0000000..4183912
--- /dev/null
+++ b/example/geolocation-control/react/index.html
@@ -0,0 +1,37 @@
+
+
+
+ React example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/geolocation-control/react/index.tsx b/example/geolocation-control/react/index.tsx
new file mode 100644
index 0000000..fc3ecb8
--- /dev/null
+++ b/example/geolocation-control/react/index.tsx
@@ -0,0 +1,38 @@
+import {LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
+ const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
+
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls} = reactify.module(mappable);
+
+ const {useState} = React;
+
+ const {MMapGeolocationControl} = reactify.module(
+ await mappable.import('@mappable-world/mappable-default-ui-theme')
+ );
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+ );
+
+ function App() {
+ const [location] = useState(LOCATION);
+
+ return (
+ (map = x)}>
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/example/geolocation-control/vanilla/index.html b/example/geolocation-control/vanilla/index.html
new file mode 100644
index 0000000..730390e
--- /dev/null
+++ b/example/geolocation-control/vanilla/index.html
@@ -0,0 +1,35 @@
+
+
+
+ Vanilla example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/geolocation-control/vanilla/index.ts b/example/geolocation-control/vanilla/index.ts
new file mode 100644
index 0000000..55d536b
--- /dev/null
+++ b/example/geolocation-control/vanilla/index.ts
@@ -0,0 +1,17 @@
+import {LOCATION} from '../common';
+window.map = null;
+
+main();
+async function main() {
+ await mappable.ready;
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls} = mappable;
+
+ const {MMapGeolocationControl} = await mappable.import('@mappable-world/mappable-default-ui-theme');
+
+ map = new MMap(document.getElementById('app'), {location: LOCATION});
+
+ map.addChild(new MMapDefaultSchemeLayer({}));
+ map.addChild(new MMapDefaultFeaturesLayer({}));
+
+ map.addChild(new MMapControls({position: 'right'}).addChild(new MMapGeolocationControl({})));
+}
diff --git a/example/geolocation-control/vue/index.html b/example/geolocation-control/vue/index.html
new file mode 100644
index 0000000..85eeebd
--- /dev/null
+++ b/example/geolocation-control/vue/index.html
@@ -0,0 +1,36 @@
+
+
+
+ Vue example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/geolocation-control/vue/index.ts b/example/geolocation-control/vue/index.ts
new file mode 100644
index 0000000..15e6fb1
--- /dev/null
+++ b/example/geolocation-control/vue/index.ts
@@ -0,0 +1,39 @@
+import {LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]);
+ const vuefy = mappableVue.vuefy.bindTo(Vue);
+
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls} = vuefy.module(mappable);
+
+ const {MMapGeolocationControl} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ const app = Vue.createApp({
+ components: {
+ MMap,
+ MMapDefaultSchemeLayer,
+ MMapDefaultFeaturesLayer,
+ MMapControls,
+ MMapGeolocationControl
+ },
+ setup() {
+ const refMap = (ref: any) => {
+ window.map = ref?.entity;
+ };
+ const onClick = () => alert('Click!');
+ return {LOCATION, refMap, onClick};
+ },
+ template: `
+
+
+
+
+
+
+ `
+ });
+ app.mount('#app');
+}
diff --git a/example/rotate-tilt-controls/common.js b/example/rotate-tilt-controls/common.ts
similarity index 61%
rename from example/rotate-tilt-controls/common.js
rename to example/rotate-tilt-controls/common.ts
index ed46311..1a75c96 100644
--- a/example/rotate-tilt-controls/common.js
+++ b/example/rotate-tilt-controls/common.ts
@@ -1,3 +1,5 @@
+import {BehaviorType, LngLatBounds, MMapBoundsLocation} from '@mappable-world/mappable-types';
+
mappable.import.loaders.unshift(async (pkg) => {
if (!pkg.startsWith('@mappable-world/mappable-default-ui-theme')) {
return;
@@ -12,12 +14,10 @@ mappable.import.loaders.unshift(async (pkg) => {
return window['@mappable-world/mappable-default-ui-theme'];
});
-const BOUNDS = [
+const BOUNDS: LngLatBounds = [
[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'];
+export const LOCATION: MMapBoundsLocation = {bounds: BOUNDS};
+export const ENABLED_BEHAVIORS: BehaviorType[] = ['drag', 'scrollZoom', 'dblClick', 'mouseTilt', 'mouseRotate'];
diff --git a/example/rotate-tilt-controls/react.html b/example/rotate-tilt-controls/react.html
deleted file mode 100644
index 8afbf92..0000000
--- a/example/rotate-tilt-controls/react.html
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
- React rotate tilt control example @mappable-world/mappable-default-ui-theme
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/rotate-tilt-controls/react/index.html b/example/rotate-tilt-controls/react/index.html
new file mode 100644
index 0000000..b9c642a
--- /dev/null
+++ b/example/rotate-tilt-controls/react/index.html
@@ -0,0 +1,36 @@
+
+
+
+ React example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/rotate-tilt-controls/react/index.tsx b/example/rotate-tilt-controls/react/index.tsx
new file mode 100644
index 0000000..600daf4
--- /dev/null
+++ b/example/rotate-tilt-controls/react/index.tsx
@@ -0,0 +1,40 @@
+import {ENABLED_BEHAVIORS, LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ // For each object in the JS API, there is a React counterpart
+ // To use the React version of the API, include the module @mappable-world/mappable-reactify
+ const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
+ const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
+ const {MMap, MMapControls, MMapDefaultSchemeLayer} = reactify.module(mappable);
+ const {MMapRotateTiltControl, MMapTiltControl, MMapRotateControl} = reactify.module(
+ await mappable.import('@mappable-world/mappable-default-ui-theme')
+ );
+ const {useState} = React;
+
+ function App() {
+ const [location] = useState(LOCATION);
+
+ return (
+ // Initialize the map and pass initialization parameters
+ (map = x)}>
+ {/* Add a map scheme layer */}
+
+
+
+
+
+
+
+ );
+ }
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+ );
+}
diff --git a/example/rotate-tilt-controls/vanilla.html b/example/rotate-tilt-controls/vanilla.html
deleted file mode 100644
index e9046c7..0000000
--- a/example/rotate-tilt-controls/vanilla.html
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
- Vanilla rotate tilt control example @mappable-world/mappable-default-ui-theme
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/rotate-tilt-controls/vanilla/index.html b/example/rotate-tilt-controls/vanilla/index.html
new file mode 100644
index 0000000..e4d869a
--- /dev/null
+++ b/example/rotate-tilt-controls/vanilla/index.html
@@ -0,0 +1,34 @@
+
+
+
+ Vanilla example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/rotate-tilt-controls/vanilla/index.ts b/example/rotate-tilt-controls/vanilla/index.ts
new file mode 100644
index 0000000..4a4c8dd
--- /dev/null
+++ b/example/rotate-tilt-controls/vanilla/index.ts
@@ -0,0 +1,30 @@
+import {ENABLED_BEHAVIORS, LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ // Waiting for all api elements to be loaded
+ await mappable.ready;
+ const {MMap, MMapControls, MMapDefaultSchemeLayer} = mappable;
+ const {MMapRotateTiltControl, MMapTiltControl, MMapRotateControl} = await mappable.import(
+ '@mappable-world/mappable-default-ui-theme'
+ );
+ // Initialize the map
+ map = new MMap(
+ // Pass the link to the HTMLElement of the container
+ document.getElementById('app'),
+ // Pass the map initialization parameters
+ {location: LOCATION, showScaleInCopyrights: true, behaviors: ENABLED_BEHAVIORS},
+ // Add a map scheme layer
+ [new MMapDefaultSchemeLayer({})]
+ );
+
+ map.addChild(
+ new MMapControls({position: 'right'}, [
+ new MMapRotateTiltControl({}),
+ new MMapRotateControl({}),
+ new MMapTiltControl({})
+ ])
+ );
+}
diff --git a/example/rotate-tilt-controls/vue.html b/example/rotate-tilt-controls/vue.html
deleted file mode 100644
index 1d84105..0000000
--- a/example/rotate-tilt-controls/vue.html
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
- Vue rotate tilt control example @mappable-world/mappable-default-ui-theme
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/rotate-tilt-controls/vue/index.html b/example/rotate-tilt-controls/vue/index.html
new file mode 100644
index 0000000..9471aa8
--- /dev/null
+++ b/example/rotate-tilt-controls/vue/index.html
@@ -0,0 +1,35 @@
+
+
+
+ Vue example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/rotate-tilt-controls/vue/index.ts b/example/rotate-tilt-controls/vue/index.ts
new file mode 100644
index 0000000..79686df
--- /dev/null
+++ b/example/rotate-tilt-controls/vue/index.ts
@@ -0,0 +1,40 @@
+import {ENABLED_BEHAVIORS, LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]);
+ const vuefy = mappableVue.vuefy.bindTo(Vue);
+ const {MMap, MMapControls, MMapDefaultSchemeLayer} = vuefy.module(mappable);
+ const {MMapRotateTiltControl, MMapTiltControl, MMapRotateControl, MMapZoomControl} = vuefy.module(
+ await mappable.import('@mappable-world/mappable-default-ui-theme')
+ );
+ const app = Vue.createApp({
+ components: {
+ MMap,
+ MMapControls,
+ MMapDefaultSchemeLayer,
+ MMapRotateTiltControl,
+ MMapTiltControl,
+ MMapRotateControl,
+ MMapZoomControl
+ },
+ setup() {
+ const refMap = (ref: any) => {
+ window.map = ref?.entity;
+ };
+ return {LOCATION, ENABLED_BEHAVIORS, refMap};
+ },
+ template: `
+
+
+
+
+
+
+
+ `
+ });
+ app.mount('#app');
+}
diff --git a/example/zoom-control/common.css b/example/zoom-control/common.css
new file mode 100644
index 0000000..e69de29
diff --git a/example/zoom-control/common.ts b/example/zoom-control/common.ts
new file mode 100644
index 0000000..ca06e2f
--- /dev/null
+++ b/example/zoom-control/common.ts
@@ -0,0 +1,8 @@
+import type {MMapLocationRequest, LngLatBounds} from '@mappable-world/mappable-types';
+
+const BOUNDS: LngLatBounds = [
+ [54.58311, 25.9985],
+ [56.30248, 24.47889]
+];
+
+export const LOCATION: MMapLocationRequest = {bounds: BOUNDS};
diff --git a/example/zoom-control/react/index.html b/example/zoom-control/react/index.html
new file mode 100644
index 0000000..4183912
--- /dev/null
+++ b/example/zoom-control/react/index.html
@@ -0,0 +1,37 @@
+
+
+
+ React example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/zoom-control/react/index.tsx b/example/zoom-control/react/index.tsx
new file mode 100644
index 0000000..62a4b33
--- /dev/null
+++ b/example/zoom-control/react/index.tsx
@@ -0,0 +1,39 @@
+import {LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
+ const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
+
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls} = reactify.module(mappable);
+
+ const {useState} = React;
+
+ const {MMapZoomControl} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+ );
+
+ function App() {
+ const [location] = useState(LOCATION);
+
+ return (
+ (map = x)}>
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/example/zoom-control/vanilla/index.html b/example/zoom-control/vanilla/index.html
new file mode 100644
index 0000000..730390e
--- /dev/null
+++ b/example/zoom-control/vanilla/index.html
@@ -0,0 +1,35 @@
+
+
+
+ Vanilla example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/zoom-control/vanilla/index.ts b/example/zoom-control/vanilla/index.ts
new file mode 100644
index 0000000..a0f61a9
--- /dev/null
+++ b/example/zoom-control/vanilla/index.ts
@@ -0,0 +1,18 @@
+import {LOCATION} from '../common';
+window.map = null;
+
+main();
+async function main() {
+ await mappable.ready;
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls} = mappable;
+
+ const {MMapZoomControl} = await mappable.import('@mappable-world/mappable-default-ui-theme');
+
+ map = new MMap(document.getElementById('app'), {location: LOCATION});
+
+ map.addChild(new MMapDefaultSchemeLayer({}));
+ map.addChild(new MMapDefaultFeaturesLayer({}));
+
+ map.addChild(new MMapControls({position: 'right'}).addChild(new MMapZoomControl({})));
+ map.addChild(new MMapControls({position: 'bottom'}).addChild(new MMapZoomControl({})));
+}
diff --git a/example/zoom-control/vue/index.html b/example/zoom-control/vue/index.html
new file mode 100644
index 0000000..85eeebd
--- /dev/null
+++ b/example/zoom-control/vue/index.html
@@ -0,0 +1,36 @@
+
+
+
+ Vue example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/zoom-control/vue/index.ts b/example/zoom-control/vue/index.ts
new file mode 100644
index 0000000..11c143c
--- /dev/null
+++ b/example/zoom-control/vue/index.ts
@@ -0,0 +1,41 @@
+import {LOCATION} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]);
+ const vuefy = mappableVue.vuefy.bindTo(Vue);
+
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls} = vuefy.module(mappable);
+
+ const {MMapZoomControl} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ const app = Vue.createApp({
+ components: {
+ MMap,
+ MMapDefaultSchemeLayer,
+ MMapDefaultFeaturesLayer,
+ MMapControls,
+ MMapZoomControl
+ },
+ setup() {
+ const refMap = (ref: any) => {
+ window.map = ref?.entity;
+ };
+ return {LOCATION, refMap};
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+ `
+ });
+ app.mount('#app');
+}
diff --git a/src/controls/MMapGeolocationControl/MMapControlSpinner/index.css b/src/controls/MMapGeolocationControl/MMapControlSpinner/index.css
new file mode 100644
index 0000000..74fc4e5
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/MMapControlSpinner/index.css
@@ -0,0 +1,55 @@
+@keyframes mappable--controls-spinner-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.mappable--controls-spinner {
+ position: relative;
+
+ display: block;
+
+ width: 16px;
+ height: 16px;
+ margin: 4px;
+
+ animation-name: mappable--controls-spinner-spin;
+ animation-duration: 1s;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+}
+
+.mappable--controls-spinner__circle {
+ position: absolute;
+ top: 0;
+ left: 50%;
+
+ overflow: hidden;
+
+ width: 100%;
+ height: 100%;
+
+ color: #000;
+}
+
+.mappable--controls-spinner__dark {
+ color: #fff;
+}
+
+.mappable--controls-spinner__circle::before {
+ position: absolute;
+ top: 0;
+ left: -50%;
+
+ width: 100%;
+ height: 100%;
+
+ content: '';
+
+ border-radius: 100%;
+ box-shadow: inset 0 0 0 2px;
+}
diff --git a/src/controls/MMapGeolocationControl/MMapControlSpinner/index.ts b/src/controls/MMapGeolocationControl/MMapControlSpinner/index.ts
new file mode 100644
index 0000000..4e0cafe
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/MMapControlSpinner/index.ts
@@ -0,0 +1,44 @@
+import type {DomDetach} from '@mappable-world/mappable-types';
+import './index.css';
+
+class MMapControlSpinner extends mappable.MMapComplexEntity<{}> {
+ private _detachDom?: DomDetach;
+ private _unwatchThemeContext?: () => void;
+
+ protected override _onAttach(): void {
+ const element = document.createElement('mappable');
+ element.classList.add('mappable--controls-spinner');
+
+ const circle = document.createElement('mappable');
+ circle.classList.add('mappable--controls-spinner__circle');
+ element.appendChild(circle);
+
+ this._detachDom = mappable.useDomContext(this, element, null);
+
+ this._unwatchThemeContext = this._watchContext(mappable.ThemeContext, () => this._updateTheme(circle), {
+ immediate: true
+ });
+ }
+
+ protected override _onDetach(): void {
+ this._detachDom?.();
+ this._detachDom = undefined;
+ this._unwatchThemeContext?.();
+ }
+
+ private _updateTheme(circle: HTMLElement): void {
+ const themeCtx = this._consumeContext(mappable.ThemeContext);
+ if (!themeCtx) {
+ return;
+ }
+ const {theme} = themeCtx;
+ const spinnerControlDarkClassName = 'mappable--controls-spinner__dark';
+ if (theme === 'dark') {
+ circle.classList.add(spinnerControlDarkClassName);
+ } else if (theme === 'light') {
+ circle.classList.remove(spinnerControlDarkClassName);
+ }
+ }
+}
+
+export {MMapControlSpinner};
diff --git a/src/controls/MMapGeolocationControl/geolocation-black.svg b/src/controls/MMapGeolocationControl/geolocation-black.svg
new file mode 100644
index 0000000..6342275
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/geolocation-black.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/MMapGeolocationControl/geolocation-white.svg b/src/controls/MMapGeolocationControl/geolocation-white.svg
new file mode 100644
index 0000000..d08ca4e
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/geolocation-white.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/MMapGeolocationControl/index.css b/src/controls/MMapGeolocationControl/index.css
new file mode 100644
index 0000000..a956b5a
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/index.css
@@ -0,0 +1,29 @@
+.mappable--geolocation-control {
+ display: block;
+
+ width: 24px;
+ height: 24px;
+
+ background: url('./geolocation-black.svg?inline') center / 24px 24px no-repeat;
+}
+
+.mappable--geolocation-control__dark {
+ background: url('./geolocation-white.svg?inline') center / 24px 24px no-repeat;
+}
+
+.mappable--geolocation-control-is-loading {
+ height: auto;
+
+ background: inherit;
+}
+
+.mappable--geolocation-control-self {
+ display: block;
+
+ width: 40px;
+ height: 40px;
+
+ background: url('./self.svg?inline') center / 24px 24px no-repeat;
+
+ transform: translate(-50%, -50%);
+}
diff --git a/src/controls/MMapGeolocationControl/index.ts b/src/controls/MMapGeolocationControl/index.ts
new file mode 100644
index 0000000..b488d25
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/index.ts
@@ -0,0 +1,175 @@
+import type TVue from '@vue/runtime-core';
+import type {MMapControl, MMapControlCommonButton, MMapMarker} from '@mappable-world/mappable-types';
+import type {EasingFunctionDescription, LngLat} from '@mappable-world/mappable-types/common/types';
+import type {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy';
+import {MMapControlSpinner} from './MMapControlSpinner';
+import './index.css';
+
+/**
+ * MMapGeolocationControl props
+ */
+type MMapGeolocationControlProps = {
+ /** Geolocation request callback */
+ onGeolocatePosition?: (position: LngLat) => void;
+ /** Data source id for geolocation placemark */
+ source?: string;
+ /** Easing function for map location animation */
+ easing?: EasingFunctionDescription;
+ /** Map location animate duration */
+ duration?: number;
+};
+
+const defaultProps = Object.freeze({duration: 500});
+
+type DefaultProps = typeof defaultProps;
+
+const MMapGeolocationControlVuefyOptions: CustomVuefyOptions = {
+ props: {
+ onGeolocatePosition: Function as TVue.PropType,
+ source: String,
+ easing: [String, Object, Function] as TVue.PropType,
+ duration: {type: Number, default: defaultProps.duration}
+ }
+};
+
+/**
+ * Display geolocation control on a map.
+ *
+ * @example
+ * ```javascript
+ * const controls = new MMapControls({position: 'right'});
+ * const geolocationControl = new MMapGeolocationControl();
+ * controls.addChild(geolocationControl);
+ * map.addChild(controls);
+ * ```
+ */
+class MMapGeolocationControl extends mappable.MMapGroupEntity {
+ static defaultProps = defaultProps;
+ static [mappable.optionsKeyVuefy] = MMapGeolocationControlVuefyOptions;
+ private _control!: MMapControl;
+ private _button!: MMapControlCommonButton;
+ private _spinner!: MMapControlSpinner;
+ private _marker!: MMapMarker;
+ private _loading: boolean = false;
+ private _element!: HTMLElement;
+ private _unwatchThemeContext?: () => void;
+
+ constructor(props: MMapGeolocationControlProps) {
+ super(props);
+ this._handleGeolocationClick = this._handleGeolocationClick.bind(this);
+ }
+
+ protected __implGetDefaultProps(): DefaultProps {
+ return MMapGeolocationControl.defaultProps;
+ }
+
+ private _timeout: number;
+ private _setLoading(loading: boolean): void {
+ this._loading = loading;
+ clearTimeout(this._timeout);
+ this._timeout = window.setTimeout(() => {
+ if (!this._spinner.parent && loading) {
+ this._button.addChild(this._spinner);
+ } else if (this._spinner.parent && !loading) {
+ this._button.removeChild(this._spinner);
+ }
+
+ this._element.classList.toggle('mappable--geolocation-control-is-loading', loading);
+ this._button.update({disabled: this._loading});
+ }, 100);
+ }
+
+ private _position: LngLat | null = null;
+
+ private _updatePosition(pos: LngLat): void {
+ this._position = pos;
+
+ if (this._props.onGeolocatePosition) {
+ this._props.onGeolocatePosition(this._position);
+ }
+
+ const map = this.root;
+ map?.update({
+ location: {
+ center: this._position,
+ duration: this._props.duration,
+ easing: this._props.easing
+ }
+ });
+
+ this.addChild(this._marker);
+ this._marker.update({coordinates: this._position});
+ }
+
+ private _handleGeolocationClick(): void {
+ if (this._loading) return;
+ this._setLoading(true);
+
+ mappable.geolocation
+ .getPosition({enableHighAccuracy: true, maximumAge: 60000})
+ .then((position: {coords: LngLat; accuracy: number}) => {
+ this._setLoading(false);
+ this._updatePosition(position.coords);
+ });
+ }
+
+ protected override _onAttach(): void {
+ this._control = new mappable.MMapControl();
+ this._element = document.createElement('mappable');
+ this._element.classList.add('mappable--geolocation-control');
+
+ this._button = new mappable.MMapControlCommonButton({
+ onClick: this._handleGeolocationClick,
+ element: this._element
+ });
+
+ this._spinner = new MMapControlSpinner({});
+ this._control.addChild(this._button);
+ this.addChild(this._control);
+
+ this._initMarker();
+
+ this._unwatchThemeContext = this._watchContext(mappable.ThemeContext, () => this._updateTheme(), {
+ immediate: true
+ });
+ }
+
+ protected override _onDetach() {
+ this._unwatchThemeContext?.();
+ }
+
+ protected override _onUpdate(props: Partial): void {
+ if (props.source) {
+ this._marker.update({
+ source: props.source
+ });
+ }
+ }
+
+ private _initMarker() {
+ this._marker = new mappable.MMapMarker(
+ {
+ source: this._props.source,
+ coordinates: [0, 0]
+ },
+ document.createElement('mappable')
+ );
+ this._marker.element.className = `mappable--geolocation-control-self`;
+ }
+
+ private _updateTheme(): void {
+ const themeCtx = this._consumeContext(mappable.ThemeContext);
+ if (!themeCtx) {
+ return;
+ }
+ const {theme} = themeCtx;
+ const geolocationControlDarkClassName = 'mappable--geolocation-control__dark';
+ if (theme === 'dark') {
+ this._element.classList.add(geolocationControlDarkClassName);
+ } else if (theme === 'light') {
+ this._element.classList.remove(geolocationControlDarkClassName);
+ }
+ }
+}
+
+export {MMapGeolocationControl, MMapGeolocationControlProps};
diff --git a/src/controls/MMapGeolocationControl/self.svg b/src/controls/MMapGeolocationControl/self.svg
new file mode 100644
index 0000000..23ce22d
--- /dev/null
+++ b/src/controls/MMapGeolocationControl/self.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/MMapZoomControl/MMapZoomControl.test.ts b/src/controls/MMapZoomControl/MMapZoomControl.test.ts
new file mode 100644
index 0000000..11c4687
--- /dev/null
+++ b/src/controls/MMapZoomControl/MMapZoomControl.test.ts
@@ -0,0 +1,38 @@
+import {MMapZoomControl} from './index';
+
+describe('MMapZoomControl', () => {
+ const container = document.createElement('div');
+ Object.assign(container.style, {width: '640px', height: '480px'});
+ document.body.append(container);
+
+ describe('zoom controls disabled', () => {
+ it('works', () => {
+ const map = new mappable.MMap(container, {
+ zoomRange: {min: 5, max: 10},
+ location: {center: [0, 0], zoom: 0}
+ });
+ const controls = new mappable.MMapControls({position: 'right'});
+ const zoomControl = new MMapZoomControl({});
+ // @ts-ignore Internal and external types do not match
+ controls.addChild(zoomControl);
+ map.addChild(controls);
+
+ const getZoomControlStatus = (zoomControl: 'in' | 'out'): boolean => {
+ return (
+ document.querySelector(`.mappable--zoom-control__${zoomControl}`)
+ ?.parentElement as HTMLButtonElement
+ ).disabled;
+ };
+
+ map.setLocation({zoom: map.zoomRange.max});
+ expect(getZoomControlStatus('in')).toBe(true);
+ expect(getZoomControlStatus('out')).toBe(false);
+
+ map.setLocation({zoom: map.zoomRange.min});
+ expect(getZoomControlStatus('out')).toBe(true);
+ expect(getZoomControlStatus('in')).toBe(false);
+
+ map.destroy();
+ });
+ });
+});
diff --git a/src/controls/MMapZoomControl/index.css b/src/controls/MMapZoomControl/index.css
new file mode 100644
index 0000000..7532f41
--- /dev/null
+++ b/src/controls/MMapZoomControl/index.css
@@ -0,0 +1,41 @@
+.mappable--zoom-control {
+ display: flex;
+}
+
+.mappable--zoom-control_vertical {
+ flex-direction: column;
+}
+
+.mappable--zoom-control_horizontal {
+ flex-direction: row-reverse;
+}
+
+.mappable--zoom-control__in {
+ display: block;
+
+ width: 24px;
+ height: 24px;
+
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ background: url('./zoom-in-black.svg?inline') center no-repeat;
+}
+
+.mappable--zoom-control__dark-in {
+ background: url('./zoom-in-white.svg?inline') center no-repeat;
+}
+
+.mappable--zoom-control__out {
+ display: block;
+
+ width: 24px;
+ height: 24px;
+
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ background: url('./zoom-out-black.svg?inline') center no-repeat;
+}
+
+.mappable--zoom-control__dark-out {
+ background: url('./zoom-out-white.svg?inline') center no-repeat;
+}
diff --git a/src/controls/MMapZoomControl/index.ts b/src/controls/MMapZoomControl/index.ts
new file mode 100644
index 0000000..e5d8394
--- /dev/null
+++ b/src/controls/MMapZoomControl/index.ts
@@ -0,0 +1,203 @@
+import type {
+ DomDetach,
+ MMapCenterZoomLocation,
+ MMapControl,
+ MMapControlCommonButton,
+ MMapListener
+} from '@mappable-world/mappable-types';
+import type {EasingFunctionDescription} from '@mappable-world/mappable-types/common/types';
+import type {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy';
+import type TVue from '@vue/runtime-core';
+
+import './index.css';
+
+/**
+ * MMapZoomControl props
+ */
+type MMapZoomControlProps = {
+ /** Easing function for map location animation */
+ easing?: EasingFunctionDescription;
+ /** Map location animate duration */
+ duration?: number;
+};
+
+const defaultProps = Object.freeze({duration: 200});
+
+type DefaultProps = typeof defaultProps;
+
+const MMapZoomControlVuefyOptions: CustomVuefyOptions = {
+ props: {
+ easing: [String, Object, Function] as TVue.PropType,
+ duration: {type: Number, default: defaultProps.duration}
+ }
+};
+
+class MMapZoomCommonControl extends mappable.MMapGroupEntity {
+ protected _zoomIn!: MMapControlCommonButton;
+ protected _zoomOut!: MMapControlCommonButton;
+ protected _listener!: MMapListener;
+
+ private _currentZoom: number = 10;
+
+ protected _detachDom?: DomDetach;
+ protected _element?: HTMLElement;
+ private _unwatchThemeContext?: () => void;
+ private _unwatchControlContext?: () => void;
+
+ constructor(props: MMapZoomControlProps) {
+ super(props);
+ this._onMapUpdate = this._onMapUpdate.bind(this);
+ }
+
+ private _onMapUpdate({location}: {location: MMapCenterZoomLocation}): void {
+ this._currentZoom = location.zoom;
+ this._onUpdate();
+ }
+
+ private _changeZoom(delta: number): void {
+ const newZoom = this._currentZoom + delta;
+ const map = this.root;
+ map.update({
+ location: {
+ zoom: newZoom,
+ duration: this._props.duration,
+ easing: this._props.easing
+ }
+ });
+ this._currentZoom = newZoom;
+ this._onUpdate();
+ }
+
+ protected override _onAttach(): void {
+ this._element = document.createElement('mappable');
+ this._element.classList.add('mappable--zoom-control');
+
+ this._detachDom = mappable.useDomContext(this, this._element, this._element);
+
+ const zoomInElement = document.createElement('mappable');
+ zoomInElement.classList.add('mappable--zoom-control__in');
+
+ const zoomOutElement = document.createElement('mappable');
+ zoomOutElement.classList.add('mappable--zoom-control__out');
+
+ this._zoomIn = new mappable.MMapControlCommonButton({
+ onClick: () => this._changeZoom(1),
+ element: zoomInElement
+ });
+
+ this._zoomOut = new mappable.MMapControlCommonButton({
+ onClick: () => this._changeZoom(-1),
+ element: zoomOutElement
+ });
+
+ this._listener = new mappable.MMapListener({onUpdate: this._onMapUpdate});
+
+ this.addChild(this._zoomIn).addChild(this._zoomOut).addChild(this._listener);
+ this._currentZoom = this.root!.zoom;
+
+ this._unwatchThemeContext = this._watchContext(
+ mappable.ThemeContext,
+ () => {
+ if (this._element) {
+ this._updateTheme({zoomIn: zoomInElement, zoomOut: zoomOutElement});
+ }
+ },
+ {immediate: true}
+ );
+
+ this._unwatchControlContext = this._watchContext(
+ mappable.ControlContext,
+ () => {
+ if (this._element) {
+ this._updateOrientation(this._element);
+ }
+ },
+ {immediate: true}
+ );
+ }
+
+ protected override _onDetach(): void {
+ this._detachDom?.();
+ this._detachDom = undefined;
+ this._element = undefined;
+ this.removeChild(this._zoomIn).removeChild(this._zoomOut).removeChild(this._listener);
+ this._unwatchThemeContext?.();
+ this._unwatchControlContext?.();
+ }
+
+ protected override _onUpdate() {
+ const map = this.root;
+ this._zoomIn.update({
+ disabled: this._currentZoom >= map.zoomRange.max
+ });
+
+ this._zoomOut.update({
+ disabled: this._currentZoom <= map.zoomRange.min
+ });
+ }
+
+ private _updateTheme(elements: {zoomOut: HTMLElement; zoomIn: HTMLElement}): void {
+ const themeCtx = this._consumeContext(mappable.ThemeContext);
+ if (!themeCtx) {
+ return;
+ }
+ const {theme} = themeCtx;
+ const {zoomIn, zoomOut} = elements;
+ const zoomInDarkClassName = 'mappable--zoom-control__dark-in';
+ const zoomOutDarkClassName = 'mappable--zoom-control__dark-out';
+ zoomIn.classList.toggle(zoomInDarkClassName, theme === 'dark');
+ zoomOut.classList.toggle(zoomOutDarkClassName, theme === 'dark');
+ }
+
+ private _updateOrientation(element: HTMLElement): void {
+ const controlCtx = this._consumeContext(mappable.ControlContext);
+ if (!controlCtx) {
+ return;
+ }
+ const verticalZoomClassName = 'mappable--zoom-control_vertical';
+ const horizontalZoomClassName = 'mappable--zoom-control_horizontal';
+ const orientation = controlCtx.position[2];
+ element.classList.toggle(verticalZoomClassName, orientation === 'vertical');
+ element.classList.toggle(horizontalZoomClassName, orientation === 'horizontal');
+ }
+}
+
+/**
+ * Display zoom control on a map.
+ *
+ * @example
+ * ```javascript
+ * const controls = new MMapControls({position: 'right'});
+ * const zoomControl = new MMapZoomControl();
+ * controls.addChild(zoomControl);
+ * map.addChild(controls);
+ * ```
+ */
+class MMapZoomControl extends mappable.MMapComplexEntity {
+ static [mappable.optionsKeyVuefy] = MMapZoomControlVuefyOptions;
+
+ static defaultProps = defaultProps;
+
+ private _control!: MMapControl;
+ private _zoom!: MMapZoomCommonControl;
+
+ protected __implGetDefaultProps(): DefaultProps {
+ return MMapZoomControl.defaultProps;
+ }
+
+ protected override _onAttach(): void {
+ this._zoom = new MMapZoomCommonControl(this._props);
+ this._control = new mappable.MMapControl().addChild(this._zoom);
+ this.addChild(this._control);
+ }
+
+ protected override _onUpdate(props: MMapZoomControlProps): void {
+ this._zoom.update(props);
+ }
+
+ protected override _onDetach(): void {
+ this.removeChild(this._control);
+ }
+}
+
+export {MMapZoomControl, MMapZoomControlProps};
diff --git a/src/controls/MMapZoomControl/zoom-in-black.svg b/src/controls/MMapZoomControl/zoom-in-black.svg
new file mode 100644
index 0000000..a31e21b
--- /dev/null
+++ b/src/controls/MMapZoomControl/zoom-in-black.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/MMapZoomControl/zoom-in-white.svg b/src/controls/MMapZoomControl/zoom-in-white.svg
new file mode 100644
index 0000000..4eea97a
--- /dev/null
+++ b/src/controls/MMapZoomControl/zoom-in-white.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/MMapZoomControl/zoom-out-black.svg b/src/controls/MMapZoomControl/zoom-out-black.svg
new file mode 100644
index 0000000..0a07651
--- /dev/null
+++ b/src/controls/MMapZoomControl/zoom-out-black.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/MMapZoomControl/zoom-out-white.svg b/src/controls/MMapZoomControl/zoom-out-white.svg
new file mode 100644
index 0000000..b077704
--- /dev/null
+++ b/src/controls/MMapZoomControl/zoom-out-white.svg
@@ -0,0 +1 @@
+
diff --git a/src/controls/index.ts b/src/controls/index.ts
index 8a4aae5..426b248 100644
--- a/src/controls/index.ts
+++ b/src/controls/index.ts
@@ -1,3 +1,5 @@
+export {MMapGeolocationControl, MMapGeolocationControlProps} from './MMapGeolocationControl';
export {MMapRotateControl, MMapRotateControlProps} from './MMapRotateControl';
-export {MMapTiltControl, MMapTiltControlProps} from './MMapTiltControl';
export {MMapRotateTiltControl, MMapRotateTiltControlProps} from './MMapRotateTiltControl';
+export {MMapTiltControl, MMapTiltControlProps} from './MMapTiltControl';
+export {MMapZoomControl, MMapZoomControlProps} from './MMapZoomControl';
diff --git a/tsconfig.json b/tsconfig.json
index d8e1bb1..c9fd3e0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
"extends": "@mappable-world/mappable-cli",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
+ "moduleResolution": "Node16",
"typeRoots": ["./node_modules/@types", "./node_modules/@mappable-world", "./types"]
},
"include": ["./src", "./node_modules/@mappable-world/mappable-cli/index.d.ts"]