react-mapfilter
Version:
These components are designed for viewing data in Mapeo. They share a common interface:
279 lines (256 loc) • 7.82 kB
JavaScript
// @flow
import React, {
useState,
useMemo,
useCallback,
useRef,
useEffect,
useImperativeHandle
} from 'react'
import { useIntl, IntlProvider } from 'react-intl'
import ReactMapboxGl from 'react-mapbox-gl'
import mapboxgl from 'mapbox-gl'
import type { Observation } from 'mapeo-schema'
import { getLastImage } from '../utils/helpers'
import { makeStyles } from '@material-ui/core/styles'
import ObservationLayer from './ObservationLayer'
import Popup from './Popup'
import type { CameraOptions, CommonViewContentProps } from '../types'
export type MapViewContentProps = {
/** Called with
* [CameraOptions](https://docs.mapbox.com/mapbox-gl-js/api/#cameraoptions)
* with properties `center`, `zoom`, `bearing`, `pitch` */
onMapMove?: CameraOptions => any,
/** Initial position of the map - an object with properties `center`, `zoom`,
* `bearing`, `pitch`. If this is not set then the map will by default zoom to
* the bounds of the observations. If you are going to unmount and re-mount
* this component (e.g. within tabs) then you will want to use onMove to store
* the position in state, and pass it as initialPosition for when the map
* re-mounts. */
initialMapPosition?: $Shape<CameraOptions>,
/** Mapbox access token */
mapboxAccessToken: string,
/** Mapbox style url */
mapStyle?: any
}
type Props = {
...$Exact<MapViewContentProps>,
...$Exact<CommonViewContentProps>,
print?: boolean
}
export type MapInstance = {
fitBounds: () => any,
flyTo: () => any
}
const useStyles = makeStyles({
container: {
flex: 1
},
'@global': {
// The "Improve Map" link does not work when Mapeo Desktop is used offline,
// and since the data the user is looking at is mainly data that is not in
// OpenStreetMap, this link does not make much sense to the user.
'.mapbox-improve-map': {
display: 'none'
}
}
})
const fitBoundsOptions = {
duration: 0,
padding: 10
}
const noop = () => {}
const MapViewContent = (
{
observations,
mapboxAccessToken,
getPreset,
getMedia,
onClick,
initialMapPosition = {},
onMapMove = noop,
mapStyle = 'mapbox://styles/mapbox/outdoors-v10',
print = false
}: Props,
ref
) => {
const map = useRef()
const classes = useStyles()
const intl = useIntl()
const [hovered, setHovered] = useState<?Observation>(null)
const [styleLoaded, setStyleLoaded] = useState(false)
useImperativeHandle(ref, () => ({
fitBounds: (...args: any) => {
if (!map.current) return
map.current.fitBounds.apply(map.current, args)
},
flyTo: (...args: any) => {
if (!map.current) return
map.current.flyTo.apply(map.current, args)
}
}))
// We don't want to change the map viewport if the observations array changes,
// which it will do if the filter changes. We only set the bounds for the very
// initial render, and only if initialMapPosition zoom and center are not set.
const initialBounds = useMemo(
() =>
initialMapPosition.center == null && initialMapPosition.zoom == null
? getBounds(observations)
: undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
useEffect(() => {
if (
!map.current ||
!observations ||
!observations.length ||
map.current.__hasMoved ||
(initialMapPosition.center != null && initialMapPosition.zoom != null)
)
return
map.current.__hasMoved = true
const bounds = getBounds(observations)
map.current.flyTo({
center: [
bounds[0][0] + (bounds[1][0] - bounds[0][0]) / 2,
bounds[0][1] + (bounds[1][1] - bounds[0][1]) / 2
],
zoom: 9,
bearing: 0,
pitch: 0
})
}, [
initialMapPosition.center,
initialMapPosition.zoom,
observations,
styleLoaded
])
// We don't allow the map to be a controlled component - position can only be
// set when the map is initially mounted and after that state is internal
const position = useMemo(() => {
const { center, zoom, bearing, pitch } = initialMapPosition
const bounds = getBounds(observations)
// initialMapPosition overrides default behaviour of fitting the map to the
// bounds of the observations, but if any properties of initialMapPosition are
// we set some default values
return {
center: center || [
bounds[0][0] + (bounds[1][0] - bounds[0][0]) / 2,
bounds[0][1] + (bounds[1][1] - bounds[0][1]) / 2
],
zoom: zoom ? [zoom] : [9],
bearing: bearing ? [bearing] : [0],
pitch: pitch ? [pitch] : [0]
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const Mapbox = useMemo(
() =>
ReactMapboxGl({
accessToken: mapboxAccessToken,
dragRotate: false,
pitchWithRotate: false,
attributionControl: false,
logoPosition: 'bottom-right',
scrollZoom: !print,
injectCSS: false
}),
[mapboxAccessToken, print]
)
const handleStyleLoad = useCallback(mapInstance => {
mapInstance.addControl(new mapboxgl.NavigationControl({}))
mapInstance.addControl(new mapboxgl.ScaleControl({}))
mapInstance.addControl(
new mapboxgl.AttributionControl({
compact: true
})
)
map.current = mapInstance
setStyleLoaded(true)
}, [])
const handleMouseMove = useCallback(
e => {
if (e.features.length === 0) return setHovered(null)
const obs = observations.find(
obs => obs.id === e.features[0].properties.id
)
setHovered(obs)
},
[observations]
)
const handleMouseLeave = useCallback(e => {
setHovered(null)
}, [])
const handleMapMove = useCallback(
(map, e) => {
onMapMove({
center: map.getCenter().toArray(),
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch()
})
},
[onMapMove]
)
function getLastImageUrl(observation: Observation): string | void {
const lastImageAttachment = getLastImage(observation)
if (!lastImageAttachment) return
const media = getMedia(lastImageAttachment, {
width: Popup.imageSize,
height: Popup.imageSize
})
if (media) return media.src
}
function getName(observation: Observation): string {
const preset = getPreset(observation)
return (preset && preset.name) || 'Observation'
}
return (
<Mapbox
style={mapStyle}
className={classes.container}
fitBounds={initialBounds}
fitBoundsOptions={fitBoundsOptions}
onStyleLoad={handleStyleLoad}
onMove={handleMapMove}
{...position}>
<ObservationLayer
observations={observations}
onClick={onClick}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
print={print}
/>
{hovered && (
<Popup
imageUrl={getLastImageUrl(hovered)}
title={getName(hovered)}
subtitle={intl.formatTime(hovered.created_at, {
year: 'numeric',
month: 'long',
day: '2-digit'
})}
coordinates={
// $FlowFixMe - these are always non-nullish when on a map
[hovered.lon, hovered.lat]
}
/>
)}
</Mapbox>
)
}
export default React.forwardRef<Props, MapInstance>(MapViewContent)
function getBounds(
observations: Observation[]
): [[number, number], [number, number]] {
const extent = [[-180, -85], [180, 85]]
for (const { lat, lon } of observations) {
if (lon == null || lat == null) continue
if (extent[0][0] < lon) extent[0][0] = lon
if (extent[0][1] < lat) extent[0][1] = lat
if (extent[1][0] > lon) extent[1][0] = lon
if (extent[1][1] > lat) extent[1][1] = lat
}
return extent
}