// app.js - Modern, idiot-proof, client-only (Overpass + Leaflet) // Features: Dark mode toggle (persist), caching (5 min), top-5 list, multiple Overpass endpoints fallback // --- UI elements const findBtn = document.getElementById('findBtn'); const resetBtn = document.getElementById('resetBtn'); const statusEl = document.getElementById('status'); const outputEl = document.getElementById('output'); const themeSwitch = document.getElementById('themeSwitch'); // --- Map state let map, userMarker, shopMarker; // --- Overpass endpoints fallback (CORS-friendly mirrors) const OVERPASS_ENDPOINTS = [ 'https://overpass-api.de/api/interpreter', 'https://overpass.kumi.systems/api/interpreter', 'https://lz4.overpass-api.de/api/interpreter' ]; // --- small helpers function setStatus(txt) { statusEl.textContent = txt; } function showHTML(html) { outputEl.innerHTML = html; outputEl.style.display = 'block'; } function clearOutput() { outputEl.innerHTML = ''; outputEl.style.display = 'none'; } function roundCoord(x){ return Math.round(x*10000)/10000; } // for cache key // --- caching: simple localStorage cache (keyed by rounded coords) // stores { ts: timestamp_ms, payload: {...} } const CACHE_TTL = 1000 * 60 * 5; // 5 minutes function getCacheKey(lat, lon, radius=3000){ return `osm_nearest_${roundCoord(lat)}_${roundCoord(lon)}_${radius}`;} function readCache(key){ try { const s = localStorage.getItem(key); if(!s) return null; const obj = JSON.parse(s); if(Date.now() - obj.ts > CACHE_TTL) { localStorage.removeItem(key); return null; } return obj.payload; } catch(e){ return null; } } function writeCache(key, payload){ try { localStorage.setItem(key, JSON.stringify({ ts: Date.now(), payload })); } catch(e){} } // --- haversine distance (meters) function haversine(lat1, lon1, lat2, lon2){ const R = 6371e3; const toRad = d => d * Math.PI / 180; const φ1 = toRad(lat1), φ2 = toRad(lat2); const Δφ = toRad(lat2 - lat1), Δλ = toRad(lon2 - lon1); const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } // --- build Overpass QL function buildQuery(lat, lon, radius=3000, maxResults=30){ return ` [out:json][timeout:25]; ( node["shop"~"supermarket|grocery|convenience"](around:${radius},${lat},${lon}); way["shop"~"supermarket|grocery|convenience"](around:${radius},${lat},${lon}); relation["shop"~"supermarket|grocery|convenience"](around:${radius},${lat},${lon}); ); out center ${maxResults}; `; } // --- try endpoints sequentially async function queryOverpass(query){ let lastError = null; for(const ep of OVERPASS_ENDPOINTS){ try { console.log('Trying Overpass endpoint:', ep); const resp = await fetch(ep, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: query, mode: 'cors' }); if(!resp.ok){ const t = await resp.text().catch(()=>null); lastError = { endpoint: ep, status: resp.status, text: t }; console.warn('Overpass returned non-OK', lastError); continue; } const data = await resp.json(); return { data, endpoint: ep }; } catch(err){ lastError = { endpoint: ep, message: err.message || String(err) }; console.warn('Overpass fetch error', lastError); continue; } } const e = new Error('All Overpass endpoints failed'); e.details = lastError; throw e; } // --- map helpers function initMap(lat=20, lon=0, zoom=2){ if(!map){ map = L.map('map', { zoomControl:true, attributionControl: true }).setView([lat, lon], zoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); } else { map.setView([lat, lon], zoom); } } function setMarkers(userLat, userLon, shopLat, shopLon, shopName){ if(userMarker) userMarker.remove(); if(shopMarker) shopMarker.remove(); userMarker = L.marker([userLat, userLon]).addTo(map).bindPopup('You are here').openPopup(); shopMarker = L.marker([shopLat, shopLon]).addTo(map).bindPopup(shopName || 'Supermarket'); const bounds = L.latLngBounds([[userLat, userLon],[shopLat, shopLon]]); map.fitBounds(bounds.pad(0.2)); } // --- main search flow async function findNearest(){ clearOutput(); setStatus('Requesting your location — please allow location access.'); if(!navigator.geolocation){ setStatus('Geolocation not supported by your browser.'); return; } navigator.geolocation.getCurrentPosition(async pos=>{ const lat = pos.coords.latitude; const lon = pos.coords.longitude; const radius = 3000; setStatus(`Location acquired: ${lat.toFixed(5)}, ${lon.toFixed(5)} — searching within ${radius} m...`); initMap(lat, lon, 15); const cacheKey = getCacheKey(lat, lon, radius); const cached = readCache(cacheKey); if(cached){ setStatus(`Using cached result (${Math.round((cached.sourceAge||0)/1000)}s ago).`); renderResults(cached.payload, lat, lon); return; } const q = buildQuery(lat, lon, radius, 40); try { const { data, endpoint } = await queryOverpass(q); if(!data || !Array.isArray(data.elements) || data.elements.length === 0){ setStatus('No supermarkets found within radius. Try increasing radius or moving closer to shops.'); showHTML(`
No supermarkets found within ${radius} m.
Endpoint: ${endpoint || 'none'}
`); return; } // build candidate list with usable coords + distance const candidates = []; for(const el of data.elements){ let elLat = null, elLon = null; if(el.type === 'node'){ elLat = el.lat; elLon = el.lon; } else if(el.type === 'way' || el.type === 'relation'){ if(el.center && typeof el.center.lat === 'number'){ elLat = el.center.lat; elLon = el.center.lon; } else continue; } else continue; const dist = Math.round(haversine(lat, lon, elLat, elLon)); candidates.push({ el, lat: elLat, lon: elLon, dist }); } if(candidates.length === 0){ setStatus('No usable coordinates in results.'); showHTML(`
Overpass returned results, but none had usable coordinates.
`); return; } candidates.sort((a,b)=>a.dist - b.dist); // prepare a compact payload to cache & render const payload = { endpoint, results: candidates.slice(0,10).map(c => ({ id: c.el.id, type: c.el.type, name: c.el.tags?.name || c.el.tags?.operator || 'Unnamed', lat: c.lat, lon: c.lon, dist: c.dist })) }; // cache it writeCache(cacheKey, { payload, sourceAge:0 }); // store sourceAge in payload for immediate display clarity payload.sourceAge = 0; renderResults(payload, lat, lon); setStatus(`Found ${payload.results.length} nearby supermarkets (endpoint: ${endpoint}).`); } catch(err){ console.error('Search failed', err); const msg = err?.message || 'Search failed'; setStatus('Search failed — see details below.'); showHTML(`
${msg}
Possible reason: Overpass endpoint unreachable or rate-limited. Try again later or use a different network.
`); } }, err=>{ if(err.code === 1) setStatus('Location access denied. Allow location permissions for this site.'); else if(err.code === 2) setStatus('Position unavailable.'); else if(err.code === 3) setStatus('Timeout while getting location.'); else setStatus('Unknown geolocation error.'); }, { enableHighAccuracy:true, maximumAge:0, timeout:20000 }); } // --- render results to UI function renderResults(payload, originLat, originLon){ if(!payload || !Array.isArray(payload.results)) { showHTML('
No results to display.
'); return; } const nearest = payload.results[0]; let html = `

