diff --git a/src/components/MapGeocoderSearch.tsx b/src/components/MapGeocoderSearch.tsx new file mode 100644 index 00000000..c1f6875a --- /dev/null +++ b/src/components/MapGeocoderSearch.tsx @@ -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 | 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 ( + + 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: ( + + + + ), + endAdornment: ( + + {isFetching ? : null} + {value ? ( + event.preventDefault()} + onClick={clear} + > + + + ) : null} + + ), + }} + /> + {showDropdown && ( + + {isError ? ( + + Search is unavailable right now. + + ) : hasNoResults ? ( + + No matches found. + + ) : ( + <> + + {results.map((result) => ( + event.stopPropagation()} + onClick={() => select(result)} + > + + + ))} + + + Search by Photon · © OpenStreetMap contributors + + + )} + + )} + + ) +} + +export default MapGeocoderSearch diff --git a/src/components/index.ts b/src/components/index.ts index 94d02e9f..3da9e3f6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -32,3 +32,4 @@ export * from './ProtectedRoute' export * from './VisuallyHiddenTextField' export * from './WellStatusChips' export * from './WIPAlert' +export * from './MapGeocoderSearch' diff --git a/src/pages/ocotillo/map/list.tsx b/src/pages/ocotillo/map/list.tsx index 6f2eae0e..e28e592b 100644 --- a/src/pages/ocotillo/map/list.tsx +++ b/src/pages/ocotillo/map/list.tsx @@ -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' @@ -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 { @@ -57,6 +57,7 @@ import { getDistinctMapPoints, getMapPointBounds, } from '@/utils/mapPointInteraction' +import type { GeocodeResult } from '@/utils/geocode' function localDateStampForExport(): string { const d = new Date() @@ -265,6 +266,11 @@ export const MapView: React.FC = () => { getExpandedGroupsForLayers(initialVisibleLayers) ) const [popupContent, setPopupContent] = useState(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 @@ -526,11 +532,59 @@ export const MapView: React.FC = () => { } }, [visibleFeaturesPage, visiblePointFeaturesByLayer]) const hasExportableLayers = exportableLayers.length > 0 + const { ref: geocoderPanelRef, height: geocoderPanelHeight } = + useMeasuredHeight([], 52) const { ref: basemapPanelRef, height: basemapPanelHeight } = useMeasuredHeight([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, @@ -1015,16 +1069,47 @@ export const MapView: React.FC = () => { /> ) : null} + {geocodeMarker ? ( + + ) : null} ({ 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, + })} + > + setGeocodeMarker(null)} + proximity={geocoderProximity} + /> + + ({ + position: 'absolute', + top: basemapPanelTop, + left: 12, + width: { xs: 'calc(100% - 24px)', sm: 320 }, display: 'flex', flexDirection: 'column', overflow: basemapCollapsed ? 'visible' : 'hidden', diff --git a/src/test/utils/geocode.test.ts b/src/test/utils/geocode.test.ts new file mode 100644 index 00000000..da751ac8 --- /dev/null +++ b/src/test/utils/geocode.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' + +import { + normalizePhotonFeatures, + type PhotonFeature, + type PhotonProperties, +} from '@/utils/geocode' + +const feature = ( + properties: PhotonProperties, + coordinates = [-106.9, 34.06] +): PhotonFeature => ({ + geometry: { type: 'Point', coordinates }, + properties: { countrycode: 'US', ...properties }, +}) + +describe('normalizePhotonFeatures', () => { + it('composes a label from Photon address components', () => { + const results = normalizePhotonFeatures([ + feature({ + osm_type: 'N', + osm_id: 1, + housenumber: '801', + street: 'Leroy Place', + city: 'Socorro', + state: 'New Mexico', + postcode: '87801', + }), + ]) + + expect(results).toHaveLength(1) + expect(results[0].label).toBe('801 Leroy Place, Socorro, New Mexico, 87801') + expect(results[0].center).toEqual([-106.9, 34.06]) + }) + + it('reorders the Photon extent into west/south/east/north', () => { + const [result] = normalizePhotonFeatures([ + feature({ + osm_type: 'R', + osm_id: 2, + name: 'Socorro', + state: 'New Mexico', + extent: [-106.95, 34.1, -106.83, 34.0], + }), + ]) + + expect(result.bbox).toEqual([-106.95, 34.0, -106.83, 34.1]) + }) + + it('drops repeated components from the label', () => { + const [result] = normalizePhotonFeatures([ + feature({ name: 'Socorro', city: 'Socorro', state: 'New Mexico' }), + ]) + + expect(result.label).toBe('Socorro, New Mexico') + }) + + it('appends the Photon type when two results share a label', () => { + const results = normalizePhotonFeatures([ + feature({ name: 'Socorro', state: 'New Mexico', type: 'city' }), + feature({ name: 'Socorro', state: 'New Mexico', type: 'county' }), + feature({ name: 'Magdalena', state: 'New Mexico', type: 'city' }), + ]) + + expect(results.map((result) => result.label)).toEqual([ + 'Socorro, New Mexico (city)', + 'Socorro, New Mexico (county)', + 'Magdalena, New Mexico', + ]) + }) + + it('skips non-US results, coordinate-less features, and bad input', () => { + const results = normalizePhotonFeatures([ + feature({ name: 'Socorro', countrycode: 'ES' }), + { properties: { countrycode: 'US', name: 'No geometry' } }, + feature({ postcode: undefined, name: undefined, street: undefined }), + ]) + + expect(results).toEqual([]) + expect(normalizePhotonFeatures(undefined)).toEqual([]) + }) +}) diff --git a/src/utils/geocode.ts b/src/utils/geocode.ts new file mode 100644 index 00000000..be074e6f --- /dev/null +++ b/src/utils/geocode.ts @@ -0,0 +1,191 @@ +import axios from 'axios' + +/** + * Forward geocoding against Photon, komoot's OpenStreetMap-backed search. + * + * Photon is key-free, like every other tile and data source in `basemaps.ts`, + * and unlike Nominatim its usage policy permits search-as-you-type. Results + * are OpenStreetMap data, so anything user-facing owes an "© OpenStreetMap + * contributors" credit. + */ +const PHOTON_URL = 'https://photon.komoot.io/api' + +/** Photon has no country filter, so US results are selected client side. */ +const COUNTRY_CODE = 'us' + +/** The subset of Photon's `properties` payload this module reads. */ +export type PhotonProperties = { + osm_id?: number | string + osm_type?: string + name?: string + housenumber?: string + street?: string + city?: string + district?: string + locality?: string + county?: string + state?: string + postcode?: string + countrycode?: string + /** Photon's own classification: city, county, state, street, house, … */ + type?: string + extent?: unknown +} + +export type PhotonFeature = { + geometry?: { type?: string; coordinates?: unknown } + properties?: PhotonProperties +} + +export type GeocodeResult = { + id: string + label: string + center: [number, number] + bbox?: [number, number, number, number] +} + +const isLonLat = (value: unknown): value is [number, number] => + Array.isArray(value) && + typeof value[0] === 'number' && + typeof value[1] === 'number' + +/** + * Photon reports `extent` as [minLon, maxLat, maxLon, minLat] — north and + * south are swapped relative to the [west, south, east, north] order that + * MapLibre's `fitBounds` expects. + */ +const toBbox = ( + extent: unknown +): [number, number, number, number] | undefined => { + if ( + !Array.isArray(extent) || + extent.length !== 4 || + !extent.every((entry) => typeof entry === 'number') + ) { + return undefined + } + + const [west, north, east, south] = extent as number[] + return [west, south, east, north] +} + +/** + * Photon returns address components rather than a single formatted string, so + * the display label is composed here: the most specific name first, then the + * containing place, state, and postcode, skipping repeats. + */ +const buildLabel = (properties: PhotonProperties | undefined): string => { + const street = properties?.street + ? [properties?.housenumber, properties.street].filter(Boolean).join(' ') + : undefined + const primary = properties?.name ?? street ?? properties?.postcode + + if (!primary) return '' + + const parts = [ + primary, + properties?.name ? street : undefined, + properties?.city ?? properties?.district ?? properties?.locality, + properties?.county, + properties?.state, + properties?.postcode, + ] + + const seen = new Set() + return parts + .filter((part): part is string => Boolean(part)) + .filter((part) => { + if (seen.has(part)) return false + seen.add(part) + return true + }) + .join(', ') +} + +/** + * A query like "socorro" matches both the city and the county, and both + * compose to the same label. Where that happens, Photon's own classification + * is appended so the two rows are told apart. + */ +const disambiguate = ( + entries: { result: GeocodeResult; kind?: string }[] +): GeocodeResult[] => { + const labelCounts = new Map() + for (const { result } of entries) { + labelCounts.set(result.label, (labelCounts.get(result.label) ?? 0) + 1) + } + + return entries.map(({ result, kind }) => + kind && (labelCounts.get(result.label) ?? 0) > 1 + ? { ...result, label: `${result.label} (${kind})` } + : result + ) +} + +export const normalizePhotonFeatures = ( + features: readonly PhotonFeature[] | null | undefined +): GeocodeResult[] => { + if (!Array.isArray(features)) return [] + + const entries = features.flatMap((feature, index) => { + const center = feature?.geometry?.coordinates + if (!isLonLat(center)) return [] + + const properties = feature?.properties + if (String(properties?.countrycode ?? '').toLowerCase() !== COUNTRY_CODE) { + return [] + } + + const label = buildLabel(properties) + if (!label) return [] + + const bbox = toBbox(properties?.extent) + + return [ + { + result: { + id: `${properties?.osm_type ?? 'x'}${properties?.osm_id ?? ''}-${index}`, + label, + center: [center[0], center[1]] as [number, number], + ...(bbox ? { bbox } : {}), + }, + kind: properties?.type, + }, + ] + }) + + return disambiguate(entries) +} + +/** + * Forward geocode a free-text place query (address, town, landmark, ZIP). + * Results are biased toward the current map center when `proximity` is given. + */ +export const geocodePlaces = async ( + query: string, + options: { + proximity?: [number, number] + limit?: number + signal?: AbortSignal + } = {} +): Promise => { + const trimmed = query.trim() + if (!trimmed) return [] + + const limit = options.limit ?? 5 + + const response = await axios.get(PHOTON_URL, { + params: { + q: trimmed, + lang: 'en', + // Over-fetch so the client-side US filter can still fill the list. + limit: limit * 3, + ...(options.proximity + ? { lon: options.proximity[0], lat: options.proximity[1] } + : {}), + }, + signal: options.signal, + }) + + return normalizePhotonFeatures(response.data?.features).slice(0, limit) +} diff --git a/src/utils/index.ts b/src/utils/index.ts index bc91b2c0..8e967bdf 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -31,3 +31,4 @@ export * from './WellBatchExport' export * from './wellSiteName' export * from './docsSearch' export * from './searchModal' +export * from './geocode'