'use client'; import { useState, useEffect, useRef } from 'react'; import { API_BASE } from '@/lib/api'; import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants'; import { parseCoordinateInput, boundsForCoordinate, sanitizeGeocodeBbox, boundsForPlaceRank, type Bounds, } from '@/lib/mapZoom'; type LocateResult = { label: string; lat: number; lng: number; bounds?: Bounds; }; /* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */ export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number, bounds?: Bounds) => void; onOpenChange?: (open: boolean) => void }) { const [open, setOpen] = useState(false); useEffect(() => { onOpenChange?.(open); }, [open]); const [value, setValue] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [searchError, setSearchError] = useState(null); const inputRef = useRef(null); const timerRef = useRef | null>(null); const searchAbortRef = useRef(null); const containerRef = useRef(null); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); // Close when clicking outside useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setValue(''); setResults([]); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const handleSearch = async (q: string) => { setValue(q); // Check for raw coordinates first const coords = parseCoordinateInput(q); if (coords) { setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, lat: coords.lat, lng: coords.lng, bounds: boundsForCoordinate(coords.lat, coords.lng, coords.decimals), }]); return; } // Geocode with Nominatim (debounced) if (timerRef.current) clearTimeout(timerRef.current); if (searchAbortRef.current) searchAbortRef.current.abort(); if (q.trim().length < 2) { setResults([]); setSearchError(null); return; } timerRef.current = setTimeout(async () => { setLoading(true); setSearchError(null); searchAbortRef.current = new AbortController(); const signal = searchAbortRef.current.signal; try { const res = await fetch( `${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`, { signal }, ); if (res.ok) { const data = await res.json(); const mapped: LocateResult[] = (data?.results || []).map( (r: { label: string; lat: number; lng: number; bbox?: string[]; place_rank?: number; }) => ({ label: r.label, lat: r.lat, lng: r.lng, // A usable bbox frames the real thing; otherwise fall back to // the rank's typical extent, which is the only honest answer // for Tokyo prefecture, France and bare OSM nodes. bounds: sanitizeGeocodeBbox(r.bbox, r.lat, r.lng) ?? boundsForPlaceRank(r.lat, r.lng, r.place_rank), }), ); setResults(mapped); if (mapped.length === 0) { setSearchError('No places found'); } } else { console.warn(`[Locate] Geocode proxy HTTP ${res.status}`); setResults([]); setSearchError('Place search unavailable — check backend connection'); } } catch (err) { if ((err as Error)?.name !== 'AbortError') { console.warn('[Locate] Geocode proxy failed:', err); setResults([]); setSearchError('Place search unavailable — check backend connection'); } } finally { setLoading(false); } }, NOMINATIM_DEBOUNCE_MS); }; const handleSelect = (r: LocateResult) => { onLocate(r.lat, r.lng, r.bounds); setOpen(false); setValue(''); setResults([]); }; if (!open) { return ( ); } return (
handleSearch(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setValue(''); setResults([]); } if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]); }} placeholder="Enter coordinates (31.8, 34.8) or place name..." className="flex-1 bg-transparent text-[12px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]" /> {loading && (
)}
{searchError && results.length === 0 && !loading && value.trim().length >= 2 && (
{searchError}
)} {results.length > 0 && (
{results.map((r, i) => ( ))}
)}
); }