// 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(); }, 30000); 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 { const stations = await transitousLocations(query); displaySuggestions(stations); } catch (error) { console.error('Suchfehler:', error); suggestions.innerHTML = '
⚠️ Suche derzeit nicht möglich – API nicht erreichbar
'; suggestions.classList.add('active'); } }, 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; rememberPosition(latitude, longitude); // für Geocode-Bias merken stationSearch.placeholder = '📍 Suche Stationen in der Nähe...'; // Stationen in der Nähe über Transitous (map/stops) let locations; try { locations = await transitousNearby(latitude, longitude); } 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, nearest.location?.latitude ?? null, nearest.location?.longitude ?? null); // 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; const lat = item.dataset.lat ? parseFloat(item.dataset.lat) : null; const lon = item.dataset.lon ? parseFloat(item.dataset.lon) : null; if (stationId && stationName) { selectStation(stationId, stationName, lat, lon); } }); }); } // "Mehr Abfahrten laden" (Event-Delegation: Button wird bei jedem // Render neu erzeugt, der Handler hier überlebt das) departuresContainer.addEventListener('click', (e) => { if (e.target.closest('#loadMoreDepartures')) { departuresCount = Math.min(60, departuresCount + 20); if (navigator.vibrate) navigator.vibrate(10); loadDepartures(); } }); function selectStation(stationId, stationName, lat = null, lon = null) { departuresCount = 20; // Neue Station -> wieder mit 20 Abfahrten starten currentStationId = stationId; currentStationName = stationName; currentStationLat = lat; currentStationLon = lon; 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, lat, lon }); updateFavoriteButton(); renderFavorites(); // Aktiv-Markierung der Favoriten-Chips auffrischen // 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 { const data = await transitousDepartures({ id: currentStationId, name: currentStationName, lat: currentStationLat, lon: currentStationLon }, departuresCount); displayDepartures(data.departures || []); saveDeparturesCache(currentStationId, data.departures || []); noteRefreshSuccess(); // Start auto-refresh after successful load startAutoRefresh(); } 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…
`; // Auto-Refresh läuft weiter (mit Backoff) } } // Update nur die Werte (Zeiten, Verspätungen) ohne DOM neu zu rendern async function updateDeparturesValues() { if (!currentStationId) return; try { const data = await transitousDepartures({ id: currentStationId, name: currentStationName, lat: currentStationLat, lon: currentStationLon }, departuresCount); const fullDepartures = data.departures || []; allDepartures = fullDepartures; const departures = (typeof applyDepartureFilter === 'function') ? applyDepartureFilter(fullDepartures) : fullDepartures; const now = new Date(); // In-Place-Update: nur Zeiten/Verspätungen im bestehenden DOM // aktualisieren (kein Flackern, Scroll-Position bleibt). // WICHTIG: Vergleich gegen die GEFILTERTE Sicht - vorher hat // ein aktiver Filter den Vergleich immer scheitern lassen und // der Auto-Refresh hat alle 30s die UNGEFILTERTE Liste gerendert. const items = departuresContainer.querySelectorAll('.departure-item'); const domTripIds = Array.from(items).map(i => i.dataset.tripId); const newTripIds = departures.map(d => String(d.tripId)); const sameSet = domTripIds.length === newTripIds.length && domTripIds.every((id, i) => id === newTripIds[i]); if (!sameSet) { displayDepartures(fullDepartures); } else { departures.forEach((dep, i) => { const item = items[i]; if (!item) return; 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 timeEl = item.querySelector('.departure-time'); if (timeEl) timeEl.innerHTML = timeDisplay + delayHTML; }); const lastUpdateElement = departuresContainer.querySelector('.last-update'); if (lastUpdateElement) { lastUpdateElement.textContent = `Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s`; } } saveDeparturesCache(currentStationId, fullDepartures); noteRefreshSuccess(); } catch (error) { console.error('Fehler beim Aktualisieren:', error); noteRefreshFailure(); // Backoff: 30s -> 60s -> 120s -> ... max 5min } } function displayDepartures(fullDepartures) { // Volle Liste für den Abfahrten-Filter merken (Zustand in extras.js) allDepartures = fullDepartures; // Filterleiste nur zeigen, wenn es überhaupt Abfahrten gibt const filtersEl = document.getElementById('departureFilters'); if (filtersEl) { filtersEl.style.display = fullDepartures.length > 0 ? 'block' : 'none'; } if (fullDepartures.length === 0) { departuresContainer.innerHTML = `
🚉
Keine Abfahrten in den nächsten 60 Minuten
`; return; } // Aktiven Verkehrsmittel-Filter anwenden const departures = (typeof applyDepartureFilter === 'function') ? applyDepartureFilter(fullDepartures) : fullDepartures; if (departures.length === 0) { departuresContainer.innerHTML = `
🔍
Keine Abfahrten mit diesem Filter
`; 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(''); // "Mehr laden": erhöht n (max. 60) und lädt neu - so bleibt die // erweiterte Liste auch beim Auto-Refresh erhalten const loadMoreHTML = departuresCount < 60 ? ` ` : ''; departuresContainer.innerHTML = stationInfoHTML + departuresHTML + loadMoreHTML; // 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 transitousTrip(departure.tripId); tripDetails = data.trip; } catch (e) { console.warn('Keine Trip-Details verfügbar'); } } displayDepartureDetails(departure, tripDetails); } catch (error) { console.error('Fehler beim Laden der Details:', error); detailContent.innerHTML = `
⚠️ Fehler beim Laden der Details
${error.message}
`; } } function displayDepartureDetails(departure, tripDetails) { const when = new Date(departure.when); const plannedWhen = departure.plannedWhen ? new Date(departure.plannedWhen) : when; const delay = departure.delay ? Math.round(departure.delay / 60) : 0; const lineColor = getLineColor(departure.line); let html = `
${departure.line.name}
→ ${departure.direction}
${departure.cancelled ? '
FÄLLT AUS
' : ''}
⏱️ Abfahrt
Geplant
${plannedWhen.toLocaleTimeString('de-DE')}
Aktuell
${when.toLocaleTimeString('de-DE')}
${delay > 0 ? `
Verspätung
+${delay} min
` : ''} ${departure.platform ? `
Gleis/Steig
${departure.platform}
` : ''}
🚊 Fahrzeug
${departure.line.product ? `
Verkehrsmittel
${departure.line.product}
` : ''} ${departure.line.operator ? `
Betreiber
${departure.line.operator.name || departure.line.operator.id}
` : ''} ${departure.line.mode ? `
Modus
${departure.line.mode}
` : ''} ${departure.tripId ? `
Fahrt-ID
${departure.tripId}
` : ''}
`; // Halte anzeigen wenn verfügbar if (tripDetails && tripDetails.stopovers && tripDetails.stopovers.length > 0) { const currentStopIndex = tripDetails.stopovers.findIndex(stop => stop.stop.id === currentStationId ); html += `
🚏 Weitere Halte (${tripDetails.stopovers.length})
${tripDetails.stopovers.map((stop, index) => { const stopWhen = stop.departure || stop.arrival; const stopPlannedWhen = stop.plannedDeparture || stop.plannedArrival; const stopTime = stopWhen ? new Date(stopWhen) : null; const stopPlannedTime = stopPlannedWhen ? new Date(stopPlannedWhen) : null; const stopDelay = stop.departureDelay || stop.arrivalDelay || 0; const stopDelayMin = Math.round(stopDelay / 60); const isCurrent = index === currentStopIndex; const isPassed = index < currentStopIndex; return `
${isCurrent ? '📍 ' : ''}${stop.stop.name}
${stopPlannedTime ? stopPlannedTime.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) : '—'} ${stopDelayMin > 0 ? `+${stopDelayMin}'` : ''}
`; }).join('')}
`; } // Hinweise anzeigen if (departure.remarks && departure.remarks.length > 0) { html += `
ℹ️ Hinweise
${departure.remarks.map(remark => `
${remark.type ? `
${remark.type}
` : ''}
${remark.text || remark.summary || 'Keine Details'}
`).join('')}
`; } // Zusätzliche Trip-Details if (tripDetails) { if (tripDetails.cycle) { html += `
🔄 Umlauf
${tripDetails.cycle.min ? `
Min. Takt
${tripDetails.cycle.min} min
` : ''} ${tripDetails.cycle.max ? `
Max. Takt
${tripDetails.cycle.max} min
` : ''}
`; } if (tripDetails.direction) { html += `
Ziel-Richtung
${tripDetails.direction}
`; } } detailContent.innerHTML = html; } function closeDetailModal() { detailModal.classList.remove('active'); detailModal.style.transform = ''; document.body.style.overflow = ''; // Resume auto-refresh if (currentStationId) { startAutoRefresh(); } // Haptic feedback if (navigator.vibrate) { navigator.vibrate(10); } } // Detail Modal schließen detailBackBtn.addEventListener('click', closeDetailModal); // ESC-Taste schließt Modal document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && detailModal.classList.contains('active')) { closeDetailModal(); } if (e.key === 'Escape' && journeyDetailModal.classList.contains('active')) { closeJourneyDetailModal(); } }); // Journey Detail Modal schließen journeyDetailBackBtn.addEventListener('click', closeJourneyDetailModal); function closeJourneyDetailModal() { journeyDetailModal.classList.remove('active'); document.body.style.overflow = ''; } function getLineColor(line) { if (!line) return '#666'; // OFFIZIELLE Farbe aus dem GTFS-Feed (kommt direkt mit der // API-Antwort) - lokale Tabellen sind nur noch Fallback if (line.color) return line.color; if (!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; let journeyViaStation = null; // optionale Zwischenstation const journeyOptions = { wheelchair: false, bike: false }; // Journey Autocomplete let journeyFromTimeout = null; let journeyToTimeout = null; // Journey-Vorschläge rendern (gemeinsam für HAFAS- und Transitous-Pfad) function renderJourneySuggestions(stops, suggestionsContainer, inputField, onSelect) { if (stops.length === 0) return; 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; const lat = item.dataset.lat ? parseFloat(item.dataset.lat) : null; const lon = item.dataset.lon ? parseFloat(item.dataset.lon) : null; onSelect(id, name, lat, lon); inputField.value = name; suggestionsContainer.classList.remove('active'); if (navigator.vibrate) navigator.vibrate(10); }); }); } function setupJourneyAutocomplete(inputField, suggestionsContainer, onSelect) { let timeout = null; inputField.addEventListener('input', (e) => { const query = e.target.value.trim(); clearTimeout(timeout); if (query.length < 2) { suggestionsContainer.classList.remove('active'); suggestionsContainer.innerHTML = ''; return; } timeout = setTimeout(async () => { if (!canMakeApiCall()) { console.warn('Rate limit reached, skipping search'); return; } try { const stops = await transitousLocations(query); renderJourneySuggestions(stops, suggestionsContainer, inputField, onSelect); } catch (error) { console.error('Autocomplete error:', error); suggestionsContainer.innerHTML = '
⚠️ Suche derzeit nicht möglich – API nicht erreichbar
'; suggestionsContainer.classList.add('active'); } }, 400); }); } // Setup Journey From setupJourneyAutocomplete(journeyFrom, suggestionsFrom, (id, name, lat, lon) => { journeyFromStation = { id, name, lat, lon }; checkJourneySearchReady(); }); // Setup Journey To setupJourneyAutocomplete(journeyTo, suggestionsTo, (id, name, lat, lon) => { journeyToStation = { id, name, lat, lon }; checkJourneySearchReady(); }); // Via-Station (optional - beeinflusst den Such-Button nicht) const journeyVia = document.getElementById('journeyVia'); const suggestionsVia = document.getElementById('suggestionsVia'); const clearViaBtn = document.getElementById('clearViaBtn'); if (journeyVia && suggestionsVia) { setupJourneyAutocomplete(journeyVia, suggestionsVia, (id, name, lat, lon) => { journeyViaStation = { id, name, lat, lon }; }); journeyVia.addEventListener('input', () => { // Freitext ohne Auswahl gilt nicht als Via if (journeyVia.value === '') journeyViaStation = null; }); if (clearViaBtn) { clearViaBtn.addEventListener('click', () => { journeyVia.value = ''; journeyViaStation = null; suggestionsVia.classList.remove('active'); }); } } // Options-Chips (Barrierefrei / Fahrrad) togglen document.addEventListener('click', (e) => { const chip = e.target.closest('.journey-option-chip'); if (!chip) return; const opt = chip.dataset.option; journeyOptions[opt] = !journeyOptions[opt]; chip.classList.toggle('active', journeyOptions[opt]); chip.setAttribute('aria-pressed', String(journeyOptions[opt])); if (navigator.vibrate) navigator.vibrate(5); }); // 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; rememberPosition(latitude, longitude); // für Geocode-Bias merken const locations = await transitousNearby(latitude, longitude); 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; } runJourneySearch(); }); // Cursor der letzten Suche (für "Früher"/"Später"-Blättern) let journeyPageCursors = { prev: null, next: null }; // Losgeh-Erinnerung: Notification 10 min vor Abfahrt. // EHRLICHE EINSCHRÄNKUNG: funktioniert nur, solange die App/der Tab // offen ist (echte Push-Benachrichtigungen bräuchten einen Server). const REMINDER_LEAD_MIN = 10; async function scheduleReminder(btn) { const depTime = new Date(btn.dataset.dep); const fireAt = depTime.getTime() - REMINDER_LEAD_MIN * 60000; const delay = fireAt - Date.now(); if (delay <= 0) { btn.textContent = '⏰ zu knapp'; setTimeout(() => { btn.textContent = '🔔'; }, 2500); return; } if (!('Notification' in window)) { btn.textContent = '🚫'; btn.title = 'Benachrichtigungen werden von diesem Browser nicht unterstützt'; return; } let permission = Notification.permission; if (permission === 'default') { permission = await Notification.requestPermission(); } if (permission !== 'granted') { btn.textContent = '🚫'; btn.title = 'Benachrichtigungen sind blockiert (Browser-Einstellungen)'; return; } const label = btn.dataset.label; setTimeout(() => { try { new Notification('🚇 Zeit loszugehen!', { body: `${label} – Abfahrt in ${REMINDER_LEAD_MIN} Minuten`, tag: 'vbb-reminder-' + btn.dataset.dep }); if (navigator.vibrate) navigator.vibrate([100, 50, 100]); } catch (e) { /* Tab evtl. eingefroren */ } }, delay); const fireTime = new Date(fireAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); btn.textContent = `🔔 ${fireTime} ✓`; btn.classList.add('active'); btn.title = `Erinnerung um ${fireTime} gesetzt (solange die App offen bleibt)`; if (navigator.vibrate) navigator.vibrate(10); } async function runJourneySearch(pageCursor = null) { journeyContainer.innerHTML = '
⏳ Suche Verbindungen...
'; try { const tIn = document.getElementById('journeyTimeInput'); const tMode = document.getElementById('journeyTimeMode'); const data = await transitousJourneys(journeyFromStation, journeyToStation, { timeISO: (tIn && tIn.value) ? new Date(tIn.value).toISOString() : null, arriveBy: tMode ? tMode.value === 'arrival' : false, products: activeTransportModes, via: journeyViaStation, wheelchair: journeyOptions.wheelchair, bike: journeyOptions.bike, pageCursor }); journeyPageCursors = { prev: data.previousPageCursor, next: data.nextPageCursor }; // Route für den nächsten App-Start merken storageSet(STORAGE_KEYS.lastJourney, { from: journeyFromStation, to: journeyToStation }); displayJourneys(data.journeys || []); } 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}
`; } } // "Früher"/"Später"-Blättern (Event-Delegation, Buttons werden // bei jedem Render neu erzeugt) journeyContainer.addEventListener('click', (e) => { // "Route merken"-Toggle const saveBtn = e.target.closest('#saveRouteBtn'); if (saveBtn) { const nowFav = toggleFavRoute(journeyFromStation, journeyToStation); saveBtn.textContent = nowFav ? '⭐ Route gemerkt ✓' : '☆ Route merken'; saveBtn.classList.toggle('active', nowFav); saveBtn.setAttribute('aria-pressed', String(nowFav)); if (navigator.vibrate) navigator.vibrate(10); return; } const remBtn = e.target.closest('.reminder-btn'); if (remBtn) { e.stopPropagation(); // Karte darunter nicht öffnen scheduleReminder(remBtn); return; } const pagerBtn = e.target.closest('.journey-pager-btn'); if (pagerBtn && pagerBtn.dataset.cursor) { if (navigator.vibrate) navigator.vibrate(10); runJourneySearch(pagerBtn.dataset.cursor); // Nach oben scrollen, damit man die neuen Ergebnisse sieht journeyContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); function displayJourneys(journeys) { currentJourneys = journeys; // Speichern für später if (!journeys || journeys.length === 0) { journeyContainer.innerHTML = `
🚫
Keine Verbindungen gefunden
`; return; } const favRouteActive = isFavRoute(journeyFromStation, journeyToStation); const saveRouteHTML = ` `; const pagerHTML = (pos) => ` ${pos === 'top' ? saveRouteHTML : ''}
${journeyPageCursors.prev ? ` ` : ''} ${journeyPageCursors.next ? ` ` : ''}
`; 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' })}
${journey.fare ? `🎫 ${journey.fare.amount.toFixed(2).replace('.', ',')} ${journey.fare.currency === 'EUR' ? '€' : escapeHtml(journey.fare.currency)}` : ''} ${durationText}
${legsHtml}
`; }).join(''); journeyContainer.innerHTML = pagerHTML('top') + html + pagerHTML('bottom'); // 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} Zwischenhalte anzeigen
    ${leg.stopovers.map(s => { const t = s.arrival || s.plannedArrival; const time = t ? new Date(t).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; return `
  • ${time} ${escapeHtml(s.stop?.name || '?')}
  • `; }).join('')}
` : ''}
${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'); }