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

400
core/views/maps.py Normal file
View File

@@ -0,0 +1,400 @@
"""
HTML views for the unified map service.
Provides web interfaces for map functionality with HTMX integration.
"""
import json
from typing import Dict, Any, Optional, Set
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse, HttpRequest, HttpResponse
from django.views.generic import TemplateView, View
from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.core.exceptions import ValidationError
from django.db.models import Q
from ..services.map_service import unified_map_service
from ..services.data_structures import GeoBounds, MapFilters, LocationType
class MapViewMixin:
"""Mixin providing common functionality for map views."""
def get_map_context(self, request: HttpRequest) -> Dict[str, Any]:
"""Get common context data for map views."""
return {
'map_api_urls': {
'locations': '/api/map/locations/',
'search': '/api/map/search/',
'bounds': '/api/map/bounds/',
'location_detail': '/api/map/locations/',
},
'location_types': [lt.value for lt in LocationType],
'default_zoom': 10,
'enable_clustering': True,
'enable_search': True,
}
def parse_location_types(self, request: HttpRequest) -> Optional[Set[LocationType]]:
"""Parse location types from request parameters."""
types_param = request.GET.get('types')
if types_param:
try:
return {
LocationType(t.strip()) for t in types_param.split(',')
if t.strip() in [lt.value for lt in LocationType]
}
except ValueError:
return None
return None
class UniversalMapView(MapViewMixin, TemplateView):
"""
Main universal map view showing all location types.
URL: /maps/
"""
template_name = 'maps/universal_map.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_map_context(self.request))
# Additional context for universal map
context.update({
'page_title': 'Interactive Map - All Locations',
'map_type': 'universal',
'show_all_types': True,
'initial_location_types': [lt.value for lt in LocationType],
'filters_enabled': True,
})
# Handle initial bounds from query parameters
if all(param in self.request.GET for param in ['north', 'south', 'east', 'west']):
try:
context['initial_bounds'] = {
'north': float(self.request.GET['north']),
'south': float(self.request.GET['south']),
'east': float(self.request.GET['east']),
'west': float(self.request.GET['west']),
}
except (ValueError, TypeError):
pass
return context
class ParkMapView(MapViewMixin, TemplateView):
"""
Map view focused specifically on parks.
URL: /maps/parks/
"""
template_name = 'maps/park_map.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_map_context(self.request))
# Park-specific context
context.update({
'page_title': 'Theme Parks Map',
'map_type': 'parks',
'show_all_types': False,
'initial_location_types': [LocationType.PARK.value],
'filters_enabled': True,
'park_specific_filters': True,
})
return context
class NearbyLocationsView(MapViewMixin, TemplateView):
"""
View for showing locations near a specific point.
URL: /maps/nearby/
"""
template_name = 'maps/nearby_locations.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_map_context(self.request))
# Parse coordinates from query parameters
lat = self.request.GET.get('lat')
lng = self.request.GET.get('lng')
radius = self.request.GET.get('radius', '50') # Default 50km radius
if lat and lng:
try:
center_lat = float(lat)
center_lng = float(lng)
search_radius = min(200, max(1, float(radius))) # Clamp between 1-200km
context.update({
'page_title': f'Locations Near {center_lat:.4f}, {center_lng:.4f}',
'map_type': 'nearby',
'center_coordinates': {'lat': center_lat, 'lng': center_lng},
'search_radius': search_radius,
'show_radius_circle': True,
})
except (ValueError, TypeError):
context['error'] = 'Invalid coordinates provided'
else:
context.update({
'page_title': 'Nearby Locations',
'map_type': 'nearby',
'prompt_for_location': True,
})
return context
class LocationFilterView(MapViewMixin, View):
"""
HTMX endpoint for updating map when filters change.
URL: /maps/htmx/filter/
"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Return filtered location data for HTMX updates."""
try:
# Parse filter parameters
location_types = self.parse_location_types(request)
search_query = request.GET.get('q', '').strip()
country = request.GET.get('country', '').strip()
state = request.GET.get('state', '').strip()
# Create filters
filters = None
if any([location_types, search_query, country, state]):
filters = MapFilters(
location_types=location_types,
search_query=search_query or None,
country=country or None,
state=state or None,
has_coordinates=True
)
# Get filtered locations
map_response = unified_map_service.get_map_data(
filters=filters,
zoom_level=int(request.GET.get('zoom', '10')),
cluster=request.GET.get('cluster', 'true').lower() == 'true'
)
# Return JSON response for HTMX
return JsonResponse({
'status': 'success',
'data': map_response.to_dict(),
'filters_applied': map_response.filters_applied
})
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=400)
class LocationSearchView(MapViewMixin, View):
"""
HTMX endpoint for real-time location search.
URL: /maps/htmx/search/
"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Return search results for HTMX updates."""
query = request.GET.get('q', '').strip()
if not query or len(query) < 3:
return render(request, 'maps/partials/search_results.html', {
'results': [],
'query': query,
'message': 'Enter at least 3 characters to search'
})
try:
# Parse optional location types
location_types = self.parse_location_types(request)
limit = min(20, max(5, int(request.GET.get('limit', '10'))))
# Perform search
results = unified_map_service.search_locations(
query=query,
location_types=location_types,
limit=limit
)
return render(request, 'maps/partials/search_results.html', {
'results': results,
'query': query,
'count': len(results)
})
except Exception as e:
return render(request, 'maps/partials/search_results.html', {
'results': [],
'query': query,
'error': str(e)
})
class MapBoundsUpdateView(MapViewMixin, View):
"""
HTMX endpoint for updating locations when map bounds change.
URL: /maps/htmx/bounds/
"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Update map data when bounds change."""
try:
data = json.loads(request.body)
# Parse bounds
bounds = GeoBounds(
north=float(data['north']),
south=float(data['south']),
east=float(data['east']),
west=float(data['west'])
)
# Parse additional parameters
zoom_level = int(data.get('zoom', 10))
location_types = None
if 'types' in data:
location_types = {
LocationType(t) for t in data['types']
if t in [lt.value for lt in LocationType]
}
# Create filters if needed
filters = None
if location_types:
filters = MapFilters(location_types=location_types)
# Get updated map data
map_response = unified_map_service.get_locations_by_bounds(
north=bounds.north,
south=bounds.south,
east=bounds.east,
west=bounds.west,
location_types=location_types,
zoom_level=zoom_level
)
return JsonResponse({
'status': 'success',
'data': map_response.to_dict()
})
except (json.JSONDecodeError, ValueError, KeyError) as e:
return JsonResponse({
'status': 'error',
'message': f'Invalid request data: {str(e)}'
}, status=400)
except Exception as e:
return JsonResponse({
'status': 'error',
'message': str(e)
}, status=500)
class LocationDetailModalView(MapViewMixin, View):
"""
HTMX endpoint for showing location details in modal.
URL: /maps/htmx/location/<type>/<id>/
"""
def get(self, request: HttpRequest, location_type: str, location_id: int) -> HttpResponse:
"""Return location detail modal content."""
try:
# Validate location type
if location_type not in [lt.value for lt in LocationType]:
return render(request, 'maps/partials/location_modal.html', {
'error': f'Invalid location type: {location_type}'
})
# Get location details
location = unified_map_service.get_location_details(location_type, location_id)
if not location:
return render(request, 'maps/partials/location_modal.html', {
'error': 'Location not found'
})
return render(request, 'maps/partials/location_modal.html', {
'location': location,
'location_type': location_type
})
except Exception as e:
return render(request, 'maps/partials/location_modal.html', {
'error': str(e)
})
class LocationListView(MapViewMixin, TemplateView):
"""
View for listing locations with pagination (non-map view).
URL: /maps/list/
"""
template_name = 'maps/location_list.html'
paginate_by = 20
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Parse filters
location_types = self.parse_location_types(self.request)
search_query = self.request.GET.get('q', '').strip()
country = self.request.GET.get('country', '').strip()
state = self.request.GET.get('state', '').strip()
# Create filters
filters = None
if any([location_types, search_query, country, state]):
filters = MapFilters(
location_types=location_types,
search_query=search_query or None,
country=country or None,
state=state or None,
has_coordinates=True
)
# Get locations without clustering
map_response = unified_map_service.get_map_data(
filters=filters,
cluster=False,
use_cache=True
)
# Paginate results
paginator = Paginator(map_response.locations, self.paginate_by)
page_number = self.request.GET.get('page')
page_obj = paginator.get_page(page_number)
context.update({
'page_title': 'All Locations',
'locations': page_obj,
'total_count': map_response.total_count,
'applied_filters': filters,
'location_types': [lt.value for lt in LocationType],
'current_filters': {
'types': self.request.GET.getlist('types'),
'q': search_query,
'country': country,
'state': state,
}
})
return context