import { useEffect, useRef, useState } from 'react' import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline' import { nominatimUsStateCode } from '../utils/stateMapping' /** Smallest geographic unit the search result actually identifies. */ export type MapAddressGranularity = 'state' | 'county' | 'place' | 'address' export interface MapAddressResult { displayName: string shortLabel: string lat: number lon: number stateCode: string | null county: string city: string /** Classifies the Nominatim hit so callers can route to the right map tier. */ granularity: MapAddressGranularity } interface MapAddressSearchProps { onPick: (result: MapAddressResult) => void onClear?: () => void initialValue?: string className?: string placeholder?: string } interface NominatimSuggestion { osm_id: number osm_type: string lat: string lon: string display_name: string address?: Record /** Nominatim's coarse classification ("state", "county", "city", "house"…). */ addresstype?: string /** Coarse OSM class ("boundary", "place", "building"…). */ class?: string /** Finer-grained OSM type within the class ("administrative", "city"…). */ type?: string } /** * Classify a Nominatim hit into the smallest tier our map can route to. * * - `state` when the result IS a state (e.g. "Georgia") * - `county` when the result IS a county (e.g. "Tuscaloosa County") * - `place` when the result is a city/town/village/CDP (e.g. "Tuscaloosa") * - `address` when there's a house number, road, or anything finer-grained * * Nominatim's `addresstype` is the primary signal; we fall back to inspecting * the parsed `address` object for ambiguous cases (e.g. a hit on a city named * the same as its county). */ function classifyGranularity(s: NominatimSuggestion): MapAddressGranularity { const at = (s.addresstype ?? '').toLowerCase() const cls = (s.class ?? '').toLowerCase() const typ = (s.type ?? '').toLowerCase() if (at === 'state') return 'state' if (at === 'county') return 'county' if (at === 'city' || at === 'town' || at === 'village' || at === 'hamlet' || at === 'municipality') { return 'place' } // Boundary-class results without an addresstype: lean on type for state/county. if (cls === 'boundary' && typ === 'administrative') { const addr = s.address ?? {} if ((addr as Record).state && !(addr as Record).county) return 'state' if ((addr as Record).county) return 'county' } // Anything with a house number or road is definitely an address. const a = (s.address ?? {}) as Record if (a.house_number || a.road || a.building || a.amenity) return 'address' // Place-class hits (neighborhood, suburb…) collapse to the parent place. if (cls === 'place') return 'place' return 'address' } function shortLabelFromAddress(addr: Record | undefined, fallback: string): string { if (!addr) return fallback const road = (addr.road as string) || '' const houseNumber = (addr.house_number as string) || '' const city = (addr.city as string) || (addr.town as string) || (addr.village as string) || (addr.hamlet as string) || (addr.municipality as string) || '' const state = (addr.state as string) || '' const street = [houseNumber, road].filter(Boolean).join(' ').trim() const parts = [street, city, state].filter(Boolean) return parts.length ? parts.join(', ') : fallback } export default function MapAddressSearch({ onPick, onClear, initialValue = '', className = '', placeholder = 'Search address, city, or place', }: MapAddressSearchProps) { const [value, setValue] = useState(initialValue) const [suggestions, setSuggestions] = useState([]) const [open, setOpen] = useState(false) const [activeIdx, setActiveIdx] = useState(-1) const [loading, setLoading] = useState(false) const debounceRef = useRef(null) const rootRef = useRef(null) useEffect(() => { const onDoc = (e: MouseEvent) => { if (!rootRef.current) return if (!rootRef.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc) }, []) const fetchSuggestions = async (query: string) => { if (query.trim().length < 3) { setSuggestions([]) setOpen(false) return } setLoading(true) try { // Same-origin proxy (api/routes/geocode.py) — avoids Nominatim CORS and // honors its 1 req/s policy via server-side throttle + cache. const r = await fetch( `/api/geocode/search?q=${encodeURIComponent(query)}&limit=6`, ) if (!r.ok) return const raw: NominatimSuggestion[] = await r.json() const seen = new Set() const deduped = raw.filter((s) => { const k = `${s.osm_type}_${s.osm_id}` if (seen.has(k)) return false seen.add(k) return true }) setSuggestions(deduped) setOpen(deduped.length > 0) setActiveIdx(-1) } catch { // ignore network blips — user can retype } finally { setLoading(false) } } const handleChange = (v: string) => { setValue(v) if (debounceRef.current) window.clearTimeout(debounceRef.current) debounceRef.current = window.setTimeout(() => fetchSuggestions(v), 280) } const choose = (s: NominatimSuggestion) => { const lat = Number(s.lat) const lon = Number(s.lon) if (!Number.isFinite(lat) || !Number.isFinite(lon)) return const stateCode = nominatimUsStateCode(s.address ?? {}) || null const county = ((s.address?.county as string) || '').trim() const city = ((s.address?.city as string) || (s.address?.town as string) || (s.address?.village as string) || (s.address?.hamlet as string) || (s.address?.municipality as string) || '').trim() const shortLabel = shortLabelFromAddress(s.address, s.display_name) const granularity = classifyGranularity(s) setValue(shortLabel) setOpen(false) setSuggestions([]) onPick({ displayName: s.display_name, shortLabel, lat, lon, stateCode, county, city, granularity, }) } const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setOpen(false) return } if (!open || !suggestions.length) { if (e.key === 'Enter') { e.preventDefault() fetchSuggestions(value) } return } if (e.key === 'ArrowDown') { e.preventDefault() setActiveIdx((i) => Math.min(suggestions.length - 1, i + 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setActiveIdx((i) => Math.max(0, i - 1)) } else if (e.key === 'Enter') { e.preventDefault() const pick = activeIdx >= 0 ? suggestions[activeIdx] : suggestions[0] if (pick) choose(pick) } } const clear = () => { setValue('') setSuggestions([]) setOpen(false) setActiveIdx(-1) onClear?.() } return (
handleChange(e.target.value)} onFocus={() => { if (suggestions.length) setOpen(true) }} onKeyDown={onKeyDown} /> {value ? ( ) : null}
{open && suggestions.length > 0 ? (
    {suggestions.map((s, i) => { const short = shortLabelFromAddress(s.address, s.display_name) const active = i === activeIdx return (
  • ) })}
) : null} {loading ? (
) : null}
) }