Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions src/components/MapGeocoderSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { Close, Search } from '@mui/icons-material'
import {
Box,
CircularProgress,
IconButton,
InputAdornment,
List,
ListItemButton,
ListItemText,
Paper,
TextField,
Typography,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'

import { type GeocodeResult, geocodePlaces } from '@/utils/geocode'

const MIN_QUERY_LENGTH = 3
const DEBOUNCE_MS = 300

interface MapGeocoderSearchProps {
onSelect: (result: GeocodeResult) => void
onClear?: () => void
/** Map center used to bias results toward what the user is looking at. */
proximity?: [number, number]
placeholder?: string
}

export const MapGeocoderSearch = ({
onSelect,
onClear,
proximity,
placeholder = 'Search place, address, or ZIP',
}: MapGeocoderSearchProps) => {
const [value, setValue] = useState('')
const [debouncedValue, setDebouncedValue] = useState('')
const [isOpen, setIsOpen] = useState(false)
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
const timeout = setTimeout(() => setDebouncedValue(value), DEBOUNCE_MS)
return () => clearTimeout(timeout)
}, [value])

useEffect(
() => () => {
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
},
[]
)

const trimmedQuery = debouncedValue.trim()
const isQueryable = trimmedQuery.length >= MIN_QUERY_LENGTH

// Rounded so small map movements do not invalidate the cached query.
const proximityKey = useMemo(
() =>
proximity
? `${proximity[0].toFixed(2)},${proximity[1].toFixed(2)}`
: 'none',
[proximity]
)

const {
data: results = [],
isFetching,
isError,
} = useQuery({
queryKey: ['photon-geocode', trimmedQuery, proximityKey],
queryFn: ({ signal }) => geocodePlaces(trimmedQuery, { proximity, signal }),
enabled: isQueryable,
staleTime: 5 * 60 * 1000,
})

const clear = () => {
setValue('')
setDebouncedValue('')
setIsOpen(false)
onClear?.()
}

const select = (result: GeocodeResult) => {
setValue(result.label)
setDebouncedValue(result.label)
setIsOpen(false)
onSelect(result)
}

const showDropdown = isOpen && isQueryable
const hasNoResults = !isFetching && !isError && results.length === 0

return (
<Box sx={{ position: 'relative' }}>
<TextField
size="small"
fullWidth
value={value}
placeholder={placeholder}
inputProps={{ 'aria-label': 'Search the map for a place or address' }}
onMouseDown={(event) => event.stopPropagation()}
onChange={(event) => {
setValue(event.target.value)
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onBlur={() => {
// Delay so a result click registers before the dropdown unmounts.
blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 150)
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
clear()
return
}
if (event.key === 'Enter' && results[0]) {
event.preventDefault()
select(results[0])
}
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
{isFetching ? <CircularProgress size={16} /> : null}
{value ? (
<IconButton
size="small"
aria-label="Clear map search"
onMouseDown={(event) => event.preventDefault()}
onClick={clear}
>
<Close fontSize="small" />
</IconButton>
) : null}
</InputAdornment>
),
}}
/>
{showDropdown && (
<Paper
elevation={6}
sx={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
maxHeight: 260,
overflowY: 'auto',
zIndex: 3,
}}
>
{isError ? (
<Typography variant="body2" sx={{ px: 1.5, py: 1 }}>
Search is unavailable right now.
</Typography>
) : hasNoResults ? (
<Typography variant="body2" sx={{ px: 1.5, py: 1 }}>
No matches found.
</Typography>
) : (
<>
<List dense disablePadding>
{results.map((result) => (
<ListItemButton
key={result.id}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => select(result)}
>
<ListItemText
primary={result.label}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
))}
</List>
<Typography
variant="caption"
sx={{
display: 'block',
px: 1.5,
py: 0.5,
color: 'text.secondary',
}}
>
Search by Photon &middot; &copy; OpenStreetMap contributors
</Typography>
</>
)}
</Paper>
)}
</Box>
)
}

