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:
+120
-20
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -1,7 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧭</text></svg>">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<meta name="theme-color" content="#3b82f6">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="JGA Prag">
|
||||
<meta name="mobile-web-app-capable" content="yes"><text y='.9em' font-size='90'>🧭</text></svg>">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JGA Prag – Tourenplan mit Tram & Metro</title>
|
||||
@@ -14,6 +20,57 @@
|
||||
#header h1 { margin: 0 0 4px 0; font-size: 22px; }
|
||||
#header .sub { color: #aaa; font-size: 13px; }
|
||||
#map { height: calc(100vh - 80px); width: 100%; }
|
||||
/* Hotel-Marker: größer + Gold-Rahmen + pulsierender Glow + permanentes Label */
|
||||
.hotel-marker {
|
||||
position: relative;
|
||||
}
|
||||
.hotel-marker::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,215,0,0.35) 0%, rgba(255,215,0,0) 70%);
|
||||
animation: hotelPulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hotelPulse {
|
||||
0%, 100% { transform: scale(0.9); opacity: 0.7; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
.hotel-marker > div {
|
||||
background: #4fc3f7 !important;
|
||||
border: 4px solid #ffd700 !important;
|
||||
box-shadow: 0 0 16px rgba(255,215,0,0.6), 0 4px 12px rgba(0,0,0,0.6) !important;
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
.hotel-label {
|
||||
background: rgba(255,215,0,0.95) !important;
|
||||
color: #1a1a1a !important;
|
||||
font-weight: 800 !important;
|
||||
font-size: 13px !important;
|
||||
padding: 4px 10px !important;
|
||||
border: 2px solid #4fc3f7 !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.5) !important;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hotel-label::before { display: none; }
|
||||
.hotel-banner {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffeb3b 100%);
|
||||
color: #1a1a1a;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 2px 6px rgba(255,215,0,0.3);
|
||||
}
|
||||
.layer-toggle { margin-top: 8px; padding-top: 6px; border-top: 1px solid #555; font-size: 12px; }
|
||||
.layer-toggle label { display: flex; align-items: center; gap: 6px; margin: 3px 0; cursor: pointer; user-select: none; color: #eee; }
|
||||
.layer-toggle input { cursor: pointer; }
|
||||
.legend { background: rgba(30,30,30,0.95); padding: 10px 12px; border-radius: 8px; color: #eee; font-size: 13px; box-shadow: 0 2px 8px rgba(0,0,0,0.4); border: 1px solid #444; max-width: 240px; }
|
||||
.legend h4 { margin: 0 0 8px 0; font-size: 14px; }
|
||||
.legend .item { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
||||
@@ -247,25 +304,25 @@ const locations = [
|
||||
category: "hotel"
|
||||
},
|
||||
{
|
||||
name: "Endorfin Escape Room",
|
||||
name: "Endorfin Escape Games",
|
||||
emoji: "🔐",
|
||||
color: "#e91e63",
|
||||
lat: 50.0639744, lon: 14.4516643,
|
||||
address: "Petrohradská 216/3, Praha 10 – Vinohrady",
|
||||
desc: "Premium Escape Rooms mit 60-Min Abenteuern. Mehrere Themes.",
|
||||
price: "ca. 25 € p.P.",
|
||||
url: "https://endorfin.cz",
|
||||
category: "fun"
|
||||
lat: 50.0639594, lon: 14.4520475,
|
||||
address: "Petrohradská 216/3, Praha 10 – Vršovice",
|
||||
desc: "Escape Rooms (Titanic, Saw, Freddy Krueger). Eingang vom Petrohradská-Hof. Max. 5 Pers/Raum → 2 Teams parallel.",
|
||||
price: "ca. 20 € p.P. / 1h",
|
||||
url: "https://endorfin.cz/en/escape-games/",
|
||||
category: "action"
|
||||
},
|
||||
{
|
||||
name: "KartPlanet Praha",
|
||||
emoji: "🏎️",
|
||||
color: "#ff9800",
|
||||
lat: 50.0753, lon: 14.5032,
|
||||
address: "Mezitraťová 420, Praha 9 – Hrdlořezy",
|
||||
desc: "Indoor Karting · 270m Strecke · 40 karts · Rennen mit Zeitmessung.",
|
||||
price: "ca. 30 € p.P. / 15 min",
|
||||
url: "https://www.kartplanet.cz",
|
||||
lat: 50.0998466, lon: 14.5133693,
|
||||
address: "Mezitraťová, Praha 9 – Hrdlořezy",
|
||||
desc: "Indoor Go-Kart. Schnellste Karts in Prag, ca. 10 Min Heizerlebnis.",
|
||||
price: "ca. 15 € p.P. / 10 min",
|
||||
url: "https://kartplanet.cz/en/",
|
||||
category: "action"
|
||||
},
|
||||
{
|
||||
@@ -274,8 +331,8 @@ const locations = [
|
||||
color: "#66bb6a",
|
||||
lat: 50.080850, lon: 14.425050,
|
||||
address: "Národní 63/26, Praha 1 – Nové Město (im Máj – House of Fun)",
|
||||
desc: "Indoor Minigolf mit Neon-Beleuchtung. 18 Löcher, Themen-Bahnen mit Überraschungen. Gaming-Bar vor Ort.",
|
||||
price: "ca. 12–25 € p.P.",
|
||||
desc: "Indoor Minigolf mit Neon-Beleuchtung. Themen-Bahnen mit Überraschungen. Auch Bowling & Escape Rooms im selben Komplex.",
|
||||
price: "ca. 25 € p.P. / 1h",
|
||||
url: "https://levelsprague.com/de/minigolf/",
|
||||
category: "fun"
|
||||
},
|
||||
@@ -291,18 +348,18 @@ const locations = [
|
||||
category: "fun"
|
||||
},
|
||||
{
|
||||
name: "Mysterium Geister-Tour",
|
||||
name: "Mysterium Tours – Geister-Tour",
|
||||
emoji: "👻",
|
||||
color: "#7e57c2",
|
||||
lat: 50.087800, lon: 14.420500,
|
||||
address: "Treffpunkt: Cartier-Geschäft, Ecke Pařížská / Altstädter Ring",
|
||||
desc: "Abendliche Geister-Tour durch die Altstadt + Josefov. Geschichtenerzähler in Kostümen mit Laterne. Dauer ~1:45h, Start 19:45 / 20:00 / 20:15.",
|
||||
price: "ca. 20 € p.P. / 1:45h",
|
||||
desc: "Abendliche Geister-Tour durch die Altstadt. Geschichten & Legenden. Startzeiten 19:45 / 20:00 / 20:15, Dauer ~1:45h.",
|
||||
price: "ca. 20 € p.P. / 2h",
|
||||
url: "https://www.mysteriumtours.com/de/prag/prager-geister-tour/",
|
||||
category: "sight"
|
||||
},
|
||||
{
|
||||
name: "Prague Ranger",
|
||||
name: "Prague Ranger (Schießstand)",
|
||||
emoji: "🎯",
|
||||
color: "#5d4037",
|
||||
lat: 50.0714571, lon: 14.4320070,
|
||||
@@ -353,7 +410,7 @@ const locations = [
|
||||
address: "Symbolischer Startpunkt: Altstädter Ring (Staroměstské náměstí)",
|
||||
desc: "Stadtweites Mobile-App Mystery-Spiel. Per GPS durch ganz Prag.",
|
||||
price: "ca. 5 € p.P. / 1–2h",
|
||||
url: "https://enigma.swallnet.com/index.php/jak-hrat-en/",
|
||||
url: "https://enigma.swallnet.com/index.php/odkaz-mistra-hvezdare-en/",
|
||||
category: "fun"
|
||||
}
|
||||
];
|
||||
@@ -414,7 +471,8 @@ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
}).addTo(map);
|
||||
|
||||
function createIcon(emoji, color, isHotel=false) {
|
||||
const size = isHotel ? 44 : 38;
|
||||
const size = isHotel ? 56 : 38;
|
||||
const wrapperClass = isHotel ? 'custom-marker hotel-marker' : 'custom-marker';
|
||||
const html = `
|
||||
<div style="
|
||||
background: ${color};
|
||||
@@ -425,40 +483,215 @@ function createIcon(emoji, color, isHotel=false) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.5);
|
||||
border: ${isHotel ? 4 : 3}px solid ${isHotel ? '#ffd700' : 'white'};
|
||||
box-shadow: ${isHotel ? '0 0 16px rgba(255,215,0,0.6), 0 4px 12px rgba(0,0,0,0.6)' : '0 3px 8px rgba(0,0,0,0.5)'};
|
||||
">
|
||||
<span style="transform: rotate(45deg); font-size: ${isHotel ? 22 : 19}px;">${emoji}</span>
|
||||
<span style="transform: rotate(45deg); font-size: ${isHotel ? 28 : 19}px;">${emoji}</span>
|
||||
</div>
|
||||
`;
|
||||
return L.divIcon({
|
||||
html: html,
|
||||
className: 'custom-marker',
|
||||
className: wrapperClass,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size/2, size],
|
||||
popupAnchor: [0, -size]
|
||||
});
|
||||
}
|
||||
|
||||
// Permanentes Label für Hotel (sichtbar ohne Klick)
|
||||
function createHotelLabel() {
|
||||
return L.divIcon({
|
||||
html: '<div class="hotel-label">🏨 Hotel Uno · Basis</div>',
|
||||
className: 'hotel-label-wrapper',
|
||||
iconSize: [140, 22],
|
||||
iconAnchor: [70, 56+22]
|
||||
});
|
||||
}
|
||||
|
||||
// Marker setzen mit Layer-Toggle und Hotel-Highlight
|
||||
const bounds = [];
|
||||
const markersByCategory = {};
|
||||
let hotelMarker = null;
|
||||
let routeLayer = null;
|
||||
|
||||
const hotelLoc = locations.find(l => l.category === 'hotel');
|
||||
const otherLocations = locations.filter(l => l.category !== 'hotel');
|
||||
|
||||
function haversineKm(a, b) {
|
||||
const R = 6371;
|
||||
const dLat = (b.lat - a.lat) * Math.PI / 180;
|
||||
const dLon = (b.lon - a.lon) * Math.PI / 180;
|
||||
const lat1 = a.lat * Math.PI / 180;
|
||||
const lat2 = b.lat * Math.PI / 180;
|
||||
const x = Math.sin(dLat/2)**2 + Math.cos(lat1)*Math.cos(lat2)*Math.sin(dLon/2)**2;
|
||||
return 2 * R * Math.asin(Math.sqrt(x));
|
||||
}
|
||||
|
||||
async function drawRoute(from, to) {
|
||||
if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; }
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${from.lon},${from.lat};${to.lon},${to.lat}?overview=full&geometries=geojson`;
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('OSRM ' + r.status);
|
||||
const data = await r.json();
|
||||
if (!data.routes || data.routes.length === 0) throw new Error('Keine Route');
|
||||
const coords = data.routes[0].geometry.coordinates.map(([lng, lat]) => [lat, lng]);
|
||||
const distKm = (data.routes[0].distance / 1000).toFixed(1);
|
||||
const durMin = Math.round(data.routes[0].duration / 60);
|
||||
routeLayer = L.polyline(coords, {
|
||||
color: '#06b6d4', weight: 5, opacity: 0.85, dashArray: '10,6',
|
||||
lineCap: 'round', lineJoin: 'round'
|
||||
}).addTo(map);
|
||||
map.fitBounds(routeLayer.getBounds(), { padding: [80, 80] });
|
||||
return { distKm, durMin, from, to };
|
||||
} catch (e) {
|
||||
const distKm = haversineKm(from, to).toFixed(1);
|
||||
routeLayer = L.polyline([[from.lat, from.lon], [to.lat, to.lon]], {
|
||||
color: '#06b6d4', weight: 4, opacity: 0.6, dashArray: '6,8'
|
||||
}).addTo(map);
|
||||
const durMin = Math.round(distKm * 2.5);
|
||||
return { distKm, durMin, from, to, fallback: true };
|
||||
}
|
||||
}
|
||||
|
||||
function isHotelTarget(btn) {
|
||||
return btn.classList.contains('route-back');
|
||||
}
|
||||
|
||||
locations.forEach(loc => {
|
||||
const isHotel = loc.category === 'hotel';
|
||||
const marker = L.marker([loc.lat, loc.lon], {
|
||||
icon: createIcon(loc.emoji, loc.color, isHotel)
|
||||
icon: createIcon(loc.emoji, loc.color, isHotel),
|
||||
zIndexOffset: isHotel ? 1000 : 0
|
||||
}).addTo(map);
|
||||
|
||||
const bannerHtml = isHotel ? '<div class="hotel-banner">⭐ Unsere Basis · Hier startet & endet jeder Tag</div>' : '';
|
||||
|
||||
// Restaurant-Tipps pro Location
|
||||
const RESTAURANTS = {"Hotel Uno (Strašnice)":[{"name":"Restaurace U Kohouta","cuisine":"Tschechisch traditionell","price":"€€","desc":"Klassische tschechische Küche, 10 Min vom Hotel. Goulash & Knödel."},{"name":"Pizza Coloseum Strašnice","cuisine":"Italienisch/Pizza","price":"€","desc":"Solide Pizza, 8 Min zu Fuß. Auch Lieferservice."}],"Endorfin (Vršovice)":[{"name":"Kavárna Kaaba","cuisine":"Café/Brunch","price":"€","desc":"Hippes Café in Vršovice, perfekt nach Escape Room."},{"name":"Pivovar U Medvídků","cuisine":"Tschechisch/Bier","price":"€€","desc":"Berühmte Hausbrauerei (Novoměstský), 15 Min mit Tram."}],"KartPlanet (Hrdlořezy)":[{"name":"Restaurace U Přístavu","cuisine":"Tschechisch","price":"€€","desc":"5 Min von KartPlanet, direkt am Wasser. Gute Knödel."}],"Level Minigolf (Národní/Máj)":[{"name":"Máj Food Court","cuisine":"Alles","price":"€","desc":"Im selben Haus, 5. OG. Burger, Asia, Pizza — schnell & günstig."},{"name":"Café Louvre","cuisine":"Café/Klassisch","price":"€€","desc":"Legendäres Wiener Kaffeehaus, 3 Min Fußweg. Atmosphäre!"}],"Bowling Ovčín (Záběhlice)":[{"name":"Restaurant im Ovčín","cuisine":"Tschechisch/International","price":"€€","desc":"Direkt vor Ort — Bowling + Essen + Trinken."}],"Mysterium (Altstadt)":[{"name":"U Fleků","cuisine":"Tschechisch/Bier","price":"€€","desc":"Älteste kontinuierlich betriebene Brauerei Prags (1499). Muss man erlebt haben."},{"name":"Lokál U Caipla","cuisine":"Tschechisch/Bier","price":"€€","desc":"Modernes Tschechisch-Restaurant, 5 Min vom Altstädter Ring. Pilsner Urquell!"}],"Prague Ranger (Vyšehrad)":[{"name":"Restaurace U Krále Václava","cuisine":"Tschechisch","price":"€€","desc":"Nahe Vyšehrad, solide Hausmannskost."}],"Beer Boat (Štefánikův most)":[{"name":"KFC","cuisine":"Fast Food","price":"€","desc":"Direkt an der Bootsstation. Praktisch für danach."},{"name":"Café Mlýnská","cuisine":"Café/Tschechisch","price":"€€","desc":"Gemütliches Restaurant auf der Kampa-Insel, 10 Min."}],"Paintball (Buďánka)":[{"name":"Restaurace Na Vinici","cuisine":"Tschechisch","price":"€€","desc":"Nähe Paintball, traditionelle Küche. Ideal für After-Paintball-Essen."}],"Mercuria (Pankrác)":[{"name":"Pankrác Food Court (DBK)","cuisine":"Alles","price":"€","desc":"Modernes Einkaufszentrum mit viel Auswahl, 10 Min Fußweg."}],"Enigma (Altstadt)":[{"name":"Choco Café","cuisine":"Café/Süß","price":"€€","desc":"Schokoladen-Spezialitäten, perfekt für Energie-Tankstelle nach dem Spiel."}]};
|
||||
|
||||
function restaurantKeyFor(loc) {
|
||||
if (loc.category === 'hotel') return 'Hotel Uno (Strašnice)';
|
||||
const a = loc.address.toLowerCase();
|
||||
if (a.includes('vršovice') || a.includes('petrohradská')) return 'Endorfin (Vršovice)';
|
||||
if (a.includes('hrdlořezy') || a.includes('mezitraťová')) return 'KartPlanet (Hrdlořezy)';
|
||||
if (a.includes('národní') || a.includes('máj')) return 'Level Minigolf (Národní/Máj)';
|
||||
if (a.includes('záběhlice')) return 'Bowling Ovčín (Záběhlice)';
|
||||
if (a.includes('pařížská') || a.includes('altstädter')) return 'Mysterium (Altstadt)';
|
||||
if (a.includes('vyšehrad') || a.includes('lublaňská')) return 'Prague Ranger (Vyšehrad)';
|
||||
if (a.includes('štefánikův')) return 'Beer Boat (Štefánikův most)';
|
||||
if (a.includes('buďánka') || a.includes('císařkou')) return 'Paintball (Buďánka)';
|
||||
if (a.includes('pankrác') || a.includes('budějovická')) return 'Mercuria (Pankrác)';
|
||||
return 'Enigma (Altstadt)';
|
||||
}
|
||||
|
||||
function restaurantsHtmlFor(loc) {
|
||||
const key = restaurantKeyFor(loc);
|
||||
const list = RESTAURANTS[key];
|
||||
if (!list || list.length === 0) return '';
|
||||
return `<div style="margin-top:8px;padding:8px;background:#dcfce7;border-left:3px solid #16a34a;border-radius:4px;">
|
||||
<div style="font-size:11px;font-weight:700;color:#14532d;margin-bottom:4px;">🍽️ Restaurant-Tipps in der Nähe:</div>
|
||||
${list.map(r => `<div style="font-size:12px;color:#14532d;margin-bottom:3px;"><strong>${r.name}</strong> <span style="color:#16a34a;font-weight:600;">${r.price}</span> <em style="color:#166534;">(${r.cuisine})</em><br><span style="color:#166534;font-size:11px;">${r.desc}</span></div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let routeButtonsHtml = '';
|
||||
if (isHotel) {
|
||||
const sorted = otherLocations
|
||||
.map(l => ({ ...l, dist: haversineKm(hotelLoc, l) }))
|
||||
.sort((a, b) => a.dist - b.dist)
|
||||
.slice(0, 3);
|
||||
routeButtonsHtml = `<div style="margin-top:10px;border-top:1px solid #ddd;padding-top:10px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#1a1a1a;margin-bottom:6px;">🧭 Schnell-Routen vom Hotel:</div>
|
||||
${sorted.map(l => `
|
||||
<button class="route-btn" data-target-lat="${l.lat}" data-target-lon="${l.lon}" data-target-name="${l.name.replace(/"/g,'"')}" data-target-emoji="${l.emoji}"
|
||||
style="display:inline-block;margin:3px 4px 3px 0;padding:5px 10px;background:#3b82f6;color:#fff;border:none;border-radius:5px;font-size:12px;cursor:pointer;font-weight:600;">
|
||||
${l.emoji} ${l.name.split(' ')[0]} (${l.dist.toFixed(1)} km)
|
||||
</button>`).join('')}
|
||||
</div>`;
|
||||
} else {
|
||||
routeButtonsHtml = `<button class="route-btn route-back" data-target-lat="${hotelLoc.lat}" data-target-lon="${hotelLoc.lon}" data-target-name="${hotelLoc.name}" data-target-emoji="${hotelLoc.emoji}"
|
||||
style="margin-top:8px;padding:6px 12px;background:linear-gradient(135deg,#ffd700,#ffeb3b);color:#1a1a1a;border:none;border-radius:5px;font-size:13px;cursor:pointer;font-weight:700;width:100%;">
|
||||
🧭 Zurück zum Hotel (Route)
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// Feature 8: "Was ist in der Nähe?" — alle Nachbarn <2km
|
||||
let nearbyHtml = '';
|
||||
const neighbors = (isHotel ? otherLocations : locations.filter(l => l !== loc))
|
||||
.map(l => ({ ...l, dist: haversineKm(loc, l) }))
|
||||
.filter(l => l.dist < 3.0 && l.dist > 0.05)
|
||||
.sort((a, b) => a.dist - b.dist);
|
||||
if (neighbors.length > 0) {
|
||||
nearbyHtml = `<div style="margin-top:8px;padding:8px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;">
|
||||
<div style="font-size:11px;font-weight:700;color:#92400e;margin-bottom:4px;">📍 In der Nähe (<3 km Luftlinie):</div>
|
||||
<div style="font-size:12px;color:#451a03;">${neighbors.map(n => `${n.emoji} ${n.name.split(' ')[0]} <span style="color:#92400e;">${n.dist.toFixed(2)} km</span>`).join(' · ')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const urlLink = loc.url ? `<a href="${loc.url}" target="_blank" rel="noopener">🔗 Website öffnen</a>` : '';
|
||||
const popupContent = `
|
||||
<div class="popup-content">
|
||||
${bannerHtml}
|
||||
<h3><span class="emoji-big">${loc.emoji}</span>${loc.name}</h3>
|
||||
<div class="meta">📍 ${loc.address}</div>
|
||||
<div class="desc">${loc.desc}</div>
|
||||
${loc.price ? `<div class="price">${loc.price}</div>` : ''}
|
||||
${loc.url ? `<a href="${loc.url}" target="_blank" rel="noopener">🔗 Website öffnen</a>` : ''}
|
||||
${urlLink}
|
||||
${routeButtonsHtml}
|
||||
${nearbyHtml}
|
||||
${restaurantsHtmlFor(loc)}
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent, { maxWidth: 320 });
|
||||
bounds.push([loc.lat, loc.lon]);
|
||||
|
||||
if (isHotel) {
|
||||
hotelMarker = marker;
|
||||
const labelMarker = L.marker([loc.lat, loc.lon], {
|
||||
icon: createHotelLabel(),
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
zIndexOffset: 999
|
||||
}).addTo(map);
|
||||
markersByCategory['hotel'] = [marker, labelMarker];
|
||||
} else {
|
||||
if (!markersByCategory[loc.category]) markersByCategory[loc.category] = [];
|
||||
markersByCategory[loc.category].push(marker);
|
||||
}
|
||||
});
|
||||
|
||||
// Route-Button Click Handler
|
||||
map.on('popupopen', (e) => {
|
||||
const popupNode = e.popup.getElement();
|
||||
if (!popupNode) return;
|
||||
popupNode.querySelectorAll('.route-btn').forEach(btn => {
|
||||
btn.onclick = async (ev) => {
|
||||
ev.preventDefault();
|
||||
const target = {
|
||||
name: btn.dataset.targetName,
|
||||
emoji: btn.dataset.targetEmoji,
|
||||
lat: parseFloat(btn.dataset.targetLat),
|
||||
lon: parseFloat(btn.dataset.targetLon)
|
||||
};
|
||||
const from = isHotelTarget(btn)
|
||||
? otherLocations.find(l => Math.abs(l.lat - target.lat) < 0.0001 && Math.abs(l.lon - target.lon) < 0.0001) || hotelLoc
|
||||
: hotelLoc;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Route wird berechnet...';
|
||||
const result = await drawRoute(from, target);
|
||||
btn.textContent = `✅ ${result.distKm} km · ca. ${result.durMin} Min mit Auto${result.fallback ? ' (Luftlinie)' : ''}`;
|
||||
btn.style.background = '#10b981';
|
||||
btn.onclick = null;
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-Open Hotel-Popup nach 1.5s beim ersten Laden
|
||||
setTimeout(() => {
|
||||
if (hotelMarker) hotelMarker.openPopup();
|
||||
}, 1500);
|
||||
|
||||
const lineLayers = {};
|
||||
function renderLine(lineKey, color, weight, opacity) {
|
||||
const shapes = LINES[lineKey];
|
||||
@@ -618,7 +851,7 @@ legend.onAdd = function() {
|
||||
const div = L.DomUtil.create('div', 'legend');
|
||||
div.innerHTML = `
|
||||
<h4>🥳 JGA Prag Tourenplan</h4>
|
||||
<div class="item"><span class="dot" style="background:#4fc3f7"></span>🏨 Hotel (Basis)</div>
|
||||
<div class="item"><span class="dot" style="background:#4fc3f7;border-color:#ffd700;"></span>🏨 <strong>Hotel Uno (Basis)</strong></div>
|
||||
<div class="item"><span class="dot" style="background:#e91e63"></span>🔐 Escape Room</div>
|
||||
<div class="item"><span class="dot" style="background:#ff9800"></span>🏎️ KartPlanet</div>
|
||||
<div class="item"><span class="dot" style="background:#66bb6a"></span>⛳ Minigolf</div>
|
||||
@@ -629,6 +862,13 @@ legend.onAdd = function() {
|
||||
<div class="item"><span class="dot" style="background:#ef5350"></span>🎨 Paintball</div>
|
||||
<div class="item"><span class="dot" style="background:#26c6da"></span>🔫 Lasertag</div>
|
||||
<div class="item"><span class="dot" style="background:#ffa726"></span>🔍 Enigma</div>
|
||||
<div class="layer-toggle">
|
||||
<div style="color:#aaa;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Filter</div>
|
||||
<label><input type="checkbox" data-layer="hotel" checked disabled style="accent-color:#ffd700;"> 🏨 Hotel (immer sichtbar)</label>
|
||||
<label><input type="checkbox" data-layer="action" checked style="accent-color:#e91e63;"> 💥 Action (5)</label>
|
||||
<label><input type="checkbox" data-layer="fun" checked style="accent-color:#66bb6a;"> 🎉 Fun (4)</label>
|
||||
<label><input type="checkbox" data-layer="sight" checked style="accent-color:#7e57c2;"> 🏛️ Sight (1)</label>
|
||||
</div>
|
||||
<div style="margin-top:8px;padding-top:6px;border-top:1px solid #555;font-size:11px;color:#aaa;">
|
||||
🚇 Metro: <span style="color:#00A562;font-weight:700">A</span> ·
|
||||
<span style="color:#F8B322;font-weight:700">B</span> ·
|
||||
@@ -637,6 +877,16 @@ legend.onAdd = function() {
|
||||
<span style="font-size:10px;">Marker anklicken für Details</span>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
div.querySelectorAll('input[data-layer]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const layer = cb.dataset.layer;
|
||||
(markersByCategory[layer] || []).forEach(m => {
|
||||
if (cb.checked) m.addTo(map); else map.removeLayer(m);
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 10);
|
||||
return div;
|
||||
};
|
||||
legend.addTo(map);
|
||||
@@ -762,5 +1012,15 @@ document.addEventListener('keydown', (e) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// PWA Service Worker registrieren
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('./sw.js')
|
||||
.then(reg => console.log('[PWA] SW registered:', reg.scope))
|
||||
.catch(err => console.warn('[PWA] SW registration failed:', err));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+288
-17
@@ -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. / 1–2h",
|
||||
url: "https://enigma.swallnet.com/index.php/jak-hrat-en/",
|
||||
url: "https://enigma.swallnet.com/index.php/odkaz-mistra-hvezdare-en/",
|
||||
category: "fun"
|
||||
}
|
||||
];
|
||||
@@ -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,'"')}" 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user