Hotel-Highlight + Route + Nachbarn + Restaurants + PWA + Theme-Toggle (index.html)

**Hotel präsenter auf Karten:**
- Größerer Hotel-Marker (56px vs 38px)
- Goldener pulsierender Glow (CSS-Animation hotelPulse)
- Permanentes Label 'Hotel Uno · Basis' unter dem Pin
- Auto-Open Popup nach 1.5s mit goldenem Banner
- Filter-Toggle (Hotel immer sichtbar, Action/Fun/Sight ausblendbar)

**Feature 7 - Route zwischen Locations:**
- OSRM Public API für echte Auto-Routen
- Schnell-Routen vom Hotel (3 nächste Locations)
- 'Zurück zum Hotel' Button in jedem Location-Popup
- Fallback auf Haversine-Luftlinie wenn API down

**Feature 8 - Was ist in der Nähe:**
- Nachbarn <3km Luftlinie in jedem Popup

**Feature 14 - Restaurant-Tipps:**
- Geo-Cluster-basierte Restaurant-Empfehlungen pro Location
- 14 handgepflegte Tipps (€€, Cuisine, Beschreibung)

**Feature 20 - PWA:**
- manifest.json + sw.js (cache-first für eigene Files)
- Theme-Color, Apple-Meta, SW-Registrierung in allen 5 HTML-Dateien

**Feature 21 - Dark/Light Mode Toggle:**
- CSS-Variablen-System + 🌙/☀️ Toggle-Button
- localStorage-Persistenz (nur index.html, andere Seiten folgen)

**Bugfixes:**
- Enigma-URL auf User-Sublink korrigiert (alle 3 Map-Dateien)
- tourenplan-mit-tram.html Locations-Sync mit tourenplan.html
  (Endorfin/KartPlanet hatten komplett andere Preise+Koordinaten+URLs)
This commit is contained in:
Hermes AI Bot
2026-06-27 08:32:48 +00:00
parent 94cc5532f3
commit c3fbe892cc
7 changed files with 827 additions and 67 deletions
+288 -17
View File
@@ -1,7 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧭</text></svg>">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#3b82f6">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="JGA Prag">
<meta name="mobile-web-app-capable" content="yes"><text y='.9em' font-size='90'>🧭</text></svg>">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JGA Prag Tourenplan</title>
@@ -14,11 +20,66 @@
#header h1 { margin: 0 0 4px 0; font-size: 22px; }
#header .sub { color: #aaa; font-size: 13px; }
#map { height: calc(100vh - 80px); width: 100%; }
.legend { background: rgba(30,30,30,0.95); padding: 10px 12px; border-radius: 8px; color: #eee; font-size: 13px; box-shadow: 0 2px 8px rgba(0,0,0,0.4); border: 1px solid #444; max-width: 240px; }
.legend h4 { margin: 0 0 8px 0; font-size: 14px; }
.legend .item { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.legend .emoji { font-size: 18px; }
.legend .dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid white; }
/* Hotel-Marker: größer + Gold-Rahmen + pulsierender Glow + permanentes Label */
.hotel-marker {
position: relative;
}
.hotel-marker::before {
content: '';
position: absolute;
inset: -10px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,215,0,0.35) 0%, rgba(255,215,0,0) 70%);
animation: hotelPulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes hotelPulse {
0%, 100% { transform: scale(0.9); opacity: 0.7; }
50% { transform: scale(1.2); opacity: 1; }
}
.hotel-marker > div {
background: #4fc3f7 !important;
border: 4px solid #ffd700 !important;
box-shadow: 0 0 16px rgba(255,215,0,0.6), 0 4px 12px rgba(0,0,0,0.6) !important;
z-index: 1000 !important;
}
.hotel-label {
background: rgba(255,215,0,0.95) !important;
color: #1a1a1a !important;
font-weight: 800 !important;
font-size: 13px !important;
padding: 4px 10px !important;
border: 2px solid #4fc3f7 !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.5) !important;
white-space: nowrap;
margin-bottom: 8px;
}
.hotel-label::before { display: none; }
.hotel-banner {
background: linear-gradient(135deg, #ffd700 0%, #ffeb3b 100%);
color: #1a1a1a;
padding: 8px 14px;
border-radius: 8px;
font-weight: 700;
font-size: 13px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 6px rgba(255,215,0,0.3);
}
/* Layer-Toggle in Legende */
.layer-toggle { margin-top: 8px; padding-top: 6px; border-top: 1px solid #555; font-size: 12px; }
.layer-toggle label { display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; user-select: none; color: #eee; }
.layer-toggle input { cursor: pointer; }
/* Legende */
.legend { background: rgba(30,30,30,0.95); padding: 10px 12px; border-radius: 8px; color: #eee; font-size: 13px; box-shadow: 0 2px 8px rgba(0,0,0,0.4); border: 1px solid #444; max-width: 240px; }
.legend h4 { margin: 0 0 8px 0; font-size: 14px; }
.legend .item { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.legend .emoji { font-size: 18px; }
.legend .dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid white; }
.popup-content { font-family: -apple-system, BlinkMacSystemFont, sans-serif; min-width: 220px; }
.popup-content .emoji-big { font-size: 28px; margin-right: 8px; }
.popup-content h3 { margin: 0 0 6px 0; font-size: 16px; color: #1a1a1a; }
@@ -163,7 +224,7 @@ const locations = [
address: "Symbolischer Startpunkt: Altstädter Ring (Staroměstské náměstí)",
desc: "Stadtweites Mobile-App Mystery-Spiel. Per GPS durch ganz Prag, kein fester Treffpunkt. Anbieter: Swallnet.",
price: "ca. 5 € p.P. / 12h",
url: "https://enigma.swallnet.com/index.php/jak-hrat-en/",
url: "https://enigma.swallnet.com/index.php/odkaz-mistra-hvezdare-en/",
category: "fun"
}
];
@@ -180,7 +241,8 @@ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
// Custom Marker mit Emoji + Label
function createIcon(emoji, color, isHotel=false) {
const size = isHotel ? 44 : 38;
const size = isHotel ? 56 : 38;
const wrapperClass = isHotel ? 'custom-marker hotel-marker' : 'custom-marker';
const html = `
<div style="
background: ${color};
@@ -191,52 +253,232 @@ function createIcon(emoji, color, isHotel=false) {
display: flex;
align-items: center;
justify-content: center;
border: 3px solid white;
box-shadow: 0 3px 8px rgba(0,0,0,0.5);
border: ${isHotel ? 4 : 3}px solid ${isHotel ? '#ffd700' : 'white'};
box-shadow: ${isHotel ? '0 0 16px rgba(255,215,0,0.6), 0 4px 12px rgba(0,0,0,0.6)' : '0 3px 8px rgba(0,0,0,0.5)'};
">
<span style="transform: rotate(45deg); font-size: ${isHotel ? 22 : 19}px;">${emoji}</span>
<span style="transform: rotate(45deg); font-size: ${isHotel ? 28 : 19}px;">${emoji}</span>
</div>
`;
return L.divIcon({
html: html,
className: 'custom-marker',
className: wrapperClass,
iconSize: [size, size],
iconAnchor: [size/2, size],
popupAnchor: [0, -size]
});
}
// Permanentes Label für Hotel (sichtbar ohne Klick)
function createHotelLabel() {
return L.divIcon({
html: '<div class="hotel-label">🏨 Hotel Uno · Basis</div>',
className: 'hotel-label-wrapper',
iconSize: [140, 22],
iconAnchor: [70, 56+22] // unter dem Hotel-Marker
});
}
// Marker setzen
const bounds = [];
const markersByCategory = {}; // für Layer-Toggle
let hotelMarker = null;
let routeLayer = null; // aktuelle Route (Polyline)
// Locations für späteren Zugriff
const hotelLoc = locations.find(l => l.category === 'hotel');
const otherLocations = locations.filter(l => l.category !== 'hotel');
// Haversine-Distanz (km) zwischen 2 Punkten
function haversineKm(a, b) {
const R = 6371;
const dLat = (b.lat - a.lat) * Math.PI / 180;
const dLon = (b.lon - a.lon) * Math.PI / 180;
const lat1 = a.lat * Math.PI / 180;
const lat2 = b.lat * Math.PI / 180;
const x = Math.sin(dLat/2)**2 + Math.cos(lat1)*Math.cos(lat2)*Math.sin(dLon/2)**2;
return 2 * R * Math.asin(Math.sqrt(x));
}
// Route zwischen 2 Punkten via OSRM (Auto-Routing)
async function drawRoute(from, to) {
if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; }
const url = `https://router.project-osrm.org/route/v1/driving/${from.lon},${from.lat};${to.lon},${to.lat}?overview=full&geometries=geojson`;
try {
const r = await fetch(url);
if (!r.ok) throw new Error('OSRM ' + r.status);
const data = await r.json();
if (!data.routes || data.routes.length === 0) throw new Error('Keine Route');
const coords = data.routes[0].geometry.coordinates.map(([lng, lat]) => [lat, lng]);
const distKm = (data.routes[0].distance / 1000).toFixed(1);
const durMin = Math.round(data.routes[0].duration / 60);
routeLayer = L.polyline(coords, {
color: '#06b6d4', weight: 5, opacity: 0.85, dashArray: '10,6',
lineCap: 'round', lineJoin: 'round'
}).addTo(map);
map.fitBounds(routeLayer.getBounds(), { padding: [80, 80] });
return { distKm, durMin, from, to };
} catch (e) {
// Fallback: gerade Linie + Luftlinie
const distKm = haversineKm(from, to).toFixed(1);
routeLayer = L.polyline([[from.lat, from.lon], [to.lat, to.lon]], {
color: '#06b6d4', weight: 4, opacity: 0.6, dashArray: '6,8'
}).addTo(map);
const durMin = Math.round(distKm * 2.5); // grobe Schätzung: 24 km/h Stadtverkehr
return { distKm, durMin, from, to, fallback: true };
}
}
function clearRoute() {
if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; }
}
locations.forEach(loc => {
const isHotel = loc.category === 'hotel';
const marker = L.marker([loc.lat, loc.lon], {
icon: createIcon(loc.emoji, loc.color, isHotel)
icon: createIcon(loc.emoji, loc.color, isHotel),
zIndexOffset: isHotel ? 1000 : 0
}).addTo(map);
const bannerHtml = isHotel ? '<div class="hotel-banner">⭐ Unsere Basis · Hier startet & endet jeder Tag</div>' : '';
// Restaurant-Tipps pro Location (basierend auf geo-cluster)
const RESTAURANTS = {"Hotel Uno (Strašnice)":[{"name":"Restaurace U Kohouta","cuisine":"Tschechisch traditionell","price":"€€","desc":"Klassische tschechische Küche, 10 Min vom Hotel. Goulash & Knödel."},{"name":"Pizza Coloseum Strašnice","cuisine":"Italienisch/Pizza","price":"€","desc":"Solide Pizza, 8 Min zu Fuß. Auch Lieferservice."}],"Endorfin (Vršovice)":[{"name":"Kavárna Kaaba","cuisine":"Café/Brunch","price":"€","desc":"Hippes Café in Vršovice, perfekt nach Escape Room."},{"name":"Pivovar U Medvídků","cuisine":"Tschechisch/Bier","price":"€€","desc":"Berühmte Hausbrauerei (Novoměstský), 15 Min mit Tram."}],"KartPlanet (Hrdlořezy)":[{"name":"Restaurace U Přístavu","cuisine":"Tschechisch","price":"€€","desc":"5 Min von KartPlanet, direkt am Wasser. Gute Knödel."}],"Level Minigolf (Národní/Máj)":[{"name":"Máj Food Court","cuisine":"Alles","price":"€","desc":"Im selben Haus, 5. OG. Burger, Asia, Pizza — schnell & günstig."},{"name":"Café Louvre","cuisine":"Café/Klassisch","price":"€€","desc":"Legendäres Wiener Kaffeehaus, 3 Min Fußweg. Atmosphäre!"}],"Bowling Ovčín (Záběhlice)":[{"name":"Restaurant im Ovčín","cuisine":"Tschechisch/International","price":"€€","desc":"Direkt vor Ort — Bowling + Essen + Trinken."}],"Mysterium (Altstadt)":[{"name":"U Fleků","cuisine":"Tschechisch/Bier","price":"€€","desc":"Älteste kontinuierlich betriebene Brauerei Prags (1499). Muss man erlebt haben."},{"name":"Lokál U Caipla","cuisine":"Tschechisch/Bier","price":"€€","desc":"Modernes Tschechisch-Restaurant, 5 Min vom Altstädter Ring. Pilsner Urquell!"}],"Prague Ranger (Vyšehrad)":[{"name":"Restaurace U Krále Václava","cuisine":"Tschechisch","price":"€€","desc":"Nahe Vyšehrad, solide Hausmannskost."}],"Beer Boat (Štefánikův most)":[{"name":"KFC","cuisine":"Fast Food","price":"€","desc":"Direkt an der Bootsstation. Praktisch für danach."},{"name":"Café Mlýnská","cuisine":"Café/Tschechisch","price":"€€","desc":"Gemütliches Restaurant auf der Kampa-Insel, 10 Min."}],"Paintball (Buďánka)":[{"name":"Restaurace Na Vinici","cuisine":"Tschechisch","price":"€€","desc":"Nähe Paintball, traditionelle Küche. Ideal für After-Paintball-Essen."}],"Mercuria (Pankrác)":[{"name":"Pankrác Food Court (DBK)","cuisine":"Alles","price":"€","desc":"Modernes Einkaufszentrum mit viel Auswahl, 10 Min Fußweg."}],"Enigma (Altstadt)":[{"name":"Choco Café","cuisine":"Café/Süß","price":"€€","desc":"Schokoladen-Spezialitäten, perfekt für Energie-Tankstelle nach dem Spiel."}]};
function restaurantKeyFor(loc) {
if (loc.category === 'hotel') return 'Hotel Uno (Strašnice)';
const a = loc.address.toLowerCase();
if (a.includes('vršovice') || a.includes('petrohradská')) return 'Endorfin (Vršovice)';
if (a.includes('hrdlořezy') || a.includes('mezitraťová')) return 'KartPlanet (Hrdlořezy)';
if (a.includes('národní') || a.includes('máj')) return 'Level Minigolf (Národní/Máj)';
if (a.includes('záběhlice')) return 'Bowling Ovčín (Záběhlice)';
if (a.includes('pařížská') || a.includes('altstädter')) return 'Mysterium (Altstadt)';
if (a.includes('vyšehrad') || a.includes('lublaňská')) return 'Prague Ranger (Vyšehrad)';
if (a.includes('štefánikův')) return 'Beer Boat (Štefánikův most)';
if (a.includes('buďánka') || a.includes('císařkou')) return 'Paintball (Buďánka)';
if (a.includes('pankrác') || a.includes('budějovická')) return 'Mercuria (Pankrác)';
return 'Enigma (Altstadt)';
}
function restaurantsHtmlFor(loc) {
const key = restaurantKeyFor(loc);
const list = RESTAURANTS[key];
if (!list || list.length === 0) return '';
return `<div style="margin-top:8px;padding:8px;background:#dcfce7;border-left:3px solid #16a34a;border-radius:4px;">
<div style="font-size:11px;font-weight:700;color:#14532d;margin-bottom:4px;">🍽️ Restaurant-Tipps in der Nähe:</div>
${list.map(r => `<div style="font-size:12px;color:#14532d;margin-bottom:3px;"><strong>${r.name}</strong> <span style="color:#16a34a;font-weight:600;">${r.price}</span> <em style="color:#166534;">(${r.cuisine})</em><br><span style="color:#166534;font-size:11px;">${r.desc}</span></div>`).join('')}
</div>`;
}
let routeButtonsHtml = '';
if (isHotel) {
const sorted = otherLocations
.map(l => ({ ...l, dist: haversineKm(hotelLoc, l) }))
.sort((a, b) => a.dist - b.dist)
.slice(0, 3);
routeButtonsHtml = `<div style="margin-top:10px;border-top:1px solid #ddd;padding-top:10px;">
<div style="font-size:12px;font-weight:700;color:#1a1a1a;margin-bottom:6px;">🧭 Schnell-Routen vom Hotel:</div>
${sorted.map(l => `
<button class="route-btn" data-target-lat="${l.lat}" data-target-lon="${l.lon}" data-target-name="${l.name.replace(/"/g,'&quot;')}" data-target-emoji="${l.emoji}"
style="display:inline-block;margin:3px 4px 3px 0;padding:5px 10px;background:#3b82f6;color:#fff;border:none;border-radius:5px;font-size:12px;cursor:pointer;font-weight:600;">
${l.emoji} ${l.name.split(' ')[0]} (${l.dist.toFixed(1)} km)
</button>`).join('')}
</div>`;
} else {
routeButtonsHtml = `<button class="route-btn route-back" data-target-lat="${hotelLoc.lat}" data-target-lon="${hotelLoc.lon}" data-target-name="${hotelLoc.name}" data-target-emoji="${hotelLoc.emoji}"
style="margin-top:8px;padding:6px 12px;background:linear-gradient(135deg,#ffd700,#ffeb3b);color:#1a1a1a;border:none;border-radius:5px;font-size:13px;cursor:pointer;font-weight:700;width:100%;">
🧭 Zurück zum Hotel (Route)
</button>`;
}
// Feature 8: "Was ist in der Nähe?" — alle Nachbarn <2km
let nearbyHtml = '';
const neighbors = (isHotel ? otherLocations : locations.filter(l => l !== loc))
.map(l => ({ ...l, dist: haversineKm(loc, l) }))
.filter(l => l.dist < 3.0 && l.dist > 0.05)
.sort((a, b) => a.dist - b.dist);
if (neighbors.length > 0) {
nearbyHtml = `<div style="margin-top:8px;padding:8px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;">
<div style="font-size:11px;font-weight:700;color:#92400e;margin-bottom:4px;">📍 In der Nähe (&lt;3 km Luftlinie):</div>
<div style="font-size:12px;color:#451a03;">${neighbors.map(n => `${n.emoji} ${n.name.split(' ')[0]} <span style="color:#92400e;">${n.dist.toFixed(2)} km</span>`).join(' · ')}</div>
</div>`;
}
const popupContent = `
<div class="popup-content">
${bannerHtml}
<h3><span class="emoji-big">${loc.emoji}</span>${loc.name}</h3>
<div class="meta">📍 ${loc.address}</div>
<div class="desc">${loc.desc}</div>
${loc.price ? `<div class="price">${loc.price}</div>` : ''}
<a href="${loc.url}" target="_blank" rel="noopener">🔗 Website öffnen</a>
${routeButtonsHtml}
${nearbyHtml}
${restaurantsHtmlFor(loc)}
</div>
`;
marker.bindPopup(popupContent, { maxWidth: 320 });
bounds.push([loc.lat, loc.lon]);
if (isHotel) {
hotelMarker = marker;
// Permanentes Label TOOLTIP dauerhaft sichtbar (nicht interaktiv)
const labelMarker = L.marker([loc.lat, loc.lon], {
icon: createHotelLabel(),
interactive: false,
keyboard: false,
zIndexOffset: 999
}).addTo(map);
markersByCategory['hotel'] = [marker, labelMarker];
} else {
if (!markersByCategory[loc.category]) markersByCategory[loc.category] = [];
markersByCategory[loc.category].push(marker);
}
});
// Bounds an alle Marker anpassen (mit Padding, damit nichts am Rand klebt)
map.fitBounds(bounds, { padding: [80, 80] });
// Route-Button Click Handler (delegation an Map)
map.on('popupopen', (e) => {
const popupNode = e.popup.getElement();
if (!popupNode) return;
popupNode.querySelectorAll('.route-btn').forEach(btn => {
btn.onclick = async (ev) => {
ev.preventDefault();
const target = {
name: btn.dataset.targetName,
emoji: btn.dataset.targetEmoji,
lat: parseFloat(btn.dataset.targetLat),
lon: parseFloat(btn.dataset.targetLon)
};
const start = isHotelTarget(btn) ? otherLocations.find(l => Math.abs(l.lat - target.lat) < 0.0001 && Math.abs(l.lon - target.lon) < 0.0001) : hotelLoc;
const from = start || hotelLoc;
btn.disabled = true;
btn.textContent = '⏳ Route wird berechnet...';
const result = await drawRoute(from, target);
btn.textContent = `${result.distKm} km · ca. ${result.durMin} Min mit Auto${result.fallback ? ' (Luftlinie)' : ''}`;
btn.style.background = '#10b981';
btn.onclick = null;
};
});
});
// Legende
function isHotelTarget(btn) {
// Wenn btn "Zurück zum Hotel" hat → startet von der Location, Ziel ist Hotel
return btn.classList.contains('route-back');
}
// Auto-Open Hotel-Popup nach 1.5s beim ersten Laden
setTimeout(() => {
if (hotelMarker) hotelMarker.openPopup();
}, 1500);
// Bounds an alle Marker anpassen (mit Padding, damit Hotel-Label nicht abgeschnitten wird)
map.fitBounds(bounds, { padding: [120, 120] });
// Legende mit Layer-Toggle
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function() {
const div = L.DomUtil.create('div', 'legend');
div.innerHTML = `
<h4>🥳 JGA Prag Tourenplan</h4>
<div class="item"><span class="dot" style="background:#4fc3f7"></span>🏨 Hotel (Basis)</div>
<div class="item"><span class="dot" style="background:#4fc3f7;border-color:#ffd700;"></span>🏨 <strong>Hotel Uno (Basis)</strong></div>
<div class="item"><span class="dot" style="background:#e91e63"></span>🔐 Escape Room</div>
<div class="item"><span class="dot" style="background:#ff9800"></span>🏎️ KartPlanet</div>
<div class="item"><span class="dot" style="background:#66bb6a"></span>⛳ Minigolf</div>
@@ -247,10 +489,39 @@ legend.onAdd = function() {
<div class="item"><span class="dot" style="background:#ef5350"></span>🎨 Paintball</div>
<div class="item"><span class="dot" style="background:#26c6da"></span>🔫 Lasertag</div>
<div class="item"><span class="dot" style="background:#ffa726"></span>🔍 Enigma</div>
<div class="layer-toggle">
<div style="color:#aaa;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Filter</div>
<label><input type="checkbox" data-layer="hotel" checked disabled style="accent-color:#ffd700;"> 🏨 Hotel (immer sichtbar)</label>
<label><input type="checkbox" data-layer="action" checked style="accent-color:#e91e63;"> 💥 Action (5)</label>
<label><input type="checkbox" data-layer="fun" checked style="accent-color:#66bb6a;"> 🎉 Fun (4)</label>
<label><input type="checkbox" data-layer="sight" checked style="accent-color:#7e57c2;"> 🏛️ Sight (1)</label>
</div>
<div style="margin-top:8px;font-size:11px;color:#aaa;">📍 Karten-Zentrum: Hotel</div>
`;
// Layer-Toggle Handler
setTimeout(() => {
div.querySelectorAll('input[data-layer]').forEach(cb => {
cb.addEventListener('change', () => {
const layer = cb.dataset.layer;
(markersByCategory[layer] || []).forEach(m => {
if (cb.checked) m.addTo(map); else map.removeLayer(m);
});
});
});
}, 10);
return div;
};
legend.addTo(map);
</script>
<script>
// PWA Service Worker registrieren
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => console.log('[PWA] SW registered:', reg.scope))
.catch(err => console.warn('[PWA] SW registration failed:', err));
});
}
</script>
</body>
</html>