/* global L */ // Initialisiert die Leaflet-Karte, verwaltet Marker-Clustering und // reagiert auf Store-Änderungen (places, selectedPlaceId). (function () { const { DEFAULT_MAP_CENTER } = EventMap.config; const { subscribe, getState, setState, getFilteredPlaces } = EventMap.store; const { getPlaceLatLng } = EventMap.models; const { createMarkerForPlace, createUserLocationMarker } = EventMap.map; let map = null; let clusterGroup = null; let userLocationMarker = null; const markersByPlaceId = new Map(); let lastFilteredKey = null; let lastSelectedPlaceId = null; let lastUserLocationKey = null; /** Günstiger Vergleichsschlüssel für eine gefilterte Trefferliste (Reihenfolge + IDs). */ function computeFilteredKey(places) { return places.map((place) => place.id).join('|'); } function hasValidCoords(place) { const { lat, lng } = getPlaceLatLng(place); return typeof lat === 'number' && typeof lng === 'number' && !Number.isNaN(lat) && !Number.isNaN(lng); } function renderMarkers(places) { clusterGroup.clearLayers(); markersByPlaceId.clear(); for (const place of places) { if (!hasValidCoords(place)) continue; const marker = createMarkerForPlace(place, { onSelect: (placeId) => setState({ selectedPlaceId: placeId }), }); markersByPlaceId.set(place.id, marker); clusterGroup.addLayer(marker); } } /** Passt den Kartenausschnitt an alle übergebenen Places an (Fallback: Default-Center). */ function fitToPlaces(places) { const valid = places.filter(hasValidCoords); if (valid.length === 0) { map.setView([DEFAULT_MAP_CENTER.lat, DEFAULT_MAP_CENTER.lng], DEFAULT_MAP_CENTER.zoom); return; } const bounds = L.latLngBounds( valid.map((place) => { const { lat, lng } = getPlaceLatLng(place); return [lat, lng]; }) ); map.fitBounds(bounds, { padding: [32, 32], maxZoom: 14 }); } /** Zentriert die Karte auf einen bestimmten Place und öffnet dessen Popup. */ function centerOnPlace(placeId) { const marker = markersByPlaceId.get(placeId); if (!marker || !map) return; map.setView(marker.getLatLng(), Math.max(map.getZoom(), 14)); marker.openPopup(); } /** Zentriert die Karte auf beliebige Koordinaten (z.B. Nutzerstandort). */ function centerOnCoords(lat, lng, zoom = 13) { if (!map) return; map.setView([lat, lng], zoom); } /** * Zeigt den übermittelten/manuell eingegebenen Nutzerstandort als eigenen * Marker auf der Karte an (Gitea Issue #2). Läuft außerhalb der * clusterGroup, damit er nie mit Ausflugsziel-Markern geclustert wird und * immer sichtbar bleibt. */ function renderUserLocation(userLocation) { if (!userLocation) { if (userLocationMarker) { map.removeLayer(userLocationMarker); userLocationMarker = null; } return; } const { lat, lng } = userLocation; if (userLocationMarker) { userLocationMarker.setLatLng([lat, lng]); } else { userLocationMarker = createUserLocationMarker(lat, lng); userLocationMarker.addTo(map); } } function getMapInstance() { return map; } let pickingActive = false; let pickingClickHandler = null; let pickingEscHandler = null; /** * Aktiviert den "Standort auf Karte wählen"-Modus: der nächste Klick auf * die Karte liefert die geklickten Koordinaten an onPick. Escape oder ein * erneuter Aufruf von stopLocationPicking() bricht ab (onCancel). * @param {{onPick?: (loc:{lat:number,lng:number}) => void, onCancel?: () => void}} [handlers] */ function startLocationPicking({ onPick, onCancel } = {}) { if (!map || pickingActive) return; pickingActive = true; map.getContainer().classList.add('map-panel__map--picking'); pickingClickHandler = (event) => { const { lat, lng } = event.latlng; stopLocationPicking(); if (onPick) onPick({ lat, lng }); }; map.once('click', pickingClickHandler); pickingEscHandler = (event) => { if (event.key === 'Escape') { stopLocationPicking(); if (onCancel) onCancel(); } }; document.addEventListener('keydown', pickingEscHandler); } /** Beendet den Standort-Auswahlmodus, ohne onPick/onCancel selbst aufzurufen. */ function stopLocationPicking() { if (!pickingActive || !map) return; pickingActive = false; map.getContainer().classList.remove('map-panel__map--picking'); if (pickingClickHandler) { map.off('click', pickingClickHandler); pickingClickHandler = null; } if (pickingEscHandler) { document.removeEventListener('keydown', pickingEscHandler); pickingEscHandler = null; } } function isPickingLocation() { return pickingActive; } /** Initialisiert die Karte im Element mit der übergebenen id. */ function initMap(containerId) { // Leaflet leert den Container beim Initialisieren nicht selbst - den // statischen "Karte wird geladen…"-Platzhalter aus index.html entfernen. const container = document.getElementById(containerId); if (container) { container.textContent = ''; } map = L.map(containerId, { center: [DEFAULT_MAP_CENTER.lat, DEFAULT_MAP_CENTER.lng], zoom: DEFAULT_MAP_CENTER.zoom, // Zoom-Regler bewusst oben rechts statt Leaflets Standard oben links, // damit links oben durchgängig Platz für eigene Overlays (z.B. // map-picking-hint) bleibt, ohne dass sich beides überschneidet. zoomControl: false, }); L.control.zoom({ position: 'topright' }).addTo(map); // tile.openstreetmap.org verlangt seit 2024 einen gültigen HTTP-Referer // (Tile Usage Policy). Seiten, die per file:// geöffnet werden, senden // browserseitig grundsätzlich keinen Referer mit ("Access blocked: Referer // is required"). Daher CARTO-Basemaps (kartographisch auf OSM-Daten // basierend, korrekt attributiert) statt des offiziellen OSM-Tile-Servers. L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap-Mitwirkende ' + '© CARTO', }).addTo(map); clusterGroup = L.markerClusterGroup(); map.addLayer(clusterGroup); const initialFiltered = getFilteredPlaces(); renderMarkers(initialFiltered); fitToPlaces(initialFiltered); lastFilteredKey = computeFilteredKey(initialFiltered); const initialUserLocation = getState().userLocation; renderUserLocation(initialUserLocation); lastUserLocationKey = initialUserLocation ? `${initialUserLocation.lat},${initialUserLocation.lng}` : null; subscribe((state) => { // Filter/Standort können sich ändern, ohne dass sich state.places // ändert - daher bei jeder Store-Änderung neu filtern und nur bei // tatsächlich geänderter Trefferliste neu rendern (vermeidet // unnötiges Marker-Flackern z.B. bei jedem Freitext-Tastendruck). const filtered = getFilteredPlaces(); const key = computeFilteredKey(filtered); if (key !== lastFilteredKey) { lastFilteredKey = key; renderMarkers(filtered); } if (state.selectedPlaceId !== lastSelectedPlaceId) { lastSelectedPlaceId = state.selectedPlaceId; if (state.selectedPlaceId) { centerOnPlace(state.selectedPlaceId); } } const userLocationKey = state.userLocation ? `${state.userLocation.lat},${state.userLocation.lng}` : null; if (userLocationKey !== lastUserLocationKey) { lastUserLocationKey = userLocationKey; renderUserLocation(state.userLocation); } }); return map; } EventMap.map.fitToPlaces = fitToPlaces; EventMap.map.centerOnPlace = centerOnPlace; EventMap.map.centerOnCoords = centerOnCoords; EventMap.map.getMapInstance = getMapInstance; EventMap.map.startLocationPicking = startLocationPicking; EventMap.map.stopLocationPicking = stopLocationPicking; EventMap.map.isPickingLocation = isPickingLocation; EventMap.map.initMap = initMap; })();