Skip to content

Commit

Permalink
feat: custom popover added
Browse files Browse the repository at this point in the history
  • Loading branch information
clintonlunn committed May 23, 2024
1 parent 66987e8 commit 41b5154
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,10 @@ export const AreaLatLngForm: React.FC<{ initLat: number, initLng: number, uuid:
</DialogTrigger>
<DialogContent title={`Pick location for ${areaName}`} fullScreen={!!isMobile}>
<div className='w-full h-100vh'>
<div className='h-[90vh] w-full'>
<div className='h-[90vh] lg:h-[50vh] w-full'>
<CoordinatePickerMap
initialCenter={[initLng, initLat]}
initialZoom={14}
onCoordinateConfirmed={(coord) => {
onCoordinateConfirmed={() => {
setPickerSelected(false)
}}
/>
Expand Down
94 changes: 43 additions & 51 deletions src/components/maps/CoordinatePickerMap.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
'use client'
import { useCallback, useState } from 'react'
import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, ViewStateChangeEvent, Marker, Popup, MarkerDragEvent } from 'react-map-gl/maplibre'
import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, Marker, MapInstance, MarkerDragEvent } from 'react-map-gl/maplibre'
import maplibregl, { MapLibreEvent } from 'maplibre-gl'
import dynamic from 'next/dynamic'
import { debounce } from 'underscore'

import { useDebouncedCallback } from 'use-debounce'
import { MAP_STYLES, type MapStyles } from './MapSelector'
import { useFormContext } from 'react-hook-form'
import MapLayersSelector from './MapLayersSelector'
import { MapPin } from '@phosphor-icons/react/dist/ssr'
import { CoordinatePickerPopup } from './CoordinatePickerPopup'

export interface CameraInfo {
center: {
Expand All @@ -20,65 +21,58 @@ export interface CameraInfo {
interface CoordinatePickerMapProps {
showFullscreenControl?: boolean
initialCenter?: [number, number]
initialZoom?: number
initialViewState?: {
bounds: maplibregl.LngLatBoundsLike
fitBoundsOptions: maplibregl.FitBoundsOptions
}
onCameraMovement?: (camera: CameraInfo) => void
onCoordinateConfirmed?: (coordinates: [number, number] | null) => void
name?: string
children?: React.ReactNode
}

/**
* Map for picking coordinates to update the AreaLatLngForm component.
* Allows user to place a marker on the map and confirm the selection.
*/
export const CoordinatePickerMap: React.FC<CoordinatePickerMapProps> = ({
showFullscreenControl = true, initialCenter, initialZoom, initialViewState, onCameraMovement, onCoordinateConfirmed, children
showFullscreenControl = true, initialCenter, onCoordinateConfirmed
}) => {
const [selectedCoord, setSelectedCoord] = useState<[number, number] | null>(null)
const [selectedCoord, setSelectedCoord] = useState({ lng: 0, lat: 0 })
const [cursor, setCursor] = useState<string>('default')
const [mapStyle, setMapStyle] = useState<string>(MAP_STYLES.standard.style)
const [mapInstance, setMapInstance] = useState<MapInstance | null>(null)
const [popupOpen, setPopupOpen] = useState(false)
const initialZoom = 14

const { setValue } = useFormContext()

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: initialZoom ?? 6 })
} else if (initialViewState != null) {
e.target.fitBounds(initialViewState.bounds, initialViewState.fitBoundsOptions)
}
}, [initialCenter, initialZoom])
}, [initialCenter])

const updateCoordinates = useDebouncedCallback((lng, lat) => {
setSelectedCoord({ lng, lat })
setPopupOpen(true)
}, 100)

/**
* Handle click event on the map. Place or replace a marker.
*/
const onClick = useCallback((event: MapLayerMouseEvent): void => {
const { lngLat } = event
setSelectedCoord([lngLat.lng, lngLat.lat])
}, [])
setPopupOpen(false)
updateCoordinates(lngLat.lng, lngLat.lat)
}, [updateCoordinates])

const onMarkerDragEnd = (event: MarkerDragEvent): void => {
const { lngLat } = event
setPopupOpen(false)
updateCoordinates(lngLat.lng, lngLat.lat)
}

const confirmSelection = (): void => {
if (selectedCoord != null) {
setValue('latlngStr', `${selectedCoord[1].toFixed(5)},${selectedCoord[0].toFixed(5)}`, { shouldDirty: true, shouldValidate: true })
}
if ((onCoordinateConfirmed != null) && (selectedCoord != null)) {
onCoordinateConfirmed(selectedCoord)
setValue('latlngStr', `${selectedCoord.lat.toFixed(5)},${selectedCoord.lng.toFixed(5)}`, { shouldDirty: true, shouldValidate: true })
if (onCoordinateConfirmed != null) {
onCoordinateConfirmed([selectedCoord.lng, selectedCoord.lat])
}
setPopupOpen(false)
}
}

Expand All @@ -87,21 +81,19 @@ export const CoordinatePickerMap: React.FC<CoordinatePickerMapProps> = ({
setMapStyle(style.style)
}

const onMarkerDragEnd = (event: MarkerDragEvent): void => {
const { lngLat } = event
setSelectedCoord([lngLat.lng, lngLat.lat])
}

return (
<div className='relative w-full h-full'>
<Map
id='coordinate-picker-map'
onLoad={onLoad}
onDragStart={() => {
setPopupOpen(false)
setCursor('move')
}}
onMove={onMove}
onDragEnd={() => {
if (selectedCoord != null) {
setPopupOpen(true)
}
setCursor('default')
}}
onClick={onClick}
Expand All @@ -110,23 +102,23 @@ export const CoordinatePickerMap: React.FC<CoordinatePickerMapProps> = ({
cooperativeGestures={showFullscreenControl}
>
<MapLayersSelector emit={updateMapLayer} />

<ScaleControl unit='imperial' style={{ marginBottom: 10 }} position='bottom-left' />
<ScaleControl unit='metric' style={{ marginBottom: 0 }} position='bottom-left' />
{showFullscreenControl && <FullscreenControl />}
<NavigationControl showCompass={false} position='bottom-right' />
{(selectedCoord != null) && (
<>
<Marker longitude={selectedCoord[0]} latitude={selectedCoord[1]} draggable onDragEnd={onMarkerDragEnd} />
<Popup longitude={selectedCoord[0]} latitude={selectedCoord[1]} closeOnClick={false} anchor='top'>
<div>
<p>Coordinates: {selectedCoord[1].toFixed(5)}, {selectedCoord[0].toFixed(5)}</p>
<button className='btn btn-primary' onClick={confirmSelection}>Confirm</button>
</div>
</Popup>
<Marker longitude={selectedCoord.lng} latitude={selectedCoord.lat} draggable onDragEnd={onMarkerDragEnd}>
<MapPin size={36} weight='fill' className='text-accent' />
</Marker>
<CoordinatePickerPopup
info={{ coordinates: selectedCoord, mapInstance }}
onConfirm={confirmSelection}
onClose={() => setPopupOpen(false)}
open={popupOpen}
/>
</>
)}
{children}
</Map>
</div>
)
Expand Down
58 changes: 58 additions & 0 deletions src/components/maps/CoordinatePickerPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as Popover from '@radix-ui/react-popover'
import { useCallback } from 'react'
import { MapInstance } from 'react-map-gl'

interface CoordinatePickerPopupProps {
info: {
coordinates: { lng: number, lat: number }
mapInstance: MapInstance | null
}
onConfirm: () => void
onClose: () => void
open: boolean
}

export const CoordinatePickerPopup: React.FC<CoordinatePickerPopupProps> = ({ info, onConfirm, onClose, open }) => {
const { coordinates, mapInstance } = info
const { lng: longitude, lat: latitude } = coordinates
const screenXY = mapInstance?.project(coordinates)

const handleConfirmClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onConfirm()
}, [onConfirm])

if (screenXY == null) return null

return (
<Popover.Root open={open}>
<Popover.Anchor style={{ position: 'absolute', left: screenXY?.x, top: screenXY?.y + -10 }} />
<Popover.Content
align='center'
side='top'
sideOffset={8}
collisionPadding={24}
className='z-[50] focus:outline-none cursor-pointer p-4 bg-white rounded shadow-md'
onClick={(e) => e.stopPropagation()}
>
<div className='text-center'>
<p className='text-sm'>Coordinates: {latitude.toFixed(5)}, {longitude.toFixed(5)}</p>
<div className='flex justify-center mt-2'>
<button
className='btn btn-primary mr-2'
onClick={handleConfirmClick}
>
Confirm
</button>
<button
className='btn btn-secondary'
onClick={onClose}
>
Cancel
</button>
</div>
</div>
</Popover.Content>
</Popover.Root>
)
}

0 comments on commit 41b5154

Please sign in to comment.