Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-07-10 11:34:25 +02:00
1 parent 67a9468142
commit 97a156ce8f
12 files changed
+666 -26

No files matched your search

+21
View File
@@ -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/), 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/). 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 ## [37.2.0] - 2026-07-07
### Favoriten-Chips in der Abfahrten-View ### Favoriten-Chips in der Abfahrten-View
+10 -2
View File
@@ -132,13 +132,14 @@
<p>Fahrzeuge in Echtzeit verfolgen</p> <p>Fahrzeuge in Echtzeit verfolgen</p>
</div> </div>
</div> </div>
<div class="home-info-card"> <button class="home-info-card home-info-card-btn" id="homeDevCard"
aria-label="Entwickler-Bereich mit Changelog öffnen">
<div class="home-info-icon">👨‍💻</div> <div class="home-info-icon">👨‍💻</div>
<div class="home-info-text"> <div class="home-info-text">
<strong>Für Entwickler</strong> <strong>Für Entwickler</strong>
<p>Changelog &amp; Infos – <span id="homeDevVersion"></span></p> <p>Changelog &amp; Infos – <span id="homeDevVersion"></span></p>
</div> </div>
</div> </button>
</div> </div>
@@ -205,6 +206,10 @@
<!-- View: Routenplaner --> <!-- View: Routenplaner -->
<div class="view-container" id="view-journey"> <div class="view-container" id="view-journey">
<div class="container"> <div class="container">
<!-- Gespeicherte Routen: 1-Tap-Suche für Pendler -->
<div class="departure-favorites" id="journeyFavorites" style="display: none;"
role="group" aria-label="Gespeicherte Routen"></div>
<div class="controls"> <div class="controls">
<div class="journey-inputs"> <div class="journey-inputs">
<div class="journey-input-group"> <div class="journey-input-group">
@@ -300,6 +305,9 @@
<span id="vehicleCount" aria-live="polite">🚇 Lade Fahrzeuge...</span> <span id="vehicleCount" aria-live="polite">🚇 Lade Fahrzeuge...</span>
</div> </div>
<div class="livemap-filters"> <div class="livemap-filters">
<button class="filter-btn locate-btn" id="locateBtn"
aria-label="Karte auf meinen Standort zentrieren"
title="Auf meinen Standort zentrieren">📍</button>
<button class="filter-btn active" data-type="all">🌐 Alle</button> <button class="filter-btn active" data-type="all">🌐 Alle</button>
<button class="filter-btn" data-type="subway">🚇 U-Bahn</button> <button class="filter-btn" data-type="subway">🚇 U-Bahn</button>
<button class="filter-btn" data-type="suburban">🚆 S-Bahn</button> <button class="filter-btn" data-type="suburban">🚆 S-Bahn</button>
+118 -2
View File
@@ -7,7 +7,7 @@
// ZENTRALE VERSIONSNUMMER - einzige Stelle, die bei Releases // ZENTRALE VERSIONSNUMMER - einzige Stelle, die bei Releases
// angepasst wird. Alle Anzeigen (Startseite, Entwickler-Tab) // angepasst wird. Alle Anzeigen (Startseite, Entwickler-Tab)
// lesen von hier; package.json + SW-Cache-Name manuell mitziehen. // 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'; const APP_RELEASE_DATE = '07.07.2026';
// Seit v35: Transitous (api.transitous.org) ist die einzige // Seit v35: Transitous (api.transitous.org) ist die einzige
@@ -25,6 +25,8 @@
// später geladenen Dateien angewiesen ist. // später geladenen Dateien angewiesen ist.
let currentView = 'home'; let currentView = 'home';
let departuresCount = 20; // Wie viele Abfahrten laden ("Mehr laden" erhöht) 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 currentStationLat = null; // Koordinaten der gewählten Station
let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping) let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping)
let liveMap = null; let liveMap = null;
@@ -203,9 +205,12 @@
// ========================================== // ==========================================
const STORAGE_KEYS = { const STORAGE_KEYS = {
favorites: 'vbb_favorites', favorites: 'vbb_favorites',
favRoutes: 'vbb_fav_routes',
lastStation: 'vbb_last_station', lastStation: 'vbb_last_station',
lastJourney: 'vbb_last_journey', 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) { function storageGet(key, fallback = null) {
@@ -335,6 +340,117 @@
btn.title = fav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'; 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 = `
<div class="filter-label">⭐ Deine Routen</div>
<div class="filter-chips">
${routes.map((r, i) => `
<button class="fav-chip route-chip" data-route-index="${i}"
aria-label="Route ${escapeHtml(r.from.name)} nach ${escapeHtml(r.to.name)} suchen">
${escapeHtml(shortStationName(r.from.name))} → ${escapeHtml(shortStationName(r.to.name))}
</button>
`).join('')}
</div>
`;
}
// --- 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 --- // --- Abfahrten-Cache für Offline-Modus ---
function saveDeparturesCache(stationId, departures) { function saveDeparturesCache(stationId, departures) {
storageSet(STORAGE_KEYS.depCachePrefix + stationId, { storageSet(STORAGE_KEYS.depCachePrefix + stationId, {
+72 -5
View File
@@ -382,6 +382,7 @@
}); });
const { latitude, longitude } = position.coords; const { latitude, longitude } = position.coords;
rememberPosition(latitude, longitude); // für Geocode-Bias merken
stationSearch.placeholder = '📍 Suche Stationen in der Nähe...'; stationSearch.placeholder = '📍 Suche Stationen in der Nähe...';
@@ -1229,6 +1230,7 @@
}); });
const { latitude, longitude } = position.coords; const { latitude, longitude } = position.coords;
rememberPosition(latitude, longitude); // für Geocode-Bias merken
const locations = await transitousNearby(latitude, longitude); const locations = await transitousNearby(latitude, longitude);
const stations = locations.filter(loc => const stations = locations.filter(loc =>
@@ -1271,6 +1273,13 @@
return; 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 = '<div class="loading">⏳ Suche Verbindungen...</div>'; journeyContainer.innerHTML = '<div class="loading">⏳ Suche Verbindungen...</div>';
try { try {
@@ -1280,9 +1289,15 @@
const data = await transitousJourneys(journeyFromStation, journeyToStation, { const data = await transitousJourneys(journeyFromStation, journeyToStation, {
timeISO: (tIn && tIn.value) ? new Date(tIn.value).toISOString() : null, timeISO: (tIn && tIn.value) ? new Date(tIn.value).toISOString() : null,
arriveBy: tMode ? tMode.value === 'arrival' : false, 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 // Route für den nächsten App-Start merken
storageSet(STORAGE_KEYS.lastJourney, { storageSet(STORAGE_KEYS.lastJourney, {
from: journeyFromStation, from: journeyFromStation,
@@ -1302,6 +1317,29 @@
</div> </div>
`; `;
} }
}
// "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) { function displayJourneys(journeys) {
@@ -1317,6 +1355,28 @@
return; return;
} }
const favRouteActive = isFavRoute(journeyFromStation, journeyToStation);
const saveRouteHTML = `
<button class="journey-pager-btn save-route-btn ${favRouteActive ? 'active' : ''}" id="saveRouteBtn"
aria-pressed="${favRouteActive}">
${favRouteActive ? '⭐ Route gemerkt ✓' : '☆ Route merken'}
</button>
`;
const pagerHTML = (pos) => `
${pos === 'top' ? saveRouteHTML : ''}
<div class="journey-pager journey-pager-${pos}">
${journeyPageCursors.prev ? `
<button class="journey-pager-btn" data-cursor="${escapeHtml(journeyPageCursors.prev)}">
◀ Frühere Verbindungen
</button>` : '<span></span>'}
${journeyPageCursors.next ? `
<button class="journey-pager-btn" data-cursor="${escapeHtml(journeyPageCursors.next)}">
Spätere Verbindungen ▶
</button>` : '<span></span>'}
</div>
`;
const html = journeys.map((journey, journeyIndex) => { const html = journeys.map((journey, journeyIndex) => {
const departure = new Date(journey.legs[0].plannedDeparture || journey.legs[0].departure); 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); const arrival = new Date(journey.legs[journey.legs.length - 1].plannedArrival || journey.legs[journey.legs.length - 1].arrival);
@@ -1480,7 +1540,7 @@
`; `;
}).join(''); }).join('');
journeyContainer.innerHTML = html; journeyContainer.innerHTML = pagerHTML('top') + html + pagerHTML('bottom');
// Click-Handler für Journey Results // Click-Handler für Journey Results
journeyContainer.querySelectorAll('.journey-result').forEach(item => { journeyContainer.querySelectorAll('.journey-result').forEach(item => {
@@ -1597,9 +1657,16 @@
</div> </div>
${leg.stopovers && leg.stopovers.length > 0 ? ` ${leg.stopovers && leg.stopovers.length > 0 ? `
<div class="journey-detail-intermediate"> <details class="journey-detail-intermediate">
<span style="opacity: 0.6; font-size: 12px;">🔄 ${leg.stopovers.length} Halte</span> <summary>🔄 ${leg.stopovers.length} Zwischenhalte anzeigen</summary>
</div> <ul class="intermediate-stops-list">
${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 `<li><span class="intermediate-time">${time}</span> ${escapeHtml(s.stop?.name || '?')}</li>`;
}).join('')}
</ul>
</details>
` : ''} ` : ''}
<div class="journey-detail-stop"> <div class="journey-detail-stop">
+22 -1
View File
@@ -425,8 +425,29 @@ document.addEventListener('DOMContentLoaded', () => {
devCard.addEventListener('click', () => switchView('developer')); 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(); 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 - // Letzte Station wiederherstellen (OHNE sofortigen API-Call -
// geladen wird erst, wenn die Abfahrten-View geöffnet wird) // geladen wird erst, wenn die Abfahrten-View geöffnet wird)
+162 -1
View File
@@ -230,6 +230,7 @@
// die lokale Animation zwischenspeichern // die lokale Animation zwischenspeichern
liveMapSegments = await transitousRadarSegments(bounds, zoom); liveMapSegments = await transitousRadarSegments(bounds, zoom);
renderVehiclesFromSegments(); renderVehiclesFromSegments();
updateMapStops(); // Stations-Punkte im selben Takt pflegen
} catch (error) { } catch (error) {
console.error('Load vehicles error:', 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 = '<div class="stop-popup-loading">⏳ Lade Abfahrten…</div>';
if (departures) {
depsHTML = departures.length === 0
? '<div class="stop-popup-loading">Keine Abfahrten in Kürze</div>'
: 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 `
<div class="stop-popup-dep">
<span class="stop-popup-line" style="background:${color};color:${bestTextColor(color)}">${escapeHtml(dep.line?.name || '?')}</span>
<span class="stop-popup-dir">${escapeHtml(dep.direction || '')}</span>
<span class="stop-popup-min">${mins === 0 ? 'Jetzt' : mins + ' min'}</span>
</div>`;
}).join('');
}
return `
<div class="stop-popup">
<div class="stop-popup-name">🚏 ${escapeHtml(stop.name)}</div>
${depsHTML}
<button class="map-stop-open" data-id="${escapeHtml(stop.id)}" data-name="${escapeHtml(stop.name)}"
data-lat="${stop.lat}" data-lon="${stop.lon}">
Alle Abfahrten →
</button>
</div>`;
}
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 // STRECKEN-ANZEIGE BEIM FAHRZEUG-KLICK
// Die Polylines liegen bereits dekodiert in liveMapSegments - // Die Polylines liegen bereits dekodiert in liveMapSegments -
@@ -265,8 +424,9 @@
}).addTo(liveMap); }).addTo(liveMap);
} }
// Fokus-Modus aktivieren: andere Fahrzeuge ausblenden // Fokus-Modus aktivieren: andere Fahrzeuge + Stations-Punkte ausblenden
renderVehiclesFromSegments(); renderVehiclesFromSegments();
clearMapStops();
// 2) ...dann die KOMPLETTE Route nachladen (map/trips liefert nur // 2) ...dann die KOMPLETTE Route nachladen (map/trips liefert nur
// das Segment im Ausschnitt; die ganze Strecke steckt in /trip). // das Segment im Ausschnitt; die ganze Strecke steckt in /trip).
@@ -295,6 +455,7 @@
if (focusedVehicleTripId) { if (focusedVehicleTripId) {
focusedVehicleTripId = null; focusedVehicleTripId = null;
renderVehiclesFromSegments(); // alle Fahrzeuge wieder zeigen renderVehiclesFromSegments(); // alle Fahrzeuge wieder zeigen
updateMapStops(); // Stations-Punkte wiederherstellen
} }
} }
+51 -9
View File
@@ -66,6 +66,15 @@ function motisDelaySeconds(actual, scheduled) {
return Number.isFinite(diff) ? Math.round(diff) : null; 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') // GTFS-Farbe aus der API normalisieren ('DA421E' -> '#DA421E')
// Das sind die OFFIZIELLEN VBB-Linienfarben aus dem GTFS-Feed - // Das sind die OFFIZIELLEN VBB-Linienfarben aus dem GTFS-Feed -
// kein Extra-Request nötig, sie stecken in stoptimes/plan/map-trips. // 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 // place=Berlin-Mitte als Bias, damit VBB-Gebiet zuerst kommt
// 6h Cache: Stationsnamen ändern sich praktisch nie // 6h Cache: Stationsnamen ändern sich praktisch nie
const matches = await transitousFetch( 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 6 * 60 * 60 * 1000
); );
@@ -119,16 +128,19 @@ async function transitousStopQuery({ id, name, lat, lon }) {
return `center=${lat},${lon}&radius=250`; return `center=${lat},${lon}&radius=250`;
} }
// Nur Name bekannt (alte Favoriten): einmalig geocoden, Mapping cachen // 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); 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( 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 6 * 60 * 60 * 1000
); );
if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden'); 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); return 'stopId=' + encodeURIComponent(matches[0].id);
} }
@@ -181,14 +193,14 @@ async function transitousPlace(station) {
return `${station.lat},${station.lon}`; return `${station.lat},${station.lon}`;
} }
const matches = await transitousFetch( 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 6 * 60 * 60 * 1000
); );
if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden'); if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden');
return matches[0].id; 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 fromPlace = await transitousPlace(fromStation);
const toPlace = await transitousPlace(toStation); const toPlace = await transitousPlace(toStation);
@@ -197,6 +209,8 @@ async function transitousJourneys(fromStation, toStation, { timeISO = null, arri
`&maxTransfers=3&minTransferTime=5`; `&maxTransfers=3&minTransferTime=5`;
if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`; if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`;
if (products) url += `&transitModes=${productsToMotisModes(products)}`; 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 // 30s Cache: identische Suche direkt hintereinander = 1 Request
const data = await transitousFetch(url, 30000); const data = await transitousFetch(url, 30000);
@@ -220,12 +234,22 @@ async function transitousJourneys(fromStation, toStation, { timeISO = null, arri
arrival: leg.endTime, arrival: leg.endTime,
plannedArrival: leg.scheduledEndTime, plannedArrival: leg.scheduledEndTime,
departurePlatform: leg.from?.track || null, 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 } }; 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). // Kompletter Streckenverlauf einer Fahrt (für die Live-Map).
// map/trips liefert nur das Segment im Kartenausschnitt - die GANZE // map/trips liefert nur das Segment im Kartenausschnitt - die GANZE
// Route steckt in der legGeometry des /trip-Endpoints. Gleiche URL wie // Route steckt in der legGeometry des /trip-Endpoints. Gleiche URL wie
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vbb-netz-status", "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", "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", "main": "server.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -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! // App-Shell: nur Dateien, die wirklich existieren!
// (Der alte SW listete eine nicht existierende Font-Datei – dadurch // (Der alte SW listete eine nicht existierende Font-Datei – dadurch
+143
View File
@@ -4067,3 +4067,146 @@ select:focus-visible {
border-style: dashed; border-style: dashed;
opacity: 0.85; 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 <details>) */
.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;
}
+45 -4
View File
@@ -18,7 +18,7 @@ const fixtures = {
mode: 'SUBWAY', realTime: true, headsign: 'U Ruhleben', routeShortName: 'U2', tripId: 'trip123', mode: 'SUBWAY', realTime: true, headsign: 'U Ruhleben', routeShortName: 'U2', tripId: 'trip123',
agencyName: 'BVG', cancelled: false, tripCancelled: false agencyName: 'BVG', cancelled: false, tripCancelled: false
}], place: {}, previousPageCursor: '', nextPageCursor: '' }, }], 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', duration: 1200, startTime: '2026-07-06T10:00:00+02:00', endTime: '2026-07-06T10:20:00+02:00', transfers: 0, id: 'it1',
legs: [ legs: [
{ mode: 'WALK', from: { name: 'Start', lat: 0, lon: 0 }, to: { name: 'S Südkreuz', lat: 0, lon: 0 }, { 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 }, { 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', 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', 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 // api.js-Ausschnitte + Adapter in EINEM Scope evaluieren
const combined = 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('async function apiFetch'), api.indexOf('// ==========================================\n // GLOBALES'))
+ api.slice(api.indexOf('function showApiOutageBanner'), api.indexOf('// HTML-Escaping')) + api.slice(api.indexOf('function showApiOutageBanner'), api.indexOf('// HTML-Escaping'))
+ api.slice(api.indexOf('function storageGet'), api.indexOf('// --- Favoriten ---')) + api.slice(api.indexOf('function storageGet'), api.indexOf('// --- Favoriten ---'))
+ adapter + 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); eval(combined);
const A = global.__a; const A = global.__a;
@@ -171,6 +177,41 @@ const A = global.__a;
t('Interpolation: außerhalb des Zeitfensters -> null', t('Interpolation: außerhalb des Zeitfensters -> null',
A.segmentPositionAt(segs[0], Date.now() + 120000) === 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`); console.log(fails === 0 ? '\n🎉 TRANSITOUS-ADAPTER-TESTS BESTANDEN' : `\n❌ ${fails} fehlgeschlagen`);
process.exit(fails ? 1 : 0); process.exit(fails ? 1 : 0);
})(); })();
+20
View File
@@ -251,6 +251,26 @@ setTimeout(() => {
if (homeItems.length !== 3 || !moreBtn) errors.push('Startseite nicht kompakt (Top 3 + Alle anzeigen)'); if (homeItems.length !== 3 || !moreBtn) errors.push('Startseite nicht kompakt (Top 3 + Alle anzeigen)');
else console.log('✅ Startseite kompakt: Top 3 + "Alle anzeigen"-Button'); 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'); console.log('✅ Struktur-Checks abgeschlossen');
if (errors.length === 0) { if (errors.length === 0) {