- Icon im eingeklappten Zustand von Lupe (wirkte wie Such-Icon) auf ein Chevron (◂) geändert, das klar als Aufklappen-Symbol erkennbar ist. - ResizeObserver auf dem Kartencontainer ruft map.invalidateSize() auf, sobald sich dessen Größe ändert (z.B. beim Ein-/Ausklappen der Filter-Spalte). Ohne das blieb der neu hinzugewonnene Kartenbereich grau/leer, bis die Karte manuell bewegt wurde. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
250 lines
8.6 KiB
JavaScript
250 lines
8.6 KiB
JavaScript
/* 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);
|
|
|
|
// Leaflet merkt Größenänderungen seines Containers nicht selbst (z.B.
|
|
// wenn die Filter-Spalte auf Desktop ein-/ausklappt und dadurch die
|
|
// Kartenspalte breiter/schmaler wird) - ohne invalidateSize() bleibt der
|
|
// neu hinzugewonnene Bereich grau/leer, bis man die Karte anfasst.
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
const resizeObserver = new ResizeObserver(() => map.invalidateSize());
|
|
resizeObserver.observe(map.getContainer());
|
|
}
|
|
|
|
// 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:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende ' +
|
|
'© <a href="https://carto.com/attributions">CARTO</a>',
|
|
}).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);
|
|
} else {
|
|
fitToPlaces(filtered);
|
|
}
|
|
}
|
|
|
|
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;
|
|
})();
|