Add files via upload
This commit is contained in:
1 parent
67a9468142
commit
97a156ce8f
12 files changed
+666
-26
No files matched your search
@@ -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 = `
|
||||
<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 ---
|
||||
function saveDeparturesCache(stationId, departures) {
|
||||
storageSet(STORAGE_KEYS.depCachePrefix + stationId, {
|
||||
|
||||
@@ -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 = '<div class="loading">⏳ Suche Verbindungen...</div>';
|
||||
|
||||
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 @@
|
||||
</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) {
|
||||
@@ -1317,6 +1355,28 @@
|
||||
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 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 @@
|
||||
</div>
|
||||
|
||||
${leg.stopovers && leg.stopovers.length > 0 ? `
|
||||
<div class="journey-detail-intermediate">
|
||||
<span style="opacity: 0.6; font-size: 12px;">🔄 ${leg.stopovers.length} Halte</span>
|
||||
</div>
|
||||
<details class="journey-detail-intermediate">
|
||||
<summary>🔄 ${leg.stopovers.length} Zwischenhalte anzeigen</summary>
|
||||
<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">
|
||||
|
||||
+22
-1
@@ -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)
|
||||
|
||||
+162
-1
@@ -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 = '<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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-9
@@ -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
|
||||
|
||||
Reference in new issue
Block a user