export default MapGeocoderSearch
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export * from './ProtectedRoute'
export * from './VisuallyHiddenTextField'
export * from './WellStatusChips'
export * from './WIPAlert'
export * from './MapGeocoderSearch'
93 changes: 89 additions & 4 deletions src/pages/ocotillo/map/list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { captureEvent } from '@/analytics/posthog'
import { Layer, Source } from 'react-map-gl/maplibre'
import { Layer, Marker, Source } from 'react-map-gl/maplibre'
import { useDataProvider, useGo } from '@refinedev/core'
import type { CustomParams } from '@refinedev/core'
import { useLocation } from 'react-router'
Expand Down Expand Up @@ -37,7 +37,7 @@ import {
PiperDiagram,
type PiperDiagramHandle,
} from '@/components/PiperDiagram'
import { MapPopup } from '@/components'
import { MapGeocoderSearch, MapPopup } from '@/components'
import { useMeasuredHeight, useThingLayers, useViewportBbox } from '@/hooks'
import { DEFAULT_BASEMAP_ID } from '@/basemaps'
import {
Expand All @@ -57,6 +57,7 @@ import {
getDistinctMapPoints,
getMapPointBounds,
} from '@/utils/mapPointInteraction'
import type { GeocodeResult } from '@/utils/geocode'

function localDateStampForExport(): string {
const d = new Date()
Expand Down Expand Up @@ -265,6 +266,11 @@ export const MapView: React.FC = () => {
getExpandedGroupsForLayers(initialVisibleLayers)
)
const [popupContent, setPopupContent] = useState<any>(null)
const [geocodeMarker, setGeocodeMarker] = useState<{
longitude: number
latitude: number
label: string
} | null>(null)
const [exportFormat, setExportFormat] = useState<'csv' | 'geojson'>('csv')
const [selectionPolygons, setSelectionPolygons] = useState<
Record<string, any>
Expand Down Expand Up @@ -526,11 +532,59 @@ export const MapView: React.FC = () => {
}
}, [visibleFeaturesPage, visiblePointFeaturesByLayer])
const hasExportableLayers = exportableLayers.length > 0
const { ref: geocoderPanelRef, height: geocoderPanelHeight } =
useMeasuredHeight<HTMLDivElement>([], 52)
const { ref: basemapPanelRef, height: basemapPanelHeight } =
useMeasuredHeight<HTMLDivElement>([basemapCollapsed, selectedBasemap], 52)
const layersPanelTop = 12 + basemapPanelHeight
const basemapPanelTop = 12 + geocoderPanelHeight + 8
const layersPanelTop = basemapPanelTop + basemapPanelHeight
const layersPanelMaxHeight = `calc(100% - ${layersPanelTop}px - 12px)`

// Bias geocoder results toward the part of the map the user is looking at.
const geocoderProximity = useMemo<[number, number] | undefined>(() => {
if (!viewportBbox) return undefined

const [west, south, east, north] = viewportBbox.split(',').map(Number)
if ([west, south, east, north].some((value) => !Number.isFinite(value))) {
return undefined
}

return [(west + east) / 2, (south + north) / 2]
}, [viewportBbox])

const onGeocodeSelect = (result: GeocodeResult) => {
const map = mapRef.current?.getMap?.()
if (!map) return

setPopupContent(null)
setGeocodeMarker({
longitude: result.center[0],
latitude: result.center[1],
label: result.label,
})

if (result.bbox) {
const [west, south, east, north] = result.bbox
map.fitBounds(
[
[west, south],
[east, north],
],
{ padding: 80, maxZoom: 14, duration: 800 }
)
} else {
map.easeTo({
center: result.center,
zoom: Math.max(map.getZoom(), 13),
duration: 800,
})
}

captureEvent('map_geocoder_result_selected', {
has_bbox: Boolean(result.bbox),
})
}

const downloadLayerBlob = (
content: BlobPart,
contentType: string,
Expand Down Expand Up @@ -1015,16 +1069,47 @@ export const MapView: React.FC = () => {
/>
</Source>
) : null}
{geocodeMarker ? (
<Marker
longitude={geocodeMarker.longitude}
latitude={geocodeMarker.latitude}
color="#d32f2f"
/>
) : null}
</MapComponent>
</Box>
<Paper
elevation={6}
ref={basemapPanelRef}
ref={geocoderPanelRef}
sx={(theme) => ({
position: 'absolute',
top: 12,
left: 12,
width: { xs: 'calc(100% - 24px)', sm: 320 },
px: 0.8,
py: 0.6,
borderRadius: 1.25,
backdropFilter: 'blur(6px)',
backgroundColor: alpha(theme.palette.background.paper, 0.9),
border: '1px solid',
borderColor: alpha(theme.palette.divider, 0.9),
zIndex: 3,
})}
>
<MapGeocoderSearch
onSelect={onGeocodeSelect}
onClear={() => setGeocodeMarker(null)}
proximity={geocoderProximity}
/>
</Paper>
<Paper
elevation={6}
ref={basemapPanelRef}
sx={(theme) => ({
position: 'absolute',
top: basemapPanelTop,
left: 12,
width: { xs: 'calc(100% - 24px)', sm: 320 },
display: 'flex',
flexDirection: 'column',
overflow: basemapCollapsed ? 'visible' : 'hidden',
Expand Down
Loading
Loading