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,47 @@
|
||||
// Feste Liste der Ausflugsziel-Kategorien (siehe anforderung.md).
|
||||
// Jede Kategorie hat eine stabile id (slug), ein Label, eine Farbe (Hex)
|
||||
// für Marker/Labels und ein Emoji-Icon als Glyph.
|
||||
|
||||
(function () {
|
||||
const CATEGORIES = [
|
||||
{ id: 'naturpark', label: 'Naturpark', color: '#2e7d32', icon: '🌳' },
|
||||
{ id: 'wald-wanderweg', label: 'Wald/Wanderweg', color: '#1b5e20', icon: '🌲' },
|
||||
{ id: 'see-badestelle', label: 'See/Badestelle', color: '#0288d1', icon: '🏖️' },
|
||||
{ id: 'erlebnispark', label: 'Erlebnispark', color: '#f57c00', icon: '🎢' },
|
||||
{ id: 'freizeitpark', label: 'Freizeitpark', color: '#e64a19', icon: '🎡' },
|
||||
{ id: 'tierpark-zoo', label: 'Tierpark/Zoo', color: '#ff8f00', icon: '🦁' },
|
||||
{ id: 'spielplatz', label: 'Spielplatz', color: '#d81b60', icon: '🛝' },
|
||||
{ id: 'museum-kinder', label: 'Museum für Kinder', color: '#7b1fa2', icon: '🏛️' },
|
||||
{ id: 'indoor-spielplatz', label: 'Indoor-Spielplatz', color: '#5e35b1', icon: '🧸' },
|
||||
{ id: 'bauernhof', label: 'Bauernhof', color: '#6d4c41', icon: '🐄' },
|
||||
{ id: 'kletterpark', label: 'Kletterpark', color: '#00897b', icon: '🧗' },
|
||||
{ id: 'aussichtspunkt', label: 'Aussichtspunkt', color: '#455a64', icon: '🔭' },
|
||||
{ id: 'cafe-restaurant', label: 'Familienfreundliches Café/Restaurant', color: '#c62828', icon: '☕' },
|
||||
{ id: 'sonstige-aktivitaet', label: 'Sonstige kinderfreundliche Aktivität', color: '#00acc1', icon: '🎈' },
|
||||
{ id: 'sonstige', label: 'Sonstige', color: '#616161', icon: '📍' },
|
||||
];
|
||||
|
||||
const CATEGORY_MAP = new Map(CATEGORIES.map((c) => [c.id, c]));
|
||||
const CATEGORY_LABEL_MAP = new Map(
|
||||
CATEGORIES.map((c) => [c.label.toLowerCase(), c])
|
||||
);
|
||||
|
||||
function getCategoryById(id) {
|
||||
return CATEGORY_MAP.get(id) ?? CATEGORY_MAP.get('sonstige');
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet eine Kategorie anhand ihres Labels (z.B. aus KI-Import-JSON).
|
||||
* Fällt auf "Sonstige" zurück, falls kein Treffer gefunden wird.
|
||||
*/
|
||||
function getCategoryByLabel(label) {
|
||||
if (typeof label !== 'string') {
|
||||
return CATEGORY_MAP.get('sonstige');
|
||||
}
|
||||
return CATEGORY_LABEL_MAP.get(label.toLowerCase()) ?? CATEGORY_MAP.get('sonstige');
|
||||
}
|
||||
|
||||
EventMap.config.CATEGORIES = CATEGORIES;
|
||||
EventMap.config.getCategoryById = getCategoryById;
|
||||
EventMap.config.getCategoryByLabel = getCategoryByLabel;
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
// Zentrale Konstanten der Anwendung.
|
||||
|
||||
(function () {
|
||||
const STORAGE_KEYS = {
|
||||
DATA: 'eventmap.data.v1',
|
||||
SETTINGS: 'eventmap.settings.v1',
|
||||
};
|
||||
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
// Geografische Mitte Deutschlands als Standard-Kartenzentrum,
|
||||
// solange der Nutzer keinen eigenen Standort freigibt oder ein
|
||||
// eigenes defaultCenter in den Settings hinterlegt hat.
|
||||
const DEFAULT_MAP_CENTER = {
|
||||
lat: 51.1657,
|
||||
lng: 10.4515,
|
||||
zoom: 6,
|
||||
};
|
||||
|
||||
EventMap.config.STORAGE_KEYS = STORAGE_KEYS;
|
||||
EventMap.config.CURRENT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION;
|
||||
EventMap.config.DEFAULT_MAP_CENTER = DEFAULT_MAP_CENTER;
|
||||
})();
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Einstiegspunkt der Anwendung: initialisiert Store, Karte und Liste.
|
||||
|
||||
(function () {
|
||||
const { initStore } = EventMap.store;
|
||||
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
||||
const { initList } = EventMap.ui;
|
||||
|
||||
function showFatalError(message) {
|
||||
const status = document.getElementById('app-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.hidden = false;
|
||||
}
|
||||
|
||||
function setupGeolocationButton() {
|
||||
const button = document.getElementById('locate-btn');
|
||||
if (!button) return;
|
||||
|
||||
const defaultLabel = button.textContent;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Standort wird ermittelt…';
|
||||
|
||||
requestUserLocation({
|
||||
onSuccess: ({ lat, lng }) => {
|
||||
centerOnCoords(lat, lng, 13);
|
||||
button.disabled = false;
|
||||
button.textContent = defaultLabel;
|
||||
},
|
||||
onError: () => {
|
||||
button.disabled = false;
|
||||
button.textContent = defaultLabel;
|
||||
showFatalError(
|
||||
'Standort konnte nicht ermittelt werden. Bitte Standortfreigabe im Browser prüfen.'
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await initStore();
|
||||
initMap('map');
|
||||
initList('place-list');
|
||||
setupGeolocationButton();
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('EventMap: Initialisierung fehlgeschlagen.', err);
|
||||
showFatalError('Die Anwendung konnte nicht geladen werden. Bitte Seite neu laden.');
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -0,0 +1,56 @@
|
||||
// Internes Place-Objekt: bleibt nah am KI-Import-JSON-Format aus
|
||||
// anforderung.md, ergänzt um intern generierte Felder (id, createdAt,
|
||||
// updatedAt). Die Kategorie wird beim Normalisieren immer auf eine
|
||||
// gültige Kategorie-id (Slug) aufgelöst, damit Styling (Farbe/Icon)
|
||||
// überall per getCategoryById() funktioniert.
|
||||
|
||||
(function () {
|
||||
const { generateId } = EventMap.utils;
|
||||
const { CATEGORIES, getCategoryById, getCategoryByLabel } = EventMap.config;
|
||||
const { createDefaultPlaceFields } = EventMap.models;
|
||||
|
||||
const CATEGORY_IDS = new Set(CATEGORIES.map((c) => c.id));
|
||||
|
||||
function resolveCategory(rawCategory) {
|
||||
if (typeof rawCategory === 'string' && CATEGORY_IDS.has(rawCategory)) {
|
||||
return rawCategory;
|
||||
}
|
||||
return getCategoryByLabel(rawCategory).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert ein rohes Place-Objekt (z.B. aus data/seed.js oder einem
|
||||
* späteren KI-Import) zu einem vollständigen internen Place-Objekt.
|
||||
* Fehlende Felder werden mit Defaults aufgefüllt, id/createdAt/updatedAt
|
||||
* werden übernommen falls vorhanden, sonst neu generiert.
|
||||
*/
|
||||
function normalizePlace(raw = {}) {
|
||||
const defaults = createDefaultPlaceFields();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: raw.id || generateId(),
|
||||
...defaults,
|
||||
...raw,
|
||||
category: resolveCategory(raw.category),
|
||||
ageRecommendation: { ...defaults.ageRecommendation, ...(raw.ageRecommendation || {}) },
|
||||
cost: { ...defaults.cost, ...(raw.cost || {}) },
|
||||
facilities: { ...defaults.facilities, ...(raw.facilities || {}) },
|
||||
tags: Array.isArray(raw.tags) ? [...raw.tags] : [],
|
||||
createdAt: raw.createdAt || now,
|
||||
updatedAt: raw.updatedAt || now,
|
||||
};
|
||||
}
|
||||
|
||||
function getPlaceCategory(place) {
|
||||
return getCategoryById(place.category);
|
||||
}
|
||||
|
||||
function getPlaceLatLng(place) {
|
||||
return { lat: place.latitude, lng: place.longitude };
|
||||
}
|
||||
|
||||
EventMap.models.normalizePlace = normalizePlace;
|
||||
EventMap.models.getPlaceCategory = getPlaceCategory;
|
||||
EventMap.models.getPlaceLatLng = getPlaceLatLng;
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
// Beschreibt die Feldstruktur eines Ausflugsziels (~27 Felder, siehe
|
||||
// anforderung.md) und liefert Default-Werte. Dient als einzige Quelle der
|
||||
// Wahrheit für die Form eines Place-Objekts; Validierungslogik für
|
||||
// Formulare folgt in einer späteren Phase.
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Liefert ein leeres/defaultes Place-Objekt ohne id/createdAt/updatedAt
|
||||
* (diese werden von place.js ergänzt).
|
||||
*/
|
||||
function createDefaultPlaceFields() {
|
||||
return {
|
||||
name: '',
|
||||
category: 'sonstige',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
address: '',
|
||||
city: '',
|
||||
region: '',
|
||||
ageRecommendation: { min: null, max: null },
|
||||
cost: { type: 'unknown', description: '' },
|
||||
openingHours: '',
|
||||
durationMinutes: null,
|
||||
facilities: {
|
||||
strollerAccessible: false,
|
||||
wheelchairAccessible: false,
|
||||
toilets: false,
|
||||
parking: false,
|
||||
restaurant: false,
|
||||
},
|
||||
environment: 'outdoor',
|
||||
website: '',
|
||||
image: '',
|
||||
rating: null,
|
||||
tags: [],
|
||||
source: '',
|
||||
sourceVerified: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Gültige Werte für die Enums des Datenmodells.
|
||||
const COST_TYPES = ['free', 'paid', 'mixed', 'unknown'];
|
||||
const ENVIRONMENT_TYPES = ['indoor', 'outdoor', 'mixed'];
|
||||
|
||||
EventMap.models.createDefaultPlaceFields = createDefaultPlaceFields;
|
||||
EventMap.models.COST_TYPES = COST_TYPES;
|
||||
EventMap.models.ENVIRONMENT_TYPES = ENVIRONMENT_TYPES;
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
// Schlanke Migrations-Infrastruktur für das LocalStorage-Datenschema.
|
||||
// Aktuell existiert nur Schema-Version 1, daher ist MIGRATIONS leer.
|
||||
// Neue Migrationen werden als { from, to, migrate(data) } ergänzt und
|
||||
// von migrate() sequenziell angewendet.
|
||||
|
||||
(function () {
|
||||
const { CURRENT_SCHEMA_VERSION } = EventMap.config;
|
||||
|
||||
const MIGRATIONS = [
|
||||
// Beispiel für zukünftige Migrationen:
|
||||
// { from: 1, to: 2, migrate: (data) => ({ ...data, neuesFeld: 'default' }) },
|
||||
];
|
||||
|
||||
/**
|
||||
* Wendet alle nötigen Migrationsschritte an, bis schemaVersion der
|
||||
* aktuellen CURRENT_SCHEMA_VERSION entspricht.
|
||||
*/
|
||||
function migrate(data) {
|
||||
let current = data ?? {};
|
||||
let version = current.schemaVersion ?? 1;
|
||||
|
||||
while (version < CURRENT_SCHEMA_VERSION) {
|
||||
const step = MIGRATIONS.find((m) => m.from === version);
|
||||
if (!step) {
|
||||
// Keine passende Migration gefunden: auf aktuelle Version springen,
|
||||
// um die App nicht zu blockieren.
|
||||
break;
|
||||
}
|
||||
current = step.migrate(current);
|
||||
version = step.to;
|
||||
}
|
||||
|
||||
return { ...current, schemaVersion: CURRENT_SCHEMA_VERSION };
|
||||
}
|
||||
|
||||
EventMap.store.migrate = migrate;
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
// Persistenzschicht: liest/schreibt die beiden LocalStorage-Keys
|
||||
// eventmap.data.v1 und eventmap.settings.v1.
|
||||
|
||||
(function () {
|
||||
const { STORAGE_KEYS, CURRENT_SCHEMA_VERSION } = EventMap.config;
|
||||
const { migrate } = EventMap.store;
|
||||
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
lastFilters: null,
|
||||
lastMapView: null,
|
||||
userConsentGeo: null,
|
||||
defaultCenter: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest die persistierten Places. Gibt null zurück, falls noch nichts gespeichert ist. */
|
||||
function loadData() {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEYS.DATA);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return migrate(parsed);
|
||||
} catch (err) {
|
||||
console.warn('EventMap: Gespeicherte Daten sind beschädigt und werden verworfen.', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveData(places) {
|
||||
const payload = {
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
places,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(STORAGE_KEYS.DATA, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
if (!raw) {
|
||||
return createDefaultSettings();
|
||||
}
|
||||
try {
|
||||
return { ...createDefaultSettings(), ...JSON.parse(raw) };
|
||||
} catch (err) {
|
||||
console.warn('EventMap: Gespeicherte Einstellungen sind beschädigt, Defaults werden verwendet.', err);
|
||||
return createDefaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
window.localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
EventMap.store.createDefaultSettings = createDefaultSettings;
|
||||
EventMap.store.loadData = loadData;
|
||||
EventMap.store.saveData = saveData;
|
||||
EventMap.store.loadSettings = loadSettings;
|
||||
EventMap.store.saveSettings = saveSettings;
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
// Zentraler Pub/Sub-Store der Anwendung. Hält den Laufzeitzustand
|
||||
// (places, filters, userLocation, selectedPlaceId) und benachrichtigt
|
||||
// Subscriber bei Änderungen. Lädt beim Start aus LocalStorage bzw. beim
|
||||
// allerersten Start aus data/seed.js und persistiert Änderungen zurück.
|
||||
|
||||
(function () {
|
||||
const { loadData, saveData, loadSettings, saveSettings } = EventMap.store;
|
||||
const { normalizePlace } = EventMap.models;
|
||||
|
||||
let state = {
|
||||
places: [],
|
||||
filters: null,
|
||||
userLocation: null,
|
||||
selectedPlaceId: null,
|
||||
};
|
||||
|
||||
let settings = loadSettings();
|
||||
|
||||
const subscribers = new Set();
|
||||
|
||||
function notify() {
|
||||
for (const fn of subscribers) {
|
||||
fn(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Registriert einen Listener, der bei jeder Zustandsänderung aufgerufen wird. */
|
||||
function subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function setState(patch) {
|
||||
state = { ...state, ...patch };
|
||||
notify();
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
function updateSettings(patch) {
|
||||
settings = { ...settings, ...patch };
|
||||
saveSettings(settings);
|
||||
}
|
||||
|
||||
function loadSeedPlaces() {
|
||||
const raw = (EventMap.data && EventMap.data.SEED_PLACES) || [];
|
||||
return raw.map((item) => normalizePlace(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert den Store: lädt persistierte Places aus LocalStorage oder,
|
||||
* falls noch keine vorhanden sind, die eingebetteten Beispieldaten
|
||||
* (data/seed.js). Gibt den finalen State zurück.
|
||||
*/
|
||||
function initStore() {
|
||||
const persisted = loadData();
|
||||
let places = [];
|
||||
|
||||
if (persisted && Array.isArray(persisted.places) && persisted.places.length > 0) {
|
||||
places = persisted.places.map((p) => normalizePlace(p));
|
||||
} else {
|
||||
places = loadSeedPlaces();
|
||||
saveData(places);
|
||||
}
|
||||
|
||||
setState({ places });
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Persistiert state.places nach Änderungen (Erstellen/Bearbeiten/Löschen). */
|
||||
function persistPlaces() {
|
||||
saveData(state.places);
|
||||
}
|
||||
|
||||
EventMap.store.subscribe = subscribe;
|
||||
EventMap.store.getState = getState;
|
||||
EventMap.store.setState = setState;
|
||||
EventMap.store.getSettings = getSettings;
|
||||
EventMap.store.updateSettings = updateSettings;
|
||||
EventMap.store.initStore = initStore;
|
||||
EventMap.store.persistPlaces = persistPlaces;
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
// Ergebnisliste (Cards), rendert aus store.state.places. Filterung folgt
|
||||
// in einer späteren Phase - aktuell werden alle Places angezeigt.
|
||||
// Rendering ausschließlich über textContent/DOM-APIs (el()), niemals
|
||||
// innerHTML mit Place-Daten.
|
||||
|
||||
(function () {
|
||||
const { subscribe, getState, setState } = EventMap.store;
|
||||
const { getCategoryById } = EventMap.config;
|
||||
const { el, clearChildren } = EventMap.utils;
|
||||
|
||||
function createCard(place) {
|
||||
const category = getCategoryById(place.category);
|
||||
|
||||
const select = () => setState({ selectedPlaceId: place.id });
|
||||
|
||||
return el(
|
||||
'li',
|
||||
{
|
||||
className: 'place-card',
|
||||
attrs: { tabindex: '0', role: 'button' },
|
||||
dataset: { placeId: place.id },
|
||||
on: {
|
||||
click: select,
|
||||
keydown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
select();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
[
|
||||
el('h3', { className: 'place-card__name', text: place.name }),
|
||||
el('span', {
|
||||
className: 'place-card__category',
|
||||
style: { backgroundColor: category.color },
|
||||
text: `${category.icon} ${category.label}`,
|
||||
}),
|
||||
el('p', { className: 'place-card__desc', text: place.shortDescription }),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function render(container, places) {
|
||||
clearChildren(container);
|
||||
|
||||
if (!places || places.length === 0) {
|
||||
container.appendChild(
|
||||
el('p', { className: 'list-empty', text: 'Keine Ausflugsziele vorhanden.' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('ul', { className: 'place-list' });
|
||||
for (const place of places) {
|
||||
list.appendChild(createCard(place));
|
||||
}
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
/** Initialisiert die Liste im Element mit der übergebenen id und abonniert den Store. */
|
||||
function initList(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error(`EventMap: List-Container #${containerId} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
render(container, getState().places);
|
||||
|
||||
subscribe((state) => {
|
||||
render(container, state.places);
|
||||
});
|
||||
}
|
||||
|
||||
EventMap.ui.initList = initList;
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
// Kleine DOM-Helfer. Wichtig: Daten aus Places werden NIE per innerHTML
|
||||
// eingefügt, sondern immer über textContent bzw. DOM-APIs (el()).
|
||||
//
|
||||
// Klassisches Script (kein ES-Modul): Die App wird per file:// direkt aus
|
||||
// einem synchronisierten Ordner geöffnet, daher können keine ES-Module
|
||||
// (import/export) verwendet werden (Browser blockieren deren Nachladen
|
||||
// unter file:// per CORS-Policy). Stattdessen hängen alle Dateien ihre
|
||||
// öffentliche API an das globale Namespace-Objekt window.EventMap.
|
||||
// Diese Datei wird als erste geladen und legt den Namespace an.
|
||||
|
||||
window.EventMap = window.EventMap || {};
|
||||
window.EventMap.utils = window.EventMap.utils || {};
|
||||
window.EventMap.config = window.EventMap.config || {};
|
||||
window.EventMap.models = window.EventMap.models || {};
|
||||
window.EventMap.store = window.EventMap.store || {};
|
||||
window.EventMap.map = window.EventMap.map || {};
|
||||
window.EventMap.ui = window.EventMap.ui || {};
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Erzeugt ein DOM-Element.
|
||||
* @param {string} tag Tag-Name, z.B. 'div'
|
||||
* @param {object} [props] Eigenschaften: className, attrs{}, dataset{}, style{},
|
||||
* text (setzt textContent), on{ click: fn, ... } für Event-Listener
|
||||
* @param {Array<Node|string>} [children] Kindknoten oder Strings (werden als Text angehängt)
|
||||
*/
|
||||
function el(tag, props = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
|
||||
if (props.className) {
|
||||
node.className = props.className;
|
||||
}
|
||||
if (props.text !== undefined) {
|
||||
node.textContent = props.text;
|
||||
}
|
||||
if (props.attrs) {
|
||||
for (const [key, value] of Object.entries(props.attrs)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (props.dataset) {
|
||||
for (const [key, value] of Object.entries(props.dataset)) {
|
||||
node.dataset[key] = value;
|
||||
}
|
||||
}
|
||||
if (props.style) {
|
||||
Object.assign(node.style, props.style);
|
||||
}
|
||||
if (props.on) {
|
||||
for (const [event, handler] of Object.entries(props.on)) {
|
||||
node.addEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (child === null || child === undefined) continue;
|
||||
if (typeof child === 'string') {
|
||||
node.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
node.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaped HTML-Sonderzeichen. Nur als Sicherheitsnetz gedacht, falls an
|
||||
* einzelnen Stellen doch einmal mit HTML-Strings gearbeitet werden muss
|
||||
* (z.B. Leaflet-Popups, die intern innerHTML nutzen). Für normales
|
||||
* Rendering bitte el()/textContent verwenden.
|
||||
*/
|
||||
function escapeHtml(value) {
|
||||
const str = String(value ?? '');
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Entfernt alle Kindknoten eines Elements. */
|
||||
function clearChildren(node) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
EventMap.utils.el = el;
|
||||
EventMap.utils.escapeHtml = escapeHtml;
|
||||
EventMap.utils.clearChildren = clearChildren;
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
// Geografische Hilfsfunktionen.
|
||||
|
||||
(function () {
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
function toRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernung zwischen zwei Koordinaten in Kilometern (Haversine-Formel).
|
||||
* @param {{lat:number,lng:number}} a
|
||||
* @param {{lat:number,lng:number}} b
|
||||
*/
|
||||
function haversineDistanceKm(a, b) {
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||
|
||||
return EARTH_RADIUS_KM * c;
|
||||
}
|
||||
|
||||
function isValidCoordinate(lat, lng) {
|
||||
return (
|
||||
typeof lat === 'number' &&
|
||||
typeof lng === 'number' &&
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lng) &&
|
||||
lat >= -90 &&
|
||||
lat <= 90 &&
|
||||
lng >= -180 &&
|
||||
lng <= 180
|
||||
);
|
||||
}
|
||||
|
||||
EventMap.utils.haversineDistanceKm = haversineDistanceKm;
|
||||
EventMap.utils.isValidCoordinate = isValidCoordinate;
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
// Erzeugt eindeutige IDs für Ausflugsziele.
|
||||
|
||||
(function () {
|
||||
function generateId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback für Umgebungen ohne crypto.randomUUID (z.B. sehr alte Browser).
|
||||
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
EventMap.utils.generateId = generateId;
|
||||
})();
|
||||
Reference in New Issue
Block a user