find a market
This commit is contained in:
270
supermarket-finder/app.js
Normal file
270
supermarket-finder/app.js
Normal file
@ -0,0 +1,270 @@
|
||||
// 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(`<div style="color:#d97706;font-weight:700">No supermarkets found within ${radius} m.</div><div style="color:var(--muted);margin-top:8px">Endpoint: ${endpoint || 'none'}</div>`);
|
||||
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(`<div style="color:var(--muted)">Overpass returned results, but none had usable coordinates.</div>`);
|
||||
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(`<div style="color:darkred;font-weight:700">${msg}</div><div style="color:var(--muted);margin-top:8px">Possible reason: Overpass endpoint unreachable or rate-limited. Try again later or use a different network.</div>`);
|
||||
}
|
||||
}, 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('<div>No results to display.</div>'); return; }
|
||||
const nearest = payload.results[0];
|
||||
let html = `<div><h1 class="result-title">${escapeHtml(nearest.name)}</h1>`;
|
||||
html += `<div class="distance">${(nearest.dist/1000).toFixed(2)} km · ${nearest.dist} m away</div>`;
|
||||
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 += `<div class="actions"><a class="big-link" href="${mapsLink}" target="_blank" rel="noopener">🗺 View on OSM</a> <a class="big-link" href="${directionsLink}" target="_blank" rel="noopener">🚗 Directions</a></div>`;
|
||||
html += `<div style="margin-top:10px;color:var(--muted)">Endpoint: ${escapeHtml(payload.endpoint || 'unknown')}</div>`;
|
||||
// top list
|
||||
html += `<hr style="margin:12px 0">`;
|
||||
html += `<div style="font-weight:700;margin-bottom:8px">Other nearby supermarkets (top ${Math.min(5,payload.results.length)}):</div><ol class="results-list">`;
|
||||
const top = payload.results.slice(0,5);
|
||||
for(const r of top){
|
||||
const rlink = `https://www.openstreetmap.org/${r.type}/${r.id}`;
|
||||
html += `<li><a href="${rlink}" target="_blank" rel="noopener">${escapeHtml(r.name)}</a> — ${(r.dist/1000).toFixed(2)} km</li>`;
|
||||
}
|
||||
html += `</ol></div>`;
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user