/** * Map Handler for EcoBuddy * Handles map initialization, postcode validation, and facility display */ // initialise map variables let map = null; let markers = []; let circle = null; let facilities = []; let currentPostcode = null; let currentRadius = 10; // Default radius in miles // initialise map on document load document.addEventListener('DOMContentLoaded', function() { // initialise the map centered on UK map = L.map('map', { scrollWheelZoom: true, // Enable scroll wheel zoom zoomControl: true // Show zoom controls }).setView([54.5, -2], 6); // Add OpenStreetMap tiles L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(map); // Get facilities data from sessionStorage facilities = JSON.parse(sessionStorage.getItem('facilityData') || '[]'); // Add location found handler map.on('locationfound', function(e) { try { const { lat, lng } = e.latlng; // Update the map directly with the coordinates updateMapLocation({ lat, lng }, currentRadius); // Remove overlay once we have a valid location const overlay = document.getElementById('mapOverlay'); if (overlay) { overlay.classList.add('hidden'); } // Get postcode from coordinates fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}`) .then(response => response.json()) .then(data => { if (data.status === 200 && data.result && data.result.length > 0) { const postcode = data.result[0].postcode; const postcodeInput = document.getElementById('postcode'); if (postcodeInput) { postcodeInput.value = postcode; } } }) .catch(error => { console.error('Error getting postcode:', error); }); } catch (error) { console.error('Error processing location:', error); alert('Error getting your location: ' + error.message); } }); // Add location error handler map.on('locationerror', function(e) { console.error('Geolocation error:', e); let message = 'Error getting your location: '; switch(e.code) { case 1: // PERMISSION_DENIED message += 'Please enable location access in your browser settings.'; break; case 2: // POSITION_UNAVAILABLE message += 'Location information is unavailable.'; break; case 3: // TIMEOUT message += 'Location request timed out.'; break; default: message += 'An unknown error occurred.'; } alert(message); }); // Set up form handlers setupFormHandlers(); // Set up search handler from header setupHeaderSearchHandler(); }); /** * Set up form handlers for postcode and radius inputs */ function setupFormHandlers() { const postcodeForm = document.getElementById('postcodeForm'); const radiusSelect = document.getElementById('radius'); if (postcodeForm) { // Add geolocation functionality to the search button const searchButton = postcodeForm.querySelector('button[type="submit"]'); if (searchButton) { searchButton.onclick = (e) => { // If the postcode input is empty, use geolocation const postcodeInput = document.getElementById('postcode'); if (!postcodeInput.value.trim()) { e.preventDefault(); map.locate({ setView: false, enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }); } }; } postcodeForm.addEventListener('submit', async function(e) { e.preventDefault(); const postcode = document.getElementById('postcode').value; const radius = parseFloat(document.getElementById('radius').value); // Show loading state const submitButton = this.querySelector('button[type="submit"]'); submitButton.disabled = true; // Validate postcode format first if (!isValidPostcode(postcode)) { submitButton.disabled = false; submitButton.innerHTML = originalButtonContent; alert('Please enter a valid UK postcode'); return; } try { // Get coordinates for postcode const coords = await getPostcodeCoordinates(postcode); if (!coords) { throw new Error('Could not find coordinates for this postcode'); } // Update map with new location and radius updateMapLocation(coords, radius); // Remove overlay once we have a valid postcode const overlay = document.getElementById('mapOverlay'); if (overlay) { overlay.classList.add('hidden'); } // Store current postcode currentPostcode = postcode; currentRadius = radius; } catch (error) { console.error('Error processing postcode:', error); alert(error.message || 'Error processing postcode'); } finally { // Always reset button state submitButton.disabled = false; } }); } if (radiusSelect) { radiusSelect.addEventListener('change', async function(e) { e.preventDefault(); const postcode = document.getElementById('postcode').value; const coords = await getPostcodeCoordinates(postcode); const radius = parseFloat(this.value); updateMapLocation(coords, radius); }); } } /** * Validate UK postcode format * @param {string} postcode - The postcode to validate * @returns {boolean} True if valid, false otherwise */ function isValidPostcode(postcode) { // Basic UK postcode regex const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}$/i; return postcodeRegex.test(postcode.trim()); } /** * Get coordinates for a UK postcode using postcodes.io API * @param {string} postcode - The postcode to geocode * @returns {Promise<{lat: number, lng: number}>} The coordinates */ async function getPostcodeCoordinates(postcode) { try { const response = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(postcode)}`); if (!response.ok) { throw new Error('Postcode not found'); } const data = await response.json(); if (data.status === 200 && data.result) { return { lat: data.result.latitude, lng: data.result.longitude }; } throw new Error('Invalid response from postcode API'); } catch (error) { console.error('Error getting postcode coordinates:', error); throw error; } } /** * Update map location and display facilities within radius * @param {Object} coords - The coordinates to center on (null to use existing) * @param {number} radius - The radius in miles */ function updateMapLocation(coords, radius) { // Clear existing markers and circle clearMapOverlays(); // Get center coordinates (either new or existing) const center = coords || map.getCenter(); // Convert radius from miles to meters (1 mile = 1609.34 meters) const radiusMeters = radius * 1609.34; // Add circle for radius circle = L.circle([center.lat, center.lng], { color: '#198754', fillColor: '#198754', fillOpacity: 0.1, radius: radiusMeters }).addTo(map); // Find facilities within radius const facilitiesInRange = findFacilitiesInRange(center, radius); // Add markers for facilities facilitiesInRange.forEach(facility => { const marker = L.marker([facility.lat, facility.lng]) .bindPopup(createPopupContent(facility)) .addTo(map); markers.push(marker); }); // Fit map bounds to circle map.fitBounds(circle.getBounds()); // Update facility list updateFacilityList(facilitiesInRange); } /** * Clear all markers and circle from map */ function clearMapOverlays() { // Clear markers markers.forEach(marker => marker.remove()); markers = []; // Clear circle if (circle) { circle.remove(); circle = null; } } /** * Find facilities within specified radius of center point * @param {Object} center - The center coordinates * @param {number} radius - The radius in miles * @returns {Array} Array of facilities within range */ function findFacilitiesInRange(center, radius) { return facilities.filter(facility => { const distance = calculateDistance( center.lat, center.lng, parseFloat(facility.lat), parseFloat(facility.lng) ); return distance <= radius; }); } /** * Calculate distance between two points using Haversine formula * @param {number} lat1 - Latitude of first point * @param {number} lon1 - Longitude of first point * @param {number} lat2 - Latitude of second point * @param {number} lon2 - Longitude of second point * @returns {number} Distance in miles */ function calculateDistance(lat1, lon1, lat2, lon2) { const R = 3959; // Earth's radius in miles const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2) * Math.sin(dLon/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; } /** * Convert degrees to radians * @param {number} degrees - Value in degrees * @returns {number} Value in radians */ function toRad(degrees) { return degrees * (Math.PI / 180); } /** * Create popup content for facility marker * @param {Object} facility - The facility data * @returns {string} HTML content for popup */ function createPopupContent(facility) { const isAuthenticated = window.auth && window.auth.isAuthenticated(); return `
${escapeHtml(facility.title)}

${escapeHtml(facility.category)}

${escapeHtml(facility.description)}

Address:
${escapeHtml(formatAddress(facility))}

Added by: ${escapeHtml(facility.contributor)}

${isAuthenticated ? `
` : `
Please login to add comments
`}
`; } /** * Open the comments modal for a facility * @param {string} facilityId - The facility ID */ function openCommentsModal(facilityId) { // Find the facility const facility = facilities.find(f => f.id === parseInt(facilityId)); if (!facility) { console.error('Facility not found:', facilityId); return; } // Get the modal const modal = document.getElementById('statusModal'); if (!modal) { console.error('Status modal not found'); return; } // Set the facility ID on the modal modal.setAttribute('data-facility-id', facilityId); // Set the facility ID in the comment form const facilityIdInput = modal.querySelector('#commentFacilityId'); if (facilityIdInput) { facilityIdInput.value = facilityId; } // Show the modal const modalInstance = new bootstrap.Modal(modal); modalInstance.show(); // Load the comments using CommentsManager CommentsManager.loadFacilityComments(facilityId); } /** * Handle comment form submission * @param {Event} event - The form submit event * @param {number} facilityId - The facility ID * @returns {boolean} False to prevent form submission */ async function handleCommentSubmit(event, facilityId) { event.preventDefault(); // Check authentication if (!window.auth || !window.auth.isAuthenticated()) { alert('You must be logged in to add comments'); return false; } const form = event.target; const textarea = form.querySelector('textarea'); const submitButton = form.querySelector('button[type="submit"]'); const originalButtonContent = submitButton.innerHTML; // Show loading state submitButton.disabled = true; submitButton.innerHTML = ` Adding... `; try { // Add the comment using the API const response = await window.api.addFacilityStatus( facilityId.toString(), // Ensure facilityId is a string textarea.value ); if (response.success) { // Clear the textarea textarea.value = ''; // Show success message const successMessage = document.createElement('div'); successMessage.className = 'alert alert-success mt-2 mb-0 py-2 small'; successMessage.innerHTML = ` Comment added successfully `; form.appendChild(successMessage); // Remove success message after 3 seconds setTimeout(() => { successMessage.remove(); // Open the comments modal to show the new comment openCommentsModal(facilityId); }, 3000); } else { throw new Error(response.error || 'Failed to add comment'); } } catch (error) { console.error('Error adding comment:', error); alert('Error adding comment: ' + error.message); } finally { // Reset button state submitButton.disabled = false; submitButton.innerHTML = originalButtonContent; } return false; } /** * Update facility list display * @param {Array} facilities - Array of facilities to display */ function updateFacilityList(facilities) { const listElement = document.getElementById('facilityList'); if (!listElement) return; listElement.innerHTML = ''; facilities.forEach(facility => { const distance = calculateDistance( circle.getLatLng().lat, circle.getLatLng().lng, parseFloat(facility.lat), parseFloat(facility.lng) ); const item = document.createElement('div'); item.className = 'list-group-item list-group-item-action'; item.innerHTML = `
${escapeHtml(facility.title)}

${escapeHtml(facility.category)}

${distance.toFixed(1)} miles away
`; listElement.appendChild(item); }); } /** * Set up header search handler */ function setupHeaderSearchHandler() { const searchInput = document.querySelector('input#searchInput'); const filterCat = document.querySelector('select#filterCat'); if (searchInput && filterCat) { const handleSearch = () => { const searchTerm = searchInput.value.toLowerCase(); const filterCategory = filterCat.value; if (!currentPostcode) return; // Only filter if map is active // Get all facilities in current radius const center = circle ? circle.getLatLng() : null; if (!center) return; const facilitiesInRange = findFacilitiesInRange(center, currentRadius); // Filter facilities based on search term and category const filteredFacilities = facilitiesInRange.filter(facility => { if (!facility) return false; if (filterCategory && searchTerm) { let searchValue = ''; switch(filterCategory) { case 'title': searchValue = (facility.title || '').toLowerCase(); break; case 'category': searchValue = (facility.category || '').toLowerCase(); break; case 'description': searchValue = (facility.description || '').toLowerCase(); break; case 'streetName': searchValue = (facility.streetName || '').toLowerCase(); break; case 'county': searchValue = (facility.county || '').toLowerCase(); break; case 'town': searchValue = (facility.town || '').toLowerCase(); break; case 'postcode': searchValue = (facility.postcode || '').toLowerCase(); break; case 'contributor': searchValue = (facility.contributor || '').toLowerCase(); break; } return searchValue.includes(searchTerm); } return true; }); // Update markers and list updateMapMarkers(filteredFacilities); updateFacilityList(filteredFacilities); }; searchInput.addEventListener('input', handleSearch); filterCat.addEventListener('change', handleSearch); } } /** * Update map markers without changing the circle or center * @param {Array} facilities - Facilities to show on map */ function updateMapMarkers(facilities) { // Clear existing markers markers.forEach(marker => marker.remove()); markers = []; // Add new markers facilities.forEach(facility => { const marker = L.marker([facility.lat, facility.lng]) .bindPopup(createPopupContent(facility)) .addTo(map); markers.push(marker); }); } /** * Show facility details in a popover * @param {string} facilityId - The facility ID */ function showFacilityDetails(facilityId) { const facility = facilities.find(f => f.id === parseInt(facilityId)); if (!facility) return; // Create popover content const content = `
${escapeHtml(facility.title)}

${escapeHtml(facility.category)}

${escapeHtml(facility.description)}

Address:
${escapeHtml(formatAddress(facility))}

Added by: ${escapeHtml(facility.contributor)}

`; // Find the marker for this facility const marker = markers.find(m => m.getLatLng().lat === parseFloat(facility.lat) && m.getLatLng().lng === parseFloat(facility.lng) ); if (marker) { marker.bindPopup(content, { maxWidth: 300, className: 'facility-popup' }).openPopup(); } } /** * Format facility address * @param {Object} facility - The facility data * @returns {string} Formatted address */ function formatAddress(facility) { const parts = [ facility.houseNumber, facility.streetName, facility.town, facility.county, facility.postcode ].filter(Boolean); return parts.join(', '); } /** * Get color class for facility category * @param {string} category - The facility category * @returns {string} Bootstrap color class */ function getCategoryColorClass(category) { const categoryLower = (category || '').toLowerCase(); if (categoryLower.includes('recycling')) return 'success'; if (categoryLower.includes('green') || categoryLower.includes('roof')) return 'success'; if (categoryLower.includes('solar') || categoryLower.includes('power')) return 'warning'; if (categoryLower.includes('water') || categoryLower.includes('rain')) return 'info'; if (categoryLower.includes('battery')) return 'danger'; if (categoryLower.includes('bench')) return 'primary'; return 'secondary'; } /** * Escape HTML to prevent XSS * @param {string} unsafe - Unsafe string * @returns {string} Escaped string */ function escapeHtml(unsafe) { if (unsafe === null || unsafe === undefined) { return ''; } return unsafe .toString() .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }