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;
|
||||
}
|
||||
|
||||
.place-card__distance {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Karten-Marker (L.divIcon) */
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 65% 35%;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'map filter'
|
||||
'map list';
|
||||
height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
grid-area: map;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
grid-area: filter;
|
||||
border-left: 1px solid var(--color-border);
|
||||
max-height: 45vh;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
grid-area: list;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
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 |
|
||||
| 1 | Datenmodell, Karte, Liste (read-only, Seed-Daten) | ✅ umgesetzt |
|
||||
| 2 | Detailansicht (Titelbild, vollständige Beschreibung, Mini-Karte, Navigation-Button, Bearbeiten/Löschen) | offen |
|
||||
| 3 | Formular zum manuellen Anlegen/Bearbeiten (Validierung, Koordinatenprüfung) | offen |
|
||||
| 4 | Filter- und Suchfunktion (Kategorie, Entfernung, Alter, Kosten, Indoor/Outdoor, Ausstattung, Bewertung, Region) inkl. Anwendung auf Karte + Liste | offen |
|
||||
| 5 | Route/Navigation-Integration (z.B. Link zu externem Kartendienst) | offen |
|
||||
| 2 | Detailansicht (Titelbild, vollständige Beschreibung, Mini-Karte, Navigation-Button, Bearbeiten/Löschen) | ✅ umgesetzt |
|
||||
| 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 | ✅ umgesetzt |
|
||||
| 5 | Route/Navigation-Integration | ✅ bereits in Phase 2 erledigt (OSM-Directions-Button in der Detailansicht) |
|
||||
| 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-Modul, Detail-Modul, Formular-Modul, Prompt-Generator-Modul,
|
||||
Import-Modul.
|
||||
## Filter- und Suchfunktion (Phase 4)
|
||||
|
||||
- `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/detail.css" />
|
||||
<link rel="stylesheet" href="css/form.css" />
|
||||
<link rel="stylesheet" href="css/filters.css" />
|
||||
<link rel="stylesheet" href="css/responsive.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -40,6 +41,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="filter-panel" class="filter-panel" aria-label="Filter und Suche"></div>
|
||||
|
||||
<aside class="list-panel" aria-label="Ergebnisliste">
|
||||
<h2 class="list-panel__title">Ausflugsziele</h2>
|
||||
<div id="place-list" class="list-panel__content">
|
||||
@@ -61,6 +64,7 @@
|
||||
<script src="js/utils/dom.js"></script>
|
||||
<script src="js/utils/geo.js"></script>
|
||||
<script src="js/utils/id.js"></script>
|
||||
<script src="js/filters/filterEngine.js"></script>
|
||||
<script src="data/seed.js"></script>
|
||||
<script src="js/config/constants.js"></script>
|
||||
<script src="js/config/categories.js"></script>
|
||||
@@ -75,6 +79,7 @@
|
||||
<script src="js/ui/list.js"></script>
|
||||
<script src="js/ui/detail.js"></script>
|
||||
<script src="js/ui/form.js"></script>
|
||||
<script src="js/filters/filterPanel.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</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 () {
|
||||
const { initStore } = EventMap.store;
|
||||
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
||||
const { initList, initDetail, initForm } = EventMap.ui;
|
||||
const { initList, initDetail, initForm, initFilterPanel } = EventMap.ui;
|
||||
|
||||
function showFatalError(message) {
|
||||
const status = document.getElementById('app-status');
|
||||
@@ -51,6 +51,7 @@
|
||||
async function bootstrap() {
|
||||
await initStore();
|
||||
initMap('map');
|
||||
initFilterPanel('filter-panel');
|
||||
initList('place-list');
|
||||
initDetail('detail-modal');
|
||||
initForm('place-form-modal');
|
||||
|
||||
+20
-9
@@ -4,7 +4,7 @@
|
||||
|
||||
(function () {
|
||||
const { DEFAULT_MAP_CENTER } = EventMap.config;
|
||||
const { subscribe, getState, setState } = EventMap.store;
|
||||
const { subscribe, setState, getFilteredPlaces } = EventMap.store;
|
||||
const { getPlaceLatLng } = EventMap.models;
|
||||
const { createMarkerForPlace } = EventMap.map;
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
let clusterGroup = null;
|
||||
const markersByPlaceId = new Map();
|
||||
|
||||
let lastPlacesRef = null;
|
||||
let lastFilteredKey = 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) {
|
||||
const { lat, lng } = getPlaceLatLng(place);
|
||||
return typeof lat === 'number' && typeof lng === 'number' && !Number.isNaN(lat) && !Number.isNaN(lng);
|
||||
@@ -101,15 +106,21 @@
|
||||
clusterGroup = L.markerClusterGroup();
|
||||
map.addLayer(clusterGroup);
|
||||
|
||||
const initialState = getState();
|
||||
renderMarkers(initialState.places);
|
||||
fitToPlaces(initialState.places);
|
||||
lastPlacesRef = initialState.places;
|
||||
const initialFiltered = getFilteredPlaces();
|
||||
renderMarkers(initialFiltered);
|
||||
fitToPlaces(initialFiltered);
|
||||
lastFilteredKey = computeFilteredKey(initialFiltered);
|
||||
|
||||
subscribe((state) => {
|
||||
if (state.places !== lastPlacesRef) {
|
||||
lastPlacesRef = state.places;
|
||||
renderMarkers(state.places);
|
||||
// Filter/Standort können sich ändern, ohne dass sich state.places
|
||||
// ändert - daher bei jeder Store-Änderung neu filtern und nur bei
|
||||
// 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) {
|
||||
lastSelectedPlaceId = state.selectedPlaceId;
|
||||
|
||||
@@ -78,6 +78,26 @@
|
||||
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.getState = getState;
|
||||
EventMap.store.setState = setState;
|
||||
@@ -85,4 +105,6 @@
|
||||
EventMap.store.updateSettings = updateSettings;
|
||||
EventMap.store.initStore = initStore;
|
||||
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
|
||||
// in einer späteren Phase - aktuell werden alle Places angezeigt.
|
||||
// Ergebnisliste (Cards), rendert die gefilterte Trefferliste
|
||||
// (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
|
||||
// innerHTML mit Place-Daten.
|
||||
|
||||
(function () {
|
||||
const { subscribe, getState, setState } = EventMap.store;
|
||||
const { subscribe, getState, setState, getFilteredPlaces } = EventMap.store;
|
||||
const { getCategoryById } = EventMap.config;
|
||||
const { getDistanceKm } = EventMap.filters;
|
||||
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 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(
|
||||
'li',
|
||||
{
|
||||
@@ -29,31 +50,25 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
[
|
||||
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 }),
|
||||
]
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
function render(container, places) {
|
||||
function render(container, filteredPlaces, totalPlacesCount, userLocation) {
|
||||
clearChildren(container);
|
||||
|
||||
if (!places || places.length === 0) {
|
||||
container.appendChild(
|
||||
el('p', { className: 'list-empty', text: 'Keine Ausflugsziele vorhanden.' })
|
||||
);
|
||||
if (!filteredPlaces || filteredPlaces.length === 0) {
|
||||
const message =
|
||||
totalPlacesCount > 0
|
||||
? 'Keine Ausflugsziele gefunden. Filter anpassen?'
|
||||
: 'Keine Ausflugsziele vorhanden.';
|
||||
container.appendChild(el('p', { className: 'list-empty', text: message }));
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('ul', { className: 'place-list' });
|
||||
for (const place of places) {
|
||||
list.appendChild(createCard(place));
|
||||
for (const place of filteredPlaces) {
|
||||
list.appendChild(createCard(place, userLocation));
|
||||
}
|
||||
container.appendChild(list);
|
||||
}
|
||||
@@ -66,11 +81,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
render(container, getState().places);
|
||||
const renderFromState = (state) => {
|
||||
render(container, getFilteredPlaces(), state.places.length, state.userLocation);
|
||||
};
|
||||
|
||||
subscribe((state) => {
|
||||
render(container, state.places);
|
||||
});
|
||||
renderFromState(getState());
|
||||
subscribe(renderFromState);
|
||||
}
|
||||
|
||||
EventMap.ui.initList = initList;
|
||||
|
||||
@@ -15,6 +15,7 @@ window.EventMap.models = window.EventMap.models || {};
|
||||
window.EventMap.store = window.EventMap.store || {};
|
||||
window.EventMap.map = window.EventMap.map || {};
|
||||
window.EventMap.ui = window.EventMap.ui || {};
|
||||
window.EventMap.filters = window.EventMap.filters || {};
|
||||
|
||||
(function () {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user