${escapeHtml(nearest.name)}

`; html += `
${(nearest.dist/1000).toFixed(2)} km · ${nearest.dist} m away
`; const mapsLink = `https://www.openstreetmap.org/${nearest.type}/${nearest.id}`; const directionsLink = `https://www.openstreetmap.org/directions?engine=fossgis_osrm_car&route=${originLat},${originLon};${nearest.lat},${nearest.lon}`; html += `
🗺 View on OSM 🚗 Directions
`; html += `
Endpoint: ${escapeHtml(payload.endpoint || 'unknown')}
`; // top list html += `
`; html += `
Other nearby supermarkets (top ${Math.min(5,payload.results.length)}):
    `; const top = payload.results.slice(0,5); for(const r of top){ const rlink = `https://www.openstreetmap.org/${r.type}/${r.id}`; html += `
  1. ${escapeHtml(r.name)} — ${(r.dist/1000).toFixed(2)} km
  2. `; } html += `
`; showHTML(html); // place markers on map initMap(originLat, originLon, 15); setMarkers(originLat, originLon, nearest.lat, nearest.lon, nearest.name); } // small utility to escape html function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } // --- theme handling (persist) function applyTheme(dark){ if(dark) document.documentElement.setAttribute('data-theme','dark'); else document.documentElement.removeAttribute('data-theme'); try { localStorage.setItem('theme_dark', dark ? '1' : '0'); } catch(e){} } function initTheme(){ const stored = localStorage.getItem('theme_dark'); if(stored === null){ // default follow prefers-color-scheme (dark) or light const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; themeSwitch.checked = prefersDark; applyTheme(prefersDark); } else { const dark = stored === '1'; themeSwitch.checked = dark; applyTheme(dark); } } themeSwitch.addEventListener('change', () => applyTheme(themeSwitch.checked)); initTheme(); // --- reset resetBtn.addEventListener('click', () => { clearOutput(); setStatus('Tap "Find" and allow location access.'); if(map) { map.setView([20,0],2); if(userMarker){ userMarker.remove(); userMarker=null;} if(shopMarker){ shopMarker.remove(); shopMarker=null;} } }); // --- init minimal map on load document.addEventListener('DOMContentLoaded', () => { try{ initMap(20,0,2); } catch(e){ console.warn('Leaflet init failed', e); } // bind find findBtn.addEventListener('click', findNearest); });