native/src/components/MapView.tsx
import MapLibreGL, { CameraSettings } from '@maplibre/maplibre-react-native'
import type { BBox, Feature, GeoJsonProperties, Geometry } from 'geojson'
import { Position } from 'geojson'
import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { useTheme } from 'styled-components/native'
import {
animationDuration,
clusterClickZoomFactor,
clusterLayerId,
clusterRadius,
defaultViewportConfig,
calculateDistance,
embedInCollection,
featureLayerId,
LocationType,
mapConfig,
MapFeature,
MIN_DISTANCE_THRESHOLD,
normalDetailZoom,
} from 'shared'
import { LocationFixedIcon, LocationNotFixedIcon, LocationOffIcon } from '../assets'
import { clusterCountLayer, clusterLayer, markerLayer } from '../constants/layers'
import useUserLocation from '../hooks/useUserLocation'
import MapAttribution from './MapsAttribution'
import Icon from './base/Icon'
import IconButton from './base/IconButton'
// Has to be set even if we use map libre
MapLibreGL.setAccessToken(null)
const MapContainer = styled.View`
flex: 1;
flex-direction: row;
justify-content: center;
`
const StyledMap = styled(MapLibreGL.MapView)`
width: 100%;
`
const StyledIcon = styled(IconButton)<{ position: number | string }>`
position: absolute;
right: 0;
bottom: ${props => props.position}${props => (typeof props.position === 'number' ? 'px' : '')};
background-color: ${props => props.theme.colors.themeColor};
margin: 16px;
width: 50px;
height: 50px;
border-radius: 25px;
`
const OverlayContainer = styled.View`
flex: 1;
flex-direction: row;
position: absolute;
top: 24px;
left: 8px;
`
type MapViewProps = {
boundingBox: BBox
features: MapFeature[]
selectedFeature: MapFeature | null
userLocation: LocationType | null
setUserLocation: (userLocation: LocationType | null) => void
iconPosition: string | number
selectFeature: (feature: MapFeature | null) => void
setSheetSnapPointIndex: (index: number) => void
bottomSheetHeight: number
zoom: number | undefined
Overlay?: ReactElement
}
const MapView = ({
boundingBox,
features,
selectedFeature,
iconPosition,
userLocation,
setUserLocation,
selectFeature,
setSheetSnapPointIndex,
Overlay,
bottomSheetHeight,
zoom,
}: MapViewProps): ReactElement => {
const cameraRef = useRef<MapLibreGL.Camera>(null)
const mapRef = useRef<MapLibreGL.MapView>(null)
const [followUserLocation, setFollowUserLocation] = useState<boolean>(false)
const { refreshPermissionAndLocation } = useUserLocation({ requestPermissionInitially: true })
const { t } = useTranslation('pois')
const theme = useTheme()
const bounds = {
ne: [boundingBox[2], boundingBox[3]],
sw: [boundingBox[0], boundingBox[1]],
}
const coordinates = selectedFeature?.geometry.coordinates
const defaultZoom = coordinates ? normalDetailZoom : defaultViewportConfig.zoom
const defaultSettings: CameraSettings = {
zoomLevel: zoom ?? defaultZoom,
centerCoordinate: coordinates,
bounds: coordinates ? undefined : bounds,
}
const moveTo = useCallback(
(location: Position, zoomLevel = normalDetailZoom) =>
cameraRef.current?.setCamera({
centerCoordinate: location,
zoomLevel,
animationDuration,
padding: { paddingBottom: bottomSheetHeight },
}),
[bottomSheetHeight],
)
const onRequestLocation = useCallback(async () => {
if (userLocation) {
moveTo(userLocation)
setFollowUserLocation(true)
}
await refreshPermissionAndLocation()
}, [refreshPermissionAndLocation, moveTo, userLocation])
useEffect(() => {
if (selectedFeature) {
moveTo(selectedFeature.geometry.coordinates)
setFollowUserLocation(false)
}
}, [moveTo, selectedFeature])
const zoomOnClusterPress = async (pressedCoordinates: [number, number]) => {
const clusterCollection = await mapRef.current?.queryRenderedFeaturesAtPoint(pressedCoordinates, undefined, [
clusterLayerId,
])
if (clusterCollection && 0 in clusterCollection.features && mapRef.current) {
const feature = clusterCollection.features[0] as MapFeature
moveTo(feature.geometry.coordinates, (await mapRef.current.getZoom()) + clusterClickZoomFactor)
}
}
const onPress = async (pressedLocation: Feature<Geometry, GeoJsonProperties>) => {
setFollowUserLocation(false)
if (!mapRef.current || !pressedLocation.properties) {
return
}
const pressedCoordinates: [number, number] = [
pressedLocation.properties.screenPointX,
pressedLocation.properties.screenPointY,
]
const featureCollection = await mapRef.current.queryRenderedFeaturesAtPoint(pressedCoordinates, undefined, [
featureLayerId,
])
const feature = featureCollection?.features.find((it): it is MapFeature => it.geometry.type === 'Point')
selectFeature(feature ?? null)
setSheetSnapPointIndex(1)
zoomOnClusterPress(pressedCoordinates)
}
const updateUserLocation = (location: MapLibreGL.Location) => {
const newUserLocation: [number, number] = [location.coords.longitude, location.coords.latitude]
// Avoid frequent rerenders if distance only changes minimally
if (!userLocation || calculateDistance(userLocation, newUserLocation) > MIN_DISTANCE_THRESHOLD) {
setUserLocation(newUserLocation)
}
}
const locationPermissionGrantedIcon = followUserLocation ? LocationFixedIcon : LocationNotFixedIcon
const locationPermissionIcon = userLocation ? locationPermissionGrantedIcon : LocationOffIcon
return (
<MapContainer>
<StyledMap
styleJSON={mapConfig.styleJSON}
zoomEnabled
onPress={onPress}
ref={mapRef}
onStartShouldSetResponder={() => true}
onResponderMove={() => setFollowUserLocation(false)}
attributionEnabled={false}
logoEnabled={false}>
<MapLibreGL.UserLocation visible={!!userLocation} onUpdate={updateUserLocation} />
<MapLibreGL.ShapeSource
id='location-pois'
shape={embedInCollection(features)}
cluster
clusterRadius={clusterRadius}>
<MapLibreGL.SymbolLayer {...clusterCountLayer} />
<MapLibreGL.CircleLayer {...clusterLayer(theme)} />
<MapLibreGL.SymbolLayer {...markerLayer(selectedFeature)} />
</MapLibreGL.ShapeSource>
<MapLibreGL.Camera defaultSettings={defaultSettings} followUserMode='normal' ref={cameraRef} />
</StyledMap>
<OverlayContainer>{Overlay}</OverlayContainer>
<MapAttribution />
<StyledIcon
icon={<Icon Icon={locationPermissionIcon} />}
onPress={onRequestLocation}
position={iconPosition}
accessibilityLabel={t('showOwnLocation')}
/>
</MapContainer>
)
}
export default MapView