Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-07-06 14:51:09 +02:00
1 parent cbc9fd2510
commit a976642183
10 files changed
+942 -269

No files matched your search

+73
View File
@@ -5,6 +5,79 @@ 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/),
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [36.0.0] - 2026-07-06
### Schnellere Karte, offizielle Linienfarben, Desktop-Layout
#### Added - Offizielle VBB-Linienfarben
- **Linienfarben kommen jetzt direkt aus der API**: MOTIS liefert routeColor/routeTextColor aus dem GTFS-Feed bei Abfahrten, Routen UND Live-Map mit - das sind die offiziellen VBB-Farben (U1 grün, U2 rot, S1, S2, ... jeweils korrekt), ohne einen einzigen Extra-Request. Lokale Farbtabellen bleiben nur als Fallback.
- **Textfarbe per Luminanz**: Ob Schwarz oder Weiß auf einem Linien-Badge, wird jetzt aus der Hintergrundfarbe berechnet statt über eine hartcodierte Liste.
#### Changed - Karten-Ladegeschwindigkeit
- **Vorinitialisierung im Leerlauf**: Nach dem App-Start wird die Leaflet-Karte im Hintergrund angelegt und die 9 Kacheln des Standard-Ausschnitts werden vorgeladen (exakt dieselben URLs inkl. Subdomain-/Retina-Logik wie Leaflet). Beim ersten Öffnen der Live-Map ist alles im Browser-Cache - gefühlt sofort. Kostet keine API-Requests.
- **Preconnect/DNS-Prefetch** für api.transitous.org, Karten-CDN und unpkg: spart DNS+TLS-Zeit beim ersten Zugriff.
- **Leaflet mit defer** geladen: blockiert das Rendern der App nicht mehr.
- **Leaflet-Dateien im Service Worker gecacht** (cache-first, versioniert = unveränderlich): ab dem zweiten Besuch null Netzwerk für die Bibliothek.
- **Tile-Tuning**: keepBuffer 4 (Randkacheln bleiben geladen -> flüssigeres Pannen), Kacheln laden schon während der Bewegung, minZoom 8 (ganz Brandenburg sichtbar).
#### Removed
- **Zoom-Tasten (+/-) auf der Karte entfernt** (vorher auf Desktop sichtbar). Zoomen weiterhin per Scrollrad, Doppelklick und Pinch-Geste.
#### Changed - Desktop-Layout (Mobile First bleibt)
- Header-Inhalt und Pull-to-Refresh richten sich am zentrierten Layout aus.
- Home: Info-Karten und Favoriten als 2-Spalten-Raster ab 768px.
- Live-Map bekommt auf dem Desktop deutlich mehr Höhe.
- Ab 1100px: breiterer Inhalt (780px), luftigere Abfahrtszeilen, größere Linien-Badges, breiteres Detail-Modal.
- Konflikt bereinigt: neue Regeln ergänzen den bestehenden 768px-Block (Journey-Grid, Header-Größen) statt ihn zu doppeln.
## [35.0.0] - 2026-07-06
### Transitous als einzige Datenquelle + animierte Live-Map
#### Breaking / Changed
- **transport.rest komplett entfernt**: Alle Funktionen (Stationssuche, Abfahrten, GPS-Nearby, Trip-Details, Routensuche, Live-Map) laufen jetzt ausschließlich über Transitous (api.transitous.org, MOTIS 2). Der VBB->BVG-Failover und alle HAFAS-Codepfade sind raus - js/api.js ist dadurch deutlich schlanker (nur noch Budget, Cache, Dedupe, Timeout, Störungs-Banner).
- **Datenverarbeitung effizienter**: Fahrt-Segmente werden pro API-Poll EINMAL normalisiert (Polyline einmal dekodiert, Zeiten als ms vorberechnet); Positionen werden daraus lokal interpoliert. Abfahrten-Auto-Refresh aktualisiert Zeiten/Verspätungen in-place im DOM (kein Flackern, Scroll bleibt) und rendert nur bei geänderter Fahrten-Menge komplett neu.
#### Added - Live-Map 2.0
- **Flüssig animierte Fahrzeuge**: MOTIS liefert Fahrt-Segmente mit Polyline + Zeiten statt Punkt-Positionen. Ein lokaler 1s-Loop interpoliert die Position linear entlang der Strecke - die Fahrzeuge GLEITEN über die Karte (CSS-Transition passend zum Takt) statt alle 15s zu springen. Kostet null zusätzliche API-Requests.
- **API-Poll auf 30s gedrosselt** (Rücksicht auf das Freiwilligen-Projekt Transitous); die Bewegung kommt aus der lokalen Animation.
- **Berlin-Klemmung der Bounding-Box entfernt**: schnitt bisher Potsdam und ganz Brandenburg ab.
- **Live-Indikator** (pulsierender Punkt) am Fahrzeugzähler.
#### Added - GUI-Politur
- Tabellarische Ziffern und feste Breite bei Abfahrtszeiten (keine Layout-Sprünge bei "9 min" -> "10 min").
- Sanftes Einblenden der Vorschlags-Dropdowns, dezenteres Verspätungs-Badge.
- `prefers-reduced-motion` wird respektiert (alle Animationen abschaltbar per Systemeinstellung).
#### Removed
- Dreistufiger API-Failover (obsolet - eine Quelle), shouldTryTransitous-Wrapper, Transitous-Direktpfade, HAFAS-URL-Bau.
## [34.0.0] - 2026-07-06
### Transitous als dritte, unabhängige Failover-Stufe
#### Added
- **Transitous-Adapter (js/transitous.js)**: Übersetzt die MOTIS-2-API von https://api.transitous.org in die HAFAS-Form der App. Transitous ist ein community-betriebener Dienst mit KOMPLETT eigener Infrastruktur - unabhängig von transport.rest. Beim Totalausfall von VBB+BVG (wie am 03.07.) übernimmt jetzt Stufe 3 für: Stationssuche (geocode), Abfahrten (stoptimes) und Routensuche (plan, inkl. Verkehrsmittel-Filter und Zeitwahl).
- **Koordinaten-basierte Stationsidentität**: Bei Stationsauswahl werden jetzt Koordinaten mitgespeichert (Suggestions, Favoriten, letzte Station). Damit funktionieren Abfahrten und Routen über Transitous OHNE Stop-ID-Mapping (stoptimes per center=lat,lon; plan per Koordinaten-fromPlace). Alte Favoriten ohne Koordinaten werden einmalig per Name geocodet und das Mapping gecacht.
- **Direktpfade für Transitous-Stationen**: Wird während eines Ausfalls eine Station aus Transitous-Vorschlägen gewählt (ID-Präfix "transitous:"), gehen Abfahrten/Routen direkt an Transitous statt sinnlos die HAFAS-APIs zu fragen.
- **Pflicht-Attribution**: Sichtbarer Link auf transitous.org/sources (inkl. OpenStreetMap-Hinweis) in der Entwickler-View, wie von der Transitous-Nutzungsrichtlinie verlangt.
#### Ressourcen-Rücksicht (Transitous ist ein Freiwilligen-Projekt)
- Transitous wird NUR kontaktiert, wenn transport.rest (beide Endpoints) nicht antwortet.
- Bewusst KEIN Radar-Polling über Transitous - die Live-Map bleibt zweistufig (VBB->BVG) und zeigt bei Totalausfall die Störungsmeldung.
- 4xx-Fehler und eigenes Rate-Limit lösen keinen Transitous-Versuch aus.
## [33.2.0] - 2026-07-03
### Sichtbare Störungsanzeige bei Anbieter-Totalausfall
#### Context
Am 03.07.2026 lieferten sowohl v6.vbb.transport.rest als auch der Fallback v6.bvg.transport.rest HTTP 503 - beide laufen auf derselben Infrastruktur des Anbieters transport.rest. Gegen einen Totalausfall des Anbieters kann die App keine Livedaten beschaffen, aber sie zeigt ihn jetzt klar an.
#### Added
- **Globales Störungs-Banner**: Versagen primäre UND Fallback-API, erscheint oben ein deutlicher Hinweis "Datenanbieter gestört". Das Banner verschwindet automatisch beim nächsten erfolgreichen Request.
- **Fehlerhinweis in der Stationssuche**: Statt stillem Versagen zeigen die Suchvorschläge "Suche derzeit nicht möglich - API nicht erreichbar" (Abfahrten- und Routenplaner-Felder).
## [33.1.0] - 2026-07-03
### Automatischer API-Failover
+12 -2
View File
@@ -13,13 +13,21 @@
<link rel="stylesheet" href="styles.css">
</head>
<!-- Frühe Verbindungen: spart DNS+TLS-Zeit beim ersten Karten-/API-Zugriff -->
<link rel="preconnect" href="https://api.transitous.org" crossorigin>
<link rel="preconnect" href="https://a.basemaps.cartocdn.com" crossorigin>
<link rel="dns-prefetch" href="https://b.basemaps.cartocdn.com">
<link rel="dns-prefetch" href="https://c.basemaps.cartocdn.com">
<link rel="dns-prefetch" href="https://d.basemaps.cartocdn.com">
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<!-- Leaflet CSS für Live-Map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<!-- Leaflet JS für Live-Map -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
</head>
@@ -337,7 +345,8 @@
<table class="dev-info-table">
<tr><td>Frontend:</td><td>Vanilla JS, CSS3, HTML5</td></tr>
<tr><td>Maps:</td><td>Leaflet.js</td></tr>
<tr><td>API:</td><td>VBB Transport REST v6</td></tr>
<tr><td>API:</td><td>Transitous (MOTIS 2)</td></tr>
<tr><td>Daten:</td><td><a href="https://transitous.org/sources/" target="_blank" rel="noopener" style="color: #FFED00;">Transitous-Quellen</a> (inkl. DELFI/VBB &amp; OpenStreetMap)</td></tr>
<tr><td>PWA:</td><td>Service Worker, Manifest</td></tr>
<tr><td>Server:</td><td>Node.js HTTPS</td></tr>
</table>
@@ -471,6 +480,7 @@
<!-- Module in fester Reihenfolge (klassische Scripts, gemeinsamer Scope) -->
<script src="js/api.js"></script>
<script src="js/transitous.js"></script>
<script src="js/app.js"></script>
<script src="js/livemap.js"></script>
<script src="js/changelog.js"></script>
+79 -87
View File
@@ -4,15 +4,9 @@
// VBB NETZ STATUS - Haupt-Script
// ==========================================
// Primäre API + baugleicher Fallback (gleicher Betreiber, gleiche
// Routen/Stop-IDs, andere Upstream-Quelle - siehe apiFetch)
const API_BASES = [
'https://v6.vbb.transport.rest',
'https://v6.bvg.transport.rest'
];
const API_BASE = API_BASES[0]; // Call-Sites bauen URLs immer mit der primären Basis
let activeApiBaseIndex = 0; // 0 = VBB, 1 = BVG-Fallback
let apiFallbackSince = 0; // Zeitpunkt des Failovers (für Recovery)
// Seit v35: Transitous (api.transitous.org) ist die einzige
// Datenquelle. transport.rest (VBB/BVG) wurde komplett entfernt.
// Die konkreten Endpoints kapselt js/transitous.js.
let currentStationId = null;
let currentStationName = null;
let searchTimeout = null;
@@ -24,11 +18,14 @@
// Zentral hier deklariert, damit KEIN Modul auf Variablen aus
// später geladenen Dateien angewiesen ist.
let currentView = 'home';
let currentStationLat = null; // Koordinaten der gewählten Station
let currentStationLon = null; // (für Transitous-Fallback ohne ID-Mapping)
let liveMap = null;
let liveMapUpdateInterval = null;
let activeVehicleTypes = new Set(['all']);
let debounceTimer = null;
let lastVehicleMovements = []; // Letzte Radar-Daten für lokales Filtern
let liveMapSegments = []; // Fahrt-Segmente von Transitous (für lokale Animation)
let liveMapAnimationInterval = null; // 1s-Loop: Positionen lokal interpolieren
const vehicleMarkerMap = new Map(); // tripId -> { marker, lineName }
let activeTransportModes = new Set(['subway', 'suburban', 'tram', 'bus', 'regional', 'express']);
@@ -70,21 +67,14 @@
* Wirft Error('RATE_LIMIT') wenn Budget aufgebraucht (Cache wird trotzdem genutzt).
*/
/**
* Zentraler API-Aufruf mit automatischem Failover.
* Zentraler API-Aufruf.
* - Budget: max. 90 Requests/Minute (Selbstschutz gegenüber Transitous)
* - Response-Cache mit TTL, Dedupe paralleler identischer Requests
* - Timeout 12s pro Request
* - Störungs-Banner bei Ausfall, automatisches Ausblenden bei Erholung
* - Bei Fehler oder leerem Budget wird notfalls abgelaufener Cache geliefert
*
* Primär: v6.vbb.transport.rest
* Fallback: v6.bvg.transport.rest (gleicher Betreiber, identische
* Routen & Stop-IDs, deckt ebenfalls ganz Berlin/Brandenburg
* ab - nutzt aber eine ANDERE Upstream-Quelle. Fällt VBB
* aus, läuft die App über BVG weiter.)
*
* - Timeout 12s pro Versuch (hängende Requests blockieren nichts)
* - Failover bei Netzwerkfehler, Timeout oder HTTP 5xx
* (NICHT bei 4xx - das ist ein Anfragefehler, kein API-Ausfall)
* - Nach 10 Minuten auf dem Fallback wird die primäre API erneut probiert
* - Cache-Keys basieren auf der primären URL -> Cache übersteht Failover
*
* @param {string} url - vollständige URL (mit API_BASE gebaut)
* @param {string} url - vollständige URL
* @param {object} opts - { ttl: Cache-Dauer in ms (0 = kein Cache) }
*/
async function apiFetch(url, { ttl = 0 } = {}) {
@@ -108,73 +98,44 @@
throw new Error('RATE_LIMIT');
}
apiCallTimestamps.push(Date.now());
const promise = (async () => {
// Recovery: Nach 10 min auf dem Fallback wieder primär versuchen
if (activeApiBaseIndex !== 0 &&
Date.now() - apiFallbackSince > 10 * 60 * 1000) {
activeApiBaseIndex = 0;
console.warn('API-Failover: Versuche wieder die primäre VBB-API');
}
try {
const response = await fetchWithTimeout(url, 12000);
const tryOrder = activeApiBaseIndex === 0 ? [0, 1] : [1, 0];
let lastError = null;
for (const idx of tryOrder) {
const realUrl = url.replace(API_BASES[0], API_BASES[idx]);
apiCallTimestamps.push(Date.now()); // jeder echte Versuch zählt
try {
const response = await fetchWithTimeout(realUrl, 12000);
if (!response.ok) {
if (response.status >= 500) {
// Serverfehler -> Failover lohnt sich
throw new Error('HTTP ' + response.status);
}
// 4xx: Anfrage ist falsch - andere API würde genauso antworten
const err = new Error('HTTP ' + response.status);
err.noFailover = true;
throw err;
}
const data = await response.json();
// Basis-Wechsel dokumentieren
if (idx !== activeApiBaseIndex) {
activeApiBaseIndex = idx;
if (idx !== 0) {
apiFallbackSince = Date.now();
console.warn(`API-Failover aktiv: ${API_BASES[idx]} (VBB-API antwortet nicht)`);
} else {
console.warn('API-Failover beendet: primäre VBB-API antwortet wieder');
}
}
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;
} catch (e) {
if (e.noFailover) throw e;
lastError = e;
// Budget für den zweiten Versuch noch da?
if (apiBudgetLeft() <= 0) break;
// -> nächste Basis probieren
if (!response.ok) {
const err = new Error('HTTP ' + response.status);
// 4xx = Anfragefehler, kein API-Ausfall -> kein Banner
if (response.status >= 500) showApiOutageBanner();
throw err;
}
}
// Beide APIs down: notfalls abgelaufenen Cache liefern
if (cached) {
console.warn('Beide APIs nicht erreichbar – liefere veraltete Cache-Daten');
return cached.data;
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 });
}
hideApiOutageBanner(); // API antwortet -> alles gut
return data;
} catch (e) {
if (!/^HTTP 4/.test(e.message || '')) {
// Netzwerkfehler/Timeout/5xx -> Störung anzeigen,
// notfalls alten Cache liefern
showApiOutageBanner();
if (cached) {
console.warn('API nicht erreichbar – liefere veraltete Cache-Daten');
return cached.data;
}
}
throw e;
}
throw lastError || new Error('API nicht erreichbar');
})().finally(() => apiInflight.delete(url));
apiInflight.set(url, promise);
@@ -192,6 +153,33 @@
}
}
// ==========================================
// GLOBALES STÖRUNGS-BANNER
// Erscheint, wenn primäre UND Fallback-API versagen
// (z.B. Wartung/Ausfall bei Transitous).
// Verschwindet automatisch beim nächsten erfolgreichen Request.
// ==========================================
function showApiOutageBanner() {
let banner = document.getElementById('apiOutageBanner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'apiOutageBanner';
banner.className = 'api-outage-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'⚠️ <strong>Datenanbieter gestört</strong> – Transitous ' +
'(transitous.org) ist aktuell nicht erreichbar. ' +
'Die App versucht es automatisch weiter.';
document.body.prepend(banner);
}
banner.style.display = '';
}
function hideApiOutageBanner() {
const banner = document.getElementById('apiOutageBanner');
if (banner) banner.style.display = 'none';
}
// HTML-Escaping für alle Strings aus der API (XSS-Schutz)
function escapeHtml(str) {
if (str === null || str === undefined) return '';
@@ -247,7 +235,10 @@
if (favorites.some(f => f.id === stationId)) {
favorites = favorites.filter(f => f.id !== stationId);
} else {
favorites.unshift({ id: stationId, name: stationName });
favorites.unshift({
id: stationId, name: stationName,
lat: currentStationLat, lon: currentStationLon
});
favorites = favorites.slice(0, MAX_FAVORITES);
}
storageSet(STORAGE_KEYS.favorites, favorites);
@@ -274,6 +265,7 @@
<div class="home-favorites-list">
${favorites.map(f => `
<button class="home-favorite-item" data-id="${escapeHtml(f.id)}" data-name="${escapeHtml(f.name)}"
data-lat="${f.lat != null ? f.lat : ''}" data-lon="${f.lon != null ? f.lon : ''}"
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>
+105 -132
View File
@@ -137,7 +137,7 @@
if (document.hidden) return;
if (currentView !== 'livemap') return;
loadVehicles();
}, 15000);
}, 30000);
loadVehicles(); // Sofort frische Daten holen
}
}
@@ -310,19 +310,13 @@
}
try {
// 6h Cache: Stationsnamen ändern sich praktisch nie
const locations = await apiFetch(
`${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`,
{ ttl: 6 * 60 * 60 * 1000 }
);
const stations = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
const stations = await transitousLocations(query);
displaySuggestions(stations);
} catch (error) {
console.error('Suchfehler:', error);
suggestions.innerHTML =
'<div class="suggestion-item suggestion-error">⚠️ Suche derzeit nicht möglich – API nicht erreichbar</div>';
suggestions.classList.add('active');
}
}, 400);
});
@@ -391,14 +385,10 @@
stationSearch.placeholder = '📍 Suche Stationen in der Nähe...';
// Search nearby stations - VBB API endpoint
const nearbyUrl = `${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10`;
// Stationen in der Nähe über Transitous (map/stops)
let locations;
try {
// 2min Cache: Standort ändert sich kurzfristig kaum
locations = await apiFetch(nearbyUrl, { ttl: 2 * 60 * 1000 });
locations = await transitousNearby(latitude, longitude);
} catch (e) {
if (e.message === 'RATE_LIMIT') throw e;
console.error('API Response error:', e.message);
@@ -418,7 +408,7 @@
const nearest = stations[0];
const distance = nearest.distance ? Math.round(nearest.distance) : '?';
selectStation(nearest.id, nearest.name);
selectStation(nearest.id, nearest.name, nearest.location?.latitude ?? null, nearest.location?.longitude ?? null);
// Show success feedback
if (navigator.vibrate) {
@@ -500,7 +490,8 @@
.join(', ') : '';
return `
<div class="suggestion-item" data-id="${escapeHtml(station.id)}" data-name="${escapeHtml(station.name)}">
<div class="suggestion-item" data-id="${escapeHtml(station.id)}" data-name="${escapeHtml(station.name)}"
data-lat="${station.location?.latitude ?? ''}" data-lon="${station.location?.longitude ?? ''}">
<div class="suggestion-name">${escapeHtml(station.name)}</div>
${products ? `<div class="suggestion-products">${escapeHtml(products)}</div>` : ''}
</div>
@@ -514,24 +505,29 @@
item.addEventListener('click', () => {
const stationId = item.dataset.id;
const stationName = item.dataset.name;
const lat = item.dataset.lat ? parseFloat(item.dataset.lat) : null;
const lon = item.dataset.lon ? parseFloat(item.dataset.lon) : null;
if (stationId && stationName) {
selectStation(stationId, stationName);
selectStation(stationId, stationName, lat, lon);
}
});
});
}
function selectStation(stationId, stationName) {
function selectStation(stationId, stationName, lat = null, lon = null) {
currentStationId = stationId;
currentStationName = stationName;
currentStationLat = lat;
currentStationLon = lon;
stationSearch.value = stationName;
suggestions.classList.remove('active');
refreshBtn.disabled = false;
// Station für den nächsten App-Start merken
storageSet(STORAGE_KEYS.lastStation, { id: stationId, name: stationName });
storageSet(STORAGE_KEYS.lastStation, { id: stationId, name: stationName, lat, lon });
updateFavoriteButton();
// Haptic feedback
@@ -592,22 +588,16 @@
departuresContainer.innerHTML = '<div class="loading">⏳ Lade Abfahrten...</div>';
try {
// 15s Cache: schützt vor Refresh-Spam (Button-Hämmern, Pull-to-Refresh)
const data = await apiFetch(
`${API_BASE}/stops/${encodeURIComponent(currentStationId)}/departures?duration=60&results=20`,
{ ttl: 15000 }
);
const data = await transitousDepartures({
id: currentStationId, name: currentStationName,
lat: currentStationLat, lon: currentStationLon
});
displayDepartures(data.departures || []);
saveDeparturesCache(currentStationId, data.departures || []);
noteRefreshSuccess();
// Start auto-refresh after successful load
startAutoRefresh();
// Haptic feedback
if (navigator.vibrate) {
navigator.vibrate(10);
}
} catch (error) {
console.error('Fehler beim Laden:', error);
noteRefreshFailure();
@@ -634,8 +624,7 @@
<small>Automatischer Neuversuch läuft…</small>
</div>
`;
// WICHTIG: Auto-Refresh läuft weiter (mit Backoff),
// statt wie früher permanent zu stoppen.
// Auto-Refresh läuft weiter (mit Backoff)
}
}
@@ -644,41 +633,44 @@
if (!currentStationId) return;
try {
// ttl 0: Auto-Refresh soll echte Frischdaten holen
// (Rate-Limit wird trotzdem zentral erzwungen)
const data = await apiFetch(
`${API_BASE}/stops/${encodeURIComponent(currentStationId)}/departures?duration=60&results=20`,
{ ttl: 15000 }
);
const data = await transitousDepartures({
id: currentStationId, name: currentStationName,
lat: currentStationLat, lon: currentStationLon
});
const departures = data.departures || [];
// Aktualisiere nur die Zeitanzeigen
const now = new Date();
const departureItems = departuresContainer.querySelectorAll('.departure-item');
departures.forEach((dep, index) => {
if (index >= departureItems.length) return;
// In-Place-Update: nur Zeiten/Verspätungen im bestehenden DOM
// aktualisieren (kein Flackern, Scroll-Position bleibt).
// Hat sich die Fahrten-Menge geändert -> voller Re-Render.
const items = departuresContainer.querySelectorAll('.departure-item');
const domTripIds = Array.from(items).map(i => i.dataset.tripId);
const newTripIds = departures.map(d => String(d.tripId));
const item = departureItems[index];
const when = new Date(dep.when);
const minutes = Math.round((when - now) / 60000);
const timeDisplay = minutes <= 0 ? 'Jetzt' : `${minutes} min`;
const sameSet = domTripIds.length === newTripIds.length &&
domTripIds.every((id, i) => id === newTripIds[i]);
const delay = dep.delay ? Math.round(dep.delay / 60) : 0;
// Update Zeit
const timeElement = item.querySelector('.departure-time');
if (timeElement) {
if (!sameSet) {
displayDepartures(departures);
} else {
departures.forEach((dep, i) => {
const item = items[i];
if (!item) return;
const when = new Date(dep.when);
const minutes = Math.round((when - now) / 60000);
const timeDisplay = minutes <= 0 ? 'Jetzt' : `${minutes} min`;
const delay = dep.delay ? Math.round(dep.delay / 60) : 0;
const delayHTML = delay > 0 ?
`<div class="departure-delay">+${delay} min Verspätung</div>` : '';
timeElement.innerHTML = timeDisplay + delayHTML;
}
});
const timeEl = item.querySelector('.departure-time');
if (timeEl) timeEl.innerHTML = timeDisplay + delayHTML;
});
// Update Timestamp in Station Info
const lastUpdateElement = departuresContainer.querySelector('.last-update');
if (lastUpdateElement) {
lastUpdateElement.textContent = `Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s`;
const lastUpdateElement = departuresContainer.querySelector('.last-update');
if (lastUpdateElement) {
lastUpdateElement.textContent =
`Aktualisiert: ${now.toLocaleTimeString('de-DE')} • Auto-Refresh alle 30s`;
}
}
saveDeparturesCache(currentStationId, departures);
@@ -739,7 +731,7 @@
<div class="departure-item" style="border-left-color: ${lineColor}" data-trip-id="${dep.tripId}" data-index="${index}">
<div class="departure-info">
<div class="departure-header">
<div class="line-badge" style="background-color: ${lineColor}; color: #000;">
<div class="line-badge" style="background-color: ${lineColor}; color: ${dep.line?.textColor || bestTextColor(lineColor)};">
${dep.line.name}${cancelled}
</div>
${platformHTML}
@@ -790,12 +782,10 @@
// Versuche Trip-Details zu laden (60s Cache)
if (departure.tripId) {
try {
const data = await apiFetch(
`${API_BASE}/trips/${encodeURIComponent(departure.tripId)}`,
{ ttl: 60000 }
);
const data = await transitousTrip(departure.tripId);
tripDetails = data.trip;
} catch (e) {
console.warn('Keine Trip-Details verfügbar');
}
}
@@ -1011,7 +1001,11 @@
}
function getLineColor(line) {
if (!line || !line.product) return '#666';
if (!line) return '#666';
// OFFIZIELLE Farbe aus dem GTFS-Feed (kommt direkt mit der
// API-Antwort) - lokale Tabellen sind nur noch Fallback
if (line.color) return line.color;
if (!line.product) return '#666';
const product = line.product.toLowerCase();
const name = line.name ? line.name.toUpperCase() : '';
@@ -1060,6 +1054,33 @@
let journeyFromTimeout = null;
let journeyToTimeout = null;
// Journey-Vorschläge rendern (gemeinsam für HAFAS- und Transitous-Pfad)
function renderJourneySuggestions(stops, suggestionsContainer, inputField, onSelect) {
if (stops.length === 0) return;
suggestionsContainer.innerHTML = stops.map(stop => `
<div class="suggestion-item" data-id="${escapeHtml(stop.id)}" data-name="${escapeHtml(stop.name)}"
data-lat="${stop.location?.latitude ?? ''}" data-lon="${stop.location?.longitude ?? ''}">
${escapeHtml(stop.name)}
</div>
`).join('');
suggestionsContainer.classList.add('active');
suggestionsContainer.querySelectorAll('.suggestion-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.id;
const name = item.dataset.name;
const lat = item.dataset.lat ? parseFloat(item.dataset.lat) : null;
const lon = item.dataset.lon ? parseFloat(item.dataset.lon) : null;
onSelect(id, name, lat, lon);
inputField.value = name;
suggestionsContainer.classList.remove('active');
if (navigator.vibrate) navigator.vibrate(10);
});
});
}
function setupJourneyAutocomplete(inputField, suggestionsContainer, onSelect) {
let timeout = null;
@@ -1081,53 +1102,27 @@
}
try {
// 6h Cache: gleiche Suchanfragen kosten keinen zweiten Request
const locations = await apiFetch(
`${API_BASE}/locations?query=${encodeURIComponent(query)}&results=10`,
{ ttl: 6 * 60 * 60 * 1000 }
);
const stops = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
if (stops.length > 0) {
suggestionsContainer.innerHTML = stops.map(stop => `
<div class="suggestion-item" data-id="${escapeHtml(stop.id)}" data-name="${escapeHtml(stop.name)}">
${escapeHtml(stop.name)}
</div>
`).join('');
suggestionsContainer.classList.add('active');
suggestionsContainer.querySelectorAll('.suggestion-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.id;
const name = item.dataset.name;
onSelect(id, name);
inputField.value = name;
suggestionsContainer.classList.remove('active');
if (navigator.vibrate) navigator.vibrate(10);
});
});
}
const stops = await transitousLocations(query);
renderJourneySuggestions(stops, suggestionsContainer, inputField, onSelect);
} catch (error) {
console.error('Autocomplete error:', error);
suggestionsContainer.innerHTML =
'<div class="suggestion-item suggestion-error">⚠️ Suche derzeit nicht möglich – API nicht erreichbar</div>';
suggestionsContainer.classList.add('active');
}
}, 400);
});
}
// Setup Journey From
setupJourneyAutocomplete(journeyFrom, suggestionsFrom, (id, name) => {
journeyFromStation = { id, name };
setupJourneyAutocomplete(journeyFrom, suggestionsFrom, (id, name, lat, lon) => {
journeyFromStation = { id, name, lat, lon };
checkJourneySearchReady();
});
// Setup Journey To
setupJourneyAutocomplete(journeyTo, suggestionsTo, (id, name) => {
journeyToStation = { id, name };
setupJourneyAutocomplete(journeyTo, suggestionsTo, (id, name, lat, lon) => {
journeyToStation = { id, name, lat, lon };
checkJourneySearchReady();
});
@@ -1188,10 +1183,7 @@
const { latitude, longitude } = position.coords;
const locations = await apiFetch(
`${API_BASE}/locations/nearby?latitude=${latitude}&longitude=${longitude}&results=10`,
{ ttl: 2 * 60 * 1000 }
);
const locations = await transitousNearby(latitude, longitude);
const stations = locations.filter(loc =>
loc.type === 'stop' || loc.type === 'station'
);
@@ -1235,25 +1227,14 @@
journeyContainer.innerHTML = '<div class="loading">⏳ Suche Verbindungen...</div>';
try {
// Verkehrsmittel-Filter anwenden (vorher totes UI - Auswahl wurde ignoriert!)
// Nur Produkte mit UI-Button steuern; Fähre bleibt API-Default (true),
// sonst würden z.B. Wannsee-Routen unerwartet wegfallen.
const filterableProducts = ['suburban', 'subway', 'tram', 'bus', 'regional', 'express'];
const productParams = filterableProducts
.map(p => `&${p}=${activeTransportModes.has(p) ? 'true' : 'false'}`)
.join('');
const tIn = document.getElementById('journeyTimeInput');
const tMode = document.getElementById('journeyTimeMode');
// Zeitwahl: "Abfahrt um" oder "Ankunft bis" (leer = jetzt)
let timeParam = '';
const timeModeEl = document.getElementById('journeyTimeMode');
const timeInputEl = document.getElementById('journeyTimeInput');
if (timeInputEl && timeInputEl.value) {
const iso = new Date(timeInputEl.value).toISOString();
const mode = timeModeEl ? timeModeEl.value : 'departure';
timeParam = `&${mode === 'arrival' ? 'arrival' : 'departure'}=${encodeURIComponent(iso)}`;
}
const url = `${API_BASE}/journeys?from=${encodeURIComponent(journeyFromStation.id)}&to=${encodeURIComponent(journeyToStation.id)}&results=5&transfers=3&transferTime=5${productParams}${timeParam}`;
const data = await transitousJourneys(journeyFromStation, journeyToStation, {
timeISO: (tIn && tIn.value) ? new Date(tIn.value).toISOString() : null,
arriveBy: tMode ? tMode.value === 'arrival' : false,
products: activeTransportModes
});
// Route für den nächsten App-Start merken
storageSet(STORAGE_KEYS.lastJourney, {
@@ -1261,12 +1242,7 @@
to: journeyToStation
});
// 30s Cache: identische Suche direkt hintereinander = 1 Request
const data = await apiFetch(url, { ttl: 30000 });
displayJourneys(data.journeys || []);
if (navigator.vibrate) navigator.vibrate(10);
} catch (error) {
console.error('Journey search error:', error);
const msg = error.message === 'RATE_LIMIT'
@@ -1281,9 +1257,6 @@
}
});
// Display Journeys
let currentJourneys = []; // Speichert aktuelle Journeys für Detail-Ansicht
function displayJourneys(journeys) {
currentJourneys = journeys; // Speichern für später
+50 -1
View File
@@ -378,6 +378,49 @@ document.addEventListener('DOMContentLoaded', () => {
// (Parallax-Effekt entfernt: Der Header inkl. Menü-Button bewegte sich
// mit der Maus mit - wirkte unruhig und hatte keinen Nutzen.)
// ==========================================
// LIVE-MAP VORINITIALISIERUNG
// Nach App-Start im Leerlauf: Leaflet-Map schon anlegen (Setup + erste
// Kacheln laden im Hintergrund). Beim ersten Öffnen der Live-Map ist
// dann alles da - gefühlt sofortiges Laden. Kostet KEINE API-Requests
// (Daten-Polling startet erst beim Öffnen der View).
// ==========================================
window.addEventListener('load', () => {
const warmup = () => {
// 1) Leaflet-Objekt vorbauen (Setup-Kosten aus dem ersten Öffnen raus)
if (typeof L !== 'undefined' && typeof initLiveMap === 'function' && !liveMap) {
try { initLiveMap(); } catch (e) { /* Karte lädt dann eben beim Öffnen */ }
}
// 2) Kacheln des Standard-Ausschnitts vorladen. Im versteckten
// Container lädt Leaflet nichts - also holen wir die exakt
// gleichen URLs selbst als <img>; der Browser-Cache liefert
// sie beim Öffnen dann sofort. (3x3-Raster um Berlin-Mitte,
// Zoom 12, gleiche Subdomain-Wahl und Retina-Logik wie Leaflet.)
const zoom = 12, lat = 52.52, lon = 13.405;
const n = Math.pow(2, zoom);
const cx = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const cy = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
const r = (window.devicePixelRatio > 1) ? '@2x' : '';
const subs = 'abcd';
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const x = cx + dx, y = cy + dy;
const s = subs[Math.abs(x + y) % subs.length]; // wie Leaflet
const img = new Image();
img.src = `https://${s}.basemaps.cartocdn.com/dark_all/${zoom}/${x}/${y}${r}.png`;
}
}
};
if ('requestIdleCallback' in window) {
requestIdleCallback(warmup, { timeout: 5000 });
} else {
setTimeout(warmup, 3000);
}
});
// Smooth Scroll bei Navigation
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
@@ -406,6 +449,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (lastStation && lastStation.id) {
currentStationId = lastStation.id;
currentStationName = lastStation.name;
currentStationLat = lastStation.lat ?? null;
currentStationLon = lastStation.lon ?? null;
stationSearch.value = lastStation.name;
refreshBtn.disabled = false;
updateFavoriteButton();
@@ -426,7 +471,11 @@ document.addEventListener('DOMContentLoaded', () => {
const favItem = e.target.closest('.home-favorite-item');
if (favItem) {
switchView('departures');
selectStation(favItem.dataset.id, favItem.dataset.name);
selectStation(
favItem.dataset.id, favItem.dataset.name,
favItem.dataset.lat ? parseFloat(favItem.dataset.lat) : null,
favItem.dataset.lon ? parseFloat(favItem.dataset.lon) : null
);
}
});
+58 -38
View File
@@ -147,33 +147,37 @@
if (liveMap) return; // Bereits initialisiert
// Zoom-Control nur auf Desktop
const isMobile = window.innerWidth < 768;
// Map zentriert auf Berlin
liveMap = L.map('liveMap', {
center: [52.52, 13.405],
zoom: 12,
zoomControl: !isMobile // Nur auf Desktop!
zoomControl: false // Keine +/- Tasten (Zoomen per Geste/Scrollrad)
});
// Tile Layer (CartoDB Dark Matter - Dark Mode!)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CartoDB',
maxZoom: 19,
minZoom: 10,
subdomains: 'abcd'
minZoom: 8,
subdomains: 'abcd',
keepBuffer: 4, // Rand-Kacheln behalten -> flüssigeres Pannen
updateWhenIdle: false // Kacheln schon WÄHREND der Bewegung laden
}).addTo(liveMap);
// Fahrzeuge initial laden
loadVehicles();
// HINWEIS: Daten-Polling startet NICHT hier, sondern beim Öffnen
// der View (switchView). Dadurch kann die Karte selbst schon im
// Hintergrund vorinitialisiert werden (Leaflet-Setup + erste
// Kacheln), ohne einen einzigen API-Request zu verursachen.
// Auto-Update alle 15 Sekunden
// Pausiert automatisch, wenn Tab im Hintergrund oder andere View aktiv
liveMapUpdateInterval = setInterval(() => {
if (document.hidden) return;
if (currentView !== 'livemap') return;
loadVehicles();
}, 15000);
// Lokaler Animations-Loop (1s-Takt, kostet keine API-Requests)
if (!liveMapAnimationInterval) {
liveMapAnimationInterval = setInterval(() => {
if (document.hidden) return;
if (currentView !== 'livemap') return;
renderVehiclesFromSegments();
}, 1000);
}
// Event-Listener: Neu laden wenn Karte bewegt/gezoomt wird
// Mit Debouncing (1 Sekunde Verzögerung) um API zu schonen
@@ -209,30 +213,23 @@
const latPadding = latDiff * 0.15;
const lngPadding = lngDiff * 0.15;
// Keine Berlin-Klemmung mehr: Transitous deckt ganz
// Brandenburg (und darüber hinaus) ab - die alte Klemmung
// schnitt z.B. Potsdam ab.
const bounds = {
north: Math.min(52.70, mapBounds.getNorth() + latPadding), // Max: Berlin Nord
south: Math.max(52.35, mapBounds.getSouth() - latPadding), // Min: Berlin Süd
west: Math.max(13.08, mapBounds.getWest() - lngPadding), // Min: Berlin West
east: Math.min(13.76, mapBounds.getEast() + lngPadding) // Max: Berlin Ost
north: mapBounds.getNorth() + latPadding,
south: mapBounds.getSouth() - latPadding,
west: mapBounds.getWest() - lngPadding,
east: mapBounds.getEast() + lngPadding
};
// Ergebnisanzahl an Zoom koppeln: nah dran reichen wenige Fahrzeuge,
// weit rausgezoomt begrenzen wir auf 200 (kleinere API-Responses)
const zoom = liveMap.getZoom ? liveMap.getZoom() : 12;
const maxResults = zoom >= 15 ? 80 : zoom >= 13 ? 140 : 200;
// Koordinaten runden -> stabile Cache-Keys bei minimalen Kartenbewegungen
const url = `${API_BASE}/radar?` +
`north=${bounds.north.toFixed(3)}&south=${bounds.south.toFixed(3)}&` +
`west=${bounds.west.toFixed(3)}&east=${bounds.east.toFixed(3)}&` +
`results=${maxResults}&duration=60&` +
`subway=true&bus=true&tram=true&suburban=true&regional=true`;
// 10s Cache: schnelles Hin- und Herzoomen kostet keine Extra-Requests
const data = await apiFetch(url, { ttl: 10000 });
lastVehicleMovements = data.movements || [];
displayVehicles(lastVehicleMovements);
// Fahrt-Segmente von Transitous holen (map/trips) und für
// die lokale Animation zwischenspeichern
liveMapSegments = await transitousRadarSegments(bounds, zoom);
renderVehiclesFromSegments();
} catch (error) {
console.error('Load vehicles error:', error);
@@ -245,6 +242,28 @@
}
}
// Aus den gespeicherten Segmenten die AKTUELLEN Positionen berechnen
// und rendern. Läuft jede Sekunde lokal - null API-Kosten.
function renderVehiclesFromSegments() {
if (!liveMap) return;
const nowMs = Date.now();
const movements = [];
for (const seg of liveMapSegments) {
const pos = segmentPositionAt(seg, nowMs);
if (!pos) continue; // Fahrzeug (noch) nicht auf diesem Segment unterwegs
movements.push({
tripId: seg.tripId,
direction: seg.direction,
delay: seg.delay,
line: { name: seg.lineName, product: seg.product, color: seg.color },
location: pos
});
}
displayVehicles(movements);
}
// Fahrzeuge auf Map anzeigen
// Marker werden per tripId wiederverwendet und nur BEWEGT (setLatLng),
// statt bei jedem Update alle zu zerstören und neu zu erzeugen.
@@ -259,11 +278,12 @@
return activeVehicleTypes.has(vehicleType);
});
// Update Counter
// Update Counter mit Live-Indikator
const countEl = document.getElementById('vehicleCount');
if (countEl) {
countEl.innerHTML =
`🚇 <span style="color: #fff;">${filteredMovements.length}</span> Fahrzeuge aktiv`;
`<span class="live-dot" aria-hidden="true"></span> ` +
`<span style="color: #fff;">${filteredMovements.length}</span> Fahrzeuge live`;
}
const seenTripIds = new Set();
@@ -281,9 +301,9 @@
const direction = movement.direction || 'Keine Angabe';
const delay = movement.delay ? `+${Math.round(movement.delay / 60)} min Verspätung` : 'Pünktlich';
const lineColor = getBVGLineColor(lineName);
const isLightBg = lineColor === '#F0D722' || lineColor === '#FFFFFF' || lineColor === '#55A823';
const textColor = isLightBg ? '#000' : '#FFF';
// Offizielle GTFS-Farbe aus der API, lokale Tabelle als Fallback
const lineColor = movement.line?.color || getBVGLineColor(lineName);
const textColor = bestTextColor(lineColor);
const emoji = getVehicleEmoji(movement.line?.product, lineName);
const popupContent = `
@@ -375,8 +395,8 @@
// Lokal filtern - Daten sind bereits da, KEIN neuer API-Call nötig!
if (lastVehicleMovements.length > 0) {
displayVehicles(lastVehicleMovements);
if (liveMapSegments.length > 0) {
renderVehiclesFromSegments();
} else {
loadVehicles();
}
+379
View File
@@ -0,0 +1,379 @@
// VBB Netz Status - Transitous-Daten-Layer (EINZIGE Datenquelle)
//
// Transitous (https://transitous.org) ist ein community-betriebener,
// kostenloser Routing-Dienst auf MOTIS-2-Basis. Seit v35 ist er die
// alleinige Datenquelle der App (transport.rest wurde entfernt).
//
// Nutzungsrichtlinie von Transitous (Freiwilligen-Projekt, begrenzte
// Ressourcen, https://transitous.org/api/):
// - Eigenes Budget-Limit (90 Req/min) + aggressive Caches als Selbstschutz
// - Radar-Polling gedrosselt auf 30s, nur bei sichtbarer Live-Map
// - Quellenangabe: sichtbarer Link auf transitous.org/sources
// (siehe Entwickler-View)
//
// Dieser Adapter übersetzt die MOTIS-Endpoints (geocode, stoptimes,
// plan, trip, map/stops, map/trips) in die HAFAS-Form, die die App
// überall erwartet.
const TRANSITOUS_BASE = 'https://api.transitous.org/api';
const TRANSITOUS_ID_PREFIX = 'transitous:';
// Budget-gezählter, gecachter, deduplizierter, timeout-gesicherter Request
// (nutzt die zentrale apiFetch-Maschinerie aus js/api.js)
async function transitousFetch(path, ttl = 0) {
return apiFetch(TRANSITOUS_BASE + path, { ttl });
}
// MOTIS-Mode -> HAFAS-Produktname (für Farben, Icons, Filter)
function motisModeToProduct(mode) {
switch (mode) {
case 'SUBWAY': return 'subway';
case 'TRAM': return 'tram';
case 'BUS':
case 'COACH': return 'bus';
case 'SUBURBAN': return 'suburban';
case 'FERRY': return 'ferry';
case 'REGIONAL_RAIL':
case 'REGIONAL_FAST_RAIL': return 'regional';
case 'LONG_DISTANCE':
case 'HIGHSPEED_RAIL':
case 'NIGHT_RAIL': return 'express';
default: return 'bus';
}
}
// HAFAS-Produkt -> MOTIS-transitModes (für die Routensuche)
function productsToMotisModes(activeProducts) {
const map = {
suburban: ['SUBURBAN'],
subway: ['SUBWAY'],
tram: ['TRAM'],
bus: ['BUS', 'COACH'],
regional: ['REGIONAL_RAIL'],
express: ['LONG_DISTANCE', 'HIGHSPEED_RAIL', 'NIGHT_RAIL']
};
const modes = new Set(['FERRY']); // Fähre hat keinen UI-Button -> immer an
for (const p of activeProducts) {
(map[p] || []).forEach(m => modes.add(m));
}
return Array.from(modes).join(',');
}
// Verspätung in Sekunden aus Ist-/Sollzeit (HAFAS liefert delay in Sekunden)
function motisDelaySeconds(actual, scheduled) {
if (!actual || !scheduled) return null;
const diff = (new Date(actual) - new Date(scheduled)) / 1000;
return Number.isFinite(diff) ? Math.round(diff) : null;
}
// 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.
function motisColor(hex) {
if (!hex || typeof hex !== 'string') return null;
const clean = hex.replace('#', '').trim();
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return '#' + clean.toUpperCase();
}
// Lesbare Textfarbe für einen Hintergrund per Luminanz berechnen
// (ersetzt die alte hartcodierte "helle Hintergründe"-Liste)
function bestTextColor(hexBg) {
if (!hexBg) return '#FFF';
const c = hexBg.replace('#', '');
const r = parseInt(c.slice(0, 2), 16);
const g = parseInt(c.slice(2, 4), 16);
const b = parseInt(c.slice(4, 6), 16);
// Relative Luminanz (vereinfachte sRGB-Gewichtung)
return (0.299 * r + 0.587 * g + 0.114 * b) > 150 ? '#000' : '#FFF';
}
// ==========================================
// STATIONSSUCHE (geocode -> HAFAS-locations-Form)
// ==========================================
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`,
6 * 60 * 60 * 1000
);
return (matches || []).map(m => ({
type: 'stop',
id: TRANSITOUS_ID_PREFIX + m.id,
name: m.name,
location: { latitude: m.lat, longitude: m.lon },
products: {} // MOTIS liefert modes-Array; App braucht products nur optional
}));
}
// Station für Transitous auflösen: Stop-ID / Koordinaten / Name
// Rückgabe: Query-Fragment für stoptimes (stopId=... oder center=...&radius=...)
async function transitousStopQuery({ id, name, lat, lon }) {
if (id && id.startsWith(TRANSITOUS_ID_PREFIX)) {
return 'stopId=' + encodeURIComponent(id.slice(TRANSITOUS_ID_PREFIX.length));
}
if (lat != null && lon != null) {
// Koordinaten reichen: stoptimes bündelt Stops im Radius
return `center=${lat},${lon}&radius=250`;
}
// Nur Name bekannt (alte Favoriten): einmalig geocoden, Mapping cachen
const cacheKey = 'vbb_transitous_stop_' + name;
const cached = storageGet(cacheKey);
if (cached) return 'stopId=' + encodeURIComponent(cached);
const matches = await transitousFetch(
`/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=52.52,13.41&numResults=1`,
6 * 60 * 60 * 1000
);
if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden');
storageSet(cacheKey, matches[0].id);
return 'stopId=' + encodeURIComponent(matches[0].id);
}
// ==========================================
// ABFAHRTEN (stoptimes -> HAFAS-departures-Form)
// ==========================================
async function transitousDepartures(station) {
const stopQuery = await transitousStopQuery(station);
// 15s Cache: schützt vor Refresh-Spam
const data = await transitousFetch(`/v6/stoptimes?${stopQuery}&n=20`, 15000);
const departures = (data.stopTimes || []).map(st => {
const p = st.place || {};
return {
tripId: st.tripId,
direction: st.headsign || '',
line: {
name: st.routeShortName || st.displayName || '?',
product: motisModeToProduct(st.mode),
mode: st.mode === 'BUS' ? 'bus' : 'train',
color: motisColor(st.routeColor),
textColor: motisColor(st.routeTextColor),
operator: st.agencyName ? { name: st.agencyName } : undefined
},
when: p.departure || p.arrival || null,
plannedWhen: p.scheduledDeparture || p.scheduledArrival || null,
delay: motisDelaySeconds(
p.departure || p.arrival,
p.scheduledDeparture || p.scheduledArrival
),
platform: p.track || null,
plannedPlatform: p.scheduledTrack || null,
cancelled: st.cancelled || st.tripCancelled || p.cancelled || false
};
});
return { departures };
}
// ==========================================
// ROUTENSUCHE (plan -> HAFAS-journeys-Form)
// ==========================================
// fromPlace/toPlace: MOTIS akzeptiert "lat,lon" ODER Stop-ID
async function transitousPlace(station) {
if (station.id && station.id.startsWith(TRANSITOUS_ID_PREFIX)) {
return station.id.slice(TRANSITOUS_ID_PREFIX.length);
}
if (station.lat != null && station.lon != null) {
return `${station.lat},${station.lon}`;
}
const matches = await transitousFetch(
`/v1/geocode?text=${encodeURIComponent(station.name)}&type=STOP&place=52.52,13.41&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 } = {}) {
const fromPlace = await transitousPlace(fromStation);
const toPlace = await transitousPlace(toStation);
let url = `/v6/plan?fromPlace=${encodeURIComponent(fromPlace)}` +
`&toPlace=${encodeURIComponent(toPlace)}` +
`&maxTransfers=3&minTransferTime=5`;
if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`;
if (products) url += `&transitModes=${productsToMotisModes(products)}`;
// 30s Cache: identische Suche direkt hintereinander = 1 Request
const data = await transitousFetch(url, 30000);
const journeys = (data.itineraries || []).map(it => ({
legs: (it.legs || []).map(leg => {
const isWalk = leg.mode === 'WALK' || leg.mode === 'BIKE';
return {
walking: isWalk,
line: isWalk ? undefined : {
name: leg.routeShortName || leg.displayName || leg.mode,
product: motisModeToProduct(leg.mode),
color: motisColor(leg.routeColor),
textColor: motisColor(leg.routeTextColor)
},
direction: leg.headsign || '',
origin: { name: leg.from?.name || '?' },
destination: { name: leg.to?.name || '?' },
departure: leg.startTime,
plannedDeparture: leg.scheduledStartTime,
arrival: leg.endTime,
plannedArrival: leg.scheduledEndTime,
departurePlatform: leg.from?.track || null,
arrivalPlatform: leg.to?.track || null
};
})
}));
return { journeys };
}
// ==========================================
// STATIONEN IN DER NÄHE (map/stops -> HAFAS-nearby-Form)
// GPS-Button: Stops in ~600m-Box um die Position, nach Distanz sortiert
// ==========================================
async function transitousNearby(lat, lon) {
const d = 0.006; // ~600m in Grad (Berlin-Breite)
const stops = await transitousFetch(
`/v6/map/stops?min=${(lat - d).toFixed(4)},${(lon - d).toFixed(4)}` +
`&max=${(lat + d).toFixed(4)},${(lon + d).toFixed(4)}`,
5 * 60 * 1000 // 5min Cache
);
const withDistance = (stops || [])
.filter(s => s.stopId && s.lat != null && s.lon != null)
.map(s => ({
type: 'stop',
id: TRANSITOUS_ID_PREFIX + s.stopId,
name: s.name,
location: { latitude: s.lat, longitude: s.lon },
products: {},
// Näherung reicht zum Sortieren (Haversine wäre Overkill für <1km)
distance: Math.round(Math.sqrt(
Math.pow((s.lat - lat) * 111320, 2) +
Math.pow((s.lon - lon) * 111320 * Math.cos(lat * Math.PI / 180), 2)
))
}))
.sort((a, b) => a.distance - b.distance);
return withDistance;
}
// ==========================================
// TRIP-DETAILS (trip -> HAFAS-trip-Form mit stopovers)
// ==========================================
async function transitousTrip(tripId) {
const rawId = tripId.startsWith(TRANSITOUS_ID_PREFIX)
? tripId.slice(TRANSITOUS_ID_PREFIX.length) : tripId;
const itinerary = await transitousFetch(
`/v6/trip?tripId=${encodeURIComponent(rawId)}`,
60000 // 60s Cache
);
// Der Trip kommt als Itinerary mit genau einem Transit-Leg
const leg = (itinerary.legs || []).find(l => l.mode !== 'WALK') || (itinerary.legs || [])[0];
if (!leg) return { trip: null };
const placeToStopover = (p) => ({
stop: { id: TRANSITOUS_ID_PREFIX + (p.stopId || ''), name: p.name },
arrival: p.arrival || null,
departure: p.departure || null,
plannedArrival: p.scheduledArrival || null,
plannedDeparture: p.scheduledDeparture || null,
arrivalDelay: motisDelaySeconds(p.arrival, p.scheduledArrival),
departureDelay: motisDelaySeconds(p.departure, p.scheduledDeparture)
});
const stopovers = [
placeToStopover(leg.from || {}),
...(leg.intermediateStops || []).map(placeToStopover),
placeToStopover(leg.to || {})
];
return { trip: { stopovers } };
}
// ==========================================
// RADAR / LIVE-MAP (map/trips -> HAFAS-radar-Form)
//
// MOTIS liefert keine Punkt-Positionen, sondern Fahrt-SEGMENTE mit
// Polyline + Abfahrts-/Ankunftszeit. Die aktuelle Fahrzeugposition wird
// zeitlich entlang der Polyline interpoliert (so macht es die
// Transitous-Web-Karte selbst auch).
// ==========================================
// Google-Polyline dekodieren (Precision 5 laut MOTIS-Spec für map/trips)
function decodePolyline5(encoded) {
const points = [];
let index = 0, lat = 0, lon = 0;
while (index < encoded.length) {
for (const which of [0, 1]) {
let result = 0, shift = 0, byte;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const delta = (result & 1) ? ~(result >> 1) : (result >> 1);
if (which === 0) lat += delta; else lon += delta;
}
points.push([lat / 1e5, lon / 1e5]);
}
return points;
}
async function transitousRadarSegments(bounds, zoom) {
const now = new Date();
const path = `/v6/map/trips?zoom=${Math.round(zoom)}` +
`&min=${bounds.south.toFixed(3)},${bounds.west.toFixed(3)}` +
`&max=${bounds.north.toFixed(3)},${bounds.east.toFixed(3)}` +
`&startTime=${encodeURIComponent(now.toISOString())}` +
`&endTime=${encodeURIComponent(new Date(now.getTime() + 90000).toISOString())}`;
// 25s Cache: passt zum 30s-Polling, fängt Karten-Gezappel ab
const raw = await transitousFetch(path, 25000);
// Normalisieren: Polyline einmal dekodieren, Zeiten als ms.
// Die Live-Map interpoliert daraus jede Sekunde die Position LOKAL -
// flüssige Bewegung ohne einen einzigen zusätzlichen API-Call.
const segments = [];
for (const seg of (raw || [])) {
const depMs = new Date(seg.departure).getTime();
const arrMs = new Date(seg.arrival).getTime();
if (!Number.isFinite(depMs) || !Number.isFinite(arrMs) || !seg.polyline) continue;
let points;
try { points = decodePolyline5(seg.polyline); } catch (e) { continue; }
if (points.length === 0) continue;
const trip = (seg.trips && seg.trips[0]) || {};
segments.push({
tripId: trip.tripId || `${seg.mode}-${depMs}-${points[0][0].toFixed(4)}`,
lineName: trip.routeShortName || trip.displayName || '?',
product: motisModeToProduct(seg.mode),
color: motisColor(seg.routeColor),
direction: seg.to?.name || '',
delay: motisDelaySeconds(seg.departure, seg.scheduledDeparture),
depMs, arrMs, points
});
}
return segments;
}
// Aktuelle Position eines Segments zum Zeitpunkt nowMs (lineare Interpolation
// zwischen den Polyline-Punkten - genauer als nur den nächsten Punkt zu wählen)
function segmentPositionAt(seg, nowMs) {
if (nowMs < seg.depMs || nowMs > seg.arrMs) return null; // fährt gerade nicht
const pts = seg.points;
if (pts.length === 1) return { latitude: pts[0][0], longitude: pts[0][1] };
const frac = seg.arrMs > seg.depMs ? (nowMs - seg.depMs) / (seg.arrMs - seg.depMs) : 0;
const scaled = frac * (pts.length - 1);
const i = Math.min(pts.length - 2, Math.floor(scaled));
const t = scaled - i;
return {
latitude: pts[i][0] + (pts[i + 1][0] - pts[i][0]) * t,
longitude: pts[i][1] + (pts[i + 1][1] - pts[i][1]) * t
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vbb-netz-status",
"version": "33.1.0",
"version": "36.0.0",
"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",
"scripts": {
+21 -3
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'vbb-status-v33';
const CACHE_NAME = 'vbb-status-v36';
// App-Shell: nur Dateien, die wirklich existieren!
// (Der alte SW listete eine nicht existierende Font-Datei – dadurch
@@ -7,6 +7,7 @@ const urlsToCache = [
'/',
'/index.html',
'/js/api.js',
'/js/transitous.js',
'/js/app.js',
'/js/livemap.js',
'/js/changelog.js',
@@ -50,7 +51,7 @@ self.addEventListener('activate', event => {
// Fetch-Strategie:
// - API-Requests (cross-origin, z.B. v6.vbb.transport.rest, Karten-Tiles):
// NIE vom SW cachen -> direkt durchreichen. Das Rate-Limit- und
// Caching-Management macht die App selbst (apiFetch in js/api.js).
// Caching-Management macht die App selbst (apiFetch in js/api.js, Endpoints in js/transitous.js).
// - App-Shell (HTML/JS/CSS): Network-first mit Cache-Fallback.
// So bekommen Nutzer nach einem Deploy sofort die neue Version,
// und offline funktioniert die App trotzdem weiter.
@@ -62,7 +63,24 @@ self.addEventListener('fetch', event => {
const url = new URL(request.url);
// Cross-Origin (API, Leaflet-CDN, Tiles): nicht anfassen
// Leaflet vom CDN: versionierte, unveränderliche Dateien -> cache-first.
// Beim zweiten Besuch lädt die Karte damit ohne Netzwerk-Roundtrip.
if (url.hostname === 'unpkg.com' && url.pathname.includes('leaflet@')) {
event.respondWith(
caches.match(request).then(cached =>
cached || fetch(request).then(response => {
if (response && response.status === 200) {
const copy = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, copy));
}
return response;
})
)
);
return;
}
// Übriges Cross-Origin (API, Karten-Tiles): nicht anfassen
if (url.origin !== self.location.origin) return;
event.respondWith(
+164 -5
View File
@@ -1760,12 +1760,7 @@
z-index: 10010 !important;
}
/* Leaflet Zoom Controls nur Desktop */
@media (max-width: 767px) {
.leaflet-control-zoom {
display: none !important;
}
.livemap-controls {
top: 5px;
left: 5px;
@@ -3802,3 +3797,167 @@ select:focus-visible {
outline: 2px solid #FFED00;
outline-offset: 2px;
}
/* ==========================================
v33.2: Störungs-Banner & Such-Fehlerhinweis
========================================== */
.api-outage-banner {
position: sticky;
top: 0;
z-index: 9999;
background: linear-gradient(135deg, #8B0000 0%, #B22222 100%);
color: #fff;
padding: 10px 16px;
text-align: center;
font-size: 13px;
border-bottom: 2px solid #FFED00;
animation: outage-slide-in 0.3s ease-out;
}
@keyframes outage-slide-in {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
.suggestion-error {
color: #FFC966;
cursor: default;
pointer-events: none;
}
/* ==========================================
v35: Live-Map-Animation & GUI-Politur
========================================== */
/* Flüssige Fahrzeug-Bewegung: 1s-Transition passt exakt zum
1s-Animationstakt -> Fahrzeuge GLEITEN statt zu springen */
.leaflet-marker-icon.custom-marker {
transition: transform 1s linear;
}
/* Pulsierender Live-Punkt am Fahrzeugzähler */
.live-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4CAF50;
animation: live-pulse 2s ease-in-out infinite;
vertical-align: middle;
margin-right: 4px;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.5); }
50% { opacity: 0.6; box-shadow: 0 0 0 5px rgba(76, 175, 80, 0); }
}
/* Abfahrten: ruhige, gleichbleibende Zahlendarstellung
(keine Layout-Sprünge, wenn "9 min" zu "10 min" wird) */
.departure-time {
font-variant-numeric: tabular-nums;
min-width: 64px;
text-align: right;
}
/* Verspätungs-Badge dezenter und konsistenter */
.departure-delay {
font-size: 11px;
color: #FF6B6B;
white-space: nowrap;
}
/* Sanfter Übergang bei Wert-Updates durch Auto-Refresh */
.departure-item {
transition: background-color 0.3s ease;
}
/* Vorschlags-Dropdown: klarere Trennung, sanftes Einblenden */
.suggestions.active {
animation: sug-fade-in 0.15s ease-out;
}
@keyframes sug-fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Reduzierte Bewegung respektieren (Barrierefreiheit) */
@media (prefers-reduced-motion: reduce) {
.leaflet-marker-icon.custom-marker,
.departure-item,
.suggestions.active {
transition: none;
animation: none;
}
.live-dot { animation: none; }
}
/* ==========================================
v36: Desktop-Layout - Ergänzungen
(Basis-Desktop-Regeln existieren bereits im 768px-Block weiter oben:
Journey-Grid, Header-Größen, Hover-Effekte. Hier nur die Lücken.)
========================================== */
@media (min-width: 768px) {
/* Header-Inhalt und Pull-to-Refresh am zentrierten Layout ausrichten */
.header-content,
.ptr-container {
max-width: 720px;
margin: 0 auto;
}
/* Home: Info-Karten und Favoriten als 2-Spalten-Raster */
.home-quick-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.home-favorites-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
/* Live-Map: mehr sichtbare Kartenfläche */
.livemap-container {
height: calc(100vh - 110px);
}
}
/* --- Große Desktops (ab 1100px): breiterer Inhalt, luftigere Listen --- */
@media (min-width: 1100px) {
.container,
.header-content,
.ptr-container {
max-width: 780px;
}
.container {
padding: 28px 24px;
}
.departure-item {
padding: 16px 18px;
}
.line-badge {
font-size: 15px;
padding: 5px 12px;
}
.departure-time {
font-size: 18px;
min-width: 76px;
}
.detail-modal {
max-width: 720px;
}
.livemap-container {
height: calc(100vh - 100px);
}
}