Phase 0+1: Scaffold, Datenmodell, Karte und Liste
Reines HTML5/CSS3/JavaScript ohne Build-Tooling, lauffaehig direkt per Doppelklick auf index.html (file://, kein Server noetig): - Datenmodell fuer Ausflugsziele mit allen Feldern aus der Anforderung, 15 feste Kategorien inkl. Farbe/Icon - Zentraler Pub/Sub-Store mit LocalStorage-Persistenz und Migrations-Grundgeruest - Interaktive Karte (Leaflet + Leaflet.markercluster, lokal vendored) mit kategoriefarbenen Markern, Clustering, Popups, Geolocation-Button - Kartengrundlage ueber CARTO-Basemaps statt tile.openstreetmap.org, da dessen Referer-Pflicht file://-Aufrufe blockiert - Seed-Daten als eingebettetes Script (data/seed.js) statt JSON-Datei, da fetch() auf lokale Dateien unter file:// nicht zuverlaessig funktioniert - Ergebnisliste mit 10 Beispiel-Ausflugszielen, sicher gerendert ueber textContent/DOM-APIs statt innerHTML (Vorbereitung fuer spaeteren KI-JSON-Import mit Fremddaten) - Responsives Layout (Desktop nebeneinander, Mobile gestapelt)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/* 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 } = EventMap.store;
|
||||
const { getPlaceLatLng } = EventMap.models;
|
||||
const { createMarkerForPlace } = EventMap.map;
|
||||
|
||||
let map = null;
|
||||
let clusterGroup = null;
|
||||
const markersByPlaceId = new Map();
|
||||
|
||||
let lastPlacesRef = null;
|
||||
let lastSelectedPlaceId = null;
|
||||
|
||||
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:
|
||||
'© <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 initialState = getState();
|
||||
renderMarkers(initialState.places);
|
||||
fitToPlaces(initialState.places);
|
||||
lastPlacesRef = initialState.places;
|
||||
|
||||
subscribe((state) => {
|
||||
if (state.places !== lastPlacesRef) {
|
||||
lastPlacesRef = state.places;
|
||||
renderMarkers(state.places);
|
||||
}
|
||||
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;
|
||||
})();
|
||||
Reference in New Issue
Block a user