// 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 // Map zentriert auf Berlin liveMap = L.map('liveMap', { center: [52.52, 13.405], zoom: 12, zoomControl: false // Keine +/- Tasten (Zoomen per Geste/Scrollrad) }); // Tile Layer (CartoDB Dark Matter - Dark Mode!) L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors, © CartoDB', maxZoom: 19, minZoom: 8, subdomains: 'abcd', keepBuffer: 4, // Rand-Kacheln behalten -> flüssigeres Pannen updateWhenIdle: false // Kacheln schon WÄHREND der Bewegung laden }).addTo(liveMap); // HINWEIS: Daten-Polling startet NICHT hier, sondern beim Öffnen // der View (switchView). Dadurch kann die Karte selbst schon im // Hintergrund vorinitialisiert werden (Leaflet-Setup + erste // Kacheln), ohne einen einzigen API-Request zu verursachen. // Lokaler Animations-Loop (1s-Takt, kostet keine API-Requests) if (!liveMapAnimationInterval) { liveMapAnimationInterval = setInterval(() => { if (document.hidden) return; if (currentView !== 'livemap') return; renderVehiclesFromSegments(); }, 1000); } // Event-Listener: Neu laden wenn Karte bewegt/gezoomt wird // Mit Debouncing (1 Sekunde Verzögerung) um API zu schonen 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 = '⏳ Lade...'; } // 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; // Keine Berlin-Klemmung mehr: Transitous deckt ganz // Brandenburg (und darüber hinaus) ab - die alte Klemmung // schnitt z.B. Potsdam ab. const bounds = { north: mapBounds.getNorth() + latPadding, south: mapBounds.getSouth() - latPadding, west: mapBounds.getWest() - lngPadding, east: mapBounds.getEast() + lngPadding }; const zoom = liveMap.getZoom ? liveMap.getZoom() : 12; // Fahrt-Segmente von Transitous holen (map/trips) und für // die lokale Animation zwischenspeichern liveMapSegments = await transitousRadarSegments(bounds, zoom); renderVehiclesFromSegments(); } catch (error) { console.error('Load vehicles error:', error); const counterEl = document.getElementById('vehicleCount'); if (counterEl) { counterEl.textContent = error.message === 'RATE_LIMIT' ? '⏳ API-Limit erreicht – kurz warten' : '⚠️ Fehler beim Laden der Fahrzeuge'; } } } // ========================================== // STRECKEN-ANZEIGE BEIM FAHRZEUG-KLICK // Die Polylines liegen bereits dekodiert in liveMapSegments - // die Route zu zeichnen kostet also NULL API-Requests. // ========================================== let activeRouteLine = null; let focusedVehicleTripId = null; // Fokus-Modus: nur diese Fahrt zeigen async function showVehicleRoute(tripId) { hideVehicleRoute(); focusedVehicleTripId = tripId; const seg = liveMapSegments.find(s => s.tripId === tripId); const color = seg?.color || getBVGLineColor(seg?.lineName || ''); // 1) SOFORT das lokale Segment zeichnen (null Wartezeit)... if (seg && seg.points && seg.points.length >= 2) { activeRouteLine = L.polyline(seg.points, { color, weight: 5, opacity: 0.75, lineCap: 'round', interactive: false }).addTo(liveMap); } // Fokus-Modus aktivieren: andere Fahrzeuge ausblenden renderVehiclesFromSegments(); // 2) ...dann die KOMPLETTE Route nachladen (map/trips liefert nur // das Segment im Ausschnitt; die ganze Strecke steckt in /trip). // Gleicher Cache wie die Abfahrts-Details -> max. 1 Request. try { const route = await transitousTripGeometry(tripId); // Nutzer könnte inzwischen woanders geklickt haben if (route && focusedVehicleTripId === tripId) { if (activeRouteLine) activeRouteLine.remove(); activeRouteLine = L.polyline(route.points, { color: route.color || color, weight: 5, opacity: 0.75, lineCap: 'round', interactive: false }).addTo(liveMap); } } catch (e) { // Volle Route nicht verfügbar -> Segment bleibt stehen console.warn('Komplette Route nicht ladbar:', e.message); } } function hideVehicleRoute() { if (activeRouteLine) { activeRouteLine.remove(); activeRouteLine = null; } if (focusedVehicleTripId) { focusedVehicleTripId = null; renderVehiclesFromSegments(); // alle Fahrzeuge wieder zeigen } } // Aus den gespeicherten Segmenten die AKTUELLEN Positionen berechnen // und rendern. Läuft jede Sekunde lokal - null API-Kosten. function renderVehiclesFromSegments() { if (!liveMap) return; const nowMs = Date.now(); const movements = []; for (const seg of liveMapSegments) { // Fokus-Modus: nur die angetippte Fahrt rendern if (focusedVehicleTripId && seg.tripId !== focusedVehicleTripId) continue; const pos = segmentPositionAt(seg, nowMs); if (!pos) continue; // Fahrzeug (noch) nicht auf diesem Segment unterwegs movements.push({ tripId: seg.tripId, direction: seg.direction, delay: seg.delay, line: { name: seg.lineName, product: seg.product, color: seg.color }, location: pos }); } displayVehicles(movements); } // Fahrzeuge auf Map anzeigen // Marker werden per tripId wiederverwendet und nur BEWEGT (setLatLng), // statt bei jedem Update alle zu zerstören und neu zu erzeugen. // -> 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 mit Live-Indikator (bzw. Fokus-Hinweis) const countEl = document.getElementById('vehicleCount'); if (countEl) { if (focusedVehicleTripId) { const focusName = filteredMovements[0]?.line?.name || ''; countEl.innerHTML = `🎯 ${escapeHtml(focusName)} im Fokus – Popup schließen zum Beenden`; } else { countEl.innerHTML = ` ` + `${filteredMovements.length} Fahrzeuge live`; } } 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'; // Offizielle GTFS-Farbe aus der API, lokale Tabelle als Fallback const lineColor = movement.line?.color || getBVGLineColor(lineName); const textColor = bestTextColor(lineColor); const emoji = getVehicleEmoji(movement.line?.product, lineName); const popupContent = `
`; 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: `
${emoji}
${escapeHtml(lineName)}
`, className: 'custom-marker', iconSize: [50, 60], iconAnchor: [25, 30] }); const marker = L.marker([lat, lon], { icon: icon }); marker.bindPopup(popupContent); // Popup auf = Strecke dieser Fahrt einzeichnen (in Linienfarbe) marker.on('popupopen', () => showVehicleRoute(tripId)); marker.on('popupclose', hideVehicleRoute); 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); } } // Route eines nicht mehr existierenden Fahrzeugs mit aufräumen if (activeRouteLine && ![...vehicleMarkerMap.values()] .some(e => e.marker.isPopupOpen && e.marker.isPopupOpen())) { hideVehicleRoute(); } } // 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 (liveMapSegments.length > 0) { renderVehiclesFromSegments(); } else { loadVehicles(); } if (navigator.vibrate) navigator.vibrate(5); } });