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:
2026-07-24 12:21:07 +02:00
parent 10f9170d61
commit dac952fbf9
12 changed files with 843 additions and 42 deletions
+130
View File
@@ -0,0 +1,130 @@
// Reine Filterlogik ohne Seiteneffekte (siehe anforderung.md Abschnitt
// "3. Kategorien und Filter"). Wird von js/store/store.js
// (getFilteredPlaces()) und js/filters/filterPanel.js verwendet.
//
// Grundregel pro Feld: ein leerer/null/[]-Wert im Filter bedeutet "kein
// Filter aktiv" - das Place wird dadurch nicht ausgeschlossen.
(function () {
const { haversineDistanceKm, isValidCoordinate } = EventMap.utils;
const DEFAULT_FILTERS = {
categories: [],
maxDistanceKm: null,
minAge: null,
maxAge: null,
costTypes: [],
environment: null,
requireStroller: false,
requireWheelchair: false,
requireParking: false,
requireToilets: false,
requireRestaurant: false,
maxDurationMinutes: null,
minRating: null,
searchText: '',
};
/**
* Entfernung (km) zwischen Nutzerstandort und Place, oder null falls
* kein Standort vorhanden ist bzw. die Place-Koordinaten ungültig sind.
*/
function getDistanceKm(place, userLocation) {
if (!userLocation) return null;
if (!isValidCoordinate(place.latitude, place.longitude)) return null;
return haversineDistanceKm(userLocation, { lat: place.latitude, lng: place.longitude });
}
function matchesCategories(place, categories) {
if (!categories || categories.length === 0) return true;
return categories.includes(place.category);
}
function matchesDistance(place, maxDistanceKm, userLocation) {
if (maxDistanceKm === null || maxDistanceKm === undefined) return true;
if (!userLocation) return true; // kein Standort -> Entfernungsfilter wird ignoriert (kein Crash/keine leere Liste)
const distance = getDistanceKm(place, userLocation);
if (distance === null) return false; // Place hat keine gültigen Koordinaten -> Entfernung nicht verifizierbar
return distance <= maxDistanceKm;
}
/** Prüft, ob sich [minAge,maxAge] mit dem Alters-Bereich des Place überschneidet. */
function matchesAge(place, minAge, maxAge) {
if (minAge === null && maxAge === null) return true;
const age = place.ageRecommendation || {};
const filterMin = minAge === null || minAge === undefined ? -Infinity : minAge;
const filterMax = maxAge === null || maxAge === undefined ? Infinity : maxAge;
const placeMin = age.min === null || age.min === undefined ? -Infinity : age.min;
const placeMax = age.max === null || age.max === undefined ? Infinity : age.max;
return filterMin <= placeMax && placeMin <= filterMax;
}
function matchesCostTypes(place, costTypes) {
if (!costTypes || costTypes.length === 0) return true;
const cost = place.cost || {};
return costTypes.includes(cost.type);
}
function matchesEnvironment(place, environment) {
if (!environment) return true;
return place.environment === environment;
}
function matchesFacilities(place, filters) {
const facilities = place.facilities || {};
if (filters.requireStroller && !facilities.strollerAccessible) return false;
if (filters.requireWheelchair && !facilities.wheelchairAccessible) return false;
if (filters.requireParking && !facilities.parking) return false;
if (filters.requireToilets && !facilities.toilets) return false;
if (filters.requireRestaurant && !facilities.restaurant) return false;
return true;
}
function matchesDuration(place, maxDurationMinutes) {
if (maxDurationMinutes === null || maxDurationMinutes === undefined) return true;
const duration = place.durationMinutes;
if (typeof duration !== 'number' || Number.isNaN(duration)) return true; // unbekannte Dauer nicht ausschließen
return duration <= maxDurationMinutes;
}
function matchesRating(place, minRating) {
if (!minRating) return true; // 0/null/undefined -> kein Filter aktiv
const rating = place.rating;
if (rating === null || rating === undefined) return true; // Places ohne Bewertung werden nicht rausgefiltert
return rating >= minRating;
}
function matchesSearchText(place, searchText) {
const trimmed = (searchText || '').trim().toLowerCase();
if (!trimmed) return true;
const haystacks = [place.name, place.city, place.region, place.address];
return haystacks.some((value) => typeof value === 'string' && value.toLowerCase().includes(trimmed));
}
/** Wendet alle Filter auf eine Liste von Places an. Reine Funktion, keine Seiteneffekte. */
function applyFilters(places, filters, userLocation) {
if (!Array.isArray(places)) return [];
const f = filters || DEFAULT_FILTERS;
return places.filter(
(place) =>
matchesCategories(place, f.categories) &&
matchesDistance(place, f.maxDistanceKm, userLocation) &&
matchesAge(place, f.minAge, f.maxAge) &&
matchesCostTypes(place, f.costTypes) &&
matchesEnvironment(place, f.environment) &&
matchesFacilities(place, f) &&
matchesDuration(place, f.maxDurationMinutes) &&
matchesRating(place, f.minRating) &&
matchesSearchText(place, f.searchText)
);
}
EventMap.filters.DEFAULT_FILTERS = DEFAULT_FILTERS;
EventMap.filters.applyFilters = applyFilters;
EventMap.filters.getDistanceKm = getDistanceKm;
})();
+395
View File
@@ -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;
})();