From fbd97dc9d560b8b86ef05edb912d86b4e8a945da Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:23:54 +0100 Subject: [PATCH] Add files via upload --- README.md | 446 +---- index.html | 4304 +-------------------------------------------- manifest.json | 52 +- package.json | 82 +- script.js | 1992 +++++++++++++++++++++ server.js | 232 +-- service-worker.js | 138 +- styles.css | 2314 ++++++++++++++++++++++++ 8 files changed, 4617 insertions(+), 4943 deletions(-) create mode 100644 script.js create mode 100644 styles.css diff --git a/README.md b/README.md index 1368f22..4ce150d 100644 --- a/README.md +++ b/README.md @@ -1,404 +1,70 @@ -# VBB Netz Status +# VBB Netz Status - Getrennte Dateien -> **Live-Nahverkehr für Berlin & Brandenburg** -> Eine Progressive Web App (PWA) für Echtzeit-Abfahrten, Routenplanung und Live-Fahrzeugverfolgung im VBB-Gebiet +## 📁 Dateistruktur -[![MIT License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/badge/Version-29.0-blue.svg)](https://github.com/Serverlele30/VBB-Status-Web-App) - -**Live-Demo:** [VBB-Status-Web-App](https://github.com/Serverlele30/VBB-Status-Web-App) - ---- - -## 📱 Features - -### 🚇 **Echtzeit-Abfahrten** -- **Live-Daten** für alle Stationen im VBB-Netz (Berlin + Brandenburg) -- **GPS-Standort** - Automatische Stationserkennung -- **Intelligente Suche** mit Autocomplete -- **Detaillierte Infos** - Verspätungen, Ausfälle, Gleis/Steig -- **Auto-Refresh** - Alle 30 Sekunden (optional) -- **Offline-Modus** - Gespeicherte Daten anzeigen - -### 🗺️ **Intelligenter Routenplaner** -- **Start/Ziel-Suche** mit GPS-Unterstützung -- **5 Min. Umstiegszeit** garantiert -- **Detaillierte Fußwege** mit Gehzeit -- **Alternative Routen** - Mehrere Optionen -- **Robuste Stationserkennung** - Funktioniert auch bei unvollständigen Eingaben - -### 🌍 **Live-Map** -- **Echtzeit-Fahrzeugverfolgung** auf Dark-Mode Karte -- **Authentische BVG-Farben** für alle Linien (U-Bahn, S-Bahn, Tram, Bus) -- **Dynamische Bounds** - Nur sichtbare Fahrzeuge laden (bis zu 69% weniger Daten) -- **Filter nach Fahrzeugtyp** - U-Bahn, S-Bahn, Tram, Bus, Fähre -- **API-optimiert** - Intelligentes Caching - -### 📱 **Progressive Web App (PWA)** -- **Installierbar** - Als App auf Home-Screen -- **Offline-Fähig** - Service Worker mit Caching -- **Touch-optimiert** - Pull-to-Refresh, Wischgesten -- **Responsive** - Funktioniert auf Mobile & Desktop -- **Dark-Mode** - Schont die Augen - ---- - -## 🚀 Quick Start - -### **Option 1: Direkt nutzen** -Öffne die App einfach im Browser - keine Installation nötig! - -### **Option 2: Als PWA installieren** - -**Mobile (iOS/Android):** -1. Öffne die App im Browser -2. Tippe auf "Teilen" → "Zum Home-Bildschirm" -3. Fertig! App ist jetzt auf deinem Home-Screen - -**Desktop (Chrome/Edge):** -1. Öffne die App im Browser -2. Klicke auf das Install-Icon in der Adressleiste -3. Fertig! App ist jetzt in deinen Apps - ---- - -## 🛠️ Installation (für Entwickler) - -### **Voraussetzungen** -- Node.js (v16+) -- npm oder yarn -- HTTPS-Server (für PWA-Features) - -### **Setup** - -```bash -# Repository klonen -git clone https://github.com/Serverlele30/VBB-Status-Web-App.git -cd VBB-Status-Web-App - -# Dependencies installieren -npm install - -# HTTPS-Server starten -npm start -``` - -Die App ist jetzt verfügbar unter: `https://localhost:3000` - -### **HTTPS-Server** - -Die App benötigt HTTPS für PWA-Features (Service Worker, GPS, etc.). - -**Server-Skript (`server.js`):** -```javascript -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const mimeTypes = require('mime-types'); - -// Auto-generiert SSL-Zertifikate falls nicht vorhanden -// Nutzt OpenSSL für self-signed certificates -``` - -**SSL-Zertifikate werden automatisch generiert!** - ---- - -## 📂 Projekt-Struktur +Die App wurde jetzt in separate Dateien aufgeteilt: ``` -VBB-Status-Web-App/ -├── index.html # Haupt-HTML (Single-Page App) -├── service-worker.js # PWA Service Worker -├── manifest.json # PWA Manifest -├── server.js # HTTPS Development Server -├── package.json # Node.js Dependencies -├── icons/ # App-Icons (verschiedene Größen) -│ ├── icon-192.png -│ ├── icon-512.png -│ └── icon-maskable.png -├── fonts/ # Custom Fonts -│ └── DotMatrix.ttf # BVG-Schriftart -└── data/ # Statische Daten - └── station_lines.json # Station-zu-Linie Mappings +vbb-netz-status/ +├── index.html # HTML-Struktur +├── styles.css # Alle CSS-Styles +├── script.js # Alle JavaScript-Funktionen +├── server.js # HTTPS-Entwicklungsserver +├── service-worker.js # PWA Service Worker +├── manifest.json # PWA Manifest +├── package.json # Node.js Dependencies +└── LICENSE # MIT Lizenz ``` ---- +## 🚀 Installation & Start -## 🎨 Design +1. **Abhängigkeiten installieren:** + ```bash + npm install + ``` -### **Farbschema** -- **Primär:** `#FFED00` (BVG-Gelb) -- **Background:** `#000000` (Schwarz) -- **Sekundär:** `#1a1a1a` (Dunkelgrau) -- **Text:** `#ffffff` / `#cccccc` -- **Borders:** `#333333` +2. **Server starten:** + ```bash + npm start + ``` -### **Typografie** -- **Haupt-Font:** DotMatrix (BVG-Stil) -- **Fallback:** Courier New, monospace +3. **Browser öffnen:** + ``` + https://localhost:3000 + ``` -### **Icons & Emojis** -- 🚇 U-Bahn -- 🚊 S-Bahn -- 🚋 Tram -- 🚌 Bus -- ⛴️ Fähre +## 📝 Änderungen -### **BVG-Linienfarben** -Authentische Farben für alle 40+ Linien: -- **U-Bahnen:** U1-U9 mit Original-Farben -- **S-Bahnen:** S-Bahn Grün + Ringbahn Orange -- **Trams:** Metro-Tram Rot + Tram Gelb -- **Busse:** Gelb + Metro-Bus Rot +**Vorher:** +- Alles in einer `index.html` (4698 Zeilen) + +**Nachher:** +- `index.html` - Nur HTML-Struktur (~380 Zeilen) +- `styles.css` - Alle CSS-Regeln (~2300 Zeilen) +- `script.js` - Alle JavaScript-Funktionen (~1990 Zeilen) + +## ✅ Vorteile der Trennung + +- 📦 **Bessere Organisation** - Jede Datei hat einen klaren Zweck +- 🔧 **Einfachere Wartung** - CSS und JS können unabhängig bearbeitet werden +- ⚡ **Caching** - Browser können CSS/JS separat cachen +- 🎨 **Entwicklung** - Einfacher zu debuggen und zu bearbeiten +- 👥 **Teamarbeit** - Mehrere Entwickler können parallel arbeiten + +## 🛠️ Technische Details + +- **CSS**: Enthält alle Styles inkl. Dark Mode, Responsive Design, Animationen +- **JavaScript**: Enthält alle Funktionen für Abfahrten, Routen, Live-Map +- **HTML**: Referenziert CSS (``) und JS (` + diff --git a/manifest.json b/manifest.json index 5097102..d64654f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,26 +1,26 @@ -{ - "name": "VBB Netz Status", - "short_name": "VBB Status", - "description": "Echtzeit-Abfahrtsmonitor für den Verkehrsverbund Berlin-Brandenburg - by Aaron K.", - "start_url": "/", - "display": "standalone", - "background_color": "#000000", - "theme_color": "#000000", - "orientation": "portrait", - "icons": [ - { - "src": "images/favicon.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any maskable" - }, - { - "src": "images/favicon.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ], - "categories": ["travel", "navigation"], - "lang": "de-DE" -} +{ + "name": "VBB Netz Status", + "short_name": "VBB Status", + "description": "Echtzeit-Abfahrtsmonitor für den Verkehrsverbund Berlin-Brandenburg - by Aaron K.", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "portrait", + "icons": [ + { + "src": "images/favicon.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "images/favicon.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["travel", "navigation"], + "lang": "de-DE" +} diff --git a/package.json b/package.json index d9dab1a..5453bcf 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,41 @@ -{ - "name": "vbb-netz-status", - "version": "29.0.0", - "description": "Live-Nahverkehr für Berlin & Brandenburg - Progressive Web App (PWA) für Echtzeit-Abfahrten, Routenplanung und Live-Fahrzeugverfolgung im VBB-Gebiet", - "main": "server.js", - "scripts": { - "start": "node server.js", - "dev": "node server.js" - }, - "keywords": [ - "vbb", - "bvg", - "berlin", - "brandenburg", - "transit", - "public-transport", - "pwa", - "real-time", - "departures", - "route-planner", - "live-map" - ], - "author": "Aaron K. & Claude (Anthropic)", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/Serverlele30/VBB-Status-Web-App.git" - }, - "bugs": { - "url": "https://github.com/Serverlele30/VBB-Status-Web-App/issues" - }, - "homepage": "https://github.com/Serverlele30/VBB-Status-Web-App#readme", - "dependencies": { - "mime-types": "^2.1.35" - }, - "devDependencies": {}, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - } -} +{ + "name": "vbb-netz-status", + "version": "29.0.0", + "description": "Live-Nahverkehr für Berlin & Brandenburg - Progressive Web App (PWA) für Echtzeit-Abfahrten, Routenplanung und Live-Fahrzeugverfolgung im VBB-Gebiet", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "keywords": [ + "vbb", + "bvg", + "berlin", + "brandenburg", + "transit", + "public-transport", + "pwa", + "real-time", + "departures", + "route-planner", + "live-map" + ], + "author": "Aaron K. & Claude (Anthropic)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Serverlele30/VBB-Status-Web-App.git" + }, + "bugs": { + "url": "https://github.com/Serverlele30/VBB-Status-Web-App/issues" + }, + "homepage": "https://github.com/Serverlele30/VBB-Status-Web-App#readme", + "dependencies": { + "mime-types": "^2.1.35" + }, + "devDependencies": {}, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + } +} diff --git a/script.js b/script.js new file mode 100644 index 0000000..912ee16 --- /dev/null +++ b/script.js @@ -0,0 +1,1992 @@ + const API_BASE = 'https://v6.vbb.transport.rest'; + let currentStationId = null; + let currentStationName = null; + let searchTimeout = null; + let touchStartY = 0; + let isPulling = false; + let autoRefreshInterval = null; + let apiCallTimestamps = []; // Track API calls for rate limiting + + // DOM Elements - Departures View + const stationSearch = document.getElementById('stationSearch'); + const clearBtn = document.getElementById('clearBtn'); + const locationBtn = document.getElementById('locationBtn'); + const suggestions = document.getElementById('suggestions'); + const refreshBtn = document.getElementById('refreshBtn'); + const departuresContainer = document.getElementById('departuresContainer'); + const offlineIndicator = document.getElementById('offlineIndicator'); + const ptrContainer = document.getElementById('ptrContainer'); + const detailModal = document.getElementById('detailModal'); + const detailBackBtn = document.getElementById('detailBackBtn'); + const detailTitle = document.getElementById('detailTitle'); + const detailContent = document.getElementById('detailContent'); + + // DOM Elements - Journey Detail Modal + const journeyDetailModal = document.getElementById('journeyDetailModal'); + const journeyDetailBackBtn = document.getElementById('journeyDetailBackBtn'); + const journeyDetailTitle = document.getElementById('journeyDetailTitle'); + const journeyDetailContent = document.getElementById('journeyDetailContent'); + + // DOM Elements - Navigation + const menuBtn = document.getElementById('menuBtn'); + const navMenu = document.getElementById('navMenu'); + const navClose = document.getElementById('navClose'); + const navOverlay = document.getElementById('navOverlay'); + const navItems = document.querySelectorAll('.nav-item'); + const viewHome = document.getElementById('view-home'); + const viewDepartures = document.getElementById('view-departures'); + const viewJourney = document.getElementById('view-journey'); + + // DOM Elements - Journey View + const journeyFrom = document.getElementById('journeyFrom'); + const journeyTo = document.getElementById('journeyTo'); + const clearFromBtn = document.getElementById('clearFromBtn'); + const clearToBtn = document.getElementById('clearToBtn'); + const locationFromBtn = document.getElementById('locationFromBtn'); + const suggestionsFrom = document.getElementById('suggestionsFrom'); + const suggestionsTo = document.getElementById('suggestionsTo'); + const swapBtn = document.getElementById('swapBtn'); + const searchJourneyBtn = document.getElementById('searchJourneyBtn'); + const journeyContainer = document.getElementById('journeyContainer'); + + // Service Worker registrieren + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('service-worker.js') + .then(reg => console.log('Service Worker registriert')) + .catch(err => console.error('Service Worker Fehler:', err)); + } + + // Offline-Status überwachen + window.addEventListener('online', () => { + offlineIndicator.classList.remove('show'); + }); + + window.addEventListener('offline', () => { + offlineIndicator.classList.add('show'); + }); + + // Menu Navigation + function openMenu() { + navMenu.classList.add('active'); + navOverlay.classList.add('active'); + document.body.style.overflow = 'hidden'; + } + + function closeMenu() { + navMenu.classList.remove('active'); + navOverlay.classList.remove('active'); + document.body.style.overflow = ''; + } + + menuBtn.addEventListener('click', openMenu); + navClose.addEventListener('click', closeMenu); + navOverlay.addEventListener('click', closeMenu); + + // View Switching + let currentView = 'home'; + + function switchView(viewName) { + // Update active nav item + navItems.forEach(item => { + if (item.dataset.view === viewName) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); + + // Update Page Title dynamisch + const titles = { + 'home': 'VBB Netz Status', + 'departures': 'VBB Netz Status - Abfahrten', + 'journey': 'VBB Netz Status - Route', + 'livemap': 'VBB Netz Status - Live-Map' + }; + document.title = titles[viewName] || 'VBB Netz Status'; + + // Switch views + const viewLiveMap = document.getElementById('view-livemap'); + + // Alle Views deaktivieren + viewHome.classList.remove('active'); + viewDepartures.classList.remove('active'); + viewJourney.classList.remove('active'); + viewLiveMap.classList.remove('active'); + + // Stop Live-Map Updates wenn wir View wechseln + if (viewName !== 'livemap' && liveMapUpdateInterval) { + clearInterval(liveMapUpdateInterval); + liveMapUpdateInterval = null; + } + + // Gewünschte View aktivieren + if (viewName === 'home') { + viewHome.classList.add('active'); + } else if (viewName === 'departures') { + viewDepartures.classList.add('active'); + } else if (viewName === 'journey') { + viewJourney.classList.add('active'); + } else if (viewName === 'livemap') { + viewLiveMap.classList.add('active'); + + // Initialize Live-Map wenn noch nicht geschehen + setTimeout(() => { + initLiveMap(); + if (liveMap) { + liveMap.invalidateSize(); + } + }, 100); + } + + currentView = viewName; + closeMenu(); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + } + + navItems.forEach(item => { + item.addEventListener('click', () => { + const view = item.dataset.view; + switchView(view); + }); + }); + + // Initialen Title setzen (für default View 'home') + document.title = 'VBB Netz Status'; + + // Pull-to-refresh + let startY = 0; + let pulling = false; + + document.addEventListener('touchstart', (e) => { + if (window.scrollY === 0 && !detailModal.classList.contains('active')) { + startY = e.touches[0].pageY; + pulling = true; + } + }); + + document.addEventListener('touchmove', (e) => { + if (!pulling) return; + const currentY = e.touches[0].pageY; + const pullDistance = currentY - startY; + + if (pullDistance > 0 && pullDistance < 100) { + ptrContainer.style.transform = `translateY(${pullDistance - 60}px)`; + } + }); + + document.addEventListener('touchend', async (e) => { + if (!pulling) return; + pulling = false; + + const currentY = e.changedTouches[0].pageY; + const pullDistance = currentY - startY; + + if (pullDistance > 60 && currentStationId) { + ptrContainer.classList.add('pulling'); + await loadDepartures(); + setTimeout(() => { + ptrContainer.classList.remove('pulling'); + ptrContainer.style.transform = ''; + }, 300); + } else { + ptrContainer.style.transform = ''; + } + }); + + // Swipe to close detail modal + let modalStartY = 0; + let modalPulling = false; + + detailModal.addEventListener('touchstart', (e) => { + if (e.target.closest('.detail-content') && detailModal.scrollTop === 0) { + modalStartY = e.touches[0].pageY; + modalPulling = true; + } + }); + + detailModal.addEventListener('touchmove', (e) => { + if (!modalPulling) return; + const currentY = e.touches[0].pageY; + const pullDistance = currentY - modalStartY; + + if (pullDistance > 0 && pullDistance < 200) { + detailModal.style.transform = `translateY(${pullDistance}px)`; + } + }); + + detailModal.addEventListener('touchend', (e) => { + if (!modalPulling) return; + modalPulling = false; + + const currentY = e.changedTouches[0].pageY; + const pullDistance = currentY - modalStartY; + + if (pullDistance > 100) { + closeDetailModal(); + } else { + detailModal.style.transform = ''; + } + }); + + // Swipe-to-close für Journey Detail Modal + let journeyModalStartY = 0; + let journeyModalPulling = false; + + journeyDetailModal.addEventListener('touchstart', (e) => { + if (e.target.closest('.detail-content') && journeyDetailModal.scrollTop === 0) { + journeyModalStartY = e.touches[0].pageY; + journeyModalPulling = true; + } + }); + + journeyDetailModal.addEventListener('touchmove', (e) => { + if (!journeyModalPulling) return; + const currentY = e.touches[0].pageY; + const pullDistance = currentY - journeyModalStartY; + + if (pullDistance > 0 && pullDistance < 200) { + journeyDetailModal.style.transform = `translateY(${pullDistance}px)`; + } + }); + + journeyDetailModal.addEventListener('touchend', (e) => { + if (!journeyModalPulling) return; + journeyModalPulling = false; + + const currentY = e.changedTouches[0].pageY; + const pullDistance = currentY - journeyModalStartY; + + if (pullDistance > 100) { + closeJourneyDetailModal(); + } else { + journeyDetailModal.style.transform = ''; + } + }); + + // Station suchen mit Autocomplete + stationSearch.addEventListener('input', (e) => { + const query = e.target.value.trim(); + + clearTimeout(searchTimeout); + + if (query.length < 2) { + suggestions.classList.remove('active'); + suggestions.innerHTML = ''; + return; + } + + // Stop auto-refresh during search + stopAutoRefresh(); + + searchTimeout = setTimeout(async () => { + if (!canMakeApiCall()) { + console.warn('Rate limit reached, skipping search'); + return; + } + + try { + const response = await fetch(`${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`); + const locations = await response.json(); + + const stations = locations.filter(loc => + loc.type === 'stop' || loc.type === 'station' + ); + + displaySuggestions(stations); + } catch (error) { + console.error('Suchfehler:', error); + } + }, 300); + }); + + // Clear button + clearBtn.addEventListener('click', () => { + stationSearch.value = ''; + suggestions.classList.remove('active'); + suggestions.innerHTML = ''; + stationSearch.focus(); + }); + + // Location button - Find nearest station + locationBtn.addEventListener('click', async () => { + // Check if geolocation is supported + if (!navigator.geolocation) { + showLocationError('⚠️ Dein Browser unterstützt keine Standortdienste'); + return; + } + + // Check if HTTPS (required for geolocation in most browsers) + if (location.protocol !== 'https:' && location.hostname !== 'localhost') { + showLocationError('⚠️ Standortdienste benötigen HTTPS. Bitte nutze eine sichere Verbindung.'); + return; + } + + // Check rate limit before starting + if (!canMakeApiCall()) { + showLocationError('⚠️ Rate-Limit erreicht. Bitte warte kurz.'); + return; + } + + locationBtn.disabled = true; + locationBtn.textContent = '⌛'; // Emoji wechseln während Loading + + // Show loading in search field + const originalPlaceholder = stationSearch.placeholder; + stationSearch.placeholder = '📍 Suche nächste Station...'; + + try { + // Try to check permission state first (if available) + if (navigator.permissions) { + try { + const permissionStatus = await navigator.permissions.query({ name: 'geolocation' }); + + if (permissionStatus.state === 'denied') { + throw new Error('PERMISSION_DENIED'); + } + } catch (e) { + // Permissions API might not be available, continue anyway + console.log('Permissions API not available, trying geolocation directly'); + } + } + + // Get current position with better options + const position = await new Promise((resolve, reject) => { + const options = { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 30000 // Cache for 30 seconds + }; + + navigator.geolocation.getCurrentPosition(resolve, reject, options); + }); + + const { latitude, longitude } = position.coords; + + stationSearch.placeholder = '📍 Suche Stationen in der Nähe...'; + + // Search nearby stations - VBB API endpoint + const nearbyUrl = `${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10`; + + console.log('Searching nearby stations:', nearbyUrl); + + const response = await fetch(nearbyUrl); + + if (!response.ok) { + console.error('API Response status:', response.status); + throw new Error('API_ERROR'); + } + + const locations = await response.json(); + + // Filter for stops/stations only + const stations = locations.filter(loc => + loc.type === 'stop' || loc.type === 'station' + ); + + if (!stations || stations.length === 0) { + throw new Error('NO_STATIONS'); + } + + // Select nearest station + const nearest = stations[0]; + const distance = nearest.distance ? Math.round(nearest.distance) : '?'; + + selectStation(nearest.id, nearest.name); + + // Show success feedback + if (navigator.vibrate) { + navigator.vibrate([50, 100, 50]); + } + + stationSearch.placeholder = `✅ ${nearest.name} (${distance}m entfernt)`; + setTimeout(() => { + stationSearch.placeholder = originalPlaceholder; + }, 4000); + + } catch (error) { + console.error('Standortfehler:', error); + + let errorMsg = ''; + let errorDetail = ''; + + if (error.message === 'PERMISSION_DENIED' || error.code === 1) { + errorMsg = '📍 Standortzugriff verweigert'; + errorDetail = 'Bitte erlaube den Standortzugriff in deinen Browser-Einstellungen:\n\n' + + '• iOS: Einstellungen → Safari → Standort\n' + + '• Android: Chrome → Einstellungen → Website-Einstellungen → Standort\n' + + '• Desktop: Adresszeile → 🔒 Symbol → Berechtigungen'; + } else if (error.code === 2) { + errorMsg = '📍 Standort nicht verfügbar'; + errorDetail = 'Dein Gerät konnte deinen Standort nicht ermitteln. Stelle sicher, dass:\n\n' + + '• GPS/Standortdienste aktiviert sind\n' + + '• Du dich nicht im Flugmodus befindest\n' + + '• Du eine gute Verbindung hast'; + } else if (error.code === 3) { + errorMsg = '⏱️ Zeitüberschreitung'; + errorDetail = 'Die Standortermittlung hat zu lange gedauert. Bitte versuche es erneut.'; + } else if (error.message === 'NO_STATIONS') { + errorMsg = '🚉 Keine Stationen in der Nähe'; + errorDetail = 'Im Umkreis von 1km wurden keine VBB-Stationen gefunden.\n\nBitte nutze die manuelle Suche.'; + } else if (error.message === 'API_ERROR') { + errorMsg = '⚠️ Verbindungsfehler'; + errorDetail = 'Die Station konnte nicht gefunden werden.\n\nMögliche Ursachen:\n• Keine Internetverbindung\n• VBB-API vorübergehend nicht erreichbar\n\nBitte versuche es erneut oder nutze die manuelle Suche.'; + } else { + errorMsg = '⚠️ Unbekannter Fehler'; + errorDetail = error.message || 'Bitte versuche es erneut oder nutze die manuelle Suche.'; + } + + showLocationError(errorMsg, errorDetail); + stationSearch.placeholder = originalPlaceholder; + } finally { + locationBtn.disabled = false; + locationBtn.textContent = '📍'; // Zurück zum Pin-Emoji + } + }); + + function showLocationError(message, detail = '') { + if (detail) { + alert(message + '\n\n' + detail); + } else { + alert(message); + } + } + + // Click außerhalb schließt Vorschläge + document.addEventListener('click', (e) => { + if (!e.target.closest('.search-container')) { + suggestions.classList.remove('active'); + } + }); + + function displaySuggestions(stations) { + if (stations.length === 0) { + suggestions.innerHTML = '
Keine Stationen gefunden
'; + suggestions.classList.add('active'); + return; + } + + suggestions.innerHTML = stations.map(station => { + const products = station.products ? + Object.entries(station.products) + .filter(([_, available]) => available) + .map(([type]) => type.toUpperCase()) + .join(', ') : ''; + + return ` +
+
${station.name}
+ ${products ? `
${products}
` : ''} +
+ `; + }).join(''); + + suggestions.classList.add('active'); + + // Event-Listener für Vorschläge + suggestions.querySelectorAll('.suggestion-item').forEach(item => { + item.addEventListener('click', () => { + const stationId = item.dataset.id; + const stationName = item.dataset.name; + + if (stationId && stationName) { + selectStation(stationId, stationName); + } + }); + }); + } + + function selectStation(stationId, stationName) { + currentStationId = stationId; + currentStationName = stationName; + + stationSearch.value = stationName; + suggestions.classList.remove('active'); + refreshBtn.disabled = false; + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + + loadDepartures(); + } + + // Rate Limiting: Max 100 requests per minute + function canMakeApiCall() { + const now = Date.now(); + const oneMinuteAgo = now - 60000; + + // Remove timestamps older than 1 minute + apiCallTimestamps = apiCallTimestamps.filter(timestamp => timestamp > oneMinuteAgo); + + // Check if we're under the limit + if (apiCallTimestamps.length >= 100) { + console.warn('Rate limit reached: 100 requests per minute'); + return false; + } + + // Add current timestamp + apiCallTimestamps.push(now); + return true; + } + + // Start/Stop Auto-Refresh + function startAutoRefresh() { + stopAutoRefresh(); // Clear any existing interval + autoRefreshInterval = setInterval(() => { + if (currentStationId && canMakeApiCall()) { + updateDeparturesValues(); // Nur Werte updaten, nicht neu rendern + } + }, 30000); // 30 seconds + } + + function stopAutoRefresh() { + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } + } + + async function loadDepartures() { + if (!currentStationId) return; + + if (!canMakeApiCall()) { + console.warn('Skipping API call due to rate limit'); + return; + } + + departuresContainer.innerHTML = '
⏳ Lade Abfahrten...
'; + + try { + const response = await fetch( + `${API_BASE}/stops/${currentStationId}/departures?duration=60&results=20` + ); + + if (!response.ok) throw new Error('API-Fehler'); + + const data = await response.json(); + displayDepartures(data.departures || []); + + // Start auto-refresh after successful load + startAutoRefresh(); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + } catch (error) { + console.error('Fehler beim Laden:', error); + departuresContainer.innerHTML = ` +
+ ⚠️ Fehler beim Laden der Abfahrten
+ ${error.message} +
+ `; + // Stop auto-refresh on error + stopAutoRefresh(); + } + } + + // Update nur die Werte (Zeiten, Verspätungen) ohne DOM neu zu rendern + async function updateDeparturesValues() { + if (!currentStationId) return; + + try { + const response = await fetch( + `${API_BASE}/stops/${currentStationId}/departures?duration=60&results=20` + ); + + if (!response.ok) throw new Error('API-Fehler'); + + const data = await response.json(); + const departures = data.departures || []; + + // Aktualisiere nur die Zeitanzeigen + const now = new Date(); + const departureItems = departuresContainer.querySelectorAll('.departure-item'); + + departures.forEach((dep, index) => { + if (index >= departureItems.length) return; + + const item = departureItems[index]; + const when = new Date(dep.when); + const minutes = Math.round((when - now) / 60000); + const timeDisplay = minutes <= 0 ? 'Jetzt' : `${minutes} min`; + + const delay = dep.delay ? Math.round(dep.delay / 60) : 0; + + // Update Zeit + const timeElement = item.querySelector('.departure-time'); + if (timeElement) { + const delayHTML = delay > 0 ? + `
+${delay} min Verspätung
` : ''; + timeElement.innerHTML = timeDisplay + delayHTML; + } + }); + + // Update Timestamp in Station Info + const lastUpdateElement = departuresContainer.querySelector('.last-update'); + if (lastUpdateElement) { + lastUpdateElement.textContent = `Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s`; + } + + } catch (error) { + console.error('Fehler beim Aktualisieren:', error); + // Bei Fehler nicht stoppen, nächster Versuch in 30s + } + } + + function displayDepartures(departures) { + if (departures.length === 0) { + departuresContainer.innerHTML = ` +
+
🚉
+
Keine Abfahrten in den nächsten 60 Minuten
+
+ `; + return; + } + + const now = new Date(); + const stationInfoHTML = ` +
+
${currentStationName}
+
Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s
+
+ `; + + const departuresHTML = departures.map((dep, index) => { + const when = new Date(dep.when); + const minutes = Math.round((when - now) / 60000); + const timeDisplay = minutes <= 0 ? 'Jetzt' : `${minutes} min`; + + const delay = dep.delay ? Math.round(dep.delay / 60) : 0; + const delayHTML = delay > 0 ? + `
+${delay} min Verspätung
` : ''; + + const lineColor = getLineColor(dep.line); + const platformHTML = dep.platform ? + `
+ 🚉 + Gleis ${dep.platform} +
` : ''; + + const cancelled = dep.cancelled ? 'FÄLLT AUS' : ''; + + return ` +
+
+
+
+ ${dep.line.name}${cancelled} +
+ ${platformHTML} +
+
→ ${dep.direction}
+
+
+ ${timeDisplay} + ${delayHTML} +
+
+ `; + }).join(''); + + departuresContainer.innerHTML = stationInfoHTML + departuresHTML; + + // Event-Listener für Departure-Items + departuresContainer.querySelectorAll('.departure-item').forEach(item => { + item.addEventListener('click', () => { + const tripId = item.dataset.tripId; + const index = item.dataset.index; + if (tripId) { + showDepartureDetails(departures[index]); + } + }); + }); + } + + async function showDepartureDetails(departure) { + detailModal.classList.add('active'); + detailTitle.textContent = departure.line.name; + detailContent.innerHTML = '
⏳ Lade Details...
'; + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + + // Pause auto-refresh while viewing details + stopAutoRefresh(); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(15); + } + + try { + let tripDetails = null; + + // Versuche Trip-Details zu laden + if (departure.tripId && canMakeApiCall()) { + try { + const response = await fetch(`${API_BASE}/trips/${departure.tripId}`); + if (response.ok) { + const data = await response.json(); + tripDetails = data.trip; + } + } catch (e) { + console.log('Keine Trip-Details verfügbar'); + } + } + + displayDepartureDetails(departure, tripDetails); + } catch (error) { + console.error('Fehler beim Laden der Details:', error); + detailContent.innerHTML = ` +
+ ⚠️ Fehler beim Laden der Details
+ ${error.message} +
+ `; + } + } + + function displayDepartureDetails(departure, tripDetails) { + const when = new Date(departure.when); + const plannedWhen = departure.plannedWhen ? new Date(departure.plannedWhen) : when; + const delay = departure.delay ? Math.round(departure.delay / 60) : 0; + const lineColor = getLineColor(departure.line); + + let html = ` + +
+
+ ${departure.line.name} +
+
+ → ${departure.direction} +
+ ${departure.cancelled ? '
FÄLLT AUS
' : ''} +
+ + +
+
⏱️ Abfahrt
+
+
Geplant
+
${plannedWhen.toLocaleTimeString('de-DE')}
+
+
+
Aktuell
+
${when.toLocaleTimeString('de-DE')}
+
+ ${delay > 0 ? ` +
+
Verspätung
+
+${delay} min
+
+ ` : ''} + ${departure.platform ? ` +
+
Gleis/Steig
+
${departure.platform}
+
+ ` : ''} +
+ + +
+
🚊 Fahrzeug
+ ${departure.line.product ? ` +
+
Verkehrsmittel
+
${departure.line.product}
+
+ ` : ''} + ${departure.line.operator ? ` +
+
Betreiber
+
${departure.line.operator.name || departure.line.operator.id}
+
+ ` : ''} + ${departure.line.mode ? ` +
+
Modus
+
${departure.line.mode}
+
+ ` : ''} + ${departure.tripId ? ` +
+
Fahrt-ID
+
${departure.tripId}
+
+ ` : ''} +
+ `; + + // Halte anzeigen wenn verfügbar + if (tripDetails && tripDetails.stopovers && tripDetails.stopovers.length > 0) { + const currentStopIndex = tripDetails.stopovers.findIndex(stop => + stop.stop.id === currentStationId + ); + + html += ` +
+
🚏 Weitere Halte (${tripDetails.stopovers.length})
+
+ ${tripDetails.stopovers.map((stop, index) => { + const stopWhen = stop.departure || stop.arrival; + const stopPlannedWhen = stop.plannedDeparture || stop.plannedArrival; + const stopTime = stopWhen ? new Date(stopWhen) : null; + const stopPlannedTime = stopPlannedWhen ? new Date(stopPlannedWhen) : null; + const stopDelay = stop.departureDelay || stop.arrivalDelay || 0; + const stopDelayMin = Math.round(stopDelay / 60); + const isCurrent = index === currentStopIndex; + const isPassed = index < currentStopIndex; + + return ` +
+
+ ${isCurrent ? '📍 ' : ''}${stop.stop.name} +
+
+ ${stopPlannedTime ? stopPlannedTime.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) : '—'} + ${stopDelayMin > 0 ? `+${stopDelayMin}'` : ''} +
+
+ `; + }).join('')} +
+
+ `; + } + + // Hinweise anzeigen + if (departure.remarks && departure.remarks.length > 0) { + html += ` +
+
ℹ️ Hinweise
+ ${departure.remarks.map(remark => ` +
+ ${remark.type ? `
${remark.type}
` : ''} +
${remark.text || remark.summary || 'Keine Details'}
+
+ `).join('')} +
+ `; + } + + // Zusätzliche Trip-Details + if (tripDetails) { + if (tripDetails.cycle) { + html += ` +
+
🔄 Umlauf
+ ${tripDetails.cycle.min ? ` +
+
Min. Takt
+
${tripDetails.cycle.min} min
+
+ ` : ''} + ${tripDetails.cycle.max ? ` +
+
Max. Takt
+
${tripDetails.cycle.max} min
+
+ ` : ''} +
+ `; + } + + if (tripDetails.direction) { + html += ` +
+
+
Ziel-Richtung
+
${tripDetails.direction}
+
+
+ `; + } + } + + detailContent.innerHTML = html; + } + + function closeDetailModal() { + detailModal.classList.remove('active'); + detailModal.style.transform = ''; + document.body.style.overflow = ''; + + // Resume auto-refresh + if (currentStationId) { + startAutoRefresh(); + } + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + } + + // Detail Modal schließen + detailBackBtn.addEventListener('click', closeDetailModal); + + // ESC-Taste schließt Modal + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && detailModal.classList.contains('active')) { + closeDetailModal(); + } + if (e.key === 'Escape' && journeyDetailModal.classList.contains('active')) { + closeJourneyDetailModal(); + } + }); + + // Journey Detail Modal schließen + journeyDetailBackBtn.addEventListener('click', closeJourneyDetailModal); + + function closeJourneyDetailModal() { + journeyDetailModal.classList.remove('active'); + document.body.style.overflow = ''; + } + + function getLineColor(line) { + if (!line || !line.product) return '#666'; + + const product = line.product.toLowerCase(); + const name = line.name ? line.name.toUpperCase() : ''; + + // U-Bahn Linien + if (product === 'subway' || name.startsWith('U')) { + const ubahnColors = { + 'U1': '#55a838', 'U2': '#da421e', 'U3': '#16683d', + 'U4': '#f0d722', 'U5': '#7e5330', 'U6': '#8c6dab', + 'U7': '#528dba', 'U8': '#224f86', 'U9': '#f3791d' + }; + return ubahnColors[name] || '#0066CC'; + } + + // S-Bahn + if (product === 'suburban' || name.startsWith('S')) { + return '#5D871E'; + } + + // Tram + if (product === 'tram') { + return '#CC0000'; + } + + // Bus + if (product === 'bus') { + return '#993399'; + } + + // Regional + if (product === 'regional' || product === 'regionalexp') { + return '#DC281E'; + } + + return '#666'; + } + + // Refresh button + refreshBtn.addEventListener('click', loadDepartures); + + // Journey Planner State + let journeyFromStation = null; + let journeyToStation = null; + + // Journey Autocomplete + let journeyFromTimeout = null; + let journeyToTimeout = null; + + function setupJourneyAutocomplete(inputField, suggestionsContainer, onSelect) { + let timeout = null; + + inputField.addEventListener('input', (e) => { + const query = e.target.value.trim(); + + clearTimeout(timeout); + + if (query.length < 2) { + suggestionsContainer.classList.remove('active'); + suggestionsContainer.innerHTML = ''; + return; + } + + timeout = setTimeout(async () => { + if (!canMakeApiCall()) { + console.warn('Rate limit reached, skipping search'); + return; + } + + try { + const response = await fetch(`${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`); + const locations = await response.json(); + + const stops = locations.filter(loc => + loc.type === 'stop' || loc.type === 'station' + ); + + if (stops.length > 0) { + suggestionsContainer.innerHTML = stops.map(stop => ` +
+ ${stop.name} +
+ `).join(''); + + suggestionsContainer.classList.add('active'); + + suggestionsContainer.querySelectorAll('.suggestion-item').forEach(item => { + item.addEventListener('click', () => { + const id = item.dataset.id; + const name = item.dataset.name; + onSelect(id, name); + inputField.value = name; + suggestionsContainer.classList.remove('active'); + + if (navigator.vibrate) navigator.vibrate(10); + }); + }); + } + } catch (error) { + console.error('Autocomplete error:', error); + } + }, 300); + }); + } + + // Setup Journey From + setupJourneyAutocomplete(journeyFrom, suggestionsFrom, (id, name) => { + journeyFromStation = { id, name }; + checkJourneySearchReady(); + }); + + // Setup Journey To + setupJourneyAutocomplete(journeyTo, suggestionsTo, (id, name) => { + journeyToStation = { id, name }; + checkJourneySearchReady(); + }); + + // Clear buttons + clearFromBtn.addEventListener('click', () => { + journeyFrom.value = ''; + journeyFromStation = null; + suggestionsFrom.classList.remove('active'); + checkJourneySearchReady(); + }); + + clearToBtn.addEventListener('click', () => { + journeyTo.value = ''; + journeyToStation = null; + suggestionsTo.classList.remove('active'); + checkJourneySearchReady(); + }); + + // Swap button + swapBtn.addEventListener('click', () => { + const tempStation = journeyFromStation; + const tempValue = journeyFrom.value; + + journeyFromStation = journeyToStation; + journeyFrom.value = journeyTo.value; + + journeyToStation = tempStation; + journeyTo.value = tempValue; + + checkJourneySearchReady(); + + if (navigator.vibrate) navigator.vibrate(15); + }); + + // Location From Button + locationFromBtn.addEventListener('click', async () => { + if (!navigator.geolocation) { + alert('⚠️ Standortdienste nicht verfügbar'); + return; + } + + if (!canMakeApiCall()) { + alert('⚠️ Rate-Limit erreicht'); + return; + } + + locationFromBtn.disabled = true; + locationFromBtn.textContent = '⌛'; + + try { + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + timeout: 15000, + maximumAge: 30000 + }); + }); + + const { latitude, longitude } = position.coords; + + const response = await fetch( + `${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10` + ); + + if (!response.ok) throw new Error('API_ERROR'); + + const locations = await response.json(); + const stations = locations.filter(loc => + loc.type === 'stop' || loc.type === 'station' + ); + + if (stations.length === 0) throw new Error('NO_STATIONS'); + + const nearest = stations[0]; + journeyFromStation = { id: nearest.id, name: nearest.name }; + journeyFrom.value = nearest.name; + checkJourneySearchReady(); + + if (navigator.vibrate) navigator.vibrate([50, 100, 50]); + + } catch (error) { + console.error('Location error:', error); + alert('⚠️ Standort konnte nicht ermittelt werden'); + } finally { + locationFromBtn.disabled = false; + locationFromBtn.textContent = '📍'; + } + }); + + // Check if search is ready + function checkJourneySearchReady() { + if (journeyFromStation && journeyToStation) { + searchJourneyBtn.disabled = false; + } else { + searchJourneyBtn.disabled = true; + } + } + + // Search Journey + searchJourneyBtn.addEventListener('click', async () => { + if (!journeyFromStation || !journeyToStation) return; + + if (!canMakeApiCall()) { + alert('⚠️ Rate-Limit erreicht. Bitte warte kurz.'); + return; + } + + journeyContainer.innerHTML = '
⏳ Suche Verbindungen...
'; + + try { + const url = `${API_BASE}/journeys?from=${journeyFromStation.id}&to=${journeyToStation.id}&results=5&transfers=3&transferTime=5`; + + const response = await fetch(url); + if (!response.ok) throw new Error('API-Fehler'); + + const data = await response.json(); + displayJourneys(data.journeys || []); + + if (navigator.vibrate) navigator.vibrate(10); + + } catch (error) { + console.error('Journey search error:', error); + journeyContainer.innerHTML = ` +
+ ⚠️ Fehler bei der Routensuche
+ ${error.message} +
+ `; + } + }); + + // Display Journeys + let currentJourneys = []; // Speichert aktuelle Journeys für Detail-Ansicht + + function displayJourneys(journeys) { + currentJourneys = journeys; // Speichern für später + + if (!journeys || journeys.length === 0) { + journeyContainer.innerHTML = ` +
+
🚫
+ Keine Verbindungen gefunden +
+ `; + return; + } + + const html = journeys.map((journey, journeyIndex) => { + const departure = new Date(journey.legs[0].plannedDeparture || journey.legs[0].departure); + const arrival = new Date(journey.legs[journey.legs.length - 1].plannedArrival || journey.legs[journey.legs.length - 1].arrival); + + const durationMs = arrival - departure; + const durationMin = Math.round(durationMs / 60000); + const hours = Math.floor(durationMin / 60); + const minutes = durationMin % 60; + const durationText = hours > 0 ? `${hours}h ${minutes}min` : `${minutes}min`; + + // Normalisierungs-Funktion definieren (wird mehrfach gebraucht) + function normalizeStationName(name) { + if (!name) return ''; + return name + .replace(/^(S\+U|U|S)\s+/i, '') + .replace(/\s*\(.*?\)\s*/g, '') + .replace(/^Berlin\s+/i, '') + .toLowerCase() + .trim(); + } + + // SCHRITT 1: Filtere Same-Place-Walks VORHER aus dem Array + const filteredLegs = journey.legs.filter((leg) => { + // Behalte alle Fahrzeuge + if (leg.line) return true; + + // Prüfe Fußwege: Nur behalten wenn verschiedene Orte + const originNorm = normalizeStationName(leg.origin?.name); + const destNorm = normalizeStationName(leg.destination?.name); + + const samePlace = originNorm && destNorm && ( + originNorm === destNorm || + originNorm.includes(destNorm) || + destNorm.includes(originNorm) + ); + + return !samePlace; // NUR Fußwege zwischen verschiedenen Orten behalten + }); + + // SCHRITT 2: Zeige gefilterte Legs an + const legsHtml = filteredLegs.map((leg, index) => { + const icon = getTransportIcon(leg); + const lineColor = leg.line ? getLineColor(leg.line) : '#666'; + + const depTime = new Date(leg.plannedDeparture || leg.departure).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const arrTime = new Date(leg.plannedArrival || leg.arrival).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + + // Fahrtdauer dieses Legs + const legDuration = Math.round((new Date(leg.plannedArrival || leg.arrival) - new Date(leg.plannedDeparture || leg.departure)) / 60000); + + // Gleisangaben + const depPlatform = leg.departurePlatform ? `Gleis ${leg.departurePlatform}` : ''; + const arrPlatform = leg.arrivalPlatform ? `Gleis ${leg.arrivalPlatform}` : ''; + + let transferHtml = ''; + // Transfer-Zeit zum NÄCHSTEN gefilterten Leg + if (index < filteredLegs.length - 1) { + const nextLeg = filteredLegs[index + 1]; + + const arrivalTime = new Date(leg.plannedArrival || leg.arrival); + const nextDepartureTime = new Date(nextLeg.plannedDeparture || nextLeg.departure); + const transferTime = Math.round((nextDepartureTime - arrivalTime) / 60000); + + // Bestimme Art des Übergangs + let transferType = ''; + let transferIcon = ''; + + // Prüfe ob gleicher Bahnhof + const destName = normalizeStationName(leg.destination?.name); + const originName = normalizeStationName(nextLeg.origin?.name); + + const sameStation = destName && originName && ( + destName === originName || + destName.includes(originName) || + originName.includes(destName) + ); + + if (leg.line && nextLeg.line) { + // Beide sind Fahrzeuge + if (sameStation) { + transferType = 'Umstieg'; + transferIcon = '🔄'; + } else { + transferType = 'Fußweg'; + transferIcon = '🚶'; + } + } else if (!leg.line || !nextLeg.line) { + // Mindestens eines ist Fußweg + transferType = 'Fußweg'; + transferIcon = '🚶'; + } + + const platformInfo = arrPlatform && nextLeg.departurePlatform ? + ` • ${arrPlatform} → Gleis ${nextLeg.departurePlatform}` : ''; + + // Berechne Anzeige-Zeit + let displayTime = transferTime >= 0 ? transferTime : 0; + + // Mindestens 2 Min bei Umstieg am gleichen Bahnhof + if (transferType === 'Umstieg' && sameStation && displayTime < 2) { + displayTime = Math.max(2, displayTime); + } + + // Warnung bei kurzen Umstiegszeiten + let warningClass = ''; + if (transferType === 'Umstieg' && displayTime < 3) { + warningClass = ' very-tight'; + } else if (transferType === 'Umstieg' && displayTime < 5) { + warningClass = ' tight'; + } + + transferHtml = ` +
+ ${transferIcon} ${transferType}${platformInfo} + ${displayTime} min +
+ `; + } + + // Zeige Dauer bei Fußwegen in der Leg-Zeile + const durationBadge = !leg.line ? ` (${legDuration} min)` : ''; + + return ` +
+
+
${icon}
+
${leg.line ? leg.line.name : 'Fußweg'}${durationBadge}
+
${legDuration} min
+
+ +
+
${leg.origin.name}
+
${depPlatform || ''}
+
${depTime}
+
+ +
+
${leg.destination.name}
+
${arrPlatform || ''}
+
${arrTime}
+
+
+ ${transferHtml} + `; + }).join(''); + + return ` +
+
+
+ ${departure.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} + → + ${arrival.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} +
+
${durationText}
+
+
+ ${legsHtml} +
+
+ `; + }).join(''); + + journeyContainer.innerHTML = html; + + // Click-Handler für Journey Results + journeyContainer.querySelectorAll('.journey-result').forEach(item => { + item.addEventListener('click', () => { + const journeyIndex = parseInt(item.dataset.journeyIndex); + showJourneyDetails(currentJourneys[journeyIndex], journeyIndex); + + if (navigator.vibrate) { + navigator.vibrate(10); + } + }); + }); + } + + // Show Journey Details + function showJourneyDetails(journey, journeyIndex) { + journeyDetailModal.classList.add('active'); + document.body.style.overflow = 'hidden'; + + const departure = new Date(journey.legs[0].plannedDeparture || journey.legs[0].departure); + const arrival = new Date(journey.legs[journey.legs.length - 1].plannedArrival || journey.legs[journey.legs.length - 1].arrival); + + const durationMs = arrival - departure; + const durationMin = Math.round(durationMs / 60000); + const hours = Math.floor(durationMin / 60); + const minutes = durationMin % 60; + const durationText = hours > 0 ? `${hours}h ${minutes}min` : `${minutes}min`; + + journeyDetailTitle.textContent = `Verbindung ${journeyIndex + 1}`; + + let contentHTML = ` +
+
📍 Übersicht
+
+ Von: + ${journey.legs[0].origin.name} +
+
+ Nach: + ${journey.legs[journey.legs.length - 1].destination.name} +
+
+ Abfahrt: + ${departure.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr +
+
+ Ankunft: + ${arrival.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr +
+
+ Dauer: + ${durationText} +
+
+ Umstiege: + ${journey.legs.filter(leg => leg.line).length - 1} Umstiege +
+
+ +
+
🚉 Fahrtabschnitte
+ `; + + // Normalisierungs-Funktion + function normalizeStationName(name) { + if (!name) return ''; + return name + .replace(/^(S\+U|U|S)\s+/i, '') + .replace(/\s*\(.*?\)\s*/g, '') + .replace(/^Berlin\s+/i, '') + .toLowerCase() + .trim(); + } + + // Filtere Same-Place-Walks VORHER + const filteredLegs = journey.legs.filter((leg) => { + if (leg.line) return true; + + const originNorm = normalizeStationName(leg.origin?.name); + const destNorm = normalizeStationName(leg.destination?.name); + + const samePlace = originNorm && destNorm && ( + originNorm === destNorm || + originNorm.includes(destNorm) || + destNorm.includes(originNorm) + ); + + return !samePlace; + }); + + filteredLegs.forEach((leg, index) => { + const icon = getTransportIcon(leg); + const lineColor = leg.line ? getLineColor(leg.line) : '#666'; + const depTime = new Date(leg.plannedDeparture || leg.departure).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const arrTime = new Date(leg.plannedArrival || leg.arrival).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + + const depPlatform = leg.departurePlatform ? `Gleis ${leg.departurePlatform}` : ''; + const arrPlatform = leg.arrivalPlatform ? `Gleis ${leg.arrivalPlatform}` : ''; + + const legDuration = Math.round((new Date(leg.plannedArrival || leg.arrival) - new Date(leg.plannedDeparture || leg.departure)) / 60000); + + contentHTML += ` +
+
+ ${icon} + ${leg.line ? leg.line.name : 'Fußweg'} + ${legDuration} min +
+ +
+
${depTime}
+
${leg.origin.name}
+ ${depPlatform ? `
${depPlatform}
` : ''} +
+ + ${leg.stopovers && leg.stopovers.length > 0 ? ` +
+ 🔄 ${leg.stopovers.length} Halte +
+ ` : ''} + +
+
${arrTime}
+
${leg.destination.name}
+ ${arrPlatform ? `
${arrPlatform}
` : ''} +
+
+ `; + + // Transfer-Zeit zum NÄCHSTEN gefilterten Leg + if (index < filteredLegs.length - 1) { + const nextLeg = filteredLegs[index + 1]; + + const arrivalTime = new Date(leg.plannedArrival || leg.arrival); + const nextDepartureTime = new Date(nextLeg.plannedDeparture || nextLeg.departure); + const transferTime = Math.round((nextDepartureTime - arrivalTime) / 60000); + + // Bestimme Art des Übergangs + let transferType = ''; + let transferIcon = ''; + + const destName = normalizeStationName(leg.destination?.name); + const originName = normalizeStationName(nextLeg.origin?.name); + + const sameStation = destName && originName && ( + destName === originName || + destName.includes(originName) || + originName.includes(destName) + ); + + if (leg.line && nextLeg.line) { + // Beide sind Fahrzeuge + if (sameStation) { + transferType = 'Umstieg'; + transferIcon = '🔄'; + } else { + transferType = 'Fußweg'; + transferIcon = '🚶'; + } + } else if (leg.walking || nextLeg.walking || !leg.line || !nextLeg.line) { + transferType = 'Fußweg'; + transferIcon = '🚶'; + } + + const platformInfo = arrPlatform && nextLeg.departurePlatform ? + `${arrPlatform} → Gleis ${nextLeg.departurePlatform}` + : ''; + + let displayTime = transferTime >= 0 ? transferTime : 0; + + if (transferType === 'Umstieg' && sameStation && displayTime < 2) { + displayTime = Math.max(2, displayTime); + } + + let warningClass = ''; + if (transferType === 'Umstieg' && displayTime < 3) { + warningClass = ' very-tight'; + } else if (transferType === 'Umstieg' && displayTime < 5) { + warningClass = ' tight'; + } + + contentHTML += ` +
+ ${transferIcon} ${transferType} • ${displayTime} min + ${platformInfo} +
+ `; + } + }); + + contentHTML += `
`; + + // Hinweise wenn vorhanden + if (journey.remarks && journey.remarks.length > 0) { + contentHTML += ` +
+
ℹ️ Hinweise
+ `; + + journey.remarks.forEach(remark => { + if (remark.text) { + contentHTML += ` +
+
${remark.text}
+
+ `; + } + }); + + contentHTML += `
`; + } + + journeyDetailContent.innerHTML = contentHTML; + + if (navigator.vibrate) { + navigator.vibrate(15); + } + } + + // Get Transport Icon + function getTransportIcon(leg) { + if (leg.walking) return '🚶'; + if (!leg.line) return '🚶'; + + const product = leg.line.product; + if (product === 'subway') return '🚇'; + if (product === 'suburban') return '🚊'; + if (product === 'tram') return '🚋'; + if (product === 'bus') return '🚌'; + if (product === 'regional' || product === 'regionalExp') return '🚆'; + if (product === 'ferry') return '⛴️'; + + return '🚉'; + } + + // Initial state + if (!navigator.onLine) { + offlineIndicator.classList.add('show'); + } + + // ========================================== + // LIVE-MAP FUNCTIONALITY + // ========================================== + + let liveMap = null; + let vehicleMarkers = []; + let liveMapUpdateInterval = null; + let activeVehicleTypes = new Set(['all']); + let debounceTimer = null; + + // Fahrzeug-Icons nach Typ + function getVehicleEmoji(product, lineName) { + const type = product?.mode || product?.name || ''; + const line = lineName || ''; + + // U-Bahn - verschiedene Icons je nach Linie + if (type.includes('subway') || type.includes('U-Bahn') || type.match(/^U\d/)) { + return '🚇'; + } + // S-Bahn + else if (type.includes('suburban') || type.includes('S-Bahn') || type.match(/^S\d/)) { + return '🚆'; + } + // Tram/Straßenbahn + else if (type.includes('tram') || type.includes('Tram') || line.match(/^M\d/) || line.match(/^\d{2}$/)) { + return '🚊'; + } + // Bus + else if (type.includes('bus') || type.includes('Bus')) { + // Express-Bus + if (line.includes('X') || line.includes('TXL')) { + return '🚌'; + } + // Nacht-Bus + else if (line.startsWith('N')) { + return '🌙'; + } + // Normal-Bus + return '🚌'; + } + // Regional-Bahn + else if (type.includes('regional') || line.includes('RE') || line.includes('RB')) { + return '🚉'; + } + // Express-Züge + else if (type.includes('express') || line.includes('ICE') || line.includes('IC')) { + return '🚅'; + } + // Fähre + else if (type.includes('ferry') || type.includes('Fähre')) { + return '⛴️'; + } + + return '🚍'; // Default + } + + + // BVG-Linienfarben (authentische Farben!) + function getBVGLineColor(lineName) { + const line = lineName?.toUpperCase() || ''; + + // U-Bahn Farben (offiziell) + const ubahnColors = { + 'U1': '#55A823', // Grün + 'U2': '#DA421E', // Rot + 'U3': '#16683D', // Türkis + 'U4': '#F0D722', // Gelb + 'U5': '#7E5330', // Braun + 'U6': '#8C6DAB', // Lila + 'U7': '#528DBA', // Hellblau + 'U8': '#224F86', // Dunkelblau + 'U9': '#F3791D' // Orange + }; + + // S-Bahn Farben (offiziell) + const sbahnColors = { + 'S1': '#DE4DA4', // Pink/Magenta + 'S2': '#076D3D', // Dunkelgrün + 'S25': '#55A823', // Grün + 'S26': '#7E5330', // Braun + 'S3': '#1E5BA8', // Blau + 'S41': '#A65A28', // Braun/Orange (Ring) + 'S42': '#A65A28', // Braun/Orange (Ring) + 'S45': '#F0D722', // Gelb + 'S46': '#DA421E', // Rot + 'S47': '#8C6DAB', // Lila + 'S5': '#F3791D', // Orange + 'S7': '#BA7AB5', // Lila/Magenta + 'S75': '#6F7072', // Grau + 'S8': '#55A823', // Grün + 'S85': '#FFFFFF', // Weiß + 'S9': '#DA421E' // Rot + }; + + // Tram Farben (Metro = Rot, andere = Gelb) + const tramColors = { + 'M1': '#DA421E', 'M2': '#DA421E', 'M4': '#DA421E', + 'M5': '#DA421E', 'M6': '#DA421E', 'M8': '#DA421E', + 'M10': '#DA421E', 'M13': '#DA421E', 'M17': '#DA421E' + }; + + // Bus Farben (Metro = Gelb, Express = Blau, Normal = Lila) + if (line.startsWith('M') && !line.match(/M\d{1,2}$/)) { + return '#F0D722'; // Metro-Bus: Gelb + } + if (line.startsWith('X') || line.includes('TXL')) { + return '#1E5BA8'; // Express-Bus: Blau + } + if (line.startsWith('N')) { + return '#000000'; // Nacht-Bus: Schwarz mit weißer Schrift + } + + // Farbe zurückgeben + return ubahnColors[line] || sbahnColors[line] || tramColors[line] || '#993399'; // Default: Lila + } + + // Fahrzeugtyp bestimmen + function getVehicleType(product, lineName) { + const type = product?.mode || product?.name || ''; + const line = lineName?.toUpperCase() || ''; + + // Prüfung nach Liniennamen (zuverlässiger!) + if (line.match(/^U\d/)) { + return 'subway'; + } else if (line.match(/^S\d/) || line.match(/^S\d{2}/)) { + return 'suburban'; + } else if (line.match(/^M\d/) || line.match(/^\d{2}$/)) { + return 'tram'; + } else if (line.startsWith('N') || line.startsWith('X') || line.match(/^\d{3}$/)) { + return 'bus'; + } + + // Fallback: Prüfung nach product.mode + if (type.includes('subway') || type.includes('U-Bahn')) { + return 'subway'; + } else if (type.includes('suburban') || type.includes('S-Bahn')) { + return 'suburban'; + } else if (type.includes('tram') || type.includes('Tram')) { + return 'tram'; + } else if (type.includes('bus') || type.includes('Bus')) { + return 'bus'; + } + + return 'other'; + } + + // Live-Map initialisieren + function initLiveMap() { + if (liveMap) return; // Bereits initialisiert + + // Zoom-Control nur auf Desktop + const isMobile = window.innerWidth < 768; + + // Map zentriert auf Berlin + liveMap = L.map('liveMap', { + center: [52.52, 13.405], + zoom: 12, + zoomControl: !isMobile // Nur auf Desktop! + }); + + // Tile Layer (CartoDB Dark Matter - Dark Mode!) + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors, © CartoDB', + maxZoom: 19, + minZoom: 10, + subdomains: 'abcd' + }).addTo(liveMap); + + // Fahrzeuge initial laden + loadVehicles(); + + // Auto-Update alle 15 Sekunden (erhöht von 10s) + liveMapUpdateInterval = setInterval(loadVehicles, 15000); + + // Event-Listener: Neu laden wenn Karte bewegt/gezoomt wird + // Mit Debouncing (1 Sekunde Verzögerung) um API zu schonen + liveMap.on('moveend', function() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + console.log('Map moved/zoomed, reloading vehicles...'); + loadVehicles(); + }, 1000); // 1 Sekunde warten nach Bewegung + }); + + console.log('Live-Map initialisiert mit dynamischen Bounds'); + } + + // Fahrzeuge von API laden + async function loadVehicles() { + // Sicherheitsabfrage: Map muss existieren + if (!liveMap) { + console.log('Live-Map noch nicht initialisiert'); + return; + } + + try { + // Loading-State anzeigen + const counterEl = document.getElementById('vehicleCount'); + if (counterEl) { + counterEl.innerHTML = '⏳ Lade...'; + } + + // Dynamische Bounding Box vom aktuellen Kartenausschnitt + const mapBounds = liveMap.getBounds(); + + // Puffer hinzufügen (15% größer) damit Fahrzeuge nicht sofort verschwinden + const latDiff = mapBounds.getNorth() - mapBounds.getSouth(); + const lngDiff = mapBounds.getEast() - mapBounds.getWest(); + const latPadding = latDiff * 0.15; + const lngPadding = lngDiff * 0.15; + + const bounds = { + north: Math.min(52.70, mapBounds.getNorth() + latPadding), // Max: Berlin Nord + south: Math.max(52.35, mapBounds.getSouth() - latPadding), // Min: Berlin Süd + west: Math.max(13.08, mapBounds.getWest() - lngPadding), // Min: Berlin West + east: Math.min(13.76, mapBounds.getEast() + lngPadding) // Max: Berlin Ost + }; + + console.log('Loading vehicles for bounds:', bounds); + + const url = `${API_BASE}/radar?` + + `north=${bounds.north}&south=${bounds.south}&` + + `west=${bounds.west}&east=${bounds.east}&` + + `results=200&duration=60&` + + `subway=true&bus=true&tram=true&suburban=true®ional=true`; + + const response = await fetch(url); + if (!response.ok) throw new Error('Radar API Error'); + + const data = await response.json(); + console.log('API returned', data.movements?.length || 0, 'vehicles'); + displayVehicles(data.movements || []); + + } catch (error) { + console.error('Load vehicles error:', error); + const counterEl = document.getElementById('vehicleCount'); + if (counterEl) { + counterEl.textContent = '⚠️ Fehler beim Laden der Fahrzeuge'; + } + } + } + + // Fahrzeuge auf Map anzeigen + function displayVehicles(movements) { + // Alte Marker entfernen + vehicleMarkers.forEach(marker => marker.remove()); + vehicleMarkers = []; + + console.log('displayVehicles called with', movements.length, 'movements'); + console.log('Active vehicle types:', Array.from(activeVehicleTypes)); + + // Filtern nach aktiven Typen + const filteredMovements = movements.filter(m => { + if (activeVehicleTypes.has('all')) return true; + const vehicleType = getVehicleType(m.line?.product, m.line?.name); + return activeVehicleTypes.has(vehicleType); + }); + + console.log('Filtered to', filteredMovements.length, 'vehicles'); + + // Update Counter + document.getElementById('vehicleCount').innerHTML = + `🚇 ${filteredMovements.length} Fahrzeuge aktiv`; + + // Marker erstellen + filteredMovements.forEach(movement => { + if (!movement.location || !movement.location.latitude || !movement.location.longitude) { + return; + } + + const lat = movement.location.latitude; + const lon = movement.location.longitude; + const lineName = movement.line?.name || 'Unbekannt'; + const emoji = getVehicleEmoji(movement.line?.product, lineName); + + // Echte BVG-Linienfarbe! + const lineColor = getBVGLineColor(lineName); + + // Textfarbe anpassen (weiß oder schwarz je nach Hintergrund) + const isLightBg = lineColor === '#F0D722' || lineColor === '#FFFFFF' || lineColor === '#55A823'; + const textColor = isLightBg ? '#000' : '#FFF'; + + // Custom Icon: Emoji OBEN, Linie UNTEN + const icon = L.divIcon({ + html: ` +
+
${emoji}
+
${lineName}
+
+ `, + className: 'custom-marker', + iconSize: [50, 60], + iconAnchor: [25, 30] + }); + + // Marker erstellen + const marker = L.marker([lat, lon], { icon: icon }); + + // Popup mit Fahrzeuginfos + const direction = movement.direction || 'Keine Angabe'; + const delay = movement.delay ? `+${Math.round(movement.delay / 60)} min Verspätung` : 'Pünktlich'; + + const popupContent = ` +
+ + + +
+ `; + + marker.bindPopup(popupContent); + marker.addTo(liveMap); + vehicleMarkers.push(marker); + }); + } + + // Filter Buttons mit Event Delegation + document.addEventListener('click', function(e) { + // Prüfen ob es ein Filter-Button ist + if (e.target.closest('.filter-btn')) { + const btn = e.target.closest('.filter-btn'); + const type = btn.dataset.type; + + console.log('Filter button clicked:', type); + + if (type === 'all') { + // Alle Filter deaktivieren, nur "Alle" aktiv + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + activeVehicleTypes.clear(); + activeVehicleTypes.add('all'); + } else { + // "Alle" deaktivieren + const allBtn = document.querySelector('.filter-btn[data-type="all"]'); + if (allBtn) allBtn.classList.remove('active'); + activeVehicleTypes.delete('all'); + + // Toggle Filter + if (activeVehicleTypes.has(type)) { + activeVehicleTypes.delete(type); + btn.classList.remove('active'); + console.log('Removed filter:', type); + } else { + activeVehicleTypes.add(type); + btn.classList.add('active'); + console.log('Added filter:', type); + } + + // Falls keine Filter aktiv, "Alle" wieder aktivieren + if (activeVehicleTypes.size === 0) { + activeVehicleTypes.add('all'); + const allBtn = document.querySelector('.filter-btn[data-type="all"]'); + if (allBtn) allBtn.classList.add('active'); + console.log('No filters active, reset to all'); + } + } + + console.log('Active filters after click:', Array.from(activeVehicleTypes)); + + // Fahrzeuge neu laden mit Filter + loadVehicles(); + + if (navigator.vibrate) navigator.vibrate(5); + } + }); + diff --git a/server.js b/server.js index 2ba742c..1057642 100644 --- a/server.js +++ b/server.js @@ -1,116 +1,116 @@ -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const mimeTypes = require('mime-types'); - -const PORT = 3000; -const CERT_DIR = path.join(__dirname, 'certs'); -const CERT_FILE = path.join(CERT_DIR, 'cert.pem'); -const KEY_FILE = path.join(CERT_DIR, 'key.pem'); - -// Create certs directory if it doesn't exist -if (!fs.existsSync(CERT_DIR)) { - fs.mkdirSync(CERT_DIR); -} - -// Generate self-signed certificate if it doesn't exist -if (!fs.existsSync(CERT_FILE) || !fs.existsSync(KEY_FILE)) { - console.log('🔐 Generating self-signed SSL certificate...'); - try { - execSync(`openssl req -x509 -newkey rsa:4096 -keyout "${KEY_FILE}" -out "${CERT_FILE}" -days 365 -nodes -subj "/CN=localhost"`, { - stdio: 'inherit' - }); - console.log('✅ Certificate generated successfully!'); - } catch (error) { - console.error('❌ Error generating certificate:', error.message); - console.log('📝 Please install OpenSSL or create certificates manually.'); - process.exit(1); - } -} - -// HTTPS server options -const options = { - key: fs.readFileSync(KEY_FILE), - cert: fs.readFileSync(CERT_FILE) -}; - -// Create HTTPS server -const server = https.createServer(options, (req, res) => { - // Remove query parameters and get clean path - let filePath = req.url.split('?')[0]; - - // Default to index.html for root - if (filePath === '/') { - filePath = '/index.html'; - } - - // Construct full file path - const fullPath = path.join(__dirname, filePath); - - // Security check: prevent directory traversal - const normalizedPath = path.normalize(fullPath); - if (!normalizedPath.startsWith(__dirname)) { - res.writeHead(403); - res.end('Forbidden'); - return; - } - - // Check if file exists - fs.access(fullPath, fs.constants.F_OK, (err) => { - if (err) { - res.writeHead(404); - res.end('File not found'); - return; - } - - // Get MIME type - const mimeType = mimeTypes.lookup(fullPath) || 'application/octet-stream'; - - // Read and serve file - fs.readFile(fullPath, (err, data) => { - if (err) { - res.writeHead(500); - res.end('Error reading file'); - return; - } - - // Set appropriate headers - res.writeHead(200, { - 'Content-Type': mimeType, - 'Cache-Control': 'no-cache' - }); - res.end(data); - }); - }); -}); - -// Start server -server.listen(PORT, () => { - console.log(''); - console.log('🚀 VBB Netz Status - HTTPS Development Server'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(`✅ Server running at: https://localhost:${PORT}`); - console.log(''); - console.log('📱 To test on mobile:'); - console.log(' 1. Find your local IP: ipconfig (Windows) or ifconfig (Mac/Linux)'); - console.log(' 2. Open https://YOUR_IP:3000 on your mobile device'); - console.log(' 3. Accept the self-signed certificate warning'); - console.log(''); - console.log('⚠️ Note: Your browser will show a security warning.'); - console.log(' This is normal for self-signed certificates in development.'); - console.log(' Click "Advanced" → "Proceed to localhost" to continue.'); - console.log(''); - console.log('🛑 Press Ctrl+C to stop the server'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(''); -}); - -// Graceful shutdown -process.on('SIGINT', () => { - console.log('\n\n👋 Shutting down server...'); - server.close(() => { - console.log('✅ Server stopped'); - process.exit(0); - }); -}); +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const mimeTypes = require('mime-types'); + +const PORT = 3000; +const CERT_DIR = path.join(__dirname, 'certs'); +const CERT_FILE = path.join(CERT_DIR, 'cert.pem'); +const KEY_FILE = path.join(CERT_DIR, 'key.pem'); + +// Create certs directory if it doesn't exist +if (!fs.existsSync(CERT_DIR)) { + fs.mkdirSync(CERT_DIR); +} + +// Generate self-signed certificate if it doesn't exist +if (!fs.existsSync(CERT_FILE) || !fs.existsSync(KEY_FILE)) { + console.log('🔐 Generating self-signed SSL certificate...'); + try { + execSync(`openssl req -x509 -newkey rsa:4096 -keyout "${KEY_FILE}" -out "${CERT_FILE}" -days 365 -nodes -subj "/CN=localhost"`, { + stdio: 'inherit' + }); + console.log('✅ Certificate generated successfully!'); + } catch (error) { + console.error('❌ Error generating certificate:', error.message); + console.log('📝 Please install OpenSSL or create certificates manually.'); + process.exit(1); + } +} + +// HTTPS server options +const options = { + key: fs.readFileSync(KEY_FILE), + cert: fs.readFileSync(CERT_FILE) +}; + +// Create HTTPS server +const server = https.createServer(options, (req, res) => { + // Remove query parameters and get clean path + let filePath = req.url.split('?')[0]; + + // Default to index.html for root + if (filePath === '/') { + filePath = '/index.html'; + } + + // Construct full file path + const fullPath = path.join(__dirname, filePath); + + // Security check: prevent directory traversal + const normalizedPath = path.normalize(fullPath); + if (!normalizedPath.startsWith(__dirname)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + // Check if file exists + fs.access(fullPath, fs.constants.F_OK, (err) => { + if (err) { + res.writeHead(404); + res.end('File not found'); + return; + } + + // Get MIME type + const mimeType = mimeTypes.lookup(fullPath) || 'application/octet-stream'; + + // Read and serve file + fs.readFile(fullPath, (err, data) => { + if (err) { + res.writeHead(500); + res.end('Error reading file'); + return; + } + + // Set appropriate headers + res.writeHead(200, { + 'Content-Type': mimeType, + 'Cache-Control': 'no-cache' + }); + res.end(data); + }); + }); +}); + +// Start server +server.listen(PORT, () => { + console.log(''); + console.log('🚀 VBB Netz Status - HTTPS Development Server'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`✅ Server running at: https://localhost:${PORT}`); + console.log(''); + console.log('📱 To test on mobile:'); + console.log(' 1. Find your local IP: ipconfig (Windows) or ifconfig (Mac/Linux)'); + console.log(' 2. Open https://YOUR_IP:3000 on your mobile device'); + console.log(' 3. Accept the self-signed certificate warning'); + console.log(''); + console.log('⚠️ Note: Your browser will show a security warning.'); + console.log(' This is normal for self-signed certificates in development.'); + console.log(' Click "Advanced" → "Proceed to localhost" to continue.'); + console.log(''); + console.log('🛑 Press Ctrl+C to stop the server'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\n\n👋 Shutting down server...'); + server.close(() => { + console.log('✅ Server stopped'); + process.exit(0); + }); +}); diff --git a/service-worker.js b/service-worker.js index 0591700..4310be2 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,69 +1,69 @@ -const CACHE_NAME = 'vbb-status-v29'; -const urlsToCache = [ - '/', - '/index.html', - '/manifest.json', - '/DotMatrix.ttf', - '/images/favicon.png' -]; - -// Installation -self.addEventListener('install', event => { - event.waitUntil( - caches.open(CACHE_NAME) - .then(cache => { - console.log('Cache geöffnet'); - return cache.addAll(urlsToCache); - }) - ); - self.skipWaiting(); -}); - -// Aktivierung -self.addEventListener('activate', event => { - event.waitUntil( - caches.keys().then(cacheNames => { - return Promise.all( - cacheNames.map(cacheName => { - if (cacheName !== CACHE_NAME) { - console.log('Alter Cache wird gelöscht:', cacheName); - return caches.delete(cacheName); - } - }) - ); - }) - ); - self.clients.claim(); -}); - -// Fetch -self.addEventListener('fetch', event => { - event.respondWith( - caches.match(event.request) - .then(response => { - // Cache hit - return response - if (response) { - return response; - } - - return fetch(event.request).then( - response => { - // Check if valid response - if (!response || response.status !== 200 || response.type !== 'basic') { - return response; - } - - // Clone the response - const responseToCache = response.clone(); - - caches.open(CACHE_NAME) - .then(cache => { - cache.put(event.request, responseToCache); - }); - - return response; - } - ); - }) - ); -}); +const CACHE_NAME = 'vbb-status-v29'; +const urlsToCache = [ + '/', + '/index.html', + '/manifest.json', + '/DotMatrix.ttf', + '/images/favicon.png' +]; + +// Installation +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('Cache geöffnet'); + return cache.addAll(urlsToCache); + }) + ); + self.skipWaiting(); +}); + +// Aktivierung +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME) { + console.log('Alter Cache wird gelöscht:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request) + .then(response => { + // Cache hit - return response + if (response) { + return response; + } + + return fetch(event.request).then( + response => { + // Check if valid response + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + // Clone the response + const responseToCache = response.clone(); + + caches.open(CACHE_NAME) + .then(cache => { + cache.put(event.request, responseToCache); + }); + + return response; + } + ); + }) + ); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c2d7177 --- /dev/null +++ b/styles.css @@ -0,0 +1,2314 @@ + /* DotMatrix Font by NikBLN - https://github.com/NikBLN/weilSieDichLieben */ + @font-face { + font-family: 'DotMatrix'; + src: url('https://raw.githubusercontent.com/NikBLN/weilSieDichLieben/refs/heads/main/public/fonts/DotMatrix.ttf') format('truetype'); + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: 'DotMatrix', 'Courier New', monospace; + background-color: #000; + color: #FFED00; + min-height: 100vh; + overflow-x: hidden; + -webkit-tap-highlight-color: transparent; + } + + .container { + max-width: 100%; + padding: 10px; + } + + @media (min-width: 768px) { + .container { + padding: 15px 20px; + } + } + + .header { + text-align: center; + padding: 20px 10px 15px; + background-color: #000; + position: sticky; + top: 0; + z-index: 10000; + border-bottom: 2px solid #FFED00; + } + + .header-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 100%; + } + + .header-title { + flex: 1; + text-align: left; + padding-left: 15px; + max-width: calc(100% - 100px); + } + + .header h1 { + font-size: 26px; + letter-spacing: 2px; + margin-bottom: 6px; + } + + .header .subtitle { + font-size: 11px; + opacity: 0.7; + letter-spacing: 1px; + margin-bottom: 6px; + } + + .header .author { + font-size: 9px; + opacity: 0.5; + font-style: italic; + } + + .menu-btn { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + background: none; + border: 2px solid #FFED00; + color: #FFED00; + font-size: 24px; + padding: 8px 12px; + cursor: pointer; + border-radius: 6px; + transition: background-color 0.2s; + line-height: 1; + z-index: 10001; + } + + .menu-btn:active { + background-color: #FFED00; + color: #000; + } + + /* Navigation Menu */ + .nav-menu { + position: fixed; + top: 0; + right: -300px; + width: 280px; + height: 100%; + background-color: #000; + border-left: 2px solid #FFED00; + z-index: 10003; + transition: right 0.3s ease; + } + + .nav-menu.active { + right: 0; + } + + .nav-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + z-index: 10002; + display: none; + } + + .nav-overlay.active { + display: block; + } + + .nav-header { + padding: 20px; + border-bottom: 1px solid #333; + display: flex; + justify-content: space-between; + align-items: center; + } + + .nav-header h3 { + font-size: 18px; + color: #FFED00; + } + + .nav-close { + background: none; + border: none; + color: #FFED00; + font-size: 32px; + cursor: pointer; + padding: 0; + line-height: 1; + } + + .nav-items { + padding: 10px 0; + } + + .nav-item { + width: 100%; + padding: 18px 20px; + background: none; + border: none; + color: #FFED00; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + gap: 15px; + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 16px; + transition: background-color 0.2s; + border-left: 4px solid transparent; + } + + .nav-item:active, + .nav-item.active { + background-color: #1a1a1a; + border-left-color: #FFED00; + } + + .nav-icon { + font-size: 24px; + } + + .nav-label { + flex: 1; + } + + /* View Container */ + .view-container { + display: none; + } + + .view-container.active { + display: block; + } + + .controls { + background-color: #0a0a0a; + padding: 15px; + border-radius: 8px; + margin: 15px 0; + border: 1px solid #222; + } + + .search-row { + display: flex; + gap: 10px; + margin-bottom: 15px; + align-items: stretch; + } + + .search-container { + position: relative; + flex: 1; + } + + .search-input { + width: 100%; + padding: 12px 45px 12px 12px; + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 16px; + background-color: #000 !important; + color: #FFED00; + border: 2px solid #FFED00; + border-radius: 6px; + outline: none; + transition: border-color 0.2s; + height: 48px; + box-sizing: border-box; + } + + .search-input:focus { + border-color: #fff; + } + + .search-input::placeholder { + color: rgba(255, 237, 0, 0.5); + } + + /* Webkit Auto-fill Override */ + .search-input:-webkit-autofill, + .search-input:-webkit-autofill:hover, + .search-input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 1000px #000 inset !important; + -webkit-text-fill-color: #FFED00 !important; + } + + .clear-btn { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #FFED00; + font-size: 24px; + cursor: pointer; + padding: 4px 6px; + display: none; + line-height: 1; + opacity: 0.7; + transition: opacity 0.2s; + } + + .clear-btn:hover, + .clear-btn:active { + opacity: 1; + } + + .search-input:not(:placeholder-shown) ~ .clear-btn { + display: block; + } + + .suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: #0a0a0a; + border: 2px solid #FFED00; + border-top: none; + border-radius: 0 0 6px 6px; + max-height: 300px; + overflow-y: auto; + z-index: 1000; + display: none; + } + + .suggestions.active { + display: block; + } + + .suggestion-item { + padding: 12px; + cursor: pointer; + border-bottom: 1px solid #222; + transition: background-color 0.15s; + } + + .suggestion-item:last-child { + border-bottom: none; + } + + .suggestion-item:hover, + .suggestion-item:active { + background-color: #FFED00; + color: #000; + } + + .suggestion-name { + font-size: 14px; + font-weight: bold; + } + + .suggestion-products { + font-size: 11px; + opacity: 0.7; + margin-top: 4px; + } + + .button-group { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 0; + } + + .btn { + flex: 1; + padding: 12px; + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 14px; + background-color: #FFED00; + color: #000; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + transition: background-color 0.15s; + } + + .btn-full-width { + width: 100%; + } + + .btn:active { + background-color: #FFD700; + } + + .btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background-color: #666; + } + + .departures-container { + margin-top: 20px; + } + + .station-info { + background-color: #0a0a0a; + padding: 15px; + border-radius: 8px; + margin-bottom: 15px; + border-left: 4px solid #FFED00; + } + + .station-name { + font-size: 22px; + font-weight: bold; + margin-bottom: 6px; + } + + .last-update { + font-size: 13px; + opacity: 0.6; + } + + .departure-item { + background-color: #0a0a0a; + padding: 14px; + margin-bottom: 10px; + border-radius: 8px; + border-left: 4px solid #666; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + transition: background-color 0.15s; + } + + .departure-item:active { + background-color: #111; + } + + .departure-info { + flex: 1; + } + + .departure-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; + } + + .line-badge { + display: inline-block; + padding: 6px 12px; + border-radius: 4px; + font-weight: bold; + font-size: 16px; + } + + .departure-destination { + font-size: 18px; + margin-bottom: 4px; + } + + .departure-platform { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background-color: #1a1a1a; + border-radius: 3px; + font-size: 13px; + border: 1px solid #333; + } + + .platform-icon { + font-size: 12px; + opacity: 0.7; + } + + .platform-text { + font-weight: bold; + color: #FFED00; + } + + .departure-time { + text-align: right; + font-size: 24px; + font-weight: bold; + min-width: 70px; + } + + .departure-delay { + font-size: 13px; + color: #ff5555; + margin-top: 4px; + font-weight: bold; + } + + /* Journey Planner */ + .journey-inputs { + margin-bottom: 15px; + } + + .journey-input-group { + margin-bottom: 12px; + } + + .journey-label { + display: block; + font-size: 16px; + margin-bottom: 8px; + font-weight: bold; + } + + .swap-btn { + width: 100%; + padding: 12px; + margin: 15px 0; + background-color: #0a0a0a; + color: #FFED00; + border: 1px solid #333; + border-radius: 6px; + font-size: 20px; + cursor: pointer; + transition: background-color 0.2s; + } + + .swap-btn:active { + background-color: #1a1a1a; + } + + .journey-container { + margin-top: 15px; + } + + .journey-result { + background-color: #0a0a0a; + border: 1px solid #222; + border-radius: 8px; + padding: 15px; + margin-bottom: 12px; + } + + .journey-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #222; + } + + .journey-time { + font-size: 20px; + font-weight: bold; + } + + .journey-duration { + font-size: 14px; + opacity: 0.7; + } + + .journey-legs { + margin-top: 12px; + } + + .journey-leg { + margin-bottom: 12px; + padding: 16px; + background-color: #050505; + border-radius: 6px; + border-left: 4px solid #666; + } + + .journey-leg-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 15px; + } + + .journey-leg-icon { + font-size: 28px; + min-width: 32px; + } + + .journey-leg-line { + font-weight: bold; + font-size: 20px; + flex: 1; + } + + .journey-leg-duration { + font-size: 16px; + opacity: 0.6; + } + + .journey-leg-stop { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 30px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #111; + } + + .journey-leg-stop:last-child { + border-bottom: none; + } + + .journey-leg-station { + font-size: 18px; + font-weight: 500; + } + + .journey-leg-platform { + font-size: 16px; + color: #FFED00; + font-weight: bold; + min-width: 100px; + text-align: right; + } + + .journey-leg-time { + font-size: 20px; + font-weight: bold; + text-align: right; + min-width: 70px; + } + + .journey-transfer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + margin: 10px 0; + background-color: #1a1a00; + border-radius: 4px; + font-size: 14px; + border-left: 3px solid #FFED00; + } + + .journey-transfer.tight { + background-color: #2a1a00; + border-left-color: #ff9900; + } + + .journey-transfer.very-tight { + background-color: #2a0000; + border-left-color: #ff3333; + } + + .journey-transfer-time { + font-weight: bold; + color: #FFED00; + font-size: 16px; + margin-left: auto; + } + + .journey-transfer.tight .journey-transfer-time { + color: #ff9900; + } + + .journey-transfer.very-tight .journey-transfer-time { + color: #ff3333; + } + + .loading { + text-align: center; + padding: 40px; + font-size: 18px; + animation: pulse 1.5s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .error { + background: linear-gradient(135deg, #ff4444 0%, #cc0000 100%); + color: #fff; + padding: 18px; + border-radius: 12px; + margin: 15px 0; + text-align: center; + box-shadow: 0 4px 20px rgba(255, 68, 68, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .empty-state { + text-align: center; + padding: 50px 20px; + opacity: 0.7; + } + + .empty-state-icon { + font-size: 56px; + margin-bottom: 20px; + animation: float 3s ease-in-out infinite; + } + + @keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } + } + + .offline-indicator { + background: linear-gradient(135deg, #ff4444 0%, #cc0000 100%); + color: #fff; + padding: 12px; + text-align: center; + font-size: 12px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 200; + display: none; + box-shadow: 0 2px 10px rgba(255, 68, 68, 0.5); + font-weight: bold; + letter-spacing: 0.5px; + } + + .offline-indicator.show { + display: block; + animation: slideDown 0.3s ease-out; + } + + @keyframes slideDown { + from { transform: translateY(-100%); } + to { transform: translateY(0); } + } + + /* Detail Modal */ + .detail-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.97); + z-index: 10005; + display: none; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + + .detail-modal.active { + display: block; + } + + .detail-header { + position: sticky; + top: 0; + background-color: #000; + padding: 15px; + border-bottom: 2px solid #FFED00; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 10006; + } + + .detail-back-btn { + background: none; + border: 1px solid #FFED00; + color: #FFED00; + font-size: 22px; + cursor: pointer; + padding: 6px 12px; + font-family: 'DotMatrix', 'Courier New', monospace; + border-radius: 6px; + transition: background-color 0.15s; + } + + .detail-back-btn:active { + background-color: #FFED00; + color: #000; + } + + .detail-title { + flex: 1; + text-align: center; + font-size: 16px; + font-weight: bold; + } + + .detail-content { + padding: 15px; + } + + .detail-section { + background-color: #0a0a0a; + padding: 15px; + margin-bottom: 12px; + border-radius: 8px; + border-left: 4px solid #FFED00; + } + + .detail-section-title { + font-size: 13px; + font-weight: bold; + margin-bottom: 10px; + opacity: 0.8; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .detail-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #222; + } + + .detail-row:last-child { + border-bottom: none; + } + + .detail-label { + font-size: 12px; + opacity: 0.7; + } + + .detail-value { + font-size: 12px; + font-weight: bold; + text-align: right; + } + + .stop-list { + margin-top: 10px; + } + + .stop-item { + padding: 10px; + margin-bottom: 8px; + background-color: #050505; + border-radius: 6px; + border-left: 3px solid #555; + transition: background-color 0.15s; + } + + .stop-item.current { + border-left-color: #FFED00; + background-color: #0f0f0a; + } + + .stop-name { + font-size: 13px; + font-weight: bold; + margin-bottom: 4px; + } + + .stop-time { + font-size: 11px; + opacity: 0.7; + } + + .stop-delay { + color: #ff5555; + font-size: 11px; + margin-left: 8px; + font-weight: bold; + } + + .remark-item { + background-color: #0a0500; + padding: 10px; + margin-bottom: 8px; + border-radius: 6px; + border-left: 3px solid #ff8800; + } + + .remark-type { + font-size: 10px; + opacity: 0.7; + text-transform: uppercase; + margin-bottom: 4px; + color: #ff8800; + font-weight: bold; + } + + .remark-text { + font-size: 12px; + line-height: 1.3; + } + + .loading-detail { + text-align: center; + padding: 50px 20px; + font-size: 16px; + opacity: 0.7; + } + + /* Journey Detail Modal */ + .journey-detail-leg { + margin-bottom: 20px; + padding: 15px; + background-color: #050505; + border-radius: 6px; + } + + .journey-detail-leg-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #222; + } + + .journey-detail-stop { + padding: 10px 0; + margin-left: 30px; + } + + .journey-detail-stop-time { + font-size: 18px; + font-weight: bold; + color: #FFED00; + margin-bottom: 4px; + } + + .journey-detail-stop-name { + font-size: 14px; + margin-bottom: 3px; + } + + .journey-detail-platform { + font-size: 11px; + color: #FFED00; + opacity: 0.8; + } + + .journey-detail-intermediate { + margin: 8px 0 8px 30px; + padding: 8px; + background-color: #0a0a0a; + border-radius: 4px; + } + + .journey-detail-transfer { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + margin: 10px 0; + background-color: #1a1a00; + border-radius: 4px; + border-left: 3px solid #FFED00; + font-size: 12px; + } + + .journey-detail-transfer.tight { + background-color: #2a1a00; + border-left-color: #ff9900; + } + + .journey-detail-transfer.very-tight { + background-color: #2a0000; + border-left-color: #ff3333; + } + + /* Touch improvements */ + .departure-item { + min-height: 70px; + -webkit-tap-highlight-color: rgba(255, 237, 0, 0.1); + } + + .suggestion-item { + min-height: 50px; + -webkit-tap-highlight-color: rgba(255, 237, 0, 0.1); + } + + /* Better scrolling */ + .suggestions { + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: #FFED00 #1a1a1a; + } + + .suggestions::-webkit-scrollbar { + width: 6px; + } + + .suggestions::-webkit-scrollbar-track { + background: #1a1a1a; + } + + .suggestions::-webkit-scrollbar-thumb { + background-color: #FFED00; + border-radius: 3px; + } + + /* Improved button spacing */ + .btn { + min-height: 48px; + } + + .location-btn-side { + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + padding: 0; + font-size: 20px; + background-color: #000; + color: #FFED00; + border: 2px solid #FFED00; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + outline: none; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-sizing: border-box; + } + + .location-btn-side:active { + background-color: #FFED00; + color: #000; + } + + .location-btn-side:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + /* KEIN Scale/Transform - nur Emoji ändert sich */ + .location-btn-side.loading { + /* Button bleibt gleich, nur Emoji wird gewechselt */ + } + + /* Status badge */ + .status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 10px; + margin-left: 8px; + font-weight: bold; + } + + .status-cancelled { + background-color: #cc0000; + color: #fff; + } + + .status-ontime { + background-color: #00cc00; + color: #000; + } + + @media (min-width: 768px) { + .container { + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + + .detail-modal { + left: 50%; + transform: translateX(-50%); + max-width: 600px; + } + } + + /* Pull-to-refresh styles */ + .ptr-container { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + background-color: #000; + transform: translateY(-100%); + transition: transform 0.3s; + z-index: 99; + } + + .ptr-container.pulling { + transform: translateY(0); + } + + .ptr-spinner { + width: 30px; + height: 30px; + border: 3px solid #333; + border-top-color: #FFED00; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* ============================================ + RESPONSIVE DESIGN + ============================================ */ + + /* Tablet & Desktop */ + @media (min-width: 768px) { + .container { + max-width: 1200px; + margin: 0 auto; + padding: 20px 30px; + } + + .header h1 { + font-size: 32px; + letter-spacing: 3px; + } + + .header .subtitle { + font-size: 13px; + } + + .menu-btn { + font-size: 28px; + padding: 10px 16px; + right: 20px; + } + + /* Hover-Effekte für Desktop */ + .menu-btn:hover { + background-color: rgba(255, 237, 0, 0.1); + } + + .nav-item:hover { + background-color: #1a1a1a; + } + + .departure-item:hover { + background-color: #111; + transform: translateX(4px); + transition: transform 0.2s; + } + + .journey-result:hover { + background-color: #111; + border-color: #333; + } + + .btn:hover { + background-color: rgba(255, 237, 0, 0.1); + } + + .swap-btn:hover { + background-color: #1a1a1a; + } + + .suggestion-item:hover { + background-color: #2a2a2a; + } + + .nav-menu { + width: 280px; + } + + /* Header größer auf Desktop */ + .header h1 { + font-size: 36px; + letter-spacing: 3px; + } + + .header .subtitle { + font-size: 14px; + } + + .header .author { + font-size: 11px; + } + + /* Controls größer */ + .controls { + padding: 20px; + background-color: rgba(10, 10, 10, 0.5); + border-radius: 8px; + } + + .search-input { + height: 56px; + font-size: 18px; + padding: 14px 50px 14px 14px; + } + + .location-btn-side { + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + font-size: 22px; + } + + .btn { + padding: 18px; + font-size: 18px; + min-height: 60px; + } + + .btn-full-width { + width: 100%; + font-size: 18px; + } + + /* Journey Input - 2 Column Layout */ + .journey-inputs { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 20px; + align-items: start; + } + + .journey-input-group { + margin-bottom: 0; + } + + .swap-btn { + margin: 45px 0 0 0; + width: auto; + padding: 12px 20px; + } + + .journey-label { + font-size: 18px; + } + + /* Station Info größer */ + .station-info { + padding: 25px; + background: linear-gradient(135deg, #0a0a0a 0%, #1a1a00 100%); + } + + .station-name { + font-size: 28px; + } + + .last-update { + font-size: 15px; + } + + /* Departure Items größer */ + .departure-item { + padding: 24px; + margin-bottom: 18px; + cursor: pointer; + } + + .line-badge { + padding: 8px 16px; + font-size: 20px; + } + + .departure-destination { + font-size: 22px; + } + + .departure-platform { + font-size: 16px; + padding: 5px 12px; + } + + .platform-icon { + font-size: 14px; + } + + .departure-time { + font-size: 32px; + } + + .departure-delay { + font-size: 16px; + } + + /* Journey Results */ + .journey-result { + padding: 25px; + margin-bottom: 20px; + cursor: pointer; + transition: all 0.2s; + } + + .journey-time { + font-size: 32px; + } + + .journey-duration { + font-size: 20px; + } + + .journey-leg { + padding: 25px; + } + + .journey-leg-header { + margin-bottom: 20px; + } + + .journey-leg-icon { + font-size: 36px; + } + + .journey-leg-line { + font-size: 28px; + } + + .journey-leg-duration { + font-size: 20px; + } + + .journey-leg-stop { + padding: 14px 0; + gap: 40px; + } + + .journey-leg-station { + font-size: 22px; + } + + .journey-leg-platform { + font-size: 20px; + min-width: 120px; + } + + .journey-leg-time { + font-size: 24px; + min-width: 80px; + } + + .journey-transfer { + padding: 18px 24px; + font-size: 20px; + } + + .journey-transfer-time { + font-size: 22px; + } + + /* Detail Modal */ + .detail-header { + padding: 28px; + } + + .detail-title { + font-size: 26px; + } + + .detail-content { + padding: 32px 28px; + } + + .detail-section { + padding: 24px; + } + + .stop-item { + padding: 16px; + } + + .stop-name { + font-size: 18px; + } + + /* Journey Detail Modal */ + .journey-detail-leg { + padding: 22px; + } + + .journey-detail-stop-time { + font-size: 24px; + } + + .journey-detail-stop-name { + font-size: 20px; + } + + .journey-detail-platform { + font-size: 16px; + } + + .journey-detail-transfer { + font-size: 18px; + } + } + + /* Large Desktop - 2 Column Layout */ + @media (min-width: 1200px) { + .container { + padding: 30px 40px; + } + + /* Abfahrten in 2 Spalten */ + #departuresContainer { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + } + + .station-info { + grid-column: 1 / -1; + } + + /* Journey auch größer */ + .journey-result { + padding: 30px; + } + + .journey-leg { + padding: 16px; + } + } + + /* Extra Large Desktop */ + @media (min-width: 1600px) { + .container { + max-width: 1400px; + } + + /* 3 Spalten für Abfahrten */ + #departuresContainer { + grid-template-columns: repeat(3, 1fr); + } + + .station-info { + grid-column: 1 / -1; + } + } + + /* Mobile Optimierungen */ + @media (max-width: 767px) { + /* Mobile Design mit größerer Schrift */ + .container { + padding: 10px; + } + + .header { + padding: 15px 10px 12px; + } + + .header-title { + max-width: calc(100% - 80px); + padding-left: 10px; + } + + .header h1 { + font-size: 24px; + letter-spacing: 1.5px; + } + + .header .subtitle { + font-size: 12px; + } + + .header .author { + font-size: 10px; + } + + .menu-btn { + font-size: 26px; + padding: 8px 12px; + right: 10px; + } + + .controls { + padding: 12px; + } + + .search-input { + font-size: 18px; + padding: 12px 44px 12px 12px; + height: 48px; + } + + .location-btn-side { + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + font-size: 20px; + } + + .btn { + padding: 14px; + font-size: 18px; + } + + .btn-full-width { + width: 100%; + font-size: 18px; + } + + .station-info { + padding: 14px; + } + + .station-name { + font-size: 20px; + } + + .last-update { + font-size: 12px; + } + + .departure-item { + padding: 16px; + margin-bottom: 12px; + } + + .line-badge { + padding: 6px 12px; + font-size: 16px; + } + + .departure-destination { + font-size: 18px; + } + + .departure-platform { + font-size: 14px; + padding: 4px 10px; + } + + .platform-icon { + font-size: 13px; + } + + .departure-time { + font-size: 26px; + } + + .departure-delay { + font-size: 14px; + } + + .journey-result { + padding: 16px; + margin-bottom: 12px; + } + + .journey-header { + margin-bottom: 12px; + padding-bottom: 12px; + } + + .journey-time { + font-size: 20px; + } + + .journey-duration { + font-size: 16px; + } + + .journey-leg { + padding: 14px; + } + + .journey-leg-header { + margin-bottom: 10px; + gap: 10px; + } + + .journey-leg-icon { + font-size: 22px; + } + + .journey-leg-line { + font-size: 17px; + } + + .journey-leg-duration { + font-size: 13px; + } + + .journey-leg-stop { + padding: 6px 0; + gap: 12px; + } + + .journey-leg-station { + font-size: 15px; + } + + .journey-leg-platform { + font-size: 13px; + min-width: 70px; + } + + .journey-leg-time { + font-size: 16px; + min-width: 55px; + } + + .journey-transfer { + padding: 12px 14px; + font-size: 15px; + } + + .journey-transfer-time { + font-size: 17px; + } + + .journey-label { + font-size: 16px; + } + + .swap-btn { + margin: 12px 0; + font-size: 20px; + } + + body { + overflow-x: hidden; + } + + /* Text truncation für lange Namen */ + .departure-destination { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + } + + .journey-leg { + padding: 10px; + gap: 10px; + } + + .journey-leg-icon { + font-size: 18px; + } + + .journey-leg-line { + font-size: 13px; + } + + .journey-leg-stations { + font-size: 11px; + } + + .journey-leg-time { + font-size: 10px; + min-width: 50px; + } + + .journey-transfer { + padding: 10px 12px; + font-size: 13px; + } + + .journey-transfer-time { + font-size: 15px; + } + + .journey-label { + font-size: 14px; + } + + .swap-btn { + margin: 10px 0; + font-size: 18px; + } + + body { + overflow-x: hidden; + } + + /* Text truncation für lange Namen */ + .departure-destination { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + } + + /* Extra Small Mobile (SE, kleine Geräte) */ + @media (max-width: 360px) { + .header h1 { + font-size: 20px; + } + + .departure-item { + padding: 10px; + } + + .line-badge { + font-size: 11px; + } + + .departure-destination { + font-size: 13px; + } + + .departure-time { + font-size: 16px; + } + } + + /* Live-Map Styles */ + .livemap-container { + width: 100%; + height: calc(100vh - 140px); + position: relative; + overflow: hidden; + } + + #liveMap { + width: 100%; + height: 100%; + } + + .livemap-controls { + position: absolute; + top: 10px; + left: 10px; + right: 10px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; + } + + .livemap-info { + background-color: rgba(0, 0, 0, 0.85); + padding: 12px 16px; + border-radius: 8px; + border: 2px solid #FFED00; + font-size: 16px; + font-weight: bold; + text-align: center; + color: #FFED00; + } + + .livemap-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; + } + + .filter-btn { + background-color: rgba(0, 0, 0, 0.85); + border: 2px solid #666; + color: #FFED00; + padding: 8px 14px; + border-radius: 6px; + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + font-weight: bold; + } + + .filter-btn.active { + background-color: #FFED00; + color: #000; + border-color: #FFED00; + } + + .filter-btn:active { + transform: scale(0.95); + } + + /* Leaflet Marker Styling */ + .vehicle-marker-container { + display: flex; + flex-direction: column; + align-items: center; + animation: pulse-marker 2s ease-in-out infinite; + cursor: pointer; + } + + .vehicle-emoji { + font-size: 28px; + line-height: 1; + margin-bottom: 3px; + text-shadow: 2px 2px 4px rgba(0,0,0,0.8); + filter: drop-shadow(0 0 3px rgba(255,237,0,0.3)); + } + + .vehicle-line { + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 11px; + font-weight: bold; + padding: 3px 6px; + border-radius: 4px; + border: 2px solid #000; + min-width: 28px; + text-align: center; + box-shadow: 0 2px 6px rgba(0,0,0,0.6); + white-space: nowrap; + line-height: 1; + } + + @keyframes pulse-marker { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } + } + + .vehicle-popup { + font-family: 'DotMatrix', 'Courier New', monospace; + background-color: #000 !important; + color: #fff; + } + + .vehicle-popup .popup-line { + font-size: 16px; + font-weight: bold; + text-align: center; + } + + .vehicle-popup .popup-direction { + font-size: 14px; + margin-bottom: 6px; + color: #fff; + text-align: center; + } + + .vehicle-popup .popup-delay { + font-size: 12px; + color: #ff5555; + font-weight: bold; + text-align: center; + } + + /* Leaflet Popup Dark Mode */ + .leaflet-popup-content-wrapper { + background-color: #000 !important; + border: 2px solid #FFED00 !important; + border-radius: 8px !important; + padding: 12px !important; + } + + .leaflet-popup-tip { + background-color: #000 !important; + } + + .leaflet-popup-content { + margin: 0 !important; + } + + /* Popup über alles andere (Header hat z-index: 10000, Nav: 10003) */ + .leaflet-popup-pane { + z-index: 10010 !important; + } + + .leaflet-popup { + z-index: 10010 !important; + } + + /* Leaflet Zoom Controls nur Desktop */ + @media (max-width: 767px) { + .leaflet-control-zoom { + display: none !important; + } + + .livemap-controls { + top: 5px; + left: 5px; + right: 5px; + } + + .livemap-info { + padding: 10px 12px; + font-size: 14px; + } + + .filter-btn { + padding: 6px 10px; + font-size: 12px; + } + + .vehicle-emoji { + font-size: 24px; + } + + .vehicle-line { + font-size: 10px; + padding: 2px 5px; + min-width: 24px; + } + } + + @media (min-width: 768px) { + .vehicle-emoji { + font-size: 32px; + } + + .vehicle-line { + font-size: 12px; + padding: 4px 8px; + min-width: 32px; + } + } + + /* ========================================== + HOME VIEW STYLING - COMBINED HERO! + ========================================== */ + + .home-container { + max-width: 900px; + margin: 0 auto; + padding: 0; + padding-bottom: 70px; + } + + /* Combined Hero + Navigation Box - ALLES IN EINER BOX! */ + .home-hero-combined { + background: linear-gradient(135deg, #000 0%, #1a1a1a 100%); + border: 3px solid #FFED00; + border-radius: 12px; + padding: 20px; + margin: 20px 20px 20px 20px; + } + + /* Navigation Hint - OBEN in der Box */ + .home-nav-hint { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 2px solid #333; + } + + .home-hint-text { + flex: 1; + color: #fff; + line-height: 1.6; + font-size: 14px; + } + + .home-hint-text strong { + color: #FFED00; + } + + /* Hero Title - UNTEN in der Box */ + .home-hero-title { + text-align: center; + } + + .home-hero-title h1 { + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 32px; + color: #FFED00; + margin: 0 0 8px 0; + letter-spacing: 2px; + } + + .home-hero-title p { + font-size: 15px; + color: #fff; + margin: 0 0 12px 0; + } + + .home-version { + display: inline-block; + background: #FFED00; + color: #000; + padding: 4px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: bold; + } + + /* Quick Info Cards */ + .home-quick-info { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 0 20px 20px 20px; + } + + .home-info-card { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 15px 12px; + text-align: center; + } + + .home-info-icon { + font-size: 32px; + margin-bottom: 8px; + } + + .home-info-text strong { + display: block; + color: #FFED00; + font-size: 13px; + margin-bottom: 4px; + } + + .home-info-text p { + margin: 0; + color: #999; + font-size: 11px; + line-height: 1.3; + } + + /* Two Column Layout */ + .home-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin: 0 20px 20px 20px; + } + + .home-box { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 18px; + } + + .home-box-title { + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 18px; + color: #FFED00; + margin: 0 0 15px 0; + padding-bottom: 8px; + border-bottom: 2px solid #333; + } + + /* Update/Changelog Items */ + .home-update { + margin-bottom: 12px; + padding: 10px; + background: #000; + border-radius: 6px; + border: 1px solid #333; + } + + .home-update-latest { + border: 2px solid #FFED00; + } + + .home-update-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + } + + .home-update-version { + background: #FFED00; + color: #000; + padding: 2px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 11px; + } + + .home-update-date { + color: #888; + font-size: 10px; + } + + .home-update h3 { + font-size: 13px; + color: #FFED00; + margin: 0 0 6px 0; + } + + .home-update ul { + margin: 0; + padding-left: 16px; + color: #ccc; + } + + .home-update li { + margin-bottom: 3px; + font-size: 12px; + line-height: 1.4; + } + + /* Details/Summary (Collapsible) */ + .home-details { + margin-top: 10px; + } + + .home-summary { + cursor: pointer; + padding: 8px; + background: #000; + border: 1px solid #333; + border-radius: 6px; + color: #FFED00; + font-weight: bold; + font-size: 12px; + user-select: none; + text-align: center; + } + + .home-summary:hover { + background: #1a1a1a; + } + + .home-details[open] .home-summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 10px; + } + + /* Credit Items */ + .home-credit { + margin-bottom: 10px; + padding: 8px; + background: #000; + border-radius: 6px; + border: 1px solid #333; + } + + .home-credit:last-child { + margin-bottom: 0; + } + + .home-credit strong { + color: #FFED00; + display: block; + margin-bottom: 3px; + font-size: 12px; + } + + .home-credit p { + margin: 0; + color: #ccc; + font-size: 12px; + line-height: 1.4; + } + + .home-credit a { + color: #FFED00; + text-decoration: none; + } + + .home-credit a:hover { + text-decoration: underline; + } + + /* Features Box */ + .home-features-box { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 18px; + margin: 0 20px 20px 20px; + } + + .home-features-box h3 { + font-family: 'DotMatrix', 'Courier New', monospace; + font-size: 18px; + color: #FFED00; + margin: 0 0 12px 0; + padding-bottom: 8px; + border-bottom: 2px solid #333; + } + + .home-features-box ul { + margin: 0; + padding-left: 20px; + color: #ccc; + } + + .home-features-box li { + margin-bottom: 8px; + font-size: 13px; + line-height: 1.5; + } + + .home-features-box li:last-child { + margin-bottom: 0; + } + + .home-features-box strong { + color: #FFED00; + } + + /* Footer */ + .home-footer { + text-align: center; + padding: 20px; + margin: 0 20px; + color: #888; + border-top: 1px solid #333; + } + + .home-footer p { + margin: 5px 0; + font-size: 13px; + } + + .home-footer strong { + color: #FFED00; + } + + .home-footer-small { + font-size: 11px; + color: #666; + } + + .home-footer-github { + margin-top: 15px !important; + } + + .home-footer-github a { + display: inline-block; + padding: 8px 16px; + background: #1a1a1a; + color: #FFED00; + text-decoration: none; + border-radius: 5px; + border: 1px solid #333; + font-size: 13px; + transition: all 0.2s ease; + } + + .home-footer-github a:hover { + background: #FFED00; + color: #000; + border-color: #FFED00; + } + + /* GitHub Link Button */ + .home-github-link { + text-align: center; + margin: 20px 0; + padding-bottom: 20px; + border-bottom: 2px solid #333; + } + + .home-github-btn { + display: inline-block; + padding: 12px 24px; + background: #1a1a1a; + color: #FFED00; + text-decoration: none; + border-radius: 5px; + border: 2px solid #FFED00; + font-size: 14px; + font-weight: bold; + transition: all 0.2s ease; + } + + .home-github-btn:hover { + background: #FFED00; + color: #000; + transform: translateY(-2px); + } + + /* Mobile Responsive */ + @media (max-width: 767px) { + .home-container { + padding-bottom: 60px; + } + + .home-hero-combined { + padding: 15px; + margin: 15px 15px 15px 15px; + } + + .home-nav-hint { + text-align: center; + margin-bottom: 15px; + padding-bottom: 15px; + } + + .home-hint-text { + font-size: 13px; + } + + .home-hero-title h1 { + font-size: 26px; + } + + .home-hero-title p { + font-size: 13px; + } + + /* Quick Info Cards - Single Row on Mobile */ + .home-quick-info { + grid-template-columns: 1fr; + gap: 10px; + margin: 0 15px 15px 15px; + } + + .home-info-card { + display: flex; + align-items: center; + gap: 12px; + text-align: left; + padding: 12px; + } + + .home-info-icon { + font-size: 28px; + margin-bottom: 0; + } + + .home-info-text strong { + font-size: 14px; + } + + .home-info-text p { + font-size: 12px; + } + + /* Single Column on Mobile */ + .home-columns { + grid-template-columns: 1fr; + gap: 12px; + margin: 0 15px 15px 15px; + } + + .home-box { + padding: 15px; + } + + .home-box-title { + font-size: 16px; + } + + .home-features-box { + padding: 15px; + margin: 0 15px 15px 15px; + } + + .home-features-box h3 { + font-size: 16px; + } + + .home-footer { + margin: 0 15px; + } + + .home-github-btn { + font-size: 13px; + padding: 10px 18px; + } + + .home-footer-github a { + font-size: 12px; + padding: 7px 14px; + } + }