Files
thrillwiki_django_no_react/templates/maps/nearby_locations.html
pacnpal 75cc618c2b update
2025-09-21 20:04:42 -04:00

581 lines
22 KiB
HTML

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