Phase 0+1: Scaffold, Datenmodell, Karte und Liste
Reines HTML5/CSS3/JavaScript ohne Build-Tooling, lauffaehig direkt per Doppelklick auf index.html (file://, kein Server noetig): - Datenmodell fuer Ausflugsziele mit allen Feldern aus der Anforderung, 15 feste Kategorien inkl. Farbe/Icon - Zentraler Pub/Sub-Store mit LocalStorage-Persistenz und Migrations-Grundgeruest - Interaktive Karte (Leaflet + Leaflet.markercluster, lokal vendored) mit kategoriefarbenen Markern, Clustering, Popups, Geolocation-Button - Kartengrundlage ueber CARTO-Basemaps statt tile.openstreetmap.org, da dessen Referer-Pflicht file://-Aufrufe blockiert - Seed-Daten als eingebettetes Script (data/seed.js) statt JSON-Datei, da fetch() auf lokale Dateien unter file:// nicht zuverlaessig funktioniert - Ergebnisliste mit 10 Beispiel-Ausflugszielen, sicher gerendert ueber textContent/DOM-APIs statt innerHTML (Vorbereitung fuer spaeteren KI-JSON-Import mit Fremddaten) - Responsives Layout (Desktop nebeneinander, Mobile gestapelt)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/* Wiederverwendbare Komponenten: Status-Banner, Buttons, Ergebnis-Cards,
|
||||
Karten-Marker/Popups. */
|
||||
|
||||
.app-status {
|
||||
background: #fdecea;
|
||||
color: var(--color-danger);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 1px solid var(--color-danger);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.map-panel__loading {
|
||||
padding: var(--space-md);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Ergebnisliste */
|
||||
|
||||
.place-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.list-empty {
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.place-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.place-card:hover,
|
||||
.place-card:focus-visible {
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.place-card__name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.place-card__category {
|
||||
align-self: flex-start;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 2px var(--space-sm);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.place-card__desc {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Karten-Marker (L.divIcon) */
|
||||
|
||||
.eventmap-marker {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.eventmap-marker__glyph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-card);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
/* Karten-Popup */
|
||||
|
||||
.eventmap-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.eventmap-popup__title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.eventmap-popup__category {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eventmap-popup__desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Grundlegendes App-Layout: Header + Karte/Liste. Mobile-first;
|
||||
Desktop-Anpassungen (>=1024px) siehe css/responsive.css. */
|
||||
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 var(--space-lg);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-header__subtitle {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
position: relative;
|
||||
height: 50vh;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-panel__toolbar {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
left: var(--space-sm);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.map-panel__map {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
padding: var(--space-md);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-panel__title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Schlankes Reset - kein komplettes Normalize.css nötig für dieses Projekt. */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* Desktop-Layout ab 1024px: Karte und Liste nebeneinander (Grid). */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 65% 35%;
|
||||
height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Design-Tokens der Anwendung. */
|
||||
|
||||
:root {
|
||||
/* Basisfarben */
|
||||
--color-primary: #2e7d32;
|
||||
--color-primary-dark: #1b5e20;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-muted: #5f6368;
|
||||
--color-background: #f5f6f8;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e0e0e0;
|
||||
--color-danger: #c62828;
|
||||
|
||||
/* Kategorie-Farben (siehe js/config/categories.js) */
|
||||
--category-naturpark: #2e7d32;
|
||||
--category-wald-wanderweg: #1b5e20;
|
||||
--category-see-badestelle: #0288d1;
|
||||
--category-erlebnispark: #f57c00;
|
||||
--category-freizeitpark: #e64a19;
|
||||
--category-tierpark-zoo: #ff8f00;
|
||||
--category-spielplatz: #d81b60;
|
||||
--category-museum-kinder: #7b1fa2;
|
||||
--category-indoor-spielplatz: #5e35b1;
|
||||
--category-bauernhof: #6d4c41;
|
||||
--category-kletterpark: #00897b;
|
||||
--category-aussichtspunkt: #455a64;
|
||||
--category-cafe-restaurant: #c62828;
|
||||
--category-sonstige-aktivitaet: #00acc1;
|
||||
--category-sonstige: #616161;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
|
||||
/* Radius/Shadow */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Layout */
|
||||
--breakpoint-desktop: 1024px;
|
||||
--header-height: 64px;
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
// Beispiel-Ausflugsziele fuer den allerersten App-Start (leerer LocalStorage).
|
||||
// Als eingebettetes Script statt JSON-Datei, da fetch() lokaler Dateien unter
|
||||
// file:// (insbesondere in Chrome) von der Same-Origin-Policy blockiert wird.
|
||||
window.EventMap = window.EventMap || {};
|
||||
EventMap.data = EventMap.data || {};
|
||||
|
||||
EventMap.data.SEED_PLACES = [
|
||||
{
|
||||
"name": "Tierpark Berlin",
|
||||
"category": "Tierpark/Zoo",
|
||||
"shortDescription": "Weitläufiger Tierpark im Grünen mit über 7.000 Tieren.",
|
||||
"description": "Der Tierpark Berlin ist einer der größten Landschaftstierparks Europas. Auf großzügigen Freianlagen können Familien zahlreiche Tierarten beobachten, darunter Elefanten, Löwen und Pandas. Weitläufige Wege eignen sich gut für Kinderwagen, mehrere Spielplätze laden zwischendurch zum Toben ein.",
|
||||
"latitude": 52.5063,
|
||||
"longitude": 13.4996,
|
||||
"address": "Am Tierpark 125",
|
||||
"city": "Berlin",
|
||||
"region": "Berlin",
|
||||
"ageRecommendation": { "min": 0, "max": 14 },
|
||||
"cost": { "type": "paid", "description": "Erwachsene ca. 16€, Kinder ca. 8€" },
|
||||
"openingHours": "Täglich ab 9:00 Uhr, saisonal wechselnde Schließzeiten",
|
||||
"durationMinutes": 240,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": true,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "https://www.tierpark-berlin.de",
|
||||
"image": "",
|
||||
"rating": 4.6,
|
||||
"tags": ["Tiere", "familienfreundlich", "Kinderwagen geeignet"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": true
|
||||
},
|
||||
{
|
||||
"name": "Spielplatz Westpark München",
|
||||
"category": "Spielplatz",
|
||||
"shortDescription": "Großer Spielplatz mit Klettergerüsten, Rutschen und Wasserspielbereich.",
|
||||
"description": "Im Westpark München gibt es mehrere gut ausgestattete Spielplätze für unterschiedliche Altersgruppen, inklusive Wasserspielbereich im Sommer, Sandkästen und Klettergerüsten. Der Park bietet zudem viel Platz für Picknicks und lange Spaziergänge.",
|
||||
"latitude": 48.1231,
|
||||
"longitude": 11.5310,
|
||||
"address": "Westendstraße 224",
|
||||
"city": "München",
|
||||
"region": "Bayern",
|
||||
"ageRecommendation": { "min": 1, "max": 10 },
|
||||
"cost": { "type": "free", "description": "Kostenlos" },
|
||||
"openingHours": "Durchgehend geöffnet",
|
||||
"durationMinutes": 90,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": true,
|
||||
"toilets": true,
|
||||
"parking": false,
|
||||
"restaurant": false
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": 4.3,
|
||||
"tags": ["kostenlos", "Spielplatz", "Wasser"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": true
|
||||
},
|
||||
{
|
||||
"name": "Kletterwald Schluchsee",
|
||||
"category": "Kletterpark",
|
||||
"shortDescription": "Hochseilgarten mit Parcours für Kinder und Erwachsene im Schwarzwald.",
|
||||
"description": "Der Kletterwald Schluchsee bietet verschiedene Parcours mit unterschiedlichen Schwierigkeitsgraden, darunter spezielle Kinderparcours ab 4 Jahren. Umgeben von dichtem Nadelwald direkt am Schluchsee gelegen.",
|
||||
"latitude": 47.8206,
|
||||
"longitude": 8.1653,
|
||||
"address": "Kaiserhaustraße 1",
|
||||
"city": "Schluchsee",
|
||||
"region": "Baden-Württemberg",
|
||||
"ageRecommendation": { "min": 4, "max": 99 },
|
||||
"cost": { "type": "paid", "description": "Ab ca. 14€ pro Person, je nach Parcours" },
|
||||
"openingHours": "April bis Oktober, 9:30 bis 18:00 Uhr (witterungsabhängig)",
|
||||
"durationMinutes": 180,
|
||||
"facilities": {
|
||||
"strollerAccessible": false,
|
||||
"wheelchairAccessible": false,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "https://www.kletterwald-schluchsee.de",
|
||||
"image": "",
|
||||
"rating": 4.7,
|
||||
"tags": ["Klettern", "Wald", "Tagesausflug"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": true
|
||||
},
|
||||
{
|
||||
"name": "Europa-Park Rust",
|
||||
"category": "Freizeitpark",
|
||||
"shortDescription": "Großer Freizeitpark mit Themenbereichen, Achterbahnen und Kinderfahrgeschäften.",
|
||||
"description": "Der Europa-Park ist einer der größten Freizeitparks Europas mit europäisch thematisierten Bereichen. Neben actionreichen Achterbahnen gibt es zahlreiche ruhigere Fahrgeschäfte und einen eigenen Kinderbereich für die jüngsten Besucher.",
|
||||
"latitude": 48.2669,
|
||||
"longitude": 7.7220,
|
||||
"address": "Europa-Park-Straße 2",
|
||||
"city": "Rust",
|
||||
"region": "Baden-Württemberg",
|
||||
"ageRecommendation": { "min": 2, "max": 99 },
|
||||
"cost": { "type": "paid", "description": "Tagesticket ab ca. 60€" },
|
||||
"openingHours": "Saisonal geöffnet, i.d.R. 9:00 bis 18:00 Uhr",
|
||||
"durationMinutes": 480,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": true,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "mixed",
|
||||
"website": "https://www.europapark.de",
|
||||
"image": "",
|
||||
"rating": 4.8,
|
||||
"tags": ["Freizeitpark", "Tagesausflug", "Achterbahn"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": true
|
||||
},
|
||||
{
|
||||
"name": "Naturpark Schönbuch",
|
||||
"category": "Naturpark",
|
||||
"shortDescription": "Ausgedehntes Waldgebiet mit markierten Wanderwegen und Wildgehegen.",
|
||||
"description": "Der Naturpark Schönbuch bei Tübingen bietet zahlreiche familienfreundliche Wanderwege, Waldlehrpfade und ein Wildgehege mit heimischen Tierarten. Ideal für einen entspannten Tag in der Natur.",
|
||||
"latitude": 48.5667,
|
||||
"longitude": 9.0667,
|
||||
"address": "Schönbuchturm",
|
||||
"city": "Herrenberg",
|
||||
"region": "Baden-Württemberg",
|
||||
"ageRecommendation": { "min": 0, "max": 99 },
|
||||
"cost": { "type": "free", "description": "Kostenlos" },
|
||||
"openingHours": "Durchgehend geöffnet",
|
||||
"durationMinutes": 150,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": false,
|
||||
"toilets": false,
|
||||
"parking": true,
|
||||
"restaurant": false
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": 4.5,
|
||||
"tags": ["Natur", "Wandern", "kostenlos", "Tiere"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": false
|
||||
},
|
||||
{
|
||||
"name": "Partnachklamm Wanderweg",
|
||||
"category": "Wald/Wanderweg",
|
||||
"shortDescription": "Spektakuläre Felsschlucht bei Garmisch-Partenkirchen, gut begehbarer Weg.",
|
||||
"description": "Der Weg durch die Partnachklamm führt über gut gesicherte Wege und teilweise durch Tunnel entlang der tosenden Partnach. Für Kinder ab dem Schulalter ein beeindruckendes Naturerlebnis, festes Schuhwerk empfohlen.",
|
||||
"latitude": 47.4931,
|
||||
"longitude": 11.0817,
|
||||
"address": "Am Kurpark",
|
||||
"city": "Garmisch-Partenkirchen",
|
||||
"region": "Bayern",
|
||||
"ageRecommendation": { "min": 6, "max": 99 },
|
||||
"cost": { "type": "paid", "description": "Eintritt ca. 6€, Kinder ermäßigt" },
|
||||
"openingHours": "Ganzjährig, witterungsabhängig gesperrt",
|
||||
"durationMinutes": 90,
|
||||
"facilities": {
|
||||
"strollerAccessible": false,
|
||||
"wheelchairAccessible": false,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": 4.7,
|
||||
"tags": ["Wandern", "Natur", "Schlucht"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": false
|
||||
},
|
||||
{
|
||||
"name": "Strandbad Herrsching am Ammersee",
|
||||
"category": "See/Badestelle",
|
||||
"shortDescription": "Familienfreundliches Strandbad mit flachem Uferbereich am Ammersee.",
|
||||
"description": "Das Strandbad Herrsching bietet einen flach abfallenden Badebereich, eine große Liegewiese und einen Spielplatz direkt am See. Ideal für einen entspannten Tag am Wasser mit kleinen Kindern.",
|
||||
"latitude": 48.0000,
|
||||
"longitude": 11.1667,
|
||||
"address": "Seepromenade",
|
||||
"city": "Herrsching am Ammersee",
|
||||
"region": "Bayern",
|
||||
"ageRecommendation": { "min": 0, "max": 99 },
|
||||
"cost": { "type": "free", "description": "Kostenlos, Liegestuhlverleih gegen Gebühr" },
|
||||
"openingHours": "Durchgehend zugänglich",
|
||||
"durationMinutes": 180,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": true,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": 4.4,
|
||||
"tags": ["Baden", "See", "kostenlos"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": false
|
||||
},
|
||||
{
|
||||
"name": "Junges Schloss Stuttgart",
|
||||
"category": "Museum für Kinder",
|
||||
"shortDescription": "Kindermuseum im Alten Schloss mit interaktiven Mitmachstationen.",
|
||||
"description": "Das Junge Schloss ist das Landesmuseum für Kinder in Stuttgart und vermittelt Geschichte spielerisch über zahlreiche Mitmach-Stationen, Verkleidungen und Rätsel. Besonders für Grundschulkinder geeignet.",
|
||||
"latitude": 48.7791,
|
||||
"longitude": 9.1804,
|
||||
"address": "Schillerplatz 6",
|
||||
"city": "Stuttgart",
|
||||
"region": "Baden-Württemberg",
|
||||
"ageRecommendation": { "min": 5, "max": 12 },
|
||||
"cost": { "type": "paid", "description": "Eintritt ca. 4€, Kinder unter 6 Jahren frei" },
|
||||
"openingHours": "Di–So 10:00–17:00 Uhr, montags geschlossen",
|
||||
"durationMinutes": 120,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": true,
|
||||
"toilets": true,
|
||||
"parking": false,
|
||||
"restaurant": false
|
||||
},
|
||||
"environment": "indoor",
|
||||
"website": "https://www.jungesschloss.de",
|
||||
"image": "",
|
||||
"rating": 4.5,
|
||||
"tags": ["Museum", "Geschichte", "Indoor"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": true
|
||||
},
|
||||
{
|
||||
"name": "Jump House Hamburg",
|
||||
"category": "Indoor-Spielplatz",
|
||||
"shortDescription": "Große Trampolinhalle mit Bereichen für kleine und große Kinder.",
|
||||
"description": "Im Jump House Hamburg können Kinder auf zahlreichen vernetzten Trampolinen springen und toben. Ein separater Bereich für Kleinkinder sorgt für sicheren Spaß auch bei den Jüngsten - ideal bei schlechtem Wetter.",
|
||||
"latitude": 53.5511,
|
||||
"longitude": 9.9937,
|
||||
"address": "Amsinckstraße 57",
|
||||
"city": "Hamburg",
|
||||
"region": "Hamburg",
|
||||
"ageRecommendation": { "min": 2, "max": 16 },
|
||||
"cost": { "type": "paid", "description": "Ab ca. 10€ pro Stunde" },
|
||||
"openingHours": "Täglich 10:00–20:00 Uhr",
|
||||
"durationMinutes": 90,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": true,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "indoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": 4.2,
|
||||
"tags": ["Indoor", "bei schlechtem Wetter geeignet", "Trampolin"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": false
|
||||
},
|
||||
{
|
||||
"name": "Erlebnisbauernhof Gut Sonnenhof",
|
||||
"category": "Bauernhof",
|
||||
"shortDescription": "Bauernhof zum Anfassen mit Streichelzoo und Traktorfahrten.",
|
||||
"description": "Auf dem Erlebnisbauernhof Gut Sonnenhof können Kinder Tiere füttern und streicheln, an Traktorfahrten teilnehmen und den Alltag auf einem Bauernhof hautnah erleben. Ein Café mit hofeigenen Produkten lädt zur Rast ein.",
|
||||
"latitude": 52.5000,
|
||||
"longitude": 9.5000,
|
||||
"address": "Hofstraße 3",
|
||||
"city": "Neustadt am Rübenberge",
|
||||
"region": "Niedersachsen",
|
||||
"ageRecommendation": { "min": 1, "max": 10 },
|
||||
"cost": { "type": "paid", "description": "Eintritt ca. 5€, Kinder unter 2 Jahren frei" },
|
||||
"openingHours": "Sa–So und Feiertage 10:00–17:00 Uhr",
|
||||
"durationMinutes": 150,
|
||||
"facilities": {
|
||||
"strollerAccessible": true,
|
||||
"wheelchairAccessible": false,
|
||||
"toilets": true,
|
||||
"parking": true,
|
||||
"restaurant": true
|
||||
},
|
||||
"environment": "outdoor",
|
||||
"website": "",
|
||||
"image": "",
|
||||
"rating": 4.4,
|
||||
"tags": ["Tiere", "Bauernhof", "mit Tieren"],
|
||||
"source": "Seed-Daten",
|
||||
"sourceVerified": false
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
# ADR 0001: Kein Build-Tooling, klassische Scripts mit globalem Namespace, Leaflet lokal vendored
|
||||
|
||||
## Status
|
||||
|
||||
Akzeptiert (überarbeitet – siehe "Revision" unten; ursprüngliche
|
||||
Entscheidung für native ES-Module wurde revidiert)
|
||||
|
||||
## Kontext
|
||||
|
||||
`anforderung.md` fordert explizit "möglichst einfache und gut verständliche
|
||||
Technologien" (HTML5, CSS3, JavaScript, eine Open-Source-Kartenbibliothek,
|
||||
LocalStorage) und stellt keinen Backend-Zwang für die erste Version. Die
|
||||
Anwendung soll außerdem vollständig responsiv sein und ohne größere
|
||||
Infrastruktur betrieben und gewartet werden können.
|
||||
|
||||
Die tatsächliche Zielumgebung des Nutzers wurde präzisiert: Die App wird
|
||||
per **Doppelklick auf `index.html`** direkt aus einem lokal synchronisierten
|
||||
Nextcloud-Ordner geöffnet (`file://...`). Es steht **kein lokaler
|
||||
HTTP-Server** zur Verfügung und ein solcher soll vom Endnutzer auch nicht
|
||||
manuell gestartet werden müssen – Priorität hat maximale Einfachheit für den
|
||||
Endnutzer, nicht Entwicklerkomfort.
|
||||
|
||||
## Revision (2026-07-24)
|
||||
|
||||
Die ursprüngliche Entscheidung, JavaScript als native ES-Module
|
||||
(`<script type="module">` mit `import`/`export`) umzusetzen, war für diesen
|
||||
Anwendungsfall **falsch**: Browser blockieren das Nachladen von ES-Modulen
|
||||
über `file://` per CORS-Policy (`Access to script ... has been blocked by
|
||||
CORS policy`). Die App ließ sich damit nur über einen lokalen HTTP-Server
|
||||
starten – was der Anforderung "Doppelklick, kein Server" widerspricht.
|
||||
|
||||
**Neue Entscheidung:** Alle JavaScript-Dateien wurden von ES-Modulen auf
|
||||
**klassische, nicht-modulare `<script>`-Tags** umgebaut. Statt
|
||||
`import`/`export` hängt jede Datei ihre öffentliche API an ein einziges
|
||||
globales Namespace-Objekt `window.EventMap` (mit Sub-Namespaces `utils`,
|
||||
`config`, `models`, `store`, `map`, `ui`) an. Jede Datei kapselt ihre
|
||||
internen Hilfsvariablen weiterhin in einer IIFE
|
||||
(`(function () { ... })();`), sodass nur das explizit an `EventMap.*`
|
||||
angehängte gemeinsam genutzt wird. Die Ladereihenfolge in `index.html`
|
||||
(Utils → Config → Models → Store → Map → UI → `main.js`) stellt sicher,
|
||||
dass jede Datei beim Ausführen bereits auf die Namespaces ihrer
|
||||
Abhängigkeiten zugreifen kann.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
- Es wird **kein Build-Tooling** (kein Bundler wie Webpack/Vite, kein
|
||||
Transpiler, kein Package-Manager-Setup für die App selbst) eingesetzt.
|
||||
- JavaScript wird als **klassische Scripts** (`<script src="...">`, ohne
|
||||
`type="module"`) eingebunden, organisiert über einen gemeinsamen globalen
|
||||
Namespace `window.EventMap` statt über `import`/`export`. Jede Datei ist
|
||||
in eine IIFE gekapselt.
|
||||
- **Leaflet** und **Leaflet.markercluster** werden **lokal vendored** unter
|
||||
`vendor/leaflet/` bzw. `vendor/leaflet.markercluster/` (JS, CSS und
|
||||
Marker-Bildassets), nicht per CDN eingebunden.
|
||||
|
||||
## Begründung
|
||||
|
||||
- Entspricht direkt der Anforderung nach einfachen, gut verständlichen
|
||||
Technologien ohne zusätzliche Lernkurve (kein Framework, kein Build-Schritt).
|
||||
- Ermöglicht das Öffnen der App per Doppelklick aus dem synchronisierten
|
||||
Ordner heraus, ohne dass der Endnutzer einen Server starten oder
|
||||
irgendetwas installieren muss – das ist für die Zielgruppe wichtiger als
|
||||
natives Modul-Tooling.
|
||||
- Lokal vendorte Bibliotheken machen die App **offline-fähig** und
|
||||
unabhängig von der Verfügbarkeit externer CDNs; das passt zum
|
||||
Offline-First-Ansatz der LocalStorage-Datenhaltung.
|
||||
- Geringere Angriffsfläche/Komplexität: keine Abhängigkeit von Node-Toolchain-
|
||||
Versionen, keine Build-Artefakte, die aktuell gehalten werden müssten.
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
- **Trade-off gegenüber ES-Modulen:** Es gibt kein natives Modul-Scoping
|
||||
mehr. Stattdessen dient der globale `window.EventMap`-Namespace plus
|
||||
IIFE-Kapselung pro Datei als Ersatz. Das erfordert Disziplin beim
|
||||
Vergeben von Namen unter `EventMap.*` (keine versehentlichen
|
||||
Überschreibungen) und macht Abhängigkeiten weniger explizit als
|
||||
`import`-Statements – sie ergeben sich aus der Script-Reihenfolge in
|
||||
`index.html`, die entsprechend sorgfältig gepflegt werden muss.
|
||||
- **Behobenes Restrisiko:** Ursprünglich lud `js/store/store.js` die
|
||||
Beispieldaten per `fetch('data/seed.json')`, was unter `file://`
|
||||
(insbesondere in Chrome/Chromium) von der Same-Origin-Policy blockiert
|
||||
wird. Die Seed-Daten wurden daher von `data/seed.json` nach
|
||||
`data/seed.js` verschoben und liegen dort als eingebettetes
|
||||
`<script>`-Objekt (`EventMap.data.SEED_PLACES`), das wie jede andere
|
||||
JS-Datei klassisch eingebunden wird. `initStore()` ist dadurch wieder
|
||||
synchron; `fetch()` kommt aktuell an keiner Stelle der Anwendung mehr
|
||||
vor. Für spätere Phasen (z.B. KI-JSON-Import) ist zu beachten: Datei-Uploads
|
||||
über `<input type="file">` + `FileReader` sind unproblematisch unter
|
||||
`file://`, ein `fetch()` auf lokale Dateien dagegen nicht.
|
||||
- Es gibt keinen automatischen Minify-/Tree-Shaking-Schritt; die
|
||||
Dateigröße im Produktivbetrieb ist etwas größer als mit Bundler, was für
|
||||
den Umfang dieser Anwendung akzeptabel ist.
|
||||
- Updates der vendorten Bibliotheken müssen manuell durchgeführt werden
|
||||
(Dateien in `vendor/` austauschen), es gibt keinen automatisierten
|
||||
Dependency-Update-Mechanismus (z.B. Dependabot) für diese Assets.
|
||||
- Sollte die Anwendung später deutlich wachsen (z.B. TypeScript, größere
|
||||
Testinfrastruktur, Bereitstellung über einen echten Webserver), kann
|
||||
diese Entscheidung revidiert und zurück auf ES-Module umgestellt werden.
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# EventMap – Gesamtplan
|
||||
|
||||
Dieses Dokument fasst den vom architect-Agenten erarbeiteten Plan zusammen
|
||||
und wird phasenweise fortgeschrieben. Aktuell umgesetzt: **Phase 0 (Scaffold)**
|
||||
und **Phase 1 (Datenmodell + Karte + Liste, read-only mit Beispieldaten)**.
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
- Vanilla HTML5/CSS3/JavaScript, klassische Scripts (kein `type="module"`);
|
||||
Module-Ersatz über einen globalen Namespace `window.EventMap` +
|
||||
IIFE-Kapselung pro Datei, siehe `docs/adr/0001-vanilla-esm.md`. Grund:
|
||||
die App wird per Doppelklick direkt aus `file://` geöffnet, native
|
||||
ES-Module würden dort per CORS-Policy blockiert.
|
||||
- Kein Build-Tool, kein Bundler, kein Framework
|
||||
- Karte: [Leaflet](https://leafletjs.com/) 1.9.4 + [Leaflet.markercluster](https://github.com/Leaflet/Leaflet.markercluster) 1.5.3, lokal vendored unter `vendor/`
|
||||
- Kartengrundlage: OpenStreetMap-Daten, ausgeliefert über CARTO-Basemap-Tiles
|
||||
(`basemaps.cartocdn.com`, Stil "light_all") statt des offiziellen
|
||||
`tile.openstreetmap.org`. Grund: dessen Tile Usage Policy verlangt seit
|
||||
2024 einen gültigen HTTP-Referer, den `file://`-Seiten (Doppelklick-Start)
|
||||
grundsätzlich nicht mitsenden können ("Access blocked: Referer is
|
||||
required"). CARTO liefert dieselben OSM-Daten kartographisch auf, ohne
|
||||
diese Einschränkung; Attribution zeigt weiterhin OpenStreetMap + CARTO.
|
||||
- Datenhaltung: LocalStorage (Offline-first), vorbereitet für spätere Migration auf Backend/API
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```
|
||||
index.html
|
||||
css/ reset, variables (Design-Tokens), layout, components, responsive
|
||||
js/config/ constants.js, categories.js
|
||||
js/store/ store.js (Pub/Sub-State), storage.js (LocalStorage-IO), migrations.js
|
||||
js/models/ place.js (Normalisierung), schema.js (Feldstruktur/Defaults)
|
||||
js/map/ mapController.js, markers.js, geolocation.js
|
||||
js/ui/ list.js (Ergebnisliste; weitere UI-Module folgen in späteren Phasen)
|
||||
js/utils/ dom.js, geo.js, id.js
|
||||
data/seed.js Beispieldaten (10 Ausflugsziele, wird nur beim allerersten Start geladen)
|
||||
vendor/ lokal vendorte Leaflet-Bibliotheken (JS/CSS/Bildassets)
|
||||
docs/ dieser Plan, ADRs
|
||||
```
|
||||
|
||||
## Datenmodell (Ausflugsziel / Place)
|
||||
|
||||
Das interne Place-Objekt orientiert sich eng am verbindlichen KI-Import-JSON-Format
|
||||
aus `anforderung.md` (siehe dortiger Abschnitt "Vorgabe für das KI-JSON"):
|
||||
|
||||
- Basisfelder: `name`, `category`, `shortDescription`, `description`, `latitude`,
|
||||
`longitude`, `address`, `city`, `region`
|
||||
- `ageRecommendation: { min, max }`
|
||||
- `cost: { type: 'free'|'paid'|'mixed'|'unknown', description }`
|
||||
- `openingHours`, `durationMinutes`
|
||||
- `facilities: { strollerAccessible, wheelchairAccessible, toilets, parking, restaurant }`
|
||||
- `environment: 'indoor'|'outdoor'|'mixed'`
|
||||
- `website`, `image`, `rating`, `tags[]`, `source`, `sourceVerified`
|
||||
- Intern generiert: `id` (UUID), `createdAt`, `updatedAt` (ISO-Strings)
|
||||
|
||||
`js/models/place.js` normalisiert rohe Objekte (aus `data/seed.js` oder späteren
|
||||
Importen) zu diesem vollständigen internen Format; die Kategorie wird dabei
|
||||
immer auf eine gültige Kategorie-`id` (Slug, siehe `js/config/categories.js`)
|
||||
aufgelöst – entweder direkt (falls schon ein Slug vorliegt) oder über das
|
||||
Label (z.B. `"Spielplatz"` → `spielplatz`), wie es die KI liefern würde.
|
||||
|
||||
### Kategorien
|
||||
|
||||
14 Kategorien aus der Anforderung + "Sonstige" als generischer Fallback
|
||||
(insgesamt 15), jeweils mit `id` (Slug), `label`, `color` (Hex) und `icon`
|
||||
(Emoji-Glyph) – siehe `js/config/categories.js`.
|
||||
|
||||
## Store-Architektur
|
||||
|
||||
`js/store/store.js` ist ein zentraler Pub/Sub-Store:
|
||||
|
||||
```js
|
||||
state = { places, filters: null, userLocation: null, selectedPlaceId: null }
|
||||
```
|
||||
|
||||
- `subscribe(fn)` registriert Listener, `setState(patch)` merged den State und
|
||||
benachrichtigt alle Subscriber.
|
||||
- `initStore()` lädt beim Start persistierte Daten aus LocalStorage
|
||||
(`js/store/storage.js`). Ist noch nichts gespeichert, wird `data/seed.js`
|
||||
geladen, normalisiert und einmalig persistiert.
|
||||
- Einstellungen (`lastFilters`, `lastMapView`, `userConsentGeo`, `defaultCenter`)
|
||||
werden getrennt über `getSettings()`/`updateSettings()` verwaltet und im
|
||||
Key `eventmap.settings.v1` persistiert.
|
||||
|
||||
### LocalStorage-Schema
|
||||
|
||||
- `eventmap.data.v1` → `{ schemaVersion: 1, places: [...], updatedAt }`
|
||||
- `eventmap.settings.v1` → `{ lastFilters, lastMapView, userConsentGeo, defaultCenter }`
|
||||
|
||||
`js/store/migrations.js` stellt eine schlanke, sequenziell anwendbare
|
||||
Migrations-Infrastruktur bereit (`migrate(data)`). Aktuell existiert nur
|
||||
Schema-Version 1, das Migrationsarray ist entsprechend leer.
|
||||
|
||||
`defaultCenter` (frei wählbares Fallback-Kartenzentrum) ist als Datenfeld
|
||||
vorbereitet, aber in Phase 1 noch nicht über die UI einstellbar. Solange kein
|
||||
`defaultCenter` gesetzt ist, verwendet die Karte den hartcodierten Default aus
|
||||
`js/config/constants.js` (geografische Mitte Deutschlands, lat 51.1657,
|
||||
lng 10.4515, zoom 6).
|
||||
|
||||
## Karte
|
||||
|
||||
- `js/map/mapController.js`: initialisiert Leaflet-Karte + OSM-Tile-Layer
|
||||
(inkl. korrekter Attribution), verwaltet ein `L.markerClusterGroup`,
|
||||
reagiert per `subscribe()` auf Änderungen an `state.places` (Marker neu
|
||||
rendern) und `state.selectedPlaceId` (Karte zentrieren + Popup öffnen).
|
||||
`fitToPlaces()` passt die Ansicht auf alle vorhandenen Places an, mit
|
||||
Fallback auf das Default-Center falls keine Places mit gültigen
|
||||
Koordinaten vorhanden sind.
|
||||
- `js/map/markers.js`: erzeugt je Kategorie ein `L.divIcon` (Farbe + Emoji,
|
||||
kein PNG-Sprite nötig) und den Popup-Inhalt (Name, Kategorie,
|
||||
Kurzbeschreibung) als DOM-Node (nicht als HTML-String, siehe
|
||||
Sicherheitshinweis unten).
|
||||
- `js/map/geolocation.js`: fragt den Standort nur auf explizite
|
||||
Nutzeraktion (Button "Meinen Standort verwenden") ab, setzt bei Erfolg
|
||||
`userLocation` im Store und zentriert die Karte; bei Ablehnung/Fehler
|
||||
bleibt das Default-Center erhalten und ein Hinweis wird angezeigt.
|
||||
|
||||
## Ergebnisliste
|
||||
|
||||
`js/ui/list.js` rendert `state.places` als Cards (Name, Kategorie mit
|
||||
Farbe/Icon, Kurzbeschreibung). Ein Klick auf eine Card setzt
|
||||
`selectedPlaceId` im Store; `mapController.js` reagiert darauf und
|
||||
zentriert die Karte auf den zugehörigen Marker. Filterung (Phase 4) ist
|
||||
hier noch nicht implementiert – aktuell werden immer alle Places gezeigt.
|
||||
|
||||
**Sicherheitshinweis:** Da künftig Fremddaten per KI-Import in die App
|
||||
gelangen, wird an keiner Stelle `innerHTML` mit Place-Daten befüllt.
|
||||
`js/utils/dom.js` stellt dafür `el()` (DOM-Erzeugung über `textContent`/
|
||||
Properties) sowie `escapeHtml()` als zusätzliches Sicherheitsnetz bereit.
|
||||
|
||||
## Layout
|
||||
|
||||
- Header mit Titel "EventMap".
|
||||
- Mobile/Tablet (< 1024px): Karte oben (fixe Höhe ca. 50vh), Liste darunter,
|
||||
normal scrollbar.
|
||||
- Desktop (≥ 1024px): Grid mit Karte (~65%) und Liste-Sidebar (~35%)
|
||||
nebeneinander, siehe `css/responsive.css`.
|
||||
|
||||
## Kein Dev-Server nötig
|
||||
|
||||
Die App verwendet klassische `<script>`-Tags statt ES-Module (siehe
|
||||
`docs/adr/0001-vanilla-esm.md`) und lässt sich daher **per Doppelklick auf
|
||||
`index.html`** direkt aus dem lokal synchronisierten Ordner öffnen
|
||||
(`file://...`) – ein lokaler HTTP-Server ist dafür nicht mehr nötig.
|
||||
|
||||
Die Beispieldaten liegen in `data/seed.js` als eingebettetes
|
||||
`<script>`-Objekt (`EventMap.data.SEED_PLACES`) statt als `.json`-Datei,
|
||||
da `fetch()` auf lokale Dateien über `file://` in manchen Browsern
|
||||
(insbesondere Chrome/Chromium) blockiert wird. `initStore()` liest die
|
||||
Seed-Daten dadurch synchron direkt aus dem globalen Namespace, ganz ohne
|
||||
`fetch()`.
|
||||
|
||||
## Phasenübersicht
|
||||
|
||||
| Phase | Inhalt | Status |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 6 | KI-Prompt-Generator (Eingabeformular, Promptgenerierung, Copy/Download) | offen |
|
||||
| 7 | KI-JSON-Import (Validierung, Vorschau, Übernahme ins LocalStorage) | offen |
|
||||
|
||||
Nicht Teil von Phase 0/1 und daher bewusst noch nicht angelegt:
|
||||
Filter-Modul, Detail-Modul, Formular-Modul, Prompt-Generator-Modul,
|
||||
Import-Modul.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EventMap – Familienfreundliche Ausflugsziele</title>
|
||||
|
||||
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
||||
<link rel="stylesheet" href="vendor/leaflet.markercluster/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="vendor/leaflet.markercluster/MarkerCluster.Default.css" />
|
||||
|
||||
<link rel="stylesheet" href="css/reset.css" />
|
||||
<link rel="stylesheet" href="css/variables.css" />
|
||||
<link rel="stylesheet" href="css/layout.css" />
|
||||
<link rel="stylesheet" href="css/components.css" />
|
||||
<link rel="stylesheet" href="css/responsive.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<h1 class="app-header__title">EventMap</h1>
|
||||
<p class="app-header__subtitle">Familienfreundliche Ausflugsziele auf der Karte</p>
|
||||
</header>
|
||||
|
||||
<p id="app-status" class="app-status" role="alert" hidden></p>
|
||||
|
||||
<main class="app-layout">
|
||||
<section class="map-panel" aria-label="Karte">
|
||||
<div class="map-panel__toolbar">
|
||||
<button id="locate-btn" type="button" class="btn btn--secondary">
|
||||
Meinen Standort verwenden
|
||||
</button>
|
||||
</div>
|
||||
<div id="map" class="map-panel__map">
|
||||
<p class="map-panel__loading">Karte wird geladen…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="list-panel" aria-label="Ergebnisliste">
|
||||
<h2 class="list-panel__title">Ausflugsziele</h2>
|
||||
<div id="place-list" class="list-panel__content">
|
||||
<p class="list-empty">Lade Ausflugsziele…</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="vendor/leaflet/leaflet.js"></script>
|
||||
<script src="vendor/leaflet.markercluster/leaflet.markercluster.js"></script>
|
||||
|
||||
<!-- Klassische Scripts statt ES-Module (import/export), damit die App
|
||||
auch per Doppelklick/file:// ohne lokalen Server funktioniert.
|
||||
Reihenfolge folgt der Abhängigkeitskette; alle Dateien hängen ihre
|
||||
öffentliche API an das globale window.EventMap-Namespace-Objekt an. -->
|
||||
<script src="js/utils/dom.js"></script>
|
||||
<script src="js/utils/geo.js"></script>
|
||||
<script src="js/utils/id.js"></script>
|
||||
<script src="data/seed.js"></script>
|
||||
<script src="js/config/constants.js"></script>
|
||||
<script src="js/config/categories.js"></script>
|
||||
<script src="js/models/schema.js"></script>
|
||||
<script src="js/models/place.js"></script>
|
||||
<script src="js/store/migrations.js"></script>
|
||||
<script src="js/store/storage.js"></script>
|
||||
<script src="js/store/store.js"></script>
|
||||
<script src="js/map/geolocation.js"></script>
|
||||
<script src="js/map/markers.js"></script>
|
||||
<script src="js/map/mapController.js"></script>
|
||||
<script src="js/ui/list.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
// Feste Liste der Ausflugsziel-Kategorien (siehe anforderung.md).
|
||||
// Jede Kategorie hat eine stabile id (slug), ein Label, eine Farbe (Hex)
|
||||
// für Marker/Labels und ein Emoji-Icon als Glyph.
|
||||
|
||||
(function () {
|
||||
const CATEGORIES = [
|
||||
{ id: 'naturpark', label: 'Naturpark', color: '#2e7d32', icon: '🌳' },
|
||||
{ id: 'wald-wanderweg', label: 'Wald/Wanderweg', color: '#1b5e20', icon: '🌲' },
|
||||
{ id: 'see-badestelle', label: 'See/Badestelle', color: '#0288d1', icon: '🏖️' },
|
||||
{ id: 'erlebnispark', label: 'Erlebnispark', color: '#f57c00', icon: '🎢' },
|
||||
{ id: 'freizeitpark', label: 'Freizeitpark', color: '#e64a19', icon: '🎡' },
|
||||
{ id: 'tierpark-zoo', label: 'Tierpark/Zoo', color: '#ff8f00', icon: '🦁' },
|
||||
{ id: 'spielplatz', label: 'Spielplatz', color: '#d81b60', icon: '🛝' },
|
||||
{ id: 'museum-kinder', label: 'Museum für Kinder', color: '#7b1fa2', icon: '🏛️' },
|
||||
{ id: 'indoor-spielplatz', label: 'Indoor-Spielplatz', color: '#5e35b1', icon: '🧸' },
|
||||
{ id: 'bauernhof', label: 'Bauernhof', color: '#6d4c41', icon: '🐄' },
|
||||
{ id: 'kletterpark', label: 'Kletterpark', color: '#00897b', icon: '🧗' },
|
||||
{ id: 'aussichtspunkt', label: 'Aussichtspunkt', color: '#455a64', icon: '🔭' },
|
||||
{ id: 'cafe-restaurant', label: 'Familienfreundliches Café/Restaurant', color: '#c62828', icon: '☕' },
|
||||
{ id: 'sonstige-aktivitaet', label: 'Sonstige kinderfreundliche Aktivität', color: '#00acc1', icon: '🎈' },
|
||||
{ id: 'sonstige', label: 'Sonstige', color: '#616161', icon: '📍' },
|
||||
];
|
||||
|
||||
const CATEGORY_MAP = new Map(CATEGORIES.map((c) => [c.id, c]));
|
||||
const CATEGORY_LABEL_MAP = new Map(
|
||||
CATEGORIES.map((c) => [c.label.toLowerCase(), c])
|
||||
);
|
||||
|
||||
function getCategoryById(id) {
|
||||
return CATEGORY_MAP.get(id) ?? CATEGORY_MAP.get('sonstige');
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet eine Kategorie anhand ihres Labels (z.B. aus KI-Import-JSON).
|
||||
* Fällt auf "Sonstige" zurück, falls kein Treffer gefunden wird.
|
||||
*/
|
||||
function getCategoryByLabel(label) {
|
||||
if (typeof label !== 'string') {
|
||||
return CATEGORY_MAP.get('sonstige');
|
||||
}
|
||||
return CATEGORY_LABEL_MAP.get(label.toLowerCase()) ?? CATEGORY_MAP.get('sonstige');
|
||||
}
|
||||
|
||||
EventMap.config.CATEGORIES = CATEGORIES;
|
||||
EventMap.config.getCategoryById = getCategoryById;
|
||||
EventMap.config.getCategoryByLabel = getCategoryByLabel;
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
// Zentrale Konstanten der Anwendung.
|
||||
|
||||
(function () {
|
||||
const STORAGE_KEYS = {
|
||||
DATA: 'eventmap.data.v1',
|
||||
SETTINGS: 'eventmap.settings.v1',
|
||||
};
|
||||
|
||||
const CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
// Geografische Mitte Deutschlands als Standard-Kartenzentrum,
|
||||
// solange der Nutzer keinen eigenen Standort freigibt oder ein
|
||||
// eigenes defaultCenter in den Settings hinterlegt hat.
|
||||
const DEFAULT_MAP_CENTER = {
|
||||
lat: 51.1657,
|
||||
lng: 10.4515,
|
||||
zoom: 6,
|
||||
};
|
||||
|
||||
EventMap.config.STORAGE_KEYS = STORAGE_KEYS;
|
||||
EventMap.config.CURRENT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION;
|
||||
EventMap.config.DEFAULT_MAP_CENTER = DEFAULT_MAP_CENTER;
|
||||
})();
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Einstiegspunkt der Anwendung: initialisiert Store, Karte und Liste.
|
||||
|
||||
(function () {
|
||||
const { initStore } = EventMap.store;
|
||||
const { initMap, centerOnCoords, requestUserLocation } = EventMap.map;
|
||||
const { initList } = EventMap.ui;
|
||||
|
||||
function showFatalError(message) {
|
||||
const status = document.getElementById('app-status');
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.hidden = false;
|
||||
}
|
||||
|
||||
function setupGeolocationButton() {
|
||||
const button = document.getElementById('locate-btn');
|
||||
if (!button) return;
|
||||
|
||||
const defaultLabel = button.textContent;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Standort wird ermittelt…';
|
||||
|
||||
requestUserLocation({
|
||||
onSuccess: ({ lat, lng }) => {
|
||||
centerOnCoords(lat, lng, 13);
|
||||
button.disabled = false;
|
||||
button.textContent = defaultLabel;
|
||||
},
|
||||
onError: () => {
|
||||
button.disabled = false;
|
||||
button.textContent = defaultLabel;
|
||||
showFatalError(
|
||||
'Standort konnte nicht ermittelt werden. Bitte Standortfreigabe im Browser prüfen.'
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await initStore();
|
||||
initMap('map');
|
||||
initList('place-list');
|
||||
setupGeolocationButton();
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('EventMap: Initialisierung fehlgeschlagen.', err);
|
||||
showFatalError('Die Anwendung konnte nicht geladen werden. Bitte Seite neu laden.');
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,39 @@
|
||||
// Optionale Standortabfrage via navigator.geolocation. Wird nur auf
|
||||
// explizite Nutzeraktion (Button) angefragt, nie automatisch beim Start.
|
||||
|
||||
(function () {
|
||||
const { setState, updateSettings } = EventMap.store;
|
||||
|
||||
/**
|
||||
* Fragt den aktuellen Standort des Nutzers an.
|
||||
* @param {{onSuccess?: (loc:{lat:number,lng:number}) => void, onError?: (err:Error) => void}} handlers
|
||||
*/
|
||||
function requestUserLocation({ onSuccess, onError } = {}) {
|
||||
if (!('geolocation' in navigator)) {
|
||||
const err = new Error('Geolocation wird von diesem Browser nicht unterstützt.');
|
||||
console.warn('EventMap:', err.message);
|
||||
if (onError) onError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const userLocation = {
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
};
|
||||
setState({ userLocation });
|
||||
updateSettings({ userConsentGeo: true });
|
||||
if (onSuccess) onSuccess(userLocation);
|
||||
},
|
||||
(error) => {
|
||||
console.warn('EventMap: Standort konnte nicht ermittelt werden.', error.message);
|
||||
updateSettings({ userConsentGeo: false });
|
||||
if (onError) onError(error);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
|
||||
);
|
||||
}
|
||||
|
||||
EventMap.map.requestUserLocation = requestUserLocation;
|
||||
})();
|
||||
@@ -0,0 +1,130 @@
|
||||
/* global L */
|
||||
// Initialisiert die Leaflet-Karte, verwaltet Marker-Clustering und
|
||||
// reagiert auf Store-Änderungen (places, selectedPlaceId).
|
||||
|
||||
(function () {
|
||||
const { DEFAULT_MAP_CENTER } = EventMap.config;
|
||||
const { subscribe, getState, setState } = EventMap.store;
|
||||
const { getPlaceLatLng } = EventMap.models;
|
||||
const { createMarkerForPlace } = EventMap.map;
|
||||
|
||||
let map = null;
|
||||
let clusterGroup = null;
|
||||
const markersByPlaceId = new Map();
|
||||
|
||||
let lastPlacesRef = null;
|
||||
let lastSelectedPlaceId = null;
|
||||
|
||||
function hasValidCoords(place) {
|
||||
const { lat, lng } = getPlaceLatLng(place);
|
||||
return typeof lat === 'number' && typeof lng === 'number' && !Number.isNaN(lat) && !Number.isNaN(lng);
|
||||
}
|
||||
|
||||
function renderMarkers(places) {
|
||||
clusterGroup.clearLayers();
|
||||
markersByPlaceId.clear();
|
||||
|
||||
for (const place of places) {
|
||||
if (!hasValidCoords(place)) continue;
|
||||
|
||||
const marker = createMarkerForPlace(place, {
|
||||
onSelect: (placeId) => setState({ selectedPlaceId: placeId }),
|
||||
});
|
||||
markersByPlaceId.set(place.id, marker);
|
||||
clusterGroup.addLayer(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/** Passt den Kartenausschnitt an alle übergebenen Places an (Fallback: Default-Center). */
|
||||
function fitToPlaces(places) {
|
||||
const valid = places.filter(hasValidCoords);
|
||||
|
||||
if (valid.length === 0) {
|
||||
map.setView([DEFAULT_MAP_CENTER.lat, DEFAULT_MAP_CENTER.lng], DEFAULT_MAP_CENTER.zoom);
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(
|
||||
valid.map((place) => {
|
||||
const { lat, lng } = getPlaceLatLng(place);
|
||||
return [lat, lng];
|
||||
})
|
||||
);
|
||||
map.fitBounds(bounds, { padding: [32, 32], maxZoom: 14 });
|
||||
}
|
||||
|
||||
/** Zentriert die Karte auf einen bestimmten Place und öffnet dessen Popup. */
|
||||
function centerOnPlace(placeId) {
|
||||
const marker = markersByPlaceId.get(placeId);
|
||||
if (!marker || !map) return;
|
||||
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 14));
|
||||
marker.openPopup();
|
||||
}
|
||||
|
||||
/** Zentriert die Karte auf beliebige Koordinaten (z.B. Nutzerstandort). */
|
||||
function centerOnCoords(lat, lng, zoom = 13) {
|
||||
if (!map) return;
|
||||
map.setView([lat, lng], zoom);
|
||||
}
|
||||
|
||||
function getMapInstance() {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Initialisiert die Karte im Element mit der übergebenen id. */
|
||||
function initMap(containerId) {
|
||||
// Leaflet leert den Container beim Initialisieren nicht selbst - den
|
||||
// statischen "Karte wird geladen…"-Platzhalter aus index.html entfernen.
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.textContent = '';
|
||||
}
|
||||
|
||||
map = L.map(containerId, {
|
||||
center: [DEFAULT_MAP_CENTER.lat, DEFAULT_MAP_CENTER.lng],
|
||||
zoom: DEFAULT_MAP_CENTER.zoom,
|
||||
});
|
||||
|
||||
// tile.openstreetmap.org verlangt seit 2024 einen gültigen HTTP-Referer
|
||||
// (Tile Usage Policy). Seiten, die per file:// geöffnet werden, senden
|
||||
// browserseitig grundsätzlich keinen Referer mit ("Access blocked: Referer
|
||||
// is required"). Daher CARTO-Basemaps (kartographisch auf OSM-Daten
|
||||
// basierend, korrekt attributiert) statt des offiziellen OSM-Tile-Servers.
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende ' +
|
||||
'© <a href="https://carto.com/attributions">CARTO</a>',
|
||||
}).addTo(map);
|
||||
|
||||
clusterGroup = L.markerClusterGroup();
|
||||
map.addLayer(clusterGroup);
|
||||
|
||||
const initialState = getState();
|
||||
renderMarkers(initialState.places);
|
||||
fitToPlaces(initialState.places);
|
||||
lastPlacesRef = initialState.places;
|
||||
|
||||
subscribe((state) => {
|
||||
if (state.places !== lastPlacesRef) {
|
||||
lastPlacesRef = state.places;
|
||||
renderMarkers(state.places);
|
||||
}
|
||||
if (state.selectedPlaceId !== lastSelectedPlaceId) {
|
||||
lastSelectedPlaceId = state.selectedPlaceId;
|
||||
if (state.selectedPlaceId) {
|
||||
centerOnPlace(state.selectedPlaceId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
EventMap.map.fitToPlaces = fitToPlaces;
|
||||
EventMap.map.centerOnPlace = centerOnPlace;
|
||||
EventMap.map.centerOnCoords = centerOnCoords;
|
||||
EventMap.map.getMapInstance = getMapInstance;
|
||||
EventMap.map.initMap = initMap;
|
||||
})();
|
||||
@@ -0,0 +1,64 @@
|
||||
/* global L */
|
||||
// Erzeugt Leaflet-Marker für Places inkl. Kategorie-Icon (L.divIcon) und
|
||||
// Popup-Inhalt. Popup-Inhalte werden als DOM-Nodes (nicht als HTML-String)
|
||||
// erzeugt, damit Place-Daten niemals ungesichert als innerHTML landen.
|
||||
|
||||
(function () {
|
||||
const { getCategoryById } = EventMap.config;
|
||||
const { getPlaceCategory, getPlaceLatLng } = EventMap.models;
|
||||
const { el } = EventMap.utils;
|
||||
|
||||
const iconCache = new Map();
|
||||
|
||||
function buildDivIcon(category) {
|
||||
return L.divIcon({
|
||||
className: 'eventmap-marker',
|
||||
html: `<span class="eventmap-marker__glyph" style="background:${category.color}">${category.icon}</span>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
}
|
||||
|
||||
function getIconForCategory(categoryId) {
|
||||
if (!iconCache.has(categoryId)) {
|
||||
iconCache.set(categoryId, buildDivIcon(getCategoryById(categoryId)));
|
||||
}
|
||||
return iconCache.get(categoryId);
|
||||
}
|
||||
|
||||
function buildPopupContent(place) {
|
||||
const category = getPlaceCategory(place);
|
||||
|
||||
return el('div', { className: 'eventmap-popup' }, [
|
||||
el('strong', { className: 'eventmap-popup__title', text: place.name }),
|
||||
el('div', {
|
||||
className: 'eventmap-popup__category',
|
||||
style: { color: category.color },
|
||||
text: `${category.icon} ${category.label}`,
|
||||
}),
|
||||
el('p', { className: 'eventmap-popup__desc', text: place.shortDescription }),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen Leaflet-Marker für ein Place-Objekt.
|
||||
* @param {object} place
|
||||
* @param {{onSelect?: (placeId: string) => void}} [handlers]
|
||||
*/
|
||||
function createMarkerForPlace(place, { onSelect } = {}) {
|
||||
const { lat, lng } = getPlaceLatLng(place);
|
||||
const marker = L.marker([lat, lng], { icon: getIconForCategory(place.category) });
|
||||
|
||||
marker.bindPopup(buildPopupContent(place));
|
||||
marker.on('click', () => {
|
||||
if (onSelect) {
|
||||
onSelect(place.id);
|
||||
}
|
||||
});
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
EventMap.map.createMarkerForPlace = createMarkerForPlace;
|
||||
})();
|
||||
@@ -0,0 +1,56 @@
|
||||
// Internes Place-Objekt: bleibt nah am KI-Import-JSON-Format aus
|
||||
// anforderung.md, ergänzt um intern generierte Felder (id, createdAt,
|
||||
// updatedAt). Die Kategorie wird beim Normalisieren immer auf eine
|
||||
// gültige Kategorie-id (Slug) aufgelöst, damit Styling (Farbe/Icon)
|
||||
// überall per getCategoryById() funktioniert.
|
||||
|
||||
(function () {
|
||||
const { generateId } = EventMap.utils;
|
||||
const { CATEGORIES, getCategoryById, getCategoryByLabel } = EventMap.config;
|
||||
const { createDefaultPlaceFields } = EventMap.models;
|
||||
|
||||
const CATEGORY_IDS = new Set(CATEGORIES.map((c) => c.id));
|
||||
|
||||
function resolveCategory(rawCategory) {
|
||||
if (typeof rawCategory === 'string' && CATEGORY_IDS.has(rawCategory)) {
|
||||
return rawCategory;
|
||||
}
|
||||
return getCategoryByLabel(rawCategory).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert ein rohes Place-Objekt (z.B. aus data/seed.js oder einem
|
||||
* späteren KI-Import) zu einem vollständigen internen Place-Objekt.
|
||||
* Fehlende Felder werden mit Defaults aufgefüllt, id/createdAt/updatedAt
|
||||
* werden übernommen falls vorhanden, sonst neu generiert.
|
||||
*/
|
||||
function normalizePlace(raw = {}) {
|
||||
const defaults = createDefaultPlaceFields();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: raw.id || generateId(),
|
||||
...defaults,
|
||||
...raw,
|
||||
category: resolveCategory(raw.category),
|
||||
ageRecommendation: { ...defaults.ageRecommendation, ...(raw.ageRecommendation || {}) },
|
||||
cost: { ...defaults.cost, ...(raw.cost || {}) },
|
||||
facilities: { ...defaults.facilities, ...(raw.facilities || {}) },
|
||||
tags: Array.isArray(raw.tags) ? [...raw.tags] : [],
|
||||
createdAt: raw.createdAt || now,
|
||||
updatedAt: raw.updatedAt || now,
|
||||
};
|
||||
}
|
||||
|
||||
function getPlaceCategory(place) {
|
||||
return getCategoryById(place.category);
|
||||
}
|
||||
|
||||
function getPlaceLatLng(place) {
|
||||
return { lat: place.latitude, lng: place.longitude };
|
||||
}
|
||||
|
||||
EventMap.models.normalizePlace = normalizePlace;
|
||||
EventMap.models.getPlaceCategory = getPlaceCategory;
|
||||
EventMap.models.getPlaceLatLng = getPlaceLatLng;
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
// Beschreibt die Feldstruktur eines Ausflugsziels (~27 Felder, siehe
|
||||
// anforderung.md) und liefert Default-Werte. Dient als einzige Quelle der
|
||||
// Wahrheit für die Form eines Place-Objekts; Validierungslogik für
|
||||
// Formulare folgt in einer späteren Phase.
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Liefert ein leeres/defaultes Place-Objekt ohne id/createdAt/updatedAt
|
||||
* (diese werden von place.js ergänzt).
|
||||
*/
|
||||
function createDefaultPlaceFields() {
|
||||
return {
|
||||
name: '',
|
||||
category: 'sonstige',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
address: '',
|
||||
city: '',
|
||||
region: '',
|
||||
ageRecommendation: { min: null, max: null },
|
||||
cost: { type: 'unknown', description: '' },
|
||||
openingHours: '',
|
||||
durationMinutes: null,
|
||||
facilities: {
|
||||
strollerAccessible: false,
|
||||
wheelchairAccessible: false,
|
||||
toilets: false,
|
||||
parking: false,
|
||||
restaurant: false,
|
||||
},
|
||||
environment: 'outdoor',
|
||||
website: '',
|
||||
image: '',
|
||||
rating: null,
|
||||
tags: [],
|
||||
source: '',
|
||||
sourceVerified: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Gültige Werte für die Enums des Datenmodells.
|
||||
const COST_TYPES = ['free', 'paid', 'mixed', 'unknown'];
|
||||
const ENVIRONMENT_TYPES = ['indoor', 'outdoor', 'mixed'];
|
||||
|
||||
EventMap.models.createDefaultPlaceFields = createDefaultPlaceFields;
|
||||
EventMap.models.COST_TYPES = COST_TYPES;
|
||||
EventMap.models.ENVIRONMENT_TYPES = ENVIRONMENT_TYPES;
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
// Schlanke Migrations-Infrastruktur für das LocalStorage-Datenschema.
|
||||
// Aktuell existiert nur Schema-Version 1, daher ist MIGRATIONS leer.
|
||||
// Neue Migrationen werden als { from, to, migrate(data) } ergänzt und
|
||||
// von migrate() sequenziell angewendet.
|
||||
|
||||
(function () {
|
||||
const { CURRENT_SCHEMA_VERSION } = EventMap.config;
|
||||
|
||||
const MIGRATIONS = [
|
||||
// Beispiel für zukünftige Migrationen:
|
||||
// { from: 1, to: 2, migrate: (data) => ({ ...data, neuesFeld: 'default' }) },
|
||||
];
|
||||
|
||||
/**
|
||||
* Wendet alle nötigen Migrationsschritte an, bis schemaVersion der
|
||||
* aktuellen CURRENT_SCHEMA_VERSION entspricht.
|
||||
*/
|
||||
function migrate(data) {
|
||||
let current = data ?? {};
|
||||
let version = current.schemaVersion ?? 1;
|
||||
|
||||
while (version < CURRENT_SCHEMA_VERSION) {
|
||||
const step = MIGRATIONS.find((m) => m.from === version);
|
||||
if (!step) {
|
||||
// Keine passende Migration gefunden: auf aktuelle Version springen,
|
||||
// um die App nicht zu blockieren.
|
||||
break;
|
||||
}
|
||||
current = step.migrate(current);
|
||||
version = step.to;
|
||||
}
|
||||
|
||||
return { ...current, schemaVersion: CURRENT_SCHEMA_VERSION };
|
||||
}
|
||||
|
||||
EventMap.store.migrate = migrate;
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
// Persistenzschicht: liest/schreibt die beiden LocalStorage-Keys
|
||||
// eventmap.data.v1 und eventmap.settings.v1.
|
||||
|
||||
(function () {
|
||||
const { STORAGE_KEYS, CURRENT_SCHEMA_VERSION } = EventMap.config;
|
||||
const { migrate } = EventMap.store;
|
||||
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
lastFilters: null,
|
||||
lastMapView: null,
|
||||
userConsentGeo: null,
|
||||
defaultCenter: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest die persistierten Places. Gibt null zurück, falls noch nichts gespeichert ist. */
|
||||
function loadData() {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEYS.DATA);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return migrate(parsed);
|
||||
} catch (err) {
|
||||
console.warn('EventMap: Gespeicherte Daten sind beschädigt und werden verworfen.', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveData(places) {
|
||||
const payload = {
|
||||
schemaVersion: CURRENT_SCHEMA_VERSION,
|
||||
places,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(STORAGE_KEYS.DATA, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
if (!raw) {
|
||||
return createDefaultSettings();
|
||||
}
|
||||
try {
|
||||
return { ...createDefaultSettings(), ...JSON.parse(raw) };
|
||||
} catch (err) {
|
||||
console.warn('EventMap: Gespeicherte Einstellungen sind beschädigt, Defaults werden verwendet.', err);
|
||||
return createDefaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
window.localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
EventMap.store.createDefaultSettings = createDefaultSettings;
|
||||
EventMap.store.loadData = loadData;
|
||||
EventMap.store.saveData = saveData;
|
||||
EventMap.store.loadSettings = loadSettings;
|
||||
EventMap.store.saveSettings = saveSettings;
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
// Zentraler Pub/Sub-Store der Anwendung. Hält den Laufzeitzustand
|
||||
// (places, filters, userLocation, selectedPlaceId) und benachrichtigt
|
||||
// Subscriber bei Änderungen. Lädt beim Start aus LocalStorage bzw. beim
|
||||
// allerersten Start aus data/seed.js und persistiert Änderungen zurück.
|
||||
|
||||
(function () {
|
||||
const { loadData, saveData, loadSettings, saveSettings } = EventMap.store;
|
||||
const { normalizePlace } = EventMap.models;
|
||||
|
||||
let state = {
|
||||
places: [],
|
||||
filters: null,
|
||||
userLocation: null,
|
||||
selectedPlaceId: null,
|
||||
};
|
||||
|
||||
let settings = loadSettings();
|
||||
|
||||
const subscribers = new Set();
|
||||
|
||||
function notify() {
|
||||
for (const fn of subscribers) {
|
||||
fn(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Registriert einen Listener, der bei jeder Zustandsänderung aufgerufen wird. */
|
||||
function subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function setState(patch) {
|
||||
state = { ...state, ...patch };
|
||||
notify();
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
function updateSettings(patch) {
|
||||
settings = { ...settings, ...patch };
|
||||
saveSettings(settings);
|
||||
}
|
||||
|
||||
function loadSeedPlaces() {
|
||||
const raw = (EventMap.data && EventMap.data.SEED_PLACES) || [];
|
||||
return raw.map((item) => normalizePlace(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert den Store: lädt persistierte Places aus LocalStorage oder,
|
||||
* falls noch keine vorhanden sind, die eingebetteten Beispieldaten
|
||||
* (data/seed.js). Gibt den finalen State zurück.
|
||||
*/
|
||||
function initStore() {
|
||||
const persisted = loadData();
|
||||
let places = [];
|
||||
|
||||
if (persisted && Array.isArray(persisted.places) && persisted.places.length > 0) {
|
||||
places = persisted.places.map((p) => normalizePlace(p));
|
||||
} else {
|
||||
places = loadSeedPlaces();
|
||||
saveData(places);
|
||||
}
|
||||
|
||||
setState({ places });
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Persistiert state.places nach Änderungen (Erstellen/Bearbeiten/Löschen). */
|
||||
function persistPlaces() {
|
||||
saveData(state.places);
|
||||
}
|
||||
|
||||
EventMap.store.subscribe = subscribe;
|
||||
EventMap.store.getState = getState;
|
||||
EventMap.store.setState = setState;
|
||||
EventMap.store.getSettings = getSettings;
|
||||
EventMap.store.updateSettings = updateSettings;
|
||||
EventMap.store.initStore = initStore;
|
||||
EventMap.store.persistPlaces = persistPlaces;
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
// Ergebnisliste (Cards), rendert aus store.state.places. Filterung folgt
|
||||
// in einer späteren Phase - aktuell werden alle Places angezeigt.
|
||||
// Rendering ausschließlich über textContent/DOM-APIs (el()), niemals
|
||||
// innerHTML mit Place-Daten.
|
||||
|
||||
(function () {
|
||||
const { subscribe, getState, setState } = EventMap.store;
|
||||
const { getCategoryById } = EventMap.config;
|
||||
const { el, clearChildren } = EventMap.utils;
|
||||
|
||||
function createCard(place) {
|
||||
const category = getCategoryById(place.category);
|
||||
|
||||
const select = () => setState({ selectedPlaceId: place.id });
|
||||
|
||||
return el(
|
||||
'li',
|
||||
{
|
||||
className: 'place-card',
|
||||
attrs: { tabindex: '0', role: 'button' },
|
||||
dataset: { placeId: place.id },
|
||||
on: {
|
||||
click: select,
|
||||
keydown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
select();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
[
|
||||
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) {
|
||||
clearChildren(container);
|
||||
|
||||
if (!places || places.length === 0) {
|
||||
container.appendChild(
|
||||
el('p', { className: 'list-empty', text: 'Keine Ausflugsziele vorhanden.' })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('ul', { className: 'place-list' });
|
||||
for (const place of places) {
|
||||
list.appendChild(createCard(place));
|
||||
}
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
/** Initialisiert die Liste im Element mit der übergebenen id und abonniert den Store. */
|
||||
function initList(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error(`EventMap: List-Container #${containerId} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
render(container, getState().places);
|
||||
|
||||
subscribe((state) => {
|
||||
render(container, state.places);
|
||||
});
|
||||
}
|
||||
|
||||
EventMap.ui.initList = initList;
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
// Kleine DOM-Helfer. Wichtig: Daten aus Places werden NIE per innerHTML
|
||||
// eingefügt, sondern immer über textContent bzw. DOM-APIs (el()).
|
||||
//
|
||||
// Klassisches Script (kein ES-Modul): Die App wird per file:// direkt aus
|
||||
// einem synchronisierten Ordner geöffnet, daher können keine ES-Module
|
||||
// (import/export) verwendet werden (Browser blockieren deren Nachladen
|
||||
// unter file:// per CORS-Policy). Stattdessen hängen alle Dateien ihre
|
||||
// öffentliche API an das globale Namespace-Objekt window.EventMap.
|
||||
// Diese Datei wird als erste geladen und legt den Namespace an.
|
||||
|
||||
window.EventMap = window.EventMap || {};
|
||||
window.EventMap.utils = window.EventMap.utils || {};
|
||||
window.EventMap.config = window.EventMap.config || {};
|
||||
window.EventMap.models = window.EventMap.models || {};
|
||||
window.EventMap.store = window.EventMap.store || {};
|
||||
window.EventMap.map = window.EventMap.map || {};
|
||||
window.EventMap.ui = window.EventMap.ui || {};
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Erzeugt ein DOM-Element.
|
||||
* @param {string} tag Tag-Name, z.B. 'div'
|
||||
* @param {object} [props] Eigenschaften: className, attrs{}, dataset{}, style{},
|
||||
* text (setzt textContent), on{ click: fn, ... } für Event-Listener
|
||||
* @param {Array<Node|string>} [children] Kindknoten oder Strings (werden als Text angehängt)
|
||||
*/
|
||||
function el(tag, props = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
|
||||
if (props.className) {
|
||||
node.className = props.className;
|
||||
}
|
||||
if (props.text !== undefined) {
|
||||
node.textContent = props.text;
|
||||
}
|
||||
if (props.attrs) {
|
||||
for (const [key, value] of Object.entries(props.attrs)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (props.dataset) {
|
||||
for (const [key, value] of Object.entries(props.dataset)) {
|
||||
node.dataset[key] = value;
|
||||
}
|
||||
}
|
||||
if (props.style) {
|
||||
Object.assign(node.style, props.style);
|
||||
}
|
||||
if (props.on) {
|
||||
for (const [event, handler] of Object.entries(props.on)) {
|
||||
node.addEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (child === null || child === undefined) continue;
|
||||
if (typeof child === 'string') {
|
||||
node.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
node.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaped HTML-Sonderzeichen. Nur als Sicherheitsnetz gedacht, falls an
|
||||
* einzelnen Stellen doch einmal mit HTML-Strings gearbeitet werden muss
|
||||
* (z.B. Leaflet-Popups, die intern innerHTML nutzen). Für normales
|
||||
* Rendering bitte el()/textContent verwenden.
|
||||
*/
|
||||
function escapeHtml(value) {
|
||||
const str = String(value ?? '');
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Entfernt alle Kindknoten eines Elements. */
|
||||
function clearChildren(node) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
EventMap.utils.el = el;
|
||||
EventMap.utils.escapeHtml = escapeHtml;
|
||||
EventMap.utils.clearChildren = clearChildren;
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
// Geografische Hilfsfunktionen.
|
||||
|
||||
(function () {
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
function toRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernung zwischen zwei Koordinaten in Kilometern (Haversine-Formel).
|
||||
* @param {{lat:number,lng:number}} a
|
||||
* @param {{lat:number,lng:number}} b
|
||||
*/
|
||||
function haversineDistanceKm(a, b) {
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||
|
||||
return EARTH_RADIUS_KM * c;
|
||||
}
|
||||
|
||||
function isValidCoordinate(lat, lng) {
|
||||
return (
|
||||
typeof lat === 'number' &&
|
||||
typeof lng === 'number' &&
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lng) &&
|
||||
lat >= -90 &&
|
||||
lat <= 90 &&
|
||||
lng >= -180 &&
|
||||
lng <= 180
|
||||
);
|
||||
}
|
||||
|
||||
EventMap.utils.haversineDistanceKm = haversineDistanceKm;
|
||||
EventMap.utils.isValidCoordinate = isValidCoordinate;
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
// Erzeugt eindeutige IDs für Ausflugsziele.
|
||||
|
||||
(function () {
|
||||
function generateId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback für Umgebungen ohne crypto.randomUUID (z.B. sehr alte Browser).
|
||||
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
EventMap.utils.generateId = generateId;
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
/* IE 6-8 fallback colors */
|
||||
.leaflet-oldie .marker-cluster-small {
|
||||
background-color: rgb(181, 226, 140);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-small div {
|
||||
background-color: rgb(110, 204, 57);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-medium {
|
||||
background-color: rgb(241, 211, 87);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-medium div {
|
||||
background-color: rgb(240, 194, 12);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-large {
|
||||
background-color: rgb(253, 156, 115);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-large div {
|
||||
background-color: rgb(241, 128, 23);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.leaflet-cluster-spider-leg {
|
||||
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
|
||||
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
|
||||
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
|
||||
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user