Files
JGA-2026/tourenplan.html
Hermes AI Bot 4e1691ac37 fix(cache): auto-reload bei SW-Update statt confirm-Prompt
Bei neuem Service Worker wird die Seite automatisch neu geladen.
Kein Popup mehr für die User.
2026-06-27 13:08:41 +00:00

690 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<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">
<script>
// Theme-Toggle: localStorage > System-Preference > Dark
(function() {
const saved = localStorage.getItem('jga-theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', saved || (sysDark ? 'dark' : 'dark'));
})();
</script>
<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>">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JGA Prag Tourenplan</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<style>
/* ====== Theme-Variablen ====== */
:root, [data-theme="dark"] {
--bg: #1a1a1a;
--bg-gradient-start: #2d2d2d;
--bg-gradient-end: #1a1a1a;
--card-bg: rgba(30,30,30,0.95);
--card-border: #444;
--text: #eee;
--text-muted: #aaa;
--text-dim: #555;
--header-border: #444;
--popup-bg: #fff;
--popup-text: #1a1a1a;
--popup-meta: #555;
--popup-desc: #333;
--accent: #1976d2;
}
[data-theme="light"] {
--bg: #f5f5f7;
--bg-gradient-start: #ffffff;
--bg-gradient-end: #f0f0f3;
--card-bg: rgba(255,255,255,0.95);
--card-border: #d0d0d5;
--text: #1a1a1a;
--text-muted: #555;
--text-dim: #888;
--header-border: #d0d0d5;
--popup-bg: #fff;
--popup-text: #1a1a1a;
--popup-meta: #555;
--popup-desc: #333;
--accent: #1976d2;
}
html, body { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); transition: background 0.3s, color 0.3s; }
#header { padding: 16px 20px; background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); border-bottom: 2px solid var(--header-border); position: relative; }
#header h1 { margin: 0 0 4px 0; font-size: 22px; }
#header .sub { color: var(--text-muted); font-size: 13px; }
/* Theme-Toggle Button */
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--card-border);
color: var(--text);
font-size: 20px;
cursor: pointer;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transition: all 0.2s;
display: flex; align-items: center; justify-content: center;
}
.theme-toggle:hover {
transform: rotate(15deg) scale(1.1);
border-color: var(--accent);
}
/* Home-Button (oben links) */
.home-btn {
position: fixed;
top: 16px;
left: 16px;
z-index: 1000;
background: var(--card-bg);
border: 1px solid var(--card-border);
color: var(--text);
text-decoration: none;
padding: 10px 16px;
border-radius: 22px;
font-size: 14px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.home-btn:hover {
transform: translateX(-2px);
border-color: var(--accent);
color: var(--accent);
}
/* Mobile: kleinere Buttons, mehr Platz für Karte */
@media (max-width: 768px) {
.home-btn { top: 10px; left: 10px; padding: 8px 12px; font-size: 13px; }
.theme-toggle { top: 10px; right: 10px; width: 38px; height: 38px; font-size: 18px; }
.leaflet-control { margin: 8px !important; }
.leaflet-top.leaflet-left .leaflet-control { margin-top: 56px !important; }
.legend { max-width: 180px !important; font-size: 12px !important; }
.legend h4 { font-size: 12px !important; }
}
#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 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: var(--card-bg); padding: 10px 12px; border-radius: 8px; color: var(--text); font-size: 13px; box-shadow: 0 2px 8px rgba(0,0,0,0.4); border: 1px solid var(--card-border); max-width: 220px; backdrop-filter: blur(8px); }
.legend h4 { margin: 0; font-size: 13px; color: var(--text); cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.legend h4 .toggle { margin-left: auto; font-size: 10px; opacity: 0.6; transition: transform 0.2s; }
.legend.open h4 .toggle { transform: rotate(180deg); }
.legend .body { display: none; margin-top: 8px; max-height: 280px; overflow-y: auto; }
.legend.open .body { display: block; }
.legend .item { display: flex; align-items: center; gap: 6px; margin: 3px 0; color: var(--text); font-size: 12px; }
.legend .emoji { font-size: 14px; }
.legend .dot { width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; flex-shrink: 0; }
.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; }
.popup-content .meta { font-size: 12px; color: #555; margin: 4px 0; }
.popup-content .desc { font-size: 13px; color: #333; margin: 6px 0; }
.popup-content .price { display: inline-block; background: #ffeb3b; color: #1a1a1a; padding: 2px 8px; border-radius: 4px; font-weight: 600; font-size: 12px; margin-top: 4px; }
.popup-content a { display: inline-block; margin-top: 8px; padding: 5px 10px; background: #1976d2; color: white; text-decoration: none; border-radius: 4px; font-size: 12px; }
.popup-content a:hover { background: #1565c0; }
.leaflet-popup-content-wrapper { border-radius: 8px; }
.marker-label { background: rgba(30,30,30,0.9); color: white; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; white-space: nowrap; box-shadow: 0 1px 4px rgba(0,0,0,0.4); border: none; }
.marker-label::before { display: none; }
</style>
</head>
<body>
<div id="header">
<h1>🥳 JGA Prag Tourenplan</h1>
<div class="sub">25.26. Juli 2026 · 8 Personen · Hotel Uno Prague als Basis</div>
</div>
<a href="index.html" class="home-btn" title="Zurück zur Startseite">🏠 Startseite</a>
<button class="theme-toggle" id="theme-toggle" aria-label="Theme wechseln" title="Light/Dark Mode">🌙</button>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script>
const locations = [
// Hotel - immer als erstes
{
name: "Hotel Uno Prague",
emoji: "🏨",
color: "#4fc3f7",
lat: 50.0717636, lon: 14.4999461,
address: "Štěchovická 2296/2a, Praha 10 Strašnice",
desc: "Unsere Unterkunft. Tram 'Na Hroudě' (Linien 4, 13, 26) in 3 Min Fußweg.",
price: null,
url: "https://www.unoprague.cz",
category: "hotel"
},
{
name: "Endorfin Escape Games",
emoji: "🔐",
color: "#e91e63",
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.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"
},
{
name: "Level Prague Minigolf",
emoji: "⛳",
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. 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"
},
{
name: "Bowling Ovčín",
emoji: "🎳",
color: "#ab47bc",
lat: 50.0537395, lon: 14.4875385,
address: "Záběhlická 82/3, Praha 10 Záběhlice",
desc: "Bowling + Billard + Restaurant. Gemütliche Atmosphäre, ideal für Gruppen.",
price: "ca. 6 € p.P. / 2h",
url: "https://www.ovcin.cz/",
category: "fun"
},
{
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. 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 (Schießstand)",
emoji: "🎯",
color: "#5d4037",
lat: 50.0714571, lon: 14.4320070,
address: "Shuttle-Pickup: Lublaňská 1729/23, Praha 2 Vyšehrad",
desc: "Outdoor-Schießstand, ca. 45 Min vom Stadtzentrum. Shuttle im Preis inklusive. Großer Komplex (70.000 m², 25500 m Bahnen). Genaue Range-Adresse wird erst nach Buchung per Mail mitgeteilt.",
price: "ca. 100200 € p.P. / 34h",
url: "https://www.pragueranger.cz/de/",
category: "action"
},
{
name: "Beer Boat Prague",
emoji: "⛵",
color: "#ffb300",
lat: 50.0941774, lon: 14.4269959,
address: "Startpunkt: Štefánikův most (Dvořákovo nábřeží), Praha 1",
desc: "Tretboot-Biertour auf der Moldau. Privater Kapitän, unbegrenzt Bier. Büro: Týnská 639/4, Praha 1.",
price: "ca. 45 € p.P. / 1h",
url: "https://beerboatsprague.com/de/Prague-Beer-Boat-Tour",
category: "fun"
},
{
name: "Paintball Game Praha",
emoji: "🎨",
color: "#ef5350",
lat: 50.0748169, lon: 14.3713262,
address: "Pod Císařkou, Praha 5 Buďánka",
desc: "Paintball mit Reball (kein Schmerz, wiederverwendbare Bälle). Indoor + Outdoor Kombi.",
price: "ca. 24 € p.P. / 1h",
url: "https://www.paintballgame.cz/de/paintball-pakete/reball",
category: "action"
},
{
name: "FunArena Axtwerfen",
emoji: "🪓",
color: "#a16207",
lat: 50.0657300, lon: 14.4386789,
address: "Bělehradská, Praha 4 Nusle (10 Min Fußweg von Metro C)",
desc: "Axtwerfen — hipper Trend, perfekt für Gruppen. Indoor. Min. 6 Personen, Coaching inklusive.",
price: "ca. 16 € p.P. / 1h",
url: "https://www.funarena.cz/en/activities/axe-throwing",
category: "action"
},
{
name: "Mercuria Laser Game Pankrác",
emoji: "🔫",
color: "#26c6da",
lat: 50.0501649, lon: 14.4432250,
address: "Budějovická 409/1a, Praha 4 Pankrác (unter Bauhaus, neben Penny)",
desc: "Lasertag Arena. Mehrere Etagen mit Hindernissen.",
price: "ca. 7 € p.P. / 20 min",
url: "https://mercurialaser.cz/en/pankrac/",
category: "action"
},
{
name: "Enigma Outdoor City Adventure",
emoji: "🔍",
color: "#ffa726",
lat: 50.0875, lon: 14.4213,
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/odkaz-mistra-hvezdare-en/",
category: "fun"
}
];
// Karte initialisieren - Start auf Hotel Uno, fitBounds überschreibt das gleich
const map = L.map('map').setView([50.0718, 14.4999], 11);
// Tile-Layer je nach Theme wählen (Dark = CartoDB Dark Matter, Light = CartoDB Positron)
const tileUrl = (document.documentElement.getAttribute('data-theme') || 'dark') === 'light'
? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const tileLayer = L.tileLayer(tileUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
// Theme-Switching für Tile-Layer
function switchMapTheme(newTheme) {
const url = newTheme === 'light'
? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
tileLayer.setUrl(url);
}
// Custom Marker mit Emoji + Label
function createIcon(emoji, color, isHotel=false) {
const size = isHotel ? 56 : 38;
const wrapperClass = isHotel ? 'custom-marker hotel-marker' : 'custom-marker';
const html = `
<div style="
background: ${color};
width: ${size}px;
height: ${size}px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
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 ? 28 : 19}px;">${emoji}</span>
</div>
`;
return L.divIcon({
html: html,
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),
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."}],"FunArena (Nusle)":[{"name":"Restaurace U Krále Václava","cuisine":"Tschechisch","price":"€€","desc":"Nahe Vyšehrad/Nusle, solide Hausmannskost. 10 Min Fußweg."},{"name":"Café Bar Botič","cuisine":"Café/Bistro","price":"€","desc":"Hippes Café in Nusle, perfekt für After-Axtwerfen-Drinks."}],"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('nusle') || a.includes('bělehradská')) return 'FunArena (Nusle)';
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);
}
});
// 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;
};
});
});
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 onclick="this.parentElement.classList.toggle('open')">📍 Legende<span class="toggle">▼</span></h4>
<div class="body">
<div class="item"><span class="dot" style="background:#4fc3f7;border-color:#ffd700;"></span>🏨 <strong>Hotel Uno</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>
<div class="item"><span class="dot" style="background:#ab47bc"></span>🎳 Bowling</div>
<div class="item"><span class="dot" style="background:#7e57c2"></span>👻 Geister-Tour</div>
<div class="item"><span class="dot" style="background:#5d4037"></span>🎯 Schießstand</div>
<div class="item"><span class="dot" style="background:#ffb300"></span>⛵ Beer Boat</div>
<div class="item"><span class="dot" style="background:#ef5350"></span>🎨 Paintball</div>
<div class="item"><span class="dot" style="background:#a16207"></span>🪓 Axtwerfen</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:var(--text-muted);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</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:var(--text-muted);">📍 Karten-Zentrum: Hotel</div>
</div>
`;
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(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>
// Theme-Toggle
const themeBtn = document.getElementById('theme-toggle');
function updateThemeIcon() {
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
themeBtn.textContent = cur === 'dark' ? '☀️' : '🌙';
themeBtn.title = cur === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren';
}
updateThemeIcon();
themeBtn.addEventListener('click', () => {
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
const next = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('jga-theme', next);
updateThemeIcon();
if (typeof switchMapTheme === 'function') switchMapTheme(next);
});
</script>
<script>
// PWA Service Worker — Network-First Strategie (Updates kommen sofort durch)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => {
console.log('[PWA] SW registered:', reg.scope);
// Update-Hook: wenn ein neuer SW installiert wurde, kurz reloaden
reg.addEventListener('updatefound', () => {
const newSw = reg.installing;
if (!newSw) return;
newSw.addEventListener('statechange', () => {
if (newSw.state === 'activated' && navigator.serviceWorker.controller) {
console.log('[PWA] Neue Version aktiviert — lade automatisch neu');
window.location.reload();
}
});
});
})
.catch(err => console.warn('[PWA] SW registration failed:', err));
});
}
</script>
</body>
</html>