Bei neuem Service Worker wird die Seite automatisch neu geladen. Kein Popup mehr für die User.
690 lines
32 KiB
HTML
690 lines
32 KiB
HTML
<!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², 25–500 m Bahnen). Genaue Range-Adresse wird erst nach Buchung per Mail mitgeteilt.",
|
||
price: "ca. 100–200 € p.P. / 3–4h",
|
||
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. / 1–2h",
|
||
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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,'"')}" 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 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> |