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:
+288
-28
@@ -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 mit Tram & Metro</title>
|
||||
@@ -14,6 +20,57 @@
|
||||
#header h1 { margin: 0 0 4px 0; font-size: 22px; }
|
||||
#header .sub { color: #aaa; font-size: 13px; }
|
||||
#map { height: calc(100vh - 80px); width: 100%; }
|
||||
/* 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 { 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; }
|
||||
.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; }
|
||||
@@ -247,25 +304,25 @@ const locations = [
|
||||
category: "hotel"
|
||||
},
|
||||
{
|
||||
name: "Endorfin Escape Room",
|
||||
name: "Endorfin Escape Games",
|
||||
emoji: "🔐",
|
||||
color: "#e91e63",
|
||||
lat: 50.0639744, lon: 14.4516643,
|
||||
address: "Petrohradská 216/3, Praha 10 – Vinohrady",
|
||||
desc: "Premium Escape Rooms mit 60-Min Abenteuern. Mehrere Themes.",
|
||||
price: "ca. 25 € p.P.",
|
||||
url: "https://endorfin.cz",
|
||||
category: "fun"
|
||||
lat: 50.0639594, lon: 14.4520475,
|
||||
address: "Petrohradská 216/3, Praha 10 – Vršovice",
|
||||
desc: "Escape Rooms (Titanic, Saw, Freddy Krueger). Eingang vom Petrohradská-Hof. Max. 5 Pers/Raum → 2 Teams parallel.",
|
||||
price: "ca. 20 € p.P. / 1h",
|
||||
url: "https://endorfin.cz/en/escape-games/",
|
||||
category: "action"
|
||||
},
|
||||
{
|
||||
name: "KartPlanet Praha",
|
||||
emoji: "🏎️",
|
||||
color: "#ff9800",
|
||||
lat: 50.0753, lon: 14.5032,
|
||||
address: "Mezitraťová 420, Praha 9 – Hrdlořezy",
|
||||
desc: "Indoor Karting · 270m Strecke · 40 karts · Rennen mit Zeitmessung.",
|
||||
price: "ca. 30 € p.P. / 15 min",
|
||||
url: "https://www.kartplanet.cz",
|
||||
lat: 50.0998466, lon: 14.5133693,
|
||||
address: "Mezitraťová, Praha 9 – Hrdlořezy",
|
||||
desc: "Indoor Go-Kart. Schnellste Karts in Prag, ca. 10 Min Heizerlebnis.",
|
||||
price: "ca. 15 € p.P. / 10 min",
|
||||
url: "https://kartplanet.cz/en/",
|
||||
category: "action"
|
||||
},
|
||||
{
|
||||
@@ -274,8 +331,8 @@ const locations = [
|
||||
color: "#66bb6a",
|
||||
lat: 50.080850, lon: 14.425050,
|
||||
address: "Národní 63/26, Praha 1 – Nové Město (im Máj – House of Fun)",
|
||||
desc: "Indoor Minigolf mit Neon-Beleuchtung. 18 Löcher, Themen-Bahnen mit Überraschungen. Gaming-Bar vor Ort.",
|
||||
price: "ca. 12–25 € p.P.",
|
||||
desc: "Indoor Minigolf mit Neon-Beleuchtung. Themen-Bahnen mit Überraschungen. Auch Bowling & Escape Rooms im selben Komplex.",
|
||||
price: "ca. 25 € p.P. / 1h",
|
||||
url: "https://levelsprague.com/de/minigolf/",
|
||||
category: "fun"
|
||||
},
|
||||
@@ -291,18 +348,18 @@ const locations = [
|
||||
category: "fun"
|
||||
},
|
||||
{
|
||||
name: "Mysterium Geister-Tour",
|
||||
name: "Mysterium Tours – Geister-Tour",
|
||||
emoji: "👻",
|
||||
color: "#7e57c2",
|
||||
lat: 50.087800, lon: 14.420500,
|
||||
address: "Treffpunkt: Cartier-Geschäft, Ecke Pařížská / Altstädter Ring",
|
||||
desc: "Abendliche Geister-Tour durch die Altstadt + Josefov. Geschichtenerzähler in Kostümen mit Laterne. Dauer ~1:45h, Start 19:45 / 20:00 / 20:15.",
|
||||
price: "ca. 20 € p.P. / 1:45h",
|
||||
desc: "Abendliche Geister-Tour durch die Altstadt. Geschichten & Legenden. Startzeiten 19:45 / 20:00 / 20:15, Dauer ~1:45h.",
|
||||
price: "ca. 20 € p.P. / 2h",
|
||||
url: "https://www.mysteriumtours.com/de/prag/prager-geister-tour/",
|
||||
category: "sight"
|
||||
},
|
||||
{
|
||||
name: "Prague Ranger",
|
||||
name: "Prague Ranger (Schießstand)",
|
||||
emoji: "🎯",
|
||||
color: "#5d4037",
|
||||
lat: 50.0714571, lon: 14.4320070,
|
||||
@@ -353,7 +410,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.",
|
||||
price: "ca. 5 € p.P. / 1–2h",
|
||||
url: "https://enigma.swallnet.com/index.php/jak-hrat-en/",
|
||||
url: "https://enigma.swallnet.com/index.php/odkaz-mistra-hvezdare-en/",
|
||||
category: "fun"
|
||||
}
|
||||
];
|
||||
@@ -414,7 +471,8 @@ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
}).addTo(map);
|
||||
|
||||
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};
|
||||
@@ -425,40 +483,215 @@ 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]
|
||||
});
|
||||
}
|
||||
|
||||
// Marker setzen mit Layer-Toggle und Hotel-Highlight
|
||||
const bounds = [];
|
||||
const markersByCategory = {};
|
||||
let hotelMarker = null;
|
||||
let routeLayer = null;
|
||||
|
||||
const hotelLoc = locations.find(l => l.category === 'hotel');
|
||||
const otherLocations = locations.filter(l => l.category !== 'hotel');
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
return { distKm, durMin, from, to, fallback: true };
|
||||
}
|
||||
}
|
||||
|
||||
function isHotelTarget(btn) {
|
||||
return btn.classList.contains('route-back');
|
||||
}
|
||||
|
||||
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
|
||||
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,'"')}" 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 (<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 urlLink = loc.url ? `<a href="${loc.url}" target="_blank" rel="noopener">🔗 Website öffnen</a>` : '';
|
||||
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>` : ''}
|
||||
${loc.url ? `<a href="${loc.url}" target="_blank" rel="noopener">🔗 Website öffnen</a>` : ''}
|
||||
${urlLink}
|
||||
${routeButtonsHtml}
|
||||
${nearbyHtml}
|
||||
${restaurantsHtmlFor(loc)}
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent, { maxWidth: 320 });
|
||||
bounds.push([loc.lat, loc.lon]);
|
||||
|
||||
if (isHotel) {
|
||||
hotelMarker = marker;
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Route-Button Click Handler
|
||||
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 from = isHotelTarget(btn)
|
||||
? otherLocations.find(l => Math.abs(l.lat - target.lat) < 0.0001 && Math.abs(l.lon - target.lon) < 0.0001) || hotelLoc
|
||||
: 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;
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-Open Hotel-Popup nach 1.5s beim ersten Laden
|
||||
setTimeout(() => {
|
||||
if (hotelMarker) hotelMarker.openPopup();
|
||||
}, 1500);
|
||||
|
||||
const lineLayers = {};
|
||||
function renderLine(lineKey, color, weight, opacity) {
|
||||
const shapes = LINES[lineKey];
|
||||
@@ -618,7 +851,7 @@ 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>
|
||||
@@ -629,6 +862,13 @@ 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;padding-top:6px;border-top:1px solid #555;font-size:11px;color:#aaa;">
|
||||
🚇 Metro: <span style="color:#00A562;font-weight:700">A</span> ·
|
||||
<span style="color:#F8B322;font-weight:700">B</span> ·
|
||||
@@ -637,6 +877,16 @@ legend.onAdd = function() {
|
||||
<span style="font-size:10px;">Marker anklicken für Details</span>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
@@ -762,5 +1012,15 @@ document.addEventListener('keydown', (e) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user