634
public/js/mapHandler.js
Normal file
634
public/js/mapHandler.js
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* 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') || '[]');
|
||||
|
||||
// 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) {
|
||||
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"]');
|
||||
const originalButtonContent = `<i class="bi bi-search me-1"></i>Search...`;
|
||||
submitButton.disabled = true;
|
||||
submitButton.innerHTML = `
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="ms-2">Searching...</span>
|
||||
`;
|
||||
|
||||
// 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;
|
||||
submitButton.innerHTML = originalButtonContent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (radiusSelect) {
|
||||
radiusSelect.addEventListener('change', function() {
|
||||
const radius = parseFloat(this.value);
|
||||
if (currentPostcode) {
|
||||
updateMapLocation(null, radius); // null coords means use existing center
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.simpleAuth && window.simpleAuth.isAuthenticated();
|
||||
|
||||
return `
|
||||
<div class="facility-popup">
|
||||
<h6 class="mb-1">${escapeHtml(facility.title)}</h6>
|
||||
<p class="mb-1 small">
|
||||
<span class="badge bg-${getCategoryColorClass(facility.category)} bg-opacity-10 text-${getCategoryColorClass(facility.category)}">
|
||||
${escapeHtml(facility.category)}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mb-2 small">${escapeHtml(facility.description)}</p>
|
||||
<p class="mb-2 small">
|
||||
<strong>Address:</strong><br>
|
||||
${escapeHtml(formatAddress(facility))}
|
||||
</p>
|
||||
<p class="mb-0 small">
|
||||
<strong>Added by:</strong> ${escapeHtml(facility.contributor)}
|
||||
</p>
|
||||
|
||||
${isAuthenticated ? `
|
||||
<div class="comment-form">
|
||||
<form onsubmit="return handleCommentSubmit(event, ${facility.id})">
|
||||
<div class="mb-2">
|
||||
<textarea class="form-control form-control-sm"
|
||||
placeholder="Add a comment..."
|
||||
required
|
||||
rows="2"></textarea>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button type="submit" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-chat-dots me-1"></i>Add Comment
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openCommentsModal('${facility.id}')">
|
||||
<i class="bi bi-info-circle me-1"></i>View All Comments
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
` : `
|
||||
<div class="comment-form">
|
||||
<div class="alert alert-light mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Please <a href="#" data-bs-toggle="modal" data-bs-target="#loginModal">login</a> to add comments
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.simpleAuth || !window.simpleAuth.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 = `
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="ms-2">Adding...</span>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
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 = `
|
||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">${escapeHtml(facility.title)}</h6>
|
||||
<p class="mb-1 small">
|
||||
<span class="badge bg-${getCategoryColorClass(facility.category)} bg-opacity-10 text-${getCategoryColorClass(facility.category)}">
|
||||
${escapeHtml(facility.category)}
|
||||
</span>
|
||||
</p>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-geo-alt me-1"></i>${distance.toFixed(1)} miles away
|
||||
</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="showFacilityDetails('${facility.id}')">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="facility-details p-2">
|
||||
<h6 class="mb-2">${escapeHtml(facility.title)}</h6>
|
||||
<p class="mb-2">
|
||||
<span class="badge bg-${getCategoryColorClass(facility.category)} bg-opacity-10 text-${getCategoryColorClass(facility.category)}">
|
||||
${escapeHtml(facility.category)}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mb-2 small">${escapeHtml(facility.description)}</p>
|
||||
<p class="mb-2 small">
|
||||
<strong>Address:</strong><br>
|
||||
${escapeHtml(formatAddress(facility))}
|
||||
</p>
|
||||
<p class="mb-0 small">
|
||||
<strong>Added by:</strong> ${escapeHtml(facility.contributor)}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
Reference in New Issue
Block a user