diff --git a/CHANGELOG.md b/CHANGELOG.md index 4221ead..f14bca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,63 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). +## [33.0.0] - 2026-07-03 + +### Favoriten, Persistenz, echte Filter & Modul-Architektur + +#### Added +- **Echte Favoriten**: Stern-Button an jeder Station speichert sie in localStorage; Favoriten erscheinen auf dem Home-Screen und öffnen mit einem Tap die Abfahrten. (Vorher war der Button eine Attrappe mit `alert()`.) +- **Teilen-Button**: Nutzt die native Teilen-Funktion des Geräts (Web Share API), Fallback: in Zwischenablage kopieren. +- **Persistenz über App-Starts**: Letzte Station und letzte Route werden gemerkt und beim Start wiederhergestellt - ohne API-Call; geladen wird erst beim Öffnen der jeweiligen View. +- **Zeitwahl in der Routensuche**: "Abfahrt um" / "Ankunft bis" mit Datum/Uhrzeit (leer = jetzt). +- **Offline-Modus für Abfahrten**: Letzte Daten werden lokal gespeichert; ohne Verbindung werden sie mit Banner "Stand: vor X min" angezeigt. +- **Tastatur-Navigation** in allen Stationsvorschlägen (Pfeiltasten, Enter, Escape). +- **aria-Labels** für alle Icon-Buttons und Eingabefelder, `aria-live` für Statusanzeigen, sichtbarer Tastatur-Fokus. + +#### Fixed +- **Verkehrsmittel-Filter der Routensuche funktionieren jetzt wirklich**: Die Auswahl wurde vorher komplett ignoriert (kein `products`-Parameter in der API-URL). Fähren bleiben bewusst aktiv, da es keinen Button dafür gibt. +- **ReferenceError bei jedem Tastendruck behoben**: Der Search-Hint-Handler referenzierte eine Variable außerhalb ihres Scopes. +- **Fake-Daten entfernt**: Zufällige Gleisnummern, erfundene "GPS-Genauigkeit ±10m" und Alert-Attrappen (Export, Kalender) wurden angezeigt, als wären es echte Informationen. +- **Auto-Refresh bricht bei Fehlern nicht mehr permanent ab**: Exponentieller Backoff (30s → 60s → ... max 5min), automatische Erholung. + +#### Changed +- **Code in 5 Module aufgeteilt** (`js/api.js`, `app.js`, `livemap.js`, `changelog.js`, `extras.js`); geteilter Zustand zentral in api.js deklariert. +- **Live-Map-Marker werden bewegt statt neu erzeugt** (per tripId wiederverwendet): flüssigere Karte, offene Popups bleiben beim Update offen. +- **Radar-Ergebnisanzahl an Zoom gekoppelt** (80/140/200): kleinere API-Responses bei hohem Zoom. +- **Parallax-Effekt mit requestAnimationFrame gedrosselt** (max. 1 Update pro Frame statt pro Mausbewegung). +- **Alle console.log-Ausgaben entfernt** (Fehler/Warnungen bleiben). + +## [32.0.0] - 2026-07-02 + +### Stabilität, Fehlerbehebungen & API-Optimierung + +#### Fixed +- **Kritisch: ReferenceError-Crash behoben**: `const originalSearchJourneys = searchJourneys` referenzierte eine nie existierende Funktion. Der Fehler brach die Ausführung des restlichen Scripts ab (Hover-System, GPS-Handling u.a. luden nie). +- **Kritisch: Service Worker installierte sich nie**: Die Cache-Liste enthielt `/DotMatrix.ttf` (Datei existiert nicht), wodurch `cache.addAll()` komplett fehlschlug. Dateien werden jetzt einzeln gecacht und Fehler toleriert. +- **Doppeltes View-Switch-System entfernt**: Zwei konkurrierende `switchView`-Implementierungen und doppelte Menü-Handler feuerten bei jedem Klick beide. Jetzt gibt es genau ein generisches System, das alle Views (inkl. Developer) kennt. +- **Doppelte `escapeHtml`-Definition entfernt**: Die zweite Version escapte keine Anführungszeichen (Risiko in HTML-Attributen). +- **Changelog wurde bis zu 13× parallel geladen**: Drei redundante Lade-Mechanismen (switchView, MutationObserver, Tab-Handler) konkurrierend. Jetzt zentraler Guard, lädt genau einmal. +- **Fragiler `navigator.geolocation`-Override entfernt**: `Object.defineProperty` auf navigator wirft in manchen Browsern. GPS-Buttons werden auf Desktop weiterhin versteckt. +- **Pull-to-Refresh** löst nur noch in der Abfahrten-View aus. +- **Endlos-Logging entfernt**: Hover-Statistiken wurden alle 30s dauerhaft in die Konsole geschrieben. + +#### Removed +- **DotMatrix-Font komplett entfernt**: Font-Datei existierte nicht mehr im Projekt; alle Referenzen aus CSS (19 Stellen), HTML-Featureliste und Service Worker entfernt. Fallback ist überall 'Courier New', monospace. + +#### Changed +- **Zentrale API-Schicht (`apiFetch`)**: Alle VBB-API-Aufrufe laufen jetzt durch eine Funktion mit: + - **Hartem Rate-Limit von 90 Requests/Minute** (API erlaubt 100 – 10 Puffer) + - **Response-Cache mit TTL pro Endpoint** (Stationssuche 6h, Nearby 2min, Abfahrten 15s, Radar 10s, Trips 60s, Journeys 30s) + - **Request-Deduplizierung**: identische parallele Anfragen werden nur einmal gefeuert + - **Cache-Fallback**: bei erreichtem Limit werden notfalls leicht veraltete Daten geliefert statt Fehler +- **Polling pausiert im Hintergrund**: Abfahrten-Auto-Refresh (30s) und Live-Map-Radar (15s) laufen nur, wenn der Tab sichtbar UND die jeweilige View aktiv ist. Bei Rückkehr in den Tab wird einmalig aufgefrischt. +- **Live-Map-Filter arbeiten lokal**: Filter-Klicks rendern die bereits geladenen Fahrzeugdaten neu, statt einen neuen Radar-Request auszulösen. +- **Radar-Koordinaten werden gerundet** (3 Dezimalstellen) für stabile Cache-Treffer bei minimalen Kartenbewegungen. +- **Service Worker: Network-first für App-Shell**: Nach einem Update bekommen Nutzer sofort neues HTML/JS/CSS; offline greift der Cache. API-Requests werden vom SW bewusst nicht gecacht (macht `apiFetch`). +- **Autocomplete-Debounce** von 300ms auf 400ms erhöht (weniger Requests beim Tippen). +- **XSS-Schutz**: Stationsnamen, Liniennamen und Richtungen aus der API werden vor dem Einfügen ins HTML escaped. +- **Nutzerfreundliche Fehlermeldungen** bei erreichtem API-Limit statt kryptischer Fehler. + ## [31.0.0] - 2026-02-10 ### Developer View & Auto-Changelog System diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a200e4d..e017b15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ Danke, dass du zu VBB Netz Status beitragen möchtest! Diese Anleitung hilft dir ``` 2. **Änderungen vornehmen** - - Editiere `index.html`, `styles.css`, `script.js` + - Editiere `index.html`, `styles.css` oder die Module in `js/` - Teste lokal: `https://localhost:3000` 3. **Regelmäßig committen** @@ -431,7 +431,7 @@ Vor Pull Request: ### APIs - **VBB API**: https://v6.vbb.transport.rest/api.html -- **BVG Farben**: Siehe `getBVGLineColor()` in script.js +- **BVG Farben**: Siehe `getBVGLineColor()` in js/livemap.js ### Libraries - **Leaflet.js**: https://leafletjs.com/ diff --git a/README.md b/README.md index 4ce150d..8fba6b8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ Die App wurde jetzt in separate Dateien aufgeteilt: vbb-netz-status/ ├── index.html # HTML-Struktur ├── styles.css # Alle CSS-Styles -├── script.js # Alle JavaScript-Funktionen +├── js/ +│ ├── api.js # API-Schicht, Rate-Limit, Cache, Favoriten, Persistenz +│ ├── app.js # Views, Abfahrten, Routenplaner, Menü +│ ├── livemap.js # Live-Map (Leaflet, Fahrzeug-Radar) +│ ├── changelog.js # Changelog-Loader + Markdown-Parser +│ └── extras.js # Filter, Tabs, App-Start-Restore, Hover-Extras ├── server.js # HTTPS-Entwicklungsserver ├── service-worker.js # PWA Service Worker ├── manifest.json # PWA Manifest @@ -41,7 +46,7 @@ vbb-netz-status/ **Nachher:** - `index.html` - Nur HTML-Struktur (~380 Zeilen) - `styles.css` - Alle CSS-Regeln (~2300 Zeilen) -- `script.js` - Alle JavaScript-Funktionen (~1990 Zeilen) +- `js/` - JavaScript, aufgeteilt in 5 Module (api, app, livemap, changelog, extras) ## ✅ Vorteile der Trennung diff --git a/index.html b/index.html index e4a7bda..d55cd11 100644 --- a/index.html +++ b/index.html @@ -24,7 +24,7 @@ crossorigin=""> -
+
📵 Offline-Modus - Zeige gespeicherte Daten
@@ -46,10 +46,10 @@
- + @@ -94,10 +94,13 @@

VBB NETZ STATUS

Live-Nahverkehr für Berlin & Brandenburg

-
v31.0
+
v33.0
+ + +
@@ -148,12 +151,13 @@ id="stationSearch" class="search-input" placeholder="Station suchen..." + aria-label="Station suchen" autocomplete="off" > - +
-
@@ -191,18 +195,19 @@ id="journeyFrom" class="search-input" placeholder="Startstation..." + aria-label="Startstation" autocomplete="off" > - +
- - @@ -215,15 +220,32 @@ id="journeyTo" class="search-input" placeholder="Zielstation..." + aria-label="Zielstation" autocomplete="off" > - +
+ +
+ +
+ + + +
+
Leer lassen = jetzt abfahren
+
+
Garantiert mind. 5 Min Umstiegszeit @@ -252,7 +274,7 @@
- 🚇 Lade Fahrzeuge... + 🚇 Lade Fahrzeuge...
@@ -412,7 +434,6 @@
  • Dark Mode (Standard)
  • Responsive Design (Mobile → Desktop)
  • -
  • DotMatrix Retro-Font
  • Touch-optimiert
  • Hover-Effekte (Desktop)
  • Accessibility (WCAG)
  • @@ -448,6 +469,11 @@
- + + + + + + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..4d0e7da --- /dev/null +++ b/js/api.js @@ -0,0 +1,233 @@ +// VBB Netz Status - API-Schicht, Rate-Limiting, Cache, Persistenz, Favoriten + +// ========================================== +// VBB NETZ STATUS - Haupt-Script +// ========================================== + + 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; + + // --- Geteilter App-Zustand (von mehreren Modulen genutzt) --- + // Zentral hier deklariert, damit KEIN Modul auf Variablen aus + // später geladenen Dateien angewiesen ist. + let currentView = 'home'; + let liveMap = null; + let liveMapUpdateInterval = null; + let activeVehicleTypes = new Set(['all']); + let debounceTimer = null; + let lastVehicleMovements = []; // Letzte Radar-Daten für lokales Filtern + const vehicleMarkerMap = new Map(); // tripId -> { marker, lineName } + let activeTransportModes = new Set(['subway', 'suburban', 'tram', 'bus', 'regional', 'express']); + + // ========================================== + // ZENTRALE API-SCHICHT + // - Rate-Limit: max. 90 Requests/Minute (API erlaubt 100, wir lassen Puffer) + // - Response-Cache mit TTL pro Endpoint (spart massiv Requests) + // - Dedupe: identische parallele Requests werden nur 1x gefeuert + // ========================================== + const API_SOFT_LIMIT = 90; // Max. Requests pro Minute (Budget) + const apiCallTimestamps = []; // Zeitstempel echter Requests + const apiCache = new Map(); // url -> { data, time, ttl } + const apiInflight = new Map(); // url -> Promise (laufende Requests) + const API_CACHE_MAX_ENTRIES = 300; // Speicher begrenzen + + function pruneApiTimestamps() { + const cutoff = Date.now() - 60000; + while (apiCallTimestamps.length && apiCallTimestamps[0] <= cutoff) { + apiCallTimestamps.shift(); + } + } + + // Wie viele Requests sind in der aktuellen Minute noch frei? + function apiBudgetLeft() { + pruneApiTimestamps(); + return API_SOFT_LIMIT - apiCallTimestamps.length; + } + + // Nicht-verbrauchende Prüfung (für UI-Vorabchecks) + function canMakeApiCall() { + return apiBudgetLeft() > 0; + } + + /** + * Zentraler API-Aufruf. + * @param {string} url - vollständige URL + * @param {object} opts - { ttl: Cache-Dauer in ms (0 = kein Cache) } + * @returns {Promise} JSON-Daten + * Wirft Error('RATE_LIMIT') wenn Budget aufgebraucht (Cache wird trotzdem genutzt). + */ + async function apiFetch(url, { ttl = 0 } = {}) { + // 1) Frischer Cache-Treffer? -> kein Request nötig + const cached = apiCache.get(url); + if (cached && (Date.now() - cached.time) < cached.ttl) { + return cached.data; + } + + // 2) Läuft derselbe Request bereits? -> Promise wiederverwenden + if (apiInflight.has(url)) { + return apiInflight.get(url); + } + + // 3) Budget prüfen; wenn leer, notfalls abgelaufenen Cache liefern + if (apiBudgetLeft() <= 0) { + if (cached) { + console.warn('Rate-Limit erreicht – liefere veraltete Cache-Daten für', url); + return cached.data; + } + throw new Error('RATE_LIMIT'); + } + + apiCallTimestamps.push(Date.now()); + + const promise = fetch(url) + .then(async (response) => { + if (!response.ok) throw new Error('HTTP ' + response.status); + const data = await response.json(); + if (ttl > 0) { + // Cache-Größe begrenzen (ältesten Eintrag entfernen) + if (apiCache.size >= API_CACHE_MAX_ENTRIES) { + const oldestKey = apiCache.keys().next().value; + apiCache.delete(oldestKey); + } + apiCache.set(url, { data, time: Date.now(), ttl }); + } + return data; + }) + .finally(() => apiInflight.delete(url)); + + apiInflight.set(url, promise); + return promise; + } + + // HTML-Escaping für alle Strings aus der API (XSS-Schutz) + function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // ========================================== + // LOKALE PERSISTENZ (localStorage) + // ========================================== + const STORAGE_KEYS = { + favorites: 'vbb_favorites', + lastStation: 'vbb_last_station', + lastJourney: 'vbb_last_journey', + depCachePrefix: 'vbb_dep_cache_' + }; + + function storageGet(key, fallback = null) { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch (e) { + return fallback; + } + } + + function storageSet(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + // Quota voll oder localStorage blockiert -> App läuft ohne Persistenz weiter + console.warn('localStorage nicht verfügbar:', e.message); + } + } + + // --- Favoriten --- + const MAX_FAVORITES = 10; + + function getFavorites() { + return storageGet(STORAGE_KEYS.favorites, []); + } + + function isFavorite(stationId) { + return getFavorites().some(f => f.id === stationId); + } + + function toggleFavorite(stationId, stationName) { + let favorites = getFavorites(); + if (favorites.some(f => f.id === stationId)) { + favorites = favorites.filter(f => f.id !== stationId); + } else { + favorites.unshift({ id: stationId, name: stationName }); + favorites = favorites.slice(0, MAX_FAVORITES); + } + storageSet(STORAGE_KEYS.favorites, favorites); + renderFavorites(); + updateFavoriteButton(); + return isFavorite(stationId); + } + + // Favoriten auf dem Home-Screen rendern + function renderFavorites() { + const container = document.getElementById('homeFavorites'); + if (!container) return; + const favorites = getFavorites(); + + if (favorites.length === 0) { + container.innerHTML = ''; + container.style.display = 'none'; + return; + } + + container.style.display = ''; + container.innerHTML = ` +
⭐ Deine Stationen
+
+ ${favorites.map(f => ` + + `).join('')} +
+ `; + } + + // Stern-Button in der Abfahrten-View aktualisieren + function updateFavoriteButton() { + const btn = document.getElementById('favoriteBtn'); + if (!btn) return; + if (!currentStationId) { + btn.style.display = 'none'; + return; + } + btn.style.display = ''; + const fav = isFavorite(currentStationId); + btn.textContent = fav ? '⭐' : '☆'; + btn.setAttribute('aria-pressed', String(fav)); + btn.title = fav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'; + } + + // --- Abfahrten-Cache für Offline-Modus --- + function saveDeparturesCache(stationId, departures) { + storageSet(STORAGE_KEYS.depCachePrefix + stationId, { + departures, + time: Date.now() + }); + } + + function loadDeparturesCache(stationId) { + return storageGet(STORAGE_KEYS.depCachePrefix + stationId, null); + } + + function formatAge(timestamp) { + const mins = Math.round((Date.now() - timestamp) / 60000); + if (mins < 1) return 'gerade eben'; + if (mins === 1) return 'vor 1 Minute'; + if (mins < 60) return `vor ${mins} Minuten`; + const hours = Math.round(mins / 60); + return hours === 1 ? 'vor 1 Stunde' : `vor ${hours} Stunden`; + } + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..ba80e4d --- /dev/null +++ b/js/app.js @@ -0,0 +1,1703 @@ +// VBB Netz Status - Views, Abfahrten, Routenplaner, Menü + + // 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') + .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'); + }); + + // Wenn der Tab wieder sichtbar wird: aktuelle View einmalig auffrischen. + // (Die Polling-Intervalle pausieren im Hintergrund und sparen so API-Budget.) + document.addEventListener('visibilitychange', () => { + if (document.hidden) return; + if (currentView === 'departures' && currentStationId && canMakeApiCall()) { + updateDeparturesValues(); + } else if (currentView === 'livemap' && liveMap && canMakeApiCall()) { + loadVehicles(); + } + }); + + // 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 + // (currentView ist zentral in js/api.js deklariert) + + 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', + 'developer': 'VBB Netz Status - Entwickler' + }; + document.title = titles[viewName] || 'VBB Netz Status'; + + // Alle Views generisch deaktivieren, Ziel-View aktivieren + document.querySelectorAll('.view-container').forEach(view => { + view.classList.remove('active'); + }); + const targetView = document.getElementById(`view-${viewName}`); + if (targetView) { + targetView.classList.add('active'); + } else { + console.error('View nicht gefunden:', viewName); + } + + // Stop Live-Map Updates wenn wir View wechseln (spart API-Requests) + if (viewName !== 'livemap' && liveMapUpdateInterval) { + clearInterval(liveMapUpdateInterval); + liveMapUpdateInterval = null; + } + + if (viewName === 'livemap') { + // Initialize Live-Map wenn noch nicht geschehen + setTimeout(() => { + initLiveMap(); + if (liveMap) { + liveMap.invalidateSize(); + // Polling neu starten falls es beim View-Wechsel gestoppt wurde + if (!liveMapUpdateInterval) { + liveMapUpdateInterval = setInterval(() => { + if (document.hidden) return; + if (currentView !== 'livemap') return; + loadVehicles(); + }, 15000); + loadVehicles(); // Sofort frische Daten holen + } + } + }, 100); + } + + // Changelog laden beim ersten Öffnen der Developer-View + if (viewName === 'developer') { + const changelogContent = document.getElementById('changelogContent'); + if (changelogContent && changelogContent.innerHTML.includes('Lade Changelog')) { + loadChangelog(); + } + } + + // Wiederhergestellte Station: Abfahrten erst laden, wenn die View + // tatsächlich geöffnet wird (spart den API-Call beim App-Start) + if (viewName === 'departures' && currentStationId && + departuresContainer.children.length === 0) { + loadDepartures(); + } + + 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 && currentView === 'departures') { + 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 { + // 6h Cache: Stationsnamen ändern sich praktisch nie + const locations = await apiFetch( + `${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`, + { ttl: 6 * 60 * 60 * 1000 } + ); + + const stations = locations.filter(loc => + loc.type === 'stop' || loc.type === 'station' + ); + + displaySuggestions(stations); + } catch (error) { + console.error('Suchfehler:', error); + } + }, 400); + }); + + // 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 + } + } + + // 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`; + + + let locations; + try { + // 2min Cache: Standort ändert sich kurzfristig kaum + locations = await apiFetch(nearbyUrl, { ttl: 2 * 60 * 1000 }); + } catch (e) { + if (e.message === 'RATE_LIMIT') throw e; + console.error('API Response error:', e.message); + throw new Error('API_ERROR'); + } + + // 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 ` +
+
${escapeHtml(station.name)}
+ ${products ? `
${escapeHtml(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; + + // Station für den nächsten App-Start merken + storageSet(STORAGE_KEYS.lastStation, { id: stationId, name: stationName }); + updateFavoriteButton(); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + + loadDepartures(); + } + + // Start/Stop Auto-Refresh + // Pausiert automatisch, wenn der Tab im Hintergrund ist oder + // die Abfahrten-View nicht aktiv ist (spart API-Budget). + // Bei Fehlern: exponentieller Backoff statt permanentem Abbruch. + let refreshFailCount = 0; + let nextRefreshAllowedAt = 0; + + function noteRefreshSuccess() { + refreshFailCount = 0; + nextRefreshAllowedAt = 0; + } + + function noteRefreshFailure() { + refreshFailCount++; + // 30s, 60s, 120s, 240s, max 5min + const backoff = Math.min(300000, 30000 * Math.pow(2, refreshFailCount - 1)); + nextRefreshAllowedAt = Date.now() + backoff; + console.warn(`Auto-Refresh: Fehler #${refreshFailCount}, nächster Versuch in ${Math.round(backoff / 1000)}s`); + } + + function startAutoRefresh() { + stopAutoRefresh(); // Clear any existing interval + autoRefreshInterval = setInterval(() => { + if (document.hidden) return; // Tab im Hintergrund + if (currentView !== 'departures') return; // andere View aktiv + if (Date.now() < nextRefreshAllowedAt) return; // Backoff nach Fehlern + 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 { + // 15s Cache: schützt vor Refresh-Spam (Button-Hämmern, Pull-to-Refresh) + const data = await apiFetch( + `${API_BASE}/stops/${encodeURIComponent(currentStationId)}/departures?duration=60&results=20`, + { ttl: 15000 } + ); + displayDepartures(data.departures || []); + saveDeparturesCache(currentStationId, data.departures || []); + noteRefreshSuccess(); + + // Start auto-refresh after successful load + startAutoRefresh(); + + // Haptic feedback + if (navigator.vibrate) { + navigator.vibrate(10); + } + } catch (error) { + console.error('Fehler beim Laden:', error); + noteRefreshFailure(); + + // Offline-Fallback: letzte bekannte Abfahrten aus localStorage zeigen + const cached = loadDeparturesCache(currentStationId); + if (cached && cached.departures && cached.departures.length > 0) { + displayDepartures(cached.departures); + const banner = document.createElement('div'); + banner.className = 'stale-data-banner'; + banner.setAttribute('role', 'status'); + banner.innerHTML = `📴 Keine Verbindung – zeige Daten von ${formatAge(cached.time)}`; + departuresContainer.prepend(banner); + return; + } + + const msg = error.message === 'RATE_LIMIT' + ? 'API-Limit erreicht – bitte kurz warten' + : escapeHtml(error.message); + departuresContainer.innerHTML = ` +
+ ⚠️ Fehler beim Laden der Abfahrten
+ ${msg}
+ Automatischer Neuversuch läuft… +
+ `; + // WICHTIG: Auto-Refresh läuft weiter (mit Backoff), + // statt wie früher permanent zu stoppen. + } + } + + // Update nur die Werte (Zeiten, Verspätungen) ohne DOM neu zu rendern + async function updateDeparturesValues() { + if (!currentStationId) return; + + try { + // ttl 0: Auto-Refresh soll echte Frischdaten holen + // (Rate-Limit wird trotzdem zentral erzwungen) + const data = await apiFetch( + `${API_BASE}/stops/${encodeURIComponent(currentStationId)}/departures?duration=60&results=20`, + { ttl: 15000 } + ); + 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`; + } + + saveDeparturesCache(currentStationId, departures); + noteRefreshSuccess(); + + } catch (error) { + console.error('Fehler beim Aktualisieren:', error); + noteRefreshFailure(); // Backoff: 30s -> 60s -> 120s -> ... max 5min + } + } + + function displayDepartures(departures) { + if (departures.length === 0) { + departuresContainer.innerHTML = ` +
+
🚉
+
Keine Abfahrten in den nächsten 60 Minuten
+
+ `; + return; + } + + const now = new Date(); + const stationInfoHTML = ` +
+
+
${escapeHtml(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 (60s Cache) + if (departure.tripId) { + try { + const data = await apiFetch( + `${API_BASE}/trips/${encodeURIComponent(departure.tripId)}`, + { ttl: 60000 } + ); + tripDetails = data.trip; + } catch (e) { + } + } + + 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 { + // 6h Cache: gleiche Suchanfragen kosten keinen zweiten Request + const locations = await apiFetch( + `${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`, + { ttl: 6 * 60 * 60 * 1000 } + ); + + const stops = locations.filter(loc => + loc.type === 'stop' || loc.type === 'station' + ); + + if (stops.length > 0) { + suggestionsContainer.innerHTML = stops.map(stop => ` +
+ ${escapeHtml(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); + } + }, 400); + }); + } + + // 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 locations = await apiFetch( + `${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10`, + { ttl: 2 * 60 * 1000 } + ); + 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 { + // Verkehrsmittel-Filter anwenden (vorher totes UI - Auswahl wurde ignoriert!) + // Nur Produkte mit UI-Button steuern; Fähre bleibt API-Default (true), + // sonst würden z.B. Wannsee-Routen unerwartet wegfallen. + const filterableProducts = ['suburban', 'subway', 'tram', 'bus', 'regional', 'express']; + const productParams = filterableProducts + .map(p => `&${p}=${activeTransportModes.has(p) ? 'true' : 'false'}`) + .join(''); + + // Zeitwahl: "Abfahrt um" oder "Ankunft bis" (leer = jetzt) + let timeParam = ''; + const timeModeEl = document.getElementById('journeyTimeMode'); + const timeInputEl = document.getElementById('journeyTimeInput'); + if (timeInputEl && timeInputEl.value) { + const iso = new Date(timeInputEl.value).toISOString(); + const mode = timeModeEl ? timeModeEl.value : 'departure'; + timeParam = `&${mode === 'arrival' ? 'arrival' : 'departure'}=${encodeURIComponent(iso)}`; + } + + const url = `${API_BASE}/journeys?from=${encodeURIComponent(journeyFromStation.id)}&to=${encodeURIComponent(journeyToStation.id)}&results=5&transfers=3&transferTime=5${productParams}${timeParam}`; + + // Route für den nächsten App-Start merken + storageSet(STORAGE_KEYS.lastJourney, { + from: journeyFromStation, + to: journeyToStation + }); + + // 30s Cache: identische Suche direkt hintereinander = 1 Request + const data = await apiFetch(url, { ttl: 30000 }); + displayJourneys(data.journeys || []); + + if (navigator.vibrate) navigator.vibrate(10); + + } catch (error) { + console.error('Journey search error:', error); + const msg = error.message === 'RATE_LIMIT' + ? 'API-Limit erreicht – bitte kurz warten' + : escapeHtml(error.message); + journeyContainer.innerHTML = ` +
+ ⚠️ Fehler bei der Routensuche
+ ${msg} +
+ `; + } + }); + + // 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'); + } + diff --git a/js/changelog.js b/js/changelog.js new file mode 100644 index 0000000..6826c8f --- /dev/null +++ b/js/changelog.js @@ -0,0 +1,241 @@ +// VBB Netz Status - Changelog-Loader + Markdown-Parser + +// ========================================== +// UPDATES VIEW - CHANGELOG LOADER +// ========================================== + + +// ========================================== +// CHANGELOG LOADER - VERBESSERT +// Fetcht CHANGELOG.md und konvertiert Markdown zu HTML +// ========================================== + +let changelogState = 'idle'; // 'idle' | 'loading' | 'loaded' + +async function loadChangelog() { + // Guard: verhindert parallele/mehrfache Ladungen + if (changelogState !== 'idle') return; + changelogState = 'loading'; + + const container = document.getElementById('changelogContent'); + + try { + // Lade CHANGELOG.md + const response = await fetch('CHANGELOG.md'); + if (!response.ok) throw new Error('CHANGELOG.md nicht gefunden'); + + const markdown = await response.text(); + + // Konvertiere Markdown zu HTML + const html = markdownToHtml(markdown); + + // Wrapper mit changelog-container Klasse + container.innerHTML = `
${html}
`; + changelogState = 'loaded'; + + } catch (error) { + console.error('Changelog Fehler:', error); + changelogState = 'idle'; // erneuter Versuch möglich + container.innerHTML = ` +
+
+

⚠️ Changelog konnte nicht geladen werden

+

${escapeHtml(error.message)}

+
+
+ `; + } +} + +// ========================================== +// MARKDOWN TO HTML CONVERTER +// ========================================== + +function markdownToHtml(markdown) { + let html = ''; + const lines = markdown.split('\n'); + let inList = false; + let inOrderedList = false; + let inCodeBlock = false; + let codeBlockContent = ''; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + + // Code-Block Start/Ende (```) + if (line.trim().startsWith('```')) { + if (!inCodeBlock) { + inCodeBlock = true; + codeBlockContent = ''; + continue; + } else { + inCodeBlock = false; + html += `
${escapeHtml(codeBlockContent)}
`; + codeBlockContent = ''; + continue; + } + } + + // Innerhalb Code-Block + if (inCodeBlock) { + codeBlockContent += line + '\n'; + continue; + } + + // Leere Zeile - Schließe Listen + if (line.trim() === '') { + if (inList) { + html += ''; + inList = false; + } + if (inOrderedList) { + html += ''; + inOrderedList = false; + } + continue; + } + + // Überschriften (# ## ### ####) + if (line.match(/^(#{1,6})\s+(.+)$/)) { + const match = line.match(/^(#{1,6})\s+(.+)$/); + const level = match[1].length; + const text = parseInline(match[2]); + + if (inList) { + html += ''; + inList = false; + } + if (inOrderedList) { + html += ''; + inOrderedList = false; + } + + html += `${text}`; + continue; + } + + // Horizontale Linie (--- oder ***) + if (line.match(/^(---+|\*\*\*+)$/)) { + if (inList) { + html += ''; + inList = false; + } + if (inOrderedList) { + html += ''; + inOrderedList = false; + } + html += '
'; + continue; + } + + // Ungeordnete Liste (- oder *) + if (line.match(/^[-*]\s+(.+)$/)) { + const match = line.match(/^[-*]\s+(.+)$/); + const text = parseInline(match[1]); + + if (inOrderedList) { + html += ''; + inOrderedList = false; + } + + if (!inList) { + html += '
    '; + inList = true; + } + + html += `
  • ${text}
  • `; + continue; + } + + // Geordnete Liste (1. 2. 3.) + if (line.match(/^\d+\.\s+(.+)$/)) { + const match = line.match(/^\d+\.\s+(.+)$/); + const text = parseInline(match[1]); + + if (inList) { + html += '
'; + inList = false; + } + + if (!inOrderedList) { + html += '
    '; + inOrderedList = true; + } + + html += `
  1. ${text}
  2. `; + continue; + } + + // Blockquote (>) + if (line.match(/^>\s+(.+)$/)) { + const match = line.match(/^>\s+(.+)$/); + const text = parseInline(match[1]); + + if (inList) { + html += ''; + inList = false; + } + if (inOrderedList) { + html += '
'; + inOrderedList = false; + } + + html += `
${text}
`; + continue; + } + + // Normaler Paragraph + if (line.trim() !== '') { + if (inList) { + html += ''; + inList = false; + } + if (inOrderedList) { + html += ''; + inOrderedList = false; + } + + const text = parseInline(line); + html += `

${text}

`; + } + } + + // Schließe offene Listen am Ende + if (inList) html += ''; + if (inOrderedList) html += ''; + + return html; +} + +// ========================================== +// INLINE MARKDOWN PARSER +// ========================================== + +function parseInline(text) { + // Links [text](url) + text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Inline Code `code` + text = text.replace(/`([^`]+)`/g, '$1'); + + // Bold **text** oder __text__ + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/__([^_]+)__/g, '$1'); + + // Italic *text* oder _text_ (aber nicht ** oder __) + text = text.replace(/(?$1'); + text = text.replace(/(?$1'); + + // Strikethrough ~~text~~ + text = text.replace(/~~([^~]+)~~/g, '$1'); + + return text; +} + +// HINWEIS: Die frühere zweite escapeHtml-Definition wurde entfernt. +// Sie überschrieb die zentrale Version und escapte keine Anführungszeichen +// (gefährlich in HTML-Attributen). Ebenso entfernt: der MutationObserver +// für den Changelog - switchView() und der Dev-Tab-Handler laden ihn bereits, +// der Observer führte zu mehrfachen parallelen Fetches. + + diff --git a/js/extras.js b/js/extras.js new file mode 100644 index 0000000..0541159 --- /dev/null +++ b/js/extras.js @@ -0,0 +1,607 @@ +// VBB Netz Status - Filter, Tabs, App-Start-Restore, Hover-Extras + +// ========================================== +// MOBILE DETECTION - GPS NUR AUF MOBILE +// ========================================== + +function isMobileDevice() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + || window.innerWidth <= 768; +} + +// GPS-Buttons verstecken auf Desktop +document.addEventListener('DOMContentLoaded', () => { + if (!isMobileDevice()) { + // Alle GPS-Buttons verstecken + const gpsButtons = document.querySelectorAll('.location-btn-side, #locationBtn, #locationFromBtn'); + gpsButtons.forEach(btn => { + if (btn) btn.style.display = 'none'; + }); + } +}); + +// ========================================== +// DEVELOPER TABS +// ========================================== + +document.addEventListener('DOMContentLoaded', () => { + const tabs = document.querySelectorAll('.dev-tab'); + const contents = document.querySelectorAll('.dev-tab-content'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const targetTab = tab.dataset.tab; + + // Deaktiviere alle Tabs + tabs.forEach(t => t.classList.remove('active')); + contents.forEach(c => c.classList.remove('active')); + + // Aktiviere gewählten Tab + tab.classList.add('active'); + const targetContent = document.getElementById(`dev-${targetTab}`); + if (targetContent) targetContent.classList.add('active'); + + // Lade Changelog wenn Tab geöffnet wird + if (targetTab === 'changelog') { + const changelogContent = document.getElementById('changelogContent'); + if (changelogContent && changelogContent.innerHTML.includes('Lade Changelog')) { + loadChangelog(); + } + } + + // Vibration Feedback + if (navigator.vibrate) navigator.vibrate(5); + }); + }); +}); + +// ========================================== +// ABFAHRTEN-FILTER +// ========================================== + +let activeDepartureFilters = new Set(['all']); +let allDepartures = []; // Alle Abfahrten speichern + +document.addEventListener('click', (e) => { + if (e.target.closest('.departure-filters .filter-btn')) { + const btn = e.target.closest('.filter-btn'); + const filterType = btn.dataset.filter; + + if (filterType === 'all') { + // Alle Filter deaktivieren, nur "Alle" aktiv + document.querySelectorAll('.departure-filters .filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + activeDepartureFilters.clear(); + activeDepartureFilters.add('all'); + } else { + // "Alle" deaktivieren + document.querySelector('.departure-filters .filter-btn[data-filter="all"]')?.classList.remove('active'); + activeDepartureFilters.delete('all'); + + // Toggle Filter + if (activeDepartureFilters.has(filterType)) { + activeDepartureFilters.delete(filterType); + btn.classList.remove('active'); + } else { + activeDepartureFilters.add(filterType); + btn.classList.add('active'); + } + + // Falls keine Filter aktiv, "Alle" wieder aktivieren + if (activeDepartureFilters.size === 0) { + activeDepartureFilters.add('all'); + document.querySelector('.departure-filters .filter-btn[data-filter="all"]')?.classList.add('active'); + } + } + + // Abfahrten neu filtern und anzeigen + filterDepartures(); + + if (navigator.vibrate) navigator.vibrate(5); + } +}); + +function filterDepartures() { + if (!allDepartures || allDepartures.length === 0) return; + + let filtered = allDepartures; + + if (!activeDepartureFilters.has('all')) { + filtered = allDepartures.filter(dep => { + const product = dep.line?.product?.toLowerCase() || ''; + return activeDepartureFilters.has(product); + }); + } + + // Container neu rendern + const container = document.getElementById('departuresContainer'); + if (filtered.length === 0) { + container.innerHTML = '
Keine Abfahrten mit diesem Filter
'; + } else { + displayDepartures(filtered); + } +} + +// ========================================== +// VERKEHRSMITTEL-FILTER (Routenplaner) +// ========================================== + +// (activeTransportModes ist zentral in js/api.js deklariert) + +document.addEventListener('click', (e) => { + if (e.target.closest('.journey-transport-filters .transport-filter-btn')) { + const btn = e.target.closest('.transport-filter-btn'); + const transport = btn.dataset.transport; + + // Toggle Filter + if (activeTransportModes.has(transport)) { + if (activeTransportModes.size > 1) { // Mindestens 1 muss aktiv bleiben + activeTransportModes.delete(transport); + btn.classList.remove('active'); + } + } else { + activeTransportModes.add(transport); + btn.classList.add('active'); + } + + if (navigator.vibrate) navigator.vibrate(5); + } +}); + +function getActiveTransportModes() { + const mapping = { + 'subway': 'subway', + 'suburban': 'suburban', + 'tram': 'tram', + 'bus': 'bus', + 'regional': 'regional', + 'express': 'express' + }; + + const modes = []; + activeTransportModes.forEach(mode => { + if (mapping[mode]) modes.push(mapping[mode]); + }); + + return modes.length > 0 ? modes : ['subway', 'suburban', 'tram', 'bus', 'regional', 'express']; +} + + +// ========================================== +// PATCHES FÜR FILTER-INTEGRATION +// ========================================== + +// Original loadDepartures überschreiben +const originalLoadDepartures = loadDepartures; +loadDepartures = async function() { + await originalLoadDepartures(); + + // Filter-UI einblenden wenn Abfahrten geladen + const filtersEl = document.getElementById('departureFilters'); + if (filtersEl && allDepartures.length > 0) { + filtersEl.style.display = 'flex'; + } +}; + +// displayDepartures erweitern um allDepartures zu speichern +const originalDisplayDepartures = displayDepartures; +displayDepartures = function(departures) { + // Speichere alle Abfahrten für Filter + if (!activeDepartureFilters || activeDepartureFilters.has('all')) { + allDepartures = departures; + } + + // Normale Anzeige + originalDisplayDepartures(departures); +}; + +// HINWEIS: Der frühere "searchJourneys"-Patch wurde entfernt. +// Die Funktion existierte nie und verursachte einen ReferenceError, +// der das restliche Script abgebrochen hat. +// Der ebenfalls entfernte navigator.geolocation-Override war fragil +// (wirft in manchen Browsern) - GPS-Buttons werden auf Desktop +// bereits per CSS/display:none versteckt, das genügt. + + +// ========================================== +// VERBESSERTE HOVER-INTERAKTIONEN +// ========================================== + +document.addEventListener('DOMContentLoaded', () => { + // Nur auf Desktop (Hover-fähige Geräte) + if (window.matchMedia('(hover: hover)').matches) { + + // Departure Items - Preview bei Hover + document.addEventListener('mouseover', (e) => { + const departureItem = e.target.closest('.departure-item'); + if (departureItem) { + // Smooth Highlight + departureItem.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + } + }); + + // Journey Results - Highlight Route bei Hover + document.addEventListener('mouseover', (e) => { + const journeyResult = e.target.closest('.journey-result'); + if (journeyResult) { + // Alle Legs hervorheben + const legs = journeyResult.querySelectorAll('.journey-leg'); + legs.forEach((leg, index) => { + setTimeout(() => { + leg.style.transform = 'translateX(4px)'; + leg.style.transition = 'transform 0.2s ease'; + }, index * 50); + }); + } + }); + + document.addEventListener('mouseout', (e) => { + const journeyResult = e.target.closest('.journey-result'); + if (journeyResult) { + const legs = journeyResult.querySelectorAll('.journey-leg'); + legs.forEach(leg => { + leg.style.transform = 'translateX(0)'; + }); + } + }); + + // Filter Buttons - Visual Feedback + document.addEventListener('mouseover', (e) => { + const filterBtn = e.target.closest('.filter-btn, .transport-filter-btn'); + if (filterBtn && !filterBtn.classList.contains('active')) { + // Ripple-Effekt simulieren + filterBtn.style.background = 'radial-gradient(circle at center, rgba(255, 237, 0, 0.2), transparent)'; + } + }); + + document.addEventListener('mouseout', (e) => { + const filterBtn = e.target.closest('.filter-btn, .transport-filter-btn'); + if (filterBtn && !filterBtn.classList.contains('active')) { + filterBtn.style.background = ''; + } + }); + + // Navigation Items - Smooth Slide + const navItems = document.querySelectorAll('.nav-item'); + navItems.forEach(item => { + item.addEventListener('mouseenter', function() { + this.style.paddingLeft = '25px'; + }); + item.addEventListener('mouseleave', function() { + this.style.paddingLeft = ''; + }); + }); + + // Tabs - Preview Content + const devTabs = document.querySelectorAll('.dev-tab'); + devTabs.forEach(tab => { + tab.addEventListener('mouseenter', function() { + if (!this.classList.contains('active')) { + this.style.borderTopWidth = '3px'; + } + }); + tab.addEventListener('mouseleave', function() { + if (!this.classList.contains('active')) { + this.style.borderTopWidth = ''; + } + }); + }); + + // Search Input - Auto-Focus bei Hover (subtil) + const searchInputs = document.querySelectorAll('.search-input'); + searchInputs.forEach(input => { + input.addEventListener('mouseenter', function() { + this.style.borderWidth = '2px'; + }); + input.addEventListener('mouseleave', function() { + if (document.activeElement !== this) { + this.style.borderWidth = ''; + } + }); + }); + + // Suggestion Items - Smooth Selection + document.addEventListener('mouseover', (e) => { + const suggestionItem = e.target.closest('.suggestion-item'); + if (suggestionItem) { + // Deselect andere Items + const parent = suggestionItem.parentElement; + parent.querySelectorAll('.suggestion-item').forEach(item => { + if (item !== suggestionItem) { + item.style.opacity = '0.6'; + } + }); + suggestionItem.style.opacity = '1'; + } + }); + + document.addEventListener('mouseout', (e) => { + const suggestionsContainer = e.target.closest('.suggestions'); + if (!suggestionsContainer || !suggestionsContainer.contains(e.relatedTarget)) { + document.querySelectorAll('.suggestion-item').forEach(item => { + item.style.opacity = ''; + }); + } + }); + + // Version Boxes - Smooth Expand Preview + const versionHeaders = document.querySelectorAll('.version-header'); + versionHeaders.forEach(header => { + header.addEventListener('mouseenter', function() { + const versionBox = this.parentElement; + if (!versionBox.classList.contains('open')) { + const content = versionBox.querySelector('.version-content'); + // Mini-Preview zeigen + content.style.maxHeight = '60px'; + content.style.overflow = 'hidden'; + setTimeout(() => { + if (!versionBox.classList.contains('open')) { + content.style.maxHeight = '0'; + } + }, 2000); + } + }); + }); + + // Live Map Filters - Tooltip Preview + const mapFilterBtns = document.querySelectorAll('.livemap-filters .filter-btn'); + mapFilterBtns.forEach(btn => { + btn.addEventListener('mouseenter', function() { + const type = this.dataset.type; + const count = document.querySelectorAll(`.vehicle-marker[data-type="${type}"]`).length; + // Zeige Count in Button + if (count > 0 && !this.querySelector('.filter-count')) { + const badge = document.createElement('span'); + badge.className = 'filter-count'; + badge.textContent = count; + badge.style.cssText = 'position: absolute; top: -5px; right: -5px; background: #ff0000; color: #fff; border-radius: 10px; padding: 2px 6px; font-size: 10px; font-weight: bold;'; + this.style.position = 'relative'; + this.appendChild(badge); + } + }); + btn.addEventListener('mouseleave', function() { + const badge = this.querySelector('.filter-count'); + if (badge) badge.remove(); + }); + }); + + // Smooth Page Load + document.body.style.opacity = '0'; + setTimeout(() => { + document.body.style.transition = 'opacity 0.5s ease'; + document.body.style.opacity = '1'; + }, 100); + + } +}); + +// Parallax-Effekt für Header +document.addEventListener('DOMContentLoaded', () => { + if (window.matchMedia('(hover: hover)').matches) { + const header = document.querySelector('.header'); + if (header) { + // Mit requestAnimationFrame gedrosselt: max. 1 Update pro Frame + // statt bei jeder einzelnen Mausbewegung (weniger Layout-Arbeit) + header.style.transition = 'transform 0.3s ease-out'; // einmalig statt pro Event + let parallaxPending = false; + let lastX = 0, lastY = 0; + document.addEventListener('mousemove', (e) => { + lastX = e.clientX; + lastY = e.clientY; + if (parallaxPending) return; + parallaxPending = true; + requestAnimationFrame(() => { + const x = (lastX / window.innerWidth - 0.5) * 10; + const y = (lastY / window.innerHeight - 0.5) * 10; + header.style.transform = `translate(${x}px, ${y}px)`; + parallaxPending = false; + }); + }, { passive: true }); + } + } +}); + +// Smooth Scroll bei Navigation +document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }); +}); + + +// ========================================== +// APP-START: GESPEICHERTEN ZUSTAND WIEDERHERSTELLEN +// ========================================== +document.addEventListener('DOMContentLoaded', () => { + // Favoriten auf dem Home-Screen anzeigen + renderFavorites(); + + // Letzte Station wiederherstellen (OHNE sofortigen API-Call - + // geladen wird erst, wenn die Abfahrten-View geöffnet wird) + const lastStation = storageGet(STORAGE_KEYS.lastStation); + if (lastStation && lastStation.id) { + currentStationId = lastStation.id; + currentStationName = lastStation.name; + stationSearch.value = lastStation.name; + refreshBtn.disabled = false; + updateFavoriteButton(); + } + + // Letzte Route wiederherstellen + const lastJourney = storageGet(STORAGE_KEYS.lastJourney); + if (lastJourney && lastJourney.from && lastJourney.to) { + journeyFromStation = lastJourney.from; + journeyToStation = lastJourney.to; + journeyFrom.value = lastJourney.from.name; + journeyTo.value = lastJourney.to.name; + checkJourneySearchReady(); + } + + // Klick auf Favorit (Home-Screen) -> direkt zu den Abfahrten + document.addEventListener('click', (e) => { + const favItem = e.target.closest('.home-favorite-item'); + if (favItem) { + switchView('departures'); + selectStation(favItem.dataset.id, favItem.dataset.name); + } + }); + + // Mindestzeit für die Zeitwahl: jetzt (keine Suche in der Vergangenheit) + const timeInput = document.getElementById('journeyTimeInput'); + if (timeInput) { + const pad = n => String(n).padStart(2, '0'); + const d = new Date(); + timeInput.min = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + // Zeitwahl zurücksetzen (= jetzt abfahren) + const timeClear = document.getElementById('journeyTimeClear'); + if (timeClear && timeInput) { + timeClear.addEventListener('click', () => { + timeInput.value = ''; + if (navigator.vibrate) navigator.vibrate(5); + }); + } +}); + +// ========================================== +// TASTATUR-NAVIGATION FÜR STATIONSVORSCHLÄGE (Barrierefreiheit) +// Pfeil hoch/runter wählt, Enter bestätigt, Escape schließt +// ========================================== +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.search-container').forEach(container => { + const input = container.querySelector('.search-input'); + const list = container.querySelector('.suggestions'); + if (!input || !list) return; + + input.addEventListener('keydown', (e) => { + const items = Array.from(list.querySelectorAll('.suggestion-item')); + if (items.length === 0) return; + + const activeIdx = items.findIndex(i => i.classList.contains('kb-active')); + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const dir = e.key === 'ArrowDown' ? 1 : -1; + const next = (activeIdx + dir + items.length) % items.length; + items.forEach(i => i.classList.remove('kb-active')); + items[next].classList.add('kb-active'); + items[next].scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter' && activeIdx >= 0) { + e.preventDefault(); + items[activeIdx].click(); + } else if (e.key === 'Escape') { + list.classList.remove('active'); + } + }); + }); +}); + +// ========================================== +// HOVER-EXTRAS (bereinigt) +// Frühere Version zeigte FAKE-Daten (zufällige Gleisnummern, +// erfundene "Genauigkeit ±10m", Alert-Attrappen für Favorit/Teilen/ +// Export/Kalender) und hatte einen Scope-Bug im Search-Hint, +// der bei jedem Tastendruck einen ReferenceError warf. +// ========================================== + +document.addEventListener('DOMContentLoaded', () => { + if (window.matchMedia('(hover: hover)').matches) { + + // Navigation Items - Quick Info hinzufügen + const navQuickInfos = [ + { selector: '[data-view="home"]', info: 'Startseite' }, + { selector: '[data-view="departures"]', info: 'Live-Abfahrten' }, + { selector: '[data-view="journey"]', info: 'Routenplaner' }, + { selector: '[data-view="livemap"]', info: 'Fahrzeuge live' }, + { selector: '[data-view="developer"]', info: 'Changelog & Infos' } + ]; + + navQuickInfos.forEach(item => { + const el = document.querySelector(item.selector); + if (el && !el.querySelector('.nav-quick-info')) { + const info = document.createElement('div'); + info.className = 'nav-quick-info'; + info.textContent = item.info; + el.appendChild(info); + } + }); + + // Search Hints (Scope-Bug behoben: hint jetzt im richtigen Scope) + document.querySelectorAll('.search-input').forEach(input => { + let hint = input.parentElement.querySelector('.search-hint'); + if (!hint) { + hint = document.createElement('div'); + hint.className = 'search-hint'; + hint.textContent = 'Tippe mindestens 2 Zeichen'; + input.parentElement.appendChild(hint); + } + + input.addEventListener('input', () => { + const len = input.value.length; + if (len === 0) { + hint.textContent = 'Tippe mindestens 2 Zeichen'; + } else if (len === 1) { + hint.textContent = 'Noch 1 Zeichen...'; + } else { + hint.textContent = 'Suche aktiv'; + } + }); + }); + + // Rich Tooltips (nur belegbare Aussagen) + const tooltips = { + '#refreshBtn': 'Lädt die neuesten Abfahrtszeiten. Auto-Refresh alle 30 Sekunden.', + '#searchJourneyBtn': 'Sucht Routen zwischen zwei Stationen mit Echtzeit-Daten.', + '#locationBtn': 'Findet die nächstgelegene Station per GPS.', + '#swapBtn': 'Vertauscht Start- und Zielstation.' + }; + + Object.entries(tooltips).forEach(([selector, tooltip]) => { + const el = document.querySelector(selector); + if (el) { + el.setAttribute('data-tooltip', tooltip); + el.style.position = 'relative'; + } + }); + } +}); + +// Quick Actions an Abfahrten: ECHTE Funktionen statt Alert-Attrappen +// (Event-Delegation statt MutationObserver - robuster und günstiger) +document.addEventListener('click', async (e) => { + const favBtn = e.target.closest('.qa-favorite'); + if (favBtn && currentStationId) { + toggleFavorite(currentStationId, currentStationName); + favBtn.textContent = isFavorite(currentStationId) ? '⭐ Favorit ✓' : '☆ Favorit'; + if (navigator.vibrate) navigator.vibrate(10); + return; + } + + const shareBtn = e.target.closest('.qa-share'); + if (shareBtn && currentStationName) { + const shareData = { + title: 'VBB Netz Status', + text: `Abfahrten für ${currentStationName}`, + url: location.href + }; + try { + if (navigator.share) { + await navigator.share(shareData); + } else { + await navigator.clipboard.writeText(`${shareData.text} - ${shareData.url}`); + shareBtn.textContent = '✓ Kopiert'; + setTimeout(() => { shareBtn.textContent = '🔗 Teilen'; }, 2000); + } + } catch (err) { + // Nutzer hat Share-Dialog abgebrochen - kein Fehler + } + } +}); diff --git a/js/livemap.js b/js/livemap.js new file mode 100644 index 0000000..b5bab55 --- /dev/null +++ b/js/livemap.js @@ -0,0 +1,388 @@ +// VBB Netz Status - Live-Map (Leaflet, Fahrzeug-Radar) + + // ========================================== + // LIVE-MAP FUNCTIONALITY + // ========================================== + + // (Geteilter Live-Map-Zustand ist zentral in js/api.js deklariert) + + // 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 + // Pausiert automatisch, wenn Tab im Hintergrund oder andere View aktiv + liveMapUpdateInterval = setInterval(() => { + if (document.hidden) return; + if (currentView !== 'livemap') return; + 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(() => { + loadVehicles(); + }, 1000); // 1 Sekunde warten nach Bewegung + }); + + } + + // Fahrzeuge von API laden + async function loadVehicles() { + // Sicherheitsabfrage: Map muss existieren + if (!liveMap) { + 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 + }; + + + // Ergebnisanzahl an Zoom koppeln: nah dran reichen wenige Fahrzeuge, + // weit rausgezoomt begrenzen wir auf 200 (kleinere API-Responses) + const zoom = liveMap.getZoom ? liveMap.getZoom() : 12; + const maxResults = zoom >= 15 ? 80 : zoom >= 13 ? 140 : 200; + + // Koordinaten runden -> stabile Cache-Keys bei minimalen Kartenbewegungen + const url = `${API_BASE}/radar?` + + `north=${bounds.north.toFixed(3)}&south=${bounds.south.toFixed(3)}&` + + `west=${bounds.west.toFixed(3)}&east=${bounds.east.toFixed(3)}&` + + `results=${maxResults}&duration=60&` + + `subway=true&bus=true&tram=true&suburban=true®ional=true`; + + // 10s Cache: schnelles Hin- und Herzoomen kostet keine Extra-Requests + const data = await apiFetch(url, { ttl: 10000 }); + lastVehicleMovements = data.movements || []; + displayVehicles(lastVehicleMovements); + + } catch (error) { + console.error('Load vehicles error:', error); + const counterEl = document.getElementById('vehicleCount'); + if (counterEl) { + counterEl.textContent = error.message === 'RATE_LIMIT' + ? '⏳ API-Limit erreicht – kurz warten' + : '⚠️ Fehler beim Laden der Fahrzeuge'; + } + } + } + + // Fahrzeuge auf Map anzeigen + // Marker werden per tripId wiederverwendet und nur BEWEGT (setLatLng), + // statt bei jedem Update alle zu zerstören und neu zu erzeugen. + // -> flüssigere Karte, offene Popups bleiben offen, weniger DOM-Arbeit. + // (vehicleMarkerMap ist zentral in js/api.js deklariert) + + function displayVehicles(movements) { + // 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); + }); + + // Update Counter + const countEl = document.getElementById('vehicleCount'); + if (countEl) { + countEl.innerHTML = + `🚇 ${filteredMovements.length} Fahrzeuge aktiv`; + } + + const seenTripIds = new Set(); + + 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 tripId = movement.tripId || `${lineName}|${movement.direction || ''}`; + seenTripIds.add(tripId); + + const direction = movement.direction || 'Keine Angabe'; + const delay = movement.delay ? `+${Math.round(movement.delay / 60)} min Verspätung` : 'Pünktlich'; + const lineColor = getBVGLineColor(lineName); + const isLightBg = lineColor === '#F0D722' || lineColor === '#FFFFFF' || lineColor === '#55A823'; + const textColor = isLightBg ? '#000' : '#FFF'; + const emoji = getVehicleEmoji(movement.line?.product, lineName); + + const popupContent = ` +
+ + + +
+ `; + + const existing = vehicleMarkerMap.get(tripId); + if (existing) { + // Marker existiert bereits -> nur bewegen und Popup-Inhalt auffrischen + existing.marker.setLatLng([lat, lon]); + existing.marker.setPopupContent(popupContent); + return; + } + + // Neues Fahrzeug -> Marker erzeugen + const icon = L.divIcon({ + html: ` +
+
${emoji}
+
${escapeHtml(lineName)}
+
+ `, + className: 'custom-marker', + iconSize: [50, 60], + iconAnchor: [25, 30] + }); + + const marker = L.marker([lat, lon], { icon: icon }); + marker.bindPopup(popupContent); + marker.addTo(liveMap); + vehicleMarkerMap.set(tripId, { marker, lineName }); + }); + + // Fahrzeuge entfernen, die nicht mehr in den Daten sind + // (außer Sichtbereich, Fahrt beendet, oder vom Filter ausgeblendet) + for (const [tripId, entry] of vehicleMarkerMap) { + if (!seenTripIds.has(tripId)) { + entry.marker.remove(); + vehicleMarkerMap.delete(tripId); + } + } + } + + // 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; + + + 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'); + } else { + activeVehicleTypes.add(type); + btn.classList.add('active'); + } + + // 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'); + } + } + + + // Lokal filtern - Daten sind bereits da, KEIN neuer API-Call nötig! + if (lastVehicleMovements.length > 0) { + displayVehicles(lastVehicleMovements); + } else { + loadVehicles(); + } + + if (navigator.vibrate) navigator.vibrate(5); + } + }); + + diff --git a/package.json b/package.json index 5453bcf..2aa9774 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": "33.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/service-worker.js b/service-worker.js index 4310be2..728a705 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,69 +1,90 @@ -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-v33'; + +// App-Shell: nur Dateien, die wirklich existieren! +// (Der alte SW listete eine nicht existierende Font-Datei – dadurch +// schlug cache.addAll() komplett fehl und der SW wurde NIE installiert.) +const urlsToCache = [ + '/', + '/index.html', + '/js/api.js', + '/js/app.js', + '/js/livemap.js', + '/js/changelog.js', + '/js/extras.js', + '/styles.css', + '/manifest.json', + '/images/favicon.png' +]; + +// Installation: Dateien einzeln cachen, damit EIN Fehler nicht alles blockiert +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => + Promise.allSettled( + urlsToCache.map(url => + cache.add(url).catch(err => console.warn('SW: Konnte nicht cachen:', url, err)) + ) + ) + ) + ); + self.skipWaiting(); +}); + +// Aktivierung: alte Caches aufräumen +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => + Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME) { + console.log('SW: Alter Cache wird gelöscht:', cacheName); + return caches.delete(cacheName); + } + }) + ) + ) + ); + self.clients.claim(); +}); + +// Fetch-Strategie: +// - API-Requests (cross-origin, z.B. v6.vbb.transport.rest, Karten-Tiles): +// NIE vom SW cachen -> direkt durchreichen. Das Rate-Limit- und +// Caching-Management macht die App selbst (apiFetch in js/api.js). +// - App-Shell (HTML/JS/CSS): Network-first mit Cache-Fallback. +// So bekommen Nutzer nach einem Deploy sofort die neue Version, +// und offline funktioniert die App trotzdem weiter. +self.addEventListener('fetch', event => { + const request = event.request; + + // Nur GET-Requests behandeln + if (request.method !== 'GET') return; + + const url = new URL(request.url); + + // Cross-Origin (API, Leaflet-CDN, Tiles): nicht anfassen + if (url.origin !== self.location.origin) return; + + event.respondWith( + fetch(request) + .then(response => { + // Gültige Antwort -> Cache aktualisieren und zurückgeben + if (response && response.status === 200 && response.type === 'basic') { + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(request, responseToCache)); + } + return response; + }) + .catch(() => + // Offline -> aus dem Cache bedienen + caches.match(request).then(cached => { + if (cached) return cached; + // Navigations-Fallback auf index.html + if (request.mode === 'navigate') { + return caches.match('/index.html'); + } + return new Response('Offline', { status: 503, statusText: 'Offline' }); + }) + ) + ); +}); diff --git a/styles.css b/styles.css index 83cb7cd..51e54b1 100644 --- a/styles.css +++ b/styles.css @@ -1,9 +1,3 @@ -/* DotMatrix Font by NikBLN - https://github.com/NikBLN/weilSieDichLieben */ - /* @font-face { - font-family: 'DotMatrix'; - src: url('./DotMatrix.ttf') format('truetype'); - } */ - * { margin: 0; padding: 0; @@ -11,7 +5,7 @@ } body { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; background-color: #000; color: #FFED00; min-height: 100vh; @@ -165,7 +159,7 @@ display: flex; align-items: center; gap: 15px; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 16px; transition: background-color 0.2s; border-left: 4px solid transparent; @@ -217,7 +211,7 @@ .search-input { width: 100%; padding: 12px 45px 12px 12px; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 16px; background-color: #000 !important; color: #FFED00; @@ -328,7 +322,7 @@ .btn { flex: 1; padding: 12px; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 14px; background-color: #FFED00; color: #000; @@ -727,7 +721,7 @@ font-size: 22px; cursor: pointer; padding: 6px 12px; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; border-radius: 6px; transition: background-color 0.15s; } @@ -1662,7 +1656,7 @@ color: #FFED00; padding: 8px 14px; border-radius: 6px; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 14px; cursor: pointer; transition: all 0.2s; @@ -1697,7 +1691,7 @@ } .vehicle-line { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold; padding: 3px 6px; @@ -1716,7 +1710,7 @@ } .vehicle-popup { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; background-color: #000 !important; color: #fff; } @@ -1855,7 +1849,7 @@ } .home-hero-title h1 { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 32px; color: #FFED00; margin: 0 0 8px 0; @@ -1929,7 +1923,7 @@ } .home-box-title { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 18px; color: #FFED00; margin: 0 0 15px 0; @@ -2063,7 +2057,7 @@ } .home-features-box h3 { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 18px; color: #FFED00; margin: 0 0 12px 0; @@ -2331,7 +2325,7 @@ font-size: 32px; color: #FFED00; margin: 0 0 10px 0; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; letter-spacing: 2px; } @@ -2384,7 +2378,7 @@ color: #FFED00; padding: 14px 24px; border-radius: 10px 10px 0 0; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; font-size: 14px; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); @@ -2654,7 +2648,7 @@ margin: 0 0 15px 0; padding-bottom: 12px; border-bottom: 2px solid #333; - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; } .dev-info-table { @@ -2714,7 +2708,7 @@ font-size: 16px; font-weight: bold; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); position: relative; overflow: hidden; @@ -3124,7 +3118,7 @@ /* Changelog Container für Markdown Content */ .changelog-container { - font-family: 'DotMatrix', 'Courier New', monospace; + font-family: 'Courier New', monospace; line-height: 1.6; color: #ccc; padding: 0; @@ -3668,4 +3662,143 @@ overflow-y: auto; -webkit-overflow-scrolling: touch; } -} \ No newline at end of file +} +/* ========================================== + v33: Favoriten, Zeitwahl, Offline-Banner, Tastatur-Navigation + ========================================== */ + +/* Favoriten auf dem Home-Screen */ +.home-favorites { + background: rgba(255, 237, 0, 0.06); + border: 1px solid rgba(255, 237, 0, 0.25); + border-radius: 16px; + padding: 16px; + margin-bottom: 20px; +} + +.home-favorites-title { + font-size: 14px; + font-weight: bold; + color: #FFED00; + margin-bottom: 12px; +} + +.home-favorites-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.home-favorite-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + color: #fff; + font-size: 14px; + text-align: left; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.home-favorite-item:hover, +.home-favorite-item:focus-visible { + background: rgba(255, 237, 0, 0.12); + border-color: rgba(255, 237, 0, 0.5); + outline: none; +} + +.home-favorite-arrow { + color: #FFED00; + font-weight: bold; +} + +/* Station-Header mit Aktions-Buttons */ +.station-info-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.station-actions { + display: flex; + gap: 8px; +} + +/* Offline-Banner ("Stand: vor X min") */ +.stale-data-banner { + background: rgba(255, 165, 0, 0.15); + border: 1px solid rgba(255, 165, 0, 0.5); + color: #FFC966; + border-radius: 10px; + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; + text-align: center; +} + +/* Zeitwahl in der Routensuche */ +.journey-time-row { + margin: 12px 0; +} + +.journey-time-controls { + display: flex; + gap: 8px; + align-items: center; + margin-top: 6px; +} + +.journey-time-select, +.journey-time-input { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: #fff; + padding: 10px; + font-size: 14px; + font-family: inherit; +} + +.journey-time-select option { + background: #1a1a1a; + color: #fff; +} + +.journey-time-input { + flex: 1; + min-width: 0; + color-scheme: dark; +} + +.journey-time-clear { + position: static; + flex-shrink: 0; +} + +.journey-time-hint { + font-size: 11px; + opacity: 0.6; + margin-top: 4px; +} + +/* Tastatur-Navigation in Vorschlägen */ +.suggestion-item.kb-active { + background: rgba(255, 237, 0, 0.2); + outline: 2px solid rgba(255, 237, 0, 0.6); + outline-offset: -2px; +} + +/* Sichtbarer Fokus für Tastaturnutzer */ +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid #FFED00; + outline-offset: 2px; +}