Add files via upload
This commit is contained in:
1 parent
33f3483055
commit
45ee14c25a
12 files changed
+3565
-151
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// VBB Netz Status - API-Schicht, Rate-Limiting, Cache, Persistenz, Favoriten
|
||||
|
||||
// ==========================================
|
||||
// VBB NETZ STATUS - Haupt-Script
|
||||
// ==========================================
|
||||
|
||||
const API_BASE = 'https://v6.vbb.transport.rest';
|
||||
let currentStationId = null;
|
||||
let currentStationName = null;
|
||||
let searchTimeout = null;
|
||||
let touchStartY = 0;
|
||||
let isPulling = false;
|
||||
let autoRefreshInterval = null;
|
||||
|
||||
// --- Geteilter App-Zustand (von mehreren Modulen genutzt) ---
|
||||
// Zentral hier deklariert, damit KEIN Modul auf Variablen aus
|
||||
// später geladenen Dateien angewiesen ist.
|
||||
let currentView = 'home';
|
||||
let liveMap = null;
|
||||
let liveMapUpdateInterval = null;
|
||||
let activeVehicleTypes = new Set(['all']);
|
||||
let debounceTimer = null;
|
||||
let lastVehicleMovements = []; // Letzte Radar-Daten für lokales Filtern
|
||||
const vehicleMarkerMap = new Map(); // tripId -> { marker, lineName }
|
||||
let activeTransportModes = new Set(['subway', 'suburban', 'tram', 'bus', 'regional', 'express']);
|
||||
|
||||
// ==========================================
|
||||
// ZENTRALE API-SCHICHT
|
||||
// - Rate-Limit: max. 90 Requests/Minute (API erlaubt 100, wir lassen Puffer)
|
||||
// - Response-Cache mit TTL pro Endpoint (spart massiv Requests)
|
||||
// - Dedupe: identische parallele Requests werden nur 1x gefeuert
|
||||
// ==========================================
|
||||
const API_SOFT_LIMIT = 90; // Max. Requests pro Minute (Budget)
|
||||
const apiCallTimestamps = []; // Zeitstempel echter Requests
|
||||
const apiCache = new Map(); // url -> { data, time, ttl }
|
||||
const apiInflight = new Map(); // url -> Promise (laufende Requests)
|
||||
const API_CACHE_MAX_ENTRIES = 300; // Speicher begrenzen
|
||||
|
||||
function pruneApiTimestamps() {
|
||||
const cutoff = Date.now() - 60000;
|
||||
while (apiCallTimestamps.length && apiCallTimestamps[0] <= cutoff) {
|
||||
apiCallTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Wie viele Requests sind in der aktuellen Minute noch frei?
|
||||
function apiBudgetLeft() {
|
||||
pruneApiTimestamps();
|
||||
return API_SOFT_LIMIT - apiCallTimestamps.length;
|
||||
}
|
||||
|
||||
// Nicht-verbrauchende Prüfung (für UI-Vorabchecks)
|
||||
function canMakeApiCall() {
|
||||
return apiBudgetLeft() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentraler API-Aufruf.
|
||||
* @param {string} url - vollständige URL
|
||||
* @param {object} opts - { ttl: Cache-Dauer in ms (0 = kein Cache) }
|
||||
* @returns {Promise<any>} JSON-Daten
|
||||
* Wirft Error('RATE_LIMIT') wenn Budget aufgebraucht (Cache wird trotzdem genutzt).
|
||||
*/
|
||||
async function apiFetch(url, { ttl = 0 } = {}) {
|
||||
// 1) Frischer Cache-Treffer? -> kein Request nötig
|
||||
const cached = apiCache.get(url);
|
||||
if (cached && (Date.now() - cached.time) < cached.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 2) Läuft derselbe Request bereits? -> Promise wiederverwenden
|
||||
if (apiInflight.has(url)) {
|
||||
return apiInflight.get(url);
|
||||
}
|
||||
|
||||
// 3) Budget prüfen; wenn leer, notfalls abgelaufenen Cache liefern
|
||||
if (apiBudgetLeft() <= 0) {
|
||||
if (cached) {
|
||||
console.warn('Rate-Limit erreicht – liefere veraltete Cache-Daten für', url);
|
||||
return cached.data;
|
||||
}
|
||||
throw new Error('RATE_LIMIT');
|
||||
}
|
||||
|
||||
apiCallTimestamps.push(Date.now());
|
||||
|
||||
const promise = fetch(url)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const data = await response.json();
|
||||
if (ttl > 0) {
|
||||
// Cache-Größe begrenzen (ältesten Eintrag entfernen)
|
||||
if (apiCache.size >= API_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = apiCache.keys().next().value;
|
||||
apiCache.delete(oldestKey);
|
||||
}
|
||||
apiCache.set(url, { data, time: Date.now(), ttl });
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.finally(() => apiInflight.delete(url));
|
||||
|
||||
apiInflight.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// HTML-Escaping für alle Strings aus der API (XSS-Schutz)
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LOKALE PERSISTENZ (localStorage)
|
||||
// ==========================================
|
||||
const STORAGE_KEYS = {
|
||||
favorites: 'vbb_favorites',
|
||||
lastStation: 'vbb_last_station',
|
||||
lastJourney: 'vbb_last_journey',
|
||||
depCachePrefix: 'vbb_dep_cache_'
|
||||
};
|
||||
|
||||
function storageGet(key, fallback = null) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function storageSet(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
// Quota voll oder localStorage blockiert -> App läuft ohne Persistenz weiter
|
||||
console.warn('localStorage nicht verfügbar:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Favoriten ---
|
||||
const MAX_FAVORITES = 10;
|
||||
|
||||
function getFavorites() {
|
||||
return storageGet(STORAGE_KEYS.favorites, []);
|
||||
}
|
||||
|
||||
function isFavorite(stationId) {
|
||||
return getFavorites().some(f => f.id === stationId);
|
||||
}
|
||||
|
||||
function toggleFavorite(stationId, stationName) {
|
||||
let favorites = getFavorites();
|
||||
if (favorites.some(f => f.id === stationId)) {
|
||||
favorites = favorites.filter(f => f.id !== stationId);
|
||||
} else {
|
||||
favorites.unshift({ id: stationId, name: stationName });
|
||||
favorites = favorites.slice(0, MAX_FAVORITES);
|
||||
}
|
||||
storageSet(STORAGE_KEYS.favorites, favorites);
|
||||
renderFavorites();
|
||||
updateFavoriteButton();
|
||||
return isFavorite(stationId);
|
||||
}
|
||||
|
||||
// Favoriten auf dem Home-Screen rendern
|
||||
function renderFavorites() {
|
||||
const container = document.getElementById('homeFavorites');
|
||||
if (!container) return;
|
||||
const favorites = getFavorites();
|
||||
|
||||
if (favorites.length === 0) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
container.innerHTML = `
|
||||
<div class="home-favorites-title">⭐ Deine Stationen</div>
|
||||
<div class="home-favorites-list">
|
||||
${favorites.map(f => `
|
||||
<button class="home-favorite-item" data-id="${escapeHtml(f.id)}" data-name="${escapeHtml(f.name)}"
|
||||
aria-label="Abfahrten für ${escapeHtml(f.name)} anzeigen">
|
||||
<span class="home-favorite-name">${escapeHtml(f.name)}</span>
|
||||
<span class="home-favorite-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Stern-Button in der Abfahrten-View aktualisieren
|
||||
function updateFavoriteButton() {
|
||||
const btn = document.getElementById('favoriteBtn');
|
||||
if (!btn) return;
|
||||
if (!currentStationId) {
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
btn.style.display = '';
|
||||
const fav = isFavorite(currentStationId);
|
||||
btn.textContent = fav ? '⭐' : '☆';
|
||||
btn.setAttribute('aria-pressed', String(fav));
|
||||
btn.title = fav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen';
|
||||
}
|
||||
|
||||
// --- Abfahrten-Cache für Offline-Modus ---
|
||||
function saveDeparturesCache(stationId, departures) {
|
||||
storageSet(STORAGE_KEYS.depCachePrefix + stationId, {
|
||||
departures,
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function loadDeparturesCache(stationId) {
|
||||
return storageGet(STORAGE_KEYS.depCachePrefix + stationId, null);
|
||||
}
|
||||
|
||||
function formatAge(timestamp) {
|
||||
const mins = Math.round((Date.now() - timestamp) / 60000);
|
||||
if (mins < 1) return 'gerade eben';
|
||||
if (mins === 1) return 'vor 1 Minute';
|
||||
if (mins < 60) return `vor ${mins} Minuten`;
|
||||
const hours = Math.round(mins / 60);
|
||||
return hours === 1 ? 'vor 1 Stunde' : `vor ${hours} Stunden`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user