Umfassende Verbesserung der Uhr unten rechts: Design-Verbesserungen: - Modernes Glassmorphism-Design mit Gradient-Hintergrund - Cyan-Akzente passend zur App-Farbgebung - Gradient-Text-Effekt für elegante Uhrzeit-Darstellung - Subtile Leuchteffekte (Glow) bei Hover - Dekorative Accent-Line am oberen Rand Typografie: - Tabular-nums für gleichbleibende Ziffernbreite - Verbesserte Lesbarkeit durch optimierten Letter-Spacing - Text-Shadows für besseren Kontrast - Uppercase-Datum mit Cyan-Färbung Interaktivität: - Smooth Hover-Animation mit Lift-Effekt - Verstärkter Glow-Effekt bei Hover - Sanfte Transitions mit cubic-bezier Responsive Design: - Optimiert für Tablets (768px) - Optimiert für Smartphones (480px) - Angepasste Schriftgrößen und Paddings - Kleinere Mindestbreite auf mobilen Geräten Browser-Kompatibilität: - Webkit-Prefixes für Safari - Backdrop-filter für modernen Blur-Effekt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1250 lines
47 KiB
HTML
1250 lines
47 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
<!-- Primary Meta Tags -->
|
|
<title>Berlin Live Transit - Echtzeit-Nahverkehrskarte</title>
|
|
<meta name="title" content="Berlin Live Transit - Echtzeit-Nahverkehrskarte">
|
|
<meta name="description" content="Verfolgen Sie den Berliner Nahverkehr in Echtzeit. U-Bahn, S-Bahn, Trams, Busse und Fähren live auf der Karte.">
|
|
<meta name="keywords" content="Berlin, Transit, Nahverkehr, Live, Karte, U-Bahn, S-Bahn, Tram, Bus, BVG, VBB">
|
|
<meta name="author" content="Berlin Live Transit">
|
|
<meta name="theme-color" content="#0a0e27">
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://example.com/">
|
|
<meta property="og:title" content="Berlin Live Transit - Echtzeit-Nahverkehrskarte">
|
|
<meta property="og:description" content="Verfolgen Sie den Berliner Nahverkehr in Echtzeit. U-Bahn, S-Bahn, Trams, Busse und Fähren live auf der Karte.">
|
|
|
|
<!-- Twitter -->
|
|
<meta property="twitter:card" content="summary_large_image">
|
|
<meta property="twitter:url" content="https://example.com/">
|
|
<meta property="twitter:title" content="Berlin Live Transit - Echtzeit-Nahverkehrskarte">
|
|
<meta property="twitter:description" content="Verfolgen Sie den Berliner Nahverkehr in Echtzeit. U-Bahn, S-Bahn, Trams, Busse und Fähren live auf der Karte.">
|
|
|
|
<!-- Preconnect to external resources for better performance -->
|
|
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
|
<link rel="preconnect" href="https://v6.vbb.transport.rest" crossorigin>
|
|
<link rel="dns-prefetch" href="https://basemaps.cartocdn.com">
|
|
<style>
|
|
/* ===== Base Styles ===== */
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
position: relative;
|
|
background: #0a0e27;
|
|
cursor: none;
|
|
}
|
|
|
|
/* ===== Map Container ===== */
|
|
#map {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 0;
|
|
transition: opacity 0.5s ease;
|
|
cursor: none !important;
|
|
}
|
|
|
|
/* ===== Clock Display ===== */
|
|
.clock-container {
|
|
position: absolute;
|
|
bottom: 40px;
|
|
right: 40px;
|
|
z-index: 1000;
|
|
background: linear-gradient(135deg,
|
|
rgba(20, 20, 35, 0.95),
|
|
rgba(10, 15, 30, 0.9)
|
|
);
|
|
backdrop-filter: blur(25px) saturate(180%);
|
|
-webkit-backdrop-filter: blur(25px) saturate(180%);
|
|
padding: 30px 40px;
|
|
border-radius: 28px;
|
|
box-shadow:
|
|
0 8px 32px rgba(0, 0, 0, 0.7),
|
|
0 0 0 1px rgba(255, 255, 255, 0.1),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
|
border: 1px solid rgba(0, 255, 255, 0.15);
|
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
min-width: 320px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.clock-container::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 1px;
|
|
background: linear-gradient(90deg,
|
|
transparent,
|
|
rgba(0, 255, 255, 0.5),
|
|
transparent
|
|
);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.clock-container:hover {
|
|
background: linear-gradient(135deg,
|
|
rgba(25, 25, 40, 0.98),
|
|
rgba(15, 20, 35, 0.95)
|
|
);
|
|
box-shadow:
|
|
0 12px 48px rgba(0, 0, 0, 0.8),
|
|
0 0 0 1px rgba(0, 255, 255, 0.3),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
|
0 0 60px rgba(0, 255, 255, 0.1);
|
|
border-color: rgba(0, 255, 255, 0.3);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.time {
|
|
font-size: 72px;
|
|
font-weight: 300;
|
|
color: #ffffff;
|
|
letter-spacing: 2px;
|
|
line-height: 1;
|
|
text-shadow:
|
|
0 0 20px rgba(0, 255, 255, 0.3),
|
|
0 4px 12px rgba(0, 0, 0, 0.8),
|
|
0 2px 4px rgba(0, 0, 0, 0.6);
|
|
font-variant-numeric: tabular-nums;
|
|
transition: all 0.3s ease;
|
|
background: linear-gradient(180deg,
|
|
rgba(255, 255, 255, 1) 0%,
|
|
rgba(200, 240, 255, 0.95) 100%
|
|
);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
|
|
.date {
|
|
font-size: 16px;
|
|
color: rgba(0, 255, 255, 0.85);
|
|
margin-top: 12px;
|
|
font-weight: 500;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
text-shadow:
|
|
0 0 10px rgba(0, 255, 255, 0.4),
|
|
0 2px 8px rgba(0, 0, 0, 0.6);
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* ===== Loading Indicator ===== */
|
|
.loading-indicator {
|
|
position: absolute;
|
|
bottom: 40px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 1000;
|
|
background: rgba(20, 20, 30, 0.9);
|
|
backdrop-filter: blur(15px);
|
|
padding: 15px 30px;
|
|
border-radius: 30px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.loading-indicator.show {
|
|
opacity: 1;
|
|
}
|
|
|
|
.spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
border-top-color: #00ffff;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.loading-text {
|
|
color: rgba(255, 255, 255, 0.9);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* ===== Vehicle Markers ===== */
|
|
.vehicle-marker-container {
|
|
position: relative;
|
|
width: 32px;
|
|
height: 32px;
|
|
}
|
|
|
|
.vehicle-marker {
|
|
width: 32px;
|
|
height: 32px;
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 20px;
|
|
font-weight: 900;
|
|
animation: vehicle-pulse 2s ease-in-out infinite;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
text-shadow:
|
|
0 0 8px currentColor,
|
|
0 0 15px currentColor,
|
|
0 2px 4px rgba(0, 0, 0, 0.8);
|
|
cursor: pointer;
|
|
transition: transform 0.2s ease;
|
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.6));
|
|
background: transparent !important;
|
|
}
|
|
|
|
.vehicle-marker:hover {
|
|
transform: translate(-50%, -50%) scale(1.2);
|
|
}
|
|
|
|
@keyframes vehicle-pulse {
|
|
0%, 100% {
|
|
transform: translate(-50%, -50%) scale(1);
|
|
}
|
|
50% {
|
|
transform: translate(-50%, -50%) scale(1.15);
|
|
}
|
|
}
|
|
|
|
@keyframes vehicle-ripple {
|
|
0% {
|
|
transform: translate(-50%, -50%) scale(0.6);
|
|
opacity: 0.8;
|
|
}
|
|
100% {
|
|
transform: translate(-50%, -50%) scale(2);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.leaflet-marker-icon {
|
|
transition: transform 10s linear !important;
|
|
}
|
|
|
|
.vehicle-label {
|
|
position: absolute;
|
|
top: -26px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(0, 0, 0, 0.95);
|
|
color: white;
|
|
padding: 4px 10px;
|
|
border-radius: 10px;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.6);
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
letter-spacing: 0.5px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ===== Vehicle Type Colors ===== */
|
|
.vehicle-subway {
|
|
background: #0066cc;
|
|
color: #0066cc;
|
|
}
|
|
.vehicle-suburban {
|
|
background: #009933;
|
|
color: #009933;
|
|
}
|
|
.vehicle-tram {
|
|
background: #cc0000;
|
|
color: #cc0000;
|
|
}
|
|
.vehicle-bus {
|
|
background: #993399;
|
|
color: #993399;
|
|
}
|
|
.vehicle-ferry {
|
|
background: #0099cc;
|
|
color: #0099cc;
|
|
}
|
|
.vehicle-regional {
|
|
background: #cc6600;
|
|
color: #cc6600;
|
|
}
|
|
|
|
/* ===== User Location Marker ===== */
|
|
.user-location-marker {
|
|
position: relative;
|
|
width: 40px;
|
|
height: 40px;
|
|
}
|
|
|
|
.user-location-dot {
|
|
position: absolute;
|
|
width: 18px;
|
|
height: 18px;
|
|
background: #00ffff;
|
|
border: 4px solid white;
|
|
border-radius: 50%;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
box-shadow:
|
|
0 0 20px rgba(0, 255, 255, 0.8),
|
|
0 0 40px rgba(0, 255, 255, 0.6);
|
|
z-index: 2;
|
|
}
|
|
|
|
.user-location-pulse {
|
|
position: absolute;
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid #00ffff;
|
|
border-radius: 50%;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
animation: location-pulse 2s ease-out infinite;
|
|
z-index: 1;
|
|
}
|
|
|
|
@keyframes location-pulse {
|
|
0% {
|
|
transform: translate(-50%, -50%) scale(0.5);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: translate(-50%, -50%) scale(2);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
/* ===== Leaflet Popup Customization ===== */
|
|
.leaflet-popup-content-wrapper {
|
|
background: rgba(20, 20, 30, 0.98);
|
|
backdrop-filter: blur(15px);
|
|
color: white;
|
|
border-radius: 15px;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
.leaflet-popup-content {
|
|
margin: 18px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.leaflet-popup-tip {
|
|
background: rgba(20, 20, 30, 0.98);
|
|
}
|
|
|
|
/* ===== Responsive Design ===== */
|
|
@media (max-width: 768px) {
|
|
.clock-container {
|
|
padding: 20px 28px;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
min-width: 260px;
|
|
border-radius: 24px;
|
|
}
|
|
|
|
.time {
|
|
font-size: 52px;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.date {
|
|
font-size: 13px;
|
|
margin-top: 10px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.clock-container {
|
|
padding: 18px 24px;
|
|
bottom: 15px;
|
|
right: 15px;
|
|
min-width: 220px;
|
|
}
|
|
|
|
.time {
|
|
font-size: 44px;
|
|
}
|
|
|
|
.date {
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
/* ===== Cursor Styles ===== */
|
|
* {
|
|
cursor: none !important;
|
|
}
|
|
|
|
/* ===== Initial Loading Screen ===== */
|
|
#initialLoader {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: #0a0e27;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 9999;
|
|
transition: opacity 0.5s ease;
|
|
}
|
|
|
|
#initialLoader.hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.loader-spinner {
|
|
width: 60px;
|
|
height: 60px;
|
|
border: 4px solid rgba(255, 255, 255, 0.1);
|
|
border-top-color: #00ffff;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.loader-text {
|
|
color: rgba(255, 255, 255, 0.9);
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
}
|
|
</style>
|
|
|
|
<!-- External Resources -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin="" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""
|
|
defer></script>
|
|
</head>
|
|
<body>
|
|
<!-- Fallback for browsers without JavaScript -->
|
|
<noscript>
|
|
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #0a0e27; display: flex; align-items: center; justify-content: center; color: white; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; text-align: center; padding: 20px;">
|
|
<div>
|
|
<h1 style="font-size: 32px; margin-bottom: 20px;">JavaScript erforderlich</h1>
|
|
<p style="font-size: 18px; color: rgba(255,255,255,0.7);">
|
|
Diese Anwendung benötigt JavaScript, um zu funktionieren.<br>
|
|
Bitte aktivieren Sie JavaScript in Ihren Browser-Einstellungen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</noscript>
|
|
|
|
<!-- Initial Loading Screen -->
|
|
<div id="initialLoader">
|
|
<div class="loader-spinner" aria-hidden="true"></div>
|
|
<div class="loader-text">Berlin Live Transit wird geladen...</div>
|
|
</div>
|
|
|
|
<!-- Main Application Container -->
|
|
<main id="app" role="main">
|
|
<!-- Interactive Map -->
|
|
<div id="map" role="application" aria-label="Berlin Transit Live Map"></div>
|
|
|
|
<!-- Clock Display -->
|
|
<aside class="clock-container" role="complementary" aria-label="Aktuelle Uhrzeit und Datum">
|
|
<time class="time" id="time" aria-live="polite" aria-atomic="true"></time>
|
|
<div class="date" id="date" aria-live="polite" aria-atomic="true"></div>
|
|
</aside>
|
|
|
|
<!-- Loading Indicator -->
|
|
<div class="loading-indicator"
|
|
id="loadingIndicator"
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-label="Ladezustand">
|
|
<div class="spinner" aria-hidden="true"></div>
|
|
<span class="loading-text">Lade Fahrzeuge...</span>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
// ===== Application Initialization =====
|
|
'use strict';
|
|
|
|
// ===== Global State =====
|
|
let map = null;
|
|
const vehicleMarkers = {};
|
|
let userLocationMarker = null;
|
|
let isLoading = false;
|
|
let userPosition = null;
|
|
let clockInterval = null;
|
|
let dataUpdateInterval = null;
|
|
let retryCount = 0;
|
|
const MAX_RETRIES = 3;
|
|
const RETRY_DELAY = 5000; // 5 seconds
|
|
|
|
/**
|
|
* Wait for Leaflet to be loaded
|
|
* @returns {Promise<void>}
|
|
*/
|
|
function waitForLeaflet() {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof L !== 'undefined') {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 50; // 5 seconds max wait
|
|
const interval = setInterval(() => {
|
|
attempts++;
|
|
if (typeof L !== 'undefined') {
|
|
clearInterval(interval);
|
|
resolve();
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(interval);
|
|
reject(new Error('Leaflet konnte nicht geladen werden'));
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize the application
|
|
*/
|
|
async function initializeApp() {
|
|
try {
|
|
// Wait for DOM to be ready
|
|
if (document.readyState === 'loading') {
|
|
await new Promise(resolve => {
|
|
document.addEventListener('DOMContentLoaded', resolve);
|
|
});
|
|
}
|
|
|
|
// Wait for Leaflet to be loaded
|
|
await waitForLeaflet();
|
|
|
|
// Initialize map
|
|
initializeMap();
|
|
|
|
// Start application
|
|
startApplication();
|
|
|
|
console.log('Berlin Live Transit erfolgreich gestartet');
|
|
} catch (error) {
|
|
console.error('Fehler beim Initialisieren der Anwendung:', error);
|
|
showErrorMessage('Anwendung konnte nicht gestartet werden. Bitte Seite neu laden.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the Leaflet map
|
|
*/
|
|
function initializeMap() {
|
|
const BERLIN_CENTER = [52.52, 13.405];
|
|
const DEFAULT_ZOOM = 14;
|
|
|
|
try {
|
|
// Initialize the Leaflet map with custom settings
|
|
map = L.map('map', {
|
|
zoomControl: false,
|
|
attributionControl: true,
|
|
zoomAnimation: true,
|
|
fadeAnimation: true,
|
|
markerZoomAnimation: true
|
|
});
|
|
|
|
// Add dark theme tile layer from CartoDB
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap © CartoDB'
|
|
}).addTo(map);
|
|
|
|
// Set initial view to Berlin center
|
|
map.setView(BERLIN_CENTER, DEFAULT_ZOOM);
|
|
|
|
// Setup map event listeners
|
|
setupMapEvents();
|
|
} catch (error) {
|
|
console.error('Fehler beim Initialisieren der Karte:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup map event listeners
|
|
*/
|
|
function setupMapEvents() {
|
|
let moveEndTimeout;
|
|
map.on('moveend', () => {
|
|
clearTimeout(moveEndTimeout);
|
|
moveEndTimeout = setTimeout(() => {
|
|
loadRadarData();
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start the application
|
|
*/
|
|
function startApplication() {
|
|
// Initialize clock display
|
|
updateClock();
|
|
|
|
// Get user location and load initial data
|
|
getUserLocation();
|
|
|
|
// Update clock every second
|
|
clockInterval = setInterval(updateClock, 1000);
|
|
|
|
// Refresh vehicle data every 15 seconds
|
|
dataUpdateInterval = setInterval(() => {
|
|
if (!isLoading) {
|
|
loadRadarData();
|
|
}
|
|
}, 15000);
|
|
|
|
// Hide initial loader
|
|
setTimeout(() => {
|
|
const loader = document.getElementById('initialLoader');
|
|
if (loader) {
|
|
loader.classList.add('hidden');
|
|
// Remove from DOM after transition
|
|
setTimeout(() => {
|
|
loader.remove();
|
|
}, 500);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
/**
|
|
* Show error message to user
|
|
* @param {string} message - Error message to display
|
|
*/
|
|
function showErrorMessage(message) {
|
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
|
if (loadingIndicator) {
|
|
const loadingText = loadingIndicator.querySelector('.loading-text');
|
|
if (loadingText) {
|
|
loadingText.textContent = message;
|
|
loadingText.style.color = '#ff6b6b';
|
|
}
|
|
loadingIndicator.classList.add('show');
|
|
|
|
// Hide after 5 seconds
|
|
setTimeout(() => {
|
|
loadingIndicator.classList.remove('show');
|
|
if (loadingText) {
|
|
loadingText.textContent = 'Lade Fahrzeuge...';
|
|
loadingText.style.color = '';
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
// ===== UI Helper Functions =====
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS attacks
|
|
* @param {string} unsafe - Unsafe string
|
|
* @returns {string} Escaped string
|
|
*/
|
|
function escapeHtml(unsafe) {
|
|
if (typeof unsafe !== 'string') return '';
|
|
return unsafe
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Display the loading indicator
|
|
*/
|
|
function showLoading() {
|
|
const indicator = document.getElementById('loadingIndicator');
|
|
if (indicator) {
|
|
indicator.classList.add('show');
|
|
isLoading = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide the loading indicator
|
|
*/
|
|
function hideLoading() {
|
|
const indicator = document.getElementById('loadingIndicator');
|
|
if (indicator) {
|
|
indicator.classList.remove('show');
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
// ===== Transit Product Type Helpers =====
|
|
|
|
/**
|
|
* Determine the vehicle type from product string
|
|
* @param {string} product - Product type from API
|
|
* @returns {string} Normalized product type
|
|
*/
|
|
function getProductType(product) {
|
|
if (!product) return 'bus';
|
|
const p = product.toLowerCase();
|
|
if (p.includes('subway') || p === 'subway') return 'subway';
|
|
if (p.includes('suburban') || p === 'suburban') return 'suburban';
|
|
if (p.includes('tram') || p === 'tram') return 'tram';
|
|
if (p.includes('bus') || p === 'bus') return 'bus';
|
|
if (p.includes('ferry')) return 'ferry';
|
|
if (p.includes('regional') || p.includes('express')) return 'regional';
|
|
return 'bus';
|
|
}
|
|
|
|
/**
|
|
* Get color associated with vehicle type
|
|
* @param {string} type - Vehicle type
|
|
* @returns {string} Hex color code
|
|
*/
|
|
function getProductColor(type) {
|
|
const colors = {
|
|
subway: '#0066cc',
|
|
suburban: '#00cc44',
|
|
tram: '#ff3333',
|
|
bus: '#cc44cc',
|
|
ferry: '#00ccff',
|
|
regional: '#ff9933'
|
|
};
|
|
return colors[type] || '#cc44cc';
|
|
}
|
|
|
|
/**
|
|
* Get icon letter for vehicle type
|
|
* @param {string} type - Vehicle type
|
|
* @returns {string} Single letter icon
|
|
*/
|
|
function getProductIcon(type) {
|
|
const icons = {
|
|
subway: 'U',
|
|
suburban: 'S',
|
|
tram: 'T',
|
|
bus: 'B',
|
|
ferry: 'F',
|
|
regional: 'R'
|
|
};
|
|
return icons[type] || 'B';
|
|
}
|
|
|
|
/**
|
|
* Get German name for vehicle type
|
|
* @param {string} type - Vehicle type
|
|
* @returns {string} Localized name
|
|
*/
|
|
function getProductName(type) {
|
|
const names = {
|
|
subway: 'U-Bahn',
|
|
suburban: 'S-Bahn',
|
|
tram: 'Straßenbahn',
|
|
bus: 'Bus',
|
|
ferry: 'Fähre',
|
|
regional: 'Regionalverkehr'
|
|
};
|
|
return names[type] || 'Bus';
|
|
}
|
|
|
|
// ===== Marker Creation Functions =====
|
|
|
|
/**
|
|
* Create custom icon for vehicle markers
|
|
* @param {string} type - Vehicle type
|
|
* @param {string} lineName - Line name to display
|
|
* @returns {L.DivIcon} Leaflet div icon
|
|
*/
|
|
function createVehicleIcon(type, lineName) {
|
|
const icon = getProductIcon(type);
|
|
const safeLineName = escapeHtml(lineName);
|
|
const safeType = escapeHtml(type);
|
|
|
|
return L.divIcon({
|
|
className: 'custom-vehicle-icon',
|
|
html: `
|
|
<div class="vehicle-marker-container">
|
|
<div class="vehicle-label">${safeLineName}</div>
|
|
<div class="vehicle-marker vehicle-${safeType}">${icon}</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 56],
|
|
iconAnchor: [16, 28]
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create custom icon for user location marker
|
|
* @returns {L.DivIcon} Leaflet div icon with pulse effect
|
|
*/
|
|
function createUserLocationIcon() {
|
|
return L.divIcon({
|
|
className: 'user-location-icon',
|
|
html: `
|
|
<div class="user-location-marker">
|
|
<div class="user-location-pulse"></div>
|
|
<div class="user-location-dot"></div>
|
|
</div>
|
|
`,
|
|
iconSize: [40, 40],
|
|
iconAnchor: [20, 20]
|
|
});
|
|
}
|
|
|
|
// ===== Data Loading Functions =====
|
|
|
|
/**
|
|
* Load vehicle data from VBB Radar API with retry logic
|
|
* Fetches vehicles within current map bounds
|
|
* Updates existing markers or creates new ones
|
|
* @param {number} attempt - Current retry attempt
|
|
*/
|
|
async function loadRadarData(attempt = 0) {
|
|
if (isLoading) return;
|
|
|
|
// Check if map is initialized
|
|
if (!map) {
|
|
console.warn('Map nicht initialisiert');
|
|
return;
|
|
}
|
|
|
|
showLoading();
|
|
|
|
try {
|
|
// Get current map bounds for API request
|
|
const bounds = map.getBounds();
|
|
const north = bounds.getNorth().toFixed(6);
|
|
const south = bounds.getSouth().toFixed(6);
|
|
const west = bounds.getWest().toFixed(6);
|
|
const east = bounds.getEast().toFixed(6);
|
|
|
|
// Build API URL with bounds
|
|
const radarUrl = `https://v6.vbb.transport.rest/radar?north=${north}&south=${south}&west=${west}&east=${east}&results=150`;
|
|
|
|
// Fetch vehicle data with timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
|
|
|
const response = await fetch(radarUrl, {
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP Error ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Validate response data
|
|
if (!data || typeof data !== 'object') {
|
|
throw new Error('Invalid response data format');
|
|
}
|
|
|
|
// Reset retry count on success
|
|
retryCount = 0;
|
|
|
|
// Track current vehicle IDs to remove stale markers
|
|
const currentVehicleIds = new Set();
|
|
|
|
// Process vehicle movements from API
|
|
if (data.movements && Array.isArray(data.movements)) {
|
|
data.movements.forEach(movement => {
|
|
// Validate movement data
|
|
if (!movement || !movement.location || !movement.line) {
|
|
return; // Skip invalid movement
|
|
}
|
|
|
|
if (movement.location && movement.line) {
|
|
const type = getProductType(movement.line.product);
|
|
const lineName = movement.line.name || '?';
|
|
const icon = getProductIcon(type);
|
|
|
|
// Create unique vehicle ID
|
|
const vehicleId = movement.tripId || `${lineName}-${movement.direction || 'unknown'}`;
|
|
currentVehicleIds.add(vehicleId);
|
|
|
|
// Create new position
|
|
const newLatLng = L.latLng(movement.location.latitude, movement.location.longitude);
|
|
|
|
// Update existing marker or create new one
|
|
if (vehicleMarkers[vehicleId]) {
|
|
// Vehicle exists - update position with animation
|
|
const marker = vehicleMarkers[vehicleId].marker;
|
|
const oldLatLng = marker.getLatLng();
|
|
|
|
// Only animate if position changed
|
|
if (oldLatLng.lat !== newLatLng.lat || oldLatLng.lng !== newLatLng.lng) {
|
|
animateMarker(marker, oldLatLng, newLatLng, 3000);
|
|
}
|
|
} else {
|
|
// Create new marker for vehicle
|
|
try {
|
|
const marker = L.marker(newLatLng, {
|
|
icon: createVehicleIcon(type, lineName),
|
|
riseOnHover: true
|
|
}).addTo(map);
|
|
|
|
const direction = escapeHtml(movement.direction || 'Unbekannt');
|
|
const productName = escapeHtml(getProductName(type));
|
|
const safeLineName = escapeHtml(lineName);
|
|
const safeIcon = escapeHtml(icon);
|
|
|
|
// Build next stop information if available
|
|
let nextStopInfo = '';
|
|
if (movement.nextStopovers && Array.isArray(movement.nextStopovers) && movement.nextStopovers.length > 0) {
|
|
const nextStop = movement.nextStopovers[0];
|
|
if (nextStop.stop && nextStop.stop.name) {
|
|
const arrivalTime = nextStop.arrival ?
|
|
new Date(nextStop.arrival).toLocaleTimeString('de-DE', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
}) : '—';
|
|
const safeStopName = escapeHtml(nextStop.stop.name);
|
|
const safeArrivalTime = escapeHtml(arrivalTime);
|
|
|
|
nextStopInfo = `
|
|
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.2);">
|
|
<div style="font-size: 11px; color: rgba(255,255,255,0.6); margin-bottom: 4px;">
|
|
NÄCHSTER HALT
|
|
</div>
|
|
<div style="font-size: 13px; font-weight: 500;">
|
|
${safeStopName}
|
|
</div>
|
|
<div style="font-size: 12px; color: rgba(255,255,255,0.7); margin-top: 2px;">
|
|
Ankunft: ${safeArrivalTime}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
const popupContent = `
|
|
<div style="text-align: center; min-width: 200px;">
|
|
<div style="
|
|
width: 60px;
|
|
height: 60px;
|
|
margin: 0 auto 15px;
|
|
background: ${getProductColor(type)};
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
color: white;
|
|
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
|
|
">${safeIcon}</div>
|
|
<div style="font-size: 11px; color: rgba(255,255,255,0.6); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;">
|
|
${productName}
|
|
</div>
|
|
<div style="font-weight: 700; font-size: 22px; margin-bottom: 8px; color: ${getProductColor(type)};">
|
|
${safeLineName}
|
|
</div>
|
|
<div style="font-size: 14px; color: rgba(255,255,255,0.85); line-height: 1.4;">
|
|
→ ${direction}
|
|
</div>
|
|
${nextStopInfo}
|
|
</div>
|
|
`;
|
|
marker.bindPopup(popupContent, {
|
|
maxWidth: 300,
|
|
className: 'custom-popup'
|
|
});
|
|
|
|
vehicleMarkers[vehicleId] = {
|
|
marker: marker,
|
|
type: type
|
|
};
|
|
} catch (markerError) {
|
|
console.error('Fehler beim Erstellen des Markers:', markerError, movement);
|
|
// Continue with next vehicle instead of crashing
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Remove vehicles that are no longer in the response
|
|
Object.keys(vehicleMarkers).forEach(vehicleId => {
|
|
if (!currentVehicleIds.has(vehicleId)) {
|
|
map.removeLayer(vehicleMarkers[vehicleId].marker);
|
|
delete vehicleMarkers[vehicleId];
|
|
}
|
|
});
|
|
|
|
console.log(`${Object.keys(vehicleMarkers).length} Fahrzeuge aktiv`);
|
|
|
|
} catch (error) {
|
|
// Handle different error types
|
|
let errorMessage = '';
|
|
let shouldRetry = false;
|
|
|
|
if (error.name === 'AbortError') {
|
|
console.warn('Request timeout - Fahrzeugdaten konnten nicht geladen werden');
|
|
errorMessage = 'Zeitüberschreitung - versuche erneut...';
|
|
shouldRetry = true;
|
|
} else if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
console.error('Netzwerkfehler - Bitte Internetverbindung prüfen:', error);
|
|
errorMessage = 'Netzwerkfehler - bitte Verbindung prüfen';
|
|
shouldRetry = true;
|
|
} else {
|
|
console.error('Fehler beim Laden der Fahrzeugdaten:', error);
|
|
errorMessage = 'Fehler beim Laden der Daten';
|
|
shouldRetry = true;
|
|
}
|
|
|
|
// Retry logic
|
|
if (shouldRetry && attempt < MAX_RETRIES) {
|
|
retryCount++;
|
|
console.log(`Wiederhole Anfrage (${retryCount}/${MAX_RETRIES}) in ${RETRY_DELAY/1000}s...`);
|
|
showErrorMessage(`${errorMessage} (Versuch ${retryCount}/${MAX_RETRIES})`);
|
|
|
|
setTimeout(() => {
|
|
loadRadarData(attempt + 1);
|
|
}, RETRY_DELAY);
|
|
} else if (attempt >= MAX_RETRIES) {
|
|
// Max retries reached
|
|
retryCount = 0;
|
|
showErrorMessage('Fehler: Maximale Anzahl Wiederholungen erreicht');
|
|
} else {
|
|
showErrorMessage(errorMessage);
|
|
}
|
|
} finally {
|
|
hideLoading();
|
|
}
|
|
}
|
|
|
|
// ===== Animation Functions =====
|
|
|
|
/**
|
|
* Smoothly animate marker from one position to another
|
|
* @param {L.Marker} marker - Leaflet marker to animate
|
|
* @param {L.LatLng} startLatLng - Starting position
|
|
* @param {L.LatLng} endLatLng - Ending position
|
|
* @param {number} duration - Animation duration in milliseconds
|
|
*/
|
|
function animateMarker(marker, startLatLng, endLatLng, duration) {
|
|
const startTime = Date.now();
|
|
const latDiff = endLatLng.lat - startLatLng.lat;
|
|
const lngDiff = endLatLng.lng - startLatLng.lng;
|
|
|
|
function updatePosition() {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Ease in-out cubic interpolation for smooth movement
|
|
const easeProgress = progress < 0.5
|
|
? 2 * progress * progress
|
|
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
|
|
const currentLat = startLatLng.lat + (latDiff * easeProgress);
|
|
const currentLng = startLatLng.lng + (lngDiff * easeProgress);
|
|
|
|
marker.setLatLng([currentLat, currentLng]);
|
|
|
|
// Continue animation until complete
|
|
if (progress < 1) {
|
|
requestAnimationFrame(updatePosition);
|
|
}
|
|
}
|
|
|
|
updatePosition();
|
|
}
|
|
|
|
// ===== Geolocation Functions =====
|
|
|
|
/**
|
|
* Get user's current location and center map on it
|
|
* Falls back to Berlin center if geolocation is unavailable
|
|
*/
|
|
function getUserLocation() {
|
|
if (navigator.geolocation) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
// Success callback
|
|
(position) => {
|
|
const lat = position.coords.latitude;
|
|
const lon = position.coords.longitude;
|
|
userPosition = [lat, lon];
|
|
|
|
// Center map on user location immediately (no animation)
|
|
map.setView([lat, lon], 16, {
|
|
animate: false
|
|
});
|
|
|
|
// Remove old location marker if exists
|
|
if (userLocationMarker) {
|
|
map.removeLayer(userLocationMarker);
|
|
}
|
|
|
|
// Create new location marker
|
|
userLocationMarker = L.marker([lat, lon], {
|
|
icon: createUserLocationIcon(),
|
|
zIndexOffset: 1000
|
|
}).addTo(map);
|
|
|
|
// Add popup with coordinates
|
|
userLocationMarker.bindPopup(`
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 32px; margin-bottom: 10px;">📍</div>
|
|
<div style="font-weight: 700; font-size: 16px; margin-bottom: 4px;">
|
|
Dein Standort
|
|
</div>
|
|
<div style="font-size: 12px; color: rgba(255,255,255,0.7);">
|
|
${lat.toFixed(4)}°, ${lon.toFixed(4)}°
|
|
</div>
|
|
</div>
|
|
`);
|
|
|
|
console.log('Standort gefunden:', lat, lon);
|
|
loadRadarData();
|
|
},
|
|
// Error callback
|
|
(error) => {
|
|
let errorMsg = 'Standort nicht verfügbar';
|
|
switch(error.code) {
|
|
case error.PERMISSION_DENIED:
|
|
errorMsg = 'Standortzugriff verweigert';
|
|
break;
|
|
case error.POSITION_UNAVAILABLE:
|
|
errorMsg = 'Standort nicht verfügbar';
|
|
break;
|
|
case error.TIMEOUT:
|
|
errorMsg = 'Standortabfrage Timeout';
|
|
break;
|
|
}
|
|
console.warn(errorMsg + ':', error.message);
|
|
// Load data with default Berlin view
|
|
loadRadarData();
|
|
},
|
|
// Options
|
|
{
|
|
enableHighAccuracy: true,
|
|
timeout: 5000,
|
|
maximumAge: 0
|
|
}
|
|
);
|
|
} else {
|
|
console.warn('Geolocation wird von diesem Browser nicht unterstützt');
|
|
loadRadarData();
|
|
}
|
|
}
|
|
|
|
// ===== Clock Functions =====
|
|
|
|
/**
|
|
* Update clock display with current time and date
|
|
* Formats time as HH:MM:SS and date in German format
|
|
*/
|
|
function updateClock() {
|
|
const now = new Date();
|
|
|
|
// Format time as HH:MM:SS
|
|
const hours = String(now.getHours()).padStart(2, '0');
|
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
|
|
const timeElement = document.getElementById('time');
|
|
if (timeElement) {
|
|
timeElement.textContent = `${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
// Format date in German locale
|
|
const options = {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
};
|
|
const dateStr = now.toLocaleDateString('de-DE', options);
|
|
const dateElement = document.getElementById('date');
|
|
if (dateElement) {
|
|
dateElement.textContent = dateStr;
|
|
}
|
|
}
|
|
|
|
// ===== Cleanup Functions =====
|
|
|
|
/**
|
|
* Clean up intervals and event listeners
|
|
* Called when page is unloaded
|
|
*/
|
|
function cleanup() {
|
|
if (clockInterval) {
|
|
clearInterval(clockInterval);
|
|
clockInterval = null;
|
|
}
|
|
if (dataUpdateInterval) {
|
|
clearInterval(dataUpdateInterval);
|
|
dataUpdateInterval = null;
|
|
}
|
|
console.log('Cleanup abgeschlossen');
|
|
}
|
|
|
|
// ===== Application Startup =====
|
|
|
|
// Initialize the application
|
|
initializeApp();
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener('beforeunload', cleanup);
|
|
|
|
// Handle visibility change to pause/resume updates
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
// Page is hidden - pause updates
|
|
if (dataUpdateInterval) {
|
|
clearInterval(dataUpdateInterval);
|
|
dataUpdateInterval = null;
|
|
}
|
|
console.log('Updates pausiert (Tab versteckt)');
|
|
} else {
|
|
// Page is visible - resume updates
|
|
if (!dataUpdateInterval) {
|
|
dataUpdateInterval = setInterval(() => {
|
|
if (!isLoading) {
|
|
loadRadarData();
|
|
}
|
|
}, 15000);
|
|
}
|
|
// Load data immediately when tab becomes visible
|
|
if (!isLoading && map) {
|
|
loadRadarData();
|
|
}
|
|
console.log('Updates fortgesetzt (Tab sichtbar)');
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |