Add files via upload
This commit is contained in:
1 parent
67a9468142
commit
97a156ce8f
12 files changed
+666
-26
No files matched your search
@@ -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
@@ -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 & Infos – <span id="homeDevVersion"></span></p>
|
<p>Changelog & 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>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
})();
|
})();
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user