`;
}
}
function initNotificationsPanel() {
const clearBtn = document.getElementById('notifications-clear-btn');
if (clearBtn && window.NotificationStore) {
clearBtn.addEventListener('click', async () => {
await NotificationStore.clear();
loadNotificationsPanel();
});
}
// Der Service Worker meldet per postMessage, sobald eine Push-
// Benachrichtigung eintraf (siehe sw.js) - Badge sofort aktualisieren,
// auch während die App gerade offen ist.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'oepnv-navi:notification-added') {
refreshNotificationBadge();
if (document.querySelector('[data-tab-panel="notifications"]:not([hidden])')) {
loadNotificationsPanel();
}
}
});
}
refreshNotificationBadge();
}
// ---------- Einstellungen ----------
// Einfacher Modus: moderat größere Schrift + weniger Optionen auf einen
// Blick (Verkehrsmittel-Filter/Fahrrad-Option im Routenplaner ausgeblendet),
// analog zum "Einfachen Modus" in der iOS-App - bewusst NICHT die
// Accessibility-Schriftgrößen-Stufe, die ist für Screenreader-Nutzung
// gedacht, nicht zum "einfacher gestalten".
function applySimpleMode(enabled) {
document.documentElement.classList.toggle('simple-mode', enabled);
}
function initSettingsPanel() {
const toggle = document.getElementById('settings-simple-mode');
if (!toggle) return;
const enabled = localStorage.getItem(SIMPLE_MODE_KEY) === 'true';
toggle.checked = enabled;
applySimpleMode(enabled);
toggle.addEventListener('change', () => {
localStorage.setItem(SIMPLE_MODE_KEY, toggle.checked ? 'true' : 'false');
applySimpleMode(toggle.checked);
});
}
// ---------- "Mehr": Verbindungstest ----------
function initMorePanel() {
const btn = document.getElementById('connection-test-btn');
const statusEl = document.getElementById('connection-status');
if (!btn || !statusEl) return;
btn.addEventListener('click', async () => {
statusEl.textContent = '…';
statusEl.className = 'connection-status';
btn.disabled = true;
try {
await API.checkHealth();
statusEl.textContent = I18N.t('more.connectionOk');
statusEl.classList.add('connection-status--ok');
} catch (err) {
statusEl.textContent = I18N.t('more.connectionError');
statusEl.classList.add('connection-status--error');
} finally {
btn.disabled = false;
}
});
}
function renderFavoriteDisruption(card, favName, disruptions, seenMap, favId) {
const el = card.querySelector('[data-disruption]');
if (!el) return;
if (!disruptions || disruptions.length === 0) {
el.hidden = true;
el.textContent = '';
card.classList.remove('favorite-card--disrupted');
delete seenMap[favId];
return;
}
card.classList.add('favorite-card--disrupted');
el.hidden = false;
el.textContent = `⚠ ${disruptions[0].summary || 'Störung'}${disruptions.length > 1 ? ` (+${disruptions.length - 1} weitere)` : ''}`;
const previousKeys = new Set(seenMap[favId] || []);
disruptions.forEach((d) => {
const key = disruptionKey(d);
if (!previousKeys.has(key)) {
notifyNewDisruption(favName, d);
}
});
seenMap[favId] = disruptions.map(disruptionKey);
}
async function loadFavoriteNextDepartures(favorites) {
const stations = favorites.map((f) => ({ id: f.id, source: f.source || 'vbb', name: f.name }));
const seenMap = loadSeenDisruptions();
try {
const { results } = await API.getSummaryDepartures(stations);
results.forEach((r) => {
const card = document.querySelector(`.favorite-card[data-fav-id="${CSS.escape(String(r.id))}"]`);
if (!card) return;
const el = card.querySelector('[data-next-departure]');
if (el) {
if (r.error || !r.nextDeparture) {
el.textContent = I18N.t('favorites.noDeparture');
} else {
const dep = r.nextDeparture;
const minutesUntil = dep.when ? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000)) : null;
if (dep.cancelled) {
el.innerHTML = `${escapeHtml(dep.line || '')} ${escapeHtml(I18N.t('favorites.cancelledShort'))}`;
} else {
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' ' + I18N.t('favorites.minutesShort') : ''}`;
}
}
}
renderFavoriteDisruption(card, r.name || '', r.disruptions, seenMap, r.id);
});
saveSeenDisruptions(seenMap);
} catch (err) {
// Stillschweigend ignorieren - die Karten zeigen dann weiterhin "Lade..."
// bzw. der Nutzer bekommt die Abfahrten trotzdem beim Antippen der Karte.
console.error('Sammel-Abfahrten-Fehler:', err.message);
}
}
function startFavoritesPolling() {
if (favoritesPollHandle) clearInterval(favoritesPollHandle);
favoritesPollHandle = setInterval(() => {
const favorites = loadFavorites();
if (favorites.length > 0) loadFavoriteNextDepartures(favorites);
else stopFavoritesPolling();
}, FAVORITES_POLL_MS);
}
function stopFavoritesPolling() {
if (favoritesPollHandle) {
clearInterval(favoritesPollHandle);
favoritesPollHandle = null;
}
}
// ---------- Toast (kurze, unaufdringliche In-App-Meldung) ----------
function showToast(message, variant = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const ICONS = { success: '✓', warning: '⚠', info: 'ℹ' };
const toast = document.createElement('div');
toast.className = `toast toast--${variant}`;
toast.innerHTML = `
${ICONS[variant] || ICONS.info}
`;
toast.querySelector('.toast__message').textContent = message;
container.appendChild(toast);
const AUTO_DISMISS_MS = 6000;
let dismissTimer = null;
function dismiss() {
clearTimeout(dismissTimer);
toast.classList.remove('is-visible');
setTimeout(() => toast.remove(), 250);
}
toast.querySelector('.toast__close').addEventListener('click', dismiss);
requestAnimationFrame(() => {
toast.classList.add('is-visible');
// Countdown-Leiste per CSS-Transition auf 0 fahren, synchron zur
// tatsächlichen Anzeigedauer - zeigt beiläufig, wie viel Zeit bleibt,
// ohne dass man aktiv draufschauen muss.
const progress = toast.querySelector('.toast__progress');
if (progress) {
progress.style.transitionDuration = `${AUTO_DISMISS_MS}ms`;
requestAnimationFrame(() => { progress.style.transform = 'scaleX(0)'; });
}
});
dismissTimer = setTimeout(dismiss, AUTO_DISMISS_MS);
}
// ---------- Browser-Benachrichtigungen an/aus ----------
function updateNotifyToggleVisibility(hasFavorites) {
const btn = document.getElementById('notify-toggle');
const callout = document.getElementById('notify-callout');
if (!btn || !callout) return;
if (!hasFavorites || !window.Notification) {
callout.hidden = true;
return;
}
callout.hidden = false;
callout.classList.remove('notify-callout--active', 'notify-callout--blocked');
if (Notification.permission === 'granted') {
btn.textContent = I18N.t('favorites.notifyActiveShort');
callout.classList.add('notify-callout--active');
btn.disabled = true;
} else if (Notification.permission === 'denied') {
btn.textContent = I18N.t('favorites.notifyBlockedShort');
callout.classList.add('notify-callout--blocked');
btn.disabled = true;
} else {
btn.textContent = I18N.t('favorites.enableNotifyShort');
btn.disabled = false;
}
}
function initNotifications() {
const btn = document.getElementById('notify-toggle');
if (!btn || !window.Notification) return;
btn.addEventListener('click', async () => {
try {
// Volles Web-Push-Abo statt nur lokaler Notification-Erlaubnis -
// funktioniert dadurch auch, wenn der Tab/Browser geschlossen ist.
// Fällt automatisch auf den bisherigen, rein Tab-lokalen Push zurück,
// falls der Browser Web-Push nicht unterstützt oder der Server es
// nicht konfiguriert hat (Push.enable() wirft dann einen Fehler, den
// wir hier abfangen - die Tab-lokalen Benachrichtigungen laufen so
// oder so über den normalen Notification.requestPermission()-Pfad
// weiter, den Push.enable() intern mit aufruft).
if (window.Push && Push.isSupported()) {
await Push.enable(loadFavorites());
} else {
await Notification.requestPermission();
}
} catch (err) {
console.error('Benachrichtigungen aktivieren fehlgeschlagen:', err.message);
showToast(err.message, 'warning');
}
updateNotifyToggleVisibility(loadFavorites().length > 0);
});
updateNotifyToggleVisibility(loadFavorites().length > 0);
}
function updateFavoriteButton() {
const btn = document.getElementById('favorite-toggle');
if (!currentStop) {
btn.hidden = true;
return;
}
btn.hidden = false;
const active = isFavorite(currentStop.id);
btn.textContent = `${active ? '★' : '☆'} ${I18N.t('favorites.favorite')}`;
btn.classList.toggle('is-active', active);
}
let lastKnownLocation = null; // { lat, lon } - einmal ermittelt, für Autostart & Störungen wiederverwendet
function getUserLocation() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation wird von diesem Browser nicht unterstützt.'));
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
lastKnownLocation = { lat: pos.coords.latitude, lon: pos.coords.longitude };
resolve(lastKnownLocation);
},
(err) => reject(new Error('Standort konnte nicht ermittelt werden: ' + err.message)),
{ enableHighAccuracy: true, timeout: 10000 },
);
});
}
let searchDebounce = null;
let searchRequestId = 0;
function initSearch() {
const input = document.getElementById('station-search');
const results = document.getElementById('search-results');
wireSearchKeyboardNav(input, results);
// Leeres Suchfeld antippen: zuletzt gesuchte Haltestellen als Vorschläge
// zeigen, statt dass man erst tippen muss, um überhaupt etwas zu sehen.
function showRecentSuggestions() {
const recents = loadRecentSearches();
if (!recents.length) {
results.hidden = true;
results.innerHTML = '';
return;
}
results.innerHTML = `
${escapeHtml(I18N.t('search.recentSearches'))}
`;
renderSearchResults(results, recents, (station) => {
selectStation(station);
input.value = '';
results.hidden = true;
}, { append: true });
}
input.addEventListener('focus', () => {
if (input.value.trim().length < 2) showRecentSuggestions();
});
input.addEventListener('input', () => {
const clear = document.getElementById('search-clear');
if (clear) clear.hidden = input.value.length === 0;
clearTimeout(searchDebounce);
const query = input.value.trim();
if (query.length < 2) {
showRecentSuggestions();
return;
}
const requestId = ++searchRequestId;
searchDebounce = setTimeout(async () => {
try {
const stations = await API.searchAll(query);
// Wettlauf-Schutz: falls währenddessen weitergetippt und eine neuere
// Suche gestartet wurde, deren (schnellere) Antwort schon
// angekommen ist, wird diese ältere, jetzt überholte Antwort
// verworfen statt die neueren Ergebnisse zu überschreiben.
if (requestId !== searchRequestId) return;
renderSearchResults(results, stations, (station) => {
selectStation(station);
input.value = '';
results.hidden = true;
});
} catch (err) {
if (requestId !== searchRequestId) return;
console.error('Suchfehler:', err.message);
}
}, 250);
});
const clear = document.getElementById('search-clear');
if (clear) clear.addEventListener('click', () => { input.value = ''; results.hidden = true; clear.hidden = true; input.focus(); });
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-box')) {
results.hidden = true;
}
});
}
// Macht ein Such-Ergebnis-Dropdown per Tastatur bedienbar (Pfeiltasten
// rauf/runter, Enter zum Auswählen, Escape zum Schließen) - vorher war das
// nur per Maus/Touch nutzbar, was Tastatur- und Screenreader-Nutzer
// komplett von der Haltestellensuche ausgeschlossen hat. Nutzt das
// "aria-activedescendant"-Muster: der echte Tastaturfokus bleibt im
// Eingabefeld, nur die optische/semantische Markierung wandert durch die
// Liste - Screenreader kündigen die jeweils markierte Option trotzdem an.
function wireSearchKeyboardNav(input, container) {
let activeIndex = -1;
function getOptions() {
return Array.from(container.querySelectorAll('[role="option"]'));
}
function setActive(index) {
const options = getOptions();
options.forEach((el) => {
el.classList.remove('search-result--active');
el.setAttribute('aria-selected', 'false');
});
if (index < 0 || index >= options.length) {
activeIndex = -1;
input.removeAttribute('aria-activedescendant');
return;
}
activeIndex = index;
const el = options[index];
el.classList.add('search-result--active');
el.setAttribute('aria-selected', 'true');
if (!el.id) el.id = `search-option-${Math.random().toString(36).slice(2, 9)}`;
input.setAttribute('aria-activedescendant', el.id);
el.scrollIntoView({ block: 'nearest' });
}
input.addEventListener('keydown', (e) => {
if (container.hidden) return;
const options = getOptions();
if (!options.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && options[activeIndex]) {
e.preventDefault();
options[activeIndex].click();
}
} else if (e.key === 'Escape') {
container.hidden = true;
setActive(-1);
}
});
// Hält aria-expanded synchron (egal von wo der Dropdown ein-/ausgeblendet
// wird) und setzt die Markierung zurück, sobald sich der Inhalt ändert
// (neue Suchergebnisse) - so muss nicht an jeder einzelnen Stelle im Code,
// die results.hidden setzt, manuell dran gedacht werden.
const observer = new MutationObserver((mutations) => {
mutations.forEach((m) => {
if (m.type === 'attributes' && m.attributeName === 'hidden') {
input.setAttribute('aria-expanded', String(!container.hidden));
if (container.hidden) setActive(-1);
}
if (m.type === 'childList') setActive(-1);
});
});
observer.observe(container, { childList: true, attributes: true, attributeFilter: ['hidden'] });
}
// Ein Symbol pro Ergebnis-Herkunft: VBB-Haltestelle (Nahverkehr), echter
// DB-Bahnhof (bundesweit), oder Adresse/POI (nur im Routenplaner relevant).
function resultIcon(loc) {
if (loc.source === 'db') return '🚉';
if (loc.kind === 'address') return '📍';
if (loc.kind === 'poi') return '⭐';
return '🚏'; // VBB-Haltestelle (U/S/Tram/Bus)
}
function renderSearchResults(container, locations, onSelect, { append = false } = {}) {
if (!append) container.innerHTML = '';
if (locations.length === 0) {
container.innerHTML = `
${escapeHtml(I18N.t('search.noResults'))}
`;
container.hidden = false;
return;
}
locations.forEach((loc) => {
const li = document.createElement('li');
li.className = 'search-result';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
const badge = loc.source === 'db' ? 'DB · bundesweit' : '';
li.innerHTML = `${resultIcon(loc)} ${escapeHtml(loc.name)}${badge}`;
li.addEventListener('click', () => onSelect(loc));
container.appendChild(li);
});
container.hidden = false;
}
function selectStation(station) {
currentStop = station;
localStorage.setItem(LAST_STOP_KEY, JSON.stringify({ id: station.id, name: station.name, source: station.source }));
saveRecentSearch(station);
activateTab('departures');
document.getElementById('board-station-name').textContent = station.name;
document.title = `${station.name} · ÖPNV Navi`;
document.getElementById('board-empty-state').hidden = true;
document.getElementById('welcome-panel').hidden = true;
document.getElementById('departures-view').hidden = false;
updateFavoriteButton();
// "In der Nähe"-Vorschau der Startseite ausblenden, sobald eine konkrete
// Haltestelle gewählt wurde - sonst bleibt sie über der Abfahrtstafel
// stehen, obwohl man längst weitergeklickt hat.
const nearbyHomeEl = document.getElementById('nearby-home');
if (nearbyHomeEl) nearbyHomeEl.hidden = true;
// Abfahrtstafel sofort leeren statt die Zeilen der vorherigen Haltestelle
// stehen zu lassen, bis die neuen Daten da sind - bei langsamer Verbindung
// sah man sonst kurz "S41 → Ringbahn" unter dem bereits neuen
// Stationsnamen, was verwirrend war.
const board = document.getElementById('departure-board');
if (board) board.innerHTML = `
${escapeHtml(I18N.t('departures.loading'))}
`;
lastRenderedSource = null;
// Störungsanzeige der zuvor gewählten Haltestelle sofort verstecken,
// damit nicht kurz die alten Störungen der letzten Station aufblitzen,
// bevor die neuen Abfahrten geladen sind.
const disruptionEl = document.getElementById('station-disruption');
if (disruptionEl) { disruptionEl.hidden = true; disruptionEl.innerHTML = ''; }
const elevatorEl = document.getElementById('elevator-status');
if (elevatorEl) { elevatorEl.hidden = true; elevatorEl.innerHTML = ''; }
// Der Modefilter (Verkehrsmittel S/U/Tram/Bus/...) gilt nur für VBB-
// Haltestellen - die bundesweite DB-Anbindung unterstützt keine Filterung.
const modeFilterEl = document.getElementById('departures-mode-filter');
if (modeFilterEl) modeFilterEl.hidden = station.source === 'db';
loadDepartures();
restartDeparturesPolling();
}
async function loadDepartures() {
if (!currentStop) return;
const board = document.getElementById('departure-board');
try {
const data = currentStop.source === 'db'
? await API.getDbDepartures(currentStop.id)
: await API.getDepartures(currentStop.id, {
modes: departuresModeFilter ? departuresModeFilter.getModesParam() : null,
});
renderDepartureBoard(data.departures);
renderStationDisruptions(data.disruptions);
} catch (err) {
board.innerHTML = `
⚠ ${escapeHtml(err.message)}
`;
const retryBtn = document.getElementById('board-retry');
if (retryBtn) retryBtn.addEventListener('click', () => loadDepartures());
}
}
// Zeigt echte Störungsmeldungen (Bauarbeiten, Ausfälle, Umleitungen) an, die
// auf einer der aktuell angezeigten Abfahrten der gewählten Haltestelle
// liegen - direkt unterhalb der Abfahrtstafel, damit man sie nicht übersieht.
// Aufzugsmeldungen sind eine Untermenge der allgemeinen Störungen (HAFAS
// liefert sie als ganz normale "warning"-Remarks) - werden aber separat und
// ruhiger dargestellt, weil es sich um eine Zugänglichkeits-Info handelt und
// nicht zwangsläufig um eine Störung der eigenen Fahrt.
const ELEVATOR_PATTERN = /aufzug|fahrstuhl|aufzüge/i;
function renderStationDisruptions(disruptions) {
const disruptionEl = document.getElementById('station-disruption');
const elevatorEl = document.getElementById('elevator-status');
if (!disruptionEl && !elevatorEl) return;
const all = disruptions || [];
const elevatorItems = all.filter((d) => ELEVATOR_PATTERN.test(`${d.summary || ''} ${d.text || ''}`));
const otherItems = all.filter((d) => !elevatorItems.includes(d));
if (elevatorEl) {
if (elevatorItems.length === 0) {
elevatorEl.hidden = true;
elevatorEl.innerHTML = '';
} else {
elevatorEl.hidden = false;
elevatorEl.innerHTML = elevatorItems
.map((d) => `
`;
lastRenderedSource = source;
return;
}
const existingRows = board.querySelectorAll('.flap-row');
// Board komplett neu aufbauen, wenn sich die Anzahl ändert ODER die
// Quelle wechselt (VBB<->DB) - sonst blieben bei zufällig gleicher
// Zeilenzahl alte Klick-Handler/Klassen vom vorherigen Backend hängen.
if (existingRows.length !== departures.length || lastRenderedSource !== source) {
board.innerHTML = '';
departures.forEach((dep, i) => {
board.appendChild(buildDepartureRow(dep, i));
});
lastRenderedSource = source;
}
departures.forEach((dep, i) => {
updateDepartureRow(board.children[i], dep);
});
}
function buildDepartureRow(dep, index) {
const row = document.createElement('div');
row.className = 'flap-row';
row.style.setProperty('--row-index', index);
row.innerHTML = `
${escapeHtml(I18N.t('departures.line'))}
${escapeHtml(I18N.t('departures.direction'))}
${escapeHtml(I18N.t('departures.platform'))}
${escapeHtml(I18N.t('departures.time'))}
`;
// Klick-für-Fahrt-Details gibt es aktuell nur für VBB-Haltestellen (kein
// passender Backend-Endpunkt für DB-Streckenverlauf/Zwischenhalte).
if (currentStop && currentStop.source !== 'db') {
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
row.classList.add('flap-row--clickable');
const openModal = () => {
if (row.dataset.tripId) TripModal.open(row.dataset.tripId);
};
row.addEventListener('click', openModal);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openModal();
}
});
} else {
// Nicht klickbare Zeile (DB-Haltestelle) - trotzdem ein sinnvolles
// Listenelement innerhalb von #departure-board (role="list"), statt
// eines rollen-losen div, das für Screenreader wie "nichts" aussieht.
row.setAttribute('role', 'listitem');
}
return row;
}
function updateDepartureRow(row, dep) {
row.dataset.tripId = dep.tripId || '';
const minutesUntil = dep.when
? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000))
: null;
const timeText = dep.cancelled
? 'FAELLT AUS'
: minutesUntil !== null
? `${String(minutesUntil).padStart(2, '0')} MIN`
: '-- MIN';
row.classList.toggle('flap-row--delayed', !!dep.delay && dep.delay > 0);
row.classList.toggle('flap-row--cancelled', !!dep.cancelled);
const lineCell = row.querySelector('[data-field="line"]');
lineCell.style.setProperty('--line-color', dep.color || '#8E99A6');
lineCell.style.setProperty('--line-text-color', dep.textColor || '#fff');
// Auch auf der Zeile selbst setzen, für den farbigen Rand links (schnelles
// Scannen der Tafel nach Linie, ohne jede Zeile einzeln lesen zu müssen).
row.style.setProperty('--line-color', dep.color || '#8E99A6');
SplitFlap.render(lineCell, (dep.line || '?').toUpperCase());
// Bei langen Zugbezeichnungen (z.B. "ICE 940") reicht die feste
// Spaltenbreite nicht - der Text läuft dann von selbst wie ein Ticker
// durch (CSS-Animation), kein manuelles Scrollen nötig. Hier nur messen,
// wie weit der Text übersteht, und Distanz/Tempo als CSS-Variablen setzen.
// requestAnimationFrame, damit die SplitFlap-Zeichen-Spans sicher schon
// im DOM stehen, bevor die Breite gemessen wird.
requestAnimationFrame(() => {
const visual = lineCell.querySelector('.flap-cell__visual');
let overflowPx = 0;
if (visual) {
// Echte Geometrie statt clientWidth/scrollWidth-Schätzung: clientWidth
// schließt das eigene Padding der Badge mit ein, wodurch die zuvor
// berechnete Laufweite immer um genau das rechte Padding zu kurz war -
// das letzte Zeichen war dadurch nie vollständig sichtbar. Hier wird
// stattdessen direkt gemessen, wie weit der rechte Rand des Textes
// über den rechten Innenrand (Content-Box, ohne Padding) der Badge hinausragt.
const lineRect = lineCell.getBoundingClientRect();
const visualRect = visual.getBoundingClientRect();
const paddingRight = parseFloat(getComputedStyle(lineCell).paddingRight) || 0;
const contentBoxRight = lineRect.right - paddingRight;
overflowPx = Math.max(0, visualRect.right - contentBoxRight);
}
const isScrollable = overflowPx > 1;
// Nicht bei jedem 30s-Poll unangetastet neu antriggern, wenn sich an der
// Überstands-Länge nichts geändert hat - sonst startet die Animation
// immer wieder von vorn und wirkt dadurch unruhiger/hektischer, als sie
// eigentlich ist.
const previousDistance = lineCell.dataset.marqueeOverflow;
const currentDistance = String(Math.round(overflowPx));
if (previousDistance === currentDistance) return;
lineCell.dataset.marqueeOverflow = currentDistance;
lineCell.classList.toggle('flap-cell--line--scrollable', isScrollable);
if (isScrollable) {
// +2px statt einer größeren Zugabe - die Distanz soll das letzte
// Zeichen exakt an den Innenrand bringen, nicht noch weiter drüber hinaus.
// Bewusst langsam und ruhig: 12px/s als Grundtempo, mindestens 4.5s pro
// Durchlauf, damit es wie ein gemächlicher Bahnhofstafel-Ticker wirkt
// statt hektisch hin- und herzuschnellen.
lineCell.style.setProperty('--marquee-distance', `-${overflowPx + 2}px`);
lineCell.style.setProperty('--marquee-duration', `${Math.max(4.5, overflowPx / 12).toFixed(1)}s`);
} else {
lineCell.style.removeProperty('--marquee-distance');
lineCell.style.removeProperty('--marquee-duration');
}
});
SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase());
// Gleiswechsel deutlich hervorheben, statt die neue Gleisnummer einfach
// kommentarlos anzuzeigen - genau das übersieht man sonst leicht, wenn
// man aus Gewohnheit zum gewohnten Gleis läuft.
const platformCell = row.querySelector('[data-field="platform"]');
const hasPlatformChange = !dep.cancelled && dep.plannedPlatform && dep.platform && dep.platform !== dep.plannedPlatform;
if (hasPlatformChange) {
const newPlatform = String(dep.platform).toUpperCase();
const plannedPlatform = String(dep.plannedPlatform).toUpperCase();
if (platformCell.dataset.changedTo !== newPlatform || platformCell.dataset.changedFrom !== plannedPlatform) {
platformCell.dataset.changedTo = newPlatform;
platformCell.dataset.changedFrom = plannedPlatform;
platformCell.classList.add('flap-cell--platform-changed');
platformCell.innerHTML = `
${escapeHtml(I18N.t('departures.platformChanged', { n: newPlatform }))}${escapeHtml(I18N.t('departures.platformInstead', { n: plannedPlatform }))}
`;
}
} else {
if (platformCell.classList.contains('flap-cell--platform-changed')) {
delete platformCell.dataset.changedTo;
delete platformCell.dataset.changedFrom;
platformCell.classList.remove('flap-cell--platform-changed');
platformCell.innerHTML = '';
}
SplitFlap.render(platformCell, (dep.platform || '-').toUpperCase());
}
const timeCell = row.querySelector('[data-field="time"]');
if (dep.cancelled) {
const plannedTime = dep.plannedWhen
? new Date(dep.plannedWhen).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: '';
if (timeCell.dataset.cancelledPlanned !== plannedTime) {
timeCell.dataset.cancelledPlanned = plannedTime;
timeCell.classList.add('flap-cell--time-cancelled');
timeCell.innerHTML = `
${escapeHtml(I18N.t('departures.cancelled'))}${escapeHtml(I18N.t('departures.plannedShort'))} ${escapeHtml(plannedTime)}
`;
}
} else {
delete timeCell.dataset.cancelledPlanned;
timeCell.classList.remove('flap-cell--time-cancelled');
SplitFlap.render(timeCell, timeText);
}
}
function restartDeparturesPolling() {
if (departuresPollHandle) clearInterval(departuresPollHandle);
departuresPollHandle = setInterval(loadDepartures, 30000);
}
function initTabs() {
document.querySelectorAll('[data-tab]').forEach((tab) => {
tab.addEventListener('click', () => activateTab(tab.dataset.tab, { focusPanel: true }));
});
// Pfeiltasten-Navigation innerhalb einer Tab-Leiste (Standard-ARIA-Tabs-
// Verhalten: Pfeil links/rechts wechselt zwischen den Tabs, Pos1/Ende
// springt an Anfang/Ende) - wichtig für Tastatur-/Screenreader-Nutzer,
// die sonst durch jeden Tab-Button einzeln tabben müssten.
[
Array.from(document.querySelectorAll('.tab-nav [role="tab"]')),
Array.from(document.querySelectorAll('.mobile-nav [role="tab"]')),
].forEach((tabs) => {
tabs.forEach((tab, index) => {
tab.addEventListener('keydown', (e) => {
let targetIndex = null;
if (e.key === 'ArrowRight') targetIndex = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') targetIndex = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') targetIndex = 0;
else if (e.key === 'End') targetIndex = tabs.length - 1;
if (targetIndex === null) return;
e.preventDefault();
tabs[targetIndex].focus();
activateTab(tabs[targetIndex].dataset.tab);
});
});
});
}
function activateTab(tabName, { focusPanel = false } = {}) {
document.querySelectorAll('.tab-button, .mobile-nav__item').forEach((btn) => {
const active = btn.dataset.tab === tabName;
btn.classList.toggle('is-active', active);
btn.setAttribute('aria-selected', String(active));
btn.tabIndex = active ? 0 : -1;
});
let activePanel = null;
document.querySelectorAll('.tab-panel').forEach((panel) => {
const active = panel.dataset.tabPanel === tabName;
panel.hidden = !active;
if (active) activePanel = panel;
});
// Bewegt den Fokus zum neu sichtbaren Panel, wenn der Tabwechsel eine
// bewusste Navigationshandlung war (Klick/Tastatur auf einen Tab-Button) -
// Screenreader-Nutzer landen so direkt im neuen Inhalt, statt weiter auf
// dem (jetzt unsichtbaren) Tab-Button zu stehen. Beim initialen Laden der
// Seite (restoreLastStop() etc.) wird das bewusst NICHT gemacht, damit der
// Fokus nicht ungefragt vom Seitenanfang wegspringt.
if (focusPanel && activePanel && activePanel.hasAttribute('tabindex')) {
activePanel.focus({ preventScroll: true });
}
if (tabName === 'map') {
LiveMap.startPolling();
} else {
LiveMap.stopPolling();
}
if (tabName === 'disruptions' && !remarksLoadedOnce) {
loadRemarks();
// "In deiner Nähe" automatisch mitladen statt auf den manuellen
// "Standort verwenden"-Klick zu warten - Störungen sollen immer sowohl
// für die Favoriten als auch für den aktuellen Standort da sein.
loadNearbyDisruptions();
}
if (tabName === 'fares' && !faresLoadedOnce) {
loadFares();
}
if (tabName === 'notifications') {
loadNotificationsPanel();
}
}
function initNearby() {
document.getElementById('welcome-nearby').addEventListener('click', () => { activateTab('nearby'); document.getElementById('nearby-locate').click(); });
document.getElementById('welcome-route').addEventListener('click', () => activateTab('journey'));
document.getElementById('nearby-home-open').addEventListener('click', () => activateTab('nearby'));
document.getElementById('nearby-locate').addEventListener('click', async () => {
const listEl = document.getElementById('nearby-list');
listEl.innerHTML = '
';
return;
}
locations.forEach((loc) => {
const item = document.createElement('button');
item.className = 'nearby-item';
item.innerHTML = `
${loc.name}${loc.distance != null ? loc.distance + ' m' : ''}
`;
item.addEventListener('click', () => {
selectStation(loc);
activateTab('departures');
});
container.appendChild(item);
});
}
function initNearbyDisruptions() {
const btn = document.getElementById('disruptions-locate');
if (!btn) return;
btn.addEventListener('click', () => loadNearbyDisruptions());
}
// Lädt Störungen rund um den aktuellen Standort - wird sowohl vom manuellen
// "Standort verwenden"-Button als auch automatisch beim ersten Öffnen des
// Störungen-Tabs aufgerufen (siehe activateTab()), damit man dafür nicht
// erst extra klicken muss. Nutzt den beim Start bereits ermittelten
// Standort weiter, statt ihn ein zweites Mal abzufragen, falls vorhanden.
async function loadNearbyDisruptions() {
const container = document.getElementById('nearby-disruptions-list');
if (!container) return;
container.innerHTML = '