mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:31:09 -05:00
- 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.
581 lines
22 KiB
HTML
581 lines
22 KiB
HTML
{% extends 'base/base.html' %}
|
|
{% load static %}
|
|
|
|
{% block title %}Nearby Locations - ThrillWiki{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
|
|
<style>
|
|
.map-container {
|
|
height: 60vh;
|
|
min-height: 400px;
|
|
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);
|
|
}
|
|
|
|
.location-card {
|
|
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow cursor-pointer;
|
|
}
|
|
|
|
.location-card:hover {
|
|
@apply ring-2 ring-blue-500 ring-opacity-50;
|
|
}
|
|
|
|
.location-card.selected {
|
|
@apply ring-2 ring-blue-500;
|
|
}
|
|
|
|
.location-type-badge {
|
|
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
|
|
}
|
|
|
|
.location-type-park {
|
|
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
|
|
}
|
|
|
|
.location-type-ride {
|
|
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
|
|
}
|
|
|
|
.location-type-company {
|
|
@apply bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100;
|
|
}
|
|
|
|
.distance-badge {
|
|
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
|
|
}
|
|
|
|
.center-marker {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
.center-marker-inner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
background: #ef4444;
|
|
border: 3px solid white;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.location-marker {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
.location-marker-inner {
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
border: 2px solid white;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.location-marker-park .location-marker-inner {
|
|
background: #10b981;
|
|
}
|
|
|
|
.location-marker-ride .location-marker-inner {
|
|
background: #3b82f6;
|
|
}
|
|
|
|
.location-marker-company .location-marker-inner {
|
|
background: #8b5cf6;
|
|
}
|
|
|
|
.radius-circle {
|
|
fill: rgba(59, 130, 246, 0.1);
|
|
stroke: #3b82f6;
|
|
stroke-width: 2;
|
|
stroke-dasharray: 5, 5;
|
|
}
|
|
|
|
.dark .radius-circle {
|
|
fill: rgba(59, 130, 246, 0.2);
|
|
}
|
|
</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">Nearby Locations</h1>
|
|
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
|
{% if center_location %}
|
|
Locations near {{ center_location.name }}
|
|
{% elif center_lat and center_lng %}
|
|
Locations near {{ center_lat|floatformat:4 }}, {{ center_lng|floatformat:4 }}
|
|
{% else %}
|
|
Find locations near a specific point
|
|
{% endif %}
|
|
</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>Universal Map
|
|
</a>
|
|
<a href="{% url 'maps:park_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-map"></i>Park Map
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search for New Location -->
|
|
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
|
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Find Nearby Locations</h3>
|
|
|
|
<form id="location-search-form"
|
|
hx-get="{% url 'maps:nearby_locations' %}"
|
|
hx-trigger="submit"
|
|
hx-target="body"
|
|
hx-push-url="true">
|
|
|
|
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-3">
|
|
<!-- Search by Address/Name -->
|
|
<div class="md:col-span-2">
|
|
<label for="search-location" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Search Location
|
|
</label>
|
|
<input type="text" name="q" id="search-location"
|
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
placeholder="Search by park name, address, or coordinates..."
|
|
value="{{ request.GET.q|default:'' }}"
|
|
hx-get="{% url 'maps:htmx_geocode' %}"
|
|
hx-trigger="input changed delay:500ms"
|
|
hx-target="#geocode-suggestions"
|
|
hx-indicator="#geocode-loading">
|
|
</div>
|
|
|
|
<!-- Radius -->
|
|
<div>
|
|
<label for="radius" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Radius (miles)
|
|
</label>
|
|
<input type="number" name="radius" id="radius"
|
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
placeholder="50" min="1" max="500"
|
|
value="{{ request.GET.radius|default:'50' }}">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Geocoding suggestions -->
|
|
<div id="geocode-suggestions" class="mb-4"></div>
|
|
<div id="geocode-loading" class="htmx-indicator mb-4">
|
|
<div class="flex items-center justify-center p-2">
|
|
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
|
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Searching locations...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location Type Filters -->
|
|
<div class="mb-4">
|
|
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Location Types</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<label class="location-type-badge location-type-park cursor-pointer">
|
|
<input type="checkbox" name="types" value="park" class="hidden type-checkbox"
|
|
{% if 'park' in location_types %}checked{% endif %}>
|
|
<i class="mr-1 fas fa-tree"></i>Parks
|
|
</label>
|
|
<label class="location-type-badge location-type-ride cursor-pointer">
|
|
<input type="checkbox" name="types" value="ride" class="hidden type-checkbox"
|
|
{% if 'ride' in location_types %}checked{% endif %}>
|
|
<i class="mr-1 fas fa-rocket"></i>Rides
|
|
</label>
|
|
<label class="location-type-badge location-type-company cursor-pointer">
|
|
<input type="checkbox" name="types" value="company" class="hidden type-checkbox"
|
|
{% if 'company' in location_types %}checked{% endif %}>
|
|
<i class="mr-1 fas fa-building"></i>Companies
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit"
|
|
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
|
<i class="mr-2 fas fa-search"></i>Search Nearby
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{% if center_lat and center_lng %}
|
|
<!-- Results Section -->
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
<!-- 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">Map View</h3>
|
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ nearby_locations|length }} location{{ nearby_locations|length|pluralize }} found
|
|
</div>
|
|
</div>
|
|
<div id="map-container" class="map-container"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location List -->
|
|
<div class="space-y-4">
|
|
<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">Nearby Locations</h3>
|
|
|
|
{% if nearby_locations %}
|
|
<div id="location-list" class="space-y-3">
|
|
{% for location in nearby_locations %}
|
|
<div class="location-card"
|
|
data-location-id="{{ location.id }}"
|
|
data-location-type="{{ location.type }}"
|
|
data-lat="{{ location.latitude }}"
|
|
data-lng="{{ location.longitude }}"
|
|
onclick="nearbyMap.selectLocation('{{ location.type }}', {{ location.id }})">
|
|
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
|
{{ location.name }}
|
|
</h4>
|
|
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
|
{{ location.formatted_location|default:"Location not specified" }}
|
|
</p>
|
|
<div class="flex items-center gap-2 mt-2">
|
|
<span class="location-type-badge location-type-{{ location.type }}">
|
|
{% if location.type == 'park' %}
|
|
<i class="mr-1 fas fa-tree"></i>Park
|
|
{% elif location.type == 'ride' %}
|
|
<i class="mr-1 fas fa-rocket"></i>Ride
|
|
{% elif location.type == 'company' %}
|
|
<i class="mr-1 fas fa-building"></i>Company
|
|
{% endif %}
|
|
</span>
|
|
<span class="distance-badge">
|
|
<i class="mr-1 fas fa-route"></i>{{ location.distance|floatformat:1 }} miles
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if location.type == 'park' and location.ride_count %}
|
|
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
|
<i class="mr-1 fas fa-rocket"></i>{{ location.ride_count }} ride{{ location.ride_count|pluralize }}
|
|
</div>
|
|
{% elif location.type == 'ride' and location.park_name %}
|
|
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
|
<i class="mr-1 fas fa-tree"></i>{{ location.park_name }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center py-8">
|
|
<i class="fas fa-search text-4xl text-gray-400 mb-4"></i>
|
|
<p class="text-gray-600 dark:text-gray-400">No locations found within {{ radius }} miles.</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">Try increasing the search radius or adjusting the location types.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<!-- No search performed yet -->
|
|
<div class="text-center py-12">
|
|
<i class="fas fa-map-marked-alt text-6xl text-gray-400 mb-6"></i>
|
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Find Nearby Locations</h3>
|
|
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
|
Enter a location above to discover theme parks, rides, and companies in the area.
|
|
</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Location Details Modal -->
|
|
<div id="location-modal" class="fixed inset-0 z-50 hidden">
|
|
<!-- Modal content will be loaded here via HTMX -->
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
<script>
|
|
// Nearby locations map class
|
|
class NearbyMap {
|
|
constructor(containerId, options = {}) {
|
|
this.containerId = containerId;
|
|
this.options = {
|
|
center: [{{ center_lat|default:39.8283 }}, {{ center_lng|default:-98.5795 }}],
|
|
radius: {{ radius|default:50 }},
|
|
...options
|
|
};
|
|
this.map = null;
|
|
this.markers = [];
|
|
this.centerMarker = null;
|
|
this.radiusCircle = null;
|
|
this.selectedLocation = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Initialize the map
|
|
this.map = L.map(this.containerId, {
|
|
center: this.options.center,
|
|
zoom: this.calculateZoom(this.options.radius),
|
|
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);
|
|
|
|
// Add center marker and radius circle
|
|
this.addCenterMarker();
|
|
this.addRadiusCircle();
|
|
|
|
// Add location markers
|
|
this.addLocationMarkers();
|
|
}
|
|
|
|
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']
|
|
});
|
|
}
|
|
|
|
calculateZoom(radiusMiles) {
|
|
// Rough calculation to fit radius in view
|
|
if (radiusMiles <= 10) return 11;
|
|
if (radiusMiles <= 25) return 9;
|
|
if (radiusMiles <= 50) return 8;
|
|
if (radiusMiles <= 100) return 7;
|
|
if (radiusMiles <= 250) return 6;
|
|
return 5;
|
|
}
|
|
|
|
addCenterMarker() {
|
|
const icon = L.divIcon({
|
|
className: 'center-marker',
|
|
html: '<div class="center-marker-inner">📍</div>',
|
|
iconSize: [24, 24],
|
|
iconAnchor: [12, 12]
|
|
});
|
|
|
|
this.centerMarker = L.marker(this.options.center, { icon });
|
|
this.centerMarker.bindPopup('Search Center');
|
|
this.centerMarker.addTo(this.map);
|
|
}
|
|
|
|
addRadiusCircle() {
|
|
// Convert miles to meters for radius
|
|
const radiusMeters = this.options.radius * 1609.34;
|
|
|
|
this.radiusCircle = L.circle(this.options.center, {
|
|
radius: radiusMeters,
|
|
className: 'radius-circle',
|
|
fillOpacity: 0.1,
|
|
color: '#3b82f6',
|
|
weight: 2,
|
|
dashArray: '5, 5'
|
|
});
|
|
|
|
this.radiusCircle.addTo(this.map);
|
|
}
|
|
|
|
addLocationMarkers() {
|
|
{% if nearby_locations %}
|
|
const locations = {{ nearby_locations|safe }};
|
|
|
|
locations.forEach(location => {
|
|
this.addLocationMarker(location);
|
|
});
|
|
{% endif %}
|
|
}
|
|
|
|
addLocationMarker(location) {
|
|
const icon = this.getLocationIcon(location.type);
|
|
const marker = L.marker([location.latitude, location.longitude], { icon });
|
|
|
|
// Create popup content
|
|
const popupContent = this.createLocationPopupContent(location);
|
|
marker.bindPopup(popupContent, { maxWidth: 300 });
|
|
|
|
// Add click handler
|
|
marker.on('click', () => {
|
|
this.selectLocation(location.type, location.id);
|
|
});
|
|
|
|
marker.addTo(this.map);
|
|
this.markers.push({ marker, location });
|
|
}
|
|
|
|
getLocationIcon(type) {
|
|
const typeClass = `location-marker-${type}`;
|
|
const icons = {
|
|
'park': '🎢',
|
|
'ride': '🎠',
|
|
'company': '🏢'
|
|
};
|
|
|
|
return L.divIcon({
|
|
className: `location-marker ${typeClass}`,
|
|
html: `<div class="location-marker-inner">${icons[type] || '📍'}</div>`,
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
});
|
|
}
|
|
|
|
createLocationPopupContent(location) {
|
|
const typeIcons = {
|
|
'park': 'fas fa-tree',
|
|
'ride': 'fas fa-rocket',
|
|
'company': 'fas fa-building'
|
|
};
|
|
|
|
return `
|
|
<div class="text-center">
|
|
<h3 class="font-semibold mb-2">${location.name}</h3>
|
|
<div class="text-sm text-gray-600 mb-2">
|
|
<i class="${typeIcons[location.type]} mr-1"></i>
|
|
${location.type.charAt(0).toUpperCase() + location.type.slice(1)}
|
|
</div>
|
|
<div class="text-sm text-gray-600 mb-2">
|
|
<i class="fas fa-route mr-1"></i>
|
|
${location.distance.toFixed(1)} miles away
|
|
</div>
|
|
${location.formatted_location ? `<div class="text-xs text-gray-500 mb-3">${location.formatted_location}</div>` : ''}
|
|
<button onclick="nearbyMap.showLocationDetails('${location.type}', ${location.id})"
|
|
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
|
|
View Details
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
selectLocation(type, id) {
|
|
// Remove previous selection
|
|
document.querySelectorAll('.location-card.selected').forEach(card => {
|
|
card.classList.remove('selected');
|
|
});
|
|
|
|
// Add selection to new location
|
|
const card = document.querySelector(`[data-location-type="${type}"][data-location-id="${id}"]`);
|
|
if (card) {
|
|
card.classList.add('selected');
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
// Find and highlight marker
|
|
const markerData = this.markers.find(m =>
|
|
m.location.type === type && m.location.id === id
|
|
);
|
|
|
|
if (markerData) {
|
|
// Temporarily highlight the marker
|
|
markerData.marker.openPopup();
|
|
this.map.setView([markerData.location.latitude, markerData.location.longitude],
|
|
Math.max(this.map.getZoom(), 12));
|
|
}
|
|
|
|
this.selectedLocation = { type, id };
|
|
}
|
|
|
|
showLocationDetails(type, id) {
|
|
htmx.ajax('GET', `{% url 'maps:htmx_location_detail' 'type' 0 %}`.replace('type', type).replace('0', id), {
|
|
target: '#location-modal',
|
|
swap: 'innerHTML'
|
|
}).then(() => {
|
|
document.getElementById('location-modal').classList.remove('hidden');
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize map when page loads
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
{% if center_lat and center_lng %}
|
|
window.nearbyMap = new NearbyMap('map-container', {
|
|
center: [{{ center_lat }}, {{ center_lng }}],
|
|
radius: {{ radius|default:50 }}
|
|
});
|
|
{% endif %}
|
|
|
|
// Handle location type filter toggles
|
|
document.querySelectorAll('.location-type-badge').forEach(badge => {
|
|
const checkbox = badge.querySelector('input[type="checkbox"]');
|
|
|
|
// Set initial state
|
|
if (checkbox && checkbox.checked) {
|
|
badge.style.opacity = '1';
|
|
} else if (checkbox) {
|
|
badge.style.opacity = '0.5';
|
|
}
|
|
|
|
badge.addEventListener('click', () => {
|
|
if (checkbox) {
|
|
checkbox.checked = !checkbox.checked;
|
|
badge.style.opacity = checkbox.checked ? '1' : '0.5';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close modal handler
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.id === 'location-modal') {
|
|
document.getElementById('location-modal').classList.add('hidden');
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %} |