/** * ThrillWiki Road Trip Planner - Multi-park Route Planning * * This module provides road trip planning functionality with multi-park selection, * route visualization, distance calculations, and export capabilities */ class RoadTripPlanner { constructor(containerId, options = {}) { this.containerId = containerId; this.options = { mapInstance: null, maxParks: 20, enableOptimization: true, enableExport: true, apiEndpoints: { parks: '/api/parks/', route: '/api/roadtrip/route/', optimize: '/api/roadtrip/optimize/', export: '/api/roadtrip/export/' }, routeOptions: { color: '#3B82F6', weight: 4, opacity: 0.8 }, ...options }; this.container = null; this.mapInstance = null; this.selectedParks = []; this.routeLayer = null; this.parkMarkers = new Map(); this.routePolyline = null; this.routeData = null; this.init(); } /** * Initialize the road trip planner */ init() { this.container = document.getElementById(this.containerId); if (!this.container) { console.error(`Road trip container with ID '${this.containerId}' not found`); return; } this.setupUI(); this.bindEvents(); // Connect to map instance if provided if (this.options.mapInstance) { this.connectToMap(this.options.mapInstance); } this.loadInitialData(); } /** * Setup the UI components */ setupUI() { const html = `

Road Trip Planner

Your Route (0/${this.options.maxParks})

Search and select parks to build your road trip route

`; this.container.innerHTML = html; } /** * Bind event handlers */ bindEvents() { // Park search const searchInput = document.getElementById('park-search'); if (searchInput) { let searchTimeout; searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { this.searchParks(e.target.value); }, 300); }); } // Route controls const optimizeBtn = document.getElementById('optimize-route'); if (optimizeBtn) { optimizeBtn.addEventListener('click', () => this.optimizeRoute()); } const clearBtn = document.getElementById('clear-route'); if (clearBtn) { clearBtn.addEventListener('click', () => this.clearRoute()); } // Export buttons const exportGpxBtn = document.getElementById('export-gpx'); if (exportGpxBtn) { exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx')); } const exportKmlBtn = document.getElementById('export-kml'); if (exportKmlBtn) { exportKmlBtn.addEventListener('click', () => this.exportRoute('kml')); } const shareBtn = document.getElementById('share-route'); if (shareBtn) { shareBtn.addEventListener('click', () => this.shareRoute()); } // Make parks list sortable this.initializeSortable(); } /** * Initialize drag-and-drop sorting for parks list */ initializeSortable() { const parksList = document.getElementById('parks-list'); if (!parksList) return; // Simple drag and drop implementation let draggedElement = null; parksList.addEventListener('dragstart', (e) => { if (e.target.classList.contains('park-item')) { draggedElement = e.target; e.target.style.opacity = '0.5'; } }); parksList.addEventListener('dragend', (e) => { if (e.target.classList.contains('park-item')) { e.target.style.opacity = '1'; draggedElement = null; } }); parksList.addEventListener('dragover', (e) => { e.preventDefault(); }); parksList.addEventListener('drop', (e) => { e.preventDefault(); if (draggedElement && e.target.classList.contains('park-item')) { const afterElement = this.getDragAfterElement(parksList, e.clientY); if (afterElement == null) { parksList.appendChild(draggedElement); } else { parksList.insertBefore(draggedElement, afterElement); } this.reorderParks(); } }); } /** * Get the element to insert after during drag and drop */ getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll('.park-item:not(.dragging)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } /** * Search for parks */ async searchParks(query) { if (!query.trim()) { document.getElementById('park-search-results').innerHTML = ''; return; } try { const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`); const data = await response.json(); if (data.status === 'success') { this.displaySearchResults(data.data); } } catch (error) { console.error('Failed to search parks:', error); } } /** * Display park search results */ displaySearchResults(parks) { const resultsContainer = document.getElementById('park-search-results'); if (parks.length === 0) { resultsContainer.innerHTML = '
No parks found
'; return; } const html = parks .filter(park => !this.isParkSelected(park.id)) .map(park => `
${park.name}
${park.formatted_location || ''}
`).join(''); resultsContainer.innerHTML = html; } /** * Check if a park is already selected */ isParkSelected(parkId) { return this.selectedParks.some(park => park.id === parkId); } /** * Add a park to the route */ async addPark(parkId) { if (this.selectedParks.length >= this.options.maxParks) { this.showMessage(`Maximum ${this.options.maxParks} parks allowed`, 'warning'); return; } try { const response = await fetch(`${this.options.apiEndpoints.parks}${parkId}/`); const data = await response.json(); if (data.status === 'success') { const park = data.data; this.selectedParks.push(park); this.updateParksDisplay(); this.addParkMarker(park); this.updateRoute(); // Clear search document.getElementById('park-search').value = ''; document.getElementById('park-search-results').innerHTML = ''; } } catch (error) { console.error('Failed to add park:', error); } } /** * Remove a park from the route */ removePark(parkId) { const index = this.selectedParks.findIndex(park => park.id === parkId); if (index > -1) { this.selectedParks.splice(index, 1); this.updateParksDisplay(); this.removeParkMarker(parkId); this.updateRoute(); } } /** * Update the parks display */ updateParksDisplay() { const parksList = document.getElementById('parks-list'); const parkCount = document.getElementById('park-count'); parkCount.textContent = this.selectedParks.length; if (this.selectedParks.length === 0) { parksList.innerHTML = `

Search and select parks to build your road trip route

`; this.updateControls(); return; } const html = this.selectedParks.map((park, index) => `
${index + 1}
${park.name}
${park.formatted_location || ''}
${park.distance_from_previous ? `
${park.distance_from_previous}
` : ''}
`).join(''); parksList.innerHTML = html; this.updateControls(); } /** * Update control buttons state */ updateControls() { const optimizeBtn = document.getElementById('optimize-route'); const clearBtn = document.getElementById('clear-route'); const hasParks = this.selectedParks.length > 0; const canOptimize = this.selectedParks.length > 2; if (optimizeBtn) optimizeBtn.disabled = !canOptimize; if (clearBtn) clearBtn.disabled = !hasParks; } /** * Reorder parks after drag and drop */ reorderParks() { const parkItems = document.querySelectorAll('.park-item'); const newOrder = []; parkItems.forEach(item => { const parkId = parseInt(item.dataset.parkId); const park = this.selectedParks.find(p => p.id === parkId); if (park) { newOrder.push(park); } }); this.selectedParks = newOrder; this.updateRoute(); } /** * Update the route visualization */ async updateRoute() { if (this.selectedParks.length < 2) { this.clearRouteVisualization(); this.updateRouteSummary(null); return; } try { const parkIds = this.selectedParks.map(park => park.id); const response = await fetch(`${this.options.apiEndpoints.route}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrfToken() }, body: JSON.stringify({ parks: parkIds }) }); const data = await response.json(); if (data.status === 'success') { this.routeData = data.data; this.visualizeRoute(data.data); this.updateRouteSummary(data.data); } } catch (error) { console.error('Failed to calculate route:', error); } } /** * Visualize the route on the map */ visualizeRoute(routeData) { if (!this.mapInstance) return; // Clear existing route this.clearRouteVisualization(); if (routeData.coordinates) { // Create polyline from coordinates this.routePolyline = L.polyline(routeData.coordinates, this.options.routeOptions); this.routePolyline.addTo(this.mapInstance); // Fit map to route bounds if (routeData.coordinates.length > 0) { this.mapInstance.fitBounds(this.routePolyline.getBounds(), { padding: [20, 20] }); } } } /** * Clear route visualization */ clearRouteVisualization() { if (this.routePolyline && this.mapInstance) { this.mapInstance.removeLayer(this.routePolyline); this.routePolyline = null; } } /** * Update route summary display */ updateRouteSummary(routeData) { const summarySection = document.getElementById('route-summary'); if (!routeData || this.selectedParks.length < 2) { summarySection.style.display = 'none'; return; } summarySection.style.display = 'block'; document.getElementById('total-distance').textContent = routeData.total_distance || '-'; document.getElementById('total-time').textContent = routeData.total_time || '-'; document.getElementById('total-parks').textContent = this.selectedParks.length; } /** * Optimize the route order */ async optimizeRoute() { if (this.selectedParks.length < 3) return; try { const parkIds = this.selectedParks.map(park => park.id); const response = await fetch(`${this.options.apiEndpoints.optimize}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrfToken() }, body: JSON.stringify({ parks: parkIds }) }); const data = await response.json(); if (data.status === 'success') { // Reorder parks based on optimization const optimizedOrder = data.data.optimized_order; this.selectedParks = optimizedOrder.map(id => this.selectedParks.find(park => park.id === id) ).filter(Boolean); this.updateParksDisplay(); this.updateRoute(); this.showMessage('Route optimized for shortest distance', 'success'); } } catch (error) { console.error('Failed to optimize route:', error); this.showMessage('Failed to optimize route', 'error'); } } /** * Clear the entire route */ clearRoute() { this.selectedParks = []; this.clearAllParkMarkers(); this.clearRouteVisualization(); this.updateParksDisplay(); this.updateRouteSummary(null); } /** * Export route in specified format */ async exportRoute(format) { if (!this.routeData) { this.showMessage('No route to export', 'warning'); return; } try { const response = await fetch(`${this.options.apiEndpoints.export}${format}/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrfToken() }, body: JSON.stringify({ parks: this.selectedParks.map(p => p.id), route_data: this.routeData }) }); if (response.ok) { const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `thrillwiki-roadtrip.${format}`; a.click(); window.URL.revokeObjectURL(url); } } catch (error) { console.error('Failed to export route:', error); this.showMessage('Failed to export route', 'error'); } } /** * Share the route */ shareRoute() { if (this.selectedParks.length === 0) { this.showMessage('No route to share', 'warning'); return; } const parkIds = this.selectedParks.map(p => p.id).join(','); const url = `${window.location.origin}/roadtrip/?parks=${parkIds}`; if (navigator.share) { navigator.share({ title: 'ThrillWiki Road Trip', text: `Check out this ${this.selectedParks.length}-park road trip!`, url: url }); } else { // Fallback to clipboard navigator.clipboard.writeText(url).then(() => { this.showMessage('Route URL copied to clipboard', 'success'); }).catch(() => { // Manual selection fallback const textarea = document.createElement('textarea'); textarea.value = url; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); this.showMessage('Route URL copied to clipboard', 'success'); }); } } /** * Add park marker to map */ addParkMarker(park) { if (!this.mapInstance) return; const marker = L.marker([park.latitude, park.longitude], { icon: this.createParkIcon(park) }); marker.bindPopup(`

${park.name}

${park.formatted_location || ''}

`); marker.addTo(this.mapInstance); this.parkMarkers.set(park.id, marker); } /** * Remove park marker from map */ removeParkMarker(parkId) { if (this.parkMarkers.has(parkId) && this.mapInstance) { this.mapInstance.removeLayer(this.parkMarkers.get(parkId)); this.parkMarkers.delete(parkId); } } /** * Clear all park markers */ clearAllParkMarkers() { this.parkMarkers.forEach(marker => { if (this.mapInstance) { this.mapInstance.removeLayer(marker); } }); this.parkMarkers.clear(); } /** * Create custom icon for park marker */ createParkIcon(park) { const index = this.selectedParks.findIndex(p => p.id === park.id) + 1; return L.divIcon({ className: 'roadtrip-park-marker', html: `
${index}
`, iconSize: [30, 30], iconAnchor: [15, 15] }); } /** * Connect to a map instance */ connectToMap(mapInstance) { this.mapInstance = mapInstance; this.options.mapInstance = mapInstance; } /** * Load initial data (from URL parameters) */ loadInitialData() { const urlParams = new URLSearchParams(window.location.search); const parkIds = urlParams.get('parks'); if (parkIds) { const ids = parkIds.split(',').map(id => parseInt(id)).filter(id => !isNaN(id)); this.loadParksById(ids); } } /** * Load parks by IDs */ async loadParksById(parkIds) { try { const promises = parkIds.map(id => fetch(`${this.options.apiEndpoints.parks}${id}/`) .then(res => res.json()) .then(data => data.status === 'success' ? data.data : null) ); const parks = (await Promise.all(promises)).filter(Boolean); this.selectedParks = parks; this.updateParksDisplay(); // Add markers and update route parks.forEach(park => this.addParkMarker(park)); this.updateRoute(); } catch (error) { console.error('Failed to load parks:', error); } } /** * Get CSRF token for POST requests */ getCsrfToken() { const token = document.querySelector('[name=csrfmiddlewaretoken]'); return token ? token.value : ''; } /** * Show message to user */ showMessage(message, type = 'info') { // Create or update message element let messageEl = this.container.querySelector('.roadtrip-message'); if (!messageEl) { messageEl = document.createElement('div'); messageEl.className = 'roadtrip-message'; this.container.insertBefore(messageEl, this.container.firstChild); } messageEl.textContent = message; messageEl.className = `roadtrip-message roadtrip-message-${type}`; // Auto-hide after delay setTimeout(() => { if (messageEl.parentNode) { messageEl.remove(); } }, 5000); } } // Auto-initialize road trip planner document.addEventListener('DOMContentLoaded', function() { const roadtripContainer = document.getElementById('roadtrip-planner'); if (roadtripContainer) { window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', { mapInstance: window.thrillwikiMap || null }); } }); // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = RoadTripPlanner; } else { window.RoadTripPlanner = RoadTripPlanner; }