Add Road Trip Planner template with interactive map and trip management features

- Implemented a new HTML template for the Road Trip Planner.
- Integrated Leaflet.js for interactive mapping and routing.
- Added functionality for searching and selecting parks to include in a trip.
- Enabled drag-and-drop reordering of selected parks.
- Included trip optimization and route calculation features.
- Created a summary display for trip statistics.
- Added functionality to save trips and manage saved trips.
- Enhanced UI with responsive design and dark mode support.
This commit is contained in:
pacnpal
2025-08-15 20:53:00 -04:00
parent da7c7e3381
commit b5bae44cb8
99 changed files with 18697 additions and 4010 deletions

View File

@@ -0,0 +1,393 @@
"""
Location-aware search service for ThrillWiki.
Integrates PostGIS location data with existing search functionality
to provide proximity-based search, location filtering, and geographic
search capabilities.
"""
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from django.db.models import Q, Case, When, F, Value, CharField
from django.db.models.functions import Coalesce
from typing import Optional, List, Dict, Any, Tuple, Set
from dataclasses import dataclass
from parks.models import Park
from rides.models import Ride
from parks.models.companies import Company
from parks.models.location import ParkLocation
from rides.models.location import RideLocation
from parks.models.companies import CompanyHeadquarters
@dataclass
class LocationSearchFilters:
"""Filters for location-aware search queries."""
# Text search
search_query: Optional[str] = None
# Location-based filters
location_point: Optional[Point] = None
radius_km: Optional[float] = None
location_types: Optional[Set[str]] = None # 'park', 'ride', 'company'
# Geographic filters
country: Optional[str] = None
state: Optional[str] = None
city: Optional[str] = None
# Content-specific filters
park_status: Optional[List[str]] = None
ride_types: Optional[List[str]] = None
company_roles: Optional[List[str]] = None
# Result options
include_distance: bool = True
max_results: int = 100
@dataclass
class LocationSearchResult:
"""Single search result with location data."""
# Core data
content_type: str # 'park', 'ride', 'company'
object_id: int
name: str
description: Optional[str] = None
url: Optional[str] = None
# Location data
latitude: Optional[float] = None
longitude: Optional[float] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
# Distance data (if proximity search)
distance_km: Optional[float] = None
# Additional metadata
status: Optional[str] = None
tags: Optional[List[str]] = None
rating: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
'content_type': self.content_type,
'object_id': self.object_id,
'name': self.name,
'description': self.description,
'url': self.url,
'location': {
'latitude': self.latitude,
'longitude': self.longitude,
'address': self.address,
'city': self.city,
'state': self.state,
'country': self.country,
},
'distance_km': self.distance_km,
'status': self.status,
'tags': self.tags or [],
'rating': self.rating,
}
class LocationSearchService:
"""Service for performing location-aware searches across ThrillWiki content."""
def search(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""
Perform a comprehensive location-aware search.
Args:
filters: Search filters and options
Returns:
List of search results with location data
"""
results = []
# Search each content type based on filters
if not filters.location_types or 'park' in filters.location_types:
results.extend(self._search_parks(filters))
if not filters.location_types or 'ride' in filters.location_types:
results.extend(self._search_rides(filters))
if not filters.location_types or 'company' in filters.location_types:
results.extend(self._search_companies(filters))
# Sort by distance if proximity search, otherwise by relevance
if filters.location_point and filters.include_distance:
results.sort(key=lambda x: x.distance_km or float('inf'))
else:
results.sort(key=lambda x: x.name.lower())
# Apply max results limit
return results[:filters.max_results]
def _search_parks(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""Search parks with location data."""
queryset = Park.objects.select_related('location', 'operator').all()
# Apply location filters
queryset = self._apply_location_filters(queryset, filters, 'location__point')
# Apply text search
if filters.search_query:
query = Q(name__icontains=filters.search_query) | \
Q(description__icontains=filters.search_query) | \
Q(location__city__icontains=filters.search_query) | \
Q(location__state__icontains=filters.search_query) | \
Q(location__country__icontains=filters.search_query)
queryset = queryset.filter(query)
# Apply park-specific filters
if filters.park_status:
queryset = queryset.filter(status__in=filters.park_status)
# Add distance annotation if proximity search
if filters.location_point and filters.include_distance:
queryset = queryset.annotate(
distance=Distance('location__point', filters.location_point)
).order_by('distance')
# Convert to search results
results = []
for park in queryset:
result = LocationSearchResult(
content_type='park',
object_id=park.id,
name=park.name,
description=park.description,
url=park.get_absolute_url() if hasattr(park, 'get_absolute_url') else None,
status=park.get_status_display(),
rating=float(park.average_rating) if park.average_rating else None,
tags=['park', park.status.lower()]
)
# Add location data
if hasattr(park, 'location') and park.location:
location = park.location
result.latitude = location.latitude
result.longitude = location.longitude
result.address = location.formatted_address
result.city = location.city
result.state = location.state
result.country = location.country
# Add distance if proximity search
if filters.location_point and filters.include_distance and hasattr(park, 'distance'):
result.distance_km = float(park.distance.km)
results.append(result)
return results
def _search_rides(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""Search rides with location data."""
queryset = Ride.objects.select_related('park', 'location').all()
# Apply location filters
queryset = self._apply_location_filters(queryset, filters, 'location__point')
# Apply text search
if filters.search_query:
query = Q(name__icontains=filters.search_query) | \
Q(description__icontains=filters.search_query) | \
Q(park__name__icontains=filters.search_query) | \
Q(location__park_area__icontains=filters.search_query)
queryset = queryset.filter(query)
# Apply ride-specific filters
if filters.ride_types:
queryset = queryset.filter(ride_type__in=filters.ride_types)
# Add distance annotation if proximity search
if filters.location_point and filters.include_distance:
queryset = queryset.annotate(
distance=Distance('location__point', filters.location_point)
).order_by('distance')
# Convert to search results
results = []
for ride in queryset:
result = LocationSearchResult(
content_type='ride',
object_id=ride.id,
name=ride.name,
description=ride.description,
url=ride.get_absolute_url() if hasattr(ride, 'get_absolute_url') else None,
status=ride.status,
tags=['ride', ride.ride_type.lower() if ride.ride_type else 'attraction']
)
# Add location data from ride location or park location
location = None
if hasattr(ride, 'location') and ride.location:
location = ride.location
result.latitude = location.latitude
result.longitude = location.longitude
result.address = f"{ride.park.name} - {location.park_area}" if location.park_area else ride.park.name
# Add distance if proximity search
if filters.location_point and filters.include_distance and hasattr(ride, 'distance'):
result.distance_km = float(ride.distance.km)
# Fall back to park location if no specific ride location
elif ride.park and hasattr(ride.park, 'location') and ride.park.location:
park_location = ride.park.location
result.latitude = park_location.latitude
result.longitude = park_location.longitude
result.address = park_location.formatted_address
result.city = park_location.city
result.state = park_location.state
result.country = park_location.country
results.append(result)
return results
def _search_companies(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
"""Search companies with headquarters location data."""
queryset = Company.objects.select_related('headquarters').all()
# Apply location filters
queryset = self._apply_location_filters(queryset, filters, 'headquarters__point')
# Apply text search
if filters.search_query:
query = Q(name__icontains=filters.search_query) | \
Q(description__icontains=filters.search_query) | \
Q(headquarters__city__icontains=filters.search_query) | \
Q(headquarters__state_province__icontains=filters.search_query) | \
Q(headquarters__country__icontains=filters.search_query)
queryset = queryset.filter(query)
# Apply company-specific filters
if filters.company_roles:
queryset = queryset.filter(roles__overlap=filters.company_roles)
# Add distance annotation if proximity search
if filters.location_point and filters.include_distance:
queryset = queryset.annotate(
distance=Distance('headquarters__point', filters.location_point)
).order_by('distance')
# Convert to search results
results = []
for company in queryset:
result = LocationSearchResult(
content_type='company',
object_id=company.id,
name=company.name,
description=company.description,
url=company.get_absolute_url() if hasattr(company, 'get_absolute_url') else None,
tags=['company'] + (company.roles or [])
)
# Add location data
if hasattr(company, 'headquarters') and company.headquarters:
hq = company.headquarters
result.latitude = hq.latitude
result.longitude = hq.longitude
result.address = hq.formatted_address
result.city = hq.city
result.state = hq.state_province
result.country = hq.country
# Add distance if proximity search
if filters.location_point and filters.include_distance and hasattr(company, 'distance'):
result.distance_km = float(company.distance.km)
results.append(result)
return results
def _apply_location_filters(self, queryset, filters: LocationSearchFilters, point_field: str):
"""Apply common location filters to a queryset."""
# Proximity filter
if filters.location_point and filters.radius_km:
distance = Distance(km=filters.radius_km)
queryset = queryset.filter(**{
f'{point_field}__distance_lte': (filters.location_point, distance)
})
# Geographic filters - adjust field names based on model
if filters.country:
if 'headquarters' in point_field:
queryset = queryset.filter(headquarters__country__icontains=filters.country)
else:
location_field = point_field.split('__')[0]
queryset = queryset.filter(**{f'{location_field}__country__icontains': filters.country})
if filters.state:
if 'headquarters' in point_field:
queryset = queryset.filter(headquarters__state_province__icontains=filters.state)
else:
location_field = point_field.split('__')[0]
queryset = queryset.filter(**{f'{location_field}__state__icontains': filters.state})
if filters.city:
location_field = point_field.split('__')[0]
queryset = queryset.filter(**{f'{location_field}__city__icontains': filters.city})
return queryset
def suggest_locations(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
"""
Get location suggestions for autocomplete.
Args:
query: Search query string
limit: Maximum number of suggestions
Returns:
List of location suggestions
"""
suggestions = []
if len(query) < 2:
return suggestions
# Get park location suggestions
park_locations = ParkLocation.objects.filter(
Q(park__name__icontains=query) |
Q(city__icontains=query) |
Q(state__icontains=query)
).select_related('park')[:limit//3]
for location in park_locations:
suggestions.append({
'type': 'park',
'name': location.park.name,
'address': location.formatted_address,
'coordinates': location.coordinates,
'url': location.park.get_absolute_url() if hasattr(location.park, 'get_absolute_url') else None
})
# Get city suggestions
cities = ParkLocation.objects.filter(
city__icontains=query
).values('city', 'state', 'country').distinct()[:limit//3]
for city_data in cities:
suggestions.append({
'type': 'city',
'name': f"{city_data['city']}, {city_data['state']}",
'address': f"{city_data['city']}, {city_data['state']}, {city_data['country']}",
'coordinates': None
})
return suggestions[:limit]
# Global instance
location_search_service = LocationSearchService()