Files
eventmap/js/map/mapController.js
T
dapelza dac952fbf9 Phase 4: Filter- und Suchfunktion
Neues Modul js/filters/ mit reiner Filterlogik (filterEngine.js:
applyFilters(), getDistanceKm()) und UI (filterPanel.js) fuer alle Filter
aus anforderung.md Abschnitt 3: Kategorie, Entfernung (mit "Standort
verwenden"-Button), Alter der Kinder, Kosten, Indoor/Outdoor, 5
Ausstattungsmerkmale, Aufenthaltsdauer, Mindestbewertung sowie
Freitextsuche ueber Name/Ort/Region/Adresse. Trefferzaehler, "Filter
zuruecksetzen" und "Auf Ergebnisse zoomen" ergaenzt.

Store erweitert um updateFilters()/getFilteredPlaces() (Selector).
Karte und Liste rendern jetzt die gefilterte statt die volle
Ausflugsziel-Liste; die Karten-Rerender-Optimierung vergleicht dafuer
die Trefferliste per Id-Schluessel statt nur state.places, damit reine
Filter-/Standortaenderungen ohne Places-Aenderung erkannt werden.

Layout: Filter-Panel als aufklappbares <details> auf Mobile, eigener
Grid-Bereich neben der Karte auf Desktop. docs/plan.md entsprechend
fortgeschrieben (Phase 2-5 als umgesetzt markiert, Phase 5
Navigation war bereits Teil von Phase 2).
2026-07-24 12:21:07 +02:00

142 lines
4.8 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, setState, getFilteredPlaces } = EventMap.store;
const { getPlaceLatLng } = EventMap.models;
const { createMarkerForPlace } = EventMap.map;
let map = null;
let clusterGroup = null;
const markersByPlaceId = new Map();
let lastFilteredKey = null;
let lastSelectedPlaceId = 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);
}
function getMapInstance() {
return map;
}
/** 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,
});
// 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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende ' +
'&copy; <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);
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);
}
}
});
return map;
}
EventMap.map.fitToPlaces = fitToPlaces;
EventMap.map.centerOnPlace = centerOnPlace;
EventMap.map.centerOnCoords = centerOnCoords;
EventMap.map.getMapInstance = getMapInstance;
EventMap.map.initMap = initMap;
})();