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"]