// Live-Karte mit mehreren Modi: // - "radar": zeigt alle Fahrzeuge im aktuellen Kartenausschnitt (Standard), // gefiltert nach den gewählten Verkehrsmitteln // - "isolated": ein Fahrzeug wurde angeklickt -> nur dieses wird live weiter- // verfolgt (serverseitig via tripId gefiltert), sein Streckenverlauf // wird eingezeichnet, alle anderen Fahrzeuge sind ausgeblendet // - "trip": verfolgt ein bekanntes Fahrzeug (z.B. aus einer Verbindung heraus) // Nutzt bewusst reines OpenStreetMap ohne zusätzlichen Kartenstil. // Zusätzlich: createMiniMap() für kleine, unabhängige Vorschau-Karten in den // Verbindungsergebnissen (Routenplaner). const LiveMap = (() => { let map = null; let radarLayer = null; let routeLayer = null; let userLayer = null; let tripMarker = null; let mode = 'radar'; let radarPollHandle = null; let tripPollHandle = null; let currentTripId = null; let isolatedMarker = null; // persistenter Marker im Isolations-Modus, wird nur aktualisiert statt neu erzeugt let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt let resizeScheduled = false; function init() { if (map) return; map = L.map('live-map', { zoomControl: true }).setView([52.5200, 13.4050], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap-Mitwirkende', maxZoom: 19, }).addTo(map); radarLayer = L.layerGroup().addTo(map); routeLayer = L.layerGroup().addTo(map); userLayer = L.layerGroup().addTo(map); map.on('moveend', () => { if (mode === 'radar') refreshRadar(); }); // Die Karte soll die tatsächlich verfügbare Bildschirmhöhe ausfüllen - // unabhängig von Gerät/Displaygröße, Adressleisten-Ein-/Ausblenden auf // Mobilgeräten oder Bildschirmdrehung. Statt die Höhe über CSS mit // geschätzten Pixelwerten zu berechnen (die bei jedem neuen Gerät wieder // falsch wären), messen wir hier direkt den tatsächlich freien Platz und // setzen die Höhe daraus - das funktioniert für jede Displaygröße gleich // gut (Mobile First: der Startwert in CSS ist bereits mobilfreundlich, // hier wird er nur noch exakt nachjustiert). resizeToViewport(); window.addEventListener('resize', scheduleResize); window.addEventListener('orientationchange', scheduleResize); if (window.visualViewport) { window.visualViewport.addEventListener('resize', scheduleResize); } } function scheduleResize() { if (resizeScheduled) return; resizeScheduled = true; requestAnimationFrame(() => { resizeScheduled = false; resizeToViewport(); }); } function resizeToViewport() { const el = document.getElementById('live-map'); if (!el) return; const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; const top = el.getBoundingClientRect().top; // Die feste untere Mobil-Navigationsleiste überlappt den Inhalt // (position: fixed) - ihre Höhe muss von der verfügbaren Fläche // abgezogen werden, sofern sie gerade sichtbar ist (auf Desktop- // Breiten ist sie per CSS ausgeblendet). const mobileNav = document.querySelector('.mobile-nav'); const mobileNavHeight = mobileNav && getComputedStyle(mobileNav).display !== 'none' ? mobileNav.getBoundingClientRect().height : 0; const bottomSafety = 12; // etwas Luft, u.a. für mobile Adressleisten am unteren Rand const available = viewportHeight - top - mobileNavHeight - bottomSafety; el.style.height = `${Math.max(280, Math.round(available))}px`; if (map) map.invalidateSize(); } function setModeFilter(fn) { getModesParam = fn; } function vehicleIcon(label, bg, fg) { return L.divIcon({ className: 'vehicle-marker', html: `${label || '?'}`, iconSize: [null, null], }); } function polylineToLatLngs(geojson) { if (!geojson || !geojson.features) return []; return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]); } function vehiclePopupHtml(v) { return `${v.line || 'Linie unbekannt'}
${v.direction || ''}
` + `Nächster Halt: ${v.nextStopover || '–'}`; } // ---------- Radar-Modus (Standard: alle Fahrzeuge im Ausschnitt) ---------- async function refreshRadar() { if (!map || mode !== 'radar') return; const bounds = map.getBounds(); let data; try { data = await API.getRadar({ north: bounds.getNorth().toFixed(5), west: bounds.getWest().toFixed(5), south: bounds.getSouth().toFixed(5), east: bounds.getEast().toFixed(5), modes: getModesParam(), }); } catch (err) { console.error('Radar-Fehler:', err.message); return; } radarLayer.clearLayers(); data.vehicles.forEach((v) => { if (v.latitude == null || v.longitude == null) return; const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) }); marker.bindPopup( `${v.line || 'Linie unbekannt'}
${v.direction || ''}
` + `Nächster Halt: ${v.nextStopover || '–'}`, ); // Klick auf eine Linie: isoliert sie (alle anderen Fahrzeuge ausblenden, // Streckenverlauf einzeichnen, live weiterverfolgen). marker.on('click', () => isolateVehicle(v)); marker.addTo(radarLayer); }); } function startRadar() { init(); setMode('radar'); refreshRadar(); if (radarPollHandle) clearInterval(radarPollHandle); radarPollHandle = setInterval(refreshRadar, 15000); setTimeout(() => resizeToViewport(), 150); } function stopRadar() { if (radarPollHandle) { clearInterval(radarPollHandle); radarPollHandle = null; } } // ---------- Isolations-Modus (eine angeklickte Linie live verfolgen) ---------- async function refreshIsolated() { if (!map || mode !== 'isolated' || !currentTripId) return; const bounds = map.getBounds(); let data; try { data = await API.getRadar({ north: bounds.getNorth().toFixed(5), west: bounds.getWest().toFixed(5), south: bounds.getSouth().toFixed(5), east: bounds.getEast().toFixed(5), tripId: currentTripId, }); } catch (err) { console.error('Radar-Fehler (isoliert):', err.message); return; } const v = data.vehicles[0]; if (!v || v.latitude == null || v.longitude == null) { // Fahrzeug gerade nicht meldend (z.B. kurzzeitig aus der Live-Erfassung) - // Marker stehen lassen statt zu entfernen, nächster Poll aktualisiert ihn wieder. return; } // WICHTIG: Marker wird wiederverwendet statt bei jedem Poll neu erzeugt zu // werden. Vorher wurde hier radarLayer.clearLayers() + ein komplett neuer // Marker angelegt - das hat ein ggf. geöffnetes Popup jedes Mal geschlossen, // sodass die Fahrzeuginfo (Richtung, nächster Halt) nach dem ersten Klick nie // wieder aktualisiert im UI sichtbar wurde. if (isolatedMarker) { isolatedMarker.setLatLng([v.latitude, v.longitude]); isolatedMarker.setIcon(vehicleIcon(v.line, v.color, v.textColor)); isolatedMarker.setPopupContent(vehiclePopupHtml(v)); } else { isolatedMarker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) }); isolatedMarker.bindPopup(vehiclePopupHtml(v)); isolatedMarker.addTo(radarLayer); } isolatedVehicleMeta = { line: v.line, color: v.color, textColor: v.textColor }; updateIsolationBanner(); } async function isolateVehicle(vehicle) { if (!vehicle || !vehicle.tripId) return; // Das ursprüngliche Leaflet-Popup des angeklickten Radar-Markers (aus // bindPopup in refreshRadar) schließen, bevor wir in den Isolations-Modus // wechseln - sonst blitzt es kurz auf und verschwindet dann kommentarlos. map.closePopup(); setMode('isolated'); currentTripId = vehicle.tripId; isolatedMarker = null; radarLayer.clearLayers(); isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor }; updateIsolationBanner(); // Sofort einen Marker mit den Daten aus dem Klick anzeigen (nicht erst auf // den ersten Poll warten) und direkt öffnen, damit man die Fahrzeuginfo // ohne weiteren Klick sieht. isolatedMarker = L.marker([vehicle.latitude, vehicle.longitude], { icon: vehicleIcon(vehicle.line, vehicle.color, vehicle.textColor), }); isolatedMarker.bindPopup(vehiclePopupHtml(vehicle)); isolatedMarker.addTo(radarLayer); isolatedMarker.openPopup(); routeLayer.clearLayers(); try { const trip = await API.getTrip(vehicle.tripId, { polyline: true }); if (trip.polyline) { const latlngs = polylineToLatLngs(trip.polyline); if (latlngs.length > 0) { L.polyline(latlngs, { color: vehicle.color || '#F5A623', weight: 5, opacity: 0.85 }).addTo(routeLayer); } } } catch (err) { console.error('Trip-Polyline-Fehler:', err.message); } if (radarPollHandle) clearInterval(radarPollHandle); radarPollHandle = setInterval(refreshIsolated, 10000); } function clearIsolation() { currentTripId = null; isolatedMarker = null; isolatedVehicleMeta = null; updateIsolationBanner(); startRadar(); } function updateIsolationBanner() { const banner = document.getElementById('map-isolation-banner'); if (!banner) return; if (!isolatedVehicleMeta) { banner.classList.remove('is-visible'); return; } const lineEl = document.getElementById('map-isolation-line'); if (lineEl) { lineEl.textContent = isolatedVehicleMeta.line || '?'; lineEl.style.background = isolatedVehicleMeta.color || '#F5A623'; lineEl.style.color = isolatedVehicleMeta.textColor || '#fff'; } banner.classList.add('is-visible'); } // ---------- Trip-Modus (bekanntes Fahrzeug aus einer Verbindung heraus verfolgen) ---------- async function refreshTrip() { if (!currentTripId) return; let data; try { data = await API.getTrip(currentTripId, { polyline: !tripMarker }); } catch (err) { console.error('Trip-Fehler:', err.message); return; } if (data.polyline && routeLayer) { routeLayer.clearLayers(); const latlngs = polylineToLatLngs(data.polyline); if (latlngs.length > 0) { L.polyline(latlngs, { color: data.color || '#F5A623', weight: 4, opacity: 0.7 }).addTo(routeLayer); map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] }); } } if (data.currentLocation) { const pos = [data.currentLocation.latitude, data.currentLocation.longitude]; const popupHtml = `${data.line || ''}
→ ${data.direction || ''}`; if (tripMarker) { tripMarker.setLatLng(pos); tripMarker.setIcon(vehicleIcon(data.line, data.color, data.textColor)); // setPopupContent statt erneutem bindPopup, sonst schließt sich ein // gerade geöffnetes Popup bei jedem Poll wieder (gleicher Fehler wie // im Isolations-Modus). if (tripMarker.getPopup()) tripMarker.setPopupContent(popupHtml); else tripMarker.bindPopup(popupHtml); } else { tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer); tripMarker.bindPopup(popupHtml); } } } function trackTrip(tripId) { init(); setMode('trip'); currentTripId = tripId; tripMarker = null; routeLayer.clearLayers(); refreshTrip(); if (tripPollHandle) clearInterval(tripPollHandle); tripPollHandle = setInterval(refreshTrip, 10000); setTimeout(() => resizeToViewport(), 150); } function stopTracking() { if (tripPollHandle) { clearInterval(tripPollHandle); tripPollHandle = null; } currentTripId = null; tripMarker = null; } // ---------- Standort ---------- function locateUser() { init(); if (!navigator.geolocation) { alert('Geolocation wird von diesem Browser nicht unterstützt.'); return; } navigator.geolocation.getCurrentPosition( (pos) => { const { latitude, longitude } = pos.coords; userLayer.clearLayers(); L.marker([latitude, longitude], { icon: L.divIcon({ className: 'user-location-marker', iconSize: [16, 16] }), }).addTo(userLayer); map.setView([latitude, longitude], 16); }, (err) => alert('Standort konnte nicht ermittelt werden: ' + err.message), { enableHighAccuracy: true, timeout: 10000 }, ); } // ---------- Modus-Verwaltung ---------- function setMode(newMode) { if (mode === newMode) return; if (mode === 'radar') stopRadar(); if (mode === 'isolated') stopRadar(); if (mode === 'trip') stopTracking(); if (newMode === 'radar') routeLayer.clearLayers(); mode = newMode; } function startPolling() { // Rückwärtskompatibler Einstieg: Karten-Tab wurde geöffnet. if (mode === 'radar' || mode === 'idle') startRadar(); else if (map) setTimeout(() => resizeToViewport(), 150); } function stopPolling() { stopRadar(); stopTracking(); } // ---------- Mini-Karte für Verbindungsvorschau (Routenplaner) ---------- function createMiniMap(containerId, journey) { const miniMap = L.map(containerId, { zoomControl: false, attributionControl: false, dragging: true, scrollWheelZoom: false, tap: true, }).setView([52.5200, 13.4050], 12); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap-Mitwirkende', maxZoom: 19, }).addTo(miniMap); const allPoints = []; (journey.legs || []).forEach((leg) => { if (!leg.polyline) return; const latlngs = polylineToLatLngs(leg.polyline); if (latlngs.length === 0) return; L.polyline(latlngs, { color: leg.walking ? '#8E99A6' : leg.color || '#F5A623', weight: leg.walking ? 3 : 4, dashArray: leg.walking ? '5, 7' : null, opacity: 0.9, }).addTo(miniMap); allPoints.push(...latlngs); }); if (allPoints.length > 0) { miniMap.fitBounds(L.latLngBounds(allPoints), { padding: [16, 16] }); } setTimeout(() => miniMap.invalidateSize(), 50); return miniMap; } return { startPolling, stopPolling, trackTrip, setModeFilter, clearIsolation, locateUser, createMiniMap, }; })();