Last active
December 4, 2020 00:55
-
-
Save thomasgwatson/b9349da97bbc75f845beb3942ff1a39c to your computer and use it in GitHub Desktop.
Recent work sample
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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