mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:11:09 -05:00
788 lines
28 KiB
HTML
788 lines
28 KiB
HTML
{% 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, '"')})"
|
|
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 %} |