mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 14:51:08 -05:00
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
720 lines
22 KiB
JavaScript
720 lines
22 KiB
JavaScript
/**
|
|
* 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(`
|
|
<div class="user-location-popup">
|
|
<h4><i class="fas fa-map-marker-alt"></i> Your Location</h4>
|
|
<p class="accuracy">Accuracy: ±${Math.round(accuracy)}m</p>
|
|
<div class="location-actions">
|
|
<button onclick="userLocation.showNearbyLocations()" class="btn btn-primary btn-sm">
|
|
<i class="fas fa-search"></i> Find Nearby Parks
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`);
|
|
|
|
// 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: `
|
|
<div class="user-location-inner">
|
|
<i class="fas fa-crosshairs"></i>
|
|
</div>
|
|
`,
|
|
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 = `
|
|
<div class="nearby-results">
|
|
<h3 class="results-title">
|
|
<i class="fas fa-map-marker-alt"></i>
|
|
Nearby Parks (${results.length} found)
|
|
</h3>
|
|
<div class="results-list">
|
|
${results.map(location => `
|
|
<div class="nearby-item">
|
|
<div class="location-info">
|
|
<h4 class="location-name">${location.name}</h4>
|
|
<p class="location-address">${location.formatted_location || ''}</p>
|
|
<p class="location-distance">
|
|
<i class="fas fa-route"></i>
|
|
${location.distance} away
|
|
</p>
|
|
</div>
|
|
<div class="location-actions">
|
|
<button onclick="userLocation.centerOnLocation(${location.latitude}, ${location.longitude})"
|
|
class="btn btn-outline btn-sm">
|
|
<i class="fas fa-map"></i> Show on Map
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
} |