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
+47
View File
@@ -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;
})();
+23
View File
@@ -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
View File
@@ -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.');
});
})();
+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;
})();
+56
View File
@@ -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;
})();
+50
View File
@@ -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;
})();
+37
View File
@@ -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;
})();
+63
View File
@@ -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;
})();
+88
View File
@@ -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;
})();
+77
View File
@@ -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;
})();
+95
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/** 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;
})();
+44
View File
@@ -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;
})();
+13
View File
@@ -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;
})();