// 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 = `
Lade Fahrt-Details…
`;
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 = '
';
// 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 = `