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
+6
View File
@@ -98,6 +98,12 @@
line-height: 1.4; line-height: 1.4;
} }
.place-card__distance {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 600;
}
/* Karten-Marker (L.divIcon) */ /* Karten-Marker (L.divIcon) */
.eventmap-marker { .eventmap-marker {
+183
View File
@@ -0,0 +1,183 @@
/* Filter- und Suchpanel (Phase 4). Struktur wird von
js/filters/filterPanel.js in #filter-panel eingehängt.
Mobile: <details>/<summary> als einfaches, JS-freies Auf-/Zuklappen.
Desktop (>=1024px, siehe css/responsive.css): immer sichtbar/offen. */
.filter-panel {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.filter-panel__details {
padding: 0 var(--space-md);
}
.filter-panel__details[open] {
padding-bottom: var(--space-md);
}
.filter-panel__summary {
padding: var(--space-md) 0;
font-weight: 700;
cursor: pointer;
list-style: none;
}
.filter-panel__summary::-webkit-details-marker {
display: none;
}
.filter-panel__summary::before {
content: '▸ ';
}
.filter-panel__details[open] > .filter-panel__summary::before {
content: '▾ ';
}
.filter-panel__body {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.filter-panel__section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
border: none;
border-top: 1px solid var(--color-border);
padding: var(--space-sm) 0 0;
margin: 0;
}
.filter-panel__section-label {
padding: 0;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--color-text-muted);
}
.filter-panel__field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
flex: 1 1 160px;
}
.filter-panel__label {
font-size: 0.85rem;
font-weight: 600;
}
.filter-panel__field input,
.filter-panel__field select,
.filter-panel__distance input,
.filter-panel__distance select {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
font: inherit;
background: var(--color-surface);
color: var(--color-text);
width: 100%;
}
.filter-panel__row {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
}
.filter-panel__categories {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm) var(--space-md);
max-height: 180px;
overflow-y: auto;
padding: var(--space-xs) 0;
}
.filter-panel__category-option,
.filter-panel__checkbox {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.85rem;
cursor: pointer;
}
.filter-panel__category-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.filter-panel__checkbox-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm) var(--space-md);
}
.filter-panel__distance {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-width: 220px;
}
.filter-panel__hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.filter-panel__hint[hidden] {
display: none;
}
.filter-panel__location-btn[hidden] {
display: none;
}
.filter-panel__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm) var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
.filter-panel__count {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
margin-right: auto;
}
@media (min-width: 1024px) {
.filter-panel {
border-bottom: none;
overflow-y: auto;
}
.filter-panel__summary {
cursor: default;
}
/* Details-Inhalt unabhängig vom open-Attribut immer anzeigen, damit das
Panel auf Desktop nicht eingeklappt werden kann/muss (überschreibt die
UA-Default-Regel "details:not([open]) > *:not(summary){display:none}"). */
.filter-panel__body {
display: grid !important;
grid-template-columns: repeat(2, 1fr);
}
.filter-panel__actions {
grid-column: 1 / -1;
}
}
+12
View File
@@ -4,15 +4,27 @@
.app-layout { .app-layout {
display: grid; display: grid;
grid-template-columns: 65% 35%; grid-template-columns: 65% 35%;
grid-template-rows: auto 1fr;
grid-template-areas:
'map filter'
'map list';
height: calc(100vh - var(--header-height)); height: calc(100vh - var(--header-height));
} }
.map-panel { .map-panel {
grid-area: map;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.filter-panel {
grid-area: filter;
border-left: 1px solid var(--color-border);
max-height: 45vh;
}
.list-panel { .list-panel {
grid-area: list;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
+27 -8
View File
@@ -156,13 +156,32 @@ Seed-Daten dadurch synchron direkt aus dem globalen Namespace, ganz ohne
|---|---|---| |---|---|---|
| 0 | Scaffold (Ordnerstruktur, vendored Leaflet, Grundgerüst) | ✅ umgesetzt | | 0 | Scaffold (Ordnerstruktur, vendored Leaflet, Grundgerüst) | ✅ umgesetzt |
| 1 | Datenmodell, Karte, Liste (read-only, Seed-Daten) | ✅ umgesetzt | | 1 | Datenmodell, Karte, Liste (read-only, Seed-Daten) | ✅ umgesetzt |
| 2 | Detailansicht (Titelbild, vollständige Beschreibung, Mini-Karte, Navigation-Button, Bearbeiten/Löschen) | offen | | 2 | Detailansicht (Titelbild, vollständige Beschreibung, Mini-Karte, Navigation-Button, Bearbeiten/Löschen) | ✅ umgesetzt |
| 3 | Formular zum manuellen Anlegen/Bearbeiten (Validierung, Koordinatenprüfung) | offen | | 3 | Formular zum manuellen Anlegen/Bearbeiten (Validierung, Koordinatenprüfung) | ✅ umgesetzt |
| 4 | Filter- und Suchfunktion (Kategorie, Entfernung, Alter, Kosten, Indoor/Outdoor, Ausstattung, Bewertung, Region) inkl. Anwendung auf Karte + Liste | offen | | 4 | Filter- und Suchfunktion (Kategorie, Entfernung, Alter, Kosten, Indoor/Outdoor, Ausstattung, Bewertung, Region) inkl. Anwendung auf Karte + Liste | ✅ umgesetzt |
| 5 | Route/Navigation-Integration (z.B. Link zu externem Kartendienst) | offen | | 5 | Route/Navigation-Integration | ✅ bereits in Phase 2 erledigt (OSM-Directions-Button in der Detailansicht) |
| 6 | KI-Prompt-Generator (Eingabeformular, Promptgenerierung, Copy/Download) | offen | | 6 | KI-Prompt-Generator (Eingabeformular, Promptgenerierung, Copy/Download) | offen |
| 7 | KI-JSON-Import (Validierung, Vorschau, Übernahme ins LocalStorage) | offen | | 7 | KI-JSON-Import (Validierung, Vorschau, Übernahme ins LocalStorage) + Backup-Export/Import + Politur | offen |
Nicht Teil von Phase 0/1 und daher bewusst noch nicht angelegt: ## Filter- und Suchfunktion (Phase 4)
Filter-Modul, Detail-Modul, Formular-Modul, Prompt-Generator-Modul,
Import-Modul. - `js/filters/filterEngine.js`: reine Funktion `applyFilters(places, filters, userLocation)`
plus `getDistanceKm()`. Leerer/`null`/`[]`-Wert je Filterfeld = kein Filter aktiv.
Places ohne Bewertung/Dauer werden bei aktivem Bewertungs-/Dauer-Filter nicht
automatisch ausgeschlossen; Places ohne gültige Koordinaten werden bei aktivem
Entfernungsfilter (mit vorhandenem Standort) ausgeschlossen, da ihre Entfernung
nicht verifizierbar ist. Fehlt der Nutzerstandort komplett, wird der
Entfernungsfilter ignoriert statt die Liste leerzuräumen.
- `js/filters/filterPanel.js`: UI für alle Filter aus `anforderung.md` Abschnitt 3
(Kategorie, Entfernung inkl. "Standort verwenden"-Button, Alter, Kosten,
Indoor/Outdoor, 5 Ausstattungsmerkmale, Aufenthaltsdauer, Mindestbewertung,
Freitextsuche über Name/Ort/Region/Adresse), Trefferzähler "X von Y",
"Filter zurücksetzen" und "Auf Ergebnisse zoomen".
- Store: `EventMap.store.updateFilters(patch)` und `EventMap.store.getFilteredPlaces()`
(Selector). `js/ui/list.js` und `js/map/mapController.js` rendern seitdem die
gefilterte statt die volle Liste.
- Layout: Mobile (< 1024px) als `<details>/<summary>` einklappbar oberhalb der
Liste; Desktop (≥ 1024px) als eigener Grid-Bereich neben der Karte, über der
Liste (`css/responsive.css`).
- Nicht umgesetzt (bewusst kein Muss laut Plan): Persistierung der Filter in
`settings.lastFilters`.
+5
View File
@@ -15,6 +15,7 @@
<link rel="stylesheet" href="css/components.css" /> <link rel="stylesheet" href="css/components.css" />
<link rel="stylesheet" href="css/detail.css" /> <link rel="stylesheet" href="css/detail.css" />
<link rel="stylesheet" href="css/form.css" /> <link rel="stylesheet" href="css/form.css" />
<link rel="stylesheet" href="css/filters.css" />
<link rel="stylesheet" href="css/responsive.css" /> <link rel="stylesheet" href="css/responsive.css" />
</head> </head>
<body> <body>
@@ -40,6 +41,8 @@
</div> </div>
</section> </section>
<div id="filter-panel" class="filter-panel" aria-label="Filter und Suche"></div>
<aside class="list-panel" aria-label="Ergebnisliste"> <aside class="list-panel" aria-label="Ergebnisliste">
<h2 class="list-panel__title">Ausflugsziele</h2> <h2 class="list-panel__title">Ausflugsziele</h2>
<div id="place-list" class="list-panel__content"> <div id="place-list" class="list-panel__content">
@@ -61,6 +64,7 @@
<script src="js/utils/dom.js"></script> <script src="js/utils/dom.js"></script>
<script src="js/utils/geo.js"></script> <script src="js/utils/geo.js"></script>
<script src="js/utils/id.js"></script> <script src="js/utils/id.js"></script>
<script src="js/filters/filterEngine.js"></script>
<script src="data/seed.js"></script> <script src="data/seed.js"></script>
<script src="js/config/constants.js"></script> <script src="js/config/constants.js"></script>
<script src="js/config/categories.js"></script> <script src="js/config/categories.js"></script>
@@ -75,6 +79,7 @@
<script src="js/ui/list.js"></script> <script src="js/ui/list.js"></script>
<script src="js/ui/detail.js"></script> <script src="js/ui/detail.js"></script>
<script src="js/ui/form.js"></script> <script src="js/ui/form.js"></script>
<script src="js/filters/filterPanel.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>
+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;
})();
+2 -1
View File
@@ -3,7 +3,7 @@
(function () { (function () {
const { initStore } = EventMap.store; const { initStore } = EventMap.store;
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map; const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
const { initList, initDetail, initForm } = EventMap.ui; const { initList, initDetail, initForm, initFilterPanel } = EventMap.ui;
function showFatalError(message) { function showFatalError(message) {
const status = document.getElementById('app-status'); const status = document.getElementById('app-status');
@@ -51,6 +51,7 @@
async function bootstrap() { async function bootstrap() {
await initStore(); await initStore();
initMap('map'); initMap('map');
initFilterPanel('filter-panel');
initList('place-list'); initList('place-list');
initDetail('detail-modal'); initDetail('detail-modal');
initForm('place-form-modal'); initForm('place-form-modal');
+20 -9
View File
@@ -4,7 +4,7 @@
(function () { (function () {
const { DEFAULT_MAP_CENTER } = EventMap.config; const { DEFAULT_MAP_CENTER } = EventMap.config;
const { subscribe, getState, setState } = EventMap.store; const { subscribe, setState, getFilteredPlaces } = EventMap.store;
const { getPlaceLatLng } = EventMap.models; const { getPlaceLatLng } = EventMap.models;
const { createMarkerForPlace } = EventMap.map; const { createMarkerForPlace } = EventMap.map;
@@ -12,9 +12,14 @@
let clusterGroup = null; let clusterGroup = null;
const markersByPlaceId = new Map(); const markersByPlaceId = new Map();
let lastPlacesRef = null; let lastFilteredKey = null;
let lastSelectedPlaceId = null; let lastSelectedPlaceId = null;
/** Günstiger Vergleichsschlüssel für eine gefilterte Trefferliste (Reihenfolge + IDs). */
function computeFilteredKey(places) {
return places.map((place) => place.id).join('|');
}
function hasValidCoords(place) { function hasValidCoords(place) {
const { lat, lng } = getPlaceLatLng(place); const { lat, lng } = getPlaceLatLng(place);
return typeof lat === 'number' && typeof lng === 'number' && !Number.isNaN(lat) && !Number.isNaN(lng); return typeof lat === 'number' && typeof lng === 'number' && !Number.isNaN(lat) && !Number.isNaN(lng);
@@ -101,15 +106,21 @@
clusterGroup = L.markerClusterGroup(); clusterGroup = L.markerClusterGroup();
map.addLayer(clusterGroup); map.addLayer(clusterGroup);
const initialState = getState(); const initialFiltered = getFilteredPlaces();
renderMarkers(initialState.places); renderMarkers(initialFiltered);
fitToPlaces(initialState.places); fitToPlaces(initialFiltered);
lastPlacesRef = initialState.places; lastFilteredKey = computeFilteredKey(initialFiltered);
subscribe((state) => { subscribe((state) => {
if (state.places !== lastPlacesRef) { // Filter/Standort können sich ändern, ohne dass sich state.places
lastPlacesRef = state.places; // ändert - daher bei jeder Store-Änderung neu filtern und nur bei
renderMarkers(state.places); // tatsächlich geänderter Trefferliste neu rendern (vermeidet
// unnötiges Marker-Flackern z.B. bei jedem Freitext-Tastendruck).
const filtered = getFilteredPlaces();
const key = computeFilteredKey(filtered);
if (key !== lastFilteredKey) {
lastFilteredKey = key;
renderMarkers(filtered);
} }
if (state.selectedPlaceId !== lastSelectedPlaceId) { if (state.selectedPlaceId !== lastSelectedPlaceId) {
lastSelectedPlaceId = state.selectedPlaceId; lastSelectedPlaceId = state.selectedPlaceId;
+22
View File
@@ -78,6 +78,26 @@
saveData(state.places); saveData(state.places);
} }
/**
* Merged patch in state.filters (initialisiert mit EventMap.filters.DEFAULT_FILTERS
* falls state.filters noch null ist) und setzt eine neue Objekt-Referenz.
*
* WICHTIG: EventMap.filters wird hier bewusst erst zur Aufrufzeit
* referenziert (nicht beim IIFE-Start destrukturiert), analog zum Muster
* zwischen js/ui/detail.js und js/ui/form.js - so ist store.js unabhängig
* von der genauen Ladereihenfolge relativ zu js/filters/filterEngine.js.
*/
function updateFilters(patch) {
const current = state.filters || EventMap.filters.DEFAULT_FILTERS;
setState({ filters: { ...current, ...patch } });
}
/** Selector: liefert state.places gefiltert nach state.filters/state.userLocation. */
function getFilteredPlaces() {
const filters = state.filters || EventMap.filters.DEFAULT_FILTERS;
return EventMap.filters.applyFilters(state.places, filters, state.userLocation);
}
EventMap.store.subscribe = subscribe; EventMap.store.subscribe = subscribe;
EventMap.store.getState = getState; EventMap.store.getState = getState;
EventMap.store.setState = setState; EventMap.store.setState = setState;
@@ -85,4 +105,6 @@
EventMap.store.updateSettings = updateSettings; EventMap.store.updateSettings = updateSettings;
EventMap.store.initStore = initStore; EventMap.store.initStore = initStore;
EventMap.store.persistPlaces = persistPlaces; EventMap.store.persistPlaces = persistPlaces;
EventMap.store.updateFilters = updateFilters;
EventMap.store.getFilteredPlaces = getFilteredPlaces;
})(); })();
+40 -24
View File
@@ -1,18 +1,39 @@
// Ergebnisliste (Cards), rendert aus store.state.places. Filterung folgt // Ergebnisliste (Cards), rendert die gefilterte Trefferliste
// in einer späteren Phase - aktuell werden alle Places angezeigt. // (EventMap.store.getFilteredPlaces()) statt state.places direkt, damit
// Kategorie-/Entfernungs-/etc.-Filter aus js/filters/ berücksichtigt werden.
// Rendering ausschließlich über textContent/DOM-APIs (el()), niemals // Rendering ausschließlich über textContent/DOM-APIs (el()), niemals
// innerHTML mit Place-Daten. // innerHTML mit Place-Daten.
(function () { (function () {
const { subscribe, getState, setState } = EventMap.store; const { subscribe, getState, setState, getFilteredPlaces } = EventMap.store;
const { getCategoryById } = EventMap.config; const { getCategoryById } = EventMap.config;
const { getDistanceKm } = EventMap.filters;
const { el, clearChildren } = EventMap.utils; const { el, clearChildren } = EventMap.utils;
function createCard(place) { function formatDistance(km) {
return km < 10 ? `${km.toFixed(1)} km entfernt` : `${Math.round(km)} km entfernt`;
}
function createCard(place, userLocation) {
const category = getCategoryById(place.category); const category = getCategoryById(place.category);
const select = () => setState({ selectedPlaceId: place.id }); const select = () => setState({ selectedPlaceId: place.id });
const children = [
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 }),
];
const distanceKm = getDistanceKm(place, userLocation);
if (distanceKm !== null) {
children.push(el('p', { className: 'place-card__distance', text: formatDistance(distanceKm) }));
}
return el( return el(
'li', 'li',
{ {
@@ -29,31 +50,25 @@
}, },
}, },
}, },
[ children
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) { function render(container, filteredPlaces, totalPlacesCount, userLocation) {
clearChildren(container); clearChildren(container);
if (!places || places.length === 0) { if (!filteredPlaces || filteredPlaces.length === 0) {
container.appendChild( const message =
el('p', { className: 'list-empty', text: 'Keine Ausflugsziele vorhanden.' }) totalPlacesCount > 0
); ? 'Keine Ausflugsziele gefunden. Filter anpassen?'
: 'Keine Ausflugsziele vorhanden.';
container.appendChild(el('p', { className: 'list-empty', text: message }));
return; return;
} }
const list = el('ul', { className: 'place-list' }); const list = el('ul', { className: 'place-list' });
for (const place of places) { for (const place of filteredPlaces) {
list.appendChild(createCard(place)); list.appendChild(createCard(place, userLocation));
} }
container.appendChild(list); container.appendChild(list);
} }
@@ -66,11 +81,12 @@
return; return;
} }
render(container, getState().places); const renderFromState = (state) => {
render(container, getFilteredPlaces(), state.places.length, state.userLocation);
};
subscribe((state) => { renderFromState(getState());
render(container, state.places); subscribe(renderFromState);
});
} }
EventMap.ui.initList = initList; EventMap.ui.initList = initList;
+1
View File
@@ -15,6 +15,7 @@ window.EventMap.models = window.EventMap.models || {};
window.EventMap.store = window.EventMap.store || {}; window.EventMap.store = window.EventMap.store || {};
window.EventMap.map = window.EventMap.map || {}; window.EventMap.map = window.EventMap.map || {};
window.EventMap.ui = window.EventMap.ui || {}; window.EventMap.ui = window.EventMap.ui || {};
window.EventMap.filters = window.EventMap.filters || {};
(function () { (function () {
/** /**