// ===================================================================== // WebGIS Citizen Participation Portal — Application Logic // Initializes the Leaflet Map, loads Contributions from the API, // handles the CRUD Workflow, and manages all UI Interactions. // // Depends on: MUNICIPALITY Object (set in index.php), Leaflet, Geoman, // Sidebar v2, Geocoder, PolylineMeasure, Fullscreen, SweetAlert2 // ===================================================================== // ===================================================================== // Block 1: Configuration and Application State // ===================================================================== // API Endpoint — relative Path from public/ to api/ var API_URL = '../api/contributions.php'; // Current User Name — set via Login Modal, stored in sessionStorage var currentUser = sessionStorage.getItem('webgis_user') || ''; // Category Definitions with Labels, Icons, and Colors var CATEGORIES = { mobility: { label: 'Mobilität', icon: '🚲', color: '#1565C0', faIcon: 'fa-bicycle' }, building: { label: 'Bauen', icon: '🏗️', color: '#E65100', faIcon: 'fa-helmet-safety' }, energy: { label: 'Energie', icon: '⚡', color: '#F9A825', faIcon: 'fa-bolt' }, environment: { label: 'Umwelt', icon: '🌳', color: '#2E7D32', faIcon: 'fa-tree' }, industry: { label: 'Industrie', icon: '🏭', color: '#6A1B9A', faIcon: 'fa-industry' }, consumption: { label: 'Konsum', icon: '🛒', color: '#AD1457', faIcon: 'fa-cart-shopping' }, other: { label: 'Sonstiges', icon: '📌', color: '#546E7A', faIcon: 'fa-map-pin' } }; // Application State var map; // Leaflet Map Instance var sidebar; // Sidebar Instance var contributionsLayer; // GeoJSON Layer holding all Contributions var contributionsData = []; // Raw Contribution Data Array var activeFilters = Object.keys(CATEGORIES); // Active Category Filters (all enabled by Default) var drawnGeometry = null; // Temporary Storage for Geometry drawn with Geoman var drawnGeomType = null; // Temporary Storage for Geometry Type // ===================================================================== // Block 2: Map Initialization // ===================================================================== map = L.map('map', { center: MUNICIPALITY.center, zoom: MUNICIPALITY.zoom, zoomControl: false, // Added manually in Block 3 for Position Control attributionControl: true }); // ===================================================================== // Block 3: Basemaps and Layer Control // ===================================================================== // Basemap Tile Layers var basemapOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 20 }); var basemapCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© CARTO', maxZoom: 20 }); var basemapSatellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri', maxZoom: 20 }); var basemapTopo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { attribution: '© OpenTopoMap', maxZoom: 18 }); // Set Default Basemap basemapCartoDB.addTo(map); // Layer Control var basemaps = { 'OpenStreetMap': basemapOSM, 'CartoDB (hell)': basemapCartoDB, 'Satellit (Esri)': basemapSatellite, 'Topographisch': basemapTopo }; var overlays = {}; // Populated later with Contribution Layer var layerControl = L.control.layers(basemaps, overlays, { position: 'topright', collapsed: true }).addTo(map); // ===================================================================== // Block 4: Map Controls // ===================================================================== // Zoom Control (top right) L.control.zoom({ position: 'topright' }).addTo(map); // Scale Bar (bottom right) L.control.scale({ position: 'bottomright', maxWidth: 200, imperial: false }).addTo(map); // Fullscreen Button L.control.fullscreen({ position: 'topright', title: 'Vollbild', titleCancel: 'Vollbild beenden' }).addTo(map); // Address Search (Geocoder with Nominatim) L.Control.geocoder({ position: 'topright', placeholder: 'Adresse suchen...', defaultMarkGeocode: true, geocoder: L.Control.Geocoder.nominatim({ geocodingQueryParams: { countrycodes: 'de', viewbox: '8.0,52.5,8.5,52.8', bounded: 1 } }) }).addTo(map); // Polyline Measure Tool L.control.polylineMeasure({ position: 'topright', unit: 'metres', showBearings: false, clearMeasurementsOnStop: false, showClearControl: true }).addTo(map); // Mouse Position Display var MousePositionControl = L.Control.extend({ options: { position: 'bottomright' }, onAdd: function () { var container = L.DomUtil.create('div', 'mouse-position-display'); container.style.background = 'rgba(255,255,255,0.85)'; container.style.padding = '2px 8px'; container.style.fontSize = '12px'; container.style.borderRadius = '4px'; container.style.fontFamily = 'monospace'; container.innerHTML = 'Lat: — | Lng: —'; map.on('mousemove', function (e) { container.innerHTML = 'Lat: ' + e.latlng.lat.toFixed(5) + ' | Lng: ' + e.latlng.lng.toFixed(5); }); return container; } }); new MousePositionControl().addTo(map); // GPS Location Button var GpsControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function () { var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); var button = L.DomUtil.create('a', '', container); button.href = '#'; button.title = 'Mein Standort'; button.innerHTML = ''; button.style.fontSize = '16px'; button.style.display = 'flex'; button.style.alignItems = 'center'; button.style.justifyContent = 'center'; button.style.width = '30px'; button.style.height = '30px'; L.DomEvent.on(button, 'click', function (e) { L.DomEvent.preventDefault(e); map.locate({ setView: true, maxZoom: 17 }); }); return container; } }); new GpsControl().addTo(map); // GPS Location Found Handler var gpsMarker = null; map.on('locationfound', function (e) { if (gpsMarker) { map.removeLayer(gpsMarker); } gpsMarker = L.circleMarker(e.latlng, { radius: 8, color: '#1565C0', fillColor: '#42A5F5', fillOpacity: 0.8, weight: 2 }).addTo(map).bindPopup('Ihr Standort').openPopup(); }); map.on('locationerror', function () { Swal.fire('Standort nicht gefunden', 'Bitte erlauben Sie den Standortzugriff in Ihrem Browser.', 'warning'); }); // ===================================================================== // Block 5: Sidebar Initialization // ===================================================================== sidebar = L.control.sidebar({ autopan: true, closeButton: true, container: 'sidebar', position: 'left' }).addTo(map); // ===================================================================== // Block 6: Geoman Drawing Tools and CRUD Trigger // ===================================================================== map.pm.addControls({ position: 'topright', drawMarker: true, drawPolyline: true, drawPolygon: true, drawCircleMarker: false, drawCircle: false, drawText: false, drawRectangle: false, editMode: false, dragMode: false, cutPolygon: false, removalMode: false, rotateMode: false }); // When a Shape is drawn, capture the Geometry and open the Create Modal map.on('pm:create', function (e) { var geojson = e.layer.toGeoJSON().geometry; // Determine Geometry Type and normalize to simple Types if (e.shape === 'Marker') { drawnGeometry = { type: 'Point', coordinates: geojson.coordinates }; drawnGeomType = 'point'; } else if (e.shape === 'Line') { drawnGeometry = { type: 'LineString', coordinates: geojson.coordinates }; drawnGeomType = 'line'; } else if (e.shape === 'Polygon') { drawnGeometry = { type: 'Polygon', coordinates: geojson.coordinates }; drawnGeomType = 'polygon'; } else { // Unsupported Shape — remove from Map and exit map.removeLayer(e.layer); return; } // Remove the drawn Layer — it will be re-added after API Confirmation map.removeLayer(e.layer); // Check if User is logged in if (!currentUser) { Swal.fire('Bitte anmelden', 'Sie müssen sich anmelden, um Beiträge zu erstellen.', 'info'); showLoginModal(); return; } // Populate hidden Fields and open Create Modal document.getElementById('create-geom').value = JSON.stringify(drawnGeometry); document.getElementById('create-geom-type').value = drawnGeomType; document.getElementById('create-modal').style.display = 'flex'; }); // ===================================================================== // Block 7: API Communication // ===================================================================== // Generic API Call Function function apiCall(data, callback) { var formData = new FormData(); for (var key in data) { formData.append(key, data[key]); } fetch(API_URL, { method: 'POST', body: formData }) .then(function (response) { return response.json().then(function (json) { json._status = response.status; return json; }); }) .then(function (json) { callback(json); }) .catch(function (error) { console.error('API Error:', error); Swal.fire('Verbindungsfehler', 'Die Verbindung zum Server ist fehlgeschlagen.', 'error'); }); } // Load all Contributions from API and display on Map function loadContributions() { apiCall({ action: 'read', municipality_id: MUNICIPALITY.id }, function (data) { if (data.error) { console.error('Load Error:', data.error); return; } contributionsData = data.features || []; // Remove existing Layer if present if (contributionsLayer) { map.removeLayer(contributionsLayer); layerControl.removeLayer(contributionsLayer); } // Create new GeoJSON Layer contributionsLayer = L.geoJSON(data, { pointToLayer: stylePoint, style: styleLinePolygon, onEachFeature: bindFeaturePopup }).addTo(map); layerControl.addOverlay(contributionsLayer, 'Beiträge'); // Update Sidebar List and Statistics updateContributionsList(); updateStatistics(); }); } // ===================================================================== // Block 8: Feature Styling by Category // ===================================================================== // Style for Point Features (CircleMarkers) function stylePoint(feature, latlng) { var cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; return L.circleMarker(latlng, { radius: 8, color: '#ffffff', weight: 2, fillColor: cat.color, fillOpacity: 0.9 }); } // Style for Line and Polygon Features function styleLinePolygon(feature) { var cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; return { color: cat.color, weight: 3, opacity: 0.8, fillColor: cat.color, fillOpacity: 0.25 }; } // ===================================================================== // Block 9: Feature Popups (Read, Vote, Edit, Delete) // ===================================================================== function bindFeaturePopup(feature, layer) { var props = feature.properties; var cat = CATEGORIES[props.category] || CATEGORIES.other; // Format Date var date = new Date(props.created_at); var dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); // Build Popup HTML var html = '' + '
Keine Beiträge gefunden.
'; return; } var html = ''; filtered.forEach(function (f) { var props = f.properties; var cat = CATEGORIES[props.category] || CATEGORIES.other; var date = new Date(props.created_at).toLocaleDateString('de-DE'); html += '' + '' + total + ' Beiträge insgesamt
'; for (var key in CATEGORIES) { var cat = CATEGORIES[key]; var count = counts[key] || 0; if (count > 0) { html += 'Das Bürgerbeteiligungsportal ermöglicht es ' + 'Bürgerinnen und Bürgern sowie der Stadtverwaltung, gemeinsam an der Gestaltung von ' + '' + MUNICIPALITY.name + ' mitzuwirken.
' + 'Tragen Sie Hinweise, Ideen und Verbesserungsvorschläge ' + 'direkt auf der Karte ein.
', confirmButtonColor: MUNICIPALITY.primaryColor }); } // Privacy Modal function showPrivacyModal() { Swal.fire({ title: 'Datenschutz', html: 'Dieses Portal speichert die von Ihnen ' + 'eingegebenen Daten (Name, Beiträge, Bewertungen) zur Durchführung der Bürgerbeteiligung.
' + 'Ihre Daten werden nicht an Dritte weitergegeben. ' + 'Details entnehmen Sie bitte der vollständigen Datenschutzerklärung der Stadt ' + MUNICIPALITY.name + '.
', confirmButtonColor: MUNICIPALITY.primaryColor }); } // Imprint Modal function showImprintModal() { Swal.fire({ title: 'Impressum', html: 'Stadt ' + MUNICIPALITY.name + '
' + 'Die vollständigen Angaben gemäß § 5 TMG ' + 'werden hier ergänzt, sobald das Portal in den Produktivbetrieb geht.
', confirmButtonColor: MUNICIPALITY.primaryColor }); } // ===================================================================== // Block 14: Mobile Navigation // ===================================================================== function toggleMobileNav() { var nav = document.querySelector('.header-nav'); nav.classList.toggle('open'); } // Close Mobile Nav when clicking outside document.addEventListener('click', function (e) { var nav = document.querySelector('.header-nav'); var toggle = document.querySelector('.header-menu-toggle'); if (nav.classList.contains('open') && !nav.contains(e.target) && !toggle.contains(e.target)) { nav.classList.remove('open'); } }); // Close Modals on Escape Key document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { document.getElementById('welcome-modal').style.display = 'none'; document.getElementById('login-modal').style.display = 'none'; document.getElementById('create-modal').style.display = 'none'; } }); // ===================================================================== // Block 15: Utility Functions // ===================================================================== // Escape HTML to prevent XSS in Popups and Lists function escapeHtml(text) { if (!text) return ''; var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } // ===================================================================== // Block 16: Application Startup // ===================================================================== // Initialize Category Filter in Sidebar buildCategoryFilter(); // Load Contributions from API loadContributions(); // Show Welcome Modal on first Visit checkWelcomeModal();