Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-08-31 10:11:52 +02:00
1 parent 63da9cdbba
commit e53cd580c5
4 files changed
+274 -19

No files matched your search

+166 -12
View File
@@ -2,10 +2,13 @@ const App = (() => {
const FAVORITES_KEY = 'oepnv-navi:db-favorites';
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
const FAVORITES_POLL_MS = 30000;
let currentStop = null;
let departuresPollHandle = null;
let departuresModeFilter = null;
let favoritesPollHandle = null;
let journeyFrom = null;
let journeyTo = null;
@@ -102,6 +105,7 @@ const App = (() => {
<span>
<strong>${escapeHtml(fav.name)}</strong>
<small class="favorite-card__next" data-next-departure>Lade nächste Abfahrt…</small>
<small class="favorite-card__disruption" data-disruption hidden></small>
</span>
<span class="favorite-card__arrow">→</span>
`;
@@ -115,32 +119,107 @@ const App = (() => {
const home = document.getElementById('favorites-home');
if (home) home.hidden = favorites.length === 0;
if (favorites.length > 0) loadFavoriteNextDepartures(favorites);
updateNotifyToggleVisibility(favorites.length > 0);
if (favorites.length > 0) {
loadFavoriteNextDepartures(favorites);
startFavoritesPolling();
} else {
stopFavoritesPolling();
}
}
// ---------- Störungs-Erkennung für Favoriten ("Störungs-Push") ----------
//
// Läuft im Hintergrund, solange Favoriten vorhanden sind (siehe
// startFavoritesPolling). Vergleicht bei jedem Poll die von /summary/departures
// gelieferten Störungen einer Haltestelle mit den zuletzt gesehenen (in
// localStorage gemerkt) und meldet nur wirklich NEUE Störungen - sonst würde
// bei jedem 30s-Poll erneut benachrichtigt werden, solange eine Störung andauert.
function loadSeenDisruptions() {
try { return JSON.parse(localStorage.getItem(SEEN_DISRUPTIONS_KEY)) || {}; }
catch (_) { return {}; }
}
function saveSeenDisruptions(map) {
localStorage.setItem(SEEN_DISRUPTIONS_KEY, JSON.stringify(map));
}
function disruptionKey(d) {
return d.id || `${d.type || ''}-${d.text || d.summary || ''}`;
}
function notifyNewDisruption(favName, disruption) {
const message = `${favName}: ${disruption.summary || 'Neue Störung'}`;
showToast(message, 'warning');
if (window.Notification && Notification.permission === 'granted') {
try {
new Notification('Störung bei ' + favName, {
body: disruption.summary || disruption.text || 'Es liegt eine neue Störungsmeldung vor.',
tag: `oepnv-navi-disruption-${favName}-${disruptionKey(disruption)}`,
});
} catch (err) {
console.error('Notification-Fehler:', err.message);
}
}
}
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) return;
if (r.error || !r.nextDeparture) {
el.textContent = 'Keine Abfahrt gefunden';
return;
if (el) {
if (r.error || !r.nextDeparture) {
el.textContent = 'Keine Abfahrt gefunden';
} 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 = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} fällt aus</span>`;
} else {
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`;
}
}
}
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 = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} fällt aus</span>`;
} else {
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`;
}
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.
@@ -148,6 +227,80 @@ const App = (() => {
}
}
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 toast = document.createElement('div');
toast.className = `toast toast--${variant}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('is-visible'));
setTimeout(() => {
toast.classList.remove('is-visible');
setTimeout(() => toast.remove(), 300);
}, 6000);
}
// ---------- Browser-Benachrichtigungen an/aus ----------
function updateNotifyToggleVisibility(hasFavorites) {
const btn = document.getElementById('notify-toggle');
if (!btn) return;
if (!hasFavorites || !window.Notification) {
btn.hidden = true;
return;
}
btn.hidden = false;
if (Notification.permission === 'granted') {
btn.textContent = '🔔 Benachrichtigungen aktiv';
btn.classList.add('is-active');
btn.disabled = true;
} else if (Notification.permission === 'denied') {
btn.textContent = '🔕 Benachrichtigungen blockiert';
btn.classList.remove('is-active');
btn.disabled = true;
} else {
btn.textContent = '🔔 Störungsbenachrichtigungen aktivieren';
btn.classList.remove('is-active');
btn.disabled = false;
}
}
function initNotifications() {
const btn = document.getElementById('notify-toggle');
if (!btn || !window.Notification) return;
btn.addEventListener('click', async () => {
try {
await Notification.requestPermission();
} catch (err) {
console.error('Notification-Permission-Fehler:', err.message);
}
updateNotifyToggleVisibility(loadFavorites().length > 0);
});
updateNotifyToggleVisibility(loadFavorites().length > 0);
}
function updateFavoriteButton() {
const btn = document.getElementById('favorite-toggle');
if (!currentStop) {
@@ -831,6 +984,7 @@ const App = (() => {
initJourneyPlanner();
initNearby();
initNearbyDisruptions();
initNotifications();
renderFavoriteChips();
renderRecentSearches();
+56 -7
View File
@@ -20,6 +20,7 @@ const LiveMap = (() => {
let radarPollHandle = null;
let tripPollHandle = null;
let currentTripId = null;
let isolatedMarker = null; // persistenter Marker im Isolations-Modus, wird nur aktualisiert statt neu erzeugt
let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige
let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt
@@ -59,6 +60,11 @@ const LiveMap = (() => {
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
}
function vehiclePopupHtml(v) {
return `<strong>${v.line || 'Linie unbekannt'}</strong><br>${v.direction || ''}<br>` +
`<small>Nächster Halt: ${v.nextStopover || '–'}</small>`;
}
// ---------- Radar-Modus (Standard: alle Fahrzeuge im Ausschnitt) ----------
async function refreshRadar() {
@@ -130,22 +136,58 @@ const LiveMap = (() => {
return;
}
radarLayer.clearLayers();
const v = data.vehicles[0];
if (v && v.latitude != null && v.longitude != null) {
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
marker.bindPopup(`<strong>${v.line || ''}</strong><br>${v.direction || ''}<br><small>Nächster Halt: ${v.nextStopover || '–'}</small>`);
marker.addTo(radarLayer);
if (!v || v.latitude == null || v.longitude == null) {
// Fahrzeug gerade nicht meldend (z.B. kurzzeitig aus der Live-Erfassung) -
// Marker stehen lassen statt zu entfernen, nächster Poll aktualisiert ihn wieder.
return;
}
// WICHTIG: Marker wird wiederverwendet statt bei jedem Poll neu erzeugt zu
// werden. Vorher wurde hier radarLayer.clearLayers() + ein komplett neuer
// Marker angelegt - das hat ein ggf. geöffnetes Popup jedes Mal geschlossen,
// sodass die Fahrzeuginfo (Richtung, nächster Halt) nach dem ersten Klick nie
// wieder aktualisiert im UI sichtbar wurde.
if (isolatedMarker) {
isolatedMarker.setLatLng([v.latitude, v.longitude]);
isolatedMarker.setIcon(vehicleIcon(v.line, v.color, v.textColor));
isolatedMarker.setPopupContent(vehiclePopupHtml(v));
} else {
isolatedMarker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
isolatedMarker.bindPopup(vehiclePopupHtml(v));
isolatedMarker.addTo(radarLayer);
}
isolatedVehicleMeta = { line: v.line, color: v.color, textColor: v.textColor };
updateIsolationBanner();
}
async function isolateVehicle(vehicle) {
if (!vehicle || !vehicle.tripId) return;
// Das ursprüngliche Leaflet-Popup des angeklickten Radar-Markers (aus
// bindPopup in refreshRadar) schließen, bevor wir in den Isolations-Modus
// wechseln - sonst blitzt es kurz auf und verschwindet dann kommentarlos.
map.closePopup();
setMode('isolated');
currentTripId = vehicle.tripId;
isolatedMarker = null;
radarLayer.clearLayers();
isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor };
updateIsolationBanner();
// Sofort einen Marker mit den Daten aus dem Klick anzeigen (nicht erst auf
// den ersten Poll warten) und direkt öffnen, damit man die Fahrzeuginfo
// ohne weiteren Klick sieht.
isolatedMarker = L.marker([vehicle.latitude, vehicle.longitude], {
icon: vehicleIcon(vehicle.line, vehicle.color, vehicle.textColor),
});
isolatedMarker.bindPopup(vehiclePopupHtml(vehicle));
isolatedMarker.addTo(radarLayer);
isolatedMarker.openPopup();
routeLayer.clearLayers();
try {
const trip = await API.getTrip(vehicle.tripId, { polyline: true });
@@ -159,13 +201,13 @@ const LiveMap = (() => {
console.error('Trip-Polyline-Fehler:', err.message);
}
refreshIsolated();
if (radarPollHandle) clearInterval(radarPollHandle);
radarPollHandle = setInterval(refreshIsolated, 10000);
}
function clearIsolation() {
currentTripId = null;
isolatedMarker = null;
isolatedVehicleMeta = null;
updateIsolationBanner();
startRadar();
@@ -211,12 +253,19 @@ const LiveMap = (() => {
if (data.currentLocation) {
const pos = [data.currentLocation.latitude, data.currentLocation.longitude];
const popupHtml = `<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`;
if (tripMarker) {
tripMarker.setLatLng(pos);
tripMarker.setIcon(vehicleIcon(data.line, data.color, data.textColor));
// setPopupContent statt erneutem bindPopup, sonst schließt sich ein
// gerade geöffnetes Popup bei jedem Poll wieder (gleicher Fehler wie
// im Isolations-Modus).
if (tripMarker.getPopup()) tripMarker.setPopupContent(popupHtml);
else tripMarker.bindPopup(popupHtml);
} else {
tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer);
tripMarker.bindPopup(popupHtml);
}
tripMarker.bindPopup(`<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`);
}
}