diff --git a/src/app/(maps)/components/FullScreenMap.tsx b/src/app/(maps)/components/FullScreenMap.tsx index 1642b840f..12407e899 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,51 @@ 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') + + const queryParams = [ + params.toString(), + `camera=${cameraInfoToQuery(camera)}` + ] + + return `${pathname}?${queryParams.filter(Boolean).join('&')}` + } + + 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.toFixed(5)}/${center.lng.toFixed(5)}` +} + +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..2841851a3 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, ViewStateChangeEvent } 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 { debounce } 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,15 +62,27 @@ export const GlobalMap: React.FC = ({ const [cursor, setCursor] = useState('default') const [mapStyle, setMapStyle] = useState(MAP_STYLES.standard.style) + const onMove = useCallback(debounce((e: ViewStateChangeEvent) => { + if (onCameraMovement != null) { + onCameraMovement({ + center: { + lat: e.viewState.latitude, + lng: e.viewState.longitude + }, + zoom: e.viewState.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 }) + 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. @@ -121,6 +144,7 @@ export const GlobalMap: React.FC = ({ onDragStart={() => { setCursor('move') }} + onMove={onMove} onDragEnd={() => { setCursor('default') }}