/* global L */ // Detailansicht (Modal) für ein einzelnes Ausflugsziel. Öffnet sich, sobald // state.selectedPlaceId gesetzt wird (Marker-Klick oder Listen-Card), // schließt sich per X, Escape oder Backdrop-Klick und setzt dabei // selectedPlaceId im Store wieder auf null zurück. // // Rendering ausschließlich über textContent/DOM-APIs (el()), niemals // innerHTML mit Place-Daten (siehe js/utils/dom.js). (function () { const { subscribe, getState, setState, persistPlaces } = EventMap.store; const { getPlaceCategory, getPlaceLatLng } = EventMap.models; const { el, clearChildren, isSafeHttpUrl } = EventMap.utils; const { isValidCoordinate } = EventMap.utils; const FACILITY_DEFS = [ { key: 'strollerAccessible', icon: '👶', label: 'Kinderwagen geeignet' }, { key: 'wheelchairAccessible', icon: '♿', label: 'Barrierefrei' }, { key: 'toilets', icon: '🚻', label: 'Toiletten vorhanden' }, { key: 'parking', icon: '🅿️', label: 'Parkplatz vorhanden' }, { key: 'restaurant', icon: '🍽️', label: 'Gastronomie vorhanden' }, ]; const COST_LABELS = { free: 'Kostenlos', paid: 'Kostenpflichtig', mixed: 'Teilweise kostenpflichtig', unknown: 'Keine Angabe', }; const ENVIRONMENT_LABELS = { indoor: 'Drinnen (Indoor)', outdoor: 'Draußen (Outdoor)', mixed: 'Drinnen & draußen', }; let refs = null; let miniMap = null; let lastSelectedPlaceId = null; let currentPlace = null; function formatAgeRecommendation(ageRecommendation) { const { min, max } = ageRecommendation || {}; if (min == null && max == null) return 'Keine Angabe'; if (min != null && max != null) return `${min} – ${max} Jahre`; if (min != null) return `Ab ${min} Jahren`; return `Bis ${max} Jahre`; } function formatDuration(minutes) { if (typeof minutes !== 'number' || Number.isNaN(minutes) || minutes <= 0) { return null; } if (minutes < 60) return `${minutes} Minuten`; const hours = Math.floor(minutes / 60); const rest = minutes % 60; return rest === 0 ? `${hours} Std.` : `${hours} Std. ${rest} Min.`; } function buildSkeleton(container) { const nodeRefs = {}; nodeRefs.backdrop = el('div', { className: 'detail-modal__backdrop', on: { click: () => setState({ selectedPlaceId: null }) }, }); nodeRefs.closeBtn = el( 'button', { className: 'detail-modal__close', attrs: { type: 'button', 'aria-label': 'Detailansicht schließen' }, on: { click: () => setState({ selectedPlaceId: null }) }, }, ['✕'] ); nodeRefs.imageWrap = el('div', { className: 'detail-image' }); nodeRefs.name = el('h2', { className: 'detail-name', attrs: { id: 'detail-name' } }); nodeRefs.categoryBadge = el('span', { className: 'detail-category' }); nodeRefs.description = el('p', { className: 'detail-description' }); nodeRefs.addressLines = el('address', { className: 'detail-address' }); nodeRefs.miniMapContainer = el('div', { className: 'detail-minimap', attrs: { id: 'detail-minimap' }, }); nodeRefs.miniMapNote = el('p', { className: 'detail-minimap__note', text: 'Keine gültigen Koordinaten hinterlegt.', attrs: { hidden: '' }, }); nodeRefs.openingHours = el('p', { className: 'detail-value' }); nodeRefs.cost = el('p', { className: 'detail-value' }); nodeRefs.age = el('p', { className: 'detail-value' }); nodeRefs.facilitiesList = el('ul', { className: 'detail-facilities' }); nodeRefs.familyNotes = el('ul', { className: 'detail-family-notes' }); nodeRefs.tags = el('div', { className: 'detail-tags' }); nodeRefs.websiteWrap = el('p', { className: 'detail-value' }); nodeRefs.navBtn = el( 'a', { className: 'btn btn--primary detail-actions__nav', attrs: { target: '_blank', rel: 'noopener noreferrer' }, }, ['Route / Navigation starten'] ); nodeRefs.editBtn = el( 'button', { className: 'btn btn--secondary', attrs: { type: 'button', id: 'detail-edit-btn' }, on: { click: () => { // Detailansicht schließen und das Bearbeiten-Formular öffnen // (js/ui/form.js, wird nach diesem Modul geladen, daher hier zur // Aufrufzeit über den globalen Namespace referenziert statt beim // Datei-Laden destrukturiert). const place = currentPlace; setState({ selectedPlaceId: null }); EventMap.ui.openPlaceForm(place); }, }, }, ['Bearbeiten'] ); nodeRefs.deleteBtn = el( 'button', { className: 'btn btn--danger', attrs: { type: 'button', id: 'detail-delete-btn' }, on: { click: handleDelete }, }, ['Löschen'] ); const body = el('div', { className: 'detail-body' }, [ nodeRefs.name, nodeRefs.categoryBadge, section('Beschreibung', [nodeRefs.description]), section('Adresse', [nodeRefs.addressLines]), section('Position', [nodeRefs.miniMapContainer, nodeRefs.miniMapNote]), section('Öffnungszeiten', [nodeRefs.openingHours]), section('Kosten', [nodeRefs.cost]), section('Altersempfehlung', [nodeRefs.age]), section('Ausstattung', [nodeRefs.facilitiesList]), section('Hinweise für Familien', [nodeRefs.familyNotes, nodeRefs.tags]), section('Website / Informationsquelle', [nodeRefs.websiteWrap]), el('div', { className: 'detail-actions' }, [ nodeRefs.navBtn, nodeRefs.editBtn, nodeRefs.deleteBtn, ]), ]); nodeRefs.panel = el( 'div', { className: 'detail-modal__panel', attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'detail-name' }, }, [nodeRefs.closeBtn, nodeRefs.imageWrap, body] ); container.appendChild(nodeRefs.backdrop); container.appendChild(nodeRefs.panel); return nodeRefs; } function section(label, children) { return el('section', { className: 'detail-section' }, [ el('h3', { className: 'detail-section__label', text: label }), ...children, ]); } function renderImage(place, category) { clearChildren(refs.imageWrap); if (isSafeHttpUrl(place.image)) { const img = el('img', { className: 'detail-image__img', attrs: { src: place.image, alt: place.name }, }); // Falls die Bild-URL zwar ein sicheres Schema hat, aber nicht lädt // (kaputter Link), auf das Platzhalterbild zurückfallen. img.addEventListener('error', () => { clearChildren(refs.imageWrap); refs.imageWrap.appendChild(buildPlaceholderImage(category)); }); refs.imageWrap.appendChild(img); } else { refs.imageWrap.appendChild(buildPlaceholderImage(category)); } } function buildPlaceholderImage(category) { return el( 'div', { className: 'detail-image__placeholder', style: { backgroundColor: category.color }, }, [ el('span', { className: 'detail-image__placeholder-icon', text: category.icon }), el('span', { className: 'detail-image__placeholder-text', text: 'Kein Bild vorhanden' }), ] ); } function renderFacilities(facilities) { clearChildren(refs.facilitiesList); for (const def of FACILITY_DEFS) { const enabled = Boolean(facilities && facilities[def.key]); refs.facilitiesList.appendChild( el( 'li', { className: `detail-facility ${enabled ? 'detail-facility--yes' : 'detail-facility--no'}`, }, [ el('span', { className: 'detail-facility__icon', attrs: { 'aria-hidden': 'true' }, text: def.icon }), el('span', { className: 'detail-facility__label', text: def.label }), el('span', { className: 'detail-facility__state', text: enabled ? '(vorhanden)' : '(nicht vorhanden)', }), ] ) ); } } function renderFamilyNotes(place) { clearChildren(refs.familyNotes); clearChildren(refs.tags); const duration = formatDuration(place.durationMinutes); if (duration) { refs.familyNotes.appendChild( el('li', { text: `Empfohlene Aufenthaltsdauer: ca. ${duration}` }) ); } refs.familyNotes.appendChild( el('li', { text: `Umgebung: ${ENVIRONMENT_LABELS[place.environment] || 'Keine Angabe'}`, }) ); if (Array.isArray(place.tags) && place.tags.length > 0) { for (const tag of place.tags) { refs.tags.appendChild(el('span', { className: 'detail-tag', text: tag })); } } } function renderWebsite(place) { clearChildren(refs.websiteWrap); if (isSafeHttpUrl(place.website)) { refs.websiteWrap.appendChild( el( 'a', { className: 'detail-website-link', attrs: { href: place.website, target: '_blank', rel: 'noopener noreferrer' }, }, [place.website] ) ); } else { refs.websiteWrap.appendChild( el('span', { className: 'detail-value--muted', text: 'Keine Website hinterlegt.' }) ); } } function renderAddress(place) { clearChildren(refs.addressLines); const lines = [place.address, [place.city, place.region].filter(Boolean).join(', ')].filter( (line) => line && line.trim() !== '' ); if (lines.length === 0) { refs.addressLines.appendChild( el('span', { className: 'detail-value--muted', text: 'Keine Adresse hinterlegt.' }) ); return; } lines.forEach((line, index) => { if (index > 0) refs.addressLines.appendChild(el('br')); refs.addressLines.appendChild(document.createTextNode(line)); }); } function renderContent(place) { currentPlace = place; const category = getPlaceCategory(place); refs.name.textContent = place.name; refs.categoryBadge.textContent = `${category.icon} ${category.label}`; refs.categoryBadge.style.backgroundColor = category.color; renderImage(place, category); refs.description.textContent = place.description || place.shortDescription || 'Keine Beschreibung vorhanden.'; renderAddress(place); refs.openingHours.textContent = place.openingHours || 'Keine Angabe'; const cost = place.cost || {}; const costLabel = COST_LABELS[cost.type] || COST_LABELS.unknown; refs.cost.textContent = cost.description ? `${costLabel} – ${cost.description}` : costLabel; refs.age.textContent = formatAgeRecommendation(place.ageRecommendation); renderFacilities(place.facilities); renderFamilyNotes(place); renderWebsite(place); setupNavButton(place); } function setupNavButton(place) { const { lat, lng } = getPlaceLatLng(place); if (isValidCoordinate(lat, lng)) { refs.navBtn.hidden = false; refs.navBtn.setAttribute( 'href', `https://www.openstreetmap.org/directions?from=&to=${lat}%2C${lng}` ); } else { refs.navBtn.hidden = true; } } function destroyMiniMap() { if (miniMap) { miniMap.remove(); miniMap = null; } } function initMiniMap(place) { destroyMiniMap(); const { lat, lng } = getPlaceLatLng(place); if (!isValidCoordinate(lat, lng)) { refs.miniMapContainer.hidden = true; refs.miniMapNote.hidden = false; return; } refs.miniMapContainer.hidden = false; refs.miniMapNote.hidden = true; miniMap = L.map(refs.miniMapContainer, { center: [lat, lng], zoom: 15, }); // Dieselbe CARTO-Tile-Quelle wie die Hauptkarte (js/map/mapController.js): // tile.openstreetmap.org blockt file://-Requests mangels HTTP-Referer. L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap-Mitwirkende ' + '© CARTO', }).addTo(miniMap); L.marker([lat, lng]).addTo(miniMap); // Der Container war bis eben Teil eines hidden-Modals; Leaflet berechnet // seine Größe beim Initialisieren daher ggf. falsch. invalidateSize() // nach dem sichtbar werden behebt das bekannte Leaflet-Verhalten. requestAnimationFrame(() => { if (miniMap) miniMap.invalidateSize(); }); } function handleDelete() { const state = getState(); const place = state.places.find((p) => p.id === state.selectedPlaceId); if (!place) return; const confirmed = window.confirm(`"${place.name}" wirklich unwiderruflich löschen?`); if (!confirmed) return; const remainingPlaces = state.places.filter((p) => p.id !== place.id); setState({ places: remainingPlaces, selectedPlaceId: null }); persistPlaces(); } function openModal(container, place) { renderContent(place); container.hidden = false; document.body.classList.add('detail-modal-open'); initMiniMap(place); refs.closeBtn.focus(); } function closeModal(container) { if (container.hidden) return; container.hidden = true; document.body.classList.remove('detail-modal-open'); destroyMiniMap(); } /** Initialisiert die Detailansicht im Element mit der übergebenen id und abonniert den Store. */ function initDetail(containerId) { const container = document.getElementById(containerId); if (!container) { console.error(`EventMap: Detail-Container #${containerId} nicht gefunden.`); return; } refs = buildSkeleton(container); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !container.hidden) { setState({ selectedPlaceId: null }); } }); const applyState = (state) => { if (state.selectedPlaceId === lastSelectedPlaceId) return; lastSelectedPlaceId = state.selectedPlaceId; if (!state.selectedPlaceId) { closeModal(container); return; } const place = state.places.find((p) => p.id === state.selectedPlaceId); if (!place) { closeModal(container); return; } openModal(container, place); }; applyState(getState()); subscribe(applyState); } EventMap.ui.initDetail = initDetail; })();