const GEOCODE_URL = '/api/geocode' const VALHALLA_URL = '/valhalla/route' const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route' const VALHALLA_HEIGHT_URL = '/valhalla/height' /** * Search geocode API with abort support. * @param {string} query * @param {number} limit * @param {AbortSignal} signal * @returns {Promise<{query, results, count}>} */ export async function searchGeocode(query, limit = 6, signal, viewport = null) { const params = new URLSearchParams({ q: query, limit: String(limit) }) if (viewport?.lat != null) params.set('lat', String(viewport.lat)) if (viewport?.lon != null) params.set('lon', String(viewport.lon)) if (viewport?.zoom != null) params.set('zoom', String(Math.round(viewport.zoom))) const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 }) if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`) return resp.json() } /** * Request a route from Valhalla. * @param {Array<{lat, lon}>} locations * @param {string} costing - 'auto' | 'pedestrian' | 'bicycle' * @returns {Promise} Valhalla trip response */ export async function requestRoute(locations, costing = 'auto') { const body = { locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })), costing, units: 'miles', directions_options: { units: 'miles' }, } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 30000) try { const resp = await fetch(VALHALLA_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal, }) if (!resp.ok) { const errBody = await resp.json().catch(() => ({})) throw new Error(errBody.error || errBody.status_message || `Route error: ${resp.status}`) } return resp.json() } finally { clearTimeout(timeout) } } /** * Request an optimized route from Valhalla. * @param {Array<{lat, lon}>} locations * @param {string} costing * @returns {Promise} Valhalla optimized trip response */ export async function requestOptimizedRoute(locations, costing = 'auto') { const body = { locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })), costing, units: 'miles', directions_options: { units: 'miles' }, } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 30000) try { const resp = await fetch(VALHALLA_OPTIMIZED_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal, }) if (!resp.ok) { const errBody = await resp.json().catch(() => ({})) throw new Error(errBody.error || errBody.status_message || `Optimize error: ${resp.status}`) } return resp.json() } finally { clearTimeout(timeout) } } /** * Fetch elevation for a point via Valhalla height API. * @param {number} lat * @param {number} lon * @returns {Promise} Height in meters, or null on error */ export async function fetchElevation(lat, lon) { try { const resp = await fetch(VALHALLA_HEIGHT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shape: [{ lat, lon }], resample_distance: 100 }), }) if (!resp.ok) return null const data = await resp.json() if (data.height && data.height.length > 0) return data.height[0] return null } catch { return null } } const REVERSE_URL = "/api/reverse" /** * Reverse geocode a point. Returns a place object or null. * @param {number} lat * @param {number} lon * @returns {Promise<{lat, lon, name, address, type, source, raw}|null>} */ export async function fetchReverse(lat, lon) { try { const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 }) if (!resp.ok) return null const data = await resp.json() if (!data.results || data.results.length === 0) return null const r = data.results[0] return { lat: r.lat, lon: r.lon, name: r.name, address: null, type: r.type, source: r.source, matchCode: null, raw: r.raw || {}, } } catch { return null } } /** * Fetch drive time between two points via Valhalla route. * @param {number} oLat - Origin latitude * @param {number} oLon - Origin longitude * @param {number} dLat - Destination latitude * @param {number} dLon - Destination longitude * @param {AbortSignal} signal - AbortController signal * @returns {Promise} Drive time in seconds, or null on error */ export async function fetchDriveTime(oLat, oLon, dLat, dLon, signal) { try { const resp = await fetch(VALHALLA_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ locations: [{ lat: oLat, lon: oLon }, { lat: dLat, lon: dLon }], costing: 'auto', }), signal, }) if (!resp.ok) return null const data = await resp.json() return data.trip?.summary?.time ?? null } catch { return null } } /** * Fetch enriched place details from the place detail proxy. * @param {string} osmType - N, W, or R * @param {number} osmId - OSM element ID * @param {AbortSignal} signal - AbortController signal for cancellation * @returns {Promise} Cleaned place detail object, or null on error */ export async function fetchPlaceDetails(osmType, osmId, signal) { try { const resp = await fetch(`/api/place/${osmType}/${osmId}`, { signal, headers: { 'Accept': 'application/json' }, }) if (!resp.ok) return null return resp.json() } catch { return null } } export async function fetchPlaceByWikidata(wikidataId, signal) { try { const resp = await fetch(`/api/place/wikidata/${wikidataId}`, { signal, headers: { "Accept": "application/json" }, }) if (!resp.ok) return null return resp.json() } catch { return null } } // ── Contacts API ── export async function fetchContacts(signal) { try { const resp = await fetch('/api/contacts', { signal }) if (resp.status === 401) return { auth: false } if (!resp.ok) throw new Error(`Contacts error: ${resp.status}`) return resp.json() } catch (e) { if (e.name === 'AbortError') throw e return [] } } export async function createContact(data) { const resp = await fetch('/api/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (resp.status === 401) return { auth: false } return resp.json().then((d) => ({ ...d, _status: resp.status })) } export async function updateContact(id, data) { const resp = await fetch(`/api/contacts/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (resp.status === 401) return { auth: false } return resp.json() } export async function deleteContact(id) { const resp = await fetch(`/api/contacts/${id}`, { method: 'DELETE' }) if (resp.status === 401) return { auth: false } return resp.json() } export async function fetchNearbyContacts(lat, lon, radiusM, signal) { try { const params = new URLSearchParams({ lat: String(lat), lon: String(lon), radius_m: String(radiusM) }) const resp = await fetch(`/api/contacts/nearby?${params}`, { signal }) if (resp.status === 401) return [] if (!resp.ok) return [] return resp.json() } catch { return [] } } /** * Fetch PAD-US land classification for a point. * @param {number} lat * @param {number} lon * @param {AbortSignal} signal * @returns {Promise} Classification data or null on error */ export async function fetchLandclass(lat, lon, signal) { try { const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) const resp = await fetch(`/api/landclass?${params}`, { signal }) if (!resp.ok) return null return resp.json() } catch { return null } }