From d0ed57fd9dac59b38bef822d36ee84c6dc16e8ac Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 21 Aug 2026 13:48:37 -0700 Subject: [PATCH 1/2] feat(map): add place/address geocoder search to the map view Adds a debounced search box to the top-left panel stack on the map view. Selecting a result fits the map to the result's bounding box (or eases to its center) and drops a marker; results are biased toward the current viewport center. Geocoding goes to Photon, komoot's OpenStreetMap-backed service. Photon needs no account or token, which keeps the map free of API keys the way basemaps.ts already does, and unlike Nominatim its usage policy permits search-as-you-type. Photon returns address components rather than a formatted label and reports `extent` as [minLon, maxLat, maxLon, minLat], so utils/geocode.ts composes the display label and reorders the box into the [west, south, east, north] order fitBounds expects. Results are filtered to the US client side, since Photon has no country parameter. Co-Authored-By: Claude Opus 5 --- src/components/MapGeocoderSearch.tsx | 200 +++++++++++++++++++++++++++ src/components/index.ts | 1 + src/pages/ocotillo/map/list.tsx | 93 ++++++++++++- src/test/utils/geocode.test.ts | 68 +++++++++ src/utils/geocode.ts | 164 ++++++++++++++++++++++ src/utils/index.ts | 1 + 6 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 src/components/MapGeocoderSearch.tsx create mode 100644 src/test/utils/geocode.test.ts create mode 100644 src/utils/geocode.ts 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..7ac8d81b --- /dev/null +++ b/src/test/utils/geocode.test.ts @@ -0,0 +1,68 @@ +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('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..103ed3c6 --- /dev/null +++ b/src/utils/geocode.ts @@ -0,0 +1,164 @@ +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 + 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(', ') +} + +export const normalizePhotonFeatures = ( + features: readonly PhotonFeature[] | null | undefined +): GeocodeResult[] => { + if (!Array.isArray(features)) return [] + + return 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 [ + { + id: `${properties?.osm_type ?? 'x'}${properties?.osm_id ?? ''}-${index}`, + label, + center: [center[0], center[1]] as [number, number], + ...(bbox ? { bbox } : {}), + }, + ] + }) +} + +/** + * 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' From ee4b4f9541b57a43b65a5e9ab6745f79fd4dcddb Mon Sep 17 00:00:00 2001 From: jakeross Date: Fri, 21 Aug 2026 14:17:47 -0700 Subject: [PATCH 2/2] fix(geocode): disambiguate Photon results that share a label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query like "socorro" matches both the city and the county relation, and both compose to "Socorro, New Mexico" — two identical rows in the dropdown that fly to different extents. Where a label repeats, Photon's own type classification is now appended to each of the colliding rows. Co-Authored-By: Claude Opus 5 --- src/test/utils/geocode.test.ts | 14 +++++++++++++ src/utils/geocode.ts | 37 +++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/test/utils/geocode.test.ts b/src/test/utils/geocode.test.ts index 7ac8d81b..da751ac8 100644 --- a/src/test/utils/geocode.test.ts +++ b/src/test/utils/geocode.test.ts @@ -55,6 +55,20 @@ describe('normalizePhotonFeatures', () => { 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' }), diff --git a/src/utils/geocode.ts b/src/utils/geocode.ts index 103ed3c6..be074e6f 100644 --- a/src/utils/geocode.ts +++ b/src/utils/geocode.ts @@ -27,6 +27,8 @@ export type PhotonProperties = { state?: string postcode?: string countrycode?: string + /** Photon's own classification: city, county, state, street, house, … */ + type?: string extent?: unknown } @@ -100,12 +102,32 @@ const buildLabel = (properties: PhotonProperties | undefined): string => { .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 [] - return features.flatMap((feature, index) => { + const entries = features.flatMap((feature, index) => { const center = feature?.geometry?.coordinates if (!isLonLat(center)) return [] @@ -121,13 +143,18 @@ export const normalizePhotonFeatures = ( return [ { - id: `${properties?.osm_type ?? 'x'}${properties?.osm_id ?? ''}-${index}`, - label, - center: [center[0], center[1]] as [number, number], - ...(bbox ? { bbox } : {}), + 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) } /**