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,788 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Road Trip Planner - ThrillWiki{% endblock %}
{% block extra_head %}
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet Routing Machine CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
<style>
.map-container {
height: 70vh;
min-height: 500px;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.park-selection-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border-2 border-transparent;
}
.park-selection-card:hover {
@apply border-blue-200 dark:border-blue-700;
}
.park-selection-card.selected {
@apply border-blue-500 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
}
.park-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm;
}
.trip-summary-card {
@apply bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900 dark:to-indigo-900 rounded-lg p-4 shadow-sm;
}
.waypoint-marker {
background: transparent;
border: none;
}
.waypoint-marker-inner {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.waypoint-start .waypoint-marker-inner {
background: #10b981;
}
.waypoint-end .waypoint-marker-inner {
background: #ef4444;
}
.waypoint-stop .waypoint-marker-inner {
background: #3b82f6;
}
.route-line {
color: #3b82f6;
weight: 4;
opacity: 0.7;
}
.dark .route-line {
color: #60a5fa;
}
.trip-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.trip-stat {
@apply text-center;
}
.trip-stat-value {
@apply text-2xl font-bold text-blue-600 dark:text-blue-400;
}
.trip-stat-label {
@apply text-sm text-gray-600 dark:text-gray-400 mt-1;
}
.draggable-item {
cursor: grab;
}
.draggable-item:active {
cursor: grabbing;
}
.drag-over {
@apply border-dashed border-2 border-blue-400 bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30;
}
.park-search-result {
@apply p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700;
}
.park-search-result:last-child {
border-bottom: none;
}
.park-search-result:hover {
@apply bg-gray-50 dark:bg-gray-700;
}
</style>
{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<!-- Header -->
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Road Trip Planner</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Plan the perfect theme park adventure across multiple destinations
</p>
</div>
<div class="flex gap-3">
<a href="{% url 'maps:universal_map' %}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="mr-2 fas fa-globe"></i>View Map
</a>
<a href="{% url 'parks:park_list' %}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="mr-2 fas fa-list"></i>Browse Parks
</a>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Panel - Trip Planning -->
<div class="lg:col-span-1 space-y-6">
<!-- Park Search -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Parks to Trip</h3>
<div class="relative">
<input type="text" id="park-search"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search parks by name or location..."
hx-get="{% url 'parks:htmx_search_parks' %}"
hx-trigger="input changed delay:300ms"
hx-target="#park-search-results"
hx-indicator="#search-loading">
<div id="search-loading" class="htmx-indicator absolute right-3 top-3">
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
</div>
</div>
<div id="park-search-results" class="mt-3 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 hidden">
<!-- Search results will be populated here -->
</div>
</div>
<!-- Trip Itinerary -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Trip Itinerary</h3>
<button id="clear-trip"
class="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400"
onclick="tripPlanner.clearTrip()">
<i class="mr-1 fas fa-trash"></i>Clear All
</button>
</div>
<div id="trip-parks" class="space-y-2 min-h-20">
<div id="empty-trip" class="text-center py-8 text-gray-500 dark:text-gray-400">
<i class="fas fa-route text-3xl mb-3"></i>
<p>Add parks to start planning your trip</p>
<p class="text-sm mt-1">Search above or click parks on the map</p>
</div>
</div>
<div class="mt-4 space-y-2">
<button id="optimize-route"
class="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick="tripPlanner.optimizeRoute()" disabled>
<i class="mr-2 fas fa-route"></i>Optimize Route
</button>
<button id="calculate-route"
class="w-full px-4 py-2 text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick="tripPlanner.calculateRoute()" disabled>
<i class="mr-2 fas fa-map"></i>Calculate Route
</button>
</div>
</div>
<!-- Trip Summary -->
<div id="trip-summary" class="trip-summary-card hidden">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Trip Summary</h3>
<div class="trip-stats">
<div class="trip-stat">
<div class="trip-stat-value" id="total-distance">-</div>
<div class="trip-stat-label">Total Miles</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-time">-</div>
<div class="trip-stat-label">Drive Time</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-parks">-</div>
<div class="trip-stat-label">Parks</div>
</div>
<div class="trip-stat">
<div class="trip-stat-value" id="total-rides">-</div>
<div class="trip-stat-label">Total Rides</div>
</div>
</div>
<div class="mt-4">
<button id="save-trip"
class="w-full px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
onclick="tripPlanner.saveTrip()">
<i class="mr-2 fas fa-save"></i>Save Trip
</button>
</div>
</div>
</div>
<!-- Right Panel - Map -->
<div class="lg:col-span-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Route Map</h3>
<div class="flex gap-2">
<button id="fit-route"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onclick="tripPlanner.fitRoute()">
<i class="mr-1 fas fa-expand-arrows-alt"></i>Fit Route
</button>
<button id="toggle-parks"
class="px-3 py-1 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
onclick="tripPlanner.toggleAllParks()">
<i class="mr-1 fas fa-eye"></i>Show All Parks
</button>
</div>
</div>
<div id="map-container" class="map-container"></div>
<!-- Map Loading Indicator -->
<div id="map-loading" class="htmx-indicator absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
<div class="text-center">
<div class="w-8 h-8 mx-auto mb-4 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Saved Trips Section -->
<div class="mt-8">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">My Saved Trips</h3>
<button class="px-3 py-1 text-sm text-blue-600 hover:text-blue-700"
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-target="#saved-trips"
hx-trigger="click">
<i class="mr-1 fas fa-sync"></i>Refresh
</button>
</div>
<div id="saved-trips"
hx-get="{% url 'parks:htmx_saved_trips' %}"
hx-trigger="load"
hx-indicator="#trips-loading">
<!-- Saved trips will be loaded here -->
</div>
<div id="trips-loading" class="htmx-indicator text-center py-4">
<div class="w-6 h-6 mx-auto border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">Loading saved trips...</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Routing Machine JS -->
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
<!-- Sortable JS for drag & drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
// Road Trip Planner class
class TripPlanner {
constructor() {
this.map = null;
this.tripParks = [];
this.allParks = [];
this.parkMarkers = {};
this.routeControl = null;
this.showingAllParks = false;
this.init();
}
init() {
this.initMap();
this.loadAllParks();
this.initDragDrop();
this.bindEvents();
}
initMap() {
// Initialize the map
this.map = L.map('map-container', {
center: [39.8283, -98.5795],
zoom: 4,
zoomControl: false
});
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
// Add tile layers with dark mode support
const lightTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
});
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO'
});
// Set initial tiles based on theme
if (document.documentElement.classList.contains('dark')) {
darkTiles.addTo(this.map);
} else {
lightTiles.addTo(this.map);
}
// Listen for theme changes
this.observeThemeChanges(lightTiles, darkTiles);
}
observeThemeChanges(lightTiles, darkTiles) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
if (document.documentElement.classList.contains('dark')) {
this.map.removeLayer(lightTiles);
this.map.addLayer(darkTiles);
} else {
this.map.removeLayer(darkTiles);
this.map.addLayer(lightTiles);
}
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
async loadAllParks() {
try {
const response = await fetch('{{ map_api_urls.locations }}?types=park&limit=1000');
const data = await response.json();
if (data.status === 'success' && data.data.locations) {
this.allParks = data.data.locations;
}
} catch (error) {
console.error('Failed to load parks:', error);
}
}
initDragDrop() {
// Make trip parks sortable
new Sortable(document.getElementById('trip-parks'), {
animation: 150,
ghostClass: 'drag-over',
onEnd: (evt) => {
this.reorderTripParks(evt.oldIndex, evt.newIndex);
}
});
}
bindEvents() {
// Handle park search results
document.addEventListener('htmx:afterRequest', (event) => {
if (event.target.id === 'park-search-results') {
this.handleSearchResults();
}
});
}
handleSearchResults() {
const results = document.getElementById('park-search-results');
if (results.children.length > 0) {
results.classList.remove('hidden');
} else {
results.classList.add('hidden');
}
}
addParkToTrip(parkData) {
// Check if park already in trip
if (this.tripParks.find(p => p.id === parkData.id)) {
return;
}
this.tripParks.push(parkData);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
// Hide search results
document.getElementById('park-search-results').classList.add('hidden');
document.getElementById('park-search').value = '';
}
removeParkFromTrip(parkId) {
this.tripParks = this.tripParks.filter(p => p.id !== parkId);
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
updateTripDisplay() {
const container = document.getElementById('trip-parks');
const emptyState = document.getElementById('empty-trip');
if (this.tripParks.length === 0) {
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
// Clear existing parks (except empty state)
Array.from(container.children).forEach(child => {
if (child.id !== 'empty-trip') {
child.remove();
}
});
// Add trip parks
this.tripParks.forEach((park, index) => {
const parkElement = this.createTripParkElement(park, index);
container.appendChild(parkElement);
});
}
createTripParkElement(park, index) {
const div = document.createElement('div');
div.className = 'park-card draggable-item';
div.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-bold">
${index + 1}
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
${park.name}
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 truncate">
${park.formatted_location || 'Location not specified'}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="text-red-500 hover:text-red-700 p-1">
<i class="fas fa-times"></i>
</button>
<i class="fas fa-grip-vertical text-gray-400 cursor-grab"></i>
</div>
</div>
`;
return div;
}
updateTripMarkers() {
// Clear existing trip markers
Object.values(this.parkMarkers).forEach(marker => {
this.map.removeLayer(marker);
});
this.parkMarkers = {};
// Add markers for trip parks
this.tripParks.forEach((park, index) => {
const marker = this.createTripMarker(park, index);
this.parkMarkers[park.id] = marker;
marker.addTo(this.map);
});
// Fit map to show all trip parks
if (this.tripParks.length > 0) {
this.fitRoute();
}
}
createTripMarker(park, index) {
let markerClass = 'waypoint-stop';
if (index === 0) markerClass = 'waypoint-start';
if (index === this.tripParks.length - 1 && this.tripParks.length > 1) markerClass = 'waypoint-end';
const icon = L.divIcon({
className: `waypoint-marker ${markerClass}`,
html: `<div class="waypoint-marker-inner">${index + 1}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([park.latitude, park.longitude], { icon });
const popupContent = `
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<div class="text-sm text-gray-600 mb-2">Stop ${index + 1}</div>
${park.ride_count ? `<div class="text-sm text-gray-600 mb-2">${park.ride_count} rides</div>` : ''}
<button onclick="tripPlanner.removeParkFromTrip(${park.id})"
class="px-3 py-1 text-sm text-red-600 border border-red-600 rounded hover:bg-red-50">
Remove from Trip
</button>
</div>
`;
marker.bindPopup(popupContent);
return marker;
}
reorderTripParks(oldIndex, newIndex) {
const park = this.tripParks.splice(oldIndex, 1)[0];
this.tripParks.splice(newIndex, 0, park);
this.updateTripDisplay();
this.updateTripMarkers();
// Clear route to force recalculation
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
}
async optimizeRoute() {
if (this.tripParks.length < 2) return;
try {
const parkIds = this.tripParks.map(p => p.id);
const response = await fetch('{% url "parks:htmx_optimize_route" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ park_ids: parkIds })
});
const data = await response.json();
if (data.status === 'success' && data.optimized_order) {
// Reorder parks based on optimization
const optimizedParks = data.optimized_order.map(id =>
this.tripParks.find(p => p.id === id)
).filter(Boolean);
this.tripParks = optimizedParks;
this.updateTripDisplay();
this.updateTripMarkers();
}
} catch (error) {
console.error('Route optimization failed:', error);
}
}
async calculateRoute() {
if (this.tripParks.length < 2) return;
// Remove existing route
if (this.routeControl) {
this.map.removeControl(this.routeControl);
}
const waypoints = this.tripParks.map(park =>
L.latLng(park.latitude, park.longitude)
);
this.routeControl = L.Routing.control({
waypoints: waypoints,
routeWhileDragging: false,
addWaypoints: false,
createMarker: () => null, // Don't create default markers
lineOptions: {
styles: [{ color: '#3b82f6', weight: 4, opacity: 0.7 }]
}
}).addTo(this.map);
this.routeControl.on('routesfound', (e) => {
const route = e.routes[0];
this.updateTripSummary(route);
});
}
updateTripSummary(route) {
if (!route) return;
const totalDistance = (route.summary.totalDistance / 1609.34).toFixed(1); // Convert to miles
const totalTime = this.formatDuration(route.summary.totalTime);
const totalRides = this.tripParks.reduce((sum, park) => sum + (park.ride_count || 0), 0);
document.getElementById('total-distance').textContent = totalDistance;
document.getElementById('total-time').textContent = totalTime;
document.getElementById('total-parks').textContent = this.tripParks.length;
document.getElementById('total-rides').textContent = totalRides;
document.getElementById('trip-summary').classList.remove('hidden');
}
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
fitRoute() {
if (this.tripParks.length === 0) return;
const group = new L.featureGroup(Object.values(this.parkMarkers));
this.map.fitBounds(group.getBounds().pad(0.1));
}
toggleAllParks() {
// Implementation for showing/hiding all parks on the map
const button = document.getElementById('toggle-parks');
const icon = button.querySelector('i');
if (this.showingAllParks) {
// Hide all parks
this.showingAllParks = false;
icon.className = 'mr-1 fas fa-eye';
button.innerHTML = icon.outerHTML + 'Show All Parks';
} else {
// Show all parks
this.showingAllParks = true;
icon.className = 'mr-1 fas fa-eye-slash';
button.innerHTML = icon.outerHTML + 'Hide All Parks';
this.displayAllParks();
}
}
displayAllParks() {
// Add markers for all parks (implementation depends on requirements)
this.allParks.forEach(park => {
if (!this.parkMarkers[park.id]) {
const marker = L.marker([park.latitude, park.longitude], {
icon: L.divIcon({
className: 'location-marker location-marker-park',
html: '<div class="location-marker-inner">🎢</div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
})
});
marker.bindPopup(`
<div class="text-center">
<h3 class="font-semibold mb-2">${park.name}</h3>
<button onclick="tripPlanner.addParkToTrip(${JSON.stringify(park).replace(/"/g, '&quot;')})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
Add to Trip
</button>
</div>
`);
marker.addTo(this.map);
this.parkMarkers[`all_${park.id}`] = marker;
}
});
}
updateButtons() {
const optimizeBtn = document.getElementById('optimize-route');
const calculateBtn = document.getElementById('calculate-route');
const hasEnoughParks = this.tripParks.length >= 2;
optimizeBtn.disabled = !hasEnoughParks;
calculateBtn.disabled = !hasEnoughParks;
}
clearTrip() {
this.tripParks = [];
this.updateTripDisplay();
this.updateTripMarkers();
this.updateButtons();
if (this.routeControl) {
this.map.removeControl(this.routeControl);
this.routeControl = null;
}
document.getElementById('trip-summary').classList.add('hidden');
}
async saveTrip() {
if (this.tripParks.length === 0) return;
const tripName = prompt('Enter a name for this trip:');
if (!tripName) return;
try {
const response = await fetch('{% url "parks:htmx_save_trip" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
name: tripName,
park_ids: this.tripParks.map(p => p.id)
})
});
const data = await response.json();
if (data.status === 'success') {
alert('Trip saved successfully!');
// Refresh saved trips
htmx.trigger('#saved-trips', 'refresh');
} else {
alert('Failed to save trip: ' + (data.message || 'Unknown error'));
}
} catch (error) {
console.error('Save trip failed:', error);
alert('Failed to save trip');
}
}
}
// Global function for adding parks from search results
window.addParkToTrip = function(parkData) {
window.tripPlanner.addParkToTrip(parkData);
};
// Initialize trip planner when page loads
document.addEventListener('DOMContentLoaded', function() {
window.tripPlanner = new TripPlanner();
// Hide search results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#park-search') && !e.target.closest('#park-search-results')) {
document.getElementById('park-search-results').classList.add('hidden');
}
});
});
</script>
{% endblock %}