From 34790822fea8377b06c452ec8bfe9fb60ca299d4 Mon Sep 17 00:00:00 2001 From: Sergei Korepanov Date: Fri, 26 Apr 2024 15:30:14 +0100 Subject: [PATCH 1/4] feat: (#1113) save map view state --- src/app/(maps)/components/FullScreenMap.tsx | 69 ++++++++++++++++++++- src/components/maps/GlobalMap.tsx | 32 ++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/app/(maps)/components/FullScreenMap.tsx b/src/app/(maps)/components/FullScreenMap.tsx index 1642b840f..096e2baac 100644 --- a/src/app/(maps)/components/FullScreenMap.tsx +++ b/src/app/(maps)/components/FullScreenMap.tsx @@ -1,11 +1,24 @@ 'use client' -import { useEffect, useState } from 'react' -import { GlobalMap } from '@/components/maps/GlobalMap' +import { useCallback, useEffect, useState } from 'react' +import { CameraInfo, GlobalMap } from '@/components/maps/GlobalMap' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' export const FullScreenMap: React.FC = () => { const [initialCenter, setInitialCenter] = useState<[number, number] | undefined>(undefined) + const [initialZoom, setInitialZoom] = useState(undefined) + const router = useRouter() + + const cameraParams = useCameraParams() useEffect(() => { + const initialStateFromUrl = cameraParams.fromUrl() + + if (initialStateFromUrl != null) { + setInitialCenter([initialStateFromUrl.center.lng, initialStateFromUrl.center.lat]) + setInitialZoom(initialStateFromUrl.zoom) + return + } + getVisitorLocation().then((visitorLocation) => { if (visitorLocation != null) { setInitialCenter([visitorLocation.longitude, visitorLocation.latitude]) @@ -15,10 +28,18 @@ export const FullScreenMap: React.FC = () => { }) }, []) + const handleCamerMovement = useCallback((camera: CameraInfo) => { + const url = cameraParams.toUrl(camera) + + router.replace(url, { scroll: false }) + }, []) + return ( ) } @@ -32,3 +53,47 @@ const getVisitorLocation = async (): Promise<{ longitude: number, latitude: numb return undefined } } + +function useCameraParams (): { toUrl: (camera: CameraInfo) => string, fromUrl: () => CameraInfo | null } { + const pathname = usePathname() + const initialSearchParams = useSearchParams() + + function toUrl (camera: CameraInfo): string { + const params = new URLSearchParams(initialSearchParams) + params.delete('camera') + params.append('camera', cameraInfoToQuery(camera)) + + return `${pathname}?${params.toString()}` + } + + function fromUrl (): CameraInfo | null { + const cameraParams = initialSearchParams.get('camera') + if (cameraParams == null) { + return null + } + + return queryToCameraInfo(cameraParams) + } + + return { toUrl, fromUrl } +} + +const cameraInfoToQuery = ({ zoom, center }: CameraInfo): string => { + return `${Math.ceil(zoom)}/${center.lat.toPrecision(3)}/${center.lng.toPrecision(3)}` +} + +const queryToCameraInfo = (cameraParam: string): CameraInfo | null => { + const [zoomRaw, latitude, longitude] = cameraParam.split('/') + const lat = parseFloat(latitude) + const lng = parseFloat(longitude) + const zoom = parseInt(zoomRaw, 10) + + if ([lat, lng, zoom].some(isNaN)) { + return null + } + + return { + center: { lat, lng }, + zoom + } +} diff --git a/src/components/maps/GlobalMap.tsx b/src/components/maps/GlobalMap.tsx index 9ddd057ce..7c7d97d88 100644 --- a/src/components/maps/GlobalMap.tsx +++ b/src/components/maps/GlobalMap.tsx @@ -1,6 +1,6 @@ 'use client' import { useCallback, useState } from 'react' -import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, MapInstance } from 'react-map-gl/maplibre' +import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, MapInstance, MapEvent } from 'react-map-gl/maplibre' import maplibregl, { MapLibreEvent } from 'maplibre-gl' import { Point, Polygon } from '@turf/helpers' import dynamic from 'next/dynamic' @@ -13,6 +13,7 @@ import { OBCustomLayers } from './OBCustomLayers' import { AreaType, ClimbType, MediaWithTags } from '@/js/types' import { TileProps, transformTileProps } from './utils' import MapLayersSelector from './MapLayersSelector' +import { throttle } from 'underscore' export type SimpleClimbType = Pick @@ -28,13 +29,23 @@ export interface HoverInfo { mapInstance: MapInstance } +export interface CameraInfo { + center: { + lng: number + lat: number + } + zoom: number +} + interface GlobalMapProps { showFullscreenControl?: boolean initialCenter?: [number, number] + initialZoom?: number initialViewState?: { bounds: maplibregl.LngLatBoundsLike fitBoundsOptions: maplibregl.FitBoundsOptions } + onCameraMovement?: (camera: CameraInfo) => void children?: React.ReactNode } @@ -42,7 +53,7 @@ interface GlobalMapProps { * Global map */ export const GlobalMap: React.FC = ({ - showFullscreenControl = true, initialCenter, initialViewState, children + showFullscreenControl = true, initialCenter, initialZoom, initialViewState, onCameraMovement, children }) => { const [clickInfo, setClickInfo] = useState(null) const [hoverInfo, setHoverInfo] = useState(null) @@ -51,11 +62,23 @@ export const GlobalMap: React.FC = ({ const [cursor, setCursor] = useState('default') const [mapStyle, setMapStyle] = useState(MAP_STYLES.standard.style) + const onRender = useCallback(throttle((e: MapEvent) => { + const zoom = e.target.getZoom() + const center = e.target.getCenter() + + if (onCameraMovement != null) { + onCameraMovement({ + center, + zoom + }) + } + }, 300), []) + const onLoad = useCallback((e: MapLibreEvent) => { if (e.target == null) return setMapInstance(e.target) - if (initialCenter != null) { - e.target.jumpTo({ center: initialCenter, zoom: 6 }) + if (initialCenter != null && initialZoom != null) { + e.target.jumpTo({ center: initialCenter, zoom: initialZoom }) } else if (initialViewState != null) { e.target.fitBounds(initialViewState.bounds, initialViewState.fitBoundsOptions) } @@ -121,6 +144,7 @@ export const GlobalMap: React.FC = ({ onDragStart={() => { setCursor('move') }} + onRender={onRender} onDragEnd={() => { setCursor('default') }} From 010f3722a38dc7a3cfaf4c63d5a46bc8c66e8795 Mon Sep 17 00:00:00 2001 From: Sergei Korepanov Date: Sun, 28 Apr 2024 18:06:29 +0100 Subject: [PATCH 2/4] feat: (#1113): Increase precision and keep the slash in the url --- src/app/(maps)/components/FullScreenMap.tsx | 10 +++++++--- src/components/maps/GlobalMap.tsx | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/(maps)/components/FullScreenMap.tsx b/src/app/(maps)/components/FullScreenMap.tsx index 096e2baac..12407e899 100644 --- a/src/app/(maps)/components/FullScreenMap.tsx +++ b/src/app/(maps)/components/FullScreenMap.tsx @@ -61,9 +61,13 @@ function useCameraParams (): { toUrl: (camera: CameraInfo) => string, fromUrl: ( function toUrl (camera: CameraInfo): string { const params = new URLSearchParams(initialSearchParams) params.delete('camera') - params.append('camera', cameraInfoToQuery(camera)) - return `${pathname}?${params.toString()}` + const queryParams = [ + params.toString(), + `camera=${cameraInfoToQuery(camera)}` + ] + + return `${pathname}?${queryParams.filter(Boolean).join('&')}` } function fromUrl (): CameraInfo | null { @@ -79,7 +83,7 @@ function useCameraParams (): { toUrl: (camera: CameraInfo) => string, fromUrl: ( } const cameraInfoToQuery = ({ zoom, center }: CameraInfo): string => { - return `${Math.ceil(zoom)}/${center.lat.toPrecision(3)}/${center.lng.toPrecision(3)}` + return `${Math.ceil(zoom)}/${center.lat.toFixed(5)}/${center.lng.toFixed(5)}` } const queryToCameraInfo = (cameraParam: string): CameraInfo | null => { diff --git a/src/components/maps/GlobalMap.tsx b/src/components/maps/GlobalMap.tsx index 7c7d97d88..9296940b2 100644 --- a/src/components/maps/GlobalMap.tsx +++ b/src/components/maps/GlobalMap.tsx @@ -13,7 +13,7 @@ import { OBCustomLayers } from './OBCustomLayers' import { AreaType, ClimbType, MediaWithTags } from '@/js/types' import { TileProps, transformTileProps } from './utils' import MapLayersSelector from './MapLayersSelector' -import { throttle } from 'underscore' +import { debounce } from 'underscore' export type SimpleClimbType = Pick @@ -62,7 +62,7 @@ export const GlobalMap: React.FC = ({ const [cursor, setCursor] = useState('default') const [mapStyle, setMapStyle] = useState(MAP_STYLES.standard.style) - const onRender = useCallback(throttle((e: MapEvent) => { + const onRender = useCallback(debounce((e: MapEvent) => { const zoom = e.target.getZoom() const center = e.target.getCenter() From e522f28aa888e7f04262affa58edb7e26dc701f6 Mon Sep 17 00:00:00 2001 From: Sergei Korepanov Date: Sun, 28 Apr 2024 18:32:54 +0100 Subject: [PATCH 3/4] feat: (#1113): use onMove event for storing view state in the url --- src/components/maps/GlobalMap.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/maps/GlobalMap.tsx b/src/components/maps/GlobalMap.tsx index 9296940b2..f25bd7083 100644 --- a/src/components/maps/GlobalMap.tsx +++ b/src/components/maps/GlobalMap.tsx @@ -1,6 +1,6 @@ 'use client' import { useCallback, useState } from 'react' -import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, MapInstance, MapEvent } from 'react-map-gl/maplibre' +import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, MapInstance, ViewStateChangeEvent } from 'react-map-gl/maplibre' import maplibregl, { MapLibreEvent } from 'maplibre-gl' import { Point, Polygon } from '@turf/helpers' import dynamic from 'next/dynamic' @@ -62,14 +62,14 @@ export const GlobalMap: React.FC = ({ const [cursor, setCursor] = useState('default') const [mapStyle, setMapStyle] = useState(MAP_STYLES.standard.style) - const onRender = useCallback(debounce((e: MapEvent) => { - const zoom = e.target.getZoom() - const center = e.target.getCenter() - + const onMove = useCallback(debounce((e: ViewStateChangeEvent) => { if (onCameraMovement != null) { onCameraMovement({ - center, - zoom + center: { + lat: e.viewState.latitude, + lng: e.viewState.longitude + }, + zoom: e.viewState.zoom }) } }, 300), []) @@ -144,7 +144,7 @@ export const GlobalMap: React.FC = ({ onDragStart={() => { setCursor('move') }} - onRender={onRender} + onMove={onMove} onDragEnd={() => { setCursor('default') }} From 23b9b34f8d0cc9492baee7220ec91648aa78e93d Mon Sep 17 00:00:00 2001 From: Sergei Korepanov Date: Mon, 29 Apr 2024 14:48:52 +0100 Subject: [PATCH 4/4] feat: (#1113): fix geoLocation of a visitor --- src/components/maps/GlobalMap.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/maps/GlobalMap.tsx b/src/components/maps/GlobalMap.tsx index f25bd7083..2841851a3 100644 --- a/src/components/maps/GlobalMap.tsx +++ b/src/components/maps/GlobalMap.tsx @@ -77,12 +77,12 @@ export const GlobalMap: React.FC = ({ const onLoad = useCallback((e: MapLibreEvent) => { if (e.target == null) return setMapInstance(e.target) - if (initialCenter != null && initialZoom != null) { - e.target.jumpTo({ center: initialCenter, zoom: initialZoom }) + if (initialCenter != null) { + e.target.jumpTo({ center: initialCenter, zoom: initialZoom ?? 6 }) } else if (initialViewState != null) { e.target.fitBounds(initialViewState.bounds, initialViewState.fitBoundsOptions) } - }, [initialCenter]) + }, [initialCenter, initialZoom]) /** * Handle click event on the map. Place a market on the map and activate the side drawer.