// Filter- und Suchpanel (Phase 4, siehe anforderung.md Abschnitt // "3. Kategorien und Filter"). Rendert alle Filter-Controls, schreibt // Änderungen über EventMap.store.updateFilters() in den Store und // abonniert den Store, um Controls/Trefferzahl synchron zu halten. // // Rendering ausschließlich über textContent/DOM-APIs (el()), niemals // innerHTML mit Nutzer-/Place-Daten (siehe js/utils/dom.js). (function () { const { subscribe, getState, getFilteredPlaces, updateFilters } = EventMap.store; const { DEFAULT_FILTERS } = EventMap.filters; const { CATEGORIES } = EventMap.config; const { COST_TYPES, ENVIRONMENT_TYPES } = EventMap.models; const { el, clearChildren } = EventMap.utils; 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; function section(legendText, children) { return el('fieldset', { className: 'filter-panel__section' }, [ el('legend', { className: 'filter-panel__section-label', text: legendText }), ...children, ]); } function readCategoryFilter() { return refs.categoryInputs.filter((input) => input.checked).map((input) => input.value); } function readCostTypeFilter() { return refs.costTypeInputs.filter((input) => input.checked).map((input) => input.value); } function readOptionalNumber(input) { const trimmed = input.value.trim(); if (trimmed === '') return null; const num = Number(trimmed); return Number.isFinite(num) ? num : null; } function buildCategoryGroup() { refs.categoryInputs = []; const options = CATEGORIES.map((cat) => { const id = `filter-cat-${cat.id}`; const input = el('input', { attrs: { type: 'checkbox', id, value: cat.id }, on: { change: () => updateFilters({ categories: readCategoryFilter() }) }, }); refs.categoryInputs.push(input); return el('label', { className: 'filter-panel__category-option', attrs: { for: id } }, [ input, el('span', { className: 'filter-panel__category-swatch', attrs: { 'aria-hidden': 'true' }, style: { backgroundColor: cat.color }, }), `${cat.icon} ${cat.label}`, ]); }); return el('div', { className: 'filter-panel__categories' }, options); } function buildDistanceGroup() { refs.maxDistance = el('input', { attrs: { type: 'number', min: '1', step: '1', id: 'filter-max-distance', placeholder: 'z.B. 20' }, on: { input: () => updateFilters({ maxDistanceKm: readOptionalNumber(refs.maxDistance) }), }, }); refs.distanceHint = el('p', { className: 'filter-panel__hint', text: 'Bitte zuerst Standort freigeben, um nach Entfernung zu filtern.', }); refs.useLocationBtn = el( 'button', { className: 'btn btn--secondary filter-panel__location-btn', attrs: { type: 'button' }, on: { click: handleUseLocationClick }, }, ['Standort verwenden'] ); return el('div', { className: 'filter-panel__distance' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-max-distance' } }, [ 'Umkreis (km)', ]), refs.maxDistance, refs.distanceHint, refs.useLocationBtn, ]); } function handleUseLocationClick() { const { requestUserLocation } = EventMap.map; // Laufzeitzugriff: js/map/geolocation.js lädt vor diesem Modul, Zugriff dennoch defensiv zur Aufrufzeit. if (!requestUserLocation) return; refs.useLocationBtn.disabled = true; const defaultLabel = refs.useLocationBtn.textContent; refs.useLocationBtn.textContent = 'Standort wird ermittelt…'; requestUserLocation({ onSuccess: () => { refs.useLocationBtn.disabled = false; refs.useLocationBtn.textContent = defaultLabel; }, onError: () => { refs.useLocationBtn.disabled = false; refs.useLocationBtn.textContent = defaultLabel; }, }); } function buildAgeGroup() { refs.minAge = el('input', { attrs: { type: 'number', min: '0', step: '1', id: 'filter-min-age', placeholder: 'ab' }, on: { input: () => updateFilters({ minAge: readOptionalNumber(refs.minAge) }) }, }); refs.maxAge = el('input', { attrs: { type: 'number', min: '0', step: '1', id: 'filter-max-age', placeholder: 'bis' }, on: { input: () => updateFilters({ maxAge: readOptionalNumber(refs.maxAge) }) }, }); return el('div', { className: 'filter-panel__row' }, [ el('div', { className: 'filter-panel__field' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-min-age' } }, ['Alter ab']), refs.minAge, ]), el('div', { className: 'filter-panel__field' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-max-age' } }, ['Alter bis']), refs.maxAge, ]), ]); } function buildCostGroup() { refs.costTypeInputs = []; const options = COST_TYPES.map((type) => { const id = `filter-cost-${type}`; const input = el('input', { attrs: { type: 'checkbox', id, value: type }, on: { change: () => updateFilters({ costTypes: readCostTypeFilter() }) }, }); refs.costTypeInputs.push(input); return el('label', { className: 'filter-panel__checkbox', attrs: { for: id } }, [ input, COST_LABELS[type] || type, ]); }); return el('div', { className: 'filter-panel__checkbox-group' }, options); } function buildEnvironmentGroup() { refs.environment = el( 'select', { attrs: { id: 'filter-environment' }, on: { change: () => updateFilters({ environment: refs.environment.value || null }) } }, [ el('option', { attrs: { value: '' }, text: 'Indoor & Outdoor' }), ...ENVIRONMENT_TYPES.map((type) => el('option', { attrs: { value: type }, text: ENVIRONMENT_LABELS[type] || type }) ), ] ); return el('div', { className: 'filter-panel__field' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-environment' } }, [ 'Indoor / Outdoor', ]), refs.environment, ]); } function facilityCheckbox(id, labelText, filterKey) { const input = el('input', { attrs: { type: 'checkbox', id }, on: { change: () => updateFilters({ [filterKey]: input.checked }) }, }); const wrapper = el('label', { className: 'filter-panel__checkbox', attrs: { for: id } }, [ input, labelText, ]); return { wrapper, input }; } function buildFacilitiesGroup() { const stroller = facilityCheckbox('filter-facility-stroller', 'Kinderwagen geeignet', 'requireStroller'); const wheelchair = facilityCheckbox('filter-facility-wheelchair', 'Barrierefrei', 'requireWheelchair'); const parking = facilityCheckbox('filter-facility-parking', 'Mit Parkplatz', 'requireParking'); const toilets = facilityCheckbox('filter-facility-toilets', 'Mit Toiletten', 'requireToilets'); const restaurant = facilityCheckbox('filter-facility-restaurant', 'Mit Gastronomie', 'requireRestaurant'); refs.requireStroller = stroller.input; refs.requireWheelchair = wheelchair.input; refs.requireParking = parking.input; refs.requireToilets = toilets.input; refs.requireRestaurant = restaurant.input; return el('div', { className: 'filter-panel__checkbox-group' }, [ stroller.wrapper, wheelchair.wrapper, parking.wrapper, toilets.wrapper, restaurant.wrapper, ]); } function buildDurationGroup() { refs.maxDuration = el('input', { attrs: { type: 'number', min: '0', step: '15', id: 'filter-max-duration', placeholder: 'z.B. 120' }, on: { input: () => updateFilters({ maxDurationMinutes: readOptionalNumber(refs.maxDuration) }) }, }); return el('div', { className: 'filter-panel__field' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-max-duration' } }, [ 'Max. Aufenthaltsdauer (Minuten)', ]), refs.maxDuration, ]); } function buildRatingGroup() { refs.minRating = el( 'select', { attrs: { id: 'filter-min-rating' }, on: { change: () => updateFilters({ minRating: refs.minRating.value === '' ? null : Number(refs.minRating.value) }), }, }, [ el('option', { attrs: { value: '' }, text: 'Alle Bewertungen' }), el('option', { attrs: { value: '1' }, text: '★ 1+' }), el('option', { attrs: { value: '2' }, text: '★★ 2+' }), el('option', { attrs: { value: '3' }, text: '★★★ 3+' }), el('option', { attrs: { value: '4' }, text: '★★★★ 4+' }), el('option', { attrs: { value: '5' }, text: '★★★★★ 5' }), ] ); return el('div', { className: 'filter-panel__field' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-min-rating' } }, [ 'Mindestbewertung', ]), refs.minRating, ]); } function buildSearchGroup() { refs.searchText = el('input', { attrs: { type: 'search', id: 'filter-search', placeholder: 'Name, Ort, Region, Adresse …' }, on: { input: () => updateFilters({ searchText: refs.searchText.value }) }, }); return el('div', { className: 'filter-panel__field' }, [ el('label', { className: 'filter-panel__label', attrs: { for: 'filter-search' } }, [ 'Region oder Ort suchen', ]), refs.searchText, ]); } function buildSkeleton(rootContainer) { refs = {}; refs.countDisplay = el('p', { className: 'filter-panel__count' }); refs.resetBtn = el( 'button', { className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: () => updateFilters({ ...DEFAULT_FILTERS }) }, }, ['Filter zurücksetzen'] ); refs.zoomToResultsBtn = el( 'button', { className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: () => EventMap.map.fitToPlaces(getFilteredPlaces()) }, }, ['Auf Ergebnisse zoomen'] ); const body = el('div', { className: 'filter-panel__body' }, [ buildSearchGroup(), section('Kategorie', [buildCategoryGroup()]), section('Entfernung', [buildDistanceGroup()]), section('Alter der Kinder', [buildAgeGroup()]), section('Kosten', [buildCostGroup()]), section('Umgebung', [buildEnvironmentGroup()]), section('Ausstattung', [buildFacilitiesGroup()]), section('Aufenthaltsdauer', [buildDurationGroup()]), section('Bewertung', [buildRatingGroup()]), el('div', { className: 'filter-panel__actions' }, [refs.countDisplay, refs.resetBtn, refs.zoomToResultsBtn]), ]); const summary = el( 'summary', { className: 'filter-panel__summary', attrs: { 'aria-label': 'Filter & Suche ein-/ausblenden' } }, [ el('span', { className: 'filter-panel__summary-icon', attrs: { 'aria-hidden': 'true' }, text: '🔍' }), el('span', { className: 'filter-panel__summary-text', text: 'Filter & Suche' }), ] ); refs.details = el('details', { className: 'filter-panel__details' }, [summary, body]); clearChildren(rootContainer); rootContainer.appendChild(refs.details); } /** Setzt die Werte aller Controls anhand des aktuellen Filter-Objekts, ohne change-Events auszulösen. */ function syncControlsFromFilters(filters) { for (const input of refs.categoryInputs) { input.checked = filters.categories.includes(input.value); } for (const input of refs.costTypeInputs) { input.checked = filters.costTypes.includes(input.value); } setNumberInputIfChanged(refs.maxDistance, filters.maxDistanceKm); setNumberInputIfChanged(refs.minAge, filters.minAge); setNumberInputIfChanged(refs.maxAge, filters.maxAge); setNumberInputIfChanged(refs.maxDuration, filters.maxDurationMinutes); refs.environment.value = filters.environment || ''; refs.minRating.value = filters.minRating ? String(filters.minRating) : ''; refs.requireStroller.checked = Boolean(filters.requireStroller); refs.requireWheelchair.checked = Boolean(filters.requireWheelchair); refs.requireParking.checked = Boolean(filters.requireParking); refs.requireToilets.checked = Boolean(filters.requireToilets); refs.requireRestaurant.checked = Boolean(filters.requireRestaurant); if (refs.searchText.value !== filters.searchText) { refs.searchText.value = filters.searchText || ''; } } function setNumberInputIfChanged(input, value) { const newValue = value === null || value === undefined ? '' : String(value); if (input.value !== newValue) { input.value = newValue; } } function syncDistanceAvailability(userLocation) { const available = Boolean(userLocation); refs.maxDistance.disabled = !available; refs.distanceHint.hidden = available; refs.useLocationBtn.hidden = available; } function updateCountDisplay(filteredCount, totalCount) { refs.countDisplay.textContent = `${filteredCount} von ${totalCount} Ausflugszielen`; } function applyState(state) { const filters = state.filters || DEFAULT_FILTERS; syncControlsFromFilters(filters); syncDistanceAvailability(state.userLocation); updateCountDisplay(getFilteredPlaces().length, state.places.length); } /** Initialisiert das Filter-Panel im Element mit der übergebenen id und abonniert den Store. */ function initFilterPanel(containerId) { const container = document.getElementById(containerId); if (!container) { console.error(`EventMap: Filter-Panel-Container #${containerId} nicht gefunden.`); return; } buildSkeleton(container); applyState(getState()); subscribe(applyState); } EventMap.ui.initFilterPanel = initFilterPanel; })();