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:
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 () {
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user