diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..6dcb3a5 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,890 @@ +// ===================================================================== +// 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(); \ No newline at end of file