From 29c56a3daf3b4058e67d7778a3b3fe7e0c33bda4 Mon Sep 17 00:00:00 2001 From: Aries Date: Fri, 23 May 2025 10:01:33 +0800 Subject: [PATCH] Feature/google map view (#7828) * custom overlay * google map clusterer * google controls * update google map in details * fix bug - image dialog does not work after add filters --------- Co-authored-by: zhouwenxuan --- .../dir-view-mode/dir-views/new-view-menu.js | 4 +- .../components/map-controller/geolocation.js | 39 ++- .../components/map-controller/index.css | 19 ++ .../components/map-controller/zoom.js | 109 +++++--- .../metadata-details/location/index.css | 5 + .../metadata-details/location/index.js | 17 +- frontend/src/metadata/constants/view/map.js | 8 + .../src/metadata/metadata-tree-view/view.js | 4 +- frontend/src/metadata/views/map/baidu.js | 113 ++++++++ frontend/src/metadata/views/map/google.js | 103 +++++++ frontend/src/metadata/views/map/index.js | 256 +++++++----------- .../map/overlay/custom-avatar-overlay.js | 70 ++++- .../src/metadata/views/map/overlay/index.js | 3 +- frontend/src/utils/map-utils.js | 45 ++- 14 files changed, 580 insertions(+), 215 deletions(-) create mode 100644 frontend/src/metadata/views/map/baidu.js create mode 100644 frontend/src/metadata/views/map/google.js diff --git a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js index 6e5222f9db..d3be66e802 100644 --- a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js +++ b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js @@ -1,7 +1,7 @@ import React from 'react'; import Icon from '../../icon'; import TextTranslation from '../../../utils/text-translation'; -import { baiduMapKey, gettext } from '../../../utils/constants'; +import { baiduMapKey, gettext, googleMapKey } from '../../../utils/constants'; import { VIEW_TYPE, VIEW_TYPE_ICON } from '../../../metadata/constants'; export const KEY_ADD_VIEW_MAP = { @@ -46,7 +46,7 @@ export const getNewViewSubMenu = () => { const options = [...ADD_VIEW_OPTIONS]; const hasMapOption = options.some(opt => opt.type === VIEW_TYPE.MAP); - if (!hasMapOption && baiduMapKey) { + if (!hasMapOption && (baiduMapKey || googleMapKey)) { options.push({ key: KEY_ADD_VIEW_MAP.ADD_MAP, type: VIEW_TYPE.MAP, diff --git a/frontend/src/metadata/components/map-controller/geolocation.js b/frontend/src/metadata/components/map-controller/geolocation.js index 11dbf36d42..a049cf94de 100644 --- a/frontend/src/metadata/components/map-controller/geolocation.js +++ b/frontend/src/metadata/components/map-controller/geolocation.js @@ -1,12 +1,41 @@ import classnames from 'classnames'; import { Utils } from '../../../utils/utils'; +import { wgs84_to_gcj02 } from '../../../utils/coord-transform'; -export function createBMapGeolocationControl(BMapGL, callback) { +export const createGeolocationControl = (map) => { + const container = document.createElement('div'); + container.className = classnames( + 'sf-map-control-container sf-map-geolocation-control-container d-flex align-items-center justify-content-center', + { 'sf-map-geolocation-control-mobile': !Utils.isDesktop() } + ); + + const button = document.createElement('div'); + button.className = 'sf-map-control sf-map-geolocation-control d-flex align-items-center justify-content-center'; + button.innerHTML = ''; + container.appendChild(button); + + container.addEventListener('click', async (e) => { + e.preventDefault(); + const originalClass = container.className; + container.className = classnames(originalClass, 'sf-map-control-loading'); + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition((userInfo) => { + const gcPosition = wgs84_to_gcj02(userInfo.coords.longitude, userInfo.coords.latitude); + map.setCenter(gcPosition); + }); + } + }); + + return container; +}; + +export function createBMapGeolocationControl({ anchor, offset, callback }) { function GeolocationControl() { - this.defaultAnchor = window.BMAP_ANCHOR_BOTTOM_RIGHT; - this.defaultOffset = new BMapGL.Size(30, Utils.isDesktop() ? 30 : 90); + this.defaultAnchor = anchor || window.BMAP_ANCHOR_BOTTOM_RIGHT; + this.defaultOffset = new window.BMapGL.Size(offset?.x || 30, offset?.y || 90); } - GeolocationControl.prototype = new BMapGL.Control(); + GeolocationControl.prototype = new window.BMapGL.Control(); GeolocationControl.prototype.initialize = function (map) { const div = document.createElement('div'); let className = classnames('sf-map-control-container sf-map-geolocation-control-container d-flex align-items-center justify-content-center', { @@ -21,7 +50,7 @@ export function createBMapGeolocationControl(BMapGL, callback) { div.className = className; div.onclick = (e) => { e.preventDefault(); - const geolocation = new BMapGL.Geolocation(); + const geolocation = new window.BMapGL.Geolocation(); div.className = classnames(className, 'sf-map-control-loading'); geolocation.getCurrentPosition((result) => { div.className = className; diff --git a/frontend/src/metadata/components/map-controller/index.css b/frontend/src/metadata/components/map-controller/index.css index 564cd0bcc5..5279d78dc9 100644 --- a/frontend/src/metadata/components/map-controller/index.css +++ b/frontend/src/metadata/components/map-controller/index.css @@ -37,11 +37,21 @@ background-color: #ccc; } +.sf-map-control-container.sf-map-geolocation-control-container { + right: 30px !important; + bottom: 30px !important; +} + .sf-map-control-container .sf-map-geolocation-control { width: 30px; line-height: 30px; } +.sf-map-control-container.sf-map-zoom-control-container { + right: 66px !important; + bottom: 30px !important; +} + .sf-map-control-container .sf-map-zoom-control { width: 40px; } @@ -64,3 +74,12 @@ .sf-map-control-container .sf-map-control.disabled { color: #ccc; } + +@media (max-width: 768px) { + .sf-map-control-container.sf-map-geolocation-control-container { + bottom: 90px !important; + } + .sf-map-control-container.sf-map-zoom-control-container { + bottom: 90px !important; + } +} diff --git a/frontend/src/metadata/components/map-controller/zoom.js b/frontend/src/metadata/components/map-controller/zoom.js index 9da0e68de1..9f2ec7822f 100644 --- a/frontend/src/metadata/components/map-controller/zoom.js +++ b/frontend/src/metadata/components/map-controller/zoom.js @@ -1,56 +1,97 @@ import classnames from 'classnames'; import { Utils } from '../../../utils/utils'; +import { MIN_ZOOM, MAX_ZOOM } from '../../constants/view/map'; -export function createBMapZoomControl(BMapGL, { maxZoom, minZoom, offset }, callback) { +const buttonClassName = 'sf-map-control sf-map-zoom-control d-flex align-items-center justify-content-center'; + +const createZoomContainer = () => { + const container = document.createElement('div'); + container.className = classnames( + 'sf-map-control-container sf-map-zoom-control-container d-flex align-items-center justify-content-center', + { 'sf-map-control-container-mobile': !Utils.isDesktop() } + ); + return container; +}; + +const createButton = (innerHTML) => { + const button = document.createElement('div'); + button.className = 'sf-map-control sf-map-zoom-control d-flex align-items-center justify-content-center'; + button.innerHTML = innerHTML; + return button; +}; + +const createDivider = () => { + const divider = document.createElement('div'); + divider.className = 'sf-map-control-divider'; + return divider; +}; + +const updateButtonStates = (map, zoomIn, zoomOut) => { + const zoomLevel = map.getZoom(); + zoomIn.className = classnames(buttonClassName, { 'disabled': zoomLevel >= MAX_ZOOM }); + zoomOut.className = classnames(buttonClassName, { 'disabled': zoomLevel <= MIN_ZOOM }); +}; + +export const createZoomControl = (map) => { + const container = createZoomContainer(); + + const zoomInButton = createButton(''); + const divider = createDivider(); + const zoomOutButton = createButton(''); + + container.appendChild(zoomInButton); + container.appendChild(divider); + container.appendChild(zoomOutButton); + + zoomInButton.addEventListener('click', (e) => { + e.preventDefault(); + const nextZoom = Math.min(map.getZoom() + 1, MAX_ZOOM); + map.setZoom(nextZoom); + }); + + zoomOutButton.addEventListener('click', (e) => { + e.preventDefault(); + const nextZoom = Math.max(map.getZoom() - 1, MIN_ZOOM); + map.setZoom(nextZoom); + }); + + map.addListener('zoom_changed', () => updateButtonStates(map, zoomInButton, zoomOutButton)); + + return container; +}; + +export function createBMapZoomControl(anchor, offset) { function ZoomControl() { - this.defaultAnchor = window.BMAP_ANCHOR_BOTTOM_RIGHT; - this.defaultOffset = new BMapGL.Size(offset.x, offset.y); + this.defaultAnchor = anchor || window.BMAP_ANCHOR_BOTTOM_RIGHT; + this.defaultOffset = new window.BMapGL.Size(offset?.x || 66, offset?.y || 30); } - ZoomControl.prototype = new BMapGL.Control(); + ZoomControl.prototype = new window.BMapGL.Control(); ZoomControl.prototype.initialize = function (map) { - const zoomLevel = map.getZoom(); - const div = document.createElement('div'); - div.className = classnames('sf-map-control-container sf-map-zoom-control-container d-flex align-items-center justify-content-center', { - 'sf-map-control-container-mobile': !Utils.isDesktop() - }); + const container = createZoomContainer(); - const buttonClassName = 'sf-map-control sf-map-zoom-control d-flex align-items-center justify-content-center'; - const zoomInButton = document.createElement('div'); - zoomInButton.className = classnames(buttonClassName, { 'disabled': zoomLevel >= maxZoom }); - zoomInButton.innerHTML = ''; - div.appendChild(zoomInButton); + const zoomInButton = createButton(''); + const divider = createDivider(); + const zoomOutButton = createButton(''); - const divider = document.createElement('div'); - divider.className = 'sf-map-control-divider'; - div.appendChild(divider); - - const zoomOutButton = document.createElement('div'); - zoomOutButton.className = classnames(buttonClassName, { 'disabled': zoomLevel <= minZoom }); - zoomOutButton.innerHTML = ''; - div.appendChild(zoomOutButton); - - const updateButtonStates = () => { - const zoomLevel = map.getZoom(); - zoomInButton.className = classnames(buttonClassName, { 'disabled': zoomLevel >= maxZoom }); - zoomOutButton.className = classnames(buttonClassName, { 'disabled': zoomLevel <= minZoom }); - callback && callback(zoomLevel); - }; + container.appendChild(zoomInButton); + container.appendChild(divider); + container.appendChild(zoomOutButton); zoomInButton.onclick = (e) => { e.preventDefault(); const nextZoom = map.getZoom() + 1; - map.zoomTo(Math.min(nextZoom, maxZoom)); + map.zoomTo(Math.min(nextZoom, MAX_ZOOM)); }; zoomOutButton.onclick = (e) => { e.preventDefault(); const nextZoom = map.getZoom() - 1; - map.zoomTo(Math.max(nextZoom, minZoom)); + map.zoomTo(Math.max(nextZoom, MIN_ZOOM)); }; - map.addEventListener('zoomend', updateButtonStates); - map.getContainer().appendChild(div); - return div; + map.addEventListener('zoomend', () => updateButtonStates(map, zoomInButton, zoomOutButton)); + map.getContainer().appendChild(container); + return container; }; return ZoomControl; diff --git a/frontend/src/metadata/components/metadata-details/location/index.css b/frontend/src/metadata/components/metadata-details/location/index.css index 92e0c1bec7..1ac3651832 100644 --- a/frontend/src/metadata/components/metadata-details/location/index.css +++ b/frontend/src/metadata/components/metadata-details/location/index.css @@ -27,3 +27,8 @@ .dirent-detail-item-value-map .sf-map-control-container .sf-map-control-divider::before { height: 14px; } + +.dirent-detail-item-value-map .sf-map-control-container.sf-map-zoom-control-container { + right: 10px !important; + bottom: 16px !important; +} diff --git a/frontend/src/metadata/components/metadata-details/location/index.js b/frontend/src/metadata/components/metadata-details/location/index.js index 7a81891462..fab4b0d3b6 100644 --- a/frontend/src/metadata/components/metadata-details/location/index.js +++ b/frontend/src/metadata/components/metadata-details/location/index.js @@ -14,6 +14,7 @@ import { getColumnDisplayName } from '../../../utils/column'; import { createBMapZoomControl } from '../../map-controller'; import { Utils } from '../../../../utils/utils'; import { eventBus } from '../../../../components/common/event-bus'; +import { createZoomControl } from '../../map-controller/zoom'; import './index.css'; @@ -30,7 +31,6 @@ class Location extends React.Component { this.mapType = type; this.mapKey = key; this.map = null; - this.currentPosition = {}; this.state = { address: '', isLoading: false, @@ -50,12 +50,12 @@ class Location extends React.Component { const { position, record } = this.props; if (!isValidPosition(position?.lng, position?.lat) || typeof record !== 'object') return; if (prevProps.position?.lng === position?.lng && prevProps.position?.lat === position?.lat) return; - this.currentPosition = position; - let convertedPos = wgs84_to_gcj02(position.lng, position.lat); + let transformedPos = wgs84_to_gcj02(position.lng, position.lat); if (this.mapType === MAP_TYPE.B_MAP) { - convertedPos = gcj02_to_bd09(convertedPos.lng, convertedPos.lat); + transformedPos = gcj02_to_bd09(transformedPos.lng, transformedPos.lat); } - this.addMarkerByPosition(convertedPos.lng, convertedPos.lat); + this.addMarkerByPosition(transformedPos.lng, transformedPos.lat); + this.setState({ address: record._location_translated?.address }); } @@ -68,7 +68,6 @@ class Location extends React.Component { const { position, record } = this.props; if (!isValidPosition(position?.lng, position?.lat) || typeof record !== 'object') return; - this.currentPosition = position; this.setState({ isLoading: true, address: record._location_translated?.address }); if (this.mapType === MAP_TYPE.B_MAP) { @@ -156,11 +155,15 @@ class Location extends React.Component { scaleControl: false, streetViewControl: false, rotateControl: false, - fullscreenControl: false + fullscreenControl: false, + disableDefaultUI: true, + gestureHandling: 'cooperative', }); this.map = window.mapInstance; this.addMarkerByPosition(lng, lat); + const zoomControl = createZoomControl(this.map); + this.map.controls[window.google.maps.ControlPosition.RIGHT_BOTTOM].push(zoomControl); this.map.setCenter(gcPosition); }); }; diff --git a/frontend/src/metadata/constants/view/map.js b/frontend/src/metadata/constants/view/map.js index d9511628ff..bb0a0600db 100644 --- a/frontend/src/metadata/constants/view/map.js +++ b/frontend/src/metadata/constants/view/map.js @@ -13,3 +13,11 @@ export const MAP_VIEW_TOOLBAR_MODE = { MAP: 'map', GALLERY: 'gallery', }; + +export const DEFAULT_POSITION = { lng: 104.195, lat: 35.861 }; + +export const DEFAULT_ZOOM = 4; + +export const MAX_ZOOM = 21; + +export const MIN_ZOOM = 3; diff --git a/frontend/src/metadata/metadata-tree-view/view.js b/frontend/src/metadata/metadata-tree-view/view.js index 133d5d2d27..69f7dea4f8 100644 --- a/frontend/src/metadata/metadata-tree-view/view.js +++ b/frontend/src/metadata/metadata-tree-view/view.js @@ -1,7 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { baiduMapKey, gettext } from '../../utils/constants'; +import { baiduMapKey, gettext, googleMapKey } from '../../utils/constants'; import Icon from '../../components/icon'; import ItemDropdownMenu from '../../components/dropdown-menu/metadata-item-dropdown-menu'; import toaster from '../../components/toast'; @@ -77,7 +77,7 @@ const ViewItem = ({ const convertableViews = Object.values(VIEW_TYPE).filter(type => type !== viewType && type !== VIEW_TYPE.FACE_RECOGNITION && - !(type === VIEW_TYPE.MAP && !baiduMapKey) + !(type === VIEW_TYPE.MAP && !baiduMapKey && !googleMapKey) ); value.push({ key: 'turn', diff --git a/frontend/src/metadata/views/map/baidu.js b/frontend/src/metadata/views/map/baidu.js new file mode 100644 index 0000000000..a30071f8fc --- /dev/null +++ b/frontend/src/metadata/views/map/baidu.js @@ -0,0 +1,113 @@ +import { appAvatarURL, mediaUrl } from '../../../utils/constants'; +import { gcj02_to_bd09, wgs84_to_gcj02 } from '../../../utils/coord-transform'; +import { Utils } from '../../../utils/utils'; +import { createBMapGeolocationControl, createBMapZoomControl } from '../../components/map-controller'; +import { MIN_ZOOM, MAX_ZOOM } from '../../constants'; +import { customAvatarOverlay, customImageOverlay } from './overlay'; + +const getPoints = (images) => { + if (!window.Cluster || !images) return []; + return window.Cluster.pointTransformer(images, (data) => ({ + point: [data.location.lng, data.location.lat], + properties: { + id: data.id, + src: data.src, + } + })); +}; + +export const createBaiduMarkerClusterer = (map, images, onClusterLeaveIds) => { + let clickTimeout = null; + const cluster = new window.Cluster.View(map, { + clusterRadius: 60, + updateRealTime: true, + fitViewOnClick: false, + isAnimation: true, + clusterMap: (properties) => ({ src: properties.src, id: properties.id }), + clusterReduce: (acc, properties) => { + if (!acc.properties) { + acc.properties = []; + } + acc.properties.push(properties); + }, + renderClusterStyle: { + type: window.Cluster.ClusterRender.DOM, + style: { offsetX: -40, offsetY: -80 }, + inject: (props) => customImageOverlay(props), + }, + }); + + cluster.setData(getPoints(images)); + + cluster.on(window.Cluster.ClusterEvent.CLICK, (element) => { + if (clickTimeout) { + clearTimeout(clickTimeout); + clickTimeout = null; + return; + } else { + clickTimeout = setTimeout(() => { + let imageIds = []; + if (element.isCluster) { + imageIds = cluster.getLeaves(element.id).map(item => item.properties.id).filter(Boolean); + } else { + imageIds = [element.properties.id]; + } + clickTimeout = null; + onClusterLeaveIds(imageIds); + }, 300); + } + }); + + return cluster; +}; + +export const createBaiduMap = ({ type, center, zoom, onMapState }) => { + if (!window.BMapGL) return; + const map = new window.BMapGL.Map('sf-metadata-map-container', { + enableMapClick: false, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, + mapType: type, + }); + + map.centerAndZoom(center, zoom); + map.enableScrollWheelZoom(true); + + // add controls + const ZoomControl = createBMapZoomControl({ + anchor: window.BMAP_ANCHOR_BOTTOM_RIGHT, + offset: new window.BMapGL.Size(66, Utils.isDesktop() ? 30 : 90), + }); + const zoomControl = new ZoomControl(); + + const GeolocationControl = createBMapGeolocationControl({ + anchor: window.BMAP_ANCHOR_BOTTOM_RIGHT, + offset: new window.BMapGL.Size(30, Utils.isDesktop() ? 30 : 90), + callback: (point) => { + const gcPosition = wgs84_to_gcj02(point.lng, point.lat); + const bdPosition = gcj02_to_bd09(gcPosition.lng, gcPosition.lat); + map.centerAndZoom(new window.BMapGL.Point(bdPosition.lng, bdPosition.lat), map.getZoom()); + } + }); + const geolocationControl = new GeolocationControl(); + + map.addControl(zoomControl); + map.addControl(geolocationControl); + + map.addEventListener('zoomend', () => onMapState()); + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition((userInfo) => { + const gcPosition = wgs84_to_gcj02(userInfo.coords.longitude, userInfo.coords.latitude); + const bdPosition = gcj02_to_bd09(gcPosition.lng, gcPosition.lat); + const { lng, lat } = bdPosition; + const userPosition = new window.BMapGL.Point(lng, lat); + const imageUrl = `${mediaUrl}img/marker.png`; + const avatarMarker = customAvatarOverlay(userPosition, appAvatarURL, imageUrl); + map.addOverlay(avatarMarker); + onMapState(); + }); + } + + return map; +}; diff --git a/frontend/src/metadata/views/map/google.js b/frontend/src/metadata/views/map/google.js new file mode 100644 index 0000000000..d8c4383f38 --- /dev/null +++ b/frontend/src/metadata/views/map/google.js @@ -0,0 +1,103 @@ +import { appAvatarURL, googleMapId, mediaUrl } from '../../../utils/constants'; +import { createGeolocationControl } from '../../components/map-controller/geolocation'; +import { createZoomControl } from '../../components/map-controller/zoom'; +import { MIN_ZOOM, MAX_ZOOM } from '../../constants'; +import { customImageOverlay, googleCustomAvatarOverlay } from './overlay'; + +let clickTimeout = null; + +export const createGoogleMarkerClusterer = (map, images, onClusterLeaveIds) => { + const markers = images.map(image => { + const overlay = customImageOverlay({ isCluster: false, src: image.src }); + overlay.addEventListener('click', () => { + if (clickTimeout) { + clearTimeout(clickTimeout); + clickTimeout = null; + const zoom = map.getZoom(); + map.setZoom(Math.min(zoom + 1, MAX_ZOOM)); + map.setCenter(image.wgs_84); + return; + } + clickTimeout = setTimeout(() => { + onClusterLeaveIds([image.id]); + clickTimeout = null; + }, 300); + }); + + return new window.google.maps.marker.AdvancedMarkerElement({ + position: image.wgs_84, + map, + content: overlay, + }); + }); + + return new window.markerClusterer.MarkerClusterer({ + map, + markers, + renderer: { + render: (cluster) => { + const imagesInBounds = images.filter(image => cluster.bounds.contains(image.wgs_84)); + const overlay = customImageOverlay({ isCluster: true, reduces: { src: imagesInBounds[0].src }, pointCount: cluster.count }); + overlay.addEventListener('click', () => { + if (clickTimeout) { + clearTimeout(clickTimeout); + clickTimeout = null; + const zoom = map.getZoom(); + map.setZoom(Math.min(zoom + 1, MAX_ZOOM)); + map.setCenter(cluster.position); + return; + } + clickTimeout = setTimeout(() => { + const imagesInBounds = images.filter(image => cluster.bounds.contains(image.wgs_84)); + onClusterLeaveIds(imagesInBounds.map(image => image.id)); + clickTimeout = null; + }, 300); + }); + return new window.google.maps.marker.AdvancedMarkerElement({ + position: cluster.position, + content: overlay, + }); + } + }, + onClusterClick: () => {}, + }); +}; + +export const createGoogleMap = ({ center, zoom, onMapState }) => { + if (!window.google?.maps?.Map) return; + + const map = new window.google.maps.Map(document.getElementById('sf-metadata-map-container'), { + mapId: googleMapId, + center, + zoom, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false, + cameraControl: false, + disableDefaultUI: true, + mapTypeId: window.google.maps.MapTypeId.ROADMAP, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, + }); + + const zoomControl = createZoomControl(map); + const geolocationControl = createGeolocationControl(map); + + map.controls[window.google.maps.ControlPosition.RIGHT_BOTTOM].push(zoomControl); + map.controls[window.google.maps.ControlPosition.RIGHT_BOTTOM].push(geolocationControl); + + map.addListener('center_changed', () => onMapState()); + map.addListener('zoom_changed', () => onMapState()); + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition((userInfo) => { + const userPosition = { lat: userInfo.coords.latitude, lng: userInfo.coords.longitude }; + const imageUrl = `${mediaUrl}img/marker.png`; + googleCustomAvatarOverlay(map, userPosition, appAvatarURL, imageUrl); + onMapState(); + }); + } + + return map; +}; + diff --git a/frontend/src/metadata/views/map/index.js b/frontend/src/metadata/views/map/index.js index bece4ef5a8..e08b1e13cb 100644 --- a/frontend/src/metadata/views/map/index.js +++ b/frontend/src/metadata/views/map/index.js @@ -5,29 +5,25 @@ import { useMetadataView } from '../../hooks/metadata-view'; import { Utils } from '../../../utils/utils'; import { isValidPosition } from '../../utils/validate'; import { gcj02_to_bd09, wgs84_to_gcj02 } from '../../../utils/coord-transform'; -import loadBMap, { initMapInfo } from '../../../utils/map-utils'; -import { appAvatarURL, baiduMapKey, fileServerRoot, googleMapKey, mediaUrl, siteRoot, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants'; +import { initMapInfo, loadBMap, loadGMap } from '../../../utils/map-utils'; +import { baiduMapKey, fileServerRoot, googleMapKey, siteRoot, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants'; import { MAP_TYPE as MAP_PROVIDER, PRIVATE_FILE_TYPE } from '../../../constants'; -import { EVENT_BUS_TYPE, MAP_TYPE, PREDEFINED_FILE_TYPE_OPTION_KEY, STORAGE_MAP_CENTER_KEY, STORAGE_MAP_TYPE_KEY, STORAGE_MAP_ZOOM_KEY } from '../../constants'; -import { createBMapGeolocationControl, createBMapZoomControl } from '../../components/map-controller'; -import { customAvatarOverlay, customImageOverlay } from './overlay'; +import { EVENT_BUS_TYPE, MAP_TYPE, PREDEFINED_FILE_TYPE_OPTION_KEY, STORAGE_MAP_CENTER_KEY, STORAGE_MAP_TYPE_KEY, STORAGE_MAP_ZOOM_KEY, DEFAULT_POSITION, DEFAULT_ZOOM } from '../../constants'; import ModalPortal from '../../../components/modal-portal'; import ImageDialog from '../../../components/dialog/image-dialog'; +import { createGoogleMap, createGoogleMarkerClusterer } from './google'; +import { createBaiduMap, createBaiduMarkerClusterer } from './baidu'; import './index.css'; -const DEFAULT_POSITION = { lng: 104.195, lat: 35.861 }; -const DEFAULT_ZOOM = 4; -const MAX_ZOOM = 21; -const MIN_ZOOM = 3; - const Map = () => { const [imageIndex, setImageIndex] = useState(0); const [clusterLeaveIds, setClusterLeaveIds] = useState([]); + const [center, setCenter] = useState(DEFAULT_POSITION); + const [zoom, setZoom] = useState(DEFAULT_ZOOM); const mapRef = useRef(null); - const clusterRef = useRef(null); - const clickTimeoutRef = useRef(null); + const containerRef = useRef(null); const { metadata, viewID, updateCurrentPath } = useMetadataView(); const repoID = window.sfMetadataContext.getSetting('repoID'); @@ -69,7 +65,8 @@ const Map = () => { downloadURL: `${fileServerRoot}repos/${repoID}/files${path}?op=download`, thumbnail, parentDir, - location: bdPosition + location: bdPosition, + wgs_84: { lng, lat }, }; }) .filter(Boolean); @@ -78,7 +75,19 @@ const Map = () => { const mapInfo = useMemo(() => initMapInfo({ baiduMapKey, googleMapKey }), []); const clusterLeaves = useMemo(() => images.filter(image => clusterLeaveIds.includes(image.id)), [images, clusterLeaveIds]); - const getPoints = useCallback(() => { + const onMapState = useCallback(() => { + if (!mapRef.current) return; + const point = mapRef.current.getCenter && mapRef.current.getCenter(); + const zoom = mapRef.current.getZoom && mapRef.current.getZoom(); + window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_CENTER_KEY, point); + window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_ZOOM_KEY, zoom); + }, []); + + const onClusterLeaveIds = useCallback((ids) => { + setClusterLeaveIds(ids); + }, []); + + const getPoints = useCallback((images) => { if (!window.Cluster || !images) return []; return window.Cluster.pointTransformer(images, (data) => ({ point: [data.location.lng, data.location.lat], @@ -87,141 +96,23 @@ const Map = () => { src: data.src, } })); - }, [images]); - - const saveMapState = useCallback(() => { - if (!mapRef.current) return; - const point = mapRef.current.getCenter && mapRef.current.getCenter(); - const zoom = mapRef.current.getZoom && mapRef.current.getZoom(); - window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_CENTER_KEY, point); - window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_ZOOM_KEY, zoom); }, []); - const addMapController = useCallback(() => { - const offset = { x: 66, y: Utils.isDesktop() ? 30 : 90 }; - const ZoomControl = createBMapZoomControl(window.BMapGL, { maxZoom: MAX_ZOOM, minZoom: MIN_ZOOM, offset }, saveMapState); - const zoomControl = new ZoomControl(); - const GeolocationControl = createBMapGeolocationControl(window.BMapGL, (point) => { - point && mapRef.current && mapRef.current.setCenter(point); - }); - - const geolocationControl = new GeolocationControl(); - mapRef.current.addControl(zoomControl); - mapRef.current.addControl(geolocationControl); - }, [saveMapState]); - - const initializeUserMarker = useCallback((centerPoint) => { - if (!window.BMapGL || !mapRef.current) return; - const imageUrl = `${mediaUrl}img/marker.png`; - const avatarMarker = customAvatarOverlay(centerPoint, appAvatarURL, imageUrl); - mapRef.current.addOverlay(avatarMarker); - }, []); - - const getBMapType = useCallback((type) => { - switch (type) { - case MAP_TYPE.SATELLITE: { - return window.BMAP_EARTH_MAP; - } - default: { - return window.BMAP_NORMAL_MAP; - } - } - }, []); - - const loadMapState = useCallback(() => { - const savedCenter = window.sfMetadataContext.localStorage.getItem(STORAGE_MAP_CENTER_KEY) || DEFAULT_POSITION; - const savedZoom = window.sfMetadataContext.localStorage.getItem(STORAGE_MAP_ZOOM_KEY) || DEFAULT_ZOOM; - return { center: savedCenter, zoom: savedZoom }; - }, []); - - const initializeCluster = useCallback(() => { - clusterRef.current = new window.Cluster.View(mapRef.current, { - clusterRadius: 60, - updateRealTime: true, - fitViewOnClick: false, - isAnimation: true, - clusterMap: (properties) => ({ src: properties.src, id: properties.id }), - clusterReduce: (acc, properties) => { - if (!acc.properties) { - acc.properties = []; - } - acc.properties.push(properties); - }, - renderClusterStyle: { - type: window.Cluster.ClusterRender.DOM, - style: { offsetX: -40, offsetY: -80 }, - inject: (props) => customImageOverlay(props), - }, - }); - - clusterRef.current.setData(getPoints()); - - clusterRef.current.on(window.Cluster.ClusterEvent.CLICK, (element) => { - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; - return; - } else { - clickTimeoutRef.current = setTimeout(() => { - let imageIds = []; - if (element.isCluster) { - imageIds = clusterRef.current.getLeaves(element.id).map(item => item.properties.id).filter(Boolean); - } else { - imageIds = [element.properties.id]; - } - clickTimeoutRef.current = null; - setClusterLeaveIds(imageIds); - }, 300); - } - }); - window.BMapCluster = clusterRef.current; - }, [getPoints]); - const renderBaiduMap = useCallback(() => { if (!window.BMapGL.Map) return; - let { center, zoom } = loadMapState(); - let userPosition = { lng: 116.40396418840683, lat: 39.915106021711345 }; - // ask for user location - if (navigator.geolocation) { - navigator.geolocation.getCurrentPosition((userInfo) => { - const gcPosition = wgs84_to_gcj02(userInfo.coords.longitude, userInfo.coords.latitude); - const bdPosition = gcj02_to_bd09(gcPosition.lng, gcPosition.lat); - const { lng, lat } = bdPosition; - userPosition = new window.BMapGL.Point(lng, lat); - center = userPosition; - window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_CENTER_KEY, center); - }); - } - const mapTypeValue = window.sfMetadataContext.localStorage.getItem(STORAGE_MAP_TYPE_KEY); + mapRef.current = createBaiduMap({ images, center, zoom, getPoints, onMapState, onClusterLeaveIds }); + createBaiduMarkerClusterer(mapRef.current, images, onClusterLeaveIds); - if (!window.BMapInstance) { - mapRef.current = new window.BMapGL.Map('sf-metadata-map-container', { - enableMapClick: false, - minZoom: MIN_ZOOM, - maxZoom: MAX_ZOOM, - mapType: getBMapType(mapTypeValue), - }); - window.BMapInstance = mapRef.current; + window.mapViewInstance = mapRef.current; + }, [images, center, zoom, getPoints, onMapState, onClusterLeaveIds]); - if (isValidPosition(center?.lng, center?.lat)) { - mapRef.current.centerAndZoom(center, zoom); - } + const renderGoogleMap = useCallback(() => { + if (!window.google?.maps?.Map) return; + mapRef.current = createGoogleMap({ center, zoom, onMapState }); + createGoogleMarkerClusterer(mapRef.current, images, onClusterLeaveIds); - mapRef.current.enableScrollWheelZoom(true); - addMapController(); - - initializeUserMarker(userPosition); - initializeCluster(); - } else { - const viewDom = document.getElementById('sf-metadata-view-map'); - const container = window.BMapInstance.getContainer(); - viewDom.replaceChild(container, mapRef.current); - - mapRef.current = window.BMapInstance; - clusterRef.current = window.BMapCluster; - clusterRef.current.setData(getPoints()); - } - }, [addMapController, initializeCluster, initializeUserMarker, getBMapType, loadMapState, getPoints]); + window.mapViewInstance = mapRef.current; + }, [images, center, zoom, onMapState, onClusterLeaveIds]); const handleClose = useCallback(() => { setImageIndex(0); @@ -236,21 +127,60 @@ const Map = () => { setImageIndex((imageIndex + 1) % clusterLeaves.length); }, [imageIndex, clusterLeaves.length]); + const onMapTypeChange = useCallback((newType) => { + window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_TYPE_KEY, newType); + + if (mapInfo.type === MAP_PROVIDER.B_MAP) { + const baiduMapType = { + [MAP_TYPE.MAP]: window.BMAP_NORMAL_MAP, + [MAP_TYPE.SATELLITE]: window.BMAP_SATELLITE_MAP + }[newType] || window.BMAP_NORMAL_MAP; + + mapRef.current?.setMapType(baiduMapType); + } else if (mapInfo.type === MAP_PROVIDER.G_MAP) { + const googleMapType = { + [MAP_TYPE.MAP]: window.google.maps.MapTypeId.ROADMAP, + [MAP_TYPE.SATELLITE]: window.google.maps.MapTypeId.HYBRID + }[newType] || window.google.maps.MapTypeId.ROADMAP; + + mapRef.current?.setMapTypeId(googleMapType); + } + + mapRef.current?.setCenter(mapRef.current.getCenter()); + }, [mapInfo.type]); + + const onClearMapInstance = useCallback(() => { + if (window.mapViewInstance) { + if (mapInfo.type === MAP_PROVIDER.B_MAP) { + window.mapViewInstance.destroy(); + } else if (mapInfo.type === MAP_PROVIDER.G_MAP) { + window.mapViewInstance.setMap(null); + } + delete window.mapViewInstance; + } + mapRef.current = null; + }, [mapInfo.type]); + + const loadMapState = useCallback(() => { + const savedCenter = window.sfMetadataContext.localStorage.getItem(STORAGE_MAP_CENTER_KEY) || DEFAULT_POSITION; + const savedZoom = window.sfMetadataContext.localStorage.getItem(STORAGE_MAP_ZOOM_KEY) || DEFAULT_ZOOM; + setCenter(savedCenter); + setZoom(savedZoom); + }, []); + useEffect(() => { updateCurrentPath(`/${PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES}/${viewID}`); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - const modifyMapTypeSubscribe = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_MAP_TYPE, (newType) => { - window.sfMetadataContext.localStorage.setItem(STORAGE_MAP_TYPE_KEY, newType); - const mapType = getBMapType(newType); - mapRef.current && mapRef.current.setMapType(mapType); - mapRef.current.setCenter(mapRef.current.getCenter()); - }); + loadMapState(); + const unsubscribeModifyMapType = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.MODIFY_MAP_TYPE, onMapTypeChange); + const unsubscribeClearMapInstance = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.CLEAR_MAP_INSTANCE, onClearMapInstance); return () => { - modifyMapTypeSubscribe(); + unsubscribeModifyMapType(); + unsubscribeClearMapInstance(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -258,18 +188,34 @@ const Map = () => { useEffect(() => { if (mapInfo.type === MAP_PROVIDER.B_MAP) { - loadBMap(mapInfo.key).then(() => renderBaiduMap()); - return () => { - window.renderMap = null; - }; + if (!window.mapViewInstance) { + loadBMap(mapInfo.key).then(() => renderBaiduMap()); + } else { + const viewDom = document.getElementById('sf-metadata-view-map'); + const container = window.mapViewInstance.getContainer(); + viewDom.replaceChild(container, containerRef.current); + + mapRef.current = window.mapViewInstance; + createBaiduMarkerClusterer(mapRef.current, images, onClusterLeaveIds); + } + } else if (mapInfo.type === MAP_PROVIDER.G_MAP) { + if (!window.mapViewInstance) { + loadGMap(mapInfo.key).then(() => renderGoogleMap()); + } else { + const viewDom = document.getElementById('sf-metadata-view-map'); + const container = window.mapViewInstance.getDiv(); + viewDom.replaceChild(container, containerRef.current); + + mapRef.current = window.mapViewInstance; + createGoogleMarkerClusterer(mapRef.current, images, onClusterLeaveIds); + } } - return; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
-
+
{clusterLeaveIds.length > 0 && ( { +export const customAvatarOverlay = (point, avatarUrl, bgUrl, width = 20, height = 25) => { class AvatarOverlay extends window.BMapGL.Overlay { constructor(point, avatarUrl, bgUrl, width, height) { super(); @@ -44,4 +44,70 @@ const customAvatarOverlay = (point, avatarUrl, bgUrl, width = 20, height = 25) = return new AvatarOverlay(point, avatarUrl, bgUrl, width, height); }; -export default customAvatarOverlay; +export const googleCustomAvatarOverlay = (map, position, avatarUrl, bgUrl, width = 20, height = 25) => { + class AvatarOverlay extends window.google.maps.OverlayView { + constructor(map, position, avatarUrl, bgUrl, width, height) { + super(); + this._position = position; + this._avatarUrl = avatarUrl; + this._bgUrl = bgUrl; + this._width = width; + this._height = height; + this._div = null; + this.setMap(map); + } + + onAdd() { + this.createDomElements(); + const panes = this.getPanes(); + panes.overlayLayer.appendChild(this._div); + } + + draw() { + const overlayProjection = this.getProjection(); + const pos = overlayProjection.fromLatLngToDivPixel(this._position); + + this._div.style.left = `${pos.x - this._width / 2}px`; + this._div.style.top = `${pos.y - (this._height * 7) / 10}px`; + } + + onRemove() { + this._div.parentNode?.removeChild(this._div); + this._div = null; + } + + createDomElements() { + this._div = document.createElement('div'); + this._div.className = 'custom-avatar-overlay'; + Object.assign(this._div.style, { + position: 'absolute', + width: `${this._width}px`, + height: `${this._height}px`, + backgroundImage: `url(${this._bgUrl})`, + backgroundPosition: '.5px 0px', + display: 'flex', + padding: '2px 2.5px 0 2px' + }); + + const img = document.createElement('img'); + img.src = this._avatarUrl; + Object.assign(img.style, { + width: '16px', + height: '16px', + borderRadius: '50%', + display: 'block' + }); + + this._div.appendChild(img); + } + } + + return new AvatarOverlay( + map, + position, + avatarUrl, + bgUrl, + width, + height + ); +}; diff --git a/frontend/src/metadata/views/map/overlay/index.js b/frontend/src/metadata/views/map/overlay/index.js index 21f2d71fa9..2c00730e37 100644 --- a/frontend/src/metadata/views/map/overlay/index.js +++ b/frontend/src/metadata/views/map/overlay/index.js @@ -1,7 +1,8 @@ -import customAvatarOverlay from './custom-avatar-overlay'; +import { customAvatarOverlay, googleCustomAvatarOverlay } from './custom-avatar-overlay'; import customImageOverlay from './custom-image-overlay'; export { customAvatarOverlay, + googleCustomAvatarOverlay, customImageOverlay }; diff --git a/frontend/src/utils/map-utils.js b/frontend/src/utils/map-utils.js index 4b311b31b4..6603378646 100644 --- a/frontend/src/utils/map-utils.js +++ b/frontend/src/utils/map-utils.js @@ -8,26 +8,27 @@ export const initMapInfo = ({ baiduMapKey, googleMapKey, mineMapKey }) => { }; export const loadMapSource = (type, key, callback) => { - if (!type || !key) return; let scriptUrl = ''; const sourceId = 'map-source-script'; + if (document.getElementById(sourceId)) return; - let script = document.createElement('script'); - script.type = 'text/javascript'; - script.id = sourceId; + if (type === MAP_TYPE.B_MAP) { scriptUrl = `https://api.map.baidu.com/api?type=webgl&v=3.0&ak=${key}&callback=renderBaiduMap`; } else if (type === MAP_TYPE.G_MAP) { - scriptUrl = `https://maps.googleapis.com/maps/api/js?key=${key}&callback=renderGoogleMap&libraries=marker&v=weekly`; + scriptUrl = `https://maps.googleapis.com/maps/api/js?key=${key}&libraries=marker,geometry&v=weekly&callback=renderGoogleMap`; } + if (scriptUrl) { + const script = document.createElement('script'); + script.id = sourceId; script.src = scriptUrl; + script.onload = callback; document.body.appendChild(script); } - callback && callback(); }; -export default function loadBMap(ak) { +export function loadBMap(ak) { return new Promise((resolve, reject) => { if (typeof window.BMapGL !== 'undefined' && document.querySelector(`script[src*="${mediaUrl}js/map/cluster.js"]`)) { resolve(true); @@ -57,6 +58,36 @@ export function asyncLoadBaiduJs(ak) { }); } +export function loadGMap(ak) { + return new Promise((resolve, reject) => { + if (typeof window.google !== 'undefined' && document.querySelector(`script[src*="${mediaUrl}js/map/cluster.js"]`)) { + resolve(true); + return; + } + asyncLoadGMapJs(ak) + .then(() => asyncLoadJs('https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js')) + .then(() => resolve(true)) + .catch((err) => reject(err)); + }); +} + +export function asyncLoadGMapJs(key) { + return new Promise((resolve, reject) => { + if (typeof window.google !== 'undefined') { + resolve(window.google); + return; + } + window.renderGoogleMap = function () { + resolve(window.google); + }; + let script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = `https://maps.googleapis.com/maps/api/js?key=${key}&libraries=marker,geometry&v=weekly&callback=renderGoogleMap&loading=async`; + script.onerror = reject; + document.body.appendChild(script); + }); +} + export function asyncLoadJs(url) { return new Promise((resolve, reject) => { let script = document.createElement('script');