From 047437dd221064b40d516b12d02c49719d1fb476 Mon Sep 17 00:00:00 2001
From: matthew44-mappable
<155086725+matthew44-mappable@users.noreply.github.com>
Date: Tue, 14 May 2024 12:38:00 +0300
Subject: [PATCH] Add tooltip and popup markers (#5)
* Added getting information about icons from Figma
* Added getting links to icons
* restructuring inside the tools folder
* Fetching components only with export settings
* load icon in local folder
* uploading icons from Figma to a local folder
* export only svg files
* refactor
* optimize svg and filter fallback icons
* generate types for icons
* generate icons list in md file
* filter fill-opacity icons
* default marker
* add nanospinner in cli tool
* path as common constants, nanospinner error in catch
* show pin with icon
* fetch colors from Figma
* color props
* optional props
* custom color
* add fallback icon
* size props in marker
* Downscale if no small icon
* upgrade markers
* download icons
* fix
* took chunk function from lodash
* rename generated files
* add tooltip marker
* tooltip combined positions, tooltip offset
* support dark theme
* separated tooltip and balloon
* add default popup
* remove close callback from popup content
* remove mock MMapPopupMarker
* add overrides
* popups examples
* add unit-tests
* add prefix in css
* add open animations
* remove __impl
* add popup in default marker
* fix popup in marker
* Rename ballon to popup
* Rename tooltip to text-popup
* fix issues
* remove text popup and default popup
* fix
---
example/marker-popup/common.ts | 5 +
example/marker-popup/react/index.html | 36 ++++
example/marker-popup/react/index.tsx | 45 ++++
example/marker-popup/vanilla/index.html | 34 +++
example/marker-popup/vanilla/index.ts | 38 ++++
example/marker-popup/vue/index.html | 35 +++
example/marker-popup/vue/index.ts | 53 +++++
example/popups-on-map/close.svg | 1 +
example/popups-on-map/common.css | 81 +++++++
example/popups-on-map/common.ts | 12 ++
example/popups-on-map/react/index.html | 37 ++++
example/popups-on-map/react/index.tsx | 78 +++++++
example/popups-on-map/vanilla/index.html | 35 +++
example/popups-on-map/vanilla/index.ts | 81 +++++++
example/popups-on-map/vue/index.html | 36 ++++
example/popups-on-map/vue/index.ts | 105 +++++++++
jest.config.js | 7 +-
src/markers/MMapDefaultMarker/index.ts | 84 +++++++-
src/markers/MMapDefaultMarker/react/index.tsx | 54 +++++
src/markers/MMapDefaultMarker/vue/index.ts | 56 ++++-
.../MMapPopupMarker/MMapPopupMarker.test.ts | 175 +++++++++++++++
src/markers/MMapPopupMarker/index.css | 200 ++++++++++++++++++
src/markers/MMapPopupMarker/index.ts | 187 ++++++++++++++++
src/markers/MMapPopupMarker/react/index.tsx | 47 ++++
src/markers/MMapPopupMarker/tail.svg | 1 +
src/markers/MMapPopupMarker/vue/index.ts | 69 ++++++
src/markers/index.ts | 10 +-
tests/common.ts | 10 +
tests/utils/svgTransform.js | 11 +
29 files changed, 1611 insertions(+), 12 deletions(-)
create mode 100644 example/marker-popup/common.ts
create mode 100644 example/marker-popup/react/index.html
create mode 100644 example/marker-popup/react/index.tsx
create mode 100644 example/marker-popup/vanilla/index.html
create mode 100644 example/marker-popup/vanilla/index.ts
create mode 100644 example/marker-popup/vue/index.html
create mode 100644 example/marker-popup/vue/index.ts
create mode 100644 example/popups-on-map/close.svg
create mode 100644 example/popups-on-map/common.css
create mode 100644 example/popups-on-map/common.ts
create mode 100644 example/popups-on-map/react/index.html
create mode 100644 example/popups-on-map/react/index.tsx
create mode 100644 example/popups-on-map/vanilla/index.html
create mode 100644 example/popups-on-map/vanilla/index.ts
create mode 100644 example/popups-on-map/vue/index.html
create mode 100644 example/popups-on-map/vue/index.ts
create mode 100644 src/markers/MMapDefaultMarker/react/index.tsx
create mode 100644 src/markers/MMapPopupMarker/MMapPopupMarker.test.ts
create mode 100644 src/markers/MMapPopupMarker/index.css
create mode 100644 src/markers/MMapPopupMarker/index.ts
create mode 100644 src/markers/MMapPopupMarker/react/index.tsx
create mode 100644 src/markers/MMapPopupMarker/tail.svg
create mode 100644 src/markers/MMapPopupMarker/vue/index.ts
create mode 100644 tests/common.ts
create mode 100644 tests/utils/svgTransform.js
diff --git a/example/marker-popup/common.ts b/example/marker-popup/common.ts
new file mode 100644
index 0000000..835e6ef
--- /dev/null
+++ b/example/marker-popup/common.ts
@@ -0,0 +1,5 @@
+import type {LngLat, MMapLocationRequest} from '@mappable-world/mappable-types';
+
+export const CENTER: LngLat = [55.442795, 25.24107];
+
+export const LOCATION: MMapLocationRequest = {center: CENTER, zoom: 9};
diff --git a/example/marker-popup/react/index.html b/example/marker-popup/react/index.html
new file mode 100644
index 0000000..b9c642a
--- /dev/null
+++ b/example/marker-popup/react/index.html
@@ -0,0 +1,36 @@
+
+
+
+ React example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/marker-popup/react/index.tsx b/example/marker-popup/react/index.tsx
new file mode 100644
index 0000000..c7a006e
--- /dev/null
+++ b/example/marker-popup/react/index.tsx
@@ -0,0 +1,45 @@
+import {MarkerSizeProps} from '../../src';
+import {CENTER, 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, MMapControlButton} =
+ reactify.module(mappable);
+
+ const {useState, useCallback, useMemo} = React;
+
+ const {MMapDefaultMarker} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+ );
+
+ function App() {
+ const [location] = useState(LOCATION);
+ const [size, setSize] = useState('normal');
+ const popup = useMemo(() => ({content: () => Marker popup}), []);
+
+ return (
+ (map = x)}>
+
+
+
+
+
+
+ setSize('normal'), [])} />
+ setSize('small'), [])} />
+ setSize('micro'), [])} />
+
+
+ );
+ }
+}
diff --git a/example/marker-popup/vanilla/index.html b/example/marker-popup/vanilla/index.html
new file mode 100644
index 0000000..e4d869a
--- /dev/null
+++ b/example/marker-popup/vanilla/index.html
@@ -0,0 +1,34 @@
+
+
+
+ Vanilla example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/marker-popup/vanilla/index.ts b/example/marker-popup/vanilla/index.ts
new file mode 100644
index 0000000..7308633
--- /dev/null
+++ b/example/marker-popup/vanilla/index.ts
@@ -0,0 +1,38 @@
+import {CENTER, LOCATION} from '../common';
+window.map = null;
+
+main();
+async function main() {
+ await mappable.ready;
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = mappable;
+
+ const {MMapDefaultMarker} = 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({}));
+
+ const marker = new MMapDefaultMarker({
+ coordinates: CENTER,
+ iconName: 'fallback',
+ size: 'normal',
+ popup: {
+ content: () => {
+ const popup = document.createElement('span');
+ popup.textContent = 'Marker popup';
+ return popup;
+ }
+ }
+ });
+
+ map.addChild(marker);
+
+ map.addChild(
+ new MMapControls({position: 'top left'}, [
+ new MMapControlButton({text: 'Normal', onClick: () => marker.update({size: 'normal'})}),
+ new MMapControlButton({text: 'Small', onClick: () => marker.update({size: 'small'})}),
+ new MMapControlButton({text: 'Micro', onClick: () => marker.update({size: 'micro'})})
+ ])
+ );
+}
diff --git a/example/marker-popup/vue/index.html b/example/marker-popup/vue/index.html
new file mode 100644
index 0000000..9471aa8
--- /dev/null
+++ b/example/marker-popup/vue/index.html
@@ -0,0 +1,35 @@
+
+
+
+ Vue example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/marker-popup/vue/index.ts b/example/marker-popup/vue/index.ts
new file mode 100644
index 0000000..074ca99
--- /dev/null
+++ b/example/marker-popup/vue/index.ts
@@ -0,0 +1,53 @@
+import {MarkerSizeProps} from '../../src';
+import {CENTER, 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, MMapControlButton} =
+ vuefy.module(mappable);
+
+ const {MMapDefaultMarker} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ const app = Vue.createApp({
+ components: {
+ MMap,
+ MMapDefaultSchemeLayer,
+ MMapDefaultFeaturesLayer,
+ MMapControls,
+ MMapControlButton,
+ MMapDefaultMarker
+ },
+ setup() {
+ const size = Vue.ref('normal');
+ const refMap = (ref: any) => {
+ window.map = ref?.entity;
+ };
+ const setNormalSize = () => (size.value = 'normal');
+ const setSmallSize = () => (size.value = 'small');
+ const setMicroSize = () => (size.value = 'micro');
+
+ return {LOCATION, CENTER, size, refMap, setNormalSize, setSmallSize, setMicroSize};
+ },
+ template: `
+
+
+
+
+
+ Marker popup
+
+
+
+
+
+
+
+ `
+ });
+ app.mount('#app');
+}
diff --git a/example/popups-on-map/close.svg b/example/popups-on-map/close.svg
new file mode 100644
index 0000000..1ba55ba
--- /dev/null
+++ b/example/popups-on-map/close.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/example/popups-on-map/common.css b/example/popups-on-map/common.css
new file mode 100644
index 0000000..820bf36
--- /dev/null
+++ b/example/popups-on-map/common.css
@@ -0,0 +1,81 @@
+.popup {
+ display: flex;
+ flex-direction: column;
+ row-gap: 8px;
+}
+
+.header {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ gap: 8px;
+ padding-right: 20px;
+
+ .header_title {
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 22px;
+ color: #050d33;
+ }
+ .header_close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: none;
+ color: #c8c9cc;
+ cursor: pointer;
+ background-image: url('./close.svg?inline');
+ background-position: center;
+ background-repeat: no-repeat;
+ }
+}
+
+.description {
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ color: #7b7d85;
+}
+
+.action {
+ width: max-content;
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: none;
+ background-color: #eefd7d;
+ transition: background-color 0.1s ease-out;
+ color: #050d33;
+ text-align: center;
+ cursor: pointer;
+ white-space: normal;
+
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 500;
+ line-height: 16px;
+}
+
+.action:hover {
+ background-color: #e5fd30;
+}
+
+._dark {
+ .header_title {
+ color: #f2f5fa;
+ }
+ .header_close {
+ color: #46464d;
+ }
+ .description {
+ color: #7b7d85;
+ }
+ .action {
+ background-color: #d6fd63;
+ }
+}
diff --git a/example/popups-on-map/common.ts b/example/popups-on-map/common.ts
new file mode 100644
index 0000000..b3b7c73
--- /dev/null
+++ b/example/popups-on-map/common.ts
@@ -0,0 +1,12 @@
+import type {MMapLocationRequest, LngLat} from '@mappable-world/mappable-types';
+
+export const CENTER: LngLat = [55.442795, 25.24107];
+export const LOCATION: MMapLocationRequest = {center: CENTER, zoom: 14};
+
+export const POPUP_TEXT = 'Default text popup';
+export const CUSTOM_POPUP_COORDS: LngLat = [CENTER[0] - 0.02, CENTER[1]];
+export const TEXT_POPUP_COORDS: LngLat = [CENTER[0] + 0.02, CENTER[1]];
+
+export const TITLE = 'Default popup marker';
+export const DESCRIPTION = 'Description for default popup';
+export const ACTION = 'Make an action';
diff --git a/example/popups-on-map/react/index.html b/example/popups-on-map/react/index.html
new file mode 100644
index 0000000..4183912
--- /dev/null
+++ b/example/popups-on-map/react/index.html
@@ -0,0 +1,37 @@
+
+
+
+ React example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/popups-on-map/react/index.tsx b/example/popups-on-map/react/index.tsx
new file mode 100644
index 0000000..a07ab88
--- /dev/null
+++ b/example/popups-on-map/react/index.tsx
@@ -0,0 +1,78 @@
+import type {MMapPopupPositionProps} from '../../src';
+import {ACTION, CUSTOM_POPUP_COORDS, DESCRIPTION, LOCATION, POPUP_TEXT, TEXT_POPUP_COORDS, TITLE} 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, MMapControlButton} =
+ reactify.module(mappable);
+
+ const {useState, useCallback} = React;
+
+ const {MMapPopupMarker} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+ );
+
+ function App() {
+ const [position, setPosition] = useState(undefined);
+ const [showCustom, setShowCustom] = useState(true);
+
+ const positionLeft = useCallback(() => setPosition('left'), []);
+ const positionLeftTop = useCallback(() => setPosition('left top'), []);
+ const positionLeftBottom = useCallback(() => setPosition('left bottom'), []);
+ const positionBottom = useCallback(() => setPosition('bottom'), []);
+ const positionTop = useCallback(() => setPosition('top'), []);
+ const positionRightTop = useCallback(() => setPosition('right top'), []);
+ const positionRightBottom = useCallback(() => setPosition('right bottom'), []);
+ const positionRight = useCallback(() => setPosition('right'), []);
+
+ const customPopupContent = useCallback(
+ () => (
+
+
+ {TITLE}
+
+
+ {DESCRIPTION}
+
+
+ ),
+ []
+ );
+
+ return (
+ (map = x)}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/example/popups-on-map/vanilla/index.html b/example/popups-on-map/vanilla/index.html
new file mode 100644
index 0000000..730390e
--- /dev/null
+++ b/example/popups-on-map/vanilla/index.html
@@ -0,0 +1,35 @@
+
+
+
+ Vanilla example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/popups-on-map/vanilla/index.ts b/example/popups-on-map/vanilla/index.ts
new file mode 100644
index 0000000..427c3ce
--- /dev/null
+++ b/example/popups-on-map/vanilla/index.ts
@@ -0,0 +1,81 @@
+import type {MMapPopupPositionProps} from '../../src';
+import {ACTION, CUSTOM_POPUP_COORDS, DESCRIPTION, LOCATION, POPUP_TEXT, TEXT_POPUP_COORDS, TITLE} from '../common';
+
+window.map = null;
+
+main();
+async function main() {
+ await mappable.ready;
+ const {MMap, MMapDefaultSchemeLayer, MMapDefaultFeaturesLayer, MMapControls, MMapControlButton} = mappable;
+
+ const {MMapPopupMarker} = 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({}));
+
+ const updatePositions = (position: MMapPopupPositionProps) => {
+ textPopup.update({position});
+ customPopup.update({position});
+ };
+
+ map.addChild(
+ new MMapControls({position: 'top right'}, [
+ new MMapControlButton({text: 'Left', onClick: () => updatePositions('left')}),
+ new MMapControlButton({text: 'Left Top', onClick: () => updatePositions('left top')}),
+ new MMapControlButton({text: 'Left Bottom', onClick: () => updatePositions('left bottom')}),
+ new MMapControlButton({text: 'Bottom', onClick: () => updatePositions('bottom')}),
+ new MMapControlButton({text: 'Top', onClick: () => updatePositions('top')}),
+ new MMapControlButton({text: 'Right Top', onClick: () => updatePositions('right top')}),
+ new MMapControlButton({text: 'Right Bottom', onClick: () => updatePositions('right bottom')}),
+ new MMapControlButton({text: 'Right', onClick: () => updatePositions('right')})
+ ])
+ );
+
+ const textPopup = new MMapPopupMarker({coordinates: TEXT_POPUP_COORDS, draggable: true, content: POPUP_TEXT});
+ map.addChild(textPopup);
+
+ const customPopup = new MMapPopupMarker({
+ coordinates: CUSTOM_POPUP_COORDS,
+ draggable: true,
+ content: createDefaultPopup
+ });
+ map.addChild(customPopup);
+
+ function createDefaultPopup(): HTMLElement {
+ const popupRootElement = document.createElement('span');
+ popupRootElement.classList.add('popup');
+
+ const popupHeaderElement = document.createElement('span');
+ popupHeaderElement.classList.add('header');
+ popupRootElement.appendChild(popupHeaderElement);
+
+ const titleElement = document.createElement('span');
+ titleElement.classList.add('header_title');
+ titleElement.textContent = TITLE;
+ popupHeaderElement.appendChild(titleElement);
+
+ const closeButton = document.createElement('button');
+ closeButton.classList.add('header_close');
+ closeButton.addEventListener('click', () => {
+ customPopup.update({show: false});
+ });
+ popupHeaderElement.appendChild(closeButton);
+
+ const descriptionElement = document.createElement('span');
+ descriptionElement.classList.add('description');
+ descriptionElement.textContent = DESCRIPTION;
+ popupRootElement.appendChild(descriptionElement);
+
+ const actionButton = document.createElement('button');
+ actionButton.classList.add('action');
+ actionButton.textContent = ACTION;
+ actionButton.addEventListener('click', () => {
+ alert('Click on action button!');
+ });
+ popupRootElement.appendChild(actionButton);
+
+ return popupRootElement;
+ }
+}
diff --git a/example/popups-on-map/vue/index.html b/example/popups-on-map/vue/index.html
new file mode 100644
index 0000000..85eeebd
--- /dev/null
+++ b/example/popups-on-map/vue/index.html
@@ -0,0 +1,36 @@
+
+
+
+ Vue example mappable-default-ui-theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/popups-on-map/vue/index.ts b/example/popups-on-map/vue/index.ts
new file mode 100644
index 0000000..8ae13bf
--- /dev/null
+++ b/example/popups-on-map/vue/index.ts
@@ -0,0 +1,105 @@
+import type {MMapPopupPositionProps} from '../../src';
+import {ACTION, CUSTOM_POPUP_COORDS, DESCRIPTION, LOCATION, POPUP_TEXT, TEXT_POPUP_COORDS, TITLE} 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, MMapControlButton} =
+ vuefy.module(mappable);
+
+ const {MMapPopupMarker} = vuefy.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
+
+ const app = Vue.createApp({
+ components: {
+ MMap,
+ MMapDefaultSchemeLayer,
+ MMapDefaultFeaturesLayer,
+ MMapControls,
+ MMapControlButton,
+ MMapPopupMarker
+ },
+ setup() {
+ const refMap = (ref: any) => {
+ window.map = ref?.entity;
+ };
+ const position = Vue.ref(undefined);
+ const showCustom = Vue.ref(true);
+
+ const positionLeft = () => (position.value = 'left');
+ const positionLeftTop = () => (position.value = 'left top');
+ const positionLeftBottom = () => (position.value = 'left bottom');
+ const positionBottom = () => (position.value = 'bottom');
+ const positionTop = () => (position.value = 'top');
+ const positionRightTop = () => (position.value = 'right top');
+ const positionRightBottom = () => (position.value = 'right bottom');
+ const positionRight = () => (position.value = 'right');
+
+ const customPopupAction = () => {
+ alert('Click on action button!');
+ };
+
+ return {
+ ACTION,
+ CUSTOM_POPUP_COORDS,
+ DESCRIPTION,
+ LOCATION,
+ POPUP_TEXT,
+ TEXT_POPUP_COORDS,
+ TITLE,
+ position,
+ showCustom,
+ refMap,
+ positionLeft,
+ positionLeftTop,
+ positionLeftBottom,
+ positionBottom,
+ positionTop,
+ positionRightTop,
+ positionRightBottom,
+ positionRight,
+ customPopupAction
+ };
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{POPUP_TEXT}}
+
+
+
+
+
+
+
+
+ `
+ });
+ app.mount('#app');
+}
diff --git a/jest.config.js b/jest.config.js
index bbb284a..34b90c9 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,3 +1,6 @@
module.exports = {
- ...require('@mappable-world/mappable-cli/jest.config')
-}
+ ...require('@mappable-world/mappable-cli/jest.config'),
+ transform: {
+ '^.+\\.svg$': '/tests/utils/svgTransform.js'
+ }
+};
diff --git a/src/markers/MMapDefaultMarker/index.ts b/src/markers/MMapDefaultMarker/index.ts
index fcad78e..83237a6 100644
--- a/src/markers/MMapDefaultMarker/index.ts
+++ b/src/markers/MMapDefaultMarker/index.ts
@@ -1,6 +1,8 @@
import {MMapMarker, MMapMarkerProps} from '@mappable-world/mappable-types';
import {IconColor, IconName, iconColors, icons} from '../../icons';
-import {MMapDefaultMarkerVuefyOptions} from './vue';
+import {MMapPopupContentProps, MMapPopupMarker} from '../MMapPopupMarker';
+import {MMapDefaultMarkerReactifyOverride} from './react';
+import {MMapDefaultMarkerVuefyOptions, MMapDefaultMarkerVuefyOverride} from './vue';
import microPoiStrokeSVG from './backgrounds/micro-poi-stroke.svg';
import microPoiSVG from './backgrounds/micro-poi.svg';
@@ -30,9 +32,15 @@ const HINT_SUBTITLE_CLASS = 'mappable--hint-subtitle';
const HINT_STABLE = 'mappable--hint__stable';
const HINT_HOVERED = 'mappable--hint__hovered';
+const DISTANCE_BETWEEN_POPUP_AND_MARKER = 8;
+
export type ThemesColor = {day: string; night: string};
export type MarkerColorProps = IconColor | ThemesColor;
export type MarkerSizeProps = 'normal' | 'small' | 'micro';
+export type MarkerPopupProps = {
+ /** The function of creating popup content */
+ content: MMapPopupContentProps;
+};
export type MMapDefaultMarkerProps = MMapMarkerProps & {
iconName?: IconName;
@@ -41,6 +49,7 @@ export type MMapDefaultMarkerProps = MMapMarkerProps & {
title?: string;
subtitle?: string;
staticHint?: boolean;
+ popup?: MarkerPopupProps;
};
const defaultProps = Object.freeze({color: 'darkgray', size: 'small', staticHint: true});
@@ -50,6 +59,8 @@ type BackgroundAndIcon = {background: HTMLElement; stroke: HTMLElement; icon: HT
export class MMapDefaultMarker extends mappable.MMapComplexEntity {
static defaultProps = defaultProps;
+ static [mappable.overrideKeyReactify] = MMapDefaultMarkerReactifyOverride;
+ static [mappable.overrideKeyVuefy] = MMapDefaultMarkerVuefyOverride;
static [mappable.optionsKeyVuefy] = MMapDefaultMarkerVuefyOptions;
private _marker: MMapMarker;
@@ -64,6 +75,8 @@ export class MMapDefaultMarker extends mappable.MMapComplexEntity this._updateTheme(), {
immediate: true
});
}
- protected _onUpdate(propsDiff: Partial): void {
+ protected _onUpdate(propsDiff: Partial, oldProps: MMapDefaultMarkerProps): void {
const {title, subtitle} = this._props;
if (propsDiff.color !== undefined) {
this._color = this._getColor();
this._updateTheme();
}
+
+ // popup props is changed
+ if (this._props.popup !== oldProps.popup) {
+ if (this._props.popup === undefined && oldProps.popup !== undefined) {
+ this.removeChild(this._popup);
+ this._popup = undefined;
+ } else if (this._props.popup !== undefined && oldProps.popup === undefined) {
+ this._popup = this._createPopupMarker();
+ this.addChild(this._popup);
+ } else {
+ this._popup.update(this._props.popup);
+ }
+ }
+
if (propsDiff.size !== undefined) {
this._updateMarkerSize();
this._updateSVG();
+ if (this._popup) {
+ this._popup.update({offset: this._getPopupOffset()});
+ }
}
this._titleHint.textContent = title ?? '';
@@ -148,7 +189,17 @@ export class MMapDefaultMarker extends mappable.MMapComplexEntity {
+ if (!this._popup) {
+ return;
+ }
+ this._popup.update({show: !this._popup.isOpen});
+ this._props.onClick?.(event);
+ };
+
private _updateTheme() {
const themeCtx = this._consumeContext(mappable.ThemeContext);
const theme = themeCtx.theme;
@@ -222,6 +281,23 @@ export class MMapDefaultMarker extends mappable.MMapComplexEntity TReact.ReactElement);
+ };
+ }
+ >
+>;
+
+type MMapDefaultMarkerR = TReact.ForwardRefExoticComponent<
+ Prettify>>
+>;
+
+export const MMapDefaultMarkerReactifyOverride: CustomReactify = (
+ MMapDefaultMarkerI,
+ {reactify, React, ReactDOM}
+) => {
+ const MMapDefaultMarkerReactified = reactify.entity(MMapDefaultMarkerI);
+
+ const MMapDefaultMarker = React.forwardRef((props, ref) => {
+ const [popupElement] = React.useState(document.createElement('mappable'));
+ const [content, setContent] = React.useState();
+
+ const popupContent = React.useMemo(() => {
+ if (props.popup === undefined) {
+ return undefined;
+ }
+
+ if (typeof props.popup.content === 'string') {
+ setContent(<>{props.popup.content}>);
+ } else if (typeof props.popup.content === 'function') {
+ setContent(props.popup.content());
+ }
+
+ return {content: () => popupElement};
+ }, [props.popup.content, popupElement]);
+
+ return (
+ <>
+
+ {ReactDOM.createPortal(content, popupElement)}
+ >
+ );
+ });
+ return MMapDefaultMarker;
+};
diff --git a/src/markers/MMapDefaultMarker/vue/index.ts b/src/markers/MMapDefaultMarker/vue/index.ts
index c72bcdf..9a7b547 100644
--- a/src/markers/MMapDefaultMarker/vue/index.ts
+++ b/src/markers/MMapDefaultMarker/vue/index.ts
@@ -1,7 +1,7 @@
-import {CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy';
-import type TVue from '@vue/runtime-core';
-import {MMapDefaultMarker, MarkerColorProps, MarkerSizeProps} from '../';
import {MMapFeatureProps, MMapMarkerEventHandler} from '@mappable-world/mappable-types';
+import {CustomVuefyFn, CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy';
+import type TVue from '@vue/runtime-core';
+import {MMapDefaultMarker, MMapDefaultMarkerProps, MarkerColorProps, MarkerPopupProps, MarkerSizeProps} from '../';
import {IconName} from '../../../icons';
export const MMapDefaultMarkerVuefyOptions: CustomVuefyOptions = {
@@ -24,10 +24,56 @@ export const MMapDefaultMarkerVuefyOptions: CustomVuefyOptions,
onFastClick: Function as TVue.PropType,
iconName: {type: String as TVue.PropType},
- color: {type: Object as TVue.PropType, default: 'darkgray'},
+ color: {type: [Object, String] as TVue.PropType, default: 'darkgray'},
size: {type: String as TVue.PropType, default: 'small'},
title: {type: String},
subtitle: {type: String},
- staticHint: {type: Boolean, default: true}
+ staticHint: {type: Boolean, default: true},
+ popup: {type: Object as TVue.PropType}
}
};
+
+type MMapDefaultMarkerSlots = {
+ popupContent: void;
+};
+
+export const MMapDefaultMarkerVuefyOverride: CustomVuefyFn = (
+ MMapDefaultMarkerI,
+ props,
+ {vuefy, Vue}
+) => {
+ const MMapDefaultMarkerV = vuefy.entity(MMapDefaultMarkerI);
+ const {popup, ...overridedProps} = props;
+
+ return Vue.defineComponent({
+ name: 'MMapDefaultMarker',
+ props: overridedProps,
+ slots: Object as TVue.SlotsType,
+ setup(props, {slots, expose}) {
+ const content: TVue.Ref = Vue.ref(null);
+ const popupHTMLElement = document.createElement('mappable');
+
+ const markerRef = Vue.ref<{entity: MMapDefaultMarker} | null>(null);
+ const markerEntity = Vue.computed(() => markerRef.value?.entity);
+
+ const popup = Vue.computed(() => {
+ if (slots.popupContent === undefined) {
+ return undefined;
+ }
+ content.value = slots.popupContent();
+ return {content: () => popupHTMLElement};
+ });
+ expose({entity: markerEntity});
+ return () =>
+ Vue.h(
+ MMapDefaultMarkerV,
+ {
+ ...props,
+ popup: popup.value,
+ ref: markerRef
+ },
+ () => Vue.h(Vue.Teleport, {to: popupHTMLElement}, [content.value])
+ );
+ }
+ });
+};
diff --git a/src/markers/MMapPopupMarker/MMapPopupMarker.test.ts b/src/markers/MMapPopupMarker/MMapPopupMarker.test.ts
new file mode 100644
index 0000000..3dce9e0
--- /dev/null
+++ b/src/markers/MMapPopupMarker/MMapPopupMarker.test.ts
@@ -0,0 +1,175 @@
+import {MMap} from '@mappable-world/mappable-types';
+import {createContainer, CENTER} from '../../../tests/common';
+import {MMapPopupMarker} from './';
+
+describe('MMapPopupMarker', () => {
+ let map: MMap;
+ let container: HTMLElement;
+
+ beforeEach(() => {
+ container = createContainer();
+ document.body.append(container);
+ map = new mappable.MMap(container, {location: {center: CENTER, zoom: 0}});
+ map.addChild(new mappable.MMapDefaultFeaturesLayer({}));
+ });
+
+ afterEach(() => {
+ map.destroy();
+ });
+
+ it('add on map', () => {
+ const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ expect(document.querySelector('.mappable--popup-marker')).not.toBeNull();
+ expect(document.querySelector('.mappable--popup-marker .test-popup')).not.toBeNull();
+ });
+ it('add on map with text', () => {
+ const popup = new MMapPopupMarker({coordinates: CENTER, content: 'test popup'});
+ map.addChild(popup);
+
+ expect(document.querySelector('.mappable--popup-marker')).not.toBeNull();
+ expect(document.querySelector('.mappable--popup-marker .mappable--popup-marker_container').textContent).toBe(
+ 'test popup'
+ );
+ });
+
+ it('changing show props', () => {
+ const popup = new MMapPopupMarker({show: true, coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ const popupMarkerElement = document.querySelector('.mappable--popup-marker');
+ expect(popupMarkerElement).not.toBeNull();
+
+ expect(popup.isOpen).toBe(true);
+ expect(document.querySelector('.mappable--popup-marker.mappable--popup-marker__hide')).toBeNull();
+
+ popup.update({show: false});
+ expect(popup.isOpen).toBe(false);
+ expect(document.querySelector('.mappable--popup-marker.mappable--popup-marker__hide')).not.toBeNull();
+
+ popup.update({show: true});
+ expect(popup.isOpen).toBe(true);
+ expect(document.querySelector('.mappable--popup-marker.mappable--popup-marker__hide')).toBeNull();
+ });
+
+ it('offset props', () => {
+ const popup = new MMapPopupMarker({offset: 12, coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ const popupMarkerElement = document.querySelector('.mappable--popup-marker');
+ expect(popupMarkerElement.style.getPropertyValue('--mappable-default-offset')).toBe('12px');
+
+ popup.update({offset: 24});
+ expect(popupMarkerElement.style.getPropertyValue('--mappable-default-offset')).toBe('24px');
+ });
+
+ describe('callback for closing and opening', () => {
+ it('callback on open', (done) => {
+ const onOpen = () => {
+ expect(popup.isOpen).toBe(true);
+ done();
+ };
+ const popup = new MMapPopupMarker({
+ show: true,
+ coordinates: CENTER,
+ content: createPopupContent,
+ onOpen
+ });
+ map.addChild(popup);
+ });
+ it('callback on close', (done) => {
+ const onClose = () => {
+ expect(popup.isOpen).toBe(false);
+ done();
+ };
+ const popup = new MMapPopupMarker({
+ show: true,
+ coordinates: CENTER,
+ content: createPopupContent,
+ onClose
+ });
+ map.addChild(popup);
+ popup.update({show: false});
+ });
+ });
+
+ describe('change popup position', () => {
+ it('initial default position', () => {
+ const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ expect(
+ document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-top')
+ ).not.toBeNull();
+ });
+ it('initial position', () => {
+ const popup = new MMapPopupMarker({position: 'left', coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ expect(
+ document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-left')
+ ).not.toBeNull();
+ });
+ it('change position props', () => {
+ const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ popup.update({position: 'top'});
+ expect(
+ document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-top')
+ ).not.toBeNull();
+
+ popup.update({position: 'bottom'});
+ expect(
+ document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-bottom')
+ ).not.toBeNull();
+
+ popup.update({position: 'left'});
+ expect(
+ document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-left')
+ ).not.toBeNull();
+
+ popup.update({position: 'right'});
+ expect(
+ document.querySelector('.mappable--popup-marker.mappable--popup-marker__position-right')
+ ).not.toBeNull();
+ });
+ it('change combined position props', () => {
+ const popup = new MMapPopupMarker({coordinates: CENTER, content: createPopupContent});
+ map.addChild(popup);
+
+ popup.update({position: 'top left'});
+ expect(
+ document.querySelector(
+ '.mappable--popup-marker.mappable--popup-marker__position-top.mappable--popup-marker__position-left'
+ )
+ ).not.toBeNull();
+ popup.update({position: 'top right'});
+ expect(
+ document.querySelector(
+ '.mappable--popup-marker.mappable--popup-marker__position-top.mappable--popup-marker__position-right'
+ )
+ ).not.toBeNull();
+
+ popup.update({position: 'bottom left'});
+ expect(
+ document.querySelector(
+ '.mappable--popup-marker.mappable--popup-marker__position-bottom.mappable--popup-marker__position-left'
+ )
+ ).not.toBeNull();
+ popup.update({position: 'bottom right'});
+ expect(
+ document.querySelector(
+ '.mappable--popup-marker.mappable--popup-marker__position-bottom.mappable--popup-marker__position-right'
+ )
+ ).not.toBeNull();
+ });
+ });
+});
+
+const createPopupContent = () => {
+ const popup = document.createElement('div');
+ popup.classList.add('test-popup');
+ return popup;
+};
diff --git a/src/markers/MMapPopupMarker/index.css b/src/markers/MMapPopupMarker/index.css
new file mode 100644
index 0000000..a1658a3
--- /dev/null
+++ b/src/markers/MMapPopupMarker/index.css
@@ -0,0 +1,200 @@
+@keyframes mappable--popup-marker-show-top {
+ from {
+ transform: translateY(12px);
+ opacity: 0;
+ }
+ to {
+ transform: none;
+ opacity: 1;
+ }
+}
+
+@keyframes mappable--popup-marker-show-bottom {
+ from {
+ transform: translateY(-12px);
+ opacity: 0;
+ }
+ to {
+ transform: none;
+ opacity: 1;
+ }
+}
+
+@keyframes mappable--popup-marker-show-left {
+ from {
+ transform: translateX(12px);
+ opacity: 0;
+ }
+ to {
+ transform: none;
+ opacity: 1;
+ }
+}
+
+@keyframes mappable--popup-marker-show-right {
+ from {
+ transform: translateX(-12px);
+ opacity: 0;
+ }
+ to {
+ transform: none;
+ opacity: 1;
+ }
+}
+
+.mappable--popup-marker {
+ --mappable-default-tail-height: 12px;
+ --mappable-default-tail-width: 16px;
+ --mappable-default-border-radius: 12px;
+
+ --mappable-default-tail-height-and-offset: calc(
+ var(--mappable-default-tail-height) + var(--mappable-default-offset)
+ );
+
+ --mappable-default-popup-tail-transform-top: translate(-50%, calc(-100% - var(--mappable-default-offset)))
+ rotate(180deg);
+ --mappable-default-popup-tail-transform-bottom: translate(-50%, var(--mappable-default-offset));
+
+ position: absolute;
+
+ &.mappable--popup-marker__hide {
+ display: none;
+ }
+}
+
+.mappable--popup-marker svg {
+ display: block;
+}
+
+.mappable--popup-marker_container {
+ width: max-content;
+ max-width: 500px;
+ max-height: 600px;
+ display: block;
+ position: absolute;
+ padding: 8px 12px;
+ border-radius: var(--mappable-default-border-radius);
+ background-color: #fff;
+ color: #34374a;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+ box-shadow:
+ 0px 4px 12px 0px #5f69831a,
+ 0px 4px 24px 0px #5f69830a;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.mappable--popup-marker__dark {
+ background-color: #272729;
+ color: #c8c9cc;
+ }
+}
+
+.mappable--popup-marker_tail {
+ display: block;
+ position: absolute;
+ color: #fff;
+
+ &.mappable--popup-marker__dark {
+ color: #272729;
+ }
+
+ svg {
+ filter: drop-shadow(0px 4px 24px rgba(95, 105, 131, 0.04)) drop-shadow(0px 4px 12px rgba(95, 105, 131, 0.1));
+ }
+}
+
+/* positions */
+.mappable--popup-marker__position-top {
+ animation: mappable--popup-marker-show-top 200ms ease-out !important;
+
+ .mappable--popup-marker_container {
+ transform: translate(-50%, calc(-100% - var(--mappable-default-tail-height-and-offset)));
+ }
+ .mappable--popup-marker_tail {
+ transform: var(--mappable-default-popup-tail-transform-top);
+ }
+ /* top left */
+ &.mappable--popup-marker__position-left {
+ .mappable--popup-marker_container {
+ transform: translate(
+ calc(-100% + var(--mappable-default-border-radius) + var(--mappable-default-tail-width) / 2),
+ calc(-100% - var(--mappable-default-tail-height-and-offset))
+ );
+ }
+ .mappable--popup-marker_tail {
+ transform: var(--mappable-default-popup-tail-transform-top);
+ }
+ }
+ /* top right */
+ &.mappable--popup-marker__position-right {
+ .mappable--popup-marker_container {
+ transform: translate(
+ calc(-1 * var(--mappable-default-border-radius) - var(--mappable-default-tail-width) / 2),
+ calc(-100% - var(--mappable-default-tail-height-and-offset))
+ );
+ }
+ .mappable--popup-marker_tail {
+ transform: var(--mappable-default-popup-tail-transform-top);
+ }
+ }
+}
+
+.mappable--popup-marker__position-bottom {
+ animation: mappable--popup-marker-show-bottom 200ms ease-out !important;
+
+ .mappable--popup-marker_container {
+ transform: translate(-50%, var(--mappable-default-tail-height-and-offset));
+ }
+ .mappable--popup-marker_tail {
+ transform: var(--mappable-default-popup-tail-transform-bottom);
+ }
+ /* bottom left */
+ &.mappable--popup-marker__position-left {
+ .mappable--popup-marker_container {
+ transform: translate(
+ calc(-100% + var(--mappable-default-border-radius) + var(--mappable-default-tail-width) / 2),
+ var(--mappable-default-tail-height-and-offset)
+ );
+ }
+ .mappable--popup-marker_tail {
+ transform: var(--mappable-default-popup-tail-transform-bottom);
+ }
+ }
+ /* bottom right */
+ &.mappable--popup-marker__position-right {
+ .mappable--popup-marker_container {
+ transform: translate(
+ calc(-1 * var(--mappable-default-border-radius) - var(--mappable-default-tail-width) / 2),
+ var(--mappable-default-tail-height-and-offset)
+ );
+ }
+ .mappable--popup-marker_tail {
+ transform: var(--mappable-default-popup-tail-transform-bottom);
+ }
+ }
+}
+
+.mappable--popup-marker__position-left {
+ animation: mappable--popup-marker-show-left 200ms ease-out;
+
+ .mappable--popup-marker_container {
+ transform: translate(calc(-100% - var(--mappable-default-tail-height-and-offset)), -50%);
+ }
+ .mappable--popup-marker_tail {
+ transform: translate(calc(-100% - var(--mappable-default-offset)), -50%) rotate(90deg);
+ }
+}
+
+.mappable--popup-marker__position-right {
+ animation: mappable--popup-marker-show-right 200ms ease-out;
+
+ .mappable--popup-marker_container {
+ transform: translate(var(--mappable-default-tail-height-and-offset), -50%);
+ }
+ .mappable--popup-marker_tail {
+ transform: translate(var(--mappable-default-offset), -50%) rotate(-90deg);
+ }
+}
diff --git a/src/markers/MMapPopupMarker/index.ts b/src/markers/MMapPopupMarker/index.ts
new file mode 100644
index 0000000..7fee34d
--- /dev/null
+++ b/src/markers/MMapPopupMarker/index.ts
@@ -0,0 +1,187 @@
+import {MMapMarker, MMapMarkerProps} from '@mappable-world/mappable-types';
+import {MMapPopupMarkerReactifyOverride} from './react';
+import {MMapPopupMarkerVuefyOptions, MMapPopupMarkerVuefyOverride} from './vue';
+
+import './index.css';
+import tailSVG from './tail.svg';
+
+type VerticalPosition = 'top' | 'bottom';
+type HorizontalPosition = 'left' | 'right';
+export type MMapPopupPositionProps =
+ | VerticalPosition
+ | HorizontalPosition
+ | `${VerticalPosition} ${HorizontalPosition}`
+ | `${HorizontalPosition} ${VerticalPosition}`;
+
+export type MMapPopupContentProps = string | (() => HTMLElement);
+
+export type MMapPopupMarkerProps = MMapMarkerProps & {
+ /** The function of creating popup content */
+ content: MMapPopupContentProps;
+ /** The position of the popup in relation to the point it is pointing to */
+ position?: MMapPopupPositionProps;
+ /** The offset in pixels between the popup pointer and the point it is pointing to. */
+ offset?: number;
+ /** Hide or show popup on map */
+ show?: boolean;
+ /** Popup closing callback */
+ onClose?: () => void;
+ /** Popup opening callback */
+ onOpen?: () => void;
+};
+
+const defaultProps = Object.freeze({position: 'top', offset: 0, show: true});
+type DefaultProps = typeof defaultProps;
+
+/**
+ * `MMapPopupMarker` is a popup with customized content.
+ * @example
+ * ```js
+ * const popup = new MMapPopupMarker({
+ * content: () => createPopupContentHTMLElement(),
+ * position: 'top',
+ * onOpen:() => console.log('open'),
+ * onClose:() => console.log('close'),
+ * // support MMapMarker props
+ * coordinates: POPUP_COORD,
+ * draggable: true,
+ * });
+ * map.addChild(popup);
+ * ```
+ */
+export class MMapPopupMarker extends mappable.MMapComplexEntity {
+ static defaultProps = defaultProps;
+ static [mappable.overrideKeyReactify] = MMapPopupMarkerReactifyOverride;
+ static [mappable.overrideKeyVuefy] = MMapPopupMarkerVuefyOverride;
+ static [mappable.optionsKeyVuefy] = MMapPopupMarkerVuefyOptions;
+
+ public get isOpen() {
+ return this._props.show;
+ }
+ private _markerElement: HTMLElement;
+ private _popupContainer: HTMLElement;
+ private _popupTail: HTMLElement;
+ private _marker: MMapMarker;
+
+ private _togglePopup(forceShowPopup?: boolean): void {
+ const openPopup = forceShowPopup ?? !this._props.show;
+
+ this._markerElement.classList.toggle('mappable--popup-marker__hide', !openPopup);
+
+ if (openPopup) {
+ this._props.onOpen?.();
+ } else {
+ this._props.onClose?.();
+ }
+
+ this._props.show = openPopup;
+ }
+
+ protected _onAttach(): void {
+ this._markerElement = document.createElement('mappable');
+ this._markerElement.classList.add('mappable--popup-marker');
+
+ this._popupContainer = document.createElement('mappable');
+ this._popupContainer.classList.add('mappable--popup-marker_container');
+
+ if (typeof this._props.content === 'string') {
+ this._popupContainer.textContent = this._props.content;
+ } else {
+ this._popupContainer.appendChild(this._props.content());
+ }
+
+ this._popupTail = document.createElement('mappable');
+ this._popupTail.classList.add('mappable--popup-marker_tail');
+ this._popupTail.innerHTML = tailSVG;
+
+ this._togglePopup(this._props.show);
+ this._updatePosition();
+ this._updateOffset();
+
+ this._markerElement.appendChild(this._popupContainer);
+ this._markerElement.appendChild(this._popupTail);
+
+ this._marker = new mappable.MMapMarker(this._props, this._markerElement);
+ this.addChild(this._marker);
+
+ this._watchContext(mappable.ThemeContext, () => this._updateTheme(), {
+ immediate: true
+ });
+ }
+
+ protected _onUpdate(propsDiff: Partial): void {
+ if (propsDiff.position !== undefined) {
+ this._updatePosition();
+ }
+ if (propsDiff.offset !== undefined) {
+ this._updateOffset();
+ }
+
+ if (propsDiff.content !== undefined) {
+ this._popupContainer.innerHTML = '';
+
+ if (typeof this._props.content === 'string') {
+ this._popupContainer.textContent = this._props.content;
+ } else {
+ this._popupContainer.appendChild(this._props.content());
+ }
+ }
+
+ if (propsDiff.show !== undefined) {
+ this._togglePopup(propsDiff.show);
+ }
+
+ this._marker.update(this._props);
+ }
+
+ private _updateTheme() {
+ const themeCtx = this._consumeContext(mappable.ThemeContext);
+ const {theme} = themeCtx;
+ this._popupContainer.classList.toggle('mappable--popup-marker__dark', theme === 'dark');
+ this._popupTail.classList.toggle('mappable--popup-marker__dark', theme === 'dark');
+ }
+
+ private _updateOffset(): void {
+ this._markerElement.style.setProperty('--mappable-default-offset', `${this._props.offset}px`);
+ }
+
+ private _updatePosition(): void {
+ const {position} = this._props;
+ let verticalPosition: VerticalPosition;
+ let horizontalPosition: HorizontalPosition;
+
+ const positionTypeHash: Record = {
+ top: 'vertical',
+ left: 'horizontal',
+ bottom: 'vertical',
+ right: 'horizontal'
+ };
+
+ if (position === 'top' || position === 'bottom') {
+ verticalPosition = position;
+ } else if (position === 'left' || position === 'right') {
+ horizontalPosition = position;
+ } else {
+ const [first, second] = position.split(' ') as (HorizontalPosition | VerticalPosition)[];
+ if (positionTypeHash[first] === 'vertical' && positionTypeHash[second] === 'horizontal') {
+ verticalPosition = first as VerticalPosition;
+ horizontalPosition = second as HorizontalPosition;
+ } else if (positionTypeHash[first] === 'horizontal' && positionTypeHash[second] === 'vertical') {
+ verticalPosition = second as VerticalPosition;
+ horizontalPosition = first as HorizontalPosition;
+ }
+ }
+
+ // check top position
+ this._markerElement.classList.toggle('mappable--popup-marker__position-top', verticalPosition === 'top');
+
+ // check bottom position
+ this._markerElement.classList.toggle('mappable--popup-marker__position-bottom', verticalPosition === 'bottom');
+
+ // check left position
+ this._markerElement.classList.toggle('mappable--popup-marker__position-left', horizontalPosition === 'left');
+
+ // check right position
+ this._markerElement.classList.toggle('mappable--popup-marker__position-right', horizontalPosition === 'right');
+ }
+}
diff --git a/src/markers/MMapPopupMarker/react/index.tsx b/src/markers/MMapPopupMarker/react/index.tsx
new file mode 100644
index 0000000..909781e
--- /dev/null
+++ b/src/markers/MMapPopupMarker/react/index.tsx
@@ -0,0 +1,47 @@
+import {MMapEntity} from '@mappable-world/mappable-types';
+import {CustomReactify, OverrideProps, Prettify} from '@mappable-world/mappable-types/reactify/reactify';
+import type TReact from 'react';
+import {MMapPopupMarker as MMapPopupMarkerI, MMapPopupMarkerProps} from '../';
+
+type MMapPopupMarkerReactifiedProps = Prettify<
+ OverrideProps<
+ MMapPopupMarkerProps,
+ {
+ /** The function of creating popup content */
+ content: string | (() => TReact.ReactElement);
+ }
+ >
+>;
+
+type MMapPopupMarkerR = TReact.ForwardRefExoticComponent<
+ Prettify>>
+>;
+
+export const MMapPopupMarkerReactifyOverride: CustomReactify = (
+ MMapPopupMarkerI,
+ {reactify, React, ReactDOM}
+) => {
+ const MMapPopupMarkerReactified = reactify.entity(MMapPopupMarkerI);
+
+ const MMapPopupMarker = React.forwardRef((props, ref) => {
+ const [popupElement] = React.useState(document.createElement('mappable'));
+ const [content, setContent] = React.useState();
+
+ const popup = React.useMemo(() => {
+ if (typeof props.content === 'string') {
+ setContent(<>{props.content}>);
+ } else {
+ setContent(props.content());
+ }
+ return () => popupElement;
+ }, [props.content, popupElement]);
+
+ return (
+ <>
+
+ {ReactDOM.createPortal(content, popupElement)}
+ >
+ );
+ });
+ return MMapPopupMarker;
+};
diff --git a/src/markers/MMapPopupMarker/tail.svg b/src/markers/MMapPopupMarker/tail.svg
new file mode 100644
index 0000000..0ac3f80
--- /dev/null
+++ b/src/markers/MMapPopupMarker/tail.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/markers/MMapPopupMarker/vue/index.ts b/src/markers/MMapPopupMarker/vue/index.ts
new file mode 100644
index 0000000..2d26804
--- /dev/null
+++ b/src/markers/MMapPopupMarker/vue/index.ts
@@ -0,0 +1,69 @@
+import {MMapFeatureProps, MMapMarkerEventHandler} from '@mappable-world/mappable-types';
+import {CustomVuefyFn, CustomVuefyOptions} from '@mappable-world/mappable-types/modules/vuefy';
+import type TVue from '@vue/runtime-core';
+import {MMapPopupContentProps, MMapPopupMarker, MMapPopupMarkerProps, MMapPopupPositionProps} from '../';
+
+export const MMapPopupMarkerVuefyOptions: CustomVuefyOptions = {
+ props: {
+ coordinates: {type: Object, required: true},
+ source: String,
+ zIndex: {type: Number, default: 0},
+ properties: Object,
+ id: String,
+ disableRoundCoordinates: {type: Boolean, default: undefined},
+ hideOutsideViewport: {type: [Object, Boolean], default: false},
+ draggable: {type: Boolean, default: false},
+ mapFollowsOnDrag: {type: [Boolean, Object]},
+ onDragStart: Function as TVue.PropType,
+ onDragEnd: Function as TVue.PropType,
+ onDragMove: Function as TVue.PropType,
+ blockEvents: {type: Boolean, default: undefined},
+ blockBehaviors: {type: Boolean, default: undefined},
+ onDoubleClick: Function as TVue.PropType,
+ onClick: Function as TVue.PropType,
+ onFastClick: Function as TVue.PropType,
+ content: {type: [Function, String] as TVue.PropType, required: true},
+ position: {type: String as TVue.PropType},
+ offset: {type: Number, default: 0},
+ show: {type: Boolean, default: true},
+ onClose: {type: Function as TVue.PropType},
+ onOpen: {type: Function as TVue.PropType}
+ }
+};
+
+type MMapPopupMarkerSlots = {
+ content: void;
+};
+
+export const MMapPopupMarkerVuefyOverride: CustomVuefyFn = (MMapPopupMarkerI, props, {vuefy, Vue}) => {
+ const MMapPopupMarkerV = vuefy.entity(MMapPopupMarkerI);
+ const {content, ...overridedProps} = props;
+ return Vue.defineComponent({
+ name: 'MMapPopupMarker',
+ props: overridedProps,
+ slots: Object as TVue.SlotsType,
+ setup(props, {slots, expose}) {
+ const content: TVue.Ref = Vue.ref(null);
+ const popupHTMLElement = document.createElement('mappable');
+
+ const markerRef = Vue.ref<{entity: MMapPopupMarker} | null>(null);
+ const markerEntity = Vue.computed(() => markerRef.value?.entity);
+
+ const popup = Vue.computed(() => {
+ content.value = slots.content?.();
+ return () => popupHTMLElement;
+ });
+ expose({entity: markerEntity});
+ return () =>
+ Vue.h(
+ MMapPopupMarkerV,
+ {
+ ...props,
+ content: popup.value,
+ ref: markerRef
+ },
+ () => Vue.h(Vue.Teleport, {to: popupHTMLElement}, [content.value])
+ );
+ }
+ });
+};
diff --git a/src/markers/index.ts b/src/markers/index.ts
index 8ad99ac..2d182cb 100644
--- a/src/markers/index.ts
+++ b/src/markers/index.ts
@@ -1 +1,9 @@
-export * from './MMapDefaultMarker';
+export {
+ MMapDefaultMarker,
+ MMapDefaultMarkerProps,
+ MarkerColorProps,
+ MarkerPopupProps,
+ MarkerSizeProps,
+ ThemesColor
+} from './MMapDefaultMarker';
+export {MMapPopupMarker, MMapPopupMarkerProps, MMapPopupPositionProps, MMapPopupContentProps} from './MMapPopupMarker';
diff --git a/tests/common.ts b/tests/common.ts
new file mode 100644
index 0000000..0502476
--- /dev/null
+++ b/tests/common.ts
@@ -0,0 +1,10 @@
+import {LngLat} from '@mappable-world/mappable-types';
+
+export const CENTER: LngLat = [0, 0];
+
+export const createContainer = () => {
+ const container = document.createElement('div');
+ container.style.width = '640px';
+ container.style.height = '480px';
+ return container;
+};
diff --git a/tests/utils/svgTransform.js b/tests/utils/svgTransform.js
new file mode 100644
index 0000000..781ca5d
--- /dev/null
+++ b/tests/utils/svgTransform.js
@@ -0,0 +1,11 @@
+module.exports = {
+ process() {
+ return {
+ code: `module.exports = {};`
+ };
+ },
+ getCacheKey() {
+ // The output is always the same.
+ return 'svgTransform';
+ }
+};