/** * ThrillWiki Geolocation - User Location and "Near Me" Functionality * * This module handles browser geolocation API integration with privacy-conscious * permission handling, distance calculations, and "near me" functionality */ class UserLocation { constructor(options = {}) { this.options = { enableHighAccuracy: true, timeout: 10000, maximumAge: 300000, // 5 minutes watchPosition: false, autoShowOnMap: true, showAccuracyCircle: true, enableCaching: true, cacheKey: 'thrillwiki_user_location', apiEndpoints: { nearby: '/api/map/nearby/', distance: '/api/map/distance/' }, defaultRadius: 50, // miles maxRadius: 500, ...options }; this.currentPosition = null; this.watchId = null; this.mapInstance = null; this.locationMarker = null; this.accuracyCircle = null; this.permissionState = 'unknown'; this.lastLocationTime = null; // Event handlers this.eventHandlers = { locationFound: [], locationError: [], permissionChanged: [] }; this.init(); } /** * Initialize the geolocation component */ init() { this.checkGeolocationSupport(); this.loadCachedLocation(); this.setupLocationButtons(); this.checkPermissionState(); } /** * Check if geolocation is supported by the browser */ checkGeolocationSupport() { if (!navigator.geolocation) { console.warn('Geolocation is not supported by this browser'); this.hideLocationButtons(); return false; } return true; } /** * Setup location-related buttons and controls */ setupLocationButtons() { // Find all "locate me" buttons const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn'); locateButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); this.requestLocation(); }); }); // Find "near me" buttons const nearMeButtons = document.querySelectorAll('[data-action="near-me"], .near-me-btn'); nearMeButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); this.showNearbyLocations(); }); }); // Distance calculator buttons const distanceButtons = document.querySelectorAll('[data-action="calculate-distance"]'); distanceButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); const targetLat = parseFloat(button.dataset.lat); const targetLng = parseFloat(button.dataset.lng); this.calculateDistance(targetLat, targetLng); }); }); } /** * Hide location buttons when geolocation is not supported */ hideLocationButtons() { const locationElements = document.querySelectorAll('.geolocation-feature'); locationElements.forEach(el => { el.style.display = 'none'; }); } /** * Check current permission state */ async checkPermissionState() { if ('permissions' in navigator) { try { const permission = await navigator.permissions.query({ name: 'geolocation' }); this.permissionState = permission.state; this.updateLocationButtonStates(); // Listen for permission changes permission.addEventListener('change', () => { this.permissionState = permission.state; this.updateLocationButtonStates(); this.triggerEvent('permissionChanged', this.permissionState); }); } catch (error) { console.warn('Could not check geolocation permission:', error); } } } /** * Update location button states based on permission */ updateLocationButtonStates() { const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn'); locateButtons.forEach(button => { const icon = button.querySelector('i') || button; switch (this.permissionState) { case 'granted': button.disabled = false; button.title = 'Find my location'; icon.className = 'fas fa-crosshairs'; break; case 'denied': button.disabled = true; button.title = 'Location access denied'; icon.className = 'fas fa-times-circle'; break; case 'prompt': default: button.disabled = false; button.title = 'Find my location (permission required)'; icon.className = 'fas fa-crosshairs'; break; } }); } /** * Request user location */ requestLocation(options = {}) { if (!navigator.geolocation) { this.handleLocationError(new Error('Geolocation not supported')); return; } const requestOptions = { ...this.options, ...options }; // Show loading state this.setLocationButtonLoading(true); navigator.geolocation.getCurrentPosition( (position) => this.handleLocationSuccess(position), (error) => this.handleLocationError(error), { enableHighAccuracy: requestOptions.enableHighAccuracy, timeout: requestOptions.timeout, maximumAge: requestOptions.maximumAge } ); } /** * Start watching user position */ startWatching() { if (!navigator.geolocation || this.watchId) return; this.watchId = navigator.geolocation.watchPosition( (position) => this.handleLocationSuccess(position), (error) => this.handleLocationError(error), { enableHighAccuracy: this.options.enableHighAccuracy, timeout: this.options.timeout, maximumAge: this.options.maximumAge } ); } /** * Stop watching user position */ stopWatching() { if (this.watchId) { navigator.geolocation.clearWatch(this.watchId); this.watchId = null; } } /** * Handle successful location acquisition */ handleLocationSuccess(position) { this.currentPosition = { lat: position.coords.latitude, lng: position.coords.longitude, accuracy: position.coords.accuracy, timestamp: position.timestamp }; this.lastLocationTime = Date.now(); // Cache location if (this.options.enableCaching) { this.cacheLocation(this.currentPosition); } // Show on map if enabled if (this.options.autoShowOnMap && this.mapInstance) { this.showLocationOnMap(); } // Update button states this.setLocationButtonLoading(false); this.updateLocationButtonStates(); // Trigger event this.triggerEvent('locationFound', this.currentPosition); console.log('Location found:', this.currentPosition); } /** * Handle location errors */ handleLocationError(error) { this.setLocationButtonLoading(false); let message = 'Unable to get your location'; switch (error.code) { case error.PERMISSION_DENIED: message = 'Location access denied. Please enable location services.'; this.permissionState = 'denied'; break; case error.POSITION_UNAVAILABLE: message = 'Location information is unavailable.'; break; case error.TIMEOUT: message = 'Location request timed out.'; break; default: message = 'An unknown error occurred while retrieving location.'; break; } this.showLocationMessage(message, 'error'); this.updateLocationButtonStates(); // Trigger event this.triggerEvent('locationError', { error, message }); console.error('Location error:', error); } /** * Show user location on map */ showLocationOnMap() { if (!this.mapInstance || !this.currentPosition) return; const { lat, lng, accuracy } = this.currentPosition; // Remove existing location marker and circle this.clearLocationDisplay(); // Add location marker this.locationMarker = L.marker([lat, lng], { icon: this.createUserLocationIcon() }).addTo(this.mapInstance); this.locationMarker.bindPopup(`

Your Location

Accuracy: ±${Math.round(accuracy)}m

`); // Add accuracy circle if enabled and accuracy is reasonable if (this.options.showAccuracyCircle && accuracy < 1000) { this.accuracyCircle = L.circle([lat, lng], { radius: accuracy, fillColor: '#3388ff', fillOpacity: 0.2, color: '#3388ff', weight: 2, opacity: 0.5 }).addTo(this.mapInstance); } // Center map on user location this.mapInstance.setView([lat, lng], 13); } /** * Create custom icon for user location */ createUserLocationIcon() { return L.divIcon({ className: 'user-location-marker', html: `
`, iconSize: [24, 24], iconAnchor: [12, 12] }); } /** * Clear location display from map */ clearLocationDisplay() { if (this.locationMarker && this.mapInstance) { this.mapInstance.removeLayer(this.locationMarker); this.locationMarker = null; } if (this.accuracyCircle && this.mapInstance) { this.mapInstance.removeLayer(this.accuracyCircle); this.accuracyCircle = null; } } /** * Show nearby locations */ async showNearbyLocations(radius = null) { if (!this.currentPosition) { this.requestLocation(); return; } try { const searchRadius = radius || this.options.defaultRadius; const { lat, lng } = this.currentPosition; const params = new URLSearchParams({ lat: lat, lng: lng, radius: searchRadius, unit: 'miles' }); const response = await fetch(`${this.options.apiEndpoints.nearby}?${params}`); const data = await response.json(); if (data.status === 'success') { this.displayNearbyResults(data.data); } else { this.showLocationMessage('No nearby locations found', 'info'); } } catch (error) { console.error('Failed to find nearby locations:', error); this.showLocationMessage('Failed to find nearby locations', 'error'); } } /** * Display nearby search results */ displayNearbyResults(results) { // Find or create results container let resultsContainer = document.getElementById('nearby-results'); if (!resultsContainer) { resultsContainer = document.createElement('div'); resultsContainer.id = 'nearby-results'; resultsContainer.className = 'nearby-results-container'; // Try to insert after a logical element const mapContainer = document.getElementById('map-container'); if (mapContainer && mapContainer.parentNode) { mapContainer.parentNode.insertBefore(resultsContainer, mapContainer.nextSibling); } else { document.body.appendChild(resultsContainer); } } const html = `

Nearby Parks (${results.length} found)

${results.map(location => `

${location.name}

${location.formatted_location || ''}

${location.distance} away

`).join('')}
`; resultsContainer.innerHTML = html; // Scroll to results resultsContainer.scrollIntoView({ behavior: 'smooth' }); } /** * Calculate distance to a specific location */ async calculateDistance(targetLat, targetLng) { if (!this.currentPosition) { this.showLocationMessage('Please enable location services first', 'warning'); return null; } try { const { lat, lng } = this.currentPosition; const params = new URLSearchParams({ from_lat: lat, from_lng: lng, to_lat: targetLat, to_lng: targetLng }); const response = await fetch(`${this.options.apiEndpoints.distance}?${params}`); const data = await response.json(); if (data.status === 'success') { return data.data; } } catch (error) { console.error('Failed to calculate distance:', error); } // Fallback to Haversine formula return this.calculateHaversineDistance( this.currentPosition.lat, this.currentPosition.lng, targetLat, targetLng ); } /** * Calculate distance using Haversine formula */ calculateHaversineDistance(lat1, lng1, lat2, lng2) { const R = 3959; // Earth's radius in miles const dLat = this.toRadians(lat2 - lat1); const dLng = this.toRadians(lng2 - lng1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); const distance = R * c; return { distance: Math.round(distance * 10) / 10, unit: 'miles' }; } /** * Convert degrees to radians */ toRadians(degrees) { return degrees * (Math.PI / 180); } /** * Center map on specific location */ centerOnLocation(lat, lng, zoom = 15) { if (this.mapInstance) { this.mapInstance.setView([lat, lng], zoom); } } /** * Cache user location */ cacheLocation(position) { try { const cacheData = { position: position, timestamp: Date.now() }; localStorage.setItem(this.options.cacheKey, JSON.stringify(cacheData)); } catch (error) { console.warn('Failed to cache location:', error); } } /** * Load cached location */ loadCachedLocation() { if (!this.options.enableCaching) return null; try { const cached = localStorage.getItem(this.options.cacheKey); if (!cached) return null; const cacheData = JSON.parse(cached); const age = Date.now() - cacheData.timestamp; // Check if cache is still valid (5 minutes) if (age < this.options.maximumAge) { this.currentPosition = cacheData.position; this.lastLocationTime = cacheData.timestamp; return this.currentPosition; } else { // Remove expired cache localStorage.removeItem(this.options.cacheKey); } } catch (error) { console.warn('Failed to load cached location:', error); } return null; } /** * Set loading state for location buttons */ setLocationButtonLoading(loading) { const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn'); locateButtons.forEach(button => { const icon = button.querySelector('i') || button; if (loading) { button.disabled = true; icon.className = 'fas fa-spinner fa-spin'; } else { button.disabled = false; // Icon will be updated by updateLocationButtonStates } }); } /** * Show location-related message */ showLocationMessage(message, type = 'info') { // Create or update message element let messageEl = document.getElementById('location-message'); if (!messageEl) { messageEl = document.createElement('div'); messageEl.id = 'location-message'; messageEl.className = 'location-message'; // Insert at top of page or after header const header = document.querySelector('header, .header'); if (header) { header.parentNode.insertBefore(messageEl, header.nextSibling); } else { document.body.insertBefore(messageEl, document.body.firstChild); } } messageEl.textContent = message; messageEl.className = `location-message location-message-${type}`; messageEl.style.display = 'block'; // Auto-hide after delay setTimeout(() => { if (messageEl.parentNode) { messageEl.style.display = 'none'; } }, 5000); } /** * Connect to a map instance */ connectToMap(mapInstance) { this.mapInstance = mapInstance; // Show cached location on map if available if (this.currentPosition && this.options.autoShowOnMap) { this.showLocationOnMap(); } } /** * Get current position */ getCurrentPosition() { return this.currentPosition; } /** * Check if location is available */ hasLocation() { return this.currentPosition !== null; } /** * Check if location is recent */ isLocationRecent(maxAge = 300000) { // 5 minutes default if (!this.lastLocationTime) return false; return (Date.now() - this.lastLocationTime) < maxAge; } /** * Add event listener */ on(event, handler) { if (!this.eventHandlers[event]) { this.eventHandlers[event] = []; } this.eventHandlers[event].push(handler); } /** * Remove event listener */ off(event, handler) { if (this.eventHandlers[event]) { const index = this.eventHandlers[event].indexOf(handler); if (index > -1) { this.eventHandlers[event].splice(index, 1); } } } /** * Trigger event */ triggerEvent(event, data) { if (this.eventHandlers[event]) { this.eventHandlers[event].forEach(handler => { try { handler(data); } catch (error) { console.error(`Error in ${event} handler:`, error); } }); } } /** * Destroy the geolocation instance */ destroy() { this.stopWatching(); this.clearLocationDisplay(); this.eventHandlers = {}; } } // Auto-initialize user location document.addEventListener('DOMContentLoaded', function() { window.userLocation = new UserLocation(); // Connect to map instance if available if (window.thrillwikiMap) { window.userLocation.connectToMap(window.thrillwikiMap); } }); // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = UserLocation; } else { window.UserLocation = UserLocation; }