From e53cd580c5882dc26a8de292560370bf8ba6f402 Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:11:52 +0200 Subject: [PATCH] Add files via upload --- index.html | 2 + js/app.js | 178 ++++++++++++++++++++++++++++++++++++++++++++++---- js/livemap.js | 63 ++++++++++++++++-- styles.css | 50 ++++++++++++++ 4 files changed, 274 insertions(+), 19 deletions(-) diff --git a/index.html b/index.html index 3b00eb4..3f3c2d2 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,7 @@ @@ -191,5 +192,6 @@ +
diff --git a/js/app.js b/js/app.js index af61548..aba1432 100644 --- a/js/app.js +++ b/js/app.js @@ -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 = (() => { ${escapeHtml(fav.name)} Lade nächste Abfahrt… + → `; @@ -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 = `${escapeHtml(dep.line || '')} fällt aus`; + } 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 = `${escapeHtml(dep.line || '')} fällt aus`; - } 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(); diff --git a/js/livemap.js b/js/livemap.js index 79fc041..281330f 100644 --- a/js/livemap.js +++ b/js/livemap.js @@ -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 `${v.line || 'Linie unbekannt'}
${v.direction || ''}
` + + `Nächster Halt: ${v.nextStopover || '–'}`; + } + // ---------- 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(`${v.line || ''}
${v.direction || ''}
Nächster Halt: ${v.nextStopover || '–'}`); - 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 = `${data.line || ''}
→ ${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(`${data.line || ''}
→ ${data.direction || ''}`); } } diff --git a/styles.css b/styles.css index 3ff200d..db2ae98 100644 --- a/styles.css +++ b/styles.css @@ -852,6 +852,55 @@ button:active { transform: translateY(1px); } .favorite-card small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; } .favorite-card__cancelled { color: var(--signal-red); font-weight: 600; } .favorite-card__arrow { color: var(--ink-muted); font-size: 18px; } + +/* Störungs-Hinweis auf Favoriten-Karten ("Störungs-Push") */ +.favorite-card--disrupted { border-color: rgba(255,93,108,.5); } +.favorite-card__disruption { + color: var(--signal-red) !important; + font-weight: 600; + margin-top: 4px; +} + +.notify-toggle, #notify-toggle.is-active { color: var(--signal-green); } +#notify-toggle:disabled { cursor: default; opacity: .8; } + +/* ============================================================ + Toast-Benachrichtigungen (z.B. für neue Störungen bei Favoriten) + ============================================================ */ + +.toast-container { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + width: min(420px, calc(100vw - 32px)); + pointer-events: none; +} + +.toast { + background: var(--panel-raised); + border: 1px solid var(--line); + border-left: 3px solid var(--signal-amber); + color: var(--ink); + border-radius: var(--radius); + padding: 12px 16px; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 8px 24px rgba(0,0,0,.35); + width: 100%; + opacity: 0; + transform: translateY(12px); + transition: opacity .25s ease, transform .25s ease; +} + +.toast.is-visible { opacity: 1; transform: translateY(0); } +.toast--warning { border-left-color: var(--signal-red); } +.toast--info { border-left-color: var(--signal-amber); } .nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; } .nearby-preview__item { display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left; @@ -1437,6 +1486,7 @@ button:active { transform: translateY(1px); } .mobile-nav__item span { font-size: 18px; line-height: 1.1; } .mobile-nav__item small { font-size: 10px; } .mobile-nav__item.is-active { color: var(--signal-amber); } + .toast-container { bottom: calc(64px + env(safe-area-inset-bottom) + 12px); } } @media (max-width: 430px) {