Skip to content

Instantly share code, notes, and snippets.

@thomasgwatson
Last active December 4, 2020 00:55
Show Gist options
  • Save thomasgwatson/b9349da97bbc75f845beb3942ff1a39c to your computer and use it in GitHub Desktop.
Save thomasgwatson/b9349da97bbc75f845beb3942ff1a39c to your computer and use it in GitHub Desktop.
Recent work sample
import styles from './waypointChecker.css'
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import queryString from 'query-string'
import { useSelector } from 'react-redux'
import Layout from '../../components/Layout'
import MapPanel from '../../components/MapPanel'
import Map from '../../components/Map'
import FormValueOnChangeWrapper from '../../components/forms/FormValueOnChangeWrapper'
import { useEnsureLastKnownRigs } from '../../hooks/useEnsureRigs'
import { useEventuallyEnsureCoverageAreas } from '../../hooks/useEnsureCampaigns'
import Text from '../../components/Text'
import { usePrepareDriveRequestsForMap } from '../../hooks/usePrepareDriveRequestsForMap'
import FormControl from '../../components/forms/FormControl'
import CheckboxGroup from '../../components/forms/CheckboxGroup'
import Checkbox from '../../components/forms/CheckboxGroup/Checkbox'
import Button from '../../components/Button'
import RigIdAutocomplete from '../../components/RigIdAutocomplete'
import NumberInput from '../../components/forms/NumberInput'
import { useEnsureOlpToken } from '../../hooks/useEnsureOlpToken'
import { useFetchWaypoints } from '../../hooks/useFetchWaypoints'
import { useRouter } from '../../hooks/useRouter'
import { usePrepareQueryParamsForRadius } from '../../hooks/usePrepareQueryParamsForRadius'
import { selectAllRigIds } from '../../selectors/selectAllRigIds'
import selectOLPToken from '../../selectors/selectOLPToken'
import { selectAllRigs } from '../../selectors/selectAllRigs'
import { selectDriveRequests } from '../../selectors/selectDriveRequests'
import { usePrepareRigsForMap } from '../../hooks/usePrepareRigsForMap'
import { usePrepareManyUncollectedCoveragesForMap } from '../../hooks/usePrepareManyUncollectedCoveragesForMap'
import { usePrepareWaypointsForMap } from '../../hooks/usePrepareWaypointsForMap'
import createRadiusGeoJson from '../../utils/geo/createRadiusGeoJson'
import geoJsonToBbox from '../../utils/geo/bbox'
import RigSecondaryMarkerIcon from '../../components/icons/RigSecondaryMarkerIcon'
import isEmpty from 'lodash-es/isEmpty'
import SimpleTitle from '../../components/TopicSummary/simpleTitle'
import InfoIcon from '../../components/icons/InfoIcon'
import Tooltip from '../../components/Tooltip'
const NONCOVERAGELAYERS = 'Non-Covered Roads'
const DRIVEREQUESTLAYER = 'Drive Request Outlines'
const RIGSLAYERS = 'Rig Markers'
const ANNOTATIONSLAYER = 'Annotation Markers'
const WAYPOINTSLAYER = 'Waypoint Markers'
const RADIUSLAYER = 'Radius Layer'
const DEFAULT_RADIUS = 50000
const MAX_RADIUS = 500000
const DEFAULT_WAYPOINTS = 300
const MAX_WAYPOINTS = 10000
/**
* Waypoint Checker Page
*/
function WaypointChecker() {
const [layersToggle, setLayersToggle] = useState([
// this order determines the order of when layers are rendered on the map
DRIVEREQUESTLAYER,
NONCOVERAGELAYERS,
RADIUSLAYER,
RIGSLAYERS,
ANNOTATIONSLAYER,
WAYPOINTSLAYER
])
const { push, query, pathname } = useRouter()
// Get values directly from the URL query string, and use them below to fetch waypoints
const { lat, lng, radius, limit, rig } = query
const initialRadius = radius || DEFAULT_RADIUS
const initialLimit = limit || DEFAULT_WAYPOINTS
// Since coordinates can be changed from two different sub-components (map, and request-waypoint form), and
// state should only be maintained in one place, we will maintain it here, in the parent component
const [workingLat, setWorkingLat] = useState(lat)
const [workingLng, setWorkingLng] = useState(lng)
const [workingRadius, setWorkingRadius] = useState(initialRadius)
// Selector hooks
const OLPToken = useSelector(selectOLPToken)
const rigIds = useSelector(selectAllRigIds)
const rigs = useSelector(selectAllRigs)
const driveRequests = useSelector(selectDriveRequests)
// Ensure fetching hooks
useEnsureOlpToken(OLPToken)
useEnsureLastKnownRigs(rigs, OLPToken)
useEventuallyEnsureCoverageAreas(driveRequests)
// direct requests (does not use store)
const { waypoints, error: waypointError, fetching: fetchingWaypoints } = useFetchWaypoints({
lng,
lat,
radius: initialRadius,
limit: initialLimit,
rigId: rig
})
//convert data into layers
const radiusLayer = usePrepareQueryParamsForRadius(
{ lng: workingLng, lat: workingLat, radius: workingRadius },
layersToggle.includes(WAYPOINTSLAYER)
)
const waypointsLayer = usePrepareWaypointsForMap(waypoints, layersToggle.includes(WAYPOINTSLAYER))
const { coverageAreaLayer, mouseState } = usePrepareDriveRequestsForMap(
driveRequests,
layersToggle.includes(DRIVEREQUESTLAYER)
)
const { rigsLayer, rigMouseState } = usePrepareRigsForMap(rigs, layersToggle.includes(RIGSLAYERS))
const nonCoverageLayers = usePrepareManyUncollectedCoveragesForMap(
driveRequests,
layersToggle.includes(NONCOVERAGELAYERS)
)
const isLoading =
(layersToggle.includes(NONCOVERAGELAYERS) && isEmpty(nonCoverageLayers)) ||
(layersToggle.includes(DRIVEREQUESTLAYER) && isEmpty(coverageAreaLayer)) ||
(layersToggle.includes(RIGSLAYERS) && isEmpty(rigsLayer)) ||
fetchingWaypoints
const bounds = useInitialViewport({ lat, lng, radius: initialRadius })
const mapLayers = [coverageAreaLayer, radiusLayer, nonCoverageLayers, rigsLayer, waypointsLayer] // controls order in which layers are rendered by Deck.gl
const handleFormSubmission = (formValues) => push(`${pathname}?${queryString.stringify(formValues)}`)
/**
* Used to update the lat/lng state here when it's updated via the map
* @param mapEvent event from DeckGL
*/
const onMapClickAction = (mapEvent) => {
if (mapEvent && mapEvent.coordinate && mapEvent.coordinate.length === 2) {
const coordinateArray = mapEvent.coordinate
setWorkingLng(coordinateArray[0].toString())
setWorkingLat(coordinateArray[1].toString())
} else {
console.error('onMapClickAction: Problem getting coordinate array from mapEvent', mapEvent)
}
}
/**
* Used to update the lat/lng state here when it's updated via the form
* @param lat
* @param lng
*/
const onCoordinateUpdateInForm = ({ lat, lng, radius }) => {
setWorkingLng(lng)
setWorkingLat(lat)
setWorkingRadius(radius)
}
const combinedMouseState =
rigMouseState.mouseOverInfo && rigMouseState.mouseOverInfo.object ? rigMouseState : mouseState
return (
<div style={{ position: 'relative' }}>
<Layout>
<div className={styles['top-section']}>
<TitleLayout loading={isLoading} />
</div>
<div className={styles['bottom-section']}>
<div className={styles['map-controls-bottom']}>
<div className={styles['left-column']}>
<MapLayout
mapLayers={mapLayers}
mouseState={combinedMouseState}
bounds={bounds}
onMapClickAction={onMapClickAction}
/>
</div>
<div className={styles['right-column']}>
<TogglePanel layersToggle={layersToggle} setLayersToggle={setLayersToggle} />
<WaypointRequestPanel
limit={initialLimit}
lat={workingLat}
radius={workingRadius}
lng={workingLng}
rigIds={rigIds}
handleSubmitForm={handleFormSubmission}
handleUpdateCoordinates={onCoordinateUpdateInForm}
/>
</div>
</div>
</div>
</Layout>
</div>
)
}
/**
* Get the title section of the page
*/
const TitleLayout = ({ loading }) => (
<div style={{ gridRow: '1 / 13', gridColumn: '1 / span 20' }}>
<SimpleTitle title={'Waypoint Checker'} loading={loading} />
</div>
)
TitleLayout.propTypes = {
loading: PropTypes.bool
}
/**
* Get the Map section of the page
*/
const MapLayout = ({ mapLayers, mouseState, bounds, onMapClickAction }) => (
<div style={{ height: '100%', overflow: 'hidden' }}>
<MapPanel>
<Map
bounds={bounds}
layers={mapLayers}
mouseOverInfo={mouseState && mouseState.mouseOverInfo}
onMapClick={onMapClickAction}
/>
</MapPanel>
</div>
)
const useInitialViewport = ({ radius, lng, lat }) => {
const [bounds, setBounds] = useState([])
useEffect(() => {
const parsedRadius = parseFloat(radius)
const parsedLat = parseFloat(lat)
const parsedLng = parseFloat(lng)
if (isNaN(parsedRadius) || isNaN(parsedLat) || isNaN(parsedLng)) {
// return generic bounds
setBounds([
[42.422453, 59.349769],
[-124.9187997, 11.8182872]
])
} else {
// return viewport bound to the radius
const radiusGeoJson = createRadiusGeoJson({ radius, lat, lng })
const bbox = geoJsonToBbox(radiusGeoJson)
setBounds([
[bbox.maxLongitude, bbox.maxLatitude],
[bbox.minLongitude, bbox.minLatitude]
])
}
}, []) // this is left empty to intentionally ensure this function only runs once
return bounds
}
MapLayout.propTypes = {
mapLayers: PropTypes.array,
mouseState: PropTypes.object,
bounds: PropTypes.array,
onMapClickAction: PropTypes.func
}
const TogglePanel = ({ layersToggle, setLayersToggle }) => (
<div style={{ height: '180px' }}>
<FormControl label={'Map layers'}>
<div style={{ display: 'grid', gridTemplateColumns: '40px 1fr', gridTemplateRows: '120px', height: 'auto' }}>
<div
style={{
display: 'grid',
gridTemplateRows: '30px 30px 30px 30px',
justifyItems: 'center',
alignItems: 'center',
backgroundColor: '#EDEDED',
margin: '2px 6px 2px 6px'
}}>
<div style={{ display: 'flex' }}>
<div style={{ height: '20px', width: '2px', backgroundColor: 'var(--red80)', marginRight: '4px' }} />{' '}
<div style={{ height: '20px', width: '3px', backgroundColor: 'var(--yellow80)', marginRight: '4px' }} />{' '}
<div style={{ height: '20px', width: '2px', backgroundColor: 'var(--orange80)' }} />
</div>
<div>
<div
style={{
height: '10px',
width: '12px',
backgroundColor: 'var(--lightblue40-a60)',
border: '1px solid var(--2020-blue50)'
}}></div>
</div>
<div>
<RigSecondaryMarkerIcon width={12} height={16} color={'var(--gray60)'} />
</div>
<div>
<div
style={{
height: '8px',
width: '8px',
borderRadius: '50%',
backgroundColor: 'var(--2020-aqua40)',
border: '1px solid #121212'
}}></div>
</div>
</div>
<FormValueOnChangeWrapper defaultValue={layersToggle} onChange={setLayersToggle}>
{(value, handleChange) => (
<CheckboxGroup containerStyle={{ flexDirection: 'column', height: 'auto' }} onChange={handleChange}>
{[NONCOVERAGELAYERS, DRIVEREQUESTLAYER, RIGSLAYERS, WAYPOINTSLAYER].map((x) => (
<Checkbox
style={{ paddingBottom: '2px', paddingTop: '2px' }}
key={x}
value={x}
checked={value.includes(x)}>
{`${x}`}
{x === NONCOVERAGELAYERS ? (
<Tooltip
content={'Red: Rig-assigned segments, Yellow: High priority segments, Orange: Normal segments'}>
<InfoIcon />
</Tooltip>
) : null}
</Checkbox>
))}
</CheckboxGroup>
)}
</FormValueOnChangeWrapper>
</div>
</FormControl>
</div>
)
TogglePanel.propTypes = {
layersToggle: PropTypes.array,
setLayersToggle: PropTypes.func
}
const checkFormValidity = ({ radius, lat, formLimit, lng }) => {
if (!parseInt(radius) || parseInt(radius) > MAX_RADIUS) return false
if (!parseFloat(lng)) return false
if (!parseFloat(lat)) return false
if (!parseInt(formLimit) || parseInt(formLimit) > 10000) return false
return true
}
/**
* Get an appropriate error message for the given input
*
* Note that just because a
* @param radius assumes it's a number (that is, that non-digits have been stripped)
* @param limit assumes it's a nuber
* @return an error message for the form, or undefined if no error
*/
const getErrorMessage = ({ radius, limit }) => {
if (limit > MAX_WAYPOINTS || limit <= 0) {
return 'Max waypoints must be from 1 to ' + MAX_WAYPOINTS
} else if (radius > MAX_RADIUS || radius <= 0) {
return 'Radius must be from 1 to ' + MAX_RADIUS
} else {
return undefined
}
}
function getSuitableInteger(newInteger) {
const digitOnlyValue = newInteger.replace(/\D+/g, '')
return isEmpty(digitOnlyValue) ? undefined : parseInt(digitOnlyValue)
}
/**
* The state of lat & lng are kept in the calling component; state of others are kept within this component
* @param rigIds
* @param lat
* @param lng
* @param radius
* @param limit
* @param rig
* @param handleSubmitForm
* @param handleUpdateCoordinates
*/
const WaypointRequestPanel = ({ rigIds, lat, lng, radius, limit, rig, handleSubmitForm, handleUpdateCoordinates }) => {
const [formLimit, setLimit] = useState(limit)
const [formRig, setRig] = useState(rig)
const [errorMsg, setErrorMsg] = useState()
const formValid = checkFormValidity({ radius, lat, formLimit, lng })
return (
<div style={{ paddingTop: '8px', display: 'flex', flexDirection: 'column' }}>
<Text size={'md'}>Request Waypoints</Text>
<div style={{ paddingTop: '12px', display: 'grid', gridTemplateColumns: '128px 160px 10px' }}>
<span style={{ paddingTop: '6px' }}>Latitude</span>
<NumberInput value={lat} onChange={(newLat) => handleUpdateCoordinates({ lat: newLat, lng, radius })} />
<Tooltip content={'Click on map to reset Latitude and Longitude'}>
<InfoIcon />
</Tooltip>
</div>
<div style={{ paddingTop: '8px', display: 'grid', gridTemplateColumns: '128px 160px 10px' }}>
<span style={{ paddingTop: '6px' }}>Longitude</span>
<NumberInput value={lng} onChange={(newLng) => handleUpdateCoordinates({ lat, lng: newLng, radius })} />
<Tooltip content={'Click on map to reset Latitude and Longitude'}>
<InfoIcon />
</Tooltip>
</div>
<div style={{ paddingTop: '8px', display: 'grid', gridTemplateColumns: '128px 160px 10px' }}>
<span style={{ paddingTop: '6px' }}>Radius (meters):</span>
<NumberInput
value={radius}
onChange={(newInteger) => {
const returnValue = getSuitableInteger(newInteger)
setErrorMsg(getErrorMessage({ radius: returnValue, limit: formLimit }))
handleUpdateCoordinates({ lat, lng, radius: returnValue })
}}
placeholder={'Max value: ' + MAX_RADIUS}
/>
<Tooltip content={'Radius limit is ' + MAX_RADIUS}>
<InfoIcon />
</Tooltip>
</div>
<div style={{ paddingTop: '8px', display: 'grid', gridTemplateColumns: '128px 160px 10px' }}>
<span style={{ paddingTop: '6px' }}>Max waypoints:</span>
<NumberInput
value={formLimit}
onChange={(newInteger) => {
const returnValue = getSuitableInteger(newInteger)
setErrorMsg(getErrorMessage({ radius, limit: returnValue }))
setLimit(returnValue)
}}
placeholder={'Max value: ' + MAX_WAYPOINTS}
/>
<Tooltip content={'Waypoint limit is ' + MAX_WAYPOINTS}>
<InfoIcon />
</Tooltip>
</div>
<FormControl label={'Rig selection (optional)'}>
<div style={{ width: '240px', paddingBottom: '24px' }}>
{formRig ? (
<FormControl>
<div style={{ display: 'inline-flex' }}>
<Text>{formRig}</Text>
<div style={{ marginLeft: '100px' }}>
<Button secondary size="sm" onClick={() => setRig(undefined)}>
{'reset?'}
</Button>
</div>
</div>
</FormControl>
) : (
<RigIdAutocomplete onSelect={(selectedRigId) => setRig(selectedRigId)} rigIds={rigIds} />
)}
</div>
</FormControl>
<Button
disabled={!formValid}
onClick={() => handleSubmitForm({ lng, lat, rig: formRig, radius, limit: formLimit })}
style={{ width: '120px' }}>
Request
</Button>
<div className={styles['form-error']}>{errorMsg}</div>
</div>
)
}
WaypointRequestPanel.propTypes = {
rigIds: PropTypes.array,
lat: PropTypes.string,
lng: PropTypes.string,
radius: PropTypes.string,
limit: PropTypes.string,
rig: PropTypes.string,
handleSubmitForm: PropTypes.func,
handleUpdateCoordinates: PropTypes.func
}
WaypointChecker.propTypes = {
coverageAreasObject: PropTypes.object,
rigs: PropTypes.object,
rigIds: PropTypes.array,
driveRequests: PropTypes.array,
olpObject: PropTypes.object
}
export default WaypointChecker
import { useEffect, useState } from 'react'
import waypointsApiCaller from '../apiCallers/waypoints'
import toNumber from 'lodash-es/toNumber'
import toInteger from 'lodash-es/toInteger'
export function useFetchWaypoints({ lng, lat, radius, limit, rigId }) {
const [waypoints, setWaypoints] = useState(null)
const [error, setError] = useState(null)
const [fetching, setFetching] = useState(false)
useEffect(() => {
if (isNaN(lng) || isNaN(lat) || isNaN(radius) || isNaN(limit)) return
const fetchWaypoints = async () => {
setFetching(true)
try {
const massagedInput = {
lat: toNumber(lat),
lng: toNumber(lng),
radius: isNaN(radius) ? undefined : toNumber(radius),
limit: isNaN(limit) ? undefined : toInteger(limit),
rigId
}
const response = await waypointsApiCaller.fetch(massagedInput)
setWaypoints(response)
setError(undefined)
setFetching(false)
} catch (error) {
console.log('error', error) // eslint-disable-line no-console
setWaypoints(undefined)
setError(error)
setFetching(false)
}
}
fetchWaypoints()
}, [lng, lat, radius, limit, rigId])
return { waypoints, error, fetching }
}
import axios from 'axios'
import appConfig from '../../utils/appConfig'
const GAPS_ENDPOINT = appConfig['GAPS_SERVICE_ENDPOINT']
const GET_WAYPOINTS_JSON_TARGET = `/waypoints.json`
const config = {
headers: {
'x-api-key': appConfig['GAPS_SERVICE_KEY'],
'Cache-Control': 'nocache',
'Content-Type': 'application/json'
}
}
/**
* Service to get waypoints from the gaps-to-drive service
*/
const waypoints = {
fetch({ lng, lat, radius = 5000, limit = 200, rigId = null }) {
// validate data, and throw error if invalid input is found
if (isNaN(lat)) {
throw TypeError('passed in lat value is not a number: ' + lat)
} else if (isNaN(lng)) {
throw TypeError('passed in lng value is not a number: ' + lng)
} else if (isNaN(radius) || radius <= 0) {
throw TypeError('passed in radius is not valid - must be positive number: ' + radius)
} else if (isNaN(limit) || limit <= 0) {
throw TypeError('passed in limit is not valid - must be positive number: ' + limit)
}
// Make the call and return the promise
const payload = { latitude: lat, longitude: lng, radius, limit, rigId }
return axios.post(GAPS_ENDPOINT + GET_WAYPOINTS_JSON_TARGET, payload, config).then(({ data }) => data)
}
}
export default waypoints
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment