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

+118 -2
View File
@@ -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, {
+72 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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