diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ba43f..d7efd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/), und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). +## [37.4.0] - 2026-07-08 + +### Favoriten-Routen, Standort auf der Karte, smarterer Geocode + +#### Added +- **Favoriten-Routen für Pendler**: Nach einer Suche merkt "☆ Route merken" die Verbindung (max. 5). Gespeicherte Routen erscheinen als Chips über dem Routenplaner-Formular - 1 Tap füllt Start/Ziel und sucht sofort. Chips zeigen Kurznamen ("Alexanderplatz → Zoolog. Garten" statt der vollen Stationsnamen). +- **Eigener Standort auf der Live-Map**: 📍-Button in der Filterleiste setzt einen blauen Punkt auf die eigene Position und zentriert die Karte (Marker wird bei erneutem Tap bewegt, nicht neu erzeugt). +- **Geocode-Bias per GPS**: Die Stationssuche sortiert Treffer jetzt nach der letzten bekannten Position (aus GPS-Nutzung, max. 1h alt, lokal gespeichert) statt hart nach Berlin-Mitte - spürbar bessere Vorschläge in Brandenburg. + +#### Fixed +- **localStorage räumt jetzt auf**: Abfahrten-Caches älter als 7 Tage und Geocode-Mappings älter als 30 Tage werden beim App-Start gelöscht (wuchsen bisher unbegrenzt). Alte Mapping-Einträge werden automatisch ins neue Format mit Zeitstempel migriert. + +## [37.3.0] - 2026-07-07 + +### Blättern, Zwischenhalte, Stationen auf der Karte + +#### Added +- **"Frühere / Spätere Verbindungen"**: Die Routensuche blättert jetzt per pageCursor durch Alternativverbindungen - Buttons oberhalb und unterhalb der Ergebnisliste, mit sanftem Scroll nach oben beim Blättern. +- **Zwischenhalte ausklappbar**: In der Routen-Detailansicht zeigt jeder Fahrt-Abschnitt "🔄 N Zwischenhalte anzeigen" (natives details-Element, tastaturbedienbar) mit Haltestellenliste inkl. Ankunftszeiten. Die Daten kamen schon immer mit der plan-Antwort - der alte Platzhalter ("N Halte") bekam sie nur nie. +- **Stationen auf der Live-Map**: Ab Zoom 15 erscheinen die Haltestellen im Ausschnitt als gelbe Punkte (map/stops, 5min-Cache). Tap -> Popup mit den nächsten 3 Abfahrten (1 Request, 15s-Cache) und "Alle Abfahrten →" springt direkt in die Abfahrten-View mit dieser Station. Im Fokus-Modus werden die Punkte automatisch ausgeblendet. + ## [37.2.0] - 2026-07-07 ### Favoriten-Chips in der Abfahrten-View diff --git a/index.html b/index.html index 2c3fc35..301ad17 100644 --- a/index.html +++ b/index.html @@ -132,13 +132,14 @@

Fahrzeuge in Echtzeit verfolgen

-
+
+ @@ -205,6 +206,10 @@
+ + +
@@ -300,6 +305,9 @@ 🚇 Lade Fahrzeuge...
+ diff --git a/js/api.js b/js/api.js index acbe57e..d9587c0 100644 --- a/js/api.js +++ b/js/api.js @@ -7,7 +7,7 @@ // ZENTRALE VERSIONSNUMMER - einzige Stelle, die bei Releases // angepasst wird. Alle Anzeigen (Startseite, Entwickler-Tab) // lesen von hier; package.json + SW-Cache-Name manuell mitziehen. - const APP_VERSION = '37.2.0'; + const APP_VERSION = '37.4.0'; const APP_RELEASE_DATE = '07.07.2026'; // Seit v35: Transitous (api.transitous.org) ist die einzige @@ -25,6 +25,8 @@ // später geladenen Dateien angewiesen ist. let currentView = 'home'; let departuresCount = 20; // Wie viele Abfahrten laden ("Mehr laden" erhöht) + let lastKnownLat = null; // Letzte bekannte GPS-Position + let lastKnownLon = null; // (für Geocode-Bias + Karten-Zentrierung) let currentStationLat = null; // Koordinaten der gewählten Station let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping) let liveMap = null; @@ -203,9 +205,12 @@ // ========================================== const STORAGE_KEYS = { favorites: 'vbb_favorites', + favRoutes: 'vbb_fav_routes', lastStation: 'vbb_last_station', lastJourney: 'vbb_last_journey', - depCachePrefix: 'vbb_dep_cache_' + lastPosition: 'vbb_last_position', + depCachePrefix: 'vbb_dep_cache_', + stopMapPrefix: 'vbb_transitous_stop_' }; function storageGet(key, fallback = null) { @@ -335,6 +340,117 @@ btn.title = fav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'; } + // --- Routen-Favoriten (Pendler: 1-Tap-Suche) --- + const MAX_FAV_ROUTES = 5; + + function getFavRoutes() { + return storageGet(STORAGE_KEYS.favRoutes, []); + } + + function routeKey(from, to) { + return `${from?.id}→${to?.id}`; + } + + function isFavRoute(from, to) { + const key = routeKey(from, to); + return getFavRoutes().some(r => routeKey(r.from, r.to) === key); + } + + function toggleFavRoute(from, to) { + if (!from || !to) return false; + let routes = getFavRoutes(); + const key = routeKey(from, to); + if (routes.some(r => routeKey(r.from, r.to) === key)) { + routes = routes.filter(r => routeKey(r.from, r.to) !== key); + } else { + routes.unshift({ from, to }); + routes = routes.slice(0, MAX_FAV_ROUTES); + } + storageSet(STORAGE_KEYS.favRoutes, routes); + renderRouteFavorites(); + return isFavRoute(from, to); + } + + // Kurzname für Chips: "S+U Alexanderplatz (Berlin)" -> "Alexanderplatz" + function shortStationName(name) { + return String(name || '') + .replace(/\s*\(Berlin\)\s*/g, '') + .replace(/^(S\+U|S|U|Bhf\.?)\s+/i, '') + .trim(); + } + + function renderRouteFavorites() { + const container = document.getElementById('journeyFavorites'); + if (!container) return; + const routes = getFavRoutes(); + + if (routes.length === 0) { + container.innerHTML = ''; + container.style.display = 'none'; + return; + } + + container.style.display = 'block'; + container.innerHTML = ` +
⭐ Deine Routen
+
+ ${routes.map((r, i) => ` + + `).join('')} +
+ `; + } + + // --- Letzte bekannte Position (für Geocode-Bias) --- + function rememberPosition(lat, lon) { + lastKnownLat = lat; + lastKnownLon = lon; + storageSet(STORAGE_KEYS.lastPosition, { lat, lon, time: Date.now() }); + } + + function restorePosition() { + const pos = storageGet(STORAGE_KEYS.lastPosition); + // Nur nutzen, wenn jünger als 1 Stunde (sonst evtl. ganz woanders) + if (pos && Date.now() - pos.time < 60 * 60 * 1000) { + lastKnownLat = pos.lat; + lastKnownLon = pos.lon; + } + } + + // --- localStorage-Aufräumung beim App-Start --- + // Abfahrten-Caches und Geocode-Mappings wuchsen bisher unbegrenzt. + function cleanupStorage() { + const now = Date.now(); + const WEEK = 7 * 24 * 60 * 60 * 1000; + const MONTH = 30 * 24 * 60 * 60 * 1000; + const toDelete = []; + + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(STORAGE_KEYS.depCachePrefix)) { + const entry = storageGet(key); + if (!entry || !entry.time || now - entry.time > WEEK) toDelete.push(key); + } else if (key.startsWith(STORAGE_KEYS.stopMapPrefix)) { + const entry = storageGet(key); + // Altes Format (reiner String, ohne Zeitstempel) -> migrieren + if (typeof entry === 'string') { + storageSet(key, { id: entry, time: now }); + } else if (!entry || !entry.time || now - entry.time > MONTH) { + toDelete.push(key); + } + } + } + toDelete.forEach(k => localStorage.removeItem(k)); + if (toDelete.length > 0) { + console.warn(`Storage-Aufräumung: ${toDelete.length} alte Einträge entfernt`); + } + } catch (e) { /* localStorage nicht verfügbar */ } + } + // --- Abfahrten-Cache für Offline-Modus --- function saveDeparturesCache(stationId, departures) { storageSet(STORAGE_KEYS.depCachePrefix + stationId, { diff --git a/js/app.js b/js/app.js index ab8f348..0171dd0 100644 --- a/js/app.js +++ b/js/app.js @@ -382,6 +382,7 @@ }); const { latitude, longitude } = position.coords; + rememberPosition(latitude, longitude); // für Geocode-Bias merken stationSearch.placeholder = '📍 Suche Stationen in der Nähe...'; @@ -1229,6 +1230,7 @@ }); const { latitude, longitude } = position.coords; + rememberPosition(latitude, longitude); // für Geocode-Bias merken const locations = await transitousNearby(latitude, longitude); const stations = locations.filter(loc => @@ -1271,6 +1273,13 @@ return; } + runJourneySearch(); + }); + + // Cursor der letzten Suche (für "Früher"/"Später"-Blättern) + let journeyPageCursors = { prev: null, next: null }; + + async function runJourneySearch(pageCursor = null) { journeyContainer.innerHTML = '
⏳ Suche Verbindungen...
'; try { @@ -1280,9 +1289,15 @@ const data = await transitousJourneys(journeyFromStation, journeyToStation, { timeISO: (tIn && tIn.value) ? new Date(tIn.value).toISOString() : null, arriveBy: tMode ? tMode.value === 'arrival' : false, - products: activeTransportModes + products: activeTransportModes, + pageCursor }); + journeyPageCursors = { + prev: data.previousPageCursor, + next: data.nextPageCursor + }; + // Route für den nächsten App-Start merken storageSet(STORAGE_KEYS.lastJourney, { from: journeyFromStation, @@ -1302,6 +1317,29 @@
`; } + } + + // "Früher"/"Später"-Blättern (Event-Delegation, Buttons werden + // bei jedem Render neu erzeugt) + journeyContainer.addEventListener('click', (e) => { + // "Route merken"-Toggle + const saveBtn = e.target.closest('#saveRouteBtn'); + if (saveBtn) { + const nowFav = toggleFavRoute(journeyFromStation, journeyToStation); + saveBtn.textContent = nowFav ? '⭐ Route gemerkt ✓' : '☆ Route merken'; + saveBtn.classList.toggle('active', nowFav); + saveBtn.setAttribute('aria-pressed', String(nowFav)); + if (navigator.vibrate) navigator.vibrate(10); + return; + } + + const pagerBtn = e.target.closest('.journey-pager-btn'); + if (pagerBtn && pagerBtn.dataset.cursor) { + if (navigator.vibrate) navigator.vibrate(10); + runJourneySearch(pagerBtn.dataset.cursor); + // Nach oben scrollen, damit man die neuen Ergebnisse sieht + journeyContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } }); function displayJourneys(journeys) { @@ -1317,6 +1355,28 @@ return; } + const favRouteActive = isFavRoute(journeyFromStation, journeyToStation); + const saveRouteHTML = ` + + `; + + const pagerHTML = (pos) => ` + ${pos === 'top' ? saveRouteHTML : ''} +
+ ${journeyPageCursors.prev ? ` + ` : ''} + ${journeyPageCursors.next ? ` + ` : ''} +
+ `; + const html = journeys.map((journey, journeyIndex) => { const departure = new Date(journey.legs[0].plannedDeparture || journey.legs[0].departure); const arrival = new Date(journey.legs[journey.legs.length - 1].plannedArrival || journey.legs[journey.legs.length - 1].arrival); @@ -1480,7 +1540,7 @@ `; }).join(''); - journeyContainer.innerHTML = html; + journeyContainer.innerHTML = pagerHTML('top') + html + pagerHTML('bottom'); // Click-Handler für Journey Results journeyContainer.querySelectorAll('.journey-result').forEach(item => { @@ -1597,9 +1657,16 @@
${leg.stopovers && leg.stopovers.length > 0 ? ` -
- 🔄 ${leg.stopovers.length} Halte -
+
+ 🔄 ${leg.stopovers.length} Zwischenhalte anzeigen +
    + ${leg.stopovers.map(s => { + const t = s.arrival || s.plannedArrival; + const time = t ? new Date(t).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; + return `
  • ${time} ${escapeHtml(s.stop?.name || '?')}
  • `; + }).join('')} +
+
` : ''}
diff --git a/js/extras.js b/js/extras.js index bc369af..6b96deb 100644 --- a/js/extras.js +++ b/js/extras.js @@ -425,8 +425,29 @@ document.addEventListener('DOMContentLoaded', () => { devCard.addEventListener('click', () => switchView('developer')); } - // Favoriten auf dem Home-Screen anzeigen + // Alte Cache-Einträge aufräumen + letzte Position wiederherstellen + cleanupStorage(); + restorePosition(); + + // Favoriten (Stationen + Routen) rendern renderFavorites(); + renderRouteFavorites(); + + // Routen-Chip -> Route laden und sofort suchen + document.addEventListener('click', (e) => { + const chip = e.target.closest('.route-chip'); + if (!chip) return; + const route = getFavRoutes()[parseInt(chip.dataset.routeIndex, 10)]; + if (!route) return; + + journeyFromStation = route.from; + journeyToStation = route.to; + journeyFrom.value = route.from.name; + journeyTo.value = route.to.name; + checkJourneySearchReady(); + if (navigator.vibrate) navigator.vibrate(10); + runJourneySearch(); + }); // Letzte Station wiederherstellen (OHNE sofortigen API-Call - // geladen wird erst, wenn die Abfahrten-View geöffnet wird) diff --git a/js/livemap.js b/js/livemap.js index 47ba1fa..f9547b7 100644 --- a/js/livemap.js +++ b/js/livemap.js @@ -230,6 +230,7 @@ // die lokale Animation zwischenspeichern liveMapSegments = await transitousRadarSegments(bounds, zoom); renderVehiclesFromSegments(); + updateMapStops(); // Stations-Punkte im selben Takt pflegen } catch (error) { console.error('Load vehicles error:', error); @@ -242,6 +243,164 @@ } } + // ========================================== + // EIGENER STANDORT AUF DER KARTE + // 📍-Button: blauer Punkt + Karte zentrieren + // ========================================== + let userLocationMarker = null; + + document.addEventListener('DOMContentLoaded', () => { + const locateBtn = document.getElementById('locateBtn'); + if (!locateBtn) return; + + locateBtn.addEventListener('click', () => { + if (!navigator.geolocation || !liveMap) return; + locateBtn.textContent = '⏳'; + + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude } = position.coords; + rememberPosition(latitude, longitude); + + // Blauer Punkt (wird bei erneutem Tap nur bewegt) + if (userLocationMarker) { + userLocationMarker.setLatLng([latitude, longitude]); + } else { + userLocationMarker = L.circleMarker([latitude, longitude], { + radius: 8, + color: '#FFFFFF', + weight: 3, + fillColor: '#2A93EE', + fillOpacity: 1 + }).addTo(liveMap); + } + + liveMap.setView([latitude, longitude], Math.max(liveMap.getZoom(), 15)); + locateBtn.textContent = '📍'; + if (navigator.vibrate) navigator.vibrate(10); + }, + (error) => { + console.warn('Standort nicht verfügbar:', error.message); + locateBtn.textContent = '📍'; + const countEl = document.getElementById('vehicleCount'); + if (countEl) countEl.textContent = '⚠️ Standort nicht verfügbar'; + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } + ); + }); + }); + + // ========================================== + // STATIONS-PUNKTE AUF DER KARTE (ab Zoom 15) + // Tap auf einen Punkt -> Popup mit den nächsten 3 Abfahrten + // (1 stoptimes-Request, 15s-Cache) + Sprung zur Abfahrten-View. + // ========================================== + const STOPS_MIN_ZOOM = 15; + let stopMarkerMap = new Map(); // stopId -> marker + + async function updateMapStops() { + if (!liveMap) return; + + // Bei niedrigem Zoom oder im Fokus-Modus: Punkte ausblenden + const zoom = liveMap.getZoom ? liveMap.getZoom() : 12; + if (zoom < STOPS_MIN_ZOOM || focusedVehicleTripId) { + clearMapStops(); + return; + } + + const b = liveMap.getBounds(); + let stops; + try { + stops = await transitousMapStops({ + north: b.getNorth(), south: b.getSouth(), + west: b.getWest(), east: b.getEast() + }); + } catch (e) { + return; // Punkte sind nice-to-have - Fehler still schlucken + } + + const seen = new Set(); + for (const stop of stops) { + seen.add(stop.id); + if (stopMarkerMap.has(stop.id)) continue; + + const marker = L.circleMarker([stop.lat, stop.lon], { + radius: 6, + color: '#FFED00', + weight: 2, + fillColor: '#111', + fillOpacity: 0.9 + }); + marker.bindPopup(stopPopupHTML(stop, null), { minWidth: 220 }); + marker.on('popupopen', () => loadStopPopupDepartures(stop, marker)); + marker.addTo(liveMap); + stopMarkerMap.set(stop.id, marker); + } + + // Punkte außerhalb des Ausschnitts entfernen + for (const [id, marker] of stopMarkerMap) { + if (!seen.has(id)) { + marker.remove(); + stopMarkerMap.delete(id); + } + } + } + + function clearMapStops() { + for (const [, marker] of stopMarkerMap) marker.remove(); + stopMarkerMap.clear(); + } + + function stopPopupHTML(stop, departures) { + let depsHTML = '
⏳ Lade Abfahrten…
'; + if (departures) { + depsHTML = departures.length === 0 + ? '
Keine Abfahrten in Kürze
' + : departures.map(dep => { + const mins = Math.max(0, Math.round((new Date(dep.when) - Date.now()) / 60000)); + const color = dep.line?.color || getBVGLineColor(dep.line?.name || ''); + return ` +
+ ${escapeHtml(dep.line?.name || '?')} + ${escapeHtml(dep.direction || '')} + ${mins === 0 ? 'Jetzt' : mins + ' min'} +
`; + }).join(''); + } + return ` +
+
🚏 ${escapeHtml(stop.name)}
+ ${depsHTML} + +
`; + } + + async function loadStopPopupDepartures(stop, marker) { + try { + const data = await transitousDepartures( + { id: stop.id, name: stop.name, lat: stop.lat, lon: stop.lon }, 3 + ); + marker.setPopupContent(stopPopupHTML(stop, (data.departures || []).slice(0, 3))); + } catch (e) { + marker.setPopupContent(stopPopupHTML(stop, [])); + } + } + + // "Alle Abfahrten" im Stations-Popup -> Abfahrten-View + document.addEventListener('click', (e) => { + const btn = e.target.closest('.map-stop-open'); + if (!btn) return; + switchView('departures'); + selectStation( + btn.dataset.id, btn.dataset.name, + btn.dataset.lat ? parseFloat(btn.dataset.lat) : null, + btn.dataset.lon ? parseFloat(btn.dataset.lon) : null + ); + }); + // ========================================== // STRECKEN-ANZEIGE BEIM FAHRZEUG-KLICK // Die Polylines liegen bereits dekodiert in liveMapSegments - @@ -265,8 +424,9 @@ }).addTo(liveMap); } - // Fokus-Modus aktivieren: andere Fahrzeuge ausblenden + // Fokus-Modus aktivieren: andere Fahrzeuge + Stations-Punkte ausblenden renderVehiclesFromSegments(); + clearMapStops(); // 2) ...dann die KOMPLETTE Route nachladen (map/trips liefert nur // das Segment im Ausschnitt; die ganze Strecke steckt in /trip). @@ -295,6 +455,7 @@ if (focusedVehicleTripId) { focusedVehicleTripId = null; renderVehiclesFromSegments(); // alle Fahrzeuge wieder zeigen + updateMapStops(); // Stations-Punkte wiederherstellen } } diff --git a/js/transitous.js b/js/transitous.js index 95ece79..935ae55 100644 --- a/js/transitous.js +++ b/js/transitous.js @@ -66,6 +66,15 @@ function motisDelaySeconds(actual, scheduled) { return Number.isFinite(diff) ? Math.round(diff) : null; } +// Geocode-Bias: letzte bekannte GPS-Position bevorzugen (bessere +// Trefferreihenfolge in Brandenburg), sonst Berlin-Mitte als Default +function geocodeBias() { + if (lastKnownLat != null && lastKnownLon != null) { + return `${lastKnownLat.toFixed(3)},${lastKnownLon.toFixed(3)}`; + } + return '52.52,13.41'; +} + // GTFS-Farbe aus der API normalisieren ('DA421E' -> '#DA421E') // Das sind die OFFIZIELLEN VBB-Linienfarben aus dem GTFS-Feed - // kein Extra-Request nötig, sie stecken in stoptimes/plan/map-trips. @@ -95,7 +104,7 @@ async function transitousLocations(query) { // place=Berlin-Mitte als Bias, damit VBB-Gebiet zuerst kommt // 6h Cache: Stationsnamen ändern sich praktisch nie const matches = await transitousFetch( - `/v1/geocode?text=${encodeURIComponent(query)}&type=STOP&place=52.52,13.41&numResults=10`, + `/v1/geocode?text=${encodeURIComponent(query)}&type=STOP&place=${geocodeBias()}&numResults=10`, 6 * 60 * 60 * 1000 ); @@ -119,16 +128,19 @@ async function transitousStopQuery({ id, name, lat, lon }) { return `center=${lat},${lon}&radius=250`; } // Nur Name bekannt (alte Favoriten): einmalig geocoden, Mapping cachen - const cacheKey = 'vbb_transitous_stop_' + name; + const cacheKey = STORAGE_KEYS.stopMapPrefix + name; const cached = storageGet(cacheKey); - if (cached) return 'stopId=' + encodeURIComponent(cached); + // Neues Format {id, time}; altes Format (String) wird von + // cleanupStorage beim Start migriert, hier zur Sicherheit beides lesen + const cachedId = cached && (typeof cached === 'string' ? cached : cached.id); + if (cachedId) return 'stopId=' + encodeURIComponent(cachedId); const matches = await transitousFetch( - `/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=52.52,13.41&numResults=1`, + `/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=${geocodeBias()}&numResults=1`, 6 * 60 * 60 * 1000 ); if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden'); - storageSet(cacheKey, matches[0].id); + storageSet(cacheKey, { id: matches[0].id, time: Date.now() }); return 'stopId=' + encodeURIComponent(matches[0].id); } @@ -181,14 +193,14 @@ async function transitousPlace(station) { return `${station.lat},${station.lon}`; } const matches = await transitousFetch( - `/v1/geocode?text=${encodeURIComponent(station.name)}&type=STOP&place=52.52,13.41&numResults=1`, + `/v1/geocode?text=${encodeURIComponent(station.name)}&type=STOP&place=${geocodeBias()}&numResults=1`, 6 * 60 * 60 * 1000 ); if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden'); return matches[0].id; } -async function transitousJourneys(fromStation, toStation, { timeISO = null, arriveBy = false, products = null } = {}) { +async function transitousJourneys(fromStation, toStation, { timeISO = null, arriveBy = false, products = null, pageCursor = null } = {}) { const fromPlace = await transitousPlace(fromStation); const toPlace = await transitousPlace(toStation); @@ -197,6 +209,8 @@ async function transitousJourneys(fromStation, toStation, { timeISO = null, arri `&maxTransfers=3&minTransferTime=5`; if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`; if (products) url += `&transitModes=${productsToMotisModes(products)}`; + // Blättern: Cursor aus der vorherigen Antwort ("Früher"/"Später") + if (pageCursor) url += `&pageCursor=${encodeURIComponent(pageCursor)}`; // 30s Cache: identische Suche direkt hintereinander = 1 Request const data = await transitousFetch(url, 30000); @@ -220,12 +234,22 @@ async function transitousJourneys(fromStation, toStation, { timeISO = null, arri arrival: leg.endTime, plannedArrival: leg.scheduledEndTime, departurePlatform: leg.from?.track || null, - arrivalPlatform: leg.to?.track || null + arrivalPlatform: leg.to?.track || null, + // Zwischenhalte (kamen bisher mit und wurden weggeworfen) + stopovers: (leg.intermediateStops || []).map(s => ({ + stop: { name: s.name }, + arrival: s.arrival || s.departure || null, + plannedArrival: s.scheduledArrival || s.scheduledDeparture || null + })) }; }) })); - return { journeys }; + return { + journeys, + previousPageCursor: data.previousPageCursor || null, + nextPageCursor: data.nextPageCursor || null + }; } @@ -294,6 +318,24 @@ async function transitousTrip(tripId) { return { trip: { stopovers } }; } +// Stationen im Kartenausschnitt (für die Stations-Punkte auf der Live-Map). +// Koordinaten gerundet -> stabile Cache-Keys, 5min TTL. +async function transitousMapStops(bounds) { + const stops = await transitousFetch( + `/v6/map/stops?min=${bounds.south.toFixed(3)},${bounds.west.toFixed(3)}` + + `&max=${bounds.north.toFixed(3)},${bounds.east.toFixed(3)}`, + 5 * 60 * 1000 + ); + return (stops || []) + .filter(s => s.stopId && s.lat != null && s.lon != null) + .map(s => ({ + id: TRANSITOUS_ID_PREFIX + s.stopId, + name: s.name, + lat: s.lat, + lon: s.lon + })); +} + // Kompletter Streckenverlauf einer Fahrt (für die Live-Map). // map/trips liefert nur das Segment im Kartenausschnitt - die GANZE // Route steckt in der legGeometry des /trip-Endpoints. Gleiche URL wie diff --git a/package.json b/package.json index a7299af..79db214 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vbb-netz-status", - "version": "37.2.0", + "version": "37.4.0", "description": "Live-Nahverkehr für Berlin & Brandenburg - Progressive Web App (PWA) für Echtzeit-Abfahrten, Routenplanung und Live-Fahrzeugverfolgung im VBB-Gebiet", "main": "server.js", "scripts": { diff --git a/service-worker.js b/service-worker.js index 731f15e..56fb58c 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'vbb-status-v37.2.0'; +const CACHE_NAME = 'vbb-status-v37.4.0'; // App-Shell: nur Dateien, die wirklich existieren! // (Der alte SW listete eine nicht existierende Font-Datei – dadurch diff --git a/styles.css b/styles.css index 244f86a..44df37e 100644 --- a/styles.css +++ b/styles.css @@ -4067,3 +4067,146 @@ select:focus-visible { border-style: dashed; opacity: 0.85; } + +/* ========================================== + v37.3: Blätter-Buttons, Zwischenhalte, Stations-Popups + ========================================== */ + +/* "Früher"/"Später"-Blättern in der Routensuche */ +.journey-pager { + display: flex; + justify-content: space-between; + gap: 10px; + margin: 12px 0; +} + +.journey-pager-btn { + background: rgba(255, 237, 0, 0.08); + border: 1px dashed rgba(255, 237, 0, 0.4); + color: #FFED00; + padding: 10px 14px; + border-radius: 8px; + font-family: var(--font-stack); + font-size: 13px; + cursor: pointer; + transition: background 0.2s ease; +} + +.journey-pager-btn:hover, +.journey-pager-btn:focus-visible { + background: rgba(255, 237, 0, 0.15); + border-style: solid; + outline: none; +} + +/* Ausklappbare Zwischenhalte (natives
) */ +.journey-detail-intermediate { + margin: 8px 0 8px 12px; +} + +.journey-detail-intermediate summary { + cursor: pointer; + opacity: 0.65; + font-size: 12px; + padding: 4px 0; + list-style-position: inside; +} + +.journey-detail-intermediate summary:hover, +.journey-detail-intermediate summary:focus-visible { + opacity: 1; + color: #FFED00; + outline: none; +} + +.intermediate-stops-list { + list-style: none; + margin: 6px 0 4px 6px; + padding: 0; + border-left: 2px dotted rgba(255, 255, 255, 0.25); +} + +.intermediate-stops-list li { + padding: 4px 0 4px 12px; + font-size: 13px; + opacity: 0.85; +} + +.intermediate-time { + display: inline-block; + min-width: 44px; + font-variant-numeric: tabular-nums; + opacity: 0.7; +} + +/* Stations-Popups auf der Live-Map */ +.stop-popup-name { + font-weight: bold; + margin-bottom: 8px; +} + +.stop-popup-dep { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; + font-size: 13px; +} + +.stop-popup-line { + padding: 2px 8px; + border-radius: 4px; + font-weight: bold; + font-size: 12px; + flex-shrink: 0; +} + +.stop-popup-dir { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stop-popup-min { + font-weight: bold; + flex-shrink: 0; +} + +.stop-popup-loading { + opacity: 0.6; + font-size: 12px; + padding: 6px 0; +} + +.map-stop-open { + display: block; + width: 100%; + margin-top: 10px; + padding: 8px; + background: #FFED00; + color: #000; + border: none; + border-radius: 6px; + font-family: var(--font-stack); + font-weight: bold; + font-size: 13px; + cursor: pointer; +} + +/* v37.4: Route-merken-Button + Standort-Button */ +.save-route-btn { + display: block; + width: 100%; + text-align: center; + margin-bottom: 4px; +} + +.save-route-btn.active { + background: rgba(255, 237, 0, 0.2); + border-style: solid; +} + +.locate-btn { + font-size: 15px; +} diff --git a/tests/adapter.test.js b/tests/adapter.test.js index ec55e23..846fba3 100644 --- a/tests/adapter.test.js +++ b/tests/adapter.test.js @@ -18,7 +18,7 @@ const fixtures = { mode: 'SUBWAY', realTime: true, headsign: 'U Ruhleben', routeShortName: 'U2', tripId: 'trip123', agencyName: 'BVG', cancelled: false, tripCancelled: false }], place: {}, previousPageCursor: '', nextPageCursor: '' }, - plan: { itineraries: [{ + plan: { previousPageCursor: 'CURSOR_PREV', nextPageCursor: 'CURSOR_NEXT', itineraries: [{ duration: 1200, startTime: '2026-07-06T10:00:00+02:00', endTime: '2026-07-06T10:20:00+02:00', transfers: 0, id: 'it1', legs: [ { mode: 'WALK', from: { name: 'Start', lat: 0, lon: 0 }, to: { name: 'S Südkreuz', lat: 0, lon: 0 }, @@ -28,7 +28,11 @@ const fixtures = { { mode: 'SUBURBAN', from: { name: 'S Südkreuz', track: '2', lat: 0, lon: 0 }, to: { name: 'S+U Alexanderplatz', lat: 0, lon: 0 }, startTime: '2026-07-06T10:06:00+02:00', endTime: '2026-07-06T10:20:00+02:00', scheduledStartTime: '2026-07-06T10:05:00+02:00', scheduledEndTime: '2026-07-06T10:19:00+02:00', - realTime: true, scheduled: false, duration: 840, routeShortName: 'S2', headsign: 'S Bernau', legGeometry: {} } + realTime: true, scheduled: false, duration: 840, routeShortName: 'S2', headsign: 'S Bernau', legGeometry: {}, + intermediateStops: [ + { name: 'S Yorckstr.', stopId: 'Y1', arrival: '2026-07-06T10:09:00+02:00', scheduledArrival: '2026-07-06T10:08:00+02:00' }, + { name: 'S Anhalter Bf', stopId: 'A1', arrival: '2026-07-06T10:12:00+02:00', scheduledArrival: '2026-07-06T10:11:00+02:00' } + ] } ] }] } }; @@ -89,12 +93,14 @@ global.fetch = async (url) => { // api.js-Ausschnitte + Adapter in EINEM Scope evaluieren const combined = - api.slice(api.indexOf('const API_SOFT_LIMIT'), api.indexOf('/**')) + 'let lastKnownLat = null, lastKnownLon = null;' + + api.slice(api.indexOf('const STORAGE_KEYS'), api.indexOf('function storageGet')) + + api.slice(api.indexOf('const API_SOFT_LIMIT'), api.indexOf('/**')) + api.slice(api.indexOf('async function apiFetch'), api.indexOf('// ==========================================\n // GLOBALES')) + api.slice(api.indexOf('function showApiOutageBanner'), api.indexOf('// HTML-Escaping')) + api.slice(api.indexOf('function storageGet'), api.indexOf('// --- Favoriten ---')) + adapter - + ';global.__a = { transitousLocations, transitousDepartures, transitousJourneys, motisModeToProduct, productsToMotisModes, transitousNearby, transitousTrip, transitousRadarSegments, segmentPositionAt, decodePolyline5, apiCache };'; + + ';global.__a = { transitousLocations, transitousDepartures, transitousJourneys, motisModeToProduct, productsToMotisModes, transitousNearby, transitousTrip, transitousRadarSegments, segmentPositionAt, decodePolyline5, apiCache, transitousMapStops };global.__t = (c) => eval(c);'; eval(combined); const A = global.__a; @@ -171,6 +177,41 @@ const A = global.__a; t('Interpolation: außerhalb des Zeitfensters -> null', A.segmentPositionAt(segs[0], Date.now() + 120000) === null); + // 10) Blätter-Cursor: rein in die URL, raus aus der Antwort + A.apiCache.clear(); + requestedUrls.length = 0; + const jPage = await A.transitousJourneys( + { id: 'x', name: 'Start', lat: 52.47, lon: 13.36 }, + { id: 'y', name: 'Ziel', lat: 52.52, lon: 13.41 }, + { pageCursor: 'CURSOR_NEXT' } + ); + const pagedUrl = requestedUrls.find(u => u.includes('/plan')); + t('pageCursor in plan-URL', pagedUrl.includes('pageCursor=CURSOR_NEXT')); + t('Cursor aus Antwort normalisiert', jPage.previousPageCursor === 'CURSOR_PREV' && jPage.nextPageCursor === 'CURSOR_NEXT'); + + // 11) Zwischenhalte im Leg + const legWithStops = jPage.journeys[0].legs[1]; + t('Zwischenhalte gemappt (2 Stück, Name + Zeit)', + legWithStops.stopovers.length === 2 && + legWithStops.stopovers[0].stop.name === 'S Yorckstr.' && + legWithStops.stopovers[0].arrival.includes('10:09')); + + // 12) map/stops normalisiert (transitous:-Präfix + Koordinaten) + const mapStops = await A.transitousMapStops({ north: 52.55, south: 52.53, west: 13.33, east: 13.36 }); + t('map/stops -> id-Präfix + Koordinaten', + mapStops.length === 2 && mapStops[0].id.startsWith('transitous:') && mapStops[0].lat > 52); + + // 13) Geocode-Bias: ohne Position Berlin-Mitte, mit Position deren Koordinaten + A.apiCache.clear(); + requestedUrls.length = 0; + await A.transitousLocations('teststr'); + t('Bias-Default Berlin-Mitte', requestedUrls[0].includes('place=52.52,13.41')); + global.__t('lastKnownLat = 52.401; lastKnownLon = 13.052;'); // Potsdam + A.apiCache.clear(); + requestedUrls.length = 0; + await A.transitousLocations('teststr2'); + t('Bias nutzt letzte GPS-Position', requestedUrls[0].includes('place=52.401,13.052')); + console.log(fails === 0 ? '\n🎉 TRANSITOUS-ADAPTER-TESTS BESTANDEN' : `\n❌ ${fails} fehlgeschlagen`); process.exit(fails ? 1 : 0); })(); diff --git a/tests/smoke.test.js b/tests/smoke.test.js index 8de63df..367ad85 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -251,6 +251,26 @@ setTimeout(() => { if (homeItems.length !== 3 || !moreBtn) errors.push('Startseite nicht kompakt (Top 3 + Alle anzeigen)'); else console.log('✅ Startseite kompakt: Top 3 + "Alle anzeigen"-Button'); + // 14) Routen-Favoriten: merken -> Chip erscheint -> Chip füllt Felder + sucht + lastJourneyUrl = null; + window.__t(` + journeyFromStation = { id: 'rA', name: 'S Pendlerheim (Berlin)', lat: 52.4, lon: 13.2 }; + journeyToStation = { id: 'rB', name: 'U Arbeitsplatz (Berlin)', lat: 52.5, lon: 13.4 }; + toggleFavRoute(journeyFromStation, journeyToStation); + `); + const routeChip = doc.querySelector('#journeyFavorites .route-chip'); + if (!routeChip) errors.push('Routen-Chip erscheint nicht'); + else if (!routeChip.textContent.includes('Pendlerheim → Arbeitsplatz')) errors.push('Routen-Chip-Kurzname falsch: ' + routeChip.textContent.trim()); + else console.log('✅ Routen-Favorit gespeichert, Chip mit Kurznamen gerendert'); + + window.__t(`journeyFromStation = null; journeyToStation = null; journeyFrom.value = ''; journeyTo.value = '';`); + routeChip?.click(); + await new Promise(r => setTimeout(r, 200)); + const filled = doc.getElementById('journeyFrom').value.includes('Pendlerheim'); + if (!filled) errors.push('Routen-Chip füllt Felder nicht'); + else if (!lastJourneyUrl) errors.push('Routen-Chip startet keine Suche'); + else console.log('✅ Routen-Chip: 1 Tap füllt Felder und sucht sofort'); + console.log('✅ Struktur-Checks abgeschlossen'); if (errors.length === 0) {