// 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,
};
})();