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 = '' + + ''; + + 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(); \ No newline at end of file