// Modal, das beim Klick auf eine Abfahrtszeile oder einen Verbindungsabschnitt // geöffnet wird: zeigt Linie, Richtung, eine kleine Karte mit dem Streckenverlauf // und alle Zwischenhalte mit Zeiten/Verspätungen. Über die Glocken-Buttons an // den kommenden Halten kann hier außerdem ein Fahrtalarm gesetzt werden // (siehe tripalarm.js). const TripModal = (() => { const ALARM_OFFSET_KEY = 'oepnv-navi:db-alarm-offset'; const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; let backdrop = null; let miniMap = null; let lastFocusedElement = null; // Zustand der aktuell geöffneten Fahrt, damit wir nach dem Setzen/Entfernen // eines Alarms den Haltestellen-Teil neu rendern können, ohne die Fahrt // erneut vom Server zu laden. let currentTripId = null; let currentTrip = null; let currentIndex = -1; function ensureBackdrop() { if (backdrop) return backdrop; backdrop = document.createElement('div'); backdrop.className = 'trip-modal-backdrop'; backdrop.hidden = true; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); backdrop.querySelector('.trip-modal__close').addEventListener('click', close); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); document.addEventListener('keydown', (e) => { if (backdrop.hidden) return; if (e.key === 'Escape') { close(); return; } // Fokus-Falle: Tab darf das Dialog nicht verlassen, solange es offen // ist - sonst würde ein Tastatur-/Screenreader-Nutzer "hinter" den // Dialog auf die (für ihn unsichtbar gewordene) Seite dahinter tabben. if (e.key === 'Tab') { const focusable = Array.from(backdrop.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }); // Delegierter Klick-Handler für die Alarm-Glocken an den Haltestellen - // einmalig registriert, damit er bei jedem open() weiter funktioniert, // ohne mehrfach angehängt zu werden. backdrop.querySelector('#trip-modal-body').addEventListener('click', (e) => { const btn = e.target.closest('[data-alarm-index]'); if (!btn || !currentTrip) return; const index = parseInt(btn.dataset.alarmIndex, 10); toggleAlarm(index); }); return backdrop; } function close() { if (!backdrop) return; backdrop.hidden = true; if (miniMap) { miniMap.remove(); miniMap = null; } // Fokus zurück zur auslösenden Abfahrtszeile/dem Link geben, statt ihn // einfach verloren gehen zu lassen (würde sonst auf zurückfallen - // Tastatur-/Screenreader-Nutzer wüssten dann nicht mehr, wo sie sind). if (lastFocusedElement && document.body.contains(lastFocusedElement)) { lastFocusedElement.focus(); } lastFocusedElement = null; } function formatTime(iso) { if (!iso) return null; return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[char])); } // Verspätungsstatistik pro Linie - Datenbasis wächst erst über Zeit, auf // einem frisch aufgesetzten Server gibt es also erstmal nichts anzuzeigen. async function loadLineStats(line) { const el = document.getElementById('trip-modal-stats'); if (!el) return; el.hidden = true; if (!line) return; try { const stats = await API.getLineDelayStats(line); if (!stats || !stats.sufficientData) return; // still hidden - keine Aussage besser als eine unsichere const avgMin = Math.round(stats.avgDelaySeconds / 60); const parts = []; parts.push(avgMin > 0 ? `Ø +${avgMin} Min` : 'Ø pünktlich'); parts.push(`${stats.pctDelayed5plus}% ≥5 Min verspätet`); if (stats.pctCancelled > 0) parts.push(`${stats.pctCancelled}% Ausfälle`); el.textContent = `📊 ${parts.join(' · ')} (${stats.sampleCount} Fahrten, ${stats.days} Tage)`; el.hidden = false; } catch (err) { // Statistik ist ein "nice to have" - bei Fehlern einfach ausblenden, // ohne die eigentliche Fahrt-Detail-Ansicht zu stören. console.error('Verspätungsstatistik-Fehler:', err.message); } } function getAlarmOffset() { return parseInt(localStorage.getItem(ALARM_OFFSET_KEY), 10) || 1; } function setAlarmOffset(value) { localStorage.setItem(ALARM_OFFSET_KEY, String(value)); } function toggleAlarm(index) { if (!currentTrip || !currentTripId) return; if (TripAlarm.isActiveFor(currentTripId, index)) { TripAlarm.clear(); } else { const select = document.getElementById('trip-alarm-offset-select'); const stopsBefore = select ? parseInt(select.value, 10) : getAlarmOffset(); setAlarmOffset(stopsBefore); TripAlarm.set({ tripId: currentTripId, line: currentTrip.line, color: currentTrip.color, textColor: currentTrip.textColor, targetIndex: index, targetName: (currentTrip.stopovers[index] || {}).name || '', stopsBefore, }); } renderStopoversBody(); } function renderStopover(s, index) { const time = s.departure || s.arrival || s.plannedDeparture || s.plannedArrival; const plannedTime = s.plannedDeparture || s.plannedArrival; const delay = s.departureDelay ?? s.arrivalDelay; const isCurrentOrNext = index === currentIndex; let timeHtml; if (s.cancelled) { timeHtml = `${formatTime(plannedTime) || '–'}`; } else if (delay && delay > 0) { timeHtml = `${formatTime(time)} (+${Math.round(delay / 60)})`; } else { timeHtml = `${formatTime(time) || '–'}`; } // Ein Fahrtalarm ergibt nur für noch bevorstehende, nicht ausgefallene // Halte Sinn - nicht für bereits vergangene. const canAlarm = !s.cancelled && index >= currentIndex; const isActiveAlarm = canAlarm && TripAlarm.isActiveFor(currentTripId, index); const alarmLabel = isActiveAlarm ? 'Fahrtalarm entfernen' : `Fahrtalarm für ${s.name || 'diese Haltestelle'} setzen`; const alarmHtml = canAlarm ? `` : ''; return `
${s.name || ''} ${timeHtml} ${alarmHtml}
`; } function renderStopoversBody() { const body = document.getElementById('trip-modal-body'); if (!body || !currentTrip) return; const stopovers = currentTrip.stopovers || []; if (!stopovers.length) { body.innerHTML = '
Keine Zwischenhalte verfügbar.
'; return; } const hasUpcoming = stopovers.some((s, i) => !s.cancelled && i >= currentIndex); const offsetSelectHtml = hasUpcoming ? `
🔔 Fahrtalarm: Glocke an der Zielhaltestelle antippen
` : ''; body.innerHTML = ` ${offsetSelectHtml}
${stopovers.map((s, i) => renderStopover(s, i)).join('')}
`; const select = document.getElementById('trip-alarm-offset-select'); if (select) select.value = String(getAlarmOffset()); } async function open(tripId) { if (!tripId) return; lastFocusedElement = document.activeElement; const el = ensureBackdrop(); el.hidden = false; currentTripId = tripId; currentTrip = null; const statsEl = document.getElementById('trip-modal-stats'); if (statsEl) { statsEl.hidden = true; } document.getElementById('trip-modal-line').textContent = '…'; document.getElementById('trip-modal-line').style.background = '#8E99A6'; document.getElementById('trip-modal-direction').textContent = ''; document.getElementById('trip-modal-meta').textContent = ''; document.getElementById('trip-modal-body').innerHTML = '
Lade Fahrt-Details…
'; // Fokus sofort in den Dialog holen (auf den Schließen-Button), damit // Screenreader-Nutzer merken, dass sich ein Dialog geöffnet hat, statt // weiter "unsichtbar" auf der jetzt verdeckten Seite zu stehen. const closeBtn = el.querySelector('.trip-modal__close'); if (closeBtn) closeBtn.focus(); let trip; try { trip = await API.getTrip(tripId, { polyline: true }); } catch (err) { document.getElementById('trip-modal-body').innerHTML = `
⚠ ${err.message}
`; return; } currentTrip = trip; const lineEl = document.getElementById('trip-modal-line'); lineEl.textContent = trip.line || '?'; lineEl.style.background = trip.color || '#8E99A6'; lineEl.style.color = trip.textColor || '#fff'; document.getElementById('trip-modal-direction').textContent = `→ ${trip.direction || trip.destination || ''}`; document.getElementById('trip-modal-meta').textContent = trip.cancelled ? 'Fahrt fällt aus' : `${trip.origin || ''} → ${trip.destination || ''}`; loadLineStats(trip.line); // Mini-Karte mit dem Streckenverlauf zeichnen if (miniMap) { miniMap.remove(); miniMap = null; } if (trip.polyline) { setTimeout(() => { miniMap = LiveMap.createMiniMap('trip-modal-map', { legs: [{ polyline: trip.polyline, walking: false, color: trip.color }], }); if (trip.currentLocation) { L.marker([trip.currentLocation.latitude, trip.currentLocation.longitude], { icon: L.divIcon({ className: 'vehicle-marker', html: `${trip.line || '?'}`, iconSize: [null, null], }), }).addTo(miniMap); } }, 50); } else { document.getElementById('trip-modal-map').innerHTML = ''; } const stopovers = trip.stopovers || []; const now = Date.now(); currentIndex = stopovers.findIndex((s) => { const t = s.departure || s.plannedDeparture; return t && new Date(t).getTime() >= now; }); if (currentIndex === -1) currentIndex = stopovers.length - 1; renderStopoversBody(); } return { open, close }; })();