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:
2026-07-24 11:24:47 +02:00
parent 6b0d10c4fb
commit ee55fb1cf9
32 changed files with 2557 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
// Optionale Standortabfrage via navigator.geolocation. Wird nur auf
// explizite Nutzeraktion (Button) angefragt, nie automatisch beim Start.
(function () {
const { setState, updateSettings } = EventMap.store;
/**
* Fragt den aktuellen Standort des Nutzers an.
* @param {{onSuccess?: (loc:{lat:number,lng:number}) => void, onError?: (err:Error) => void}} handlers
*/
function requestUserLocation({ onSuccess, onError } = {}) {
if (!('geolocation' in navigator)) {
const err = new Error('Geolocation wird von diesem Browser nicht unterstützt.');
console.warn('EventMap:', err.message);
if (onError) onError(err);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const userLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude,
};
setState({ userLocation });
updateSettings({ userConsentGeo: true });
if (onSuccess) onSuccess(userLocation);
},
(error) => {
console.warn('EventMap: Standort konnte nicht ermittelt werden.', error.message);
updateSettings({ userConsentGeo: false });
if (onError) onError(error);
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
EventMap.map.requestUserLocation = requestUserLocation;
})();
+130
View File
@@ -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:
'&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 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;
})();
+64
View File
@@ -0,0 +1,64 @@
/* global L */
// Erzeugt Leaflet-Marker für Places inkl. Kategorie-Icon (L.divIcon) und
// Popup-Inhalt. Popup-Inhalte werden als DOM-Nodes (nicht als HTML-String)
// erzeugt, damit Place-Daten niemals ungesichert als innerHTML landen.
(function () {
const { getCategoryById } = EventMap.config;
const { getPlaceCategory, getPlaceLatLng } = EventMap.models;
const { el } = EventMap.utils;
const iconCache = new Map();
function buildDivIcon(category) {
return L.divIcon({
className: 'eventmap-marker',
html: `<span class="eventmap-marker__glyph" style="background:${category.color}">${category.icon}</span>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -16],
});
}
function getIconForCategory(categoryId) {
if (!iconCache.has(categoryId)) {
iconCache.set(categoryId, buildDivIcon(getCategoryById(categoryId)));
}
return iconCache.get(categoryId);
}
function buildPopupContent(place) {
const category = getPlaceCategory(place);
return el('div', { className: 'eventmap-popup' }, [
el('strong', { className: 'eventmap-popup__title', text: place.name }),
el('div', {
className: 'eventmap-popup__category',
style: { color: category.color },
text: `${category.icon} ${category.label}`,
}),
el('p', { className: 'eventmap-popup__desc', text: place.shortDescription }),
]);
}
/**
* Erzeugt einen Leaflet-Marker für ein Place-Objekt.
* @param {object} place
* @param {{onSelect?: (placeId: string) => void}} [handlers]
*/
function createMarkerForPlace(place, { onSelect } = {}) {
const { lat, lng } = getPlaceLatLng(place);
const marker = L.marker([lat, lng], { icon: getIconForCategory(place.category) });
marker.bindPopup(buildPopupContent(place));
marker.on('click', () => {
if (onSelect) {
onSelect(place.id);
}
});
return marker;
}
EventMap.map.createMarkerForPlace = createMarkerForPlace;
})();