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
+120 -20
View File
@@ -4,15 +4,62 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JGA Prag 2026 Übersicht</title>
<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">
<script>
// Theme-Toggle: localStorage > System-Preference > Dark
(function() {
const saved = localStorage.getItem('jga-theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (sysDark ? 'dark' : 'dark'); // Default Dark
document.documentElement.setAttribute('data-theme', theme);
})();
</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>">
<style>
/* ====== Theme-Variablen ====== */
:root, [data-theme="dark"] {
--bg: #1c1c1f;
--card-bg: #26262a;
--card-border: #353539;
--text: #e8e8ea;
--text-muted: #999;
--text-dim: #666;
--accent: #3b82f6;
--accent-hover: #2563eb;
--info-bg: #26262a;
--info-label: #888;
--footer-border: #2a2a2d;
--footer-text: #666;
}
[data-theme="light"] {
--bg: #f5f5f7;
--card-bg: #ffffff;
--card-border: #e0e0e3;
--text: #1a1a1a;
--text-muted: #555;
--text-dim: #888;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--info-bg: #ffffff;
--info-label: #6b6b6b;
--footer-border: #e0e0e3;
--footer-text: #888;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: #1c1c1f;
color: #e8e8ea;
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: background 0.3s, color 0.3s;
}
.container {
@@ -28,18 +75,18 @@
h1 {
font-size: 42px;
margin: 0 0 12px 0;
color: #fff;
color: var(--text);
font-weight: 700;
}
.subtitle {
font-size: 18px;
color: #999;
color: var(--text-muted);
margin: 0;
}
.badge {
display: inline-block;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
color: var(--accent);
padding: 4px 12px;
border-radius: 12px;
font-size: 13px;
@@ -47,6 +94,28 @@
border: 1px solid rgba(59, 130, 246, 0.3);
}
/* 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: 100;
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);
}
/* Emoji global in Original-Farbe */
.card-icon, h1, .badge, .info-item .label {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Segoe UI Symbol", emoji, sans-serif;
@@ -61,8 +130,8 @@
}
.card {
background: #26262a;
border: 1px solid #353539;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
padding: 28px;
transition: all 0.3s ease;
@@ -71,7 +140,7 @@
}
.card:hover {
transform: translateY(-2px);
border-color: #3b82f6;
border-color: var(--accent);
}
.card-icon {
font-size: 48px;
@@ -81,11 +150,11 @@
.card h2 {
margin: 0 0 8px 0;
font-size: 20px;
color: #fff;
color: var(--text);
font-weight: 600;
}
.card .description {
color: #aaa;
color: var(--text-muted);
font-size: 14px;
line-height: 1.6;
margin: 0 0 16px 0;
@@ -94,7 +163,7 @@
.card .open-btn {
display: inline-block;
padding: 10px 20px;
background: #3b82f6;
background: var(--accent);
color: #fff;
text-decoration: none;
border-radius: 6px;
@@ -103,14 +172,14 @@
transition: background 0.2s;
}
.card .open-btn:hover {
background: #2563eb;
background: var(--accent-hover);
}
/* Section Header */
.section-title {
font-size: 22px;
margin: 48px 0 24px 0;
color: #fff;
color: var(--text);
font-weight: 600;
}
@@ -122,21 +191,21 @@
margin-bottom: 32px;
}
.info-item {
background: #26262a;
border-left: 3px solid #3b82f6;
background: var(--info-bg);
border-left: 3px solid var(--accent);
padding: 16px;
border-radius: 4px;
}
.info-item .label {
font-size: 12px;
color: #888;
color: var(--info-label);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.info-item .value {
font-size: 16px;
color: #fff;
color: var(--text);
font-weight: 600;
}
@@ -168,11 +237,14 @@
footer {
text-align: center;
padding: 32px 0 16px 0;
color: #666;
color: var(--footer-text);
font-size: 12px;
border-top: 1px solid #2a2a2d;
border-top: 1px solid var(--footer-border);
margin-top: 48px;
}
footer a {
color: var(--accent) !important;
}
@media (max-width: 600px) {
h1 { font-size: 32px; }
@@ -184,6 +256,8 @@
<body>
<div class="container">
<button class="theme-toggle" id="theme-toggle" aria-label="Theme wechseln" title="Light/Dark Mode">🌙</button>
<header>
<h1>🧭 JGA Prag 2026</h1>
<p class="subtitle">Interaktive Karten, Wetter-Vorschau & Packliste für den Junggesellenabschied</p>
@@ -346,7 +420,7 @@
<div class="card-icon">🔍</div>
<h2>Enigma Outdoor Adventure</h2>
<p class="description">Stadtweites Mobile-App Mystery-Spiel. Kein fester Treffpunkt.</p>
<a href="https://enigma.swallnet.com/" target="_blank" rel="noopener" class="open-btn">🔗 Website öffnen</a>
<a href="https://enigma.swallnet.com/index.php/odkaz-mistra-hvezdare-en/" target="_blank" rel="noopener" class="open-btn">🔗 Website öffnen</a>
</div>
</div>
@@ -360,6 +434,32 @@
<script>
document.getElementById('build-date').textContent = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
// 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();
});
</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>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "JGA Prag 2026",
"short_name": "JGA Prag",
"description": "Interaktive Karten, Wetter-Vorschau, Packliste und mehr für den Junggesellenabschied in Prag (25.26. Juli 2026)",
"start_url": "./index.html",
"scope": "./",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1c1c1f",
"theme_color": "#3b82f6",
"lang": "de",
"categories": ["travel", "lifestyle", "utilities"],
"icons": [
{
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 192 192'><rect width='192' height='192' fill='%231c1c1f'/><text y='150' x='50%' text-anchor='middle' font-size='140'>🧭</text></svg>",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' fill='%231c1c1f'/><text y='400' x='50%' text-anchor='middle' font-size='380'>🧭</text></svg>",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+17 -1
View File
@@ -4,7 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JGA Prag Packliste</title>
<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>">
<style>
* { box-sizing: border-box; }
html, body {
@@ -276,5 +282,15 @@
updateProgress();
</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>
+70
View File
@@ -0,0 +1,70 @@
// JGA Prag 2026 — Service Worker
// Cache-Strategie: cache-first für HTML/Assets, network-first für externe APIs (OSRM)
const CACHE_VERSION = 'jga-2026-v1';
const CORE_ASSETS = [
'./',
'./index.html',
'./tourenplan.html',
'./tourenplan-mit-tram.html',
'./wetter.html',
'./packliste.html',
'./manifest.json'
];
// Install: alle Core-Files cachen
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION)
.then(cache => cache.addAll(CORE_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: alte Caches aufräumen
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
// Fetch: cache-first für eigene Files, network-first für externe APIs
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Externe APIs (OSRM, OpenStreetMap, Wetter) → network-first
if (url.origin !== self.location.origin) {
event.respondWith(
fetch(event.request)
.then(response => {
// Erfolgreich: clone & cache für offline
if (response.ok && (url.hostname.includes('openstreetmap') ||
url.hostname.includes('cartocdn') ||
url.hostname.includes('unpkg'))) {
const clone = response.clone();
caches.open(CACHE_VERSION).then(c => c.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Eigene Files → cache-first
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request)
.then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_VERSION).then(c => c.put(event.request, clone));
}
return response;
})
)
);
});
+288 -28
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 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. 1225 € 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. / 12h",
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,'&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 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>
+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>
+17 -1
View File
@@ -4,7 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prag Wetter JGA 25.26. Juli 2026</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><text y=%2226%22 font-size=%2228%22>🧭</text></svg>">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22>
<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=%2226%22 font-size=%2228%22>🧭</text></svg>">
<style>
* { box-sizing: border-box; }
html, body { margin:0; padding:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:#0f0f12; color:#eee; min-height:100vh; }
@@ -98,5 +104,15 @@ h1 { font-size:42px; margin:0 0 8px 0; font-weight:700; letter-spacing:-1px; }
</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>