// ===================================================================== // 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 = '' + ''; layer.bindPopup(html, { maxWidth: 320, minWidth: 240 }); // Tooltip on Hover layer.bindTooltip(cat.icon + ' ' + escapeHtml(props.title), { direction: 'top', offset: [0, -10] }); } // ===================================================================== // Block 10: CRUD Operations // ===================================================================== // CREATE — Submit new Contribution from Modal function submitCreate() { var category = document.getElementById('create-category').value; var title = document.getElementById('create-title').value.trim(); var description = document.getElementById('create-description').value.trim(); var geom = document.getElementById('create-geom').value; var geomType = document.getElementById('create-geom-type').value; // Validate if (!category) { Swal.fire('Kategorie fehlt', 'Bitte wählen Sie eine Kategorie aus.', 'warning'); return; } if (!title) { Swal.fire('Titel fehlt', 'Bitte geben Sie einen Titel ein.', 'warning'); return; } if (!geom) { Swal.fire('Geometrie fehlt', 'Bitte zeichnen Sie zuerst ein Objekt auf der Karte.', 'warning'); return; } apiCall({ action: 'create', municipality_id: MUNICIPALITY.id, category: category, title: title, description: description, geom: geom, geom_type: geomType, author_name: currentUser }, function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } Swal.fire('Eingereicht!', 'Ihr Beitrag wurde erfolgreich eingereicht und wird nach Prüfung veröffentlicht.', 'success'); closeCreateModal(); loadContributions(); }); } // Cancel Create — close Modal and clear Form function cancelCreate() { closeCreateModal(); } function closeCreateModal() { document.getElementById('create-modal').style.display = 'none'; document.getElementById('create-category').value = ''; document.getElementById('create-title').value = ''; document.getElementById('create-description').value = ''; document.getElementById('create-geom').value = ''; document.getElementById('create-geom-type').value = ''; drawnGeometry = null; drawnGeomType = null; } // UPDATE — Edit an existing Contribution function editContribution(contributionId) { // Find Contribution in local Data var contribution = contributionsData.find(function (f) { return f.properties.contribution_id === contributionId; }); if (!contribution) return; var props = contribution.properties; Swal.fire({ title: 'Beitrag bearbeiten', html: '
' + '' + '' + '' + '' + '
', showCancelButton: true, confirmButtonText: 'Speichern', cancelButtonText: 'Abbrechen', confirmButtonColor: MUNICIPALITY.primaryColor, preConfirm: function () { return { title: document.getElementById('swal-title').value.trim(), description: document.getElementById('swal-description').value.trim() }; } }).then(function (result) { if (!result.isConfirmed) return; apiCall({ action: 'update', contribution_id: contributionId, title: result.value.title, description: result.value.description }, function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } Swal.fire('Gespeichert!', 'Der Beitrag wurde aktualisiert.', 'success'); loadContributions(); }); }); } // DELETE — Delete a Contribution function deleteContribution(contributionId) { Swal.fire({ title: 'Beitrag löschen?', text: 'Diese Aktion kann nicht rückgängig gemacht werden.', icon: 'warning', showCancelButton: true, confirmButtonText: 'Löschen', cancelButtonText: 'Abbrechen', confirmButtonColor: '#c62828' }).then(function (result) { if (!result.isConfirmed) return; apiCall({ action: 'delete', contribution_id: contributionId }, function (response) { if (response.error) { Swal.fire('Fehler', response.error, 'error'); return; } Swal.fire('Gelöscht!', 'Der Beitrag wurde entfernt.', 'success'); map.closePopup(); loadContributions(); }); }); } // VOTE — Like or Dislike a Contribution function voteContribution(contributionId, voteType) { if (!currentUser) { Swal.fire('Bitte anmelden', 'Sie müssen sich anmelden, um abzustimmen.', 'info'); showLoginModal(); return; } apiCall({ action: 'vote', contribution_id: contributionId, voter_name: currentUser, vote_type: voteType }, function (response) { if (response.error) { Swal.fire('Hinweis', response.error, 'info'); return; } // Update Vote Counts in the Popup without reloading everything loadContributions(); }); } // ===================================================================== // Block 11: Sidebar — Contributions List // ===================================================================== function updateContributionsList() { var container = document.getElementById('contributions-list'); var searchInput = document.getElementById('list-search-input'); var searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; // Filter by active Categories and Search Term var filtered = contributionsData.filter(function (f) { var props = f.properties; var matchesCategory = activeFilters.indexOf(props.category) !== -1; var matchesSearch = !searchTerm || props.title.toLowerCase().indexOf(searchTerm) !== -1 || (props.description && props.description.toLowerCase().indexOf(searchTerm) !== -1) || props.author_name.toLowerCase().indexOf(searchTerm) !== -1; return matchesCategory && matchesSearch; }); // Sort by Date (newest first) filtered.sort(function (a, b) { return new Date(b.properties.created_at) - new Date(a.properties.created_at); }); // Build HTML if (filtered.length === 0) { container.innerHTML = '

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 += '' + '
' + '
' + '' + cat.icon + ' ' + cat.label + '' + '
' + '
' + escapeHtml(props.title) + '
' + '
' + '' + escapeHtml(props.author_name) + ' · ' + date + '' + '' + ' ' + props.likes_count + '' + ' ' + props.dislikes_count + '' + '' + '
' + '
'; }); container.innerHTML = html; } // Fly to a Contribution on the Map and open its Popup function flyToContribution(contributionId) { if (!contributionsLayer) return; contributionsLayer.eachLayer(function (layer) { if (layer.feature && layer.feature.properties.contribution_id === contributionId) { // Zoom to Feature if (layer.getLatLng) { // Point Feature map.flyTo(layer.getLatLng(), 17); } else if (layer.getBounds) { // Line or Polygon Feature map.flyToBounds(layer.getBounds(), { maxZoom: 17 }); } // Open Popup layer.openPopup(); // Close Sidebar on Mobile if (window.innerWidth < 769) { sidebar.close(); } } }); } // Search Input Event Listener document.getElementById('list-search-input').addEventListener('input', function () { updateContributionsList(); }); // ===================================================================== // Block 12: Sidebar — Category Filter and Statistics // ===================================================================== // Build Category Filter Checkboxes function buildCategoryFilter() { var container = document.getElementById('category-filter'); var html = ''; for (var key in CATEGORIES) { var cat = CATEGORIES[key]; var checked = activeFilters.indexOf(key) !== -1 ? 'checked' : ''; html += '' + ''; } container.innerHTML = html; } // Toggle a Category Filter on/off function toggleCategoryFilter(checkbox) { var category = checkbox.value; if (checkbox.checked) { if (activeFilters.indexOf(category) === -1) { activeFilters.push(category); } } else { activeFilters = activeFilters.filter(function (c) { return c !== category; }); } // Re-filter the Map Layer if (contributionsLayer) { contributionsLayer.eachLayer(function (layer) { if (layer.feature) { var cat = layer.feature.properties.category; if (activeFilters.indexOf(cat) !== -1) { layer.setStyle({ opacity: 1, fillOpacity: layer.feature.geometry.type === 'Point' ? 0.9 : 0.25 }); if (layer.setRadius) layer.setRadius(8); } else { layer.setStyle({ opacity: 0, fillOpacity: 0 }); if (layer.setRadius) layer.setRadius(0); } } }); } // Update List updateContributionsList(); } // Update Statistics in Home Tab function updateStatistics() { var container = document.getElementById('stats-container'); var total = contributionsData.length; // Count per Category var counts = {}; contributionsData.forEach(function (f) { var cat = f.properties.category; counts[cat] = (counts[cat] || 0) + 1; }); var html = '

' + total + ' Beiträge insgesamt

'; for (var key in CATEGORIES) { var cat = CATEGORIES[key]; var count = counts[key] || 0; if (count > 0) { html += '
' + '' + cat.label + ': ' + count + '
'; } } container.innerHTML = html; } // ===================================================================== // Block 13: Modals — Welcome, Login, Info, Privacy, Imprint // ===================================================================== // Welcome Modal — show on first Visit function checkWelcomeModal() { var hasVisited = localStorage.getItem('webgis_welcomed'); if (!hasVisited) { document.getElementById('welcome-modal').style.display = 'flex'; } } function closeWelcomeAndShowLogin() { localStorage.setItem('webgis_welcomed', 'true'); document.getElementById('welcome-modal').style.display = 'none'; showLoginModal(); } // Login Modal function showLoginModal() { document.getElementById('login-modal').style.display = 'flex'; document.getElementById('user-name-input').value = currentUser; document.getElementById('user-name-input').focus(); } function submitLogin() { var name = document.getElementById('user-name-input').value.trim(); if (!name) { Swal.fire('Name eingeben', 'Bitte geben Sie Ihren Namen ein.', 'warning'); return; } currentUser = name; sessionStorage.setItem('webgis_user', currentUser); document.getElementById('login-modal').style.display = 'none'; } function skipLogin() { document.getElementById('login-modal').style.display = 'none'; } // Info Modal function showInfoModal() { Swal.fire({ title: 'Über das Portal', 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();