Phase 4: Filter- und Suchfunktion
Neues Modul js/filters/ mit reiner Filterlogik (filterEngine.js: applyFilters(), getDistanceKm()) und UI (filterPanel.js) fuer alle Filter aus anforderung.md Abschnitt 3: Kategorie, Entfernung (mit "Standort verwenden"-Button), Alter der Kinder, Kosten, Indoor/Outdoor, 5 Ausstattungsmerkmale, Aufenthaltsdauer, Mindestbewertung sowie Freitextsuche ueber Name/Ort/Region/Adresse. Trefferzaehler, "Filter zuruecksetzen" und "Auf Ergebnisse zoomen" ergaenzt. Store erweitert um updateFilters()/getFilteredPlaces() (Selector). Karte und Liste rendern jetzt die gefilterte statt die volle Ausflugsziel-Liste; die Karten-Rerender-Optimierung vergleicht dafuer die Trefferliste per Id-Schluessel statt nur state.places, damit reine Filter-/Standortaenderungen ohne Places-Aenderung erkannt werden. Layout: Filter-Panel als aufklappbares <details> auf Mobile, eigener Grid-Bereich neben der Karte auf Desktop. docs/plan.md entsprechend fortgeschrieben (Phase 2-5 als umgesetzt markiert, Phase 5 Navigation war bereits Teil von Phase 2).
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
// 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]),
|
||||
]);
|
||||
|
||||
refs.details = el(
|
||||
'details',
|
||||
{ className: 'filter-panel__details' },
|
||||
[el('summary', { className: 'filter-panel__summary', text: 'Filter & Suche' }), 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user