Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-07-03 12:45:23 +02:00
1 parent 33f3483055
commit 45ee14c25a
12 files changed
+3565 -151

No files matched your search

+233
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ==========================================
// 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`;
}
+1703
View File
File diff suppressed because it is too large. Load diff
+241
View File
@@ -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
View File
@@ -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
View File
@@ -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&regional=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);
}
});