1
0

find a market

This commit is contained in:
Creat0r
2025-10-25 12:32:29 +02:00
parent 1415e6b14f
commit 58c225445e
6 changed files with 513 additions and 0 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

View File

@ -125,6 +125,12 @@
</div>
</button>
<button class="button" onclick="window.location.href='/supermarket-finder'">
<div class="button-inner">
<span class="button-text">Find a market</span>
</div>
</button>
<footer><i id="footer-text"></i></footer>
<script>
// Ladeanimation beim Laden der Seite anzeigen

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "test.creative-crafter.de",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

270
supermarket-finder/app.js Normal file
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);
});

View File

@ -0,0 +1,195 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Nearest Supermarket — Modern</title>
<!-- Inter font (modern) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<style>
:root{
--bg: #ffffff;
--card: #ffffff;
--text: #0f1724;
--muted: #6b7280;
--blue: #0b54a0; /* darker blue for Light mode */
--blue-accent: #0b54a0;
--success: #16a34a;
--radius: 14px;
--glass: rgba(255,255,255,0.6);
}
/* Dark theme variables (apply via [data-theme="dark"]) */
:root[data-theme="dark"]{
--bg: #000000;
--card: #0b0b0c;
--text: #ffffff;
--muted: #9ca3af;
--blue: #59b7ff; /* lighter blue for Dark mode */
--blue-accent: #59b7ff;
--glass: rgba(255,255,255,0.04);
}
html,body{
height:100%;
margin:0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
background: linear-gradient(180deg, var(--bg), var(--bg));
color: var(--text);
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
}
header{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:14px 18px;
background: linear-gradient(90deg, var(--blue) 0%, color-mix(in srgb, var(--blue) 60%, black 40%) 100%);
color:white;
box-shadow: 0 6px 20px rgba(2,6,23,0.12);
}
.brand { font-weight:700; font-size:1.1rem; }
.subtitle { font-weight:500; opacity:0.92; font-size:0.92rem; }
main{
max-width:880px;
margin:18px auto;
padding:12px;
}
.controls {
display:flex;
gap:12px;
align-items:center;
margin-bottom:12px;
flex-wrap:wrap;
}
.btn-primary{
background: var(--blue-accent);
color: white;
border: none;
padding: 14px 18px;
border-radius: 12px;
font-weight:700;
font-size:1.02rem;
cursor:pointer;
box-shadow: 0 8px 24px color-mix(in srgb, var(--blue-accent) 12%, transparent);
transition: transform .08s ease, box-shadow .12s ease;
}
.btn-primary:active{ transform: translateY(1px); }
.btn-ghost{
background: var(--glass);
border: 1px solid rgba(0,0,0,0.06);
color: var(--text);
padding:12px 14px;
border-radius: 10px;
cursor:pointer;
font-weight:600;
}
.theme-toggle {
margin-left:auto;
display:flex;
gap:10px;
align-items:center;
}
#status {
margin-top:8px;
color:var(--muted);
font-size:0.96rem;
min-height:1.2rem;
}
.layout {
display:grid;
grid-template-columns: 1fr 420px;
gap:16px;
}
@media(max-width:980px){
.layout { grid-template-columns: 1fr; }
}
.card {
background: var(--card);
border-radius: var(--radius);
padding:14px;
box-shadow: 0 6px 24px rgba(2,6,23,0.06);
}
#output {
min-height:140px;
}
h1.result-title { margin:0; font-size:1.15rem; font-weight:700; color:var(--text); }
.distance { font-size:1.05rem; font-weight:700; margin-top:6px; color:var(--muted); }
.actions { margin-top:12px; display:flex; gap:8px; flex-wrap:wrap; }
.big-link {
background:var(--blue);
color:white;
padding:10px 14px;
border-radius:10px;
text-decoration:none;
font-weight:700;
display:inline-block;
}
#map { height:520px; border-radius:12px; overflow:hidden; }
ol.results-list { margin:12px 0 0 18px; padding:0; color:var(--muted); }
ol.results-list li { margin:8px 0; }
footer { text-align:center; color:var(--muted); margin-top:18px; font-size:0.9rem; }
/* little accessibility */
.sr-only { position:absolute; left:-9999px; top:auto; width:1px; height:1px; overflow:hidden; }
</style>
</head>
<body>
<header>
<div>
<div class="brand">Nearest Supermarket</div>
<div class="subtitle">Find the closest grocery — fast & offline-friendly</div>
</div>
<div class="theme-toggle">
<label for="themeSwitch" style="font-weight:600;color:rgba(255,255,255,0.9);margin-right:8px">Dark</label>
<input id="themeSwitch" type="checkbox" aria-label="Toggle dark mode" />
</div>
</header>
<main>
<div class="controls">
<button id="findBtn" class="btn-primary" aria-live="polite">🔍 Find nearest supermarket</button>
<button id="resetBtn" class="btn-ghost">↺ Reset</button>
<div id="status" aria-live="polite">Tap "Find" and allow location access.</div>
</div>
<div class="layout">
<div class="card" id="leftCard">
<div id="output" aria-live="polite" class="card" style="padding:12px;"></div>
</div>
<div class="card" id="rightCard">
<div id="map"></div>
</div>
</div>
<footer>Data from OpenStreetMap • Uses Overpass API • Works fully in your browser</footer>
</main>
<!-- Leaflet -->
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<!-- App -->
<script src="app.js"></script>
</body>
</html>

33
test.tx Normal file
View File

@ -0,0 +1,33 @@
npm start
> supermarket-finder@1.0.0 start
> node server.js
(node:77996) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///C:/Users/.../Documents/.../pivat/test.creative-crafter.de/tools/supermarket-finder/server.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to C:\Users\...\Documents\...\pivat\test.creative-crafter.de\tools\supermarket-finder\package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
node:events:497
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use :::3000
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
at listenInCluster (node:net:1997:12)
at Server.listen (node:net:2102:7)
at Function.listen (C:\Users\...\Documents\...\pivat\test.creative-crafter.de\tools\supermarket-finder\node_modules\express\lib\application.js:635:24)
at file:///C:/Users/.../Documents/.../pivat/test.creative-crafter.de/tools/supermarket-finder/server.js:87:5
at ModuleJob.run (node:internal/modules/esm/module_job:345:25)
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:651:26)
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:1976:8)
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'EADDRINUSE',
errno: -4091,
syscall: 'listen',
address: '::',
port: 3000
}
Node.js v22.19.0