// ===================================================================== // WebGIS Citizen Participation Portal — Application Logic // Initializes Leaflet Map, loads Contributions from the API, // handles CRUD Workflow, and manages all UI Interactions. // // Depends on: MUNICIPALITY Object set in Citizen Portal // ===================================================================== // ===================================================================== // Block 1: Configuration and Application State // ===================================================================== // API Endpoint as relative Path const API_URL = 'api/contributions.php'; // Username set via Login Modal stored in sessionStorage let currentUser = sessionStorage.getItem('webgis_user') || decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)webgis_user\s*=\s*([^;]*).*$)|^.*$/, '$1')) || ''; // Browser Identification Number for anonymous User Identification stored as Cookie let browserId = getBrowserId(); function getBrowserId() { let id = document.cookie.replace(/(?:(?:^|.*;\s*)webgis_browser_id\s*=\s*([^;]*).*$)|^.*$/, '$1'); if (!id) { id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); // Cookie Expiration in one Year document.cookie = 'webgis_browser_id=' + id + ';path=/;max-age=31536000;SameSite=Lax'; } return id; } // Application State let map; // Leaflet Map Instance let sidebar; // Sidebar Instance let contributionsLayer; // GeoJSON Layer holding all Contributions let contributionsData = []; // Raw Contribution Data Array let activeFilters = Object.keys(CATEGORIES); // Active Category Filters let drawnGeometry = null; // Temporary Storage for Geometry drawn with Geoman let drawnGeomType = null; // Temporary Storage for Geometry Type let userVotes = {}; // Tracks User Votes // ===================================================================== // Block 2: Map Initialization // ===================================================================== map = L.map('map', { center: MUNICIPALITY.center, zoom: MUNICIPALITY.zoom, minZoom: 10, maxBounds: [ [MUNICIPALITY.center[0] - 0.25, MUNICIPALITY.center[1] - 0.25], [MUNICIPALITY.center[0] + 0.25, MUNICIPALITY.center[1] + 0.25] ], maxBoundsViscosity: 0.8, zoomControl: false, attributionControl: true }); // ===================================================================== // Block 3: Basemaps and Layer Control // ===================================================================== // Basemap Tile Layers const basemapOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 20 }); const basemapCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© Carto', maxZoom: 20 }); const basemapSatellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri', maxZoom: 20 }); // Set Default Basemap basemapCartoDB.addTo(map); // Layer Control const basemaps = { ' Hintergrundkarte (farbe)': basemapOSM, ' Hintergrundkarte (grau)': basemapCartoDB, ' Satellitenbild': basemapSatellite, }; const overlays = {}; // Populated later with Contribution Layers const layerControl = L.control.layers(basemaps, overlays, { position: 'topright', collapsed: true }).addTo(map); // ===================================================================== // Block 4: Map Controls // ===================================================================== // Zoom Control L.control.zoom({ position: 'topright' }).addTo(map); // Scale Bar L.control.scale({ position: 'bottomright', maxWidth: 200, imperial: false }).addTo(map); // Fullscreen Button L.control.fullscreen({ position: 'topright', title: 'Vollbild', titleCancel: 'Vollbild deaktivieren' }).addTo(map); // Geocoder Address Search L.Control.geocoder({ position: 'topright', placeholder: 'Adresse suchen...', defaultMarkGeocode: true, geocoder: L.Control.Geocoder.nominatim({ geocodingQueryParams: { countrycodes: 'de', viewbox: (MUNICIPALITY.center[1] - 0.3) + ',' + (MUNICIPALITY.center[0] - 0.2) + ',' + (MUNICIPALITY.center[1] + 0.3) + ',' + (MUNICIPALITY.center[0] + 0.2), bounded: 0, } }) }).addTo(map); // Polyline Measure Tool // L.control.polylineMeasure({ // position: 'topright', // unit: 'metres', // showBearings: false, // clearMeasurementsOnStop: false, // showClearControl: true // }).addTo(map); // Mouse Position Display // const MousePositionControl = L.Control.extend({ // options: { position: 'bottomright' }, // onAdd: function () { // const container = L.DomUtil.create('div', 'mouse-position-display'); // 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 const GpsControl = L.Control.extend({ options: { position: 'topright' }, onAdd: function () { const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); const button = L.DomUtil.create('a', 'gps-control-button', container); button.href = '#'; button.title = 'Mein Standort'; button.innerHTML = ''; 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 let 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 gestatten 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 }); map.pm.setLang('de'); // Captures drawn Geometry and opens the Create Modal map.on('pm:create', function (e) { const geojson = e.layer.toGeoJSON().geometry; // Determines drawn Geometry Type and normalizes 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 { // removes unsupported Objects map.removeLayer(e.layer); return; } // Removes the drawn Layer, which will be re-added after Moderation Confirmation map.removeLayer(e.layer); // Checks if User is logged in if (!currentUser) { showLoginModal(); return; } // Populates hidden Fields and opens 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) { const formData = new FormData(); for (const 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', 'Verbindung zum Server fehlgeschlagen.', 'error'); }); } // Loads all Contributions from API and displays Contributions on Map function loadContributions() { const readParams = { action: 'read', municipality_id: MUNICIPALITY.id }; // Sends Browser ID for persistent Vote Display readParams.browser_id = browserId; apiCall(readParams, function (data) { if (data.error) { console.error('Load Error:', data.error); return; } contributionsData = data.features || []; // Restores Vote Highlights from API Response if (data.user_votes) { userVotes = {}; for (const key in data.user_votes) { userVotes[key] = data.user_votes[key]; } } // Removes existing Layer if present if (contributionsLayer) { map.removeLayer(contributionsLayer); layerControl.removeLayer(contributionsLayer); } // Creates 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(); buildCategoryFilter(); }); } // ===================================================================== // Block 8: Feature Styling by Category // ===================================================================== // Style for Point Features (CircleMarkers) function stylePoint(feature, latlng) { const cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; return L.circleMarker(latlng, { radius: 8, color: cat.color, weight: 3, fillColor: cat.color, fillOpacity: 0.25, opacity: 0.8 }); } // Style for Line and Polygon Features function styleLinePolygon(feature) { const 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 for Read, Edit, Delete and Vote // ===================================================================== // Builds Popup HTML for Features called every Time the Popup opens function buildPopupHtml(feature) { const props = feature.properties; const cat = CATEGORIES[props.category] || CATEGORIES.other; // Formats Date const date = new Date(props.created_at); const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); let html = '' + '
Keine Beiträge gefunden.
'; return; } let html = ''; filtered.forEach(function (f) { const props = f.properties; const cat = CATEGORIES[props.category] || CATEGORIES.other; const date = new Date(props.created_at).toLocaleDateString('de-DE'); html += '' + '' + total + ' Beiträge insgesamt
'; container.innerHTML = html; } // Toggles a Category Filter on or off function toggleCategoryFilter(checkbox) { const category = checkbox.value; if (checkbox.checked) { if (activeFilters.indexOf(category) === -1) { activeFilters.push(category); } } else { activeFilters = activeFilters.filter(function (c) { return c !== category; }); } // Refilters Map Layer if (contributionsLayer) { contributionsLayer.eachLayer(function (layer) { if (layer.feature) { const cat = layer.feature.properties.category; if (activeFilters.indexOf(cat) !== -1) { const catDef = CATEGORIES[cat] || CATEGORIES.other; layer.setStyle({ color: catDef.color, weight: 3, opacity: 0.8, fillColor: catDef.color, fillOpacity: 0.25 }); if (layer.setRadius) layer.setRadius(8); layer.options.interactive = true; } else { layer.setStyle({ opacity: 0, fillOpacity: 0 }); if (layer.setRadius) layer.setRadius(0); layer.options.interactive = false; layer.closePopup(); layer.closeTooltip(); } } }); } // Updates List updateContributionsList(); } // ===================================================================== // Block 13: Modals — Welcome, Login, Info, Privacy, Imprint // ===================================================================== // Welcome Modal shows on new Visits function checkWelcomeModal() { const 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 shows new Session function showLoginModal() { document.getElementById('login-modal').style.display = 'flex'; document.getElementById('user-name-input').value = currentUser; document.getElementById('user-name-input').focus(); } function submitLogin() { const 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.cookie = 'webgis_user=' + encodeURIComponent(name) + ';path=/;max-age=31536000;SameSite=Lax'; document.getElementById('login-modal').style.display = 'none'; // Open Create Modal if Geometry is pending if (drawnGeometry) { document.getElementById('create-geom').value = JSON.stringify(drawnGeometry); document.getElementById('create-geom-type').value = drawnGeomType; document.getElementById('create-modal').style.display = 'flex'; } } function skipLogin() { document.getElementById('login-modal').style.display = 'none'; } // Info Modal function showInfoModal() { Swal.fire({ title: 'Informationen', html: 'Das Bürgerbeteiligungsportal gestattet ' + 'Bürgerinnen und Bürgern sowie der Stadtverwaltung, gemeinsam an der Gestaltung von ' + '' + MUNICIPALITY.name + ' mitzuwirken.
' + 'Bitte tragen Sie Hinweise, Anregungen und Vorschläge ' + 'mithilfe der Zeichenwerkzeuge auf der Karte ein.
', showDenyButton: true, confirmButtonText: 'Schließen', denyButtonText: ' Tutorial starten', confirmButtonColor: MUNICIPALITY.primaryColor, denyButtonColor: '#546E7A' }).then(function (result) { if (result.isDenied && typeof restartOnboarding === 'function') { restartOnboarding(); } }); } // ===================================================================== // Block 14: Mobile Navigation // ===================================================================== function toggleMobileNav() { const nav = document.querySelector('.header-nav'); nav.classList.toggle('open'); } // Closes Mobile Nav when clicking outside document.addEventListener('click', function (e) { const nav = document.querySelector('.header-nav'); const toggle = document.querySelector('.header-menu-toggle'); if (nav.classList.contains('open') && !nav.contains(e.target) && !toggle.contains(e.target)) { nav.classList.remove('open'); } }); // Closes 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 // ===================================================================== // Escapes HTML to prevent Cross-Site Scripting (XSS) in Popups and Lists function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } // Returns a colored Font Awesome Icon HTML String for a Category function categoryIcon(cat) { return ''; } // Reverse Geocodes Coordinates and saves Address to Contribution via API function reverseGeocode(contributionId, lat, lng) { fetch('https://nominatim.openstreetmap.org/reverse?format=json&lat=' + lat + '&lon=' + lng + '&zoom=18&addressdetails=1', { headers: { 'Accept-Language': 'de' } }) .then(function (r) { return r.json(); }) .then(function (data) { if (data.display_name) { const addr = data.address || {}; const parts = []; if (addr.road) parts.push(addr.road + (addr.house_number ? ' ' + addr.house_number : '')); if (addr.city || addr.town || addr.village) parts.push(addr.city || addr.town || addr.village); const shortAddress = parts.length > 0 ? parts.join(', ') : data.display_name.split(',').slice(0, 2).join(','); // Saves Address to Database via API apiCall({ action: 'update', contribution_id: contributionId, address: shortAddress }, function () {}); } }) .catch(function () {}); } // Filters News Items in Sidebar by Search Term function filterNews() { const searchTerm = document.getElementById('news-search-input').value.toLowerCase(); const newsItems = document.querySelectorAll('#news-list .news-item'); newsItems.forEach(function (item) { const title = item.dataset.title || ''; const content = item.dataset.content || ''; const author = item.dataset.author || ''; // Shows Item if Search Term matches Title, Content or Author if (!searchTerm || title.indexOf(searchTerm) !== -1 || content.indexOf(searchTerm) !== -1 || author.indexOf(searchTerm) !== -1) { item.style.display = ''; } else { item.style.display = 'none'; } }); } // Loads and Displays Comments forContributions in Popups function loadComments(contributionId) { apiCall({ action: 'read_comments', contribution_id: contributionId }, function (response) { const listContainer = document.getElementById('comments-list-' + contributionId); const countSpan = document.getElementById('comment-count-' + contributionId); if (!listContainer) return; if (response.error || !response.comments || response.comments.length === 0) { listContainer.innerHTML = '