Add files via upload
This commit is contained in:
1 parent
33f3483055
commit
45ee14c25a
12 files changed
+3493
-79
No files matched your search
@@ -5,6 +5,63 @@ 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/).
|
||||||
|
|
||||||
|
## [33.0.0] - 2026-07-03
|
||||||
|
|
||||||
|
### Favoriten, Persistenz, echte Filter & Modul-Architektur
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Echte Favoriten**: Stern-Button an jeder Station speichert sie in localStorage; Favoriten erscheinen auf dem Home-Screen und öffnen mit einem Tap die Abfahrten. (Vorher war der Button eine Attrappe mit `alert()`.)
|
||||||
|
- **Teilen-Button**: Nutzt die native Teilen-Funktion des Geräts (Web Share API), Fallback: in Zwischenablage kopieren.
|
||||||
|
- **Persistenz über App-Starts**: Letzte Station und letzte Route werden gemerkt und beim Start wiederhergestellt - ohne API-Call; geladen wird erst beim Öffnen der jeweiligen View.
|
||||||
|
- **Zeitwahl in der Routensuche**: "Abfahrt um" / "Ankunft bis" mit Datum/Uhrzeit (leer = jetzt).
|
||||||
|
- **Offline-Modus für Abfahrten**: Letzte Daten werden lokal gespeichert; ohne Verbindung werden sie mit Banner "Stand: vor X min" angezeigt.
|
||||||
|
- **Tastatur-Navigation** in allen Stationsvorschlägen (Pfeiltasten, Enter, Escape).
|
||||||
|
- **aria-Labels** für alle Icon-Buttons und Eingabefelder, `aria-live` für Statusanzeigen, sichtbarer Tastatur-Fokus.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- **Verkehrsmittel-Filter der Routensuche funktionieren jetzt wirklich**: Die Auswahl wurde vorher komplett ignoriert (kein `products`-Parameter in der API-URL). Fähren bleiben bewusst aktiv, da es keinen Button dafür gibt.
|
||||||
|
- **ReferenceError bei jedem Tastendruck behoben**: Der Search-Hint-Handler referenzierte eine Variable außerhalb ihres Scopes.
|
||||||
|
- **Fake-Daten entfernt**: Zufällige Gleisnummern, erfundene "GPS-Genauigkeit ±10m" und Alert-Attrappen (Export, Kalender) wurden angezeigt, als wären es echte Informationen.
|
||||||
|
- **Auto-Refresh bricht bei Fehlern nicht mehr permanent ab**: Exponentieller Backoff (30s → 60s → ... max 5min), automatische Erholung.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **Code in 5 Module aufgeteilt** (`js/api.js`, `app.js`, `livemap.js`, `changelog.js`, `extras.js`); geteilter Zustand zentral in api.js deklariert.
|
||||||
|
- **Live-Map-Marker werden bewegt statt neu erzeugt** (per tripId wiederverwendet): flüssigere Karte, offene Popups bleiben beim Update offen.
|
||||||
|
- **Radar-Ergebnisanzahl an Zoom gekoppelt** (80/140/200): kleinere API-Responses bei hohem Zoom.
|
||||||
|
- **Parallax-Effekt mit requestAnimationFrame gedrosselt** (max. 1 Update pro Frame statt pro Mausbewegung).
|
||||||
|
- **Alle console.log-Ausgaben entfernt** (Fehler/Warnungen bleiben).
|
||||||
|
|
||||||
|
## [32.0.0] - 2026-07-02
|
||||||
|
|
||||||
|
### Stabilität, Fehlerbehebungen & API-Optimierung
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- **Kritisch: ReferenceError-Crash behoben**: `const originalSearchJourneys = searchJourneys` referenzierte eine nie existierende Funktion. Der Fehler brach die Ausführung des restlichen Scripts ab (Hover-System, GPS-Handling u.a. luden nie).
|
||||||
|
- **Kritisch: Service Worker installierte sich nie**: Die Cache-Liste enthielt `/DotMatrix.ttf` (Datei existiert nicht), wodurch `cache.addAll()` komplett fehlschlug. Dateien werden jetzt einzeln gecacht und Fehler toleriert.
|
||||||
|
- **Doppeltes View-Switch-System entfernt**: Zwei konkurrierende `switchView`-Implementierungen und doppelte Menü-Handler feuerten bei jedem Klick beide. Jetzt gibt es genau ein generisches System, das alle Views (inkl. Developer) kennt.
|
||||||
|
- **Doppelte `escapeHtml`-Definition entfernt**: Die zweite Version escapte keine Anführungszeichen (Risiko in HTML-Attributen).
|
||||||
|
- **Changelog wurde bis zu 13× parallel geladen**: Drei redundante Lade-Mechanismen (switchView, MutationObserver, Tab-Handler) konkurrierend. Jetzt zentraler Guard, lädt genau einmal.
|
||||||
|
- **Fragiler `navigator.geolocation`-Override entfernt**: `Object.defineProperty` auf navigator wirft in manchen Browsern. GPS-Buttons werden auf Desktop weiterhin versteckt.
|
||||||
|
- **Pull-to-Refresh** löst nur noch in der Abfahrten-View aus.
|
||||||
|
- **Endlos-Logging entfernt**: Hover-Statistiken wurden alle 30s dauerhaft in die Konsole geschrieben.
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
- **DotMatrix-Font komplett entfernt**: Font-Datei existierte nicht mehr im Projekt; alle Referenzen aus CSS (19 Stellen), HTML-Featureliste und Service Worker entfernt. Fallback ist überall 'Courier New', monospace.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **Zentrale API-Schicht (`apiFetch`)**: Alle VBB-API-Aufrufe laufen jetzt durch eine Funktion mit:
|
||||||
|
- **Hartem Rate-Limit von 90 Requests/Minute** (API erlaubt 100 – 10 Puffer)
|
||||||
|
- **Response-Cache mit TTL pro Endpoint** (Stationssuche 6h, Nearby 2min, Abfahrten 15s, Radar 10s, Trips 60s, Journeys 30s)
|
||||||
|
- **Request-Deduplizierung**: identische parallele Anfragen werden nur einmal gefeuert
|
||||||
|
- **Cache-Fallback**: bei erreichtem Limit werden notfalls leicht veraltete Daten geliefert statt Fehler
|
||||||
|
- **Polling pausiert im Hintergrund**: Abfahrten-Auto-Refresh (30s) und Live-Map-Radar (15s) laufen nur, wenn der Tab sichtbar UND die jeweilige View aktiv ist. Bei Rückkehr in den Tab wird einmalig aufgefrischt.
|
||||||
|
- **Live-Map-Filter arbeiten lokal**: Filter-Klicks rendern die bereits geladenen Fahrzeugdaten neu, statt einen neuen Radar-Request auszulösen.
|
||||||
|
- **Radar-Koordinaten werden gerundet** (3 Dezimalstellen) für stabile Cache-Treffer bei minimalen Kartenbewegungen.
|
||||||
|
- **Service Worker: Network-first für App-Shell**: Nach einem Update bekommen Nutzer sofort neues HTML/JS/CSS; offline greift der Cache. API-Requests werden vom SW bewusst nicht gecacht (macht `apiFetch`).
|
||||||
|
- **Autocomplete-Debounce** von 300ms auf 400ms erhöht (weniger Requests beim Tippen).
|
||||||
|
- **XSS-Schutz**: Stationsnamen, Liniennamen und Richtungen aus der API werden vor dem Einfügen ins HTML escaped.
|
||||||
|
- **Nutzerfreundliche Fehlermeldungen** bei erreichtem API-Limit statt kryptischer Fehler.
|
||||||
|
|
||||||
## [31.0.0] - 2026-02-10
|
## [31.0.0] - 2026-02-10
|
||||||
|
|
||||||
### Developer View & Auto-Changelog System
|
### Developer View & Auto-Changelog System
|
||||||
|
|||||||
+2
-2
@@ -104,7 +104,7 @@ Danke, dass du zu VBB Netz Status beitragen möchtest! Diese Anleitung hilft dir
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Änderungen vornehmen**
|
2. **Änderungen vornehmen**
|
||||||
- Editiere `index.html`, `styles.css`, `script.js`
|
- Editiere `index.html`, `styles.css` oder die Module in `js/`
|
||||||
- Teste lokal: `https://localhost:3000`
|
- Teste lokal: `https://localhost:3000`
|
||||||
|
|
||||||
3. **Regelmäßig committen**
|
3. **Regelmäßig committen**
|
||||||
@@ -431,7 +431,7 @@ Vor Pull Request:
|
|||||||
|
|
||||||
### APIs
|
### APIs
|
||||||
- **VBB API**: https://v6.vbb.transport.rest/api.html
|
- **VBB API**: https://v6.vbb.transport.rest/api.html
|
||||||
- **BVG Farben**: Siehe `getBVGLineColor()` in script.js
|
- **BVG Farben**: Siehe `getBVGLineColor()` in js/livemap.js
|
||||||
|
|
||||||
### Libraries
|
### Libraries
|
||||||
- **Leaflet.js**: https://leafletjs.com/
|
- **Leaflet.js**: https://leafletjs.com/
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ Die App wurde jetzt in separate Dateien aufgeteilt:
|
|||||||
vbb-netz-status/
|
vbb-netz-status/
|
||||||
├── index.html # HTML-Struktur
|
├── index.html # HTML-Struktur
|
||||||
├── styles.css # Alle CSS-Styles
|
├── styles.css # Alle CSS-Styles
|
||||||
├── script.js # Alle JavaScript-Funktionen
|
├── js/
|
||||||
|
│ ├── api.js # API-Schicht, Rate-Limit, Cache, Favoriten, Persistenz
|
||||||
|
│ ├── app.js # Views, Abfahrten, Routenplaner, Menü
|
||||||
|
│ ├── livemap.js # Live-Map (Leaflet, Fahrzeug-Radar)
|
||||||
|
│ ├── changelog.js # Changelog-Loader + Markdown-Parser
|
||||||
|
│ └── extras.js # Filter, Tabs, App-Start-Restore, Hover-Extras
|
||||||
├── server.js # HTTPS-Entwicklungsserver
|
├── server.js # HTTPS-Entwicklungsserver
|
||||||
├── service-worker.js # PWA Service Worker
|
├── service-worker.js # PWA Service Worker
|
||||||
├── manifest.json # PWA Manifest
|
├── manifest.json # PWA Manifest
|
||||||
@@ -41,7 +46,7 @@ vbb-netz-status/
|
|||||||
**Nachher:**
|
**Nachher:**
|
||||||
- `index.html` - Nur HTML-Struktur (~380 Zeilen)
|
- `index.html` - Nur HTML-Struktur (~380 Zeilen)
|
||||||
- `styles.css` - Alle CSS-Regeln (~2300 Zeilen)
|
- `styles.css` - Alle CSS-Regeln (~2300 Zeilen)
|
||||||
- `script.js` - Alle JavaScript-Funktionen (~1990 Zeilen)
|
- `js/` - JavaScript, aufgeteilt in 5 Module (api, app, livemap, changelog, extras)
|
||||||
|
|
||||||
## ✅ Vorteile der Trennung
|
## ✅ Vorteile der Trennung
|
||||||
|
|
||||||
|
|||||||
+40
-14
@@ -24,7 +24,7 @@
|
|||||||
crossorigin=""></script>
|
crossorigin=""></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="offline-indicator" id="offlineIndicator">
|
<div class="offline-indicator" id="offlineIndicator" role="status">
|
||||||
📵 Offline-Modus - Zeige gespeicherte Daten
|
📵 Offline-Modus - Zeige gespeicherte Daten
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation Menu -->
|
<!-- Navigation Menu -->
|
||||||
<div class="nav-menu" id="navMenu">
|
<nav class="nav-menu" id="navMenu" aria-label="Hauptnavigation">
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
<h3>Funktionen</h3>
|
<h3>Funktionen</h3>
|
||||||
<button class="nav-close" id="navClose">×</button>
|
<button class="nav-close" id="navClose" aria-label="Menü schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-items">
|
<div class="nav-items">
|
||||||
<button class="nav-item" data-view="home">
|
<button class="nav-item" data-view="home">
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<span class="nav-label">Für Entwickler</span>
|
<span class="nav-label">Für Entwickler</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
<div class="nav-overlay" id="navOverlay"></div>
|
<div class="nav-overlay" id="navOverlay"></div>
|
||||||
|
|
||||||
<!-- View: Home -->
|
<!-- View: Home -->
|
||||||
@@ -94,10 +94,13 @@
|
|||||||
<div class="home-hero-title">
|
<div class="home-hero-title">
|
||||||
<h1>VBB NETZ STATUS</h1>
|
<h1>VBB NETZ STATUS</h1>
|
||||||
<p>Live-Nahverkehr für Berlin & Brandenburg</p>
|
<p>Live-Nahverkehr für Berlin & Brandenburg</p>
|
||||||
<div class="home-version">v31.0</div>
|
<div class="home-version">v33.0</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Favoriten (per JS aus localStorage gerendert) -->
|
||||||
|
<div class="home-favorites" id="homeFavorites" style="display: none;"></div>
|
||||||
|
|
||||||
<!-- Quick Info Cards -->
|
<!-- Quick Info Cards -->
|
||||||
<div class="home-quick-info">
|
<div class="home-quick-info">
|
||||||
<div class="home-info-card">
|
<div class="home-info-card">
|
||||||
@@ -148,12 +151,13 @@
|
|||||||
id="stationSearch"
|
id="stationSearch"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="Station suchen..."
|
placeholder="Station suchen..."
|
||||||
|
aria-label="Station suchen"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<button class="clear-btn" id="clearBtn">×</button>
|
<button class="clear-btn" id="clearBtn" aria-label="Suche löschen">×</button>
|
||||||
<div class="suggestions" id="suggestions"></div>
|
<div class="suggestions" id="suggestions"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="location-btn-side" id="locationBtn" title="Nächste Station per GPS finden">
|
<button class="location-btn-side" id="locationBtn" title="Nächste Station per GPS finden" aria-label="Nächste Station per GPS finden">
|
||||||
📍
|
📍
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,18 +195,19 @@
|
|||||||
id="journeyFrom"
|
id="journeyFrom"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="Startstation..."
|
placeholder="Startstation..."
|
||||||
|
aria-label="Startstation"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<button class="clear-btn" id="clearFromBtn">×</button>
|
<button class="clear-btn" id="clearFromBtn" aria-label="Startstation löschen">×</button>
|
||||||
<div class="suggestions" id="suggestionsFrom"></div>
|
<div class="suggestions" id="suggestionsFrom"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="location-btn-side" id="locationFromBtn" title="Aktuellen Standort nutzen">
|
<button class="location-btn-side" id="locationFromBtn" title="Aktuellen Standort nutzen" aria-label="Aktuellen Standort als Start nutzen">
|
||||||
📍
|
📍
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="swap-btn" id="swapBtn" title="Start und Ziel tauschen">
|
<button class="swap-btn" id="swapBtn" title="Start und Ziel tauschen" aria-label="Start und Ziel tauschen">
|
||||||
⇅
|
⇅
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -215,15 +220,32 @@
|
|||||||
id="journeyTo"
|
id="journeyTo"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="Zielstation..."
|
placeholder="Zielstation..."
|
||||||
|
aria-label="Zielstation"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<button class="clear-btn" id="clearToBtn">×</button>
|
<button class="clear-btn" id="clearToBtn" aria-label="Zielstation löschen">×</button>
|
||||||
<div class="suggestions" id="suggestionsTo"></div>
|
<div class="suggestions" id="suggestionsTo"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Zeitwahl: Abfahrt/Ankunft -->
|
||||||
|
<div class="journey-time-row">
|
||||||
|
<label class="journey-label" for="journeyTimeMode">🕐 Zeit</label>
|
||||||
|
<div class="journey-time-controls">
|
||||||
|
<select id="journeyTimeMode" class="journey-time-select" aria-label="Abfahrts- oder Ankunftszeit">
|
||||||
|
<option value="departure">Abfahrt um</option>
|
||||||
|
<option value="arrival">Ankunft bis</option>
|
||||||
|
</select>
|
||||||
|
<input type="datetime-local" id="journeyTimeInput" class="journey-time-input"
|
||||||
|
aria-label="Datum und Uhrzeit (leer = jetzt)">
|
||||||
|
<button type="button" id="journeyTimeClear" class="clear-btn journey-time-clear"
|
||||||
|
aria-label="Zeitwahl zurücksetzen" title="Zurücksetzen (= jetzt)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="journey-time-hint">Leer lassen = jetzt abfahren</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<div style="font-size: 12px; opacity: 0.7; text-align: center; margin-bottom: 10px; color: #FFED00;">
|
<div style="font-size: 12px; opacity: 0.7; text-align: center; margin-bottom: 10px; color: #FFED00;">
|
||||||
Garantiert mind. 5 Min Umstiegszeit
|
Garantiert mind. 5 Min Umstiegszeit
|
||||||
@@ -252,7 +274,7 @@
|
|||||||
<div class="livemap-container">
|
<div class="livemap-container">
|
||||||
<div class="livemap-controls">
|
<div class="livemap-controls">
|
||||||
<div class="livemap-info">
|
<div class="livemap-info">
|
||||||
<span id="vehicleCount">🚇 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 active" data-type="all">🌐 Alle</button>
|
<button class="filter-btn active" data-type="all">🌐 Alle</button>
|
||||||
@@ -412,7 +434,6 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Dark Mode (Standard)</li>
|
<li>Dark Mode (Standard)</li>
|
||||||
<li>Responsive Design (Mobile → Desktop)</li>
|
<li>Responsive Design (Mobile → Desktop)</li>
|
||||||
<li>DotMatrix Retro-Font</li>
|
|
||||||
<li>Touch-optimiert</li>
|
<li>Touch-optimiert</li>
|
||||||
<li>Hover-Effekte (Desktop)</li>
|
<li>Hover-Effekte (Desktop)</li>
|
||||||
<li>Accessibility (WCAG)</li>
|
<li>Accessibility (WCAG)</li>
|
||||||
@@ -448,6 +469,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="script.js"></script>
|
<!-- Module in fester Reihenfolge (klassische Scripts, gemeinsamer Scope) -->
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
<script src="js/livemap.js"></script>
|
||||||
|
<script src="js/changelog.js"></script>
|
||||||
|
<script src="js/extras.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
// VBB Netz Status - Changelog-Loader + Markdown-Parser
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// UPDATES VIEW - CHANGELOG LOADER
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CHANGELOG LOADER - VERBESSERT
|
||||||
|
// Fetcht CHANGELOG.md und konvertiert Markdown zu HTML
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
let changelogState = 'idle'; // 'idle' | 'loading' | 'loaded'
|
||||||
|
|
||||||
|
async function loadChangelog() {
|
||||||
|
// Guard: verhindert parallele/mehrfache Ladungen
|
||||||
|
if (changelogState !== 'idle') return;
|
||||||
|
changelogState = 'loading';
|
||||||
|
|
||||||
|
const container = document.getElementById('changelogContent');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Lade CHANGELOG.md
|
||||||
|
const response = await fetch('CHANGELOG.md');
|
||||||
|
if (!response.ok) throw new Error('CHANGELOG.md nicht gefunden');
|
||||||
|
|
||||||
|
const markdown = await response.text();
|
||||||
|
|
||||||
|
// Konvertiere Markdown zu HTML
|
||||||
|
const html = markdownToHtml(markdown);
|
||||||
|
|
||||||
|
// Wrapper mit changelog-container Klasse
|
||||||
|
container.innerHTML = `<div class="changelog-container">${html}</div>`;
|
||||||
|
changelogState = 'loaded';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Changelog Fehler:', error);
|
||||||
|
changelogState = 'idle'; // erneuter Versuch möglich
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="changelog-container">
|
||||||
|
<div style="text-align: center; padding: 40px 20px; color: #ff5555;">
|
||||||
|
<p style="font-size: 18px; margin-bottom: 10px;">⚠️ Changelog konnte nicht geladen werden</p>
|
||||||
|
<p style="font-size: 14px; opacity: 0.7;">${escapeHtml(error.message)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MARKDOWN TO HTML CONVERTER
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function markdownToHtml(markdown) {
|
||||||
|
let html = '';
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
let inList = false;
|
||||||
|
let inOrderedList = false;
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let codeBlockContent = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i];
|
||||||
|
|
||||||
|
// Code-Block Start/Ende (```)
|
||||||
|
if (line.trim().startsWith('```')) {
|
||||||
|
if (!inCodeBlock) {
|
||||||
|
inCodeBlock = true;
|
||||||
|
codeBlockContent = '';
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
inCodeBlock = false;
|
||||||
|
html += `<pre><code>${escapeHtml(codeBlockContent)}</code></pre>`;
|
||||||
|
codeBlockContent = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Innerhalb Code-Block
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeBlockContent += line + '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leere Zeile - Schließe Listen
|
||||||
|
if (line.trim() === '') {
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
html += '</ol>';
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überschriften (# ## ### ####)
|
||||||
|
if (line.match(/^(#{1,6})\s+(.+)$/)) {
|
||||||
|
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
const level = match[1].length;
|
||||||
|
const text = parseInline(match[2]);
|
||||||
|
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
html += '</ol>';
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<h${level}>${text}</h${level}>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontale Linie (--- oder ***)
|
||||||
|
if (line.match(/^(---+|\*\*\*+)$/)) {
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
html += '</ol>';
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
html += '<hr>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungeordnete Liste (- oder *)
|
||||||
|
if (line.match(/^[-*]\s+(.+)$/)) {
|
||||||
|
const match = line.match(/^[-*]\s+(.+)$/);
|
||||||
|
const text = parseInline(match[1]);
|
||||||
|
|
||||||
|
if (inOrderedList) {
|
||||||
|
html += '</ol>';
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inList) {
|
||||||
|
html += '<ul>';
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<li>${text}</li>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geordnete Liste (1. 2. 3.)
|
||||||
|
if (line.match(/^\d+\.\s+(.+)$/)) {
|
||||||
|
const match = line.match(/^\d+\.\s+(.+)$/);
|
||||||
|
const text = parseInline(match[1]);
|
||||||
|
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inOrderedList) {
|
||||||
|
html += '<ol>';
|
||||||
|
inOrderedList = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<li>${text}</li>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote (>)
|
||||||
|
if (line.match(/^>\s+(.+)$/)) {
|
||||||
|
const match = line.match(/^>\s+(.+)$/);
|
||||||
|
const text = parseInline(match[1]);
|
||||||
|
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
html += '</ol>';
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<blockquote>${text}</blockquote>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normaler Paragraph
|
||||||
|
if (line.trim() !== '') {
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
html += '</ol>';
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = parseInline(line);
|
||||||
|
html += `<p>${text}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schließe offene Listen am Ende
|
||||||
|
if (inList) html += '</ul>';
|
||||||
|
if (inOrderedList) html += '</ol>';
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// INLINE MARKDOWN PARSER
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function parseInline(text) {
|
||||||
|
// Links [text](url)
|
||||||
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
||||||
|
|
||||||
|
// Inline Code `code`
|
||||||
|
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Bold **text** oder __text__
|
||||||
|
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic *text* oder _text_ (aber nicht ** oder __)
|
||||||
|
text = text.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||||
|
text = text.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Strikethrough ~~text~~
|
||||||
|
text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HINWEIS: Die frühere zweite escapeHtml-Definition wurde entfernt.
|
||||||
|
// Sie überschrieb die zentrale Version und escapte keine Anführungszeichen
|
||||||
|
// (gefährlich in HTML-Attributen). Ebenso entfernt: der MutationObserver
|
||||||
|
// für den Changelog - switchView() und der Dev-Tab-Handler laden ihn bereits,
|
||||||
|
// der Observer führte zu mehrfachen parallelen Fetches.
|
||||||
|
|
||||||
|
|
||||||
+607
@@ -0,0 +1,607 @@
|
|||||||
|
// VBB Netz Status - Filter, Tabs, App-Start-Restore, Hover-Extras
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MOBILE DETECTION - GPS NUR AUF MOBILE
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function isMobileDevice() {
|
||||||
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||||
|
|| window.innerWidth <= 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPS-Buttons verstecken auf Desktop
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (!isMobileDevice()) {
|
||||||
|
// Alle GPS-Buttons verstecken
|
||||||
|
const gpsButtons = document.querySelectorAll('.location-btn-side, #locationBtn, #locationFromBtn');
|
||||||
|
gpsButtons.forEach(btn => {
|
||||||
|
if (btn) btn.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DEVELOPER TABS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const tabs = document.querySelectorAll('.dev-tab');
|
||||||
|
const contents = document.querySelectorAll('.dev-tab-content');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const targetTab = tab.dataset.tab;
|
||||||
|
|
||||||
|
// Deaktiviere alle Tabs
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
contents.forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
// Aktiviere gewählten Tab
|
||||||
|
tab.classList.add('active');
|
||||||
|
const targetContent = document.getElementById(`dev-${targetTab}`);
|
||||||
|
if (targetContent) targetContent.classList.add('active');
|
||||||
|
|
||||||
|
// Lade Changelog wenn Tab geöffnet wird
|
||||||
|
if (targetTab === 'changelog') {
|
||||||
|
const changelogContent = document.getElementById('changelogContent');
|
||||||
|
if (changelogContent && changelogContent.innerHTML.includes('Lade Changelog')) {
|
||||||
|
loadChangelog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vibration Feedback
|
||||||
|
if (navigator.vibrate) navigator.vibrate(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ABFAHRTEN-FILTER
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
let activeDepartureFilters = new Set(['all']);
|
||||||
|
let allDepartures = []; // Alle Abfahrten speichern
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.departure-filters .filter-btn')) {
|
||||||
|
const btn = e.target.closest('.filter-btn');
|
||||||
|
const filterType = btn.dataset.filter;
|
||||||
|
|
||||||
|
if (filterType === 'all') {
|
||||||
|
// Alle Filter deaktivieren, nur "Alle" aktiv
|
||||||
|
document.querySelectorAll('.departure-filters .filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
activeDepartureFilters.clear();
|
||||||
|
activeDepartureFilters.add('all');
|
||||||
|
} else {
|
||||||
|
// "Alle" deaktivieren
|
||||||
|
document.querySelector('.departure-filters .filter-btn[data-filter="all"]')?.classList.remove('active');
|
||||||
|
activeDepartureFilters.delete('all');
|
||||||
|
|
||||||
|
// Toggle Filter
|
||||||
|
if (activeDepartureFilters.has(filterType)) {
|
||||||
|
activeDepartureFilters.delete(filterType);
|
||||||
|
btn.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
activeDepartureFilters.add(filterType);
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls keine Filter aktiv, "Alle" wieder aktivieren
|
||||||
|
if (activeDepartureFilters.size === 0) {
|
||||||
|
activeDepartureFilters.add('all');
|
||||||
|
document.querySelector('.departure-filters .filter-btn[data-filter="all"]')?.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abfahrten neu filtern und anzeigen
|
||||||
|
filterDepartures();
|
||||||
|
|
||||||
|
if (navigator.vibrate) navigator.vibrate(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function filterDepartures() {
|
||||||
|
if (!allDepartures || allDepartures.length === 0) return;
|
||||||
|
|
||||||
|
let filtered = allDepartures;
|
||||||
|
|
||||||
|
if (!activeDepartureFilters.has('all')) {
|
||||||
|
filtered = allDepartures.filter(dep => {
|
||||||
|
const product = dep.line?.product?.toLowerCase() || '';
|
||||||
|
return activeDepartureFilters.has(product);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container neu rendern
|
||||||
|
const container = document.getElementById('departuresContainer');
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div style="text-align: center; padding: 40px; color: #FFED00;">Keine Abfahrten mit diesem Filter</div>';
|
||||||
|
} else {
|
||||||
|
displayDepartures(filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VERKEHRSMITTEL-FILTER (Routenplaner)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// (activeTransportModes ist zentral in js/api.js deklariert)
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.journey-transport-filters .transport-filter-btn')) {
|
||||||
|
const btn = e.target.closest('.transport-filter-btn');
|
||||||
|
const transport = btn.dataset.transport;
|
||||||
|
|
||||||
|
// Toggle Filter
|
||||||
|
if (activeTransportModes.has(transport)) {
|
||||||
|
if (activeTransportModes.size > 1) { // Mindestens 1 muss aktiv bleiben
|
||||||
|
activeTransportModes.delete(transport);
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeTransportModes.add(transport);
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.vibrate) navigator.vibrate(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getActiveTransportModes() {
|
||||||
|
const mapping = {
|
||||||
|
'subway': 'subway',
|
||||||
|
'suburban': 'suburban',
|
||||||
|
'tram': 'tram',
|
||||||
|
'bus': 'bus',
|
||||||
|
'regional': 'regional',
|
||||||
|
'express': 'express'
|
||||||
|
};
|
||||||
|
|
||||||
|
const modes = [];
|
||||||
|
activeTransportModes.forEach(mode => {
|
||||||
|
if (mapping[mode]) modes.push(mapping[mode]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return modes.length > 0 ? modes : ['subway', 'suburban', 'tram', 'bus', 'regional', 'express'];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PATCHES FÜR FILTER-INTEGRATION
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Original loadDepartures überschreiben
|
||||||
|
const originalLoadDepartures = loadDepartures;
|
||||||
|
loadDepartures = async function() {
|
||||||
|
await originalLoadDepartures();
|
||||||
|
|
||||||
|
// Filter-UI einblenden wenn Abfahrten geladen
|
||||||
|
const filtersEl = document.getElementById('departureFilters');
|
||||||
|
if (filtersEl && allDepartures.length > 0) {
|
||||||
|
filtersEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// displayDepartures erweitern um allDepartures zu speichern
|
||||||
|
const originalDisplayDepartures = displayDepartures;
|
||||||
|
displayDepartures = function(departures) {
|
||||||
|
// Speichere alle Abfahrten für Filter
|
||||||
|
if (!activeDepartureFilters || activeDepartureFilters.has('all')) {
|
||||||
|
allDepartures = departures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normale Anzeige
|
||||||
|
originalDisplayDepartures(departures);
|
||||||
|
};
|
||||||
|
|
||||||
|
// HINWEIS: Der frühere "searchJourneys"-Patch wurde entfernt.
|
||||||
|
// Die Funktion existierte nie und verursachte einen ReferenceError,
|
||||||
|
// der das restliche Script abgebrochen hat.
|
||||||
|
// Der ebenfalls entfernte navigator.geolocation-Override war fragil
|
||||||
|
// (wirft in manchen Browsern) - GPS-Buttons werden auf Desktop
|
||||||
|
// bereits per CSS/display:none versteckt, das genügt.
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VERBESSERTE HOVER-INTERAKTIONEN
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Nur auf Desktop (Hover-fähige Geräte)
|
||||||
|
if (window.matchMedia('(hover: hover)').matches) {
|
||||||
|
|
||||||
|
// Departure Items - Preview bei Hover
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const departureItem = e.target.closest('.departure-item');
|
||||||
|
if (departureItem) {
|
||||||
|
// Smooth Highlight
|
||||||
|
departureItem.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Journey Results - Highlight Route bei Hover
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const journeyResult = e.target.closest('.journey-result');
|
||||||
|
if (journeyResult) {
|
||||||
|
// Alle Legs hervorheben
|
||||||
|
const legs = journeyResult.querySelectorAll('.journey-leg');
|
||||||
|
legs.forEach((leg, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
leg.style.transform = 'translateX(4px)';
|
||||||
|
leg.style.transition = 'transform 0.2s ease';
|
||||||
|
}, index * 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseout', (e) => {
|
||||||
|
const journeyResult = e.target.closest('.journey-result');
|
||||||
|
if (journeyResult) {
|
||||||
|
const legs = journeyResult.querySelectorAll('.journey-leg');
|
||||||
|
legs.forEach(leg => {
|
||||||
|
leg.style.transform = 'translateX(0)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter Buttons - Visual Feedback
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const filterBtn = e.target.closest('.filter-btn, .transport-filter-btn');
|
||||||
|
if (filterBtn && !filterBtn.classList.contains('active')) {
|
||||||
|
// Ripple-Effekt simulieren
|
||||||
|
filterBtn.style.background = 'radial-gradient(circle at center, rgba(255, 237, 0, 0.2), transparent)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseout', (e) => {
|
||||||
|
const filterBtn = e.target.closest('.filter-btn, .transport-filter-btn');
|
||||||
|
if (filterBtn && !filterBtn.classList.contains('active')) {
|
||||||
|
filterBtn.style.background = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation Items - Smooth Slide
|
||||||
|
const navItems = document.querySelectorAll('.nav-item');
|
||||||
|
navItems.forEach(item => {
|
||||||
|
item.addEventListener('mouseenter', function() {
|
||||||
|
this.style.paddingLeft = '25px';
|
||||||
|
});
|
||||||
|
item.addEventListener('mouseleave', function() {
|
||||||
|
this.style.paddingLeft = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabs - Preview Content
|
||||||
|
const devTabs = document.querySelectorAll('.dev-tab');
|
||||||
|
devTabs.forEach(tab => {
|
||||||
|
tab.addEventListener('mouseenter', function() {
|
||||||
|
if (!this.classList.contains('active')) {
|
||||||
|
this.style.borderTopWidth = '3px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tab.addEventListener('mouseleave', function() {
|
||||||
|
if (!this.classList.contains('active')) {
|
||||||
|
this.style.borderTopWidth = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search Input - Auto-Focus bei Hover (subtil)
|
||||||
|
const searchInputs = document.querySelectorAll('.search-input');
|
||||||
|
searchInputs.forEach(input => {
|
||||||
|
input.addEventListener('mouseenter', function() {
|
||||||
|
this.style.borderWidth = '2px';
|
||||||
|
});
|
||||||
|
input.addEventListener('mouseleave', function() {
|
||||||
|
if (document.activeElement !== this) {
|
||||||
|
this.style.borderWidth = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suggestion Items - Smooth Selection
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const suggestionItem = e.target.closest('.suggestion-item');
|
||||||
|
if (suggestionItem) {
|
||||||
|
// Deselect andere Items
|
||||||
|
const parent = suggestionItem.parentElement;
|
||||||
|
parent.querySelectorAll('.suggestion-item').forEach(item => {
|
||||||
|
if (item !== suggestionItem) {
|
||||||
|
item.style.opacity = '0.6';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
suggestionItem.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseout', (e) => {
|
||||||
|
const suggestionsContainer = e.target.closest('.suggestions');
|
||||||
|
if (!suggestionsContainer || !suggestionsContainer.contains(e.relatedTarget)) {
|
||||||
|
document.querySelectorAll('.suggestion-item').forEach(item => {
|
||||||
|
item.style.opacity = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Version Boxes - Smooth Expand Preview
|
||||||
|
const versionHeaders = document.querySelectorAll('.version-header');
|
||||||
|
versionHeaders.forEach(header => {
|
||||||
|
header.addEventListener('mouseenter', function() {
|
||||||
|
const versionBox = this.parentElement;
|
||||||
|
if (!versionBox.classList.contains('open')) {
|
||||||
|
const content = versionBox.querySelector('.version-content');
|
||||||
|
// Mini-Preview zeigen
|
||||||
|
content.style.maxHeight = '60px';
|
||||||
|
content.style.overflow = 'hidden';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!versionBox.classList.contains('open')) {
|
||||||
|
content.style.maxHeight = '0';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live Map Filters - Tooltip Preview
|
||||||
|
const mapFilterBtns = document.querySelectorAll('.livemap-filters .filter-btn');
|
||||||
|
mapFilterBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('mouseenter', function() {
|
||||||
|
const type = this.dataset.type;
|
||||||
|
const count = document.querySelectorAll(`.vehicle-marker[data-type="${type}"]`).length;
|
||||||
|
// Zeige Count in Button
|
||||||
|
if (count > 0 && !this.querySelector('.filter-count')) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'filter-count';
|
||||||
|
badge.textContent = count;
|
||||||
|
badge.style.cssText = 'position: absolute; top: -5px; right: -5px; background: #ff0000; color: #fff; border-radius: 10px; padding: 2px 6px; font-size: 10px; font-weight: bold;';
|
||||||
|
this.style.position = 'relative';
|
||||||
|
this.appendChild(badge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btn.addEventListener('mouseleave', function() {
|
||||||
|
const badge = this.querySelector('.filter-count');
|
||||||
|
if (badge) badge.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smooth Page Load
|
||||||
|
document.body.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.style.transition = 'opacity 0.5s ease';
|
||||||
|
document.body.style.opacity = '1';
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallax-Effekt für Header
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (window.matchMedia('(hover: hover)').matches) {
|
||||||
|
const header = document.querySelector('.header');
|
||||||
|
if (header) {
|
||||||
|
// Mit requestAnimationFrame gedrosselt: max. 1 Update pro Frame
|
||||||
|
// statt bei jeder einzelnen Mausbewegung (weniger Layout-Arbeit)
|
||||||
|
header.style.transition = 'transform 0.3s ease-out'; // einmalig statt pro Event
|
||||||
|
let parallaxPending = false;
|
||||||
|
let lastX = 0, lastY = 0;
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
if (parallaxPending) return;
|
||||||
|
parallaxPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const x = (lastX / window.innerWidth - 0.5) * 10;
|
||||||
|
const y = (lastY / window.innerHeight - 0.5) * 10;
|
||||||
|
header.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
|
parallaxPending = false;
|
||||||
|
});
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smooth Scroll bei Navigation
|
||||||
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.querySelector(this.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// APP-START: GESPEICHERTEN ZUSTAND WIEDERHERSTELLEN
|
||||||
|
// ==========================================
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Favoriten auf dem Home-Screen anzeigen
|
||||||
|
renderFavorites();
|
||||||
|
|
||||||
|
// Letzte Station wiederherstellen (OHNE sofortigen API-Call -
|
||||||
|
// geladen wird erst, wenn die Abfahrten-View geöffnet wird)
|
||||||
|
const lastStation = storageGet(STORAGE_KEYS.lastStation);
|
||||||
|
if (lastStation && lastStation.id) {
|
||||||
|
currentStationId = lastStation.id;
|
||||||
|
currentStationName = lastStation.name;
|
||||||
|
stationSearch.value = lastStation.name;
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
updateFavoriteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Route wiederherstellen
|
||||||
|
const lastJourney = storageGet(STORAGE_KEYS.lastJourney);
|
||||||
|
if (lastJourney && lastJourney.from && lastJourney.to) {
|
||||||
|
journeyFromStation = lastJourney.from;
|
||||||
|
journeyToStation = lastJourney.to;
|
||||||
|
journeyFrom.value = lastJourney.from.name;
|
||||||
|
journeyTo.value = lastJourney.to.name;
|
||||||
|
checkJourneySearchReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klick auf Favorit (Home-Screen) -> direkt zu den Abfahrten
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const favItem = e.target.closest('.home-favorite-item');
|
||||||
|
if (favItem) {
|
||||||
|
switchView('departures');
|
||||||
|
selectStation(favItem.dataset.id, favItem.dataset.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mindestzeit für die Zeitwahl: jetzt (keine Suche in der Vergangenheit)
|
||||||
|
const timeInput = document.getElementById('journeyTimeInput');
|
||||||
|
if (timeInput) {
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const d = new Date();
|
||||||
|
timeInput.min = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeitwahl zurücksetzen (= jetzt abfahren)
|
||||||
|
const timeClear = document.getElementById('journeyTimeClear');
|
||||||
|
if (timeClear && timeInput) {
|
||||||
|
timeClear.addEventListener('click', () => {
|
||||||
|
timeInput.value = '';
|
||||||
|
if (navigator.vibrate) navigator.vibrate(5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TASTATUR-NAVIGATION FÜR STATIONSVORSCHLÄGE (Barrierefreiheit)
|
||||||
|
// Pfeil hoch/runter wählt, Enter bestätigt, Escape schließt
|
||||||
|
// ==========================================
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.search-container').forEach(container => {
|
||||||
|
const input = container.querySelector('.search-input');
|
||||||
|
const list = container.querySelector('.suggestions');
|
||||||
|
if (!input || !list) return;
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
const items = Array.from(list.querySelectorAll('.suggestion-item'));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
const activeIdx = items.findIndex(i => i.classList.contains('kb-active'));
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
const next = (activeIdx + dir + items.length) % items.length;
|
||||||
|
items.forEach(i => i.classList.remove('kb-active'));
|
||||||
|
items[next].classList.add('kb-active');
|
||||||
|
items[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
items[activeIdx].click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
list.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HOVER-EXTRAS (bereinigt)
|
||||||
|
// Frühere Version zeigte FAKE-Daten (zufällige Gleisnummern,
|
||||||
|
// erfundene "Genauigkeit ±10m", Alert-Attrappen für Favorit/Teilen/
|
||||||
|
// Export/Kalender) und hatte einen Scope-Bug im Search-Hint,
|
||||||
|
// der bei jedem Tastendruck einen ReferenceError warf.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (window.matchMedia('(hover: hover)').matches) {
|
||||||
|
|
||||||
|
// Navigation Items - Quick Info hinzufügen
|
||||||
|
const navQuickInfos = [
|
||||||
|
{ selector: '[data-view="home"]', info: 'Startseite' },
|
||||||
|
{ selector: '[data-view="departures"]', info: 'Live-Abfahrten' },
|
||||||
|
{ selector: '[data-view="journey"]', info: 'Routenplaner' },
|
||||||
|
{ selector: '[data-view="livemap"]', info: 'Fahrzeuge live' },
|
||||||
|
{ selector: '[data-view="developer"]', info: 'Changelog & Infos' }
|
||||||
|
];
|
||||||
|
|
||||||
|
navQuickInfos.forEach(item => {
|
||||||
|
const el = document.querySelector(item.selector);
|
||||||
|
if (el && !el.querySelector('.nav-quick-info')) {
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'nav-quick-info';
|
||||||
|
info.textContent = item.info;
|
||||||
|
el.appendChild(info);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search Hints (Scope-Bug behoben: hint jetzt im richtigen Scope)
|
||||||
|
document.querySelectorAll('.search-input').forEach(input => {
|
||||||
|
let hint = input.parentElement.querySelector('.search-hint');
|
||||||
|
if (!hint) {
|
||||||
|
hint = document.createElement('div');
|
||||||
|
hint.className = 'search-hint';
|
||||||
|
hint.textContent = 'Tippe mindestens 2 Zeichen';
|
||||||
|
input.parentElement.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const len = input.value.length;
|
||||||
|
if (len === 0) {
|
||||||
|
hint.textContent = 'Tippe mindestens 2 Zeichen';
|
||||||
|
} else if (len === 1) {
|
||||||
|
hint.textContent = 'Noch 1 Zeichen...';
|
||||||
|
} else {
|
||||||
|
hint.textContent = 'Suche aktiv';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rich Tooltips (nur belegbare Aussagen)
|
||||||
|
const tooltips = {
|
||||||
|
'#refreshBtn': 'Lädt die neuesten Abfahrtszeiten. Auto-Refresh alle 30 Sekunden.',
|
||||||
|
'#searchJourneyBtn': 'Sucht Routen zwischen zwei Stationen mit Echtzeit-Daten.',
|
||||||
|
'#locationBtn': 'Findet die nächstgelegene Station per GPS.',
|
||||||
|
'#swapBtn': 'Vertauscht Start- und Zielstation.'
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(tooltips).forEach(([selector, tooltip]) => {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (el) {
|
||||||
|
el.setAttribute('data-tooltip', tooltip);
|
||||||
|
el.style.position = 'relative';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick Actions an Abfahrten: ECHTE Funktionen statt Alert-Attrappen
|
||||||
|
// (Event-Delegation statt MutationObserver - robuster und günstiger)
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const favBtn = e.target.closest('.qa-favorite');
|
||||||
|
if (favBtn && currentStationId) {
|
||||||
|
toggleFavorite(currentStationId, currentStationName);
|
||||||
|
favBtn.textContent = isFavorite(currentStationId) ? '⭐ Favorit ✓' : '☆ Favorit';
|
||||||
|
if (navigator.vibrate) navigator.vibrate(10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareBtn = e.target.closest('.qa-share');
|
||||||
|
if (shareBtn && currentStationName) {
|
||||||
|
const shareData = {
|
||||||
|
title: 'VBB Netz Status',
|
||||||
|
text: `Abfahrten für ${currentStationName}`,
|
||||||
|
url: location.href
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(`${shareData.text} - ${shareData.url}`);
|
||||||
|
shareBtn.textContent = '✓ Kopiert';
|
||||||
|
setTimeout(() => { shareBtn.textContent = '🔗 Teilen'; }, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Nutzer hat Share-Dialog abgebrochen - kein Fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+388
@@ -0,0 +1,388 @@
|
|||||||
|
// VBB Netz Status - Live-Map (Leaflet, Fahrzeug-Radar)
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LIVE-MAP FUNCTIONALITY
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// (Geteilter Live-Map-Zustand ist zentral in js/api.js deklariert)
|
||||||
|
|
||||||
|
// Fahrzeug-Icons nach Typ
|
||||||
|
function getVehicleEmoji(product, lineName) {
|
||||||
|
const type = product?.mode || product?.name || '';
|
||||||
|
const line = lineName || '';
|
||||||
|
|
||||||
|
// U-Bahn - verschiedene Icons je nach Linie
|
||||||
|
if (type.includes('subway') || type.includes('U-Bahn') || type.match(/^U\d/)) {
|
||||||
|
return '🚇';
|
||||||
|
}
|
||||||
|
// S-Bahn
|
||||||
|
else if (type.includes('suburban') || type.includes('S-Bahn') || type.match(/^S\d/)) {
|
||||||
|
return '🚆';
|
||||||
|
}
|
||||||
|
// Tram/Straßenbahn
|
||||||
|
else if (type.includes('tram') || type.includes('Tram') || line.match(/^M\d/) || line.match(/^\d{2}$/)) {
|
||||||
|
return '🚊';
|
||||||
|
}
|
||||||
|
// Bus
|
||||||
|
else if (type.includes('bus') || type.includes('Bus')) {
|
||||||
|
// Express-Bus
|
||||||
|
if (line.includes('X') || line.includes('TXL')) {
|
||||||
|
return '🚌';
|
||||||
|
}
|
||||||
|
// Nacht-Bus
|
||||||
|
else if (line.startsWith('N')) {
|
||||||
|
return '🌙';
|
||||||
|
}
|
||||||
|
// Normal-Bus
|
||||||
|
return '🚌';
|
||||||
|
}
|
||||||
|
// Regional-Bahn
|
||||||
|
else if (type.includes('regional') || line.includes('RE') || line.includes('RB')) {
|
||||||
|
return '🚉';
|
||||||
|
}
|
||||||
|
// Express-Züge
|
||||||
|
else if (type.includes('express') || line.includes('ICE') || line.includes('IC')) {
|
||||||
|
return '🚅';
|
||||||
|
}
|
||||||
|
// Fähre
|
||||||
|
else if (type.includes('ferry') || type.includes('Fähre')) {
|
||||||
|
return '⛴️';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '🚍'; // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// BVG-Linienfarben (authentische Farben!)
|
||||||
|
function getBVGLineColor(lineName) {
|
||||||
|
const line = lineName?.toUpperCase() || '';
|
||||||
|
|
||||||
|
// U-Bahn Farben (offiziell)
|
||||||
|
const ubahnColors = {
|
||||||
|
'U1': '#55A823', // Grün
|
||||||
|
'U2': '#DA421E', // Rot
|
||||||
|
'U3': '#16683D', // Türkis
|
||||||
|
'U4': '#F0D722', // Gelb
|
||||||
|
'U5': '#7E5330', // Braun
|
||||||
|
'U6': '#8C6DAB', // Lila
|
||||||
|
'U7': '#528DBA', // Hellblau
|
||||||
|
'U8': '#224F86', // Dunkelblau
|
||||||
|
'U9': '#F3791D' // Orange
|
||||||
|
};
|
||||||
|
|
||||||
|
// S-Bahn Farben (offiziell)
|
||||||
|
const sbahnColors = {
|
||||||
|
'S1': '#DE4DA4', // Pink/Magenta
|
||||||
|
'S2': '#076D3D', // Dunkelgrün
|
||||||
|
'S25': '#55A823', // Grün
|
||||||
|
'S26': '#7E5330', // Braun
|
||||||
|
'S3': '#1E5BA8', // Blau
|
||||||
|
'S41': '#A65A28', // Braun/Orange (Ring)
|
||||||
|
'S42': '#A65A28', // Braun/Orange (Ring)
|
||||||
|
'S45': '#F0D722', // Gelb
|
||||||
|
'S46': '#DA421E', // Rot
|
||||||
|
'S47': '#8C6DAB', // Lila
|
||||||
|
'S5': '#F3791D', // Orange
|
||||||
|
'S7': '#BA7AB5', // Lila/Magenta
|
||||||
|
'S75': '#6F7072', // Grau
|
||||||
|
'S8': '#55A823', // Grün
|
||||||
|
'S85': '#FFFFFF', // Weiß
|
||||||
|
'S9': '#DA421E' // Rot
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tram Farben (Metro = Rot, andere = Gelb)
|
||||||
|
const tramColors = {
|
||||||
|
'M1': '#DA421E', 'M2': '#DA421E', 'M4': '#DA421E',
|
||||||
|
'M5': '#DA421E', 'M6': '#DA421E', 'M8': '#DA421E',
|
||||||
|
'M10': '#DA421E', 'M13': '#DA421E', 'M17': '#DA421E'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bus Farben (Metro = Gelb, Express = Blau, Normal = Lila)
|
||||||
|
if (line.startsWith('M') && !line.match(/M\d{1,2}$/)) {
|
||||||
|
return '#F0D722'; // Metro-Bus: Gelb
|
||||||
|
}
|
||||||
|
if (line.startsWith('X') || line.includes('TXL')) {
|
||||||
|
return '#1E5BA8'; // Express-Bus: Blau
|
||||||
|
}
|
||||||
|
if (line.startsWith('N')) {
|
||||||
|
return '#000000'; // Nacht-Bus: Schwarz mit weißer Schrift
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farbe zurückgeben
|
||||||
|
return ubahnColors[line] || sbahnColors[line] || tramColors[line] || '#993399'; // Default: Lila
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fahrzeugtyp bestimmen
|
||||||
|
function getVehicleType(product, lineName) {
|
||||||
|
const type = product?.mode || product?.name || '';
|
||||||
|
const line = lineName?.toUpperCase() || '';
|
||||||
|
|
||||||
|
// Prüfung nach Liniennamen (zuverlässiger!)
|
||||||
|
if (line.match(/^U\d/)) {
|
||||||
|
return 'subway';
|
||||||
|
} else if (line.match(/^S\d/) || line.match(/^S\d{2}/)) {
|
||||||
|
return 'suburban';
|
||||||
|
} else if (line.match(/^M\d/) || line.match(/^\d{2}$/)) {
|
||||||
|
return 'tram';
|
||||||
|
} else if (line.startsWith('N') || line.startsWith('X') || line.match(/^\d{3}$/)) {
|
||||||
|
return 'bus';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Prüfung nach product.mode
|
||||||
|
if (type.includes('subway') || type.includes('U-Bahn')) {
|
||||||
|
return 'subway';
|
||||||
|
} else if (type.includes('suburban') || type.includes('S-Bahn')) {
|
||||||
|
return 'suburban';
|
||||||
|
} else if (type.includes('tram') || type.includes('Tram')) {
|
||||||
|
return 'tram';
|
||||||
|
} else if (type.includes('bus') || type.includes('Bus')) {
|
||||||
|
return 'bus';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-Map initialisieren
|
||||||
|
function initLiveMap() {
|
||||||
|
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!
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
}).addTo(liveMap);
|
||||||
|
|
||||||
|
// Fahrzeuge initial laden
|
||||||
|
loadVehicles();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Event-Listener: Neu laden wenn Karte bewegt/gezoomt wird
|
||||||
|
// Mit Debouncing (1 Sekunde Verzögerung) um API zu schonen
|
||||||
|
liveMap.on('moveend', function() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
loadVehicles();
|
||||||
|
}, 1000); // 1 Sekunde warten nach Bewegung
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fahrzeuge von API laden
|
||||||
|
async function loadVehicles() {
|
||||||
|
// Sicherheitsabfrage: Map muss existieren
|
||||||
|
if (!liveMap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Loading-State anzeigen
|
||||||
|
const counterEl = document.getElementById('vehicleCount');
|
||||||
|
if (counterEl) {
|
||||||
|
counterEl.innerHTML = '⏳ <span style="color: #fff;">Lade...</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamische Bounding Box vom aktuellen Kartenausschnitt
|
||||||
|
const mapBounds = liveMap.getBounds();
|
||||||
|
|
||||||
|
// Puffer hinzufügen (15% größer) damit Fahrzeuge nicht sofort verschwinden
|
||||||
|
const latDiff = mapBounds.getNorth() - mapBounds.getSouth();
|
||||||
|
const lngDiff = mapBounds.getEast() - mapBounds.getWest();
|
||||||
|
const latPadding = latDiff * 0.15;
|
||||||
|
const lngPadding = lngDiff * 0.15;
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 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®ional=true`;
|
||||||
|
|
||||||
|
// 10s Cache: schnelles Hin- und Herzoomen kostet keine Extra-Requests
|
||||||
|
const data = await apiFetch(url, { ttl: 10000 });
|
||||||
|
lastVehicleMovements = data.movements || [];
|
||||||
|
displayVehicles(lastVehicleMovements);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load vehicles error:', error);
|
||||||
|
const counterEl = document.getElementById('vehicleCount');
|
||||||
|
if (counterEl) {
|
||||||
|
counterEl.textContent = error.message === 'RATE_LIMIT'
|
||||||
|
? '⏳ API-Limit erreicht – kurz warten'
|
||||||
|
: '⚠️ Fehler beim Laden der Fahrzeuge';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// -> flüssigere Karte, offene Popups bleiben offen, weniger DOM-Arbeit.
|
||||||
|
// (vehicleMarkerMap ist zentral in js/api.js deklariert)
|
||||||
|
|
||||||
|
function displayVehicles(movements) {
|
||||||
|
// Filtern nach aktiven Typen
|
||||||
|
const filteredMovements = movements.filter(m => {
|
||||||
|
if (activeVehicleTypes.has('all')) return true;
|
||||||
|
const vehicleType = getVehicleType(m.line?.product, m.line?.name);
|
||||||
|
return activeVehicleTypes.has(vehicleType);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Counter
|
||||||
|
const countEl = document.getElementById('vehicleCount');
|
||||||
|
if (countEl) {
|
||||||
|
countEl.innerHTML =
|
||||||
|
`🚇 <span style="color: #fff;">${filteredMovements.length}</span> Fahrzeuge aktiv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenTripIds = new Set();
|
||||||
|
|
||||||
|
filteredMovements.forEach(movement => {
|
||||||
|
if (!movement.location || !movement.location.latitude || !movement.location.longitude) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = movement.location.latitude;
|
||||||
|
const lon = movement.location.longitude;
|
||||||
|
const lineName = movement.line?.name || 'Unbekannt';
|
||||||
|
const tripId = movement.tripId || `${lineName}|${movement.direction || ''}`;
|
||||||
|
seenTripIds.add(tripId);
|
||||||
|
|
||||||
|
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';
|
||||||
|
const emoji = getVehicleEmoji(movement.line?.product, lineName);
|
||||||
|
|
||||||
|
const popupContent = `
|
||||||
|
<div class="vehicle-popup">
|
||||||
|
<div class="popup-line" style="background: ${lineColor}; color: ${textColor}; padding: 6px 10px; border-radius: 6px; margin-bottom: 10px;">
|
||||||
|
${emoji} ${escapeHtml(lineName)}
|
||||||
|
</div>
|
||||||
|
<div class="popup-direction">→ ${escapeHtml(direction)}</div>
|
||||||
|
<div class="popup-delay">${escapeHtml(delay)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existing = vehicleMarkerMap.get(tripId);
|
||||||
|
if (existing) {
|
||||||
|
// Marker existiert bereits -> nur bewegen und Popup-Inhalt auffrischen
|
||||||
|
existing.marker.setLatLng([lat, lon]);
|
||||||
|
existing.marker.setPopupContent(popupContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neues Fahrzeug -> Marker erzeugen
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: `
|
||||||
|
<div class="vehicle-marker-container">
|
||||||
|
<div class="vehicle-emoji">${emoji}</div>
|
||||||
|
<div class="vehicle-line" style="
|
||||||
|
background: ${lineColor};
|
||||||
|
color: ${textColor};
|
||||||
|
">${escapeHtml(lineName)}</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
className: 'custom-marker',
|
||||||
|
iconSize: [50, 60],
|
||||||
|
iconAnchor: [25, 30]
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = L.marker([lat, lon], { icon: icon });
|
||||||
|
marker.bindPopup(popupContent);
|
||||||
|
marker.addTo(liveMap);
|
||||||
|
vehicleMarkerMap.set(tripId, { marker, lineName });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fahrzeuge entfernen, die nicht mehr in den Daten sind
|
||||||
|
// (außer Sichtbereich, Fahrt beendet, oder vom Filter ausgeblendet)
|
||||||
|
for (const [tripId, entry] of vehicleMarkerMap) {
|
||||||
|
if (!seenTripIds.has(tripId)) {
|
||||||
|
entry.marker.remove();
|
||||||
|
vehicleMarkerMap.delete(tripId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Buttons mit Event Delegation
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
// Prüfen ob es ein Filter-Button ist
|
||||||
|
if (e.target.closest('.filter-btn')) {
|
||||||
|
const btn = e.target.closest('.filter-btn');
|
||||||
|
const type = btn.dataset.type;
|
||||||
|
|
||||||
|
|
||||||
|
if (type === 'all') {
|
||||||
|
// Alle Filter deaktivieren, nur "Alle" aktiv
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
activeVehicleTypes.clear();
|
||||||
|
activeVehicleTypes.add('all');
|
||||||
|
} else {
|
||||||
|
// "Alle" deaktivieren
|
||||||
|
const allBtn = document.querySelector('.filter-btn[data-type="all"]');
|
||||||
|
if (allBtn) allBtn.classList.remove('active');
|
||||||
|
activeVehicleTypes.delete('all');
|
||||||
|
|
||||||
|
// Toggle Filter
|
||||||
|
if (activeVehicleTypes.has(type)) {
|
||||||
|
activeVehicleTypes.delete(type);
|
||||||
|
btn.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
activeVehicleTypes.add(type);
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls keine Filter aktiv, "Alle" wieder aktivieren
|
||||||
|
if (activeVehicleTypes.size === 0) {
|
||||||
|
activeVehicleTypes.add('all');
|
||||||
|
const allBtn = document.querySelector('.filter-btn[data-type="all"]');
|
||||||
|
if (allBtn) allBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Lokal filtern - Daten sind bereits da, KEIN neuer API-Call nötig!
|
||||||
|
if (lastVehicleMovements.length > 0) {
|
||||||
|
displayVehicles(lastVehicleMovements);
|
||||||
|
} else {
|
||||||
|
loadVehicles();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.vibrate) navigator.vibrate(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vbb-netz-status",
|
"name": "vbb-netz-status",
|
||||||
"version": "29.0.0",
|
"version": "33.0.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": {
|
||||||
|
|||||||
+59
-38
@@ -1,69 +1,90 @@
|
|||||||
const CACHE_NAME = 'vbb-status-v29';
|
const CACHE_NAME = 'vbb-status-v33';
|
||||||
|
|
||||||
|
// App-Shell: nur Dateien, die wirklich existieren!
|
||||||
|
// (Der alte SW listete eine nicht existierende Font-Datei – dadurch
|
||||||
|
// schlug cache.addAll() komplett fehl und der SW wurde NIE installiert.)
|
||||||
const urlsToCache = [
|
const urlsToCache = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
'/js/api.js',
|
||||||
|
'/js/app.js',
|
||||||
|
'/js/livemap.js',
|
||||||
|
'/js/changelog.js',
|
||||||
|
'/js/extras.js',
|
||||||
|
'/styles.css',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/DotMatrix.ttf',
|
|
||||||
'/images/favicon.png'
|
'/images/favicon.png'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Installation
|
// Installation: Dateien einzeln cachen, damit EIN Fehler nicht alles blockiert
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME).then(cache =>
|
||||||
.then(cache => {
|
Promise.allSettled(
|
||||||
console.log('Cache geöffnet');
|
urlsToCache.map(url =>
|
||||||
return cache.addAll(urlsToCache);
|
cache.add(url).catch(err => console.warn('SW: Konnte nicht cachen:', url, err))
|
||||||
})
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aktivierung
|
// Aktivierung: alte Caches aufräumen
|
||||||
self.addEventListener('activate', event => {
|
self.addEventListener('activate', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then(cacheNames => {
|
caches.keys().then(cacheNames =>
|
||||||
return Promise.all(
|
Promise.all(
|
||||||
cacheNames.map(cacheName => {
|
cacheNames.map(cacheName => {
|
||||||
if (cacheName !== CACHE_NAME) {
|
if (cacheName !== CACHE_NAME) {
|
||||||
console.log('Alter Cache wird gelöscht:', cacheName);
|
console.log('SW: Alter Cache wird gelöscht:', cacheName);
|
||||||
return caches.delete(cacheName);
|
return caches.delete(cacheName);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
})
|
)
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch
|
// 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).
|
||||||
|
// - 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.
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
|
// Nur GET-Requests behandeln
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Cross-Origin (API, Leaflet-CDN, Tiles): nicht anfassen
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request)
|
fetch(request)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
// Cache hit - return response
|
// Gültige Antwort -> Cache aktualisieren und zurückgeben
|
||||||
if (response) {
|
if (response && response.status === 200 && response.type === 'basic') {
|
||||||
return response;
|
const responseToCache = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(cache => cache.put(request, responseToCache));
|
||||||
}
|
}
|
||||||
|
return response;
|
||||||
return fetch(event.request).then(
|
|
||||||
response => {
|
|
||||||
// Check if valid response
|
|
||||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone the response
|
|
||||||
const responseToCache = response.clone();
|
|
||||||
|
|
||||||
caches.open(CACHE_NAME)
|
|
||||||
.then(cache => {
|
|
||||||
cache.put(event.request, responseToCache);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
|
.catch(() =>
|
||||||
|
// Offline -> aus dem Cache bedienen
|
||||||
|
caches.match(request).then(cached => {
|
||||||
|
if (cached) return cached;
|
||||||
|
// Navigations-Fallback auf index.html
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
return caches.match('/index.html');
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503, statusText: 'Offline' });
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
+155
-22
@@ -1,9 +1,3 @@
|
|||||||
/* DotMatrix Font by NikBLN - https://github.com/NikBLN/weilSieDichLieben */
|
|
||||||
/* @font-face {
|
|
||||||
font-family: 'DotMatrix';
|
|
||||||
src: url('./DotMatrix.ttf') format('truetype');
|
|
||||||
} */
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -11,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -165,7 +159,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
border-left: 4px solid transparent;
|
border-left: 4px solid transparent;
|
||||||
@@ -217,7 +211,7 @@
|
|||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 45px 12px 12px;
|
padding: 12px 45px 12px 12px;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
background-color: #000 !important;
|
background-color: #000 !important;
|
||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
@@ -328,7 +322,7 @@
|
|||||||
.btn {
|
.btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background-color: #FFED00;
|
background-color: #FFED00;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -727,7 +721,7 @@
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
}
|
}
|
||||||
@@ -1662,7 +1656,7 @@
|
|||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -1697,7 +1691,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-line {
|
.vehicle-line {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
@@ -1716,7 +1710,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-popup {
|
.vehicle-popup {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
background-color: #000 !important;
|
background-color: #000 !important;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -1855,7 +1849,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-hero-title h1 {
|
.home-hero-title h1 {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
@@ -1929,7 +1923,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-box-title {
|
.home-box-title {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
@@ -2063,7 +2057,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-features-box h3 {
|
.home-features-box h3 {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
@@ -2331,7 +2325,7 @@
|
|||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2384,7 +2378,7 @@
|
|||||||
color: #FFED00;
|
color: #FFED00;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
border-radius: 10px 10px 0 0;
|
border-radius: 10px 10px 0 0;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -2654,7 +2648,7 @@
|
|||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 2px solid #333;
|
border-bottom: 2px solid #333;
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dev-info-table {
|
.dev-info-table {
|
||||||
@@ -2714,7 +2708,7 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3124,7 +3118,7 @@
|
|||||||
|
|
||||||
/* Changelog Container für Markdown Content */
|
/* Changelog Container für Markdown Content */
|
||||||
.changelog-container {
|
.changelog-container {
|
||||||
font-family: 'DotMatrix', 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3669,3 +3663,142 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* ==========================================
|
||||||
|
v33: Favoriten, Zeitwahl, Offline-Banner, Tastatur-Navigation
|
||||||
|
========================================== */
|
||||||
|
|
||||||
|
/* Favoriten auf dem Home-Screen */
|
||||||
|
.home-favorites {
|
||||||
|
background: rgba(255, 237, 0, 0.06);
|
||||||
|
border: 1px solid rgba(255, 237, 0, 0.25);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-favorites-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FFED00;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-favorites-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-favorite-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-favorite-item:hover,
|
||||||
|
.home-favorite-item:focus-visible {
|
||||||
|
background: rgba(255, 237, 0, 0.12);
|
||||||
|
border-color: rgba(255, 237, 0, 0.5);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-favorite-arrow {
|
||||||
|
color: #FFED00;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Station-Header mit Aktions-Buttons */
|
||||||
|
.station-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Offline-Banner ("Stand: vor X min") */
|
||||||
|
.stale-data-banner {
|
||||||
|
background: rgba(255, 165, 0, 0.15);
|
||||||
|
border: 1px solid rgba(255, 165, 0, 0.5);
|
||||||
|
color: #FFC966;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeitwahl in der Routensuche */
|
||||||
|
.journey-time-row {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-time-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-time-select,
|
||||||
|
.journey-time-input {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-time-select option {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-time-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-time-clear {
|
||||||
|
position: static;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-time-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tastatur-Navigation in Vorschlägen */
|
||||||
|
.suggestion-item.kb-active {
|
||||||
|
background: rgba(255, 237, 0, 0.2);
|
||||||
|
outline: 2px solid rgba(255, 237, 0, 0.6);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sichtbarer Fokus für Tastaturnutzer */
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid #FFED00;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user