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,332 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Location Search - ThrillWiki{% endblock %}
{% block extra_head %}
<style>
.search-result-card {
transition: all 0.2s ease-in-out;
}
.search-result-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.distance-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.content-type-badge {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Search Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Location Search Results
</h1>
<p class="text-gray-600 dark:text-gray-300">
Found {{ total_results }} result{{ total_results|pluralize }} across parks, rides, and companies
</p>
</div>
<div class="flex flex-col lg:flex-row gap-8">
<!-- Enhanced Search Filters -->
<div class="lg:w-1/4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 sticky top-4">
<h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Search Filters</h2>
<form hx-get="{% url 'search:location_search' %}"
hx-target="#search-results"
hx-swap="outerHTML"
hx-indicator="#search-loading"
class="space-y-4">
<!-- Text Search -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ search_form.q.label }}
</label>
{{ search_form.q }}
</div>
<!-- Location Search -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ search_form.location.label }}
</label>
<div class="space-y-2">
{{ search_form.location }}
{{ search_form.lat }}
{{ search_form.lng }}
<div class="flex gap-2">
<button type="button"
id="use-my-location"
class="flex-1 px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300">
📍 Use My Location
</button>
</div>
</div>
</div>
<!-- Radius -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ search_form.radius_km.label }}
</label>
{{ search_form.radius_km }}
</div>
<!-- Content Types -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search In
</label>
<div class="space-y-2">
<label class="flex items-center">
{{ search_form.search_parks }}
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ search_form.search_parks.label }}</span>
</label>
<label class="flex items-center">
{{ search_form.search_rides }}
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ search_form.search_rides.label }}</span>
</label>
<label class="flex items-center">
{{ search_form.search_companies }}
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ search_form.search_companies.label }}</span>
</label>
</div>
</div>
<!-- Geographic Filters -->
<div class="border-t pt-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Geographic Filters</h3>
<div class="space-y-2">
{{ search_form.country }}
{{ search_form.state }}
{{ search_form.city }}
</div>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
🔍 Search
</button>
{% if request.GET %}
<a href="{% url 'search:location_search' %}"
class="block w-full text-center bg-gray-100 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-200 transition-colors dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600">
Clear Filters
</a>
{% endif %}
</form>
</div>
</div>
<!-- Results Section -->
<div class="lg:w-3/4" id="search-results">
<!-- Loading indicator -->
<div id="search-loading" class="hidden">
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
{% if results %}
<!-- Results Summary -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-6 p-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ total_results }} Result{{ total_results|pluralize }} Found
</h2>
{% if has_location_filter %}
<p class="text-sm text-gray-600 dark:text-gray-400">
Sorted by distance from your location
</p>
{% endif %}
</div>
<!-- Quick Stats -->
<div class="flex gap-4 text-sm">
{% if grouped_results.parks %}
<span class="bg-green-100 text-green-800 px-2 py-1 rounded dark:bg-green-900 dark:text-green-300">
{{ grouped_results.parks|length }} Park{{ grouped_results.parks|length|pluralize }}
</span>
{% endif %}
{% if grouped_results.rides %}
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded dark:bg-blue-900 dark:text-blue-300">
{{ grouped_results.rides|length }} Ride{{ grouped_results.rides|length|pluralize }}
</span>
{% endif %}
{% if grouped_results.companies %}
<span class="bg-purple-100 text-purple-800 px-2 py-1 rounded dark:bg-purple-900 dark:text-purple-300">
{{ grouped_results.companies|length }} Compan{{ grouped_results.companies|length|pluralize:"y,ies" }}
</span>
{% endif %}
</div>
</div>
</div>
<!-- Search Results -->
<div class="space-y-4">
{% for result in results %}
<div class="search-result-card bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<!-- Header with type badge -->
<div class="flex items-center gap-3 mb-2">
<span class="content-type-badge px-2 py-1 rounded-full text-xs
{% if result.content_type == 'park' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300
{% elif result.content_type == 'ride' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300
{% else %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300{% endif %}">
{{ result.content_type|title }}
</span>
{% if result.distance_km %}
<span class="distance-badge text-white px-2 py-1 rounded-full text-xs">
{{ result.distance_km|floatformat:1 }} km away
</span>
{% endif %}
</div>
<!-- Title -->
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{% if result.url %}
<a href="{{ result.url }}" class="hover:text-blue-600 dark:hover:text-blue-400">
{{ result.name }}
</a>
{% else %}
{{ result.name }}
{% endif %}
</h3>
<!-- Description -->
{% if result.description %}
<p class="text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
{{ result.description }}
</p>
{% endif %}
<!-- Location Info -->
{% if result.city or result.address %}
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400 mb-2">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
{% if result.address %}
{{ result.address }}
{% else %}
{{ result.city }}{% if result.state %}, {{ result.state }}{% endif %}
{% endif %}
</div>
{% endif %}
<!-- Tags and Status -->
<div class="flex flex-wrap gap-2">
{% if result.status %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
{{ result.status }}
</span>
{% endif %}
{% if result.rating %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300">
★ {{ result.rating|floatformat:1 }}
</span>
{% endif %}
{% for tag in result.tags %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
{{ tag|title }}
</span>
{% endfor %}
</div>
</div>
<!-- Map link -->
{% if result.latitude and result.longitude %}
<div class="ml-4">
<a href="{% url 'maps:universal_map' %}?lat={{ result.latitude }}&lng={{ result.longitude }}&zoom=15"
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-600">
🗺️ View on Map
</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- No Results -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<div class="text-gray-400 mb-4">
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No results found</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Try adjusting your search criteria or expanding your search radius.
</p>
<div class="space-y-2 text-sm text-gray-500 dark:text-gray-400">
<p>• Try broader search terms</p>
<p>• Increase the search radius</p>
<p>• Check spelling and try different keywords</p>
<p>• Remove some filters to see more results</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Geolocation support
const useLocationBtn = document.getElementById('use-my-location');
const latInput = document.getElementById('lat-input');
const lngInput = document.getElementById('lng-input');
const locationInput = document.getElementById('location-input');
if (useLocationBtn && 'geolocation' in navigator) {
useLocationBtn.addEventListener('click', function() {
this.textContent = '📍 Getting location...';
this.disabled = true;
navigator.geolocation.getCurrentPosition(
function(position) {
latInput.value = position.coords.latitude;
lngInput.value = position.coords.longitude;
locationInput.value = `${position.coords.latitude}, ${position.coords.longitude}`;
useLocationBtn.textContent = '✅ Location set';
setTimeout(() => {
useLocationBtn.textContent = '📍 Use My Location';
useLocationBtn.disabled = false;
}, 2000);
},
function(error) {
useLocationBtn.textContent = '❌ Location failed';
console.error('Geolocation error:', error);
setTimeout(() => {
useLocationBtn.textContent = '📍 Use My Location';
useLocationBtn.disabled = false;
}, 2000);
}
);
});
} else if (useLocationBtn) {
useLocationBtn.style.display = 'none';
}
});
</script>
<script src="{% static 'js/location-search.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,577 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Location Search Results - ThrillWiki{% endblock %}
{% block extra_head %}
<style>
.location-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all cursor-pointer border border-gray-200 dark:border-gray-700;
}
.location-card:hover {
@apply border-blue-300 dark:border-blue-600 shadow-lg;
}
.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;
}
.status-badge {
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
}
.status-operating {
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
}
.status-closed {
@apply bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100;
}
.status-construction {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
}
.status-demolished {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
}
.filter-chip {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium cursor-pointer transition-all;
}
.filter-chip.active {
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
}
.filter-chip.inactive {
@apply bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600;
}
.pagination-container {
@apply flex items-center justify-between mt-8;
}
.pagination-info {
@apply text-sm text-gray-700 dark:text-gray-300;
}
.pagination-controls {
@apply flex items-center space-x-2;
}
.pagination-btn {
@apply px-3 py-2 text-sm font-medium rounded-lg border transition-colors;
}
.pagination-btn.active {
@apply bg-blue-600 text-white border-blue-600;
}
.pagination-btn.inactive {
@apply bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700;
}
.pagination-btn:disabled {
@apply opacity-50 cursor-not-allowed;
}
.search-summary {
@apply bg-blue-50 dark:bg-blue-900 dark:bg-opacity-30 rounded-lg p-4 mb-6;
}
.no-results {
@apply text-center py-12;
}
.loading-skeleton {
@apply animate-pulse;
}
.loading-skeleton .skeleton-text {
@apply bg-gray-200 dark:bg-gray-700 rounded h-4;
}
.loading-skeleton .skeleton-text.w-3-4 {
width: 75%;
}
.loading-skeleton .skeleton-text.w-1-2 {
width: 50%;
}
.loading-skeleton .skeleton-text.w-1-4 {
width: 25%;
}
</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">Search Results</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
{% if query %}
Search results for "{{ query }}"
{% else %}
Browse all locations in ThrillWiki
{% endif %}
</p>
</div>
<div class="flex gap-3">
<a href="{% url 'maps:universal_map' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
<i class="mr-2 fas fa-map"></i>View on Map
</a>
<a href="{% url 'maps:nearby_locations' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
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-search-location"></i>Find Nearby
</a>
</div>
</div>
<!-- Search and Filters -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form id="search-form" method="get" class="space-y-4">
<!-- Search Input -->
<div>
<label for="search" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<div class="relative">
<input type="text" name="q" id="search"
class="w-full pl-10 pr-4 py-2 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search by name, location, or keyword..."
value="{{ request.GET.q|default:'' }}"
hx-get="{% url 'maps:location_list' %}"
hx-trigger="input changed delay:500ms"
hx-target="#results-container"
hx-include="#search-form"
hx-indicator="#search-loading">
<i class="absolute left-3 top-1/2 transform -translate-y-1/2 fas fa-search text-gray-400"></i>
<div id="search-loading" class="htmx-indicator absolute right-3 top-1/2 transform -translate-y-1/2">
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
</div>
</div>
</div>
<!-- Filter Chips -->
<div class="space-y-3">
<!-- Location Types -->
<div>
<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="filter-chip {% if 'park' in location_types %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="types" value="park" class="hidden"
{% if 'park' in location_types %}checked{% endif %}>
<i class="mr-2 fas fa-tree"></i>Parks
</label>
<label class="filter-chip {% if 'ride' in location_types %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="types" value="ride" class="hidden"
{% if 'ride' in location_types %}checked{% endif %}>
<i class="mr-2 fas fa-rocket"></i>Rides
</label>
<label class="filter-chip {% if 'company' in location_types %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="types" value="company" class="hidden"
{% if 'company' in location_types %}checked{% endif %}>
<i class="mr-2 fas fa-building"></i>Companies
</label>
</div>
</div>
<!-- Additional Filters (for parks) -->
{% if 'park' in location_types %}
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Park Status</label>
<div class="flex flex-wrap gap-2">
<label class="filter-chip {% if 'OPERATING' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="OPERATING" class="hidden"
{% if 'OPERATING' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-check-circle"></i>Operating
</label>
<label class="filter-chip {% if 'CLOSED_TEMP' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="CLOSED_TEMP" class="hidden"
{% if 'CLOSED_TEMP' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-clock"></i>Temporarily Closed
</label>
<label class="filter-chip {% if 'CLOSED_PERM' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="CLOSED_PERM" class="hidden"
{% if 'CLOSED_PERM' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-times-circle"></i>Permanently Closed
</label>
<label class="filter-chip {% if 'UNDER_CONSTRUCTION' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="UNDER_CONSTRUCTION" class="hidden"
{% if 'UNDER_CONSTRUCTION' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-hard-hat"></i>Under Construction
</label>
</div>
</div>
{% endif %}
<!-- Location Filters -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text" name="country" id="country"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by country..."
value="{{ request.GET.country|default:'' }}">
</div>
<div>
<label for="state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
<input type="text" name="state" id="state"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by state..."
value="{{ request.GET.state|default:'' }}">
</div>
<div>
<label for="sort" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Sort By</label>
<select name="sort" id="sort"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="name" {% if request.GET.sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
<option value="location" {% if request.GET.sort == 'location' %}selected{% endif %}>Location</option>
<option value="-created_at" {% if request.GET.sort == '-created_at' %}selected{% endif %}>Recently Added</option>
{% if 'park' in location_types %}
<option value="-ride_count" {% if request.GET.sort == '-ride_count' %}selected{% endif %}>Most Rides</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="flex gap-3">
<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>Apply Filters
</button>
<a href="{% url 'maps:location_list' %}"
class="px-4 py-2 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-times"></i>Clear All
</a>
</div>
</form>
</div>
<!-- Search Summary -->
{% if locations %}
<div class="search-summary">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-100">
{{ paginator.count }} location{{ paginator.count|pluralize }} found
</h3>
<p class="text-sm text-blue-700 dark:text-blue-200 mt-1">
{% if query %}
Showing results for "{{ query }}"
{% endif %}
{% if location_types %}
• Types: {{ location_types|join:", "|title }}
{% endif %}
{% if request.GET.country %}
• Country: {{ request.GET.country }}
{% endif %}
{% if request.GET.state %}
• State: {{ request.GET.state }}
{% endif %}
</p>
</div>
<div class="text-sm text-blue-700 dark:text-blue-200">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</div>
</div>
</div>
{% endif %}
<!-- Results Container -->
<div id="results-container">
{% if locations %}
<!-- Location Cards -->
<div class="grid grid-cols-1 gap-4 mb-8 md:grid-cols-2 lg:grid-cols-3">
{% for location in locations %}
<div class="location-card"
onclick="window.location.href='{{ location.get_absolute_url }}'">
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
{{ location.name }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">
{{ location.formatted_location|default:"Location not specified" }}
</p>
</div>
<div class="flex-shrink-0 ml-3">
<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>
</div>
</div>
<!-- Type-specific information -->
{% if location.type == 'park' %}
<div class="space-y-2">
{% if location.status %}
<div class="flex items-center">
<span class="status-badge {% if location.status == 'OPERATING' %}status-operating{% elif location.status == 'CLOSED_TEMP' or location.status == 'CLOSED_PERM' %}status-closed{% elif location.status == 'UNDER_CONSTRUCTION' %}status-construction{% else %}status-demolished{% endif %}">
{% if location.status == 'OPERATING' %}
<i class="mr-1 fas fa-check-circle"></i>Operating
{% elif location.status == 'CLOSED_TEMP' %}
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
{% elif location.status == 'CLOSED_PERM' %}
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
{% elif location.status == 'UNDER_CONSTRUCTION' %}
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
{% elif location.status == 'DEMOLISHED' %}
<i class="mr-1 fas fa-ban"></i>Demolished
{% endif %}
</span>
</div>
{% endif %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
{% if location.operator %}
<i class="mr-2 fas fa-building"></i>
<span>{{ location.operator }}</span>
{% endif %}
</div>
{% if location.ride_count %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-rocket"></i>
<span>{{ location.ride_count }} ride{{ location.ride_count|pluralize }}</span>
</div>
{% endif %}
{% if location.average_rating %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-star text-yellow-500"></i>
<span>{{ location.average_rating|floatformat:1 }}/10</span>
</div>
{% endif %}
</div>
{% elif location.type == 'ride' %}
<div class="space-y-2">
{% if location.park_name %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-tree"></i>
<span>{{ location.park_name }}</span>
</div>
{% endif %}
{% if location.manufacturer %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-industry"></i>
<span>{{ location.manufacturer }}</span>
</div>
{% endif %}
{% if location.opening_date %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar"></i>
<span>Opened {{ location.opening_date.year }}</span>
</div>
{% endif %}
</div>
{% elif location.type == 'company' %}
<div class="space-y-2">
{% if location.company_type %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-tag"></i>
<span>{{ location.get_company_type_display }}</span>
</div>
{% endif %}
{% if location.founded_year %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar"></i>
<span>Founded {{ location.founded_year }}</span>
</div>
{% endif %}
</div>
{% endif %}
<!-- Action buttons -->
<div class="flex gap-2 mt-4">
<a href="{{ location.get_absolute_url }}"
class="flex-1 px-3 py-2 text-sm text-center text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
View Details
</a>
{% if location.latitude and location.longitude %}
<a href="{% url 'maps:nearby_locations' %}?lat={{ location.latitude }}&lng={{ location.longitude }}&radius=25"
class="px-3 py-2 text-sm text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors">
<i class="fas fa-search-location"></i>
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<div class="pagination-container">
<div class="pagination-info">
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ paginator.count }} results
</div>
<div class="pagination-controls">
{% if page_obj.has_previous %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page=1"
class="pagination-btn inactive">
<i class="fas fa-angle-double-left"></i>
</a>
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
class="pagination-btn inactive">
<i class="fas fa-angle-left"></i>
</a>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<span class="pagination-btn active">{{ num }}</span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
class="pagination-btn inactive">{{ num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
class="pagination-btn inactive">
<i class="fas fa-angle-right"></i>
</a>
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ paginator.num_pages }}"
class="pagination-btn inactive">
<i class="fas fa-angle-double-right"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<!-- No Results -->
<div class="no-results">
<i class="fas fa-search text-6xl text-gray-400 mb-6"></i>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">No locations found</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
{% if query %}
No results found for "{{ query }}". Try adjusting your search or filters.
{% else %}
No locations match your current filters. Try adjusting your search criteria.
{% endif %}
</p>
<div class="flex justify-center gap-3">
<a href="{% url 'maps:location_list' %}"
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
<i class="mr-2 fas fa-refresh"></i>Clear Filters
</a>
<a href="{% url 'maps:universal_map' %}"
class="px-4 py-2 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>Browse Map
</a>
</div>
</div>
{% endif %}
</div>
<!-- Loading Template for HTMX -->
<template id="loading-template">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for i in "123456789" %}
<div class="location-card loading-skeleton">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="skeleton-text w-3-4 h-6 mb-2"></div>
<div class="skeleton-text w-1-2 h-4"></div>
</div>
<div class="skeleton-text w-1-4 h-6"></div>
</div>
<div class="space-y-2">
<div class="skeleton-text w-1-2 h-4"></div>
<div class="skeleton-text w-3-4 h-4"></div>
</div>
<div class="flex gap-2 mt-4">
<div class="skeleton-text flex-1 h-8"></div>
<div class="skeleton-text w-10 h-8"></div>
</div>
</div>
{% endfor %}
</div>
</template>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle filter chip toggles
document.querySelectorAll('.filter-chip').forEach(chip => {
const checkbox = chip.querySelector('input[type="checkbox"]');
chip.addEventListener('click', (e) => {
e.preventDefault();
if (checkbox) {
checkbox.checked = !checkbox.checked;
chip.classList.toggle('active', checkbox.checked);
chip.classList.toggle('inactive', !checkbox.checked);
// Auto-submit form on filter change
document.getElementById('search-form').dispatchEvent(new Event('submit'));
}
});
});
// Handle form changes
document.getElementById('search-form').addEventListener('change', function(e) {
if (e.target.name !== 'q') { // Don't auto-submit on search input changes
this.submit();
}
});
// Show loading state during HTMX requests
document.addEventListener('htmx:beforeRequest', function(event) {
if (event.target.id === 'results-container') {
const template = document.getElementById('loading-template');
event.target.innerHTML = template.innerHTML;
}
});
// Handle HTMX errors
document.addEventListener('htmx:responseError', function(event) {
console.error('Search request failed:', event.detail);
event.target.innerHTML = `
<div class="text-center py-12">
<i class="fas fa-exclamation-triangle text-6xl text-red-400 mb-6"></i>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Search Error</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">There was an error performing your search. Please try again.</p>
<button onclick="location.reload()"
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
<i class="mr-2 fas fa-refresh"></i>Retry
</button>
</div>
`;
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,581 @@
{% 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 %}

View File

@@ -0,0 +1,618 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{{ page_title }} - ThrillWiki{% endblock %}
{% block extra_head %}
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<style>
.map-container {
height: 75vh;
min-height: 600px;
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-status-badge {
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
}
.park-status-operating {
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
}
.park-status-closed {
@apply bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100;
}
.park-status-construction {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
}
.park-status-demolished {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
}
.park-marker {
background: transparent;
border: none;
}
.park-marker-inner {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 18px;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.park-marker-operating {
background: #10b981;
}
.park-marker-closed {
background: #ef4444;
}
.park-marker-construction {
background: #f59e0b;
}
.park-marker-demolished {
background: #6b7280;
}
.park-info-popup {
max-width: 350px;
}
.park-info-popup h3 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
font-weight: 600;
}
.park-info-popup .park-meta {
margin: 0.25rem 0;
font-size: 0.9rem;
color: #666;
}
.dark .park-info-popup .park-meta {
color: #ccc;
}
.quick-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.quick-stat-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-4 text-center shadow-sm;
}
.quick-stat-value {
@apply text-2xl font-bold text-blue-600 dark:text-blue-400;
}
.quick-stat-label {
@apply text-sm text-gray-600 dark:text-gray-400 mt-1;
}
</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">{{ page_title }}</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Discover theme parks and amusement parks worldwide
</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>All Locations
</a>
<a href="{% url 'parks:roadtrip_planner' %}"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors">
<i class="mr-2 fas fa-route"></i>Plan Road Trip
</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>List View
</a>
</div>
</div>
<!-- Quick Stats -->
<div class="quick-stats mb-6" id="park-stats">
<div class="quick-stat-card">
<div class="quick-stat-value" id="total-parks">-</div>
<div class="quick-stat-label">Total Parks</div>
</div>
<div class="quick-stat-card">
<div class="quick-stat-value" id="operating-parks">-</div>
<div class="quick-stat-label">Operating</div>
</div>
<div class="quick-stat-card">
<div class="quick-stat-value" id="countries-count">-</div>
<div class="quick-stat-label">Countries</div>
</div>
<div class="quick-stat-card">
<div class="quick-stat-value" id="total-rides">-</div>
<div class="quick-stat-label">Total Rides</div>
</div>
</div>
<!-- Filters Panel -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form id="park-filters"
hx-get="{% url 'maps:htmx_filter' %}"
hx-trigger="change, submit"
hx-target="#map-container"
hx-swap="none"
hx-push-url="false">
<!-- Hidden input to specify park-only filtering -->
<input type="hidden" name="types" value="park">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Search -->
<div>
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search Parks</label>
<input type="text" name="q" id="search"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search park names..."
hx-get="{% url 'maps:htmx_search' %}"
hx-trigger="input changed delay:500ms"
hx-target="#search-results"
hx-indicator="#search-loading">
</div>
<!-- Country -->
<div>
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text" name="country" id="country"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by country...">
</div>
<!-- State/Region -->
<div>
<label for="state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
<input type="text" name="state" id="state"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by state...">
</div>
<!-- Clustering Toggle -->
<div class="flex items-end">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="cluster" value="true" id="cluster-toggle"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
checked>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Group Nearby Parks</span>
</label>
</div>
</div>
<!-- Park Status Filters -->
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Park Status</label>
<div class="flex flex-wrap gap-2">
<label class="park-status-badge park-status-operating cursor-pointer">
<input type="checkbox" name="park_status" value="OPERATING" class="hidden status-checkbox" checked>
<i class="mr-1 fas fa-check-circle"></i>Operating
</label>
<label class="park-status-badge park-status-closed cursor-pointer">
<input type="checkbox" name="park_status" value="CLOSED_TEMP" class="hidden status-checkbox">
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
</label>
<label class="park-status-badge park-status-closed cursor-pointer">
<input type="checkbox" name="park_status" value="CLOSED_PERM" class="hidden status-checkbox">
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
</label>
<label class="park-status-badge park-status-construction cursor-pointer">
<input type="checkbox" name="park_status" value="UNDER_CONSTRUCTION" class="hidden status-checkbox">
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
</label>
<label class="park-status-badge park-status-demolished cursor-pointer">
<input type="checkbox" name="park_status" value="DEMOLISHED" class="hidden status-checkbox">
<i class="mr-1 fas fa-ban"></i>Demolished
</label>
</div>
</div>
</form>
<!-- Search Results -->
<div id="search-results" class="mt-4"></div>
<div id="search-loading" class="htmx-indicator">
<div class="flex items-center justify-center p-4">
<div class="w-6 h-6 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 parks...</span>
</div>
</div>
</div>
<!-- Map Container -->
<div class="relative">
<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 park data...</p>
</div>
</div>
</div>
<!-- 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>
<!-- Leaflet MarkerCluster JS -->
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script>
// Park-specific map class
class ParkMap {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
center: [39.8283, -98.5795],
zoom: 4,
enableClustering: true,
...options
};
this.map = null;
this.markers = new L.MarkerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false
});
this.currentData = [];
this.init();
}
init() {
// Initialize the map
this.map = L.map(this.containerId, {
center: this.options.center,
zoom: this.options.zoom,
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 markers cluster group
this.map.addLayer(this.markers);
// Bind map events
this.bindEvents();
// Load initial data
this.loadMapData();
}
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']
});
}
bindEvents() {
// Update map when bounds change
this.map.on('moveend zoomend', () => {
this.updateMapBounds();
});
// Handle filter form changes
document.getElementById('park-filters').addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
this.loadMapData();
}
});
}
async loadMapData() {
try {
document.getElementById('map-loading').style.display = 'flex';
const formData = new FormData(document.getElementById('park-filters'));
const params = new URLSearchParams();
// Add form data to params
for (let [key, value] of formData.entries()) {
params.append(key, value);
}
// Add map bounds
const bounds = this.map.getBounds();
params.append('north', bounds.getNorth());
params.append('south', bounds.getSouth());
params.append('east', bounds.getEast());
params.append('west', bounds.getWest());
params.append('zoom', this.map.getZoom());
const response = await fetch(`{{ map_api_urls.locations }}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.updateMarkers(data.data);
this.updateStats(data.data);
} else {
console.error('Park data error:', data.message);
}
} catch (error) {
console.error('Failed to load park data:', error);
} finally {
document.getElementById('map-loading').style.display = 'none';
}
}
updateStats(data) {
// Update quick stats
const totalParks = (data.locations || []).length + (data.clusters || []).reduce((sum, cluster) => sum + cluster.count, 0);
const operatingParks = (data.locations || []).filter(park => park.status === 'OPERATING').length;
const countries = new Set((data.locations || []).map(park => park.country).filter(Boolean)).size;
const totalRides = (data.locations || []).reduce((sum, park) => sum + (park.ride_count || 0), 0);
document.getElementById('total-parks').textContent = totalParks.toLocaleString();
document.getElementById('operating-parks').textContent = operatingParks.toLocaleString();
document.getElementById('countries-count').textContent = countries.toLocaleString();
document.getElementById('total-rides').textContent = totalRides.toLocaleString();
}
updateMarkers(data) {
// Clear existing markers
this.markers.clearLayers();
// Add park markers
if (data.locations) {
data.locations.forEach(park => {
this.addParkMarker(park);
});
}
// Add cluster markers
if (data.clusters) {
data.clusters.forEach(cluster => {
this.addClusterMarker(cluster);
});
}
}
addParkMarker(park) {
const icon = this.getParkIcon(park.status);
const marker = L.marker([park.latitude, park.longitude], { icon });
// Create popup content
const popupContent = this.createParkPopupContent(park);
marker.bindPopup(popupContent, { maxWidth: 350 });
// Add click handler for detailed view
marker.on('click', () => {
this.showParkDetails(park.id);
});
this.markers.addLayer(marker);
}
addClusterMarker(cluster) {
const marker = L.marker([cluster.latitude, cluster.longitude], {
icon: L.divIcon({
className: 'cluster-marker',
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
iconSize: [40, 40]
})
});
marker.bindPopup(`${cluster.count} parks in this area`);
this.markers.addLayer(marker);
}
getParkIcon(status) {
const statusClass = {
'OPERATING': 'park-marker-operating',
'CLOSED_TEMP': 'park-marker-closed',
'CLOSED_PERM': 'park-marker-closed',
'UNDER_CONSTRUCTION': 'park-marker-construction',
'DEMOLISHED': 'park-marker-demolished'
}[status] || 'park-marker-operating';
return L.divIcon({
className: 'park-marker',
html: `<div class="park-marker-inner ${statusClass}">🎢</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
}
createParkPopupContent(park) {
const statusClass = {
'OPERATING': 'park-status-operating',
'CLOSED_TEMP': 'park-status-closed',
'CLOSED_PERM': 'park-status-closed',
'UNDER_CONSTRUCTION': 'park-status-construction',
'DEMOLISHED': 'park-status-demolished'
}[park.status] || 'park-status-operating';
return `
<div class="park-info-popup">
<h3>${park.name}</h3>
<div class="park-meta">
<span class="park-status-badge ${statusClass}">
${this.getStatusDisplayName(park.status)}
</span>
</div>
${park.formatted_location ? `<div class="park-meta"><i class="fas fa-map-marker-alt mr-1"></i>${park.formatted_location}</div>` : ''}
${park.operator ? `<div class="park-meta"><i class="fas fa-building mr-1"></i>${park.operator}</div>` : ''}
${park.ride_count ? `<div class="park-meta"><i class="fas fa-rocket mr-1"></i>${park.ride_count} rides</div>` : ''}
${park.average_rating ? `<div class="park-meta"><i class="fas fa-star mr-1"></i>${park.average_rating}/10 rating</div>` : ''}
<div class="mt-3 flex gap-2">
<button onclick="parkMap.showParkDetails(${park.id})"
class="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">
View Details
</button>
<a href="/parks/${park.slug}/"
class="px-3 py-1 text-sm text-blue-600 border border-blue-600 rounded hover:bg-blue-50">
Visit Page
</a>
</div>
</div>
`;
}
getStatusDisplayName(status) {
const statusMap = {
'OPERATING': 'Operating',
'CLOSED_TEMP': 'Temporarily Closed',
'CLOSED_PERM': 'Permanently Closed',
'UNDER_CONSTRUCTION': 'Under Construction',
'DEMOLISHED': 'Demolished'
};
return statusMap[status] || 'Unknown';
}
showParkDetails(parkId) {
htmx.ajax('GET', `{% url 'maps:htmx_location_detail' 'park' 0 %}`.replace('0', parkId), {
target: '#location-modal',
swap: 'innerHTML'
}).then(() => {
document.getElementById('location-modal').classList.remove('hidden');
});
}
updateMapBounds() {
// Reload data when the map moves significantly
clearTimeout(this.boundsUpdateTimeout);
this.boundsUpdateTimeout = setTimeout(() => {
this.loadMapData();
}, 1000);
}
}
// Initialize map when page loads
document.addEventListener('DOMContentLoaded', function() {
window.parkMap = new ParkMap('map-container', {
enableClustering: {{ enable_clustering|yesno:"true,false" }}
});
// Handle status filter toggles
document.querySelectorAll('.park-status-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';
// Trigger form change
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
});
});
// Close modal handler
document.addEventListener('click', (e) => {
if (e.target.id === 'location-modal') {
document.getElementById('location-modal').classList.add('hidden');
}
});
});
</script>
<style>
.cluster-marker {
background: transparent;
border: none;
}
.cluster-marker-inner {
background: #3b82f6;
color: white;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.dark .cluster-marker-inner {
border-color: #374151;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,432 @@
<!-- Reusable Filter Panel Component -->
<div class="filter-panel {% if panel_classes %}{{ panel_classes }}{% endif %}">
<form id="{{ form_id|default:'filters-form' }}"
method="get"
{% if hx_target %}hx-get="{{ hx_url }}" hx-trigger="{{ hx_trigger|default:'change, submit' }}" hx-target="{{ hx_target }}" hx-swap="{{ hx_swap|default:'none' }}" hx-push-url="false"{% endif %}
class="space-y-4">
<!-- Search Input -->
{% if show_search %}
<div>
<label for="{{ form_id }}-search" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ search_label|default:"Search" }}
</label>
<div class="relative">
<input type="text"
name="q"
id="{{ form_id }}-search"
class="w-full pl-10 pr-4 py-2 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="{{ search_placeholder|default:'Search by name, location, or keyword...' }}"
value="{{ request.GET.q|default:'' }}"
{% if search_hx_url %}
hx-get="{{ search_hx_url }}"
hx-trigger="input changed delay:500ms"
hx-target="{{ search_hx_target|default:'#search-results' }}"
hx-indicator="{{ search_hx_indicator|default:'#search-loading' }}"
{% endif %}>
<i class="absolute left-3 top-1/2 transform -translate-y-1/2 fas fa-search text-gray-400"></i>
{% if search_hx_url %}
<div id="{{ search_hx_indicator|default:'search-loading' }}" class="htmx-indicator absolute right-3 top-1/2 transform -translate-y-1/2">
<div class="w-4 h-4 border-2 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
</div>
{% endif %}
</div>
{% if search_hx_url %}
<div id="{{ search_hx_target|default:'search-results' }}" class="mt-2"></div>
{% endif %}
</div>
{% endif %}
<!-- Location Type Filters -->
{% if show_location_types %}
<div>
<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="filter-chip {% if 'park' in location_types %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="types" value="park" class="hidden"
{% if 'park' in location_types %}checked{% endif %}>
<i class="mr-2 fas fa-tree"></i>Parks
</label>
<label class="filter-chip {% if 'ride' in location_types %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="types" value="ride" class="hidden"
{% if 'ride' in location_types %}checked{% endif %}>
<i class="mr-2 fas fa-rocket"></i>Rides
</label>
<label class="filter-chip {% if 'company' in location_types %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="types" value="company" class="hidden"
{% if 'company' in location_types %}checked{% endif %}>
<i class="mr-2 fas fa-building"></i>Companies
</label>
</div>
</div>
{% endif %}
<!-- Park Status Filters -->
{% if show_park_status and 'park' in location_types %}
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Park Status</label>
<div class="flex flex-wrap gap-2">
<label class="filter-chip {% if 'OPERATING' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="OPERATING" class="hidden"
{% if 'OPERATING' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-check-circle"></i>Operating
</label>
<label class="filter-chip {% if 'CLOSED_TEMP' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="CLOSED_TEMP" class="hidden"
{% if 'CLOSED_TEMP' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-clock"></i>Temporarily Closed
</label>
<label class="filter-chip {% if 'CLOSED_PERM' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="CLOSED_PERM" class="hidden"
{% if 'CLOSED_PERM' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-times-circle"></i>Permanently Closed
</label>
<label class="filter-chip {% if 'UNDER_CONSTRUCTION' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="UNDER_CONSTRUCTION" class="hidden"
{% if 'UNDER_CONSTRUCTION' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-hard-hat"></i>Under Construction
</label>
<label class="filter-chip {% if 'DEMOLISHED' in park_statuses %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="park_status" value="DEMOLISHED" class="hidden"
{% if 'DEMOLISHED' in park_statuses %}checked{% endif %}>
<i class="mr-2 fas fa-ban"></i>Demolished
</label>
</div>
</div>
{% endif %}
<!-- Location Filters -->
{% if show_location_filters %}
<div class="grid grid-cols-1 gap-4 md:grid-cols-{{ location_filter_columns|default:'3' }}">
{% if show_country %}
<div>
<label for="{{ form_id }}-country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text"
name="country"
id="{{ form_id }}-country"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by country..."
value="{{ request.GET.country|default:'' }}">
</div>
{% endif %}
{% if show_state %}
<div>
<label for="{{ form_id }}-state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
<input type="text"
name="state"
id="{{ form_id }}-state"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by state..."
value="{{ request.GET.state|default:'' }}">
</div>
{% endif %}
{% if show_city %}
<div>
<label for="{{ form_id }}-city" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<input type="text"
name="city"
id="{{ form_id }}-city"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by city..."
value="{{ request.GET.city|default:'' }}">
</div>
{% endif %}
</div>
{% endif %}
<!-- Distance/Radius Filter -->
{% if show_radius %}
<div>
<label for="{{ form_id }}-radius" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ radius_label|default:"Search Radius" }} (miles)
</label>
<div class="flex items-center space-x-4">
<input type="range"
name="radius"
id="{{ form_id }}-radius"
min="{{ radius_min|default:'1' }}"
max="{{ radius_max|default:'500' }}"
value="{{ request.GET.radius|default:'50' }}"
class="flex-1"
oninput="document.getElementById('{{ form_id }}-radius-value').textContent = this.value">
<span id="{{ form_id }}-radius-value" class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-16">
{{ request.GET.radius|default:'50' }}
</span>
</div>
</div>
{% endif %}
<!-- Sorting -->
{% if show_sort %}
<div>
<label for="{{ form_id }}-sort" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Sort By</label>
<select name="sort"
id="{{ form_id }}-sort"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
{% for value, label in sort_options %}
<option value="{{ value }}" {% if request.GET.sort == value %}selected{% endif %}>{{ label }}</option>
{% empty %}
<option value="name" {% if request.GET.sort == 'name' %}selected{% endif %}>Name (A-Z)</option>
<option value="-name" {% if request.GET.sort == '-name' %}selected{% endif %}>Name (Z-A)</option>
<option value="location" {% if request.GET.sort == 'location' %}selected{% endif %}>Location</option>
<option value="-created_at" {% if request.GET.sort == '-created_at' %}selected{% endif %}>Recently Added</option>
{% endfor %}
</select>
</div>
{% endif %}
<!-- Map Options -->
{% if show_map_options %}
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">Map Options</label>
<div class="space-y-2">
{% if show_clustering_toggle %}
<label class="flex items-center cursor-pointer">
<input type="checkbox"
name="cluster"
value="true"
id="{{ form_id }}-cluster"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
{% if enable_clustering %}checked{% endif %}>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Group Nearby Locations</span>
</label>
{% endif %}
{% if show_satellite_toggle %}
<label class="flex items-center cursor-pointer">
<input type="checkbox"
name="satellite"
value="true"
id="{{ form_id }}-satellite"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Satellite View</span>
</label>
{% endif %}
</div>
</div>
{% endif %}
<!-- Custom Filter Sections -->
{% if custom_filters %}
{% for filter_section in custom_filters %}
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ filter_section.title }}</label>
{% if filter_section.type == 'checkboxes' %}
<div class="flex flex-wrap gap-2">
{% for option in filter_section.options %}
<label class="filter-chip {% if option.value in filter_section.selected %}active{% else %}inactive{% endif %}">
<input type="checkbox" name="{{ filter_section.name }}" value="{{ option.value }}" class="hidden"
{% if option.value in filter_section.selected %}checked{% endif %}>
{% if option.icon %}<i class="mr-2 {{ option.icon }}"></i>{% endif %}{{ option.label }}
</label>
{% endfor %}
</div>
{% elif filter_section.type == 'select' %}
<select name="{{ filter_section.name }}"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">{{ filter_section.placeholder|default:"All" }}</option>
{% for option in filter_section.options %}
<option value="{{ option.value }}" {% if option.value == filter_section.selected %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
{% elif filter_section.type == 'range' %}
<div class="flex items-center space-x-4">
<input type="range"
name="{{ filter_section.name }}"
min="{{ filter_section.min }}"
max="{{ filter_section.max }}"
value="{{ filter_section.value }}"
class="flex-1"
oninput="document.getElementById('{{ filter_section.name }}-value').textContent = this.value">
<span id="{{ filter_section.name }}-value" class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-16">
{{ filter_section.value }}
</span>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
<!-- Action Buttons -->
<div class="flex gap-3 pt-2">
{% if show_submit_button %}
<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>{{ submit_text|default:"Apply Filters" }}
</button>
{% endif %}
{% if show_clear_button %}
<a href="{{ clear_url|default:request.path }}"
class="px-4 py-2 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-times"></i>{{ clear_text|default:"Clear All" }}
</a>
{% endif %}
<!-- Custom Action Buttons -->
{% if custom_actions %}
{% for action in custom_actions %}
<a href="{{ action.url }}"
class="px-4 py-2 text-sm font-medium {{ action.classes|default:'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600' }} rounded-lg transition-colors">
{% if action.icon %}<i class="mr-2 {{ action.icon }}"></i>{% endif %}{{ action.text }}
</a>
{% endfor %}
{% endif %}
</div>
</form>
</div>
<!-- Filter Panel Styles -->
<style>
.filter-panel {
@apply bg-white dark:bg-gray-800 rounded-lg shadow p-4;
}
.filter-chip {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium cursor-pointer transition-all;
}
.filter-chip.active {
@apply bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100;
}
.filter-chip.inactive {
@apply bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600;
}
.filter-chip:hover {
transform: translateY(-1px);
}
/* Custom range slider styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type="range"]::-webkit-slider-track {
background: #e5e7eb;
height: 4px;
border-radius: 2px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background: #3b82f6;
height: 16px;
width: 16px;
border-radius: 50%;
margin-top: -6px;
}
input[type="range"]::-moz-range-track {
background: #e5e7eb;
height: 4px;
border-radius: 2px;
border: none;
}
input[type="range"]::-moz-range-thumb {
background: #3b82f6;
height: 16px;
width: 16px;
border-radius: 50%;
border: none;
cursor: pointer;
}
.dark input[type="range"]::-webkit-slider-track {
background: #4b5563;
}
.dark input[type="range"]::-moz-range-track {
background: #4b5563;
}
</style>
<!-- Filter Panel JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const formId = '{{ form_id|default:"filters-form" }}';
const form = document.getElementById(formId);
if (!form) return;
// Handle filter chip toggles
form.querySelectorAll('.filter-chip').forEach(chip => {
const checkbox = chip.querySelector('input[type="checkbox"]');
chip.addEventListener('click', (e) => {
e.preventDefault();
if (checkbox) {
checkbox.checked = !checkbox.checked;
chip.classList.toggle('active', checkbox.checked);
chip.classList.toggle('inactive', !checkbox.checked);
// Trigger form change for HTMX
form.dispatchEvent(new Event('change'));
}
});
});
// Auto-submit form on most changes (except search input)
form.addEventListener('change', function(e) {
if (e.target.name !== 'q' && !e.target.closest('.no-auto-submit')) {
{% if hx_target %}
// HTMX will handle the submission
{% else %}
this.submit();
{% endif %}
}
});
// Handle search input separately with debouncing
const searchInput = form.querySelector('input[name="q"]');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
{% if not search_hx_url %}
form.dispatchEvent(new Event('change'));
{% endif %}
}, 500);
});
}
// Custom event for filter updates
form.addEventListener('filtersUpdated', function(e) {
// Custom logic when filters are updated
console.log('Filters updated:', e.detail);
});
// Emit initial filter state
window.addEventListener('load', function() {
const formData = new FormData(form);
const filters = {};
for (let [key, value] of formData.entries()) {
if (filters[key]) {
if (Array.isArray(filters[key])) {
filters[key].push(value);
} else {
filters[key] = [filters[key], value];
}
} else {
filters[key] = value;
}
}
const event = new CustomEvent('filtersInitialized', {
detail: filters
});
form.dispatchEvent(event);
});
});
</script>

View File

@@ -0,0 +1,346 @@
<!-- Reusable Location Card Component -->
<div class="location-card {% if card_classes %}{{ card_classes }}{% endif %}"
{% if location.id %}data-location-id="{{ location.id }}"{% endif %}
{% if location.type %}data-location-type="{{ location.type }}"{% endif %}
{% if location.latitude and location.longitude %}data-lat="{{ location.latitude }}" data-lng="{{ location.longitude }}"{% endif %}
{% if clickable %}onclick="{{ onclick_action|default:'window.location.href=\''|add:location.get_absolute_url|add:'\'' }}"{% endif %}>
<!-- Card Header -->
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
{% if location.name %}
{{ location.name }}
{% else %}
Unknown Location
{% endif %}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">
{{ location.formatted_location|default:"Location not specified" }}
</p>
</div>
<div class="flex-shrink-0 ml-3">
<span class="location-type-badge location-type-{{ location.type|default:'unknown' }}">
{% 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
{% else %}
<i class="mr-1 fas fa-map-marker-alt"></i>Location
{% endif %}
</span>
</div>
</div>
<!-- Distance Badge (if applicable) -->
{% if location.distance %}
<div class="mb-3">
<span class="distance-badge">
<i class="mr-1 fas fa-route"></i>{{ location.distance|floatformat:1 }} miles away
</span>
</div>
{% endif %}
<!-- Type-specific Content -->
{% if location.type == 'park' %}
{% include 'maps/partials/park_card_content.html' with park=location %}
{% elif location.type == 'ride' %}
{% include 'maps/partials/ride_card_content.html' with ride=location %}
{% elif location.type == 'company' %}
{% include 'maps/partials/company_card_content.html' with company=location %}
{% endif %}
<!-- Action Buttons -->
{% if show_actions %}
<div class="flex gap-2 mt-4">
<a href="{{ location.get_absolute_url }}"
class="flex-1 px-3 py-2 text-sm text-center text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
{{ primary_action_text|default:"View Details" }}
</a>
{% if location.latitude and location.longitude %}
<a href="{% url 'maps:nearby_locations' %}?lat={{ location.latitude }}&lng={{ location.longitude }}&radius=25"
class="px-3 py-2 text-sm text-blue-600 border border-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors"
title="Find nearby locations">
<i class="fas fa-search-location"></i>
</a>
{% endif %}
{% if show_map_action %}
<button onclick="showOnMap('{{ location.type }}', {{ location.id }})"
class="px-3 py-2 text-sm text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900 transition-colors"
title="Show on map">
<i class="fas fa-map-marker-alt"></i>
</button>
{% endif %}
{% if show_trip_action %}
<button onclick="addToTrip({{ location|safe }})"
class="px-3 py-2 text-sm text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900 transition-colors"
title="Add to trip">
<i class="fas fa-plus"></i>
</button>
{% endif %}
</div>
{% endif %}
</div>
<!-- Card Content Partials -->
<!-- Park Card Content -->
{% comment %}
This would be in templates/maps/partials/park_card_content.html
{% endcomment %}
<script type="text/template" id="park-card-content-template">
<div class="space-y-2">
{% if park.status %}
<div class="flex items-center">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction{% else %}status-demolished{% endif %}">
{% if park.status == 'OPERATING' %}
<i class="mr-1 fas fa-check-circle"></i>Operating
{% elif park.status == 'CLOSED_TEMP' %}
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
{% elif park.status == 'CLOSED_PERM' %}
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
{% elif park.status == 'DEMOLISHED' %}
<i class="mr-1 fas fa-ban"></i>Demolished
{% endif %}
</span>
</div>
{% endif %}
{% if park.operator %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-building"></i>
<span>{{ park.operator }}</span>
</div>
{% endif %}
{% if park.ride_count %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-rocket"></i>
<span>{{ park.ride_count }} ride{{ park.ride_count|pluralize }}</span>
</div>
{% endif %}
{% if park.average_rating %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-star text-yellow-500"></i>
<span>{{ park.average_rating|floatformat:1 }}/10</span>
</div>
{% endif %}
{% if park.opening_date %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar"></i>
<span>Opened {{ park.opening_date.year }}</span>
</div>
{% endif %}
</div>
</script>
<!-- Ride Card Content -->
<script type="text/template" id="ride-card-content-template">
<div class="space-y-2">
{% if ride.park_name %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-tree"></i>
<span>{{ ride.park_name }}</span>
</div>
{% endif %}
{% if ride.manufacturer %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-industry"></i>
<span>{{ ride.manufacturer }}</span>
</div>
{% endif %}
{% if ride.designer %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-drafting-compass"></i>
<span>{{ ride.designer }}</span>
</div>
{% endif %}
{% if ride.opening_date %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar"></i>
<span>Opened {{ ride.opening_date.year }}</span>
</div>
{% endif %}
{% if ride.status %}
<div class="flex items-center">
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating{% elif ride.status == 'CLOSED' %}status-closed{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction{% else %}status-demolished{% endif %}">
{% if ride.status == 'OPERATING' %}
<i class="mr-1 fas fa-check-circle"></i>Operating
{% elif ride.status == 'CLOSED' %}
<i class="mr-1 fas fa-times-circle"></i>Closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
{% elif ride.status == 'DEMOLISHED' %}
<i class="mr-1 fas fa-ban"></i>Demolished
{% endif %}
</span>
</div>
{% endif %}
</div>
</script>
<!-- Company Card Content -->
<script type="text/template" id="company-card-content-template">
<div class="space-y-2">
{% if company.company_type %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-tag"></i>
<span>{{ company.get_company_type_display }}</span>
</div>
{% endif %}
{% if company.founded_year %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar"></i>
<span>Founded {{ company.founded_year }}</span>
</div>
{% endif %}
{% if company.website %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-globe"></i>
<a href="{{ company.website }}" target="_blank" class="text-blue-600 hover:text-blue-700 dark:text-blue-400">
Visit Website
</a>
</div>
{% endif %}
{% if company.parks_count %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-tree"></i>
<span>{{ company.parks_count }} park{{ company.parks_count|pluralize }}</span>
</div>
{% endif %}
{% if company.rides_count %}
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-rocket"></i>
<span>{{ company.rides_count }} ride{{ company.rides_count|pluralize }}</span>
</div>
{% endif %}
</div>
</script>
<!-- Location Card Styles -->
<style>
.location-card {
@apply bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm hover:shadow-md transition-all border border-gray-200 dark:border-gray-700;
}
.location-card:hover {
@apply border-blue-300 dark:border-blue-600 shadow-lg;
}
.location-card.selected {
@apply ring-2 ring-blue-500 border-blue-500;
}
.location-card.clickable {
cursor: pointer;
}
.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;
}
.location-type-unknown {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-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;
}
.status-badge {
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-medium;
}
.status-operating {
@apply bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100;
}
.status-closed {
@apply bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100;
}
.status-construction {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100;
}
.status-demolished {
@apply bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100;
}
</style>
<!-- Location Card JavaScript -->
<script>
// Global functions for location card actions
window.showOnMap = function(type, id) {
// Emit custom event for map integration
const event = new CustomEvent('showLocationOnMap', {
detail: { type, id }
});
document.dispatchEvent(event);
};
window.addToTrip = function(locationData) {
// Emit custom event for trip integration
const event = new CustomEvent('addLocationToTrip', {
detail: locationData
});
document.dispatchEvent(event);
};
// Handle location card selection
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('click', function(e) {
const card = e.target.closest('.location-card');
if (card && card.dataset.locationId) {
// Remove previous selections
document.querySelectorAll('.location-card.selected').forEach(c => {
c.classList.remove('selected');
});
// Add selection to clicked card
card.classList.add('selected');
// Emit selection event
const event = new CustomEvent('locationCardSelected', {
detail: {
id: card.dataset.locationId,
type: card.dataset.locationType,
lat: card.dataset.lat,
lng: card.dataset.lng,
element: card
}
});
document.dispatchEvent(event);
}
});
});
</script>

View File

@@ -0,0 +1,530 @@
<!-- Reusable Location Popup Component for Maps -->
<div class="location-popup {% if popup_classes %}{{ popup_classes }}{% endif %}"
data-location-id="{{ location.id }}"
data-location-type="{{ location.type }}">
<!-- Popup Header -->
<div class="popup-header">
<h3 class="popup-title">{{ location.name|default:"Unknown Location" }}</h3>
{% if location.type %}
<span class="popup-type-badge popup-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
{% else %}
<i class="mr-1 fas fa-map-marker-alt"></i>Location
{% endif %}
</span>
{% endif %}
</div>
<!-- Location Information -->
{% if location.formatted_location %}
<div class="popup-meta">
<i class="fas fa-map-marker-alt mr-1"></i>{{ location.formatted_location }}
</div>
{% endif %}
<!-- Distance (if applicable) -->
{% if location.distance %}
<div class="popup-meta">
<i class="fas fa-route mr-1"></i>{{ location.distance|floatformat:1 }} miles away
</div>
{% endif %}
<!-- Type-specific Content -->
{% if location.type == 'park' %}
<!-- Park-specific popup content -->
{% if location.status %}
<div class="popup-meta">
<span class="popup-status-badge popup-status-{% if location.status == 'OPERATING' %}operating{% elif location.status == 'CLOSED_TEMP' or location.status == 'CLOSED_PERM' %}closed{% elif location.status == 'UNDER_CONSTRUCTION' %}construction{% else %}demolished{% endif %}">
{% if location.status == 'OPERATING' %}
<i class="mr-1 fas fa-check-circle"></i>Operating
{% elif location.status == 'CLOSED_TEMP' %}
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
{% elif location.status == 'CLOSED_PERM' %}
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
{% elif location.status == 'UNDER_CONSTRUCTION' %}
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
{% elif location.status == 'DEMOLISHED' %}
<i class="mr-1 fas fa-ban"></i>Demolished
{% endif %}
</span>
</div>
{% endif %}
{% if location.operator %}
<div class="popup-meta">
<i class="fas fa-building mr-1"></i>{{ location.operator }}
</div>
{% endif %}
{% if location.ride_count %}
<div class="popup-meta">
<i class="fas fa-rocket mr-1"></i>{{ location.ride_count }} ride{{ location.ride_count|pluralize }}
</div>
{% endif %}
{% if location.average_rating %}
<div class="popup-meta">
<i class="fas fa-star mr-1 text-yellow-500"></i>{{ location.average_rating|floatformat:1 }}/10 rating
</div>
{% endif %}
{% if location.opening_date %}
<div class="popup-meta">
<i class="fas fa-calendar mr-1"></i>Opened {{ location.opening_date.year }}
</div>
{% endif %}
{% elif location.type == 'ride' %}
<!-- Ride-specific popup content -->
{% if location.park_name %}
<div class="popup-meta">
<i class="fas fa-tree mr-1"></i>{{ location.park_name }}
</div>
{% endif %}
{% if location.manufacturer %}
<div class="popup-meta">
<i class="fas fa-industry mr-1"></i>{{ location.manufacturer }}
</div>
{% endif %}
{% if location.designer %}
<div class="popup-meta">
<i class="fas fa-drafting-compass mr-1"></i>{{ location.designer }}
</div>
{% endif %}
{% if location.opening_date %}
<div class="popup-meta">
<i class="fas fa-calendar mr-1"></i>Opened {{ location.opening_date.year }}
</div>
{% endif %}
{% if location.status %}
<div class="popup-meta">
<span class="popup-status-badge popup-status-{% if location.status == 'OPERATING' %}operating{% elif location.status == 'CLOSED' %}closed{% elif location.status == 'UNDER_CONSTRUCTION' %}construction{% else %}demolished{% endif %}">
{% if location.status == 'OPERATING' %}
<i class="mr-1 fas fa-check-circle"></i>Operating
{% elif location.status == 'CLOSED' %}
<i class="mr-1 fas fa-times-circle"></i>Closed
{% elif location.status == 'UNDER_CONSTRUCTION' %}
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
{% elif location.status == 'DEMOLISHED' %}
<i class="mr-1 fas fa-ban"></i>Demolished
{% endif %}
</span>
</div>
{% endif %}
{% elif location.type == 'company' %}
<!-- Company-specific popup content -->
{% if location.company_type %}
<div class="popup-meta">
<i class="fas fa-tag mr-1"></i>{{ location.get_company_type_display }}
</div>
{% endif %}
{% if location.founded_year %}
<div class="popup-meta">
<i class="fas fa-calendar mr-1"></i>Founded {{ location.founded_year }}
</div>
{% endif %}
{% if location.parks_count %}
<div class="popup-meta">
<i class="fas fa-tree mr-1"></i>{{ location.parks_count }} park{{ location.parks_count|pluralize }}
</div>
{% endif %}
{% if location.rides_count %}
<div class="popup-meta">
<i class="fas fa-rocket mr-1"></i>{{ location.rides_count }} ride{{ location.rides_count|pluralize }}
</div>
{% endif %}
{% endif %}
<!-- Custom Content -->
{% if custom_content %}
<div class="popup-custom">
{{ custom_content|safe }}
</div>
{% endif %}
<!-- Action Buttons -->
<div class="popup-actions">
{% if show_details_button %}
<a href="{{ location.get_absolute_url }}"
class="popup-btn popup-btn-primary">
<i class="mr-1 fas fa-info-circle"></i>{{ details_button_text|default:"View Details" }}
</a>
{% endif %}
{% if show_nearby_button and location.latitude and location.longitude %}
<a href="{% url 'maps:nearby_locations' %}?lat={{ location.latitude }}&lng={{ location.longitude }}&radius=25"
class="popup-btn popup-btn-secondary">
<i class="mr-1 fas fa-search-location"></i>{{ nearby_button_text|default:"Find Nearby" }}
</a>
{% endif %}
{% if show_directions_button and location.latitude and location.longitude %}
<button onclick="getDirections({{ location.latitude }}, {{ location.longitude }})"
class="popup-btn popup-btn-secondary">
<i class="mr-1 fas fa-directions"></i>{{ directions_button_text|default:"Directions" }}
</button>
{% endif %}
{% if show_trip_button %}
<button onclick="addLocationToTrip({{ location|safe }})"
class="popup-btn popup-btn-accent">
<i class="mr-1 fas fa-plus"></i>{{ trip_button_text|default:"Add to Trip" }}
</button>
{% endif %}
{% if show_share_button %}
<button onclick="shareLocation('{{ location.type }}', {{ location.id }})"
class="popup-btn popup-btn-secondary">
<i class="mr-1 fas fa-share"></i>{{ share_button_text|default:"Share" }}
</button>
{% endif %}
<!-- Custom Action Buttons -->
{% if custom_actions %}
{% for action in custom_actions %}
<{{ action.tag|default:"button" }}
{% if action.href %}href="{{ action.href }}"{% endif %}
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
class="popup-btn {{ action.classes|default:'popup-btn-secondary' }}">
{% if action.icon %}<i class="mr-1 {{ action.icon }}"></i>{% endif %}{{ action.text }}
</{{ action.tag|default:"button" }}>
{% endfor %}
{% endif %}
</div>
</div>
<!-- Popup Styles -->
<style>
.location-popup {
max-width: 350px;
min-width: 250px;
font-family: inherit;
}
.popup-header {
margin-bottom: 0.75rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.popup-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
line-height: 1.3;
flex: 1;
}
.popup-type-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
flex-shrink: 0;
}
.popup-type-park {
background-color: #dcfce7;
color: #166534;
}
.popup-type-ride {
background-color: #dbeafe;
color: #1e40af;
}
.popup-type-company {
background-color: #ede9fe;
color: #7c3aed;
}
.popup-meta {
margin: 0.375rem 0;
font-size: 0.875rem;
color: #6b7280;
display: flex;
align-items: center;
}
.popup-meta i {
width: 1rem;
flex-shrink: 0;
}
.popup-status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.popup-status-operating {
background-color: #dcfce7;
color: #166534;
}
.popup-status-closed {
background-color: #fee2e2;
color: #dc2626;
}
.popup-status-construction {
background-color: #fef3c7;
color: #d97706;
}
.popup-status-demolished {
background-color: #f3f4f6;
color: #6b7280;
}
.popup-custom {
margin: 0.75rem 0;
}
.popup-actions {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.popup-btn {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.375rem;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.popup-btn:hover {
transform: translateY(-1px);
}
.popup-btn-primary {
background-color: #3b82f6;
color: white;
}
.popup-btn-primary:hover {
background-color: #2563eb;
color: white;
}
.popup-btn-secondary {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.popup-btn-secondary:hover {
background-color: #e5e7eb;
color: #374151;
}
.popup-btn-accent {
background-color: #10b981;
color: white;
}
.popup-btn-accent:hover {
background-color: #059669;
color: white;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.popup-title {
color: #f9fafb;
}
.popup-meta {
color: #d1d5db;
}
.popup-type-park {
background-color: #166534;
color: #dcfce7;
}
.popup-type-ride {
background-color: #1e40af;
color: #dbeafe;
}
.popup-type-company {
background-color: #7c3aed;
color: #ede9fe;
}
.popup-status-operating {
background-color: #166534;
color: #dcfce7;
}
.popup-status-closed {
background-color: #dc2626;
color: #fee2e2;
}
.popup-status-construction {
background-color: #d97706;
color: #fef3c7;
}
.popup-status-demolished {
background-color: #6b7280;
color: #f3f4f6;
}
.popup-btn-secondary {
background-color: #374151;
color: #f3f4f6;
border-color: #4b5563;
}
.popup-btn-secondary:hover {
background-color: #4b5563;
color: #f3f4f6;
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
.location-popup {
max-width: 280px;
min-width: 200px;
}
.popup-actions {
flex-direction: column;
}
.popup-btn {
justify-content: center;
width: 100%;
}
}
</style>
<!-- Popup JavaScript Functions -->
<script>
// Global functions for popup actions
window.getDirections = function(lat, lng) {
// Open directions in user's preferred map app
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
// Try to open in native maps app
window.open(`geo:${lat},${lng}`, '_blank');
} else {
// Open in Google Maps
window.open(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`, '_blank');
}
};
window.addLocationToTrip = function(locationData) {
// Emit custom event for trip integration
const event = new CustomEvent('addLocationToTrip', {
detail: locationData
});
document.dispatchEvent(event);
// Show feedback
showPopupFeedback('Added to trip!', 'success');
};
window.shareLocation = function(type, id) {
// Share location URL
const url = window.location.origin + `/{{ type }}/${id}/`;
if (navigator.share) {
navigator.share({
title: 'Check out this location on ThrillWiki',
url: url
});
} else {
// Fallback: copy to clipboard
navigator.clipboard.writeText(url).then(() => {
showPopupFeedback('Link copied to clipboard!', 'success');
}).catch(() => {
showPopupFeedback('Could not copy link', 'error');
});
}
};
window.showPopupFeedback = function(message, type = 'info') {
// Create temporary feedback element
const feedback = document.createElement('div');
feedback.className = `popup-feedback popup-feedback-${type}`;
feedback.textContent = message;
feedback.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#3b82f6'};
color: white;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
z-index: 10000;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
animation: slideIn 0.3s ease;
`;
document.body.appendChild(feedback);
// Remove after 3 seconds
setTimeout(() => {
feedback.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(feedback);
}, 300);
}, 3000);
};
// Add CSS animations
if (!document.getElementById('popup-animations')) {
const style = document.createElement('style');
style.id = 'popup-animations';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
}
</script>

View File

@@ -0,0 +1,196 @@
<!-- Reusable Map Container Component -->
<div class="relative">
<div id="{{ map_id|default:'map-container' }}"
class="map-container {% if map_classes %}{{ map_classes }}{% endif %}"
style="{% if map_height %}height: {{ map_height }};{% endif %}">
</div>
<!-- Map Loading Indicator -->
<div id="{{ map_id|default:'map-container' }}-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_text|default:"Loading map data..." }}
</p>
</div>
</div>
<!-- Map Controls Overlay -->
{% if show_controls %}
<div class="absolute top-4 right-4 z-10 space-y-2">
{% if show_fullscreen %}
<button id="{{ map_id|default:'map-container' }}-fullscreen"
class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow"
title="Toggle Fullscreen">
<i class="fas fa-expand text-gray-600 dark:text-gray-400"></i>
</button>
{% endif %}
{% if show_layers %}
<button id="{{ map_id|default:'map-container' }}-layers"
class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow"
title="Map Layers">
<i class="fas fa-layer-group text-gray-600 dark:text-gray-400"></i>
</button>
{% endif %}
{% if show_locate %}
<button id="{{ map_id|default:'map-container' }}-locate"
class="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow"
title="Find My Location">
<i class="fas fa-crosshairs text-gray-600 dark:text-gray-400"></i>
</button>
{% endif %}
</div>
{% endif %}
<!-- Map Legend -->
{% if show_legend %}
<div class="absolute bottom-4 left-4 z-10">
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Legend</h4>
<div class="space-y-1 text-xs">
{% if legend_items %}
{% for item in legend_items %}
<div class="flex items-center">
<div class="w-3 h-3 mr-2 rounded-full" style="background-color: {{ item.color }};"></div>
<span class="text-gray-700 dark:text-gray-300">{{ item.label }}</span>
</div>
{% endfor %}
{% else %}
<div class="flex items-center">
<div class="w-3 h-3 mr-2 rounded-full bg-green-500"></div>
<span class="text-gray-700 dark:text-gray-300">Operating Parks</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 mr-2 rounded-full bg-blue-500"></div>
<span class="text-gray-700 dark:text-gray-300">Rides</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 mr-2 rounded-full bg-purple-500"></div>
<span class="text-gray-700 dark:text-gray-300">Companies</span>
</div>
<div class="flex items-center">
<div class="w-3 h-3 mr-2 rounded-full bg-red-500"></div>
<span class="text-gray-700 dark:text-gray-300">Closed/Demolished</span>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
<!-- Map Container Styles -->
<style>
.map-container {
height: {{ map_height|default:'60vh' }};
min-height: {{ min_height|default:'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);
}
.map-container.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
border-radius: 0;
height: 100vh !important;
min-height: 100vh !important;
}
.map-container.fullscreen + .absolute {
z-index: 10000;
}
/* Dark mode adjustments */
.dark .map-container {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
</style>
<!-- Map Container JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const mapId = '{{ map_id|default:"map-container" }}';
const mapContainer = document.getElementById(mapId);
{% if show_fullscreen %}
// Fullscreen toggle
const fullscreenBtn = document.getElementById(mapId + '-fullscreen');
if (fullscreenBtn) {
fullscreenBtn.addEventListener('click', function() {
const icon = this.querySelector('i');
if (mapContainer.classList.contains('fullscreen')) {
mapContainer.classList.remove('fullscreen');
icon.className = 'fas fa-expand text-gray-600 dark:text-gray-400';
this.title = 'Toggle Fullscreen';
} else {
mapContainer.classList.add('fullscreen');
icon.className = 'fas fa-compress text-gray-600 dark:text-gray-400';
this.title = 'Exit Fullscreen';
}
// Trigger map resize if map instance exists
if (window[mapId + 'Instance']) {
setTimeout(() => {
window[mapId + 'Instance'].invalidateSize();
}, 100);
}
});
}
{% endif %}
{% if show_locate %}
// Geolocation
const locateBtn = document.getElementById(mapId + '-locate');
if (locateBtn && navigator.geolocation) {
locateBtn.addEventListener('click', function() {
const icon = this.querySelector('i');
icon.className = 'fas fa-spinner fa-spin text-gray-600 dark:text-gray-400';
navigator.geolocation.getCurrentPosition(
function(position) {
icon.className = 'fas fa-crosshairs text-gray-600 dark:text-gray-400';
// Trigger custom event with user location
const event = new CustomEvent('userLocationFound', {
detail: {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy
}
});
mapContainer.dispatchEvent(event);
},
function(error) {
icon.className = 'fas fa-crosshairs text-red-500';
console.error('Geolocation error:', error);
// Reset icon after delay
setTimeout(() => {
icon.className = 'fas fa-crosshairs text-gray-600 dark:text-gray-400';
}, 2000);
}
);
});
}
{% endif %}
// Escape key handler for fullscreen
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && mapContainer.classList.contains('fullscreen')) {
const fullscreenBtn = document.getElementById(mapId + '-fullscreen');
if (fullscreenBtn) {
fullscreenBtn.click();
}
}
});
});
</script>

View File

@@ -0,0 +1,504 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{{ page_title }} - ThrillWiki{% endblock %}
{% block extra_head %}
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.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);
}
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.dark .map-controls {
background: rgba(31, 41, 55, 0.95);
}
.filter-pill {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 cursor-pointer transition-colors;
}
.filter-pill.active {
@apply bg-blue-500 text-white dark:bg-blue-600;
}
.filter-pill:hover {
@apply bg-gray-200 dark:bg-gray-600;
}
.filter-pill.active:hover {
@apply bg-blue-600 dark:bg-blue-700;
}
.location-info-popup {
max-width: 300px;
}
.location-info-popup h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
}
.location-info-popup p {
margin: 0.25rem 0;
font-size: 0.9rem;
color: #666;
}
.dark .location-info-popup p {
color: #ccc;
}
</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">{{ page_title }}</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">
Explore theme parks, rides, and attractions from around the world
</p>
</div>
<div class="flex gap-3">
<a href="{% url 'maps:park_map' %}"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
<i class="mr-2 fas fa-map-marker-alt"></i>Parks Only
</a>
<a href="{% url 'maps:location_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>List View
</a>
</div>
</div>
<!-- Filters Panel -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form id="map-filters"
hx-get="{% url 'maps:htmx_filter' %}"
hx-trigger="change, submit"
hx-target="#map-container"
hx-swap="none"
hx-push-url="false">
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Search -->
<div>
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="q" id="search"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search locations..."
hx-get="{% url 'maps:htmx_search' %}"
hx-trigger="input changed delay:500ms"
hx-target="#search-results"
hx-indicator="#search-loading">
</div>
<!-- Country -->
<div>
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text" name="country" id="country"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by country...">
</div>
<!-- State/Region -->
<div>
<label for="state" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
<input type="text" name="state" id="state"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Filter by state...">
</div>
<!-- Clustering Toggle -->
<div class="flex items-end">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="cluster" value="true" id="cluster-toggle"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
checked>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Enable Clustering</span>
</label>
</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">
{% for type in location_types %}
<label class="filter-pill" data-type="{{ type }}">
<input type="checkbox" name="types" value="{{ type }}"
class="hidden location-type-checkbox"
{% if type in initial_location_types %}checked{% endif %}>
<i class="mr-2 fas fa-{% if type == 'park' %}map-marker-alt{% elif type == 'ride' %}rocket{% elif type == 'company' %}building{% else %}map-pin{% endif %}"></i>
{{ type|title }}
</label>
{% endfor %}
</div>
</div>
</form>
<!-- Search Results -->
<div id="search-results" class="mt-4"></div>
<div id="search-loading" class="htmx-indicator">
<div class="flex items-center justify-center p-4">
<div class="w-6 h-6 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...</span>
</div>
</div>
</div>
<!-- Map Container -->
<div class="relative">
<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 data...</p>
</div>
</div>
</div>
<!-- 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>
<!-- Leaflet MarkerCluster JS -->
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script>
// Map initialization and management
class ThrillWikiMap {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
center: [39.8283, -98.5795], // Center of USA
zoom: 4,
enableClustering: true,
...options
};
this.map = null;
this.markers = new L.MarkerClusterGroup();
this.currentData = [];
this.init();
}
init() {
// Initialize the map
this.map = L.map(this.containerId, {
center: this.options.center,
zoom: this.options.zoom,
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',
className: 'map-tiles'
});
const darkTiles = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
className: 'map-tiles-dark'
});
// 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 markers cluster group
this.map.addLayer(this.markers);
// Bind map events
this.bindEvents();
// Load initial data
this.loadMapData();
}
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']
});
}
bindEvents() {
// Update map when bounds change
this.map.on('moveend zoomend', () => {
this.updateMapBounds();
});
// Handle filter form changes
document.getElementById('map-filters').addEventListener('htmx:afterRequest', (event) => {
if (event.detail.successful) {
this.loadMapData();
}
});
}
async loadMapData() {
try {
document.getElementById('map-loading').style.display = 'flex';
const formData = new FormData(document.getElementById('map-filters'));
const params = new URLSearchParams();
// Add form data to params
for (let [key, value] of formData.entries()) {
params.append(key, value);
}
// Add map bounds
const bounds = this.map.getBounds();
params.append('north', bounds.getNorth());
params.append('south', bounds.getSouth());
params.append('east', bounds.getEast());
params.append('west', bounds.getWest());
params.append('zoom', this.map.getZoom());
const response = await fetch(`{{ map_api_urls.locations }}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.updateMarkers(data.data);
} else {
console.error('Map data error:', data.message);
}
} catch (error) {
console.error('Failed to load map data:', error);
} finally {
document.getElementById('map-loading').style.display = 'none';
}
}
updateMarkers(data) {
// Clear existing markers
this.markers.clearLayers();
// Add location markers
if (data.locations) {
data.locations.forEach(location => {
this.addLocationMarker(location);
});
}
// Add cluster markers
if (data.clusters) {
data.clusters.forEach(cluster => {
this.addClusterMarker(cluster);
});
}
}
addLocationMarker(location) {
const icon = this.getLocationIcon(location.type);
const marker = L.marker([location.latitude, location.longitude], { icon });
// Create popup content
const popupContent = this.createPopupContent(location);
marker.bindPopup(popupContent);
// Add click handler for detailed view
marker.on('click', () => {
this.showLocationDetails(location.type, location.id);
});
this.markers.addLayer(marker);
}
addClusterMarker(cluster) {
const marker = L.marker([cluster.latitude, cluster.longitude], {
icon: L.divIcon({
className: 'cluster-marker',
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
iconSize: [40, 40]
})
});
marker.bindPopup(`${cluster.count} locations in this area`);
this.markers.addLayer(marker);
}
getLocationIcon(type) {
const iconMap = {
'park': '🎢',
'ride': '🎠',
'company': '🏢',
'generic': '📍'
};
return L.divIcon({
className: 'location-marker',
html: `<div class="location-marker-inner">${iconMap[type] || '📍'}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
}
createPopupContent(location) {
return `
<div class="location-info-popup">
<h3>${location.name}</h3>
${location.formatted_location ? `<p><i class="fas fa-map-marker-alt mr-1"></i>${location.formatted_location}</p>` : ''}
${location.operator ? `<p><i class="fas fa-building mr-1"></i>${location.operator}</p>` : ''}
${location.ride_count ? `<p><i class="fas fa-rocket mr-1"></i>${location.ride_count} rides</p>` : ''}
<div class="mt-2">
<button onclick="thrillwikiMap.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>
</div>
`;
}
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');
});
}
updateMapBounds() {
// This could trigger an HTMX request to update data based on new bounds
// For now, we'll just reload data when the map moves significantly
clearTimeout(this.boundsUpdateTimeout);
this.boundsUpdateTimeout = setTimeout(() => {
this.loadMapData();
}, 1000);
}
}
// Initialize map when page loads
document.addEventListener('DOMContentLoaded', function() {
window.thrillwikiMap = new ThrillWikiMap('map-container', {
{% if initial_bounds %}
center: [{{ initial_bounds.north|add:initial_bounds.south|floatformat:6|div:2 }}, {{ initial_bounds.east|add:initial_bounds.west|floatformat:6|div:2 }}],
{% endif %}
enableClustering: {{ enable_clustering|yesno:"true,false" }}
});
// Handle filter pill toggles
document.querySelectorAll('.filter-pill').forEach(pill => {
const checkbox = pill.querySelector('input[type="checkbox"]');
// Set initial state
if (checkbox.checked) {
pill.classList.add('active');
}
pill.addEventListener('click', () => {
checkbox.checked = !checkbox.checked;
pill.classList.toggle('active', checkbox.checked);
// Trigger form change
document.getElementById('map-filters').dispatchEvent(new Event('change'));
});
});
// Close modal handler
document.addEventListener('click', (e) => {
if (e.target.id === 'location-modal') {
document.getElementById('location-modal').classList.add('hidden');
}
});
});
</script>
<style>
.cluster-marker {
background: transparent;
border: none;
}
.cluster-marker-inner {
background: #3b82f6;
color: white;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.location-marker {
background: transparent;
border: none;
}
.location-marker-inner {
font-size: 20px;
text-align: center;
line-height: 30px;
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
}
.dark .cluster-marker-inner {
border-color: #374151;
}
</style>
{% endblock %}

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 %}