This commit is contained in:
pacnpal
2025-09-21 20:04:42 -04:00
parent 42a3dc7637
commit 75cc618c2b
610 changed files with 1719 additions and 4816 deletions

18
static/js/alerts.js Normal file
View File

@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', function() {
// Get all alert elements
const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => {
// After 5 seconds
setTimeout(() => {
// Add slideOut animation
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
setTimeout(() => {
alert.remove();
}, 500);
}, 5000);
});
});

View File

@@ -0,0 +1,711 @@
/**
* Alpine.js Components for ThrillWiki
* Enhanced components matching React frontend functionality
*/
// Theme Toggle Component
Alpine.data('themeToggle', () => ({
theme: localStorage.getItem('theme') || 'system',
init() {
this.updateTheme();
// Watch for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme === 'system') {
this.updateTheme();
}
});
},
toggleTheme() {
const themes = ['light', 'dark', 'system'];
const currentIndex = themes.indexOf(this.theme);
this.theme = themes[(currentIndex + 1) % themes.length];
localStorage.setItem('theme', this.theme);
this.updateTheme();
},
updateTheme() {
const root = document.documentElement;
if (this.theme === 'dark' ||
(this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
}));
// Search Component
Alpine.data('searchComponent', () => ({
query: '',
results: [],
loading: false,
showResults: false,
async search() {
if (this.query.length < 2) {
this.results = [];
this.showResults = false;
return;
}
this.loading = true;
try {
const response = await fetch(`/api/search/?q=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = data.results || [];
this.showResults = this.results.length > 0;
} catch (error) {
console.error('Search error:', error);
this.results = [];
this.showResults = false;
} finally {
this.loading = false;
}
},
selectResult(result) {
window.location.href = result.url;
this.showResults = false;
this.query = '';
},
clearSearch() {
this.query = '';
this.results = [];
this.showResults = false;
}
}));
// Browse Menu Component
Alpine.data('browseMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
// Mobile Menu Component
Alpine.data('mobileMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
// Prevent body scroll when menu is open
if (this.open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
},
close() {
this.open = false;
document.body.style.overflow = '';
}
}));
// User Menu Component
Alpine.data('userMenu', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
// Modal Component
Alpine.data('modal', (initialOpen = false) => ({
open: initialOpen,
show() {
this.open = true;
document.body.style.overflow = 'hidden';
},
hide() {
this.open = false;
document.body.style.overflow = '';
},
toggle() {
if (this.open) {
this.hide();
} else {
this.show();
}
}
}));
// Dropdown Component
Alpine.data('dropdown', (initialOpen = false) => ({
open: initialOpen,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
show() {
this.open = true;
}
}));
// Tabs Component
Alpine.data('tabs', (defaultTab = 0) => ({
activeTab: defaultTab,
setTab(index) {
this.activeTab = index;
},
isActive(index) {
return this.activeTab === index;
}
}));
// Accordion Component
Alpine.data('accordion', (allowMultiple = false) => ({
openItems: [],
toggle(index) {
if (this.isOpen(index)) {
this.openItems = this.openItems.filter(item => item !== index);
} else {
if (allowMultiple) {
this.openItems.push(index);
} else {
this.openItems = [index];
}
}
},
isOpen(index) {
return this.openItems.includes(index);
},
open(index) {
if (!this.isOpen(index)) {
if (allowMultiple) {
this.openItems.push(index);
} else {
this.openItems = [index];
}
}
},
close(index) {
this.openItems = this.openItems.filter(item => item !== index);
}
}));
// Form Component with Validation
Alpine.data('form', (initialData = {}) => ({
data: initialData,
errors: {},
loading: false,
setField(field, value) {
this.data[field] = value;
// Clear error when user starts typing
if (this.errors[field]) {
delete this.errors[field];
}
},
setError(field, message) {
this.errors[field] = message;
},
clearErrors() {
this.errors = {};
},
hasError(field) {
return !!this.errors[field];
},
getError(field) {
return this.errors[field] || '';
},
async submit(url, options = {}) {
this.loading = true;
this.clearErrors();
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
...options.headers
},
body: JSON.stringify(this.data),
...options
});
const result = await response.json();
if (!response.ok) {
if (result.errors) {
this.errors = result.errors;
}
throw new Error(result.message || 'Form submission failed');
}
return result;
} catch (error) {
console.error('Form submission error:', error);
throw error;
} finally {
this.loading = false;
}
}
}));
// Pagination Component
Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({
currentPage: initialPage,
totalPages: totalPages,
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
nextPage() {
this.goToPage(this.currentPage + 1);
},
prevPage() {
this.goToPage(this.currentPage - 1);
},
hasNext() {
return this.currentPage < this.totalPages;
},
hasPrev() {
return this.currentPage > 1;
},
getPages() {
const pages = [];
const start = Math.max(1, this.currentPage - 2);
const end = Math.min(this.totalPages, this.currentPage + 2);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
}));
// Toast/Alert Component
Alpine.data('toast', () => ({
toasts: [],
show(message, type = 'info', duration = 5000) {
const id = Date.now();
const toast = { id, message, type, visible: true };
this.toasts.push(toast);
if (duration > 0) {
setTimeout(() => {
this.hide(id);
}, duration);
}
return id;
},
hide(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300); // Wait for animation
}
},
success(message, duration) {
return this.show(message, 'success', duration);
},
error(message, duration) {
return this.show(message, 'error', duration);
},
warning(message, duration) {
return this.show(message, 'warning', duration);
},
info(message, duration) {
return this.show(message, 'info', duration);
}
}));
// Enhanced Authentication Modal Component
Alpine.data('authModal', (defaultMode = 'login') => ({
open: false,
mode: defaultMode, // 'login' or 'register'
showPassword: false,
socialProviders: [],
socialLoading: true,
// Login form data
loginForm: {
username: '',
password: ''
},
loginLoading: false,
loginError: '',
// Register form data
registerForm: {
first_name: '',
last_name: '',
email: '',
username: '',
password1: '',
password2: ''
},
registerLoading: false,
registerError: '',
init() {
this.fetchSocialProviders();
// Listen for auth modal events
this.$watch('open', (value) => {
if (value) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
this.resetForms();
}
});
},
async fetchSocialProviders() {
try {
const response = await fetch('/api/v1/auth/social-providers/');
const data = await response.json();
this.socialProviders = data.available_providers || [];
} catch (error) {
console.error('Failed to fetch social providers:', error);
this.socialProviders = [];
} finally {
this.socialLoading = false;
}
},
show(mode = 'login') {
this.mode = mode;
this.open = true;
},
close() {
this.open = false;
},
switchToLogin() {
this.mode = 'login';
this.resetForms();
},
switchToRegister() {
this.mode = 'register';
this.resetForms();
},
resetForms() {
this.loginForm = { username: '', password: '' };
this.registerForm = {
first_name: '',
last_name: '',
email: '',
username: '',
password1: '',
password2: ''
};
this.loginError = '';
this.registerError = '';
this.showPassword = false;
},
async handleLogin() {
if (!this.loginForm.username || !this.loginForm.password) {
this.loginError = 'Please fill in all fields';
return;
}
this.loginLoading = true;
this.loginError = '';
try {
const response = await fetch('/accounts/login/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': this.getCSRFToken(),
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({
login: this.loginForm.username,
password: this.loginForm.password
})
});
if (response.ok) {
// Login successful - reload page to update auth state
window.location.reload();
} else {
const data = await response.json();
this.loginError = data.message || 'Login failed. Please check your credentials.';
}
} catch (error) {
console.error('Login error:', error);
this.loginError = 'An error occurred. Please try again.';
} finally {
this.loginLoading = false;
}
},
async handleRegister() {
if (!this.registerForm.first_name || !this.registerForm.last_name ||
!this.registerForm.email || !this.registerForm.username ||
!this.registerForm.password1 || !this.registerForm.password2) {
this.registerError = 'Please fill in all fields';
return;
}
if (this.registerForm.password1 !== this.registerForm.password2) {
this.registerError = 'Passwords do not match';
return;
}
this.registerLoading = true;
this.registerError = '';
try {
const response = await fetch('/accounts/signup/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': this.getCSRFToken(),
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({
first_name: this.registerForm.first_name,
last_name: this.registerForm.last_name,
email: this.registerForm.email,
username: this.registerForm.username,
password1: this.registerForm.password1,
password2: this.registerForm.password2
})
});
if (response.ok) {
// Registration successful
this.close();
// Show success message or redirect
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
} else {
const data = await response.json();
this.registerError = data.message || 'Registration failed. Please try again.';
}
} catch (error) {
console.error('Registration error:', error);
this.registerError = 'An error occurred. Please try again.';
} finally {
this.registerLoading = false;
}
},
handleSocialLogin(providerId) {
const provider = this.socialProviders.find(p => p.id === providerId);
if (!provider) {
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
return;
}
// Redirect to social auth URL
window.location.href = provider.auth_url;
},
getCSRFToken() {
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
return token || '';
}
}));
// Enhanced Toast Component with Better UX
Alpine.data('toast', () => ({
toasts: [],
show(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random();
const toast = {
id,
message,
type,
visible: true,
progress: 100
};
this.toasts.push(toast);
if (duration > 0) {
// Animate progress bar
const interval = setInterval(() => {
toast.progress -= (100 / (duration / 100));
if (toast.progress <= 0) {
clearInterval(interval);
this.hide(id);
}
}, 100);
}
return id;
},
hide(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300);
}
},
success(message, duration = 5000) {
return this.show(message, 'success', duration);
},
error(message, duration = 7000) {
return this.show(message, 'error', duration);
},
warning(message, duration = 6000) {
return this.show(message, 'warning', duration);
},
info(message, duration = 5000) {
return this.show(message, 'info', duration);
}
}));
// Global Store for App State
Alpine.store('app', {
user: null,
theme: 'system',
searchQuery: '',
notifications: [],
setUser(user) {
this.user = user;
},
setTheme(theme) {
this.theme = theme;
localStorage.setItem('theme', theme);
},
addNotification(notification) {
this.notifications.push({
id: Date.now(),
...notification
});
},
removeNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
});
// Global Toast Store
Alpine.store('toast', {
toasts: [],
show(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random();
const toast = {
id,
message,
type,
visible: true,
progress: 100
};
this.toasts.push(toast);
if (duration > 0) {
const interval = setInterval(() => {
toast.progress -= (100 / (duration / 100));
if (toast.progress <= 0) {
clearInterval(interval);
this.hide(id);
}
}, 100);
}
return id;
},
hide(id) {
const toast = this.toasts.find(t => t.id === id);
if (toast) {
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 300);
}
},
success(message, duration = 5000) {
return this.show(message, 'success', duration);
},
error(message, duration = 7000) {
return this.show(message, 'error', duration);
},
warning(message, duration = 6000) {
return this.show(message, 'warning', duration);
},
info(message, duration = 5000) {
return this.show(message, 'info', duration);
}
});
// Initialize Alpine.js when DOM is ready
document.addEventListener('alpine:init', () => {
console.log('Alpine.js components initialized');
});

5
static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

665
static/js/dark-mode-maps.js Normal file
View File

@@ -0,0 +1,665 @@
/**
* ThrillWiki Dark Mode Maps - Dark Mode Integration for Maps
*
* This module provides comprehensive dark mode support for maps,
* including automatic theme switching, dark tile layers, and consistent styling
*/
class DarkModeMaps {
constructor(options = {}) {
this.options = {
enableAutoThemeDetection: true,
enableSystemPreference: true,
enableStoredPreference: true,
storageKey: 'thrillwiki_dark_mode',
transitionDuration: 300,
tileProviders: {
light: {
osm: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
cartodb: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
},
dark: {
osm: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
cartodb: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
}
},
...options
};
this.currentTheme = 'light';
this.mapInstances = new Set();
this.tileLayers = new Map();
this.observer = null;
this.mediaQuery = null;
this.init();
}
/**
* Initialize dark mode support
*/
init() {
this.detectCurrentTheme();
this.setupThemeObserver();
this.setupSystemPreferenceDetection();
this.setupStorageSync();
this.setupMapThemeStyles();
this.bindEventHandlers();
console.log('Dark mode maps initialized with theme:', this.currentTheme);
}
/**
* Detect current theme from DOM
*/
detectCurrentTheme() {
if (document.documentElement.classList.contains('dark')) {
this.currentTheme = 'dark';
} else {
this.currentTheme = 'light';
}
// Check stored preference
if (this.options.enableStoredPreference) {
const stored = localStorage.getItem(this.options.storageKey);
if (stored && ['light', 'dark', 'auto'].includes(stored)) {
this.applyStoredPreference(stored);
}
}
// Check system preference if auto
if (this.options.enableSystemPreference && this.getStoredPreference() === 'auto') {
this.applySystemPreference();
}
}
/**
* Setup theme observer to watch for changes
*/
setupThemeObserver() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const newTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (newTheme !== this.currentTheme) {
this.handleThemeChange(newTheme);
}
}
});
});
this.observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
/**
* Setup system preference detection
*/
setupSystemPreferenceDetection() {
if (!this.options.enableSystemPreference) return;
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = (e) => {
if (this.getStoredPreference() === 'auto') {
const newTheme = e.matches ? 'dark' : 'light';
this.setTheme(newTheme);
}
};
// Modern browsers
if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener('change', handleSystemChange);
} else {
// Fallback for older browsers
this.mediaQuery.addListener(handleSystemChange);
}
}
/**
* Setup storage synchronization
*/
setupStorageSync() {
// Listen for storage changes from other tabs
window.addEventListener('storage', (e) => {
if (e.key === this.options.storageKey) {
const newPreference = e.newValue;
if (newPreference) {
this.applyStoredPreference(newPreference);
}
}
});
}
/**
* Setup map theme styles
*/
setupMapThemeStyles() {
if (document.getElementById('dark-mode-map-styles')) return;
const styles = `
<style id="dark-mode-map-styles">
/* Light theme map styles */
.map-container {
transition: filter ${this.options.transitionDuration}ms ease;
}
/* Dark theme map styles */
.dark .map-container {
filter: brightness(0.9) contrast(1.1);
}
/* Dark theme popup styles */
.dark .leaflet-popup-content-wrapper {
background: #1F2937;
color: #F9FAFB;
border: 1px solid #374151;
}
.dark .leaflet-popup-tip {
background: #1F2937;
border: 1px solid #374151;
}
.dark .leaflet-popup-close-button {
color: #D1D5DB;
}
.dark .leaflet-popup-close-button:hover {
color: #F9FAFB;
}
/* Dark theme control styles */
.dark .leaflet-control-zoom a {
background-color: #374151;
border-color: #4B5563;
color: #F9FAFB;
}
.dark .leaflet-control-zoom a:hover {
background-color: #4B5563;
}
.dark .leaflet-control-attribution {
background: rgba(31, 41, 55, 0.8);
color: #D1D5DB;
}
/* Dark theme marker cluster styles */
.dark .cluster-marker-inner {
background: #1E40AF;
border-color: #1F2937;
}
.dark .cluster-marker-medium .cluster-marker-inner {
background: #059669;
}
.dark .cluster-marker-large .cluster-marker-inner {
background: #DC2626;
}
/* Dark theme location marker styles */
.dark .location-marker-inner {
border-color: #1F2937;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* Dark theme filter panel styles */
.dark .filter-chip.active {
background: #1E40AF;
color: #F9FAFB;
}
.dark .filter-chip.inactive {
background: #374151;
color: #D1D5DB;
}
.dark .filter-chip.inactive:hover {
background: #4B5563;
}
/* Dark theme road trip styles */
.dark .park-item {
background: #374151;
border-color: #4B5563;
}
.dark .park-item:hover {
background: #4B5563;
}
/* Dark theme search results */
.dark .search-result-item {
background: #374151;
border-color: #4B5563;
}
.dark .search-result-item:hover {
background: #4B5563;
}
/* Dark theme loading indicators */
.dark .htmx-indicator {
color: #D1D5DB;
}
/* Theme transition effects */
.theme-transition {
transition: background-color ${this.options.transitionDuration}ms ease,
color ${this.options.transitionDuration}ms ease,
border-color ${this.options.transitionDuration}ms ease;
}
/* Dark theme toggle button */
.theme-toggle {
position: relative;
width: 48px;
height: 24px;
background: #E5E7EB;
border-radius: 12px;
border: none;
cursor: pointer;
transition: background-color ${this.options.transitionDuration}ms ease;
}
.dark .theme-toggle {
background: #374151;
}
.theme-toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform ${this.options.transitionDuration}ms ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.dark .theme-toggle::after {
transform: translateX(24px);
background: #F9FAFB;
}
/* Theme icons */
.theme-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
transition: opacity ${this.options.transitionDuration}ms ease;
}
.theme-icon.sun {
left: 4px;
color: #F59E0B;
opacity: 1;
}
.theme-icon.moon {
right: 4px;
color: #6366F1;
opacity: 0;
}
.dark .theme-icon.sun {
opacity: 0;
}
.dark .theme-icon.moon {
opacity: 1;
}
/* System preference indicator */
.theme-auto-indicator {
font-size: 10px;
opacity: 0.6;
margin-left: 4px;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
/**
* Bind event handlers
*/
bindEventHandlers() {
// Handle theme toggle buttons
const themeToggleButtons = document.querySelectorAll('[data-theme-toggle]');
themeToggleButtons.forEach(button => {
button.addEventListener('click', () => {
this.toggleTheme();
});
});
// Handle theme selection
const themeSelectors = document.querySelectorAll('[data-theme-select]');
themeSelectors.forEach(selector => {
selector.addEventListener('change', (e) => {
this.setThemePreference(e.target.value);
});
});
}
/**
* Handle theme change
*/
handleThemeChange(newTheme) {
const oldTheme = this.currentTheme;
this.currentTheme = newTheme;
// Update map tile layers
this.updateMapTileLayers(newTheme);
// Update marker themes
this.updateMarkerThemes(newTheme);
// Emit theme change event
const event = new CustomEvent('themeChanged', {
detail: {
oldTheme,
newTheme,
isSystemPreference: this.getStoredPreference() === 'auto'
}
});
document.dispatchEvent(event);
console.log(`Theme changed from ${oldTheme} to ${newTheme}`);
}
/**
* Update map tile layers for theme
*/
updateMapTileLayers(theme) {
this.mapInstances.forEach(mapInstance => {
const currentTileLayer = this.tileLayers.get(mapInstance);
if (currentTileLayer) {
mapInstance.removeLayer(currentTileLayer);
}
// Create new tile layer for theme
const tileUrl = this.options.tileProviders[theme].osm;
const newTileLayer = L.tileLayer(tileUrl, {
attribution: '© OpenStreetMap contributors' + (theme === 'dark' ? ', © CARTO' : ''),
className: `map-tiles-${theme}`
});
newTileLayer.addTo(mapInstance);
this.tileLayers.set(mapInstance, newTileLayer);
});
}
/**
* Update marker themes
*/
updateMarkerThemes(theme) {
if (window.mapMarkers) {
// Clear marker caches to force re-render with new theme
window.mapMarkers.clearIconCache();
window.mapMarkers.clearPopupCache();
}
}
/**
* Toggle between light and dark themes
*/
toggleTheme() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(newTheme);
this.setStoredPreference(newTheme);
}
/**
* Set specific theme
*/
setTheme(theme) {
if (!['light', 'dark'].includes(theme)) return;
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Update theme toggle states
this.updateThemeToggleStates(theme);
}
/**
* Set theme preference (light, dark, auto)
*/
setThemePreference(preference) {
if (!['light', 'dark', 'auto'].includes(preference)) return;
this.setStoredPreference(preference);
if (preference === 'auto') {
this.applySystemPreference();
} else {
this.setTheme(preference);
}
}
/**
* Apply system preference
*/
applySystemPreference() {
if (this.mediaQuery) {
const systemPrefersDark = this.mediaQuery.matches;
this.setTheme(systemPrefersDark ? 'dark' : 'light');
}
}
/**
* Apply stored preference
*/
applyStoredPreference(preference) {
if (preference === 'auto') {
this.applySystemPreference();
} else {
this.setTheme(preference);
}
}
/**
* Get stored theme preference
*/
getStoredPreference() {
return localStorage.getItem(this.options.storageKey) || 'auto';
}
/**
* Set stored theme preference
*/
setStoredPreference(preference) {
localStorage.setItem(this.options.storageKey, preference);
}
/**
* Update theme toggle button states
*/
updateThemeToggleStates(theme) {
const toggleButtons = document.querySelectorAll('[data-theme-toggle]');
toggleButtons.forEach(button => {
button.setAttribute('data-theme', theme);
button.setAttribute('aria-label', `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`);
});
const themeSelectors = document.querySelectorAll('[data-theme-select]');
themeSelectors.forEach(selector => {
selector.value = this.getStoredPreference();
});
}
/**
* Register map instance for theme management
*/
registerMapInstance(mapInstance) {
this.mapInstances.add(mapInstance);
// Apply current theme immediately
setTimeout(() => {
this.updateMapTileLayers(this.currentTheme);
}, 100);
}
/**
* Unregister map instance
*/
unregisterMapInstance(mapInstance) {
this.mapInstances.delete(mapInstance);
this.tileLayers.delete(mapInstance);
}
/**
* Create theme toggle button
*/
createThemeToggle() {
const toggle = document.createElement('button');
toggle.className = 'theme-toggle';
toggle.setAttribute('data-theme-toggle', 'true');
toggle.setAttribute('aria-label', 'Toggle theme');
toggle.innerHTML = `
<i class="theme-icon sun fas fa-sun"></i>
<i class="theme-icon moon fas fa-moon"></i>
`;
toggle.addEventListener('click', () => {
this.toggleTheme();
});
return toggle;
}
/**
* Create theme selector dropdown
*/
createThemeSelector() {
const selector = document.createElement('select');
selector.className = 'theme-selector';
selector.setAttribute('data-theme-select', 'true');
selector.innerHTML = `
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
`;
selector.value = this.getStoredPreference();
selector.addEventListener('change', (e) => {
this.setThemePreference(e.target.value);
});
return selector;
}
/**
* Get current theme
*/
getCurrentTheme() {
return this.currentTheme;
}
/**
* Check if dark mode is active
*/
isDarkMode() {
return this.currentTheme === 'dark';
}
/**
* Check if system preference is supported
*/
isSystemPreferenceSupported() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').media !== 'not all';
}
/**
* Get system preference
*/
getSystemPreference() {
if (this.isSystemPreferenceSupported() && this.mediaQuery) {
return this.mediaQuery.matches ? 'dark' : 'light';
}
return 'light';
}
/**
* Add theme transition classes
*/
addThemeTransitions() {
const elements = document.querySelectorAll('.filter-chip, .park-item, .search-result-item, .popup-btn');
elements.forEach(element => {
element.classList.add('theme-transition');
});
}
/**
* Remove theme transition classes
*/
removeThemeTransitions() {
const elements = document.querySelectorAll('.theme-transition');
elements.forEach(element => {
element.classList.remove('theme-transition');
});
}
/**
* Destroy dark mode instance
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
}
if (this.mediaQuery) {
if (this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener('change', this.applySystemPreference);
} else {
this.mediaQuery.removeListener(this.applySystemPreference);
}
}
this.mapInstances.clear();
this.tileLayers.clear();
}
}
// Auto-initialize dark mode support
document.addEventListener('DOMContentLoaded', function() {
window.darkModeMaps = new DarkModeMaps();
// Register existing map instances
if (window.thrillwikiMap) {
window.darkModeMaps.registerMapInstance(window.thrillwikiMap);
}
// Add theme transitions
window.darkModeMaps.addThemeTransitions();
// Add theme toggle to navigation if it doesn't exist
const nav = document.querySelector('nav, .navbar, .header-nav');
if (nav && !nav.querySelector('[data-theme-toggle]')) {
const themeToggle = window.darkModeMaps.createThemeToggle();
themeToggle.style.marginLeft = 'auto';
nav.appendChild(themeToggle);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = DarkModeMaps;
} else {
window.DarkModeMaps = DarkModeMaps;
}

720
static/js/geolocation.js Normal file
View File

@@ -0,0 +1,720 @@
/**
* ThrillWiki Geolocation - User Location and "Near Me" Functionality
*
* This module handles browser geolocation API integration with privacy-conscious
* permission handling, distance calculations, and "near me" functionality
*/
class UserLocation {
constructor(options = {}) {
this.options = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 300000, // 5 minutes
watchPosition: false,
autoShowOnMap: true,
showAccuracyCircle: true,
enableCaching: true,
cacheKey: 'thrillwiki_user_location',
apiEndpoints: {
nearby: '/api/map/nearby/',
distance: '/api/map/distance/'
},
defaultRadius: 50, // miles
maxRadius: 500,
...options
};
this.currentPosition = null;
this.watchId = null;
this.mapInstance = null;
this.locationMarker = null;
this.accuracyCircle = null;
this.permissionState = 'unknown';
this.lastLocationTime = null;
// Event handlers
this.eventHandlers = {
locationFound: [],
locationError: [],
permissionChanged: []
};
this.init();
}
/**
* Initialize the geolocation component
*/
init() {
this.checkGeolocationSupport();
this.loadCachedLocation();
this.setupLocationButtons();
this.checkPermissionState();
}
/**
* Check if geolocation is supported by the browser
*/
checkGeolocationSupport() {
if (!navigator.geolocation) {
console.warn('Geolocation is not supported by this browser');
this.hideLocationButtons();
return false;
}
return true;
}
/**
* Setup location-related buttons and controls
*/
setupLocationButtons() {
// Find all "locate me" buttons
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
locateButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.requestLocation();
});
});
// Find "near me" buttons
const nearMeButtons = document.querySelectorAll('[data-action="near-me"], .near-me-btn');
nearMeButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.showNearbyLocations();
});
});
// Distance calculator buttons
const distanceButtons = document.querySelectorAll('[data-action="calculate-distance"]');
distanceButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const targetLat = parseFloat(button.dataset.lat);
const targetLng = parseFloat(button.dataset.lng);
this.calculateDistance(targetLat, targetLng);
});
});
}
/**
* Hide location buttons when geolocation is not supported
*/
hideLocationButtons() {
const locationElements = document.querySelectorAll('.geolocation-feature');
locationElements.forEach(el => {
el.style.display = 'none';
});
}
/**
* Check current permission state
*/
async checkPermissionState() {
if ('permissions' in navigator) {
try {
const permission = await navigator.permissions.query({ name: 'geolocation' });
this.permissionState = permission.state;
this.updateLocationButtonStates();
// Listen for permission changes
permission.addEventListener('change', () => {
this.permissionState = permission.state;
this.updateLocationButtonStates();
this.triggerEvent('permissionChanged', this.permissionState);
});
} catch (error) {
console.warn('Could not check geolocation permission:', error);
}
}
}
/**
* Update location button states based on permission
*/
updateLocationButtonStates() {
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
locateButtons.forEach(button => {
const icon = button.querySelector('i') || button;
switch (this.permissionState) {
case 'granted':
button.disabled = false;
button.title = 'Find my location';
icon.className = 'fas fa-crosshairs';
break;
case 'denied':
button.disabled = true;
button.title = 'Location access denied';
icon.className = 'fas fa-times-circle';
break;
case 'prompt':
default:
button.disabled = false;
button.title = 'Find my location (permission required)';
icon.className = 'fas fa-crosshairs';
break;
}
});
}
/**
* Request user location
*/
requestLocation(options = {}) {
if (!navigator.geolocation) {
this.handleLocationError(new Error('Geolocation not supported'));
return;
}
const requestOptions = {
...this.options,
...options
};
// Show loading state
this.setLocationButtonLoading(true);
navigator.geolocation.getCurrentPosition(
(position) => this.handleLocationSuccess(position),
(error) => this.handleLocationError(error),
{
enableHighAccuracy: requestOptions.enableHighAccuracy,
timeout: requestOptions.timeout,
maximumAge: requestOptions.maximumAge
}
);
}
/**
* Start watching user position
*/
startWatching() {
if (!navigator.geolocation || this.watchId) return;
this.watchId = navigator.geolocation.watchPosition(
(position) => this.handleLocationSuccess(position),
(error) => this.handleLocationError(error),
{
enableHighAccuracy: this.options.enableHighAccuracy,
timeout: this.options.timeout,
maximumAge: this.options.maximumAge
}
);
}
/**
* Stop watching user position
*/
stopWatching() {
if (this.watchId) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
}
/**
* Handle successful location acquisition
*/
handleLocationSuccess(position) {
this.currentPosition = {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp
};
this.lastLocationTime = Date.now();
// Cache location
if (this.options.enableCaching) {
this.cacheLocation(this.currentPosition);
}
// Show on map if enabled
if (this.options.autoShowOnMap && this.mapInstance) {
this.showLocationOnMap();
}
// Update button states
this.setLocationButtonLoading(false);
this.updateLocationButtonStates();
// Trigger event
this.triggerEvent('locationFound', this.currentPosition);
console.log('Location found:', this.currentPosition);
}
/**
* Handle location errors
*/
handleLocationError(error) {
this.setLocationButtonLoading(false);
let message = 'Unable to get your location';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Location access denied. Please enable location services.';
this.permissionState = 'denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information is unavailable.';
break;
case error.TIMEOUT:
message = 'Location request timed out.';
break;
default:
message = 'An unknown error occurred while retrieving location.';
break;
}
this.showLocationMessage(message, 'error');
this.updateLocationButtonStates();
// Trigger event
this.triggerEvent('locationError', { error, message });
console.error('Location error:', error);
}
/**
* Show user location on map
*/
showLocationOnMap() {
if (!this.mapInstance || !this.currentPosition) return;
const { lat, lng, accuracy } = this.currentPosition;
// Remove existing location marker and circle
this.clearLocationDisplay();
// Add location marker
this.locationMarker = L.marker([lat, lng], {
icon: this.createUserLocationIcon()
}).addTo(this.mapInstance);
this.locationMarker.bindPopup(`
<div class="user-location-popup">
<h4><i class="fas fa-map-marker-alt"></i> Your Location</h4>
<p class="accuracy">Accuracy: ±${Math.round(accuracy)}m</p>
<div class="location-actions">
<button onclick="userLocation.showNearbyLocations()" class="btn btn-primary btn-sm">
<i class="fas fa-search"></i> Find Nearby Parks
</button>
</div>
</div>
`);
// Add accuracy circle if enabled and accuracy is reasonable
if (this.options.showAccuracyCircle && accuracy < 1000) {
this.accuracyCircle = L.circle([lat, lng], {
radius: accuracy,
fillColor: '#3388ff',
fillOpacity: 0.2,
color: '#3388ff',
weight: 2,
opacity: 0.5
}).addTo(this.mapInstance);
}
// Center map on user location
this.mapInstance.setView([lat, lng], 13);
}
/**
* Create custom icon for user location
*/
createUserLocationIcon() {
return L.divIcon({
className: 'user-location-marker',
html: `
<div class="user-location-inner">
<i class="fas fa-crosshairs"></i>
</div>
`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
/**
* Clear location display from map
*/
clearLocationDisplay() {
if (this.locationMarker && this.mapInstance) {
this.mapInstance.removeLayer(this.locationMarker);
this.locationMarker = null;
}
if (this.accuracyCircle && this.mapInstance) {
this.mapInstance.removeLayer(this.accuracyCircle);
this.accuracyCircle = null;
}
}
/**
* Show nearby locations
*/
async showNearbyLocations(radius = null) {
if (!this.currentPosition) {
this.requestLocation();
return;
}
try {
const searchRadius = radius || this.options.defaultRadius;
const { lat, lng } = this.currentPosition;
const params = new URLSearchParams({
lat: lat,
lng: lng,
radius: searchRadius,
unit: 'miles'
});
const response = await fetch(`${this.options.apiEndpoints.nearby}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.displayNearbyResults(data.data);
} else {
this.showLocationMessage('No nearby locations found', 'info');
}
} catch (error) {
console.error('Failed to find nearby locations:', error);
this.showLocationMessage('Failed to find nearby locations', 'error');
}
}
/**
* Display nearby search results
*/
displayNearbyResults(results) {
// Find or create results container
let resultsContainer = document.getElementById('nearby-results');
if (!resultsContainer) {
resultsContainer = document.createElement('div');
resultsContainer.id = 'nearby-results';
resultsContainer.className = 'nearby-results-container';
// Try to insert after a logical element
const mapContainer = document.getElementById('map-container');
if (mapContainer && mapContainer.parentNode) {
mapContainer.parentNode.insertBefore(resultsContainer, mapContainer.nextSibling);
} else {
document.body.appendChild(resultsContainer);
}
}
const html = `
<div class="nearby-results">
<h3 class="results-title">
<i class="fas fa-map-marker-alt"></i>
Nearby Parks (${results.length} found)
</h3>
<div class="results-list">
${results.map(location => `
<div class="nearby-item">
<div class="location-info">
<h4 class="location-name">${location.name}</h4>
<p class="location-address">${location.formatted_location || ''}</p>
<p class="location-distance">
<i class="fas fa-route"></i>
${location.distance} away
</p>
</div>
<div class="location-actions">
<button onclick="userLocation.centerOnLocation(${location.latitude}, ${location.longitude})"
class="btn btn-outline btn-sm">
<i class="fas fa-map"></i> Show on Map
</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
resultsContainer.innerHTML = html;
// Scroll to results
resultsContainer.scrollIntoView({ behavior: 'smooth' });
}
/**
* Calculate distance to a specific location
*/
async calculateDistance(targetLat, targetLng) {
if (!this.currentPosition) {
this.showLocationMessage('Please enable location services first', 'warning');
return null;
}
try {
const { lat, lng } = this.currentPosition;
const params = new URLSearchParams({
from_lat: lat,
from_lng: lng,
to_lat: targetLat,
to_lng: targetLng
});
const response = await fetch(`${this.options.apiEndpoints.distance}?${params}`);
const data = await response.json();
if (data.status === 'success') {
return data.data;
}
} catch (error) {
console.error('Failed to calculate distance:', error);
}
// Fallback to Haversine formula
return this.calculateHaversineDistance(
this.currentPosition.lat,
this.currentPosition.lng,
targetLat,
targetLng
);
}
/**
* Calculate distance using Haversine formula
*/
calculateHaversineDistance(lat1, lng1, lat2, lng2) {
const R = 3959; // Earth's radius in miles
const dLat = this.toRadians(lat2 - lat1);
const dLng = this.toRadians(lng2 - lng1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return {
distance: Math.round(distance * 10) / 10,
unit: 'miles'
};
}
/**
* Convert degrees to radians
*/
toRadians(degrees) {
return degrees * (Math.PI / 180);
}
/**
* Center map on specific location
*/
centerOnLocation(lat, lng, zoom = 15) {
if (this.mapInstance) {
this.mapInstance.setView([lat, lng], zoom);
}
}
/**
* Cache user location
*/
cacheLocation(position) {
try {
const cacheData = {
position: position,
timestamp: Date.now()
};
localStorage.setItem(this.options.cacheKey, JSON.stringify(cacheData));
} catch (error) {
console.warn('Failed to cache location:', error);
}
}
/**
* Load cached location
*/
loadCachedLocation() {
if (!this.options.enableCaching) return null;
try {
const cached = localStorage.getItem(this.options.cacheKey);
if (!cached) return null;
const cacheData = JSON.parse(cached);
const age = Date.now() - cacheData.timestamp;
// Check if cache is still valid (5 minutes)
if (age < this.options.maximumAge) {
this.currentPosition = cacheData.position;
this.lastLocationTime = cacheData.timestamp;
return this.currentPosition;
} else {
// Remove expired cache
localStorage.removeItem(this.options.cacheKey);
}
} catch (error) {
console.warn('Failed to load cached location:', error);
}
return null;
}
/**
* Set loading state for location buttons
*/
setLocationButtonLoading(loading) {
const locateButtons = document.querySelectorAll('[data-action="locate-user"], .locate-user-btn');
locateButtons.forEach(button => {
const icon = button.querySelector('i') || button;
if (loading) {
button.disabled = true;
icon.className = 'fas fa-spinner fa-spin';
} else {
button.disabled = false;
// Icon will be updated by updateLocationButtonStates
}
});
}
/**
* Show location-related message
*/
showLocationMessage(message, type = 'info') {
// Create or update message element
let messageEl = document.getElementById('location-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.id = 'location-message';
messageEl.className = 'location-message';
// Insert at top of page or after header
const header = document.querySelector('header, .header');
if (header) {
header.parentNode.insertBefore(messageEl, header.nextSibling);
} else {
document.body.insertBefore(messageEl, document.body.firstChild);
}
}
messageEl.textContent = message;
messageEl.className = `location-message location-message-${type}`;
messageEl.style.display = 'block';
// Auto-hide after delay
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.style.display = 'none';
}
}, 5000);
}
/**
* Connect to a map instance
*/
connectToMap(mapInstance) {
this.mapInstance = mapInstance;
// Show cached location on map if available
if (this.currentPosition && this.options.autoShowOnMap) {
this.showLocationOnMap();
}
}
/**
* Get current position
*/
getCurrentPosition() {
return this.currentPosition;
}
/**
* Check if location is available
*/
hasLocation() {
return this.currentPosition !== null;
}
/**
* Check if location is recent
*/
isLocationRecent(maxAge = 300000) { // 5 minutes default
if (!this.lastLocationTime) return false;
return (Date.now() - this.lastLocationTime) < maxAge;
}
/**
* Add event listener
*/
on(event, handler) {
if (!this.eventHandlers[event]) {
this.eventHandlers[event] = [];
}
this.eventHandlers[event].push(handler);
}
/**
* Remove event listener
*/
off(event, handler) {
if (this.eventHandlers[event]) {
const index = this.eventHandlers[event].indexOf(handler);
if (index > -1) {
this.eventHandlers[event].splice(index, 1);
}
}
}
/**
* Trigger event
*/
triggerEvent(event, data) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(`Error in ${event} handler:`, error);
}
});
}
}
/**
* Destroy the geolocation instance
*/
destroy() {
this.stopWatching();
this.clearLocationDisplay();
this.eventHandlers = {};
}
}
// Auto-initialize user location
document.addEventListener('DOMContentLoaded', function() {
window.userLocation = new UserLocation();
// Connect to map instance if available
if (window.thrillwikiMap) {
window.userLocation.connectToMap(window.thrillwikiMap);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = UserLocation;
} else {
window.UserLocation = UserLocation;
}

725
static/js/htmx-maps.js Normal file
View File

@@ -0,0 +1,725 @@
/**
* ThrillWiki HTMX Maps Integration - Dynamic Map Updates via HTMX
*
* This module handles HTMX events for map updates, manages loading states
* during API calls, updates map content based on HTMX responses, and provides
* error handling for failed requests
*/
class HTMXMapIntegration {
constructor(options = {}) {
this.options = {
mapInstance: null,
filterInstance: null,
defaultTarget: '#map-container',
loadingClass: 'htmx-loading',
errorClass: 'htmx-error',
successClass: 'htmx-success',
loadingTimeout: 30000, // 30 seconds
retryAttempts: 3,
retryDelay: 1000,
...options
};
this.loadingElements = new Set();
this.activeRequests = new Map();
this.requestQueue = [];
this.retryCount = new Map();
this.init();
}
/**
* Initialize HTMX integration
*/
init() {
if (typeof htmx === 'undefined') {
console.warn('HTMX not found, map integration disabled');
return;
}
this.setupEventHandlers();
this.setupCustomEvents();
this.setupErrorHandling();
this.enhanceExistingElements();
}
/**
* Setup HTMX event handlers
*/
setupEventHandlers() {
// Before request - show loading states
document.addEventListener('htmx:beforeRequest', (e) => {
this.handleBeforeRequest(e);
});
// After request - handle response and update maps
document.addEventListener('htmx:afterRequest', (e) => {
this.handleAfterRequest(e);
});
// Response error - handle failed requests
document.addEventListener('htmx:responseError', (e) => {
this.handleResponseError(e);
});
// Send error - handle network errors
document.addEventListener('htmx:sendError', (e) => {
this.handleSendError(e);
});
// Timeout - handle request timeouts
document.addEventListener('htmx:timeout', (e) => {
this.handleTimeout(e);
});
// Before swap - prepare for content updates
document.addEventListener('htmx:beforeSwap', (e) => {
this.handleBeforeSwap(e);
});
// After swap - update maps with new content
document.addEventListener('htmx:afterSwap', (e) => {
this.handleAfterSwap(e);
});
// Config request - modify requests before sending
document.addEventListener('htmx:configRequest', (e) => {
this.handleConfigRequest(e);
});
}
/**
* Setup custom map-specific events
*/
setupCustomEvents() {
// Custom event for map data updates
document.addEventListener('map:dataUpdate', (e) => {
this.handleMapDataUpdate(e);
});
// Custom event for filter changes
document.addEventListener('filter:changed', (e) => {
this.handleFilterChange(e);
});
// Custom event for search updates
document.addEventListener('search:results', (e) => {
this.handleSearchResults(e);
});
}
/**
* Setup global error handling
*/
setupErrorHandling() {
// Global error handler
window.addEventListener('error', (e) => {
if (e.filename && e.filename.includes('htmx')) {
console.error('HTMX error:', e.error);
this.showErrorMessage('An error occurred while updating the map');
}
});
// Unhandled promise rejection handler
window.addEventListener('unhandledrejection', (e) => {
if (e.reason && e.reason.toString().includes('htmx')) {
console.error('HTMX promise rejection:', e.reason);
this.showErrorMessage('Failed to complete map request');
}
});
}
/**
* Enhance existing elements with HTMX map functionality
*/
enhanceExistingElements() {
// Add map-specific attributes to filter forms
const filterForms = document.querySelectorAll('[data-map-filter]');
filterForms.forEach(form => {
if (!form.hasAttribute('hx-get')) {
form.setAttribute('hx-get', form.getAttribute('data-map-filter'));
form.setAttribute('hx-trigger', 'change, submit');
form.setAttribute('hx-target', '#map-container');
form.setAttribute('hx-swap', 'none');
}
});
// Add map update attributes to search inputs
const searchInputs = document.querySelectorAll('[data-map-search]');
searchInputs.forEach(input => {
if (!input.hasAttribute('hx-get')) {
input.setAttribute('hx-get', input.getAttribute('data-map-search'));
input.setAttribute('hx-trigger', 'input changed delay:500ms');
input.setAttribute('hx-target', '#search-results');
}
});
}
/**
* Handle before request event
*/
handleBeforeRequest(e) {
const element = e.target;
const requestId = this.generateRequestId();
// Store request information
this.activeRequests.set(requestId, {
element: element,
startTime: Date.now(),
url: e.detail.requestConfig.path
});
// Show loading state
this.showLoadingState(element, true);
// Add request ID to detail for tracking
e.detail.requestId = requestId;
// Set timeout
setTimeout(() => {
if (this.activeRequests.has(requestId)) {
this.handleTimeout({ detail: { requestId } });
}
}, this.options.loadingTimeout);
console.log('HTMX request started:', e.detail.requestConfig.path);
}
/**
* Handle after request event
*/
handleAfterRequest(e) {
const requestId = e.detail.requestId;
const request = this.activeRequests.get(requestId);
if (request) {
const duration = Date.now() - request.startTime;
console.log(`HTMX request completed in ${duration}ms:`, request.url);
this.activeRequests.delete(requestId);
this.showLoadingState(request.element, false);
}
if (e.detail.successful) {
this.handleSuccessfulResponse(e);
} else {
this.handleFailedResponse(e);
}
}
/**
* Handle successful response
*/
handleSuccessfulResponse(e) {
const element = e.target;
// Add success class temporarily
element.classList.add(this.options.successClass);
setTimeout(() => {
element.classList.remove(this.options.successClass);
}, 2000);
// Reset retry count
this.retryCount.delete(element);
// Check if this is a map-related request
if (this.isMapRequest(e)) {
this.updateMapFromResponse(e);
}
}
/**
* Handle failed response
*/
handleFailedResponse(e) {
const element = e.target;
// Add error class
element.classList.add(this.options.errorClass);
setTimeout(() => {
element.classList.remove(this.options.errorClass);
}, 5000);
// Check if we should retry
if (this.shouldRetry(element)) {
this.scheduleRetry(element, e.detail);
} else {
this.showErrorMessage('Failed to update map data');
}
}
/**
* Handle response error
*/
handleResponseError(e) {
console.error('HTMX response error:', e.detail);
const element = e.target;
const status = e.detail.xhr.status;
let message = 'An error occurred while updating the map';
switch (status) {
case 400:
message = 'Invalid request parameters';
break;
case 401:
message = 'Authentication required';
break;
case 403:
message = 'Access denied';
break;
case 404:
message = 'Map data not found';
break;
case 429:
message = 'Too many requests. Please wait a moment.';
break;
case 500:
message = 'Server error. Please try again later.';
break;
}
this.showErrorMessage(message);
this.showLoadingState(element, false);
}
/**
* Handle send error
*/
handleSendError(e) {
console.error('HTMX send error:', e.detail);
this.showErrorMessage('Network error. Please check your connection.');
this.showLoadingState(e.target, false);
}
/**
* Handle timeout
*/
handleTimeout(e) {
console.warn('HTMX request timeout');
if (e.detail.requestId) {
const request = this.activeRequests.get(e.detail.requestId);
if (request) {
this.showLoadingState(request.element, false);
this.activeRequests.delete(e.detail.requestId);
}
}
this.showErrorMessage('Request timed out. Please try again.');
}
/**
* Handle before swap
*/
handleBeforeSwap(e) {
// Prepare map for content update
if (this.isMapRequest(e)) {
console.log('Preparing map for content swap');
}
}
/**
* Handle after swap
*/
handleAfterSwap(e) {
// Re-initialize any new HTMX elements
this.enhanceExistingElements();
// Update maps if needed
if (this.isMapRequest(e)) {
this.reinitializeMapComponents();
}
}
/**
* Handle config request
*/
handleConfigRequest(e) {
const config = e.detail;
// Add CSRF token if available
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken && (config.verb === 'post' || config.verb === 'put' || config.verb === 'patch')) {
config.headers['X-CSRFToken'] = csrfToken.value;
}
// Add map-specific headers
if (this.isMapRequest(e)) {
config.headers['X-Map-Request'] = 'true';
// Add current map bounds if available
if (this.options.mapInstance) {
const bounds = this.options.mapInstance.getBounds();
if (bounds) {
config.headers['X-Map-Bounds'] = JSON.stringify({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest(),
zoom: this.options.mapInstance.getZoom()
});
}
}
}
}
/**
* Handle map data updates
*/
handleMapDataUpdate(e) {
if (this.options.mapInstance) {
const data = e.detail;
this.options.mapInstance.updateMarkers(data);
}
}
/**
* Handle filter changes
*/
handleFilterChange(e) {
if (this.options.filterInstance) {
const filters = e.detail;
// Trigger HTMX request for filter update
const filterForm = document.getElementById('map-filters');
if (filterForm && filterForm.hasAttribute('hx-get')) {
htmx.trigger(filterForm, 'change');
}
}
}
/**
* Handle search results
*/
handleSearchResults(e) {
const results = e.detail;
// Update map with search results if applicable
if (results.locations && this.options.mapInstance) {
this.options.mapInstance.updateMarkers({ locations: results.locations });
}
}
/**
* Show/hide loading state
*/
showLoadingState(element, show) {
if (show) {
element.classList.add(this.options.loadingClass);
this.loadingElements.add(element);
// Show loading indicators
const indicators = element.querySelectorAll('.htmx-indicator');
indicators.forEach(indicator => {
indicator.style.display = 'block';
});
// Disable form elements
const inputs = element.querySelectorAll('input, button, select');
inputs.forEach(input => {
input.disabled = true;
});
} else {
element.classList.remove(this.options.loadingClass);
this.loadingElements.delete(element);
// Hide loading indicators
const indicators = element.querySelectorAll('.htmx-indicator');
indicators.forEach(indicator => {
indicator.style.display = 'none';
});
// Re-enable form elements
const inputs = element.querySelectorAll('input, button, select');
inputs.forEach(input => {
input.disabled = false;
});
}
}
/**
* Check if request is map-related
*/
isMapRequest(e) {
const element = e.target;
const url = e.detail.requestConfig ? e.detail.requestConfig.path : '';
return element.hasAttribute('data-map-filter') ||
element.hasAttribute('data-map-search') ||
element.closest('[data-map-target]') ||
url.includes('/api/map/') ||
url.includes('/maps/');
}
/**
* Update map from HTMX response
*/
updateMapFromResponse(e) {
if (!this.options.mapInstance) return;
try {
// Try to extract map data from response
const responseText = e.detail.xhr.responseText;
// If response is JSON, update map directly
try {
const data = JSON.parse(responseText);
if (data.status === 'success' && data.data) {
this.options.mapInstance.updateMarkers(data.data);
}
} catch (jsonError) {
// If not JSON, look for data attributes in HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = responseText;
const mapData = tempDiv.querySelector('[data-map-data]');
if (mapData) {
const data = JSON.parse(mapData.getAttribute('data-map-data'));
this.options.mapInstance.updateMarkers(data);
}
}
} catch (error) {
console.error('Failed to update map from response:', error);
}
}
/**
* Check if element should be retried
*/
shouldRetry(element) {
const retryCount = this.retryCount.get(element) || 0;
return retryCount < this.options.retryAttempts;
}
/**
* Schedule retry for failed request
*/
scheduleRetry(element, detail) {
const retryCount = (this.retryCount.get(element) || 0) + 1;
this.retryCount.set(element, retryCount);
const delay = this.options.retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff
setTimeout(() => {
console.log(`Retrying HTMX request (attempt ${retryCount})`);
htmx.trigger(element, 'retry');
}, delay);
}
/**
* Show error message to user
*/
showErrorMessage(message) {
// Create or update error message element
let errorEl = document.getElementById('htmx-error-message');
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = 'htmx-error-message';
errorEl.className = 'htmx-error-message';
// Insert at top of page
document.body.insertBefore(errorEl, document.body.firstChild);
}
errorEl.innerHTML = `
<div class="error-content">
<i class="fas fa-exclamation-circle"></i>
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="error-close">
<i class="fas fa-times"></i>
</button>
</div>
`;
errorEl.style.display = 'block';
// Auto-hide after 10 seconds
setTimeout(() => {
if (errorEl.parentNode) {
errorEl.remove();
}
}, 10000);
}
/**
* Reinitialize map components after content swap
*/
reinitializeMapComponents() {
// Reinitialize filter components
if (this.options.filterInstance) {
this.options.filterInstance.init();
}
// Reinitialize any new map containers
const newMapContainers = document.querySelectorAll('[data-map="auto"]:not([data-initialized])');
newMapContainers.forEach(container => {
container.setAttribute('data-initialized', 'true');
// Initialize new map instance if needed
});
}
/**
* Generate unique request ID
*/
generateRequestId() {
return `htmx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Connect to map instance
*/
connectToMap(mapInstance) {
this.options.mapInstance = mapInstance;
}
/**
* Connect to filter instance
*/
connectToFilter(filterInstance) {
this.options.filterInstance = filterInstance;
}
/**
* Get active request count
*/
getActiveRequestCount() {
return this.activeRequests.size;
}
/**
* Cancel all active requests
*/
cancelAllRequests() {
this.activeRequests.forEach((request, id) => {
this.showLoadingState(request.element, false);
});
this.activeRequests.clear();
}
/**
* Get loading elements
*/
getLoadingElements() {
return Array.from(this.loadingElements);
}
}
// Auto-initialize HTMX integration
document.addEventListener('DOMContentLoaded', function() {
window.htmxMapIntegration = new HTMXMapIntegration();
// Connect to existing instances
if (window.thrillwikiMap) {
window.htmxMapIntegration.connectToMap(window.thrillwikiMap);
}
if (window.mapFilters) {
window.htmxMapIntegration.connectToFilter(window.mapFilters);
}
});
// Add styles for HTMX integration
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('htmx-map-styles')) return;
const styles = `
<style id="htmx-map-styles">
.htmx-loading {
opacity: 0.7;
pointer-events: none;
}
.htmx-error {
border-color: #EF4444;
background-color: #FEE2E2;
}
.htmx-success {
border-color: #10B981;
background-color: #D1FAE5;
}
.htmx-indicator {
display: none;
}
.htmx-error-message {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
max-width: 400px;
background: #FEE2E2;
border: 1px solid #FCA5A5;
border-radius: 8px;
padding: 0;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
animation: slideInRight 0.3s ease;
}
.error-content {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
color: #991B1B;
font-size: 14px;
}
.error-close {
background: none;
border: none;
color: #991B1B;
cursor: pointer;
padding: 4px;
margin-left: auto;
}
.error-close:hover {
color: #7F1D1D;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Dark mode styles */
.dark .htmx-error-message {
background: #7F1D1D;
border-color: #991B1B;
}
.dark .error-content {
color: #FCA5A5;
}
.dark .error-close {
color: #FCA5A5;
}
.dark .error-close:hover {
color: #F87171;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = HTMXMapIntegration;
} else {
window.HTMXMapIntegration = HTMXMapIntegration;
}

View File

@@ -0,0 +1,81 @@
function locationAutocomplete(field, filterParks = false) {
return {
query: '',
suggestions: [],
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
switch (field) {
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (url) {
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
this.suggestions = data;
});
}
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
}
};
}

View File

@@ -0,0 +1,54 @@
document.addEventListener('DOMContentLoaded', function() {
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.toFixed(6)}, ${position.coords.longitude.toFixed(6)}`;
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';
}
// Autocomplete for location search
if (locationInput) {
locationInput.addEventListener('input', function() {
const query = this.value;
if (query.length < 3) {
return;
}
fetch(`/search/location/suggestions/?q=${query}`)
.then(response => response.json())
.then(data => {
// This is a simplified example. A more robust solution would use a library like Awesomplete or build a custom dropdown.
console.log('Suggestions:', data.suggestions);
})
.catch(error => console.error('Error fetching suggestions:', error));
});
}
});

119
static/js/main.js Normal file
View File

@@ -0,0 +1,119 @@
// Theme handling
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// Initialize toggle state based on current theme
if (themeToggle) {
themeToggle.checked = html.classList.contains('dark');
// Handle toggle changes
themeToggle.addEventListener('change', function() {
const isDark = this.checked;
html.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
const isDark = e.matches;
html.classList.toggle('dark', isDark);
themeToggle.checked = isDark;
}
});
}
});
// Handle search form submission
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action*="search"]')) {
const searchInput = e.target.querySelector('input[name="q"]');
if (!searchInput.value.trim()) {
e.preventDefault();
}
}
});
// Mobile menu toggle with transitions
document.addEventListener('DOMContentLoaded', () => {
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
const mobileMenu = document.getElementById('mobileMenu');
if (mobileMenuBtn && mobileMenu) {
let isMenuOpen = false;
const toggleMenu = () => {
isMenuOpen = !isMenuOpen;
mobileMenu.classList.toggle('show', isMenuOpen);
mobileMenuBtn.setAttribute('aria-expanded', isMenuOpen.toString());
// Update icon
const icon = mobileMenuBtn.querySelector('i');
icon.classList.remove(isMenuOpen ? 'fa-bars' : 'fa-times');
icon.classList.add(isMenuOpen ? 'fa-times' : 'fa-bars');
};
mobileMenuBtn.addEventListener('click', toggleMenu);
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (isMenuOpen && !mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) {
toggleMenu();
}
});
// Close menu when pressing escape
document.addEventListener('keydown', (e) => {
if (isMenuOpen && e.key === 'Escape') {
toggleMenu();
}
});
// Handle viewport changes
const mediaQuery = window.matchMedia('(min-width: 1024px)');
mediaQuery.addEventListener('change', (e) => {
if (e.matches && isMenuOpen) {
toggleMenu();
}
});
}
});
// User dropdown functionality is handled by Alpine.js in the template
// No additional JavaScript needed for dropdown functionality
// Handle flash messages
document.addEventListener('DOMContentLoaded', () => {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 300);
}, 5000);
});
});
// Initialize tooltips
document.addEventListener('DOMContentLoaded', () => {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(tooltip => {
tooltip.addEventListener('mouseenter', (e) => {
const text = e.target.getAttribute('data-tooltip');
const tooltipEl = document.createElement('div');
tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip';
tooltipEl.textContent = text;
document.body.appendChild(tooltipEl);
const rect = e.target.getBoundingClientRect();
tooltipEl.style.top = rect.bottom + 5 + 'px';
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
});
tooltip.addEventListener('mouseleave', () => {
const tooltips = document.querySelectorAll('.tooltip');
tooltips.forEach(t => t.remove());
});
});
});

573
static/js/map-filters.js Normal file
View File

@@ -0,0 +1,573 @@
/**
* ThrillWiki Map Filters - Location Filtering Component
*
* This module handles filter panel interactions and updates maps via HTMX
* Supports location type filtering, geographic filtering, and real-time search
*/
class MapFilters {
constructor(formId, options = {}) {
this.formId = formId;
this.options = {
autoSubmit: true,
searchDelay: 500,
enableLocalStorage: true,
storageKey: 'thrillwiki_map_filters',
mapInstance: null,
htmxTarget: '#map-container',
htmxUrl: null,
...options
};
this.form = null;
this.searchTimeout = null;
this.currentFilters = {};
this.filterChips = [];
this.init();
}
/**
* Initialize the filter component
*/
init() {
this.form = document.getElementById(this.formId);
if (!this.form) {
console.error(`Filter form with ID '${this.formId}' not found`);
return;
}
this.setupFilterChips();
this.bindEvents();
this.loadSavedFilters();
this.initializeFilters();
}
/**
* Setup filter chip interactions
*/
setupFilterChips() {
this.filterChips = this.form.querySelectorAll('.filter-chip, .filter-pill');
this.filterChips.forEach(chip => {
const checkbox = chip.querySelector('input[type="checkbox"]');
if (checkbox) {
// Set initial state
this.updateChipState(chip, checkbox.checked);
// Bind click handler
chip.addEventListener('click', (e) => {
e.preventDefault();
this.toggleChip(chip, checkbox);
});
}
});
}
/**
* Toggle filter chip state
*/
toggleChip(chip, checkbox) {
checkbox.checked = !checkbox.checked;
this.updateChipState(chip, checkbox.checked);
// Trigger change event
this.handleFilterChange();
}
/**
* Update visual state of filter chip
*/
updateChipState(chip, isActive) {
if (isActive) {
chip.classList.add('active');
chip.classList.remove('inactive');
} else {
chip.classList.remove('active');
chip.classList.add('inactive');
}
}
/**
* Bind event handlers
*/
bindEvents() {
// Form submission
this.form.addEventListener('submit', (e) => {
if (this.options.autoSubmit) {
e.preventDefault();
this.submitFilters();
}
});
// Input changes (excluding search)
this.form.addEventListener('change', (e) => {
if (e.target.name !== 'q' && !e.target.closest('.no-auto-submit')) {
this.handleFilterChange();
}
});
// Search input with debouncing
const searchInput = this.form.querySelector('input[name="q"]');
if (searchInput) {
searchInput.addEventListener('input', () => {
this.handleSearchInput();
});
}
// Range inputs
const rangeInputs = this.form.querySelectorAll('input[type="range"]');
rangeInputs.forEach(input => {
input.addEventListener('input', (e) => {
this.updateRangeDisplay(e.target);
});
});
// Clear filters button
const clearButton = this.form.querySelector('[data-action="clear-filters"]');
if (clearButton) {
clearButton.addEventListener('click', (e) => {
e.preventDefault();
this.clearAllFilters();
});
}
// HTMX events
if (typeof htmx !== 'undefined') {
this.form.addEventListener('htmx:beforeRequest', () => {
this.showLoadingState(true);
});
this.form.addEventListener('htmx:afterRequest', (e) => {
this.showLoadingState(false);
if (e.detail.successful) {
this.onFiltersApplied(this.getCurrentFilters());
}
});
this.form.addEventListener('htmx:responseError', (e) => {
this.showLoadingState(false);
this.handleError(e.detail);
});
}
}
/**
* Handle search input with debouncing
*/
handleSearchInput() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.handleFilterChange();
}, this.options.searchDelay);
}
/**
* Handle filter changes
*/
handleFilterChange() {
const filters = this.getCurrentFilters();
this.currentFilters = filters;
if (this.options.autoSubmit) {
this.submitFilters();
}
// Save filters to localStorage
if (this.options.enableLocalStorage) {
this.saveFilters(filters);
}
// Update map if connected
if (this.options.mapInstance && this.options.mapInstance.updateFilters) {
this.options.mapInstance.updateFilters(filters);
}
// Trigger custom event
this.triggerFilterEvent('filterChange', filters);
}
/**
* Submit filters via HTMX or form submission
*/
submitFilters() {
if (typeof htmx !== 'undefined' && this.options.htmxUrl) {
// Use HTMX
const formData = new FormData(this.form);
const params = new URLSearchParams(formData);
htmx.ajax('GET', `${this.options.htmxUrl}?${params}`, {
target: this.options.htmxTarget,
swap: 'none'
});
} else {
// Regular form submission
this.form.submit();
}
}
/**
* Get current filter values
*/
getCurrentFilters() {
const formData = new FormData(this.form);
const filters = {};
for (let [key, value] of formData.entries()) {
if (value.trim() === '') continue;
if (filters[key]) {
if (Array.isArray(filters[key])) {
filters[key].push(value);
} else {
filters[key] = [filters[key], value];
}
} else {
filters[key] = value;
}
}
return filters;
}
/**
* Set filter values
*/
setFilters(filters) {
Object.entries(filters).forEach(([key, value]) => {
const elements = this.form.querySelectorAll(`[name="${key}"]`);
elements.forEach(element => {
if (element.type === 'checkbox' || element.type === 'radio') {
if (Array.isArray(value)) {
element.checked = value.includes(element.value);
} else {
element.checked = element.value === value;
}
// Update chip state if applicable
const chip = element.closest('.filter-chip, .filter-pill');
if (chip) {
this.updateChipState(chip, element.checked);
}
} else {
element.value = Array.isArray(value) ? value[0] : value;
// Update range display if applicable
if (element.type === 'range') {
this.updateRangeDisplay(element);
}
}
});
});
this.currentFilters = filters;
}
/**
* Clear all filters
*/
clearAllFilters() {
// Reset form
this.form.reset();
// Update all chip states
this.filterChips.forEach(chip => {
const checkbox = chip.querySelector('input[type="checkbox"]');
if (checkbox) {
this.updateChipState(chip, false);
}
});
// Update range displays
const rangeInputs = this.form.querySelectorAll('input[type="range"]');
rangeInputs.forEach(input => {
this.updateRangeDisplay(input);
});
// Clear saved filters
if (this.options.enableLocalStorage) {
localStorage.removeItem(this.options.storageKey);
}
// Submit cleared filters
this.handleFilterChange();
}
/**
* Update range input display
*/
updateRangeDisplay(rangeInput) {
const valueDisplay = document.getElementById(`${rangeInput.id}-value`) ||
document.getElementById(`${rangeInput.name}-value`);
if (valueDisplay) {
valueDisplay.textContent = rangeInput.value;
}
}
/**
* Load saved filters from localStorage
*/
loadSavedFilters() {
if (!this.options.enableLocalStorage) return;
try {
const saved = localStorage.getItem(this.options.storageKey);
if (saved) {
const filters = JSON.parse(saved);
this.setFilters(filters);
}
} catch (error) {
console.warn('Failed to load saved filters:', error);
}
}
/**
* Save filters to localStorage
*/
saveFilters(filters) {
if (!this.options.enableLocalStorage) return;
try {
localStorage.setItem(this.options.storageKey, JSON.stringify(filters));
} catch (error) {
console.warn('Failed to save filters:', error);
}
}
/**
* Initialize filters from URL parameters or defaults
*/
initializeFilters() {
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
const urlFilters = {};
for (let [key, value] of urlParams.entries()) {
if (urlFilters[key]) {
if (Array.isArray(urlFilters[key])) {
urlFilters[key].push(value);
} else {
urlFilters[key] = [urlFilters[key], value];
}
} else {
urlFilters[key] = value;
}
}
if (Object.keys(urlFilters).length > 0) {
this.setFilters(urlFilters);
}
// Emit initial filter state
this.triggerFilterEvent('filterInit', this.getCurrentFilters());
}
/**
* Show/hide loading state
*/
showLoadingState(show) {
const loadingIndicators = this.form.querySelectorAll('.filter-loading, .htmx-indicator');
loadingIndicators.forEach(indicator => {
indicator.style.display = show ? 'block' : 'none';
});
// Disable form during loading
const inputs = this.form.querySelectorAll('input, select, button');
inputs.forEach(input => {
input.disabled = show;
});
}
/**
* Handle errors
*/
handleError(detail) {
console.error('Filter request failed:', detail);
// Show user-friendly error message
this.showMessage('Failed to apply filters. Please try again.', 'error');
}
/**
* Show message to user
*/
showMessage(message, type = 'info') {
// Create or update message element
let messageEl = this.form.querySelector('.filter-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.className = 'filter-message';
this.form.insertBefore(messageEl, this.form.firstChild);
}
messageEl.textContent = message;
messageEl.className = `filter-message filter-message-${type}`;
// Auto-hide after delay
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
}, 5000);
}
/**
* Callback for when filters are successfully applied
*/
onFiltersApplied(filters) {
this.triggerFilterEvent('filterApplied', filters);
}
/**
* Trigger custom events
*/
triggerFilterEvent(eventName, data) {
const event = new CustomEvent(eventName, {
detail: data
});
this.form.dispatchEvent(event);
}
/**
* Connect to a map instance
*/
connectToMap(mapInstance) {
this.options.mapInstance = mapInstance;
// Listen to map events
if (mapInstance.on) {
mapInstance.on('boundsChange', (bounds) => {
// Could update location-based filters here
});
}
}
/**
* Export current filters as URL parameters
*/
getFilterUrl(baseUrl = window.location.pathname) {
const filters = this.getCurrentFilters();
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => params.append(key, v));
} else {
params.append(key, value);
}
});
return params.toString() ? `${baseUrl}?${params}` : baseUrl;
}
/**
* Update URL with current filters (without page reload)
*/
updateUrl() {
const url = this.getFilterUrl();
if (window.history && window.history.pushState) {
window.history.pushState(null, '', url);
}
}
/**
* Get filter summary for display
*/
getFilterSummary() {
const filters = this.getCurrentFilters();
const summary = [];
// Location types
if (filters.types) {
const types = Array.isArray(filters.types) ? filters.types : [filters.types];
summary.push(`Types: ${types.join(', ')}`);
}
// Geographic filters
if (filters.country) summary.push(`Country: ${filters.country}`);
if (filters.state) summary.push(`State: ${filters.state}`);
if (filters.city) summary.push(`City: ${filters.city}`);
// Search query
if (filters.q) summary.push(`Search: "${filters.q}"`);
// Radius
if (filters.radius) summary.push(`Within ${filters.radius} miles`);
return summary.length > 0 ? summary.join(' • ') : 'No filters applied';
}
/**
* Reset to default filters
*/
resetToDefaults() {
const defaults = {
types: ['park'],
cluster: 'true'
};
this.setFilters(defaults);
this.handleFilterChange();
}
/**
* Destroy the filter component
*/
destroy() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Remove event listeners would go here if we stored references
// For now, rely on garbage collection
}
}
// Auto-initialize filter forms
document.addEventListener('DOMContentLoaded', function() {
// Initialize map filters form
const mapFiltersForm = document.getElementById('map-filters');
if (mapFiltersForm) {
window.mapFilters = new MapFilters('map-filters', {
htmxUrl: mapFiltersForm.getAttribute('hx-get'),
htmxTarget: mapFiltersForm.getAttribute('hx-target') || '#map-container'
});
// Connect to map instance if available
if (window.thrillwikiMap) {
window.mapFilters.connectToMap(window.thrillwikiMap);
}
}
// Initialize other filter forms with data attributes
const filterForms = document.querySelectorAll('[data-filter-form]');
filterForms.forEach(form => {
const options = {};
// Parse data attributes
Object.keys(form.dataset).forEach(key => {
if (key.startsWith('filter')) {
const optionKey = key.replace('filter', '').toLowerCase();
let value = form.dataset[key];
// Parse boolean values
if (value === 'true') value = true;
else if (value === 'false') value = false;
options[optionKey] = value;
}
});
new MapFilters(form.id, options);
});
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MapFilters;
} else {
window.MapFilters = MapFilters;
}

View File

@@ -0,0 +1,553 @@
/**
* ThrillWiki Map Integration - Master Integration Script
*
* This module coordinates all map components, handles initialization order,
* manages component communication, and provides a unified API
*/
class MapIntegration {
constructor(options = {}) {
this.options = {
autoInit: true,
enableLogging: true,
enablePerformanceMonitoring: true,
initTimeout: 10000,
retryAttempts: 3,
components: {
maps: true,
filters: true,
roadtrip: true,
geolocation: true,
markers: true,
htmx: true,
mobileTouch: true,
darkMode: true
},
...options
};
this.components = {};
this.initOrder = [
'darkMode',
'mobileTouch',
'maps',
'markers',
'filters',
'geolocation',
'htmx',
'roadtrip'
];
this.initialized = false;
this.initStartTime = null;
this.errors = [];
if (this.options.autoInit) {
this.init();
}
}
/**
* Initialize all map components
*/
async init() {
this.initStartTime = performance.now();
this.log('Starting map integration initialization...');
try {
// Wait for DOM to be ready
await this.waitForDOM();
// Initialize components in order
await this.initializeComponents();
// Connect components
this.connectComponents();
// Setup global event handlers
this.setupGlobalHandlers();
// Verify integration
this.verifyIntegration();
this.initialized = true;
this.logPerformance();
this.log('Map integration initialized successfully');
// Emit ready event
this.emitEvent('mapIntegrationReady', {
components: Object.keys(this.components),
initTime: performance.now() - this.initStartTime
});
} catch (error) {
this.handleInitError(error);
}
}
/**
* Wait for DOM to be ready
*/
waitForDOM() {
return new Promise((resolve) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', resolve);
} else {
resolve();
}
});
}
/**
* Initialize components in the correct order
*/
async initializeComponents() {
for (const componentName of this.initOrder) {
if (!this.options.components[componentName]) {
this.log(`Skipping ${componentName} (disabled)`);
continue;
}
try {
await this.initializeComponent(componentName);
this.log(`${componentName} initialized`);
} catch (error) {
this.error(`✗ Failed to initialize ${componentName}:`, error);
this.errors.push({ component: componentName, error });
}
}
}
/**
* Initialize individual component
*/
async initializeComponent(componentName) {
switch (componentName) {
case 'darkMode':
if (window.DarkModeMaps) {
this.components.darkMode = window.darkModeMaps || new DarkModeMaps();
}
break;
case 'mobileTouch':
if (window.MobileTouchSupport) {
this.components.mobileTouch = window.mobileTouchSupport || new MobileTouchSupport();
}
break;
case 'maps':
// Look for existing map instances or create new ones
if (window.thrillwikiMap) {
this.components.maps = window.thrillwikiMap;
} else if (window.ThrillWikiMap) {
const mapContainer = document.getElementById('map-container');
if (mapContainer) {
this.components.maps = new ThrillWikiMap('map-container');
window.thrillwikiMap = this.components.maps;
}
}
break;
case 'markers':
if (window.MapMarkers && this.components.maps) {
this.components.markers = window.mapMarkers || new MapMarkers(this.components.maps);
}
break;
case 'filters':
if (window.MapFilters) {
const filterForm = document.getElementById('map-filters');
if (filterForm) {
this.components.filters = window.mapFilters || new MapFilters('map-filters');
}
}
break;
case 'geolocation':
if (window.UserLocation) {
this.components.geolocation = window.userLocation || new UserLocation();
}
break;
case 'htmx':
if (window.HTMXMapIntegration && typeof htmx !== 'undefined') {
this.components.htmx = window.htmxMapIntegration || new HTMXMapIntegration();
}
break;
case 'roadtrip':
if (window.RoadTripPlanner) {
const roadtripContainer = document.getElementById('roadtrip-planner');
if (roadtripContainer) {
this.components.roadtrip = window.roadTripPlanner || new RoadTripPlanner('roadtrip-planner');
}
}
break;
}
}
/**
* Connect components together
*/
connectComponents() {
this.log('Connecting components...');
// Connect maps to other components
if (this.components.maps) {
// Connect to dark mode
if (this.components.darkMode) {
this.components.darkMode.registerMapInstance(this.components.maps);
}
// Connect to mobile touch
if (this.components.mobileTouch) {
this.components.mobileTouch.registerMapInstance(this.components.maps);
}
// Connect to geolocation
if (this.components.geolocation) {
this.components.geolocation.connectToMap(this.components.maps);
}
// Connect to road trip planner
if (this.components.roadtrip) {
this.components.roadtrip.connectToMap(this.components.maps);
}
}
// Connect filters to other components
if (this.components.filters) {
// Connect to maps
if (this.components.maps) {
this.components.filters.connectToMap(this.components.maps);
}
// Connect to HTMX
if (this.components.htmx) {
this.components.htmx.connectToFilter(this.components.filters);
}
}
// Connect HTMX to maps
if (this.components.htmx && this.components.maps) {
this.components.htmx.connectToMap(this.components.maps);
}
}
/**
* Setup global event handlers
*/
setupGlobalHandlers() {
// Handle global map events
document.addEventListener('mapDataUpdate', (e) => {
this.handleMapDataUpdate(e.detail);
});
// Handle filter changes
document.addEventListener('filterChange', (e) => {
this.handleFilterChange(e.detail);
});
// Handle theme changes
document.addEventListener('themeChanged', (e) => {
this.handleThemeChange(e.detail);
});
// Handle orientation changes
document.addEventListener('orientationChanged', (e) => {
this.handleOrientationChange(e.detail);
});
// Handle visibility changes for performance
document.addEventListener('visibilitychange', () => {
this.handleVisibilityChange();
});
// Handle errors
window.addEventListener('error', (e) => {
if (this.isMapRelatedError(e)) {
this.handleGlobalError(e);
}
});
}
/**
* Handle map data updates
*/
handleMapDataUpdate(data) {
if (this.components.maps) {
this.components.maps.updateMarkers(data);
}
}
/**
* Handle filter changes
*/
handleFilterChange(filters) {
if (this.components.maps) {
this.components.maps.updateFilters(filters);
}
}
/**
* Handle theme changes
*/
handleThemeChange(themeData) {
// All components should already be listening for this
// Just log for monitoring
this.log(`Theme changed to ${themeData.newTheme}`);
}
/**
* Handle orientation changes
*/
handleOrientationChange(orientationData) {
// Invalidate map sizes after orientation change
if (this.components.maps) {
setTimeout(() => {
this.components.maps.invalidateSize();
}, 300);
}
}
/**
* Handle visibility changes
*/
handleVisibilityChange() {
const isHidden = document.hidden;
// Pause/resume location watching
if (this.components.geolocation) {
if (isHidden) {
this.components.geolocation.stopWatching();
} else if (this.components.geolocation.options.watchPosition) {
this.components.geolocation.startWatching();
}
}
}
/**
* Check if error is map-related
*/
isMapRelatedError(error) {
const mapKeywords = ['leaflet', 'map', 'marker', 'tile', 'geolocation', 'htmx'];
const errorMessage = error.message ? error.message.toLowerCase() : '';
const errorStack = error.error && error.error.stack ? error.error.stack.toLowerCase() : '';
return mapKeywords.some(keyword =>
errorMessage.includes(keyword) || errorStack.includes(keyword)
);
}
/**
* Handle global errors
*/
handleGlobalError(error) {
this.error('Global map error:', error);
this.errors.push({ type: 'global', error });
// Emit error event
this.emitEvent('mapError', { error, timestamp: Date.now() });
}
/**
* Verify integration is working
*/
verifyIntegration() {
const issues = [];
// Check required components
if (this.options.components.maps && !this.components.maps) {
issues.push('Maps component not initialized');
}
// Check component connections
if (this.components.maps && this.components.darkMode) {
if (!this.components.darkMode.mapInstances.has(this.components.maps)) {
issues.push('Maps not connected to dark mode');
}
}
// Check DOM elements
const mapContainer = document.getElementById('map-container');
if (this.components.maps && !mapContainer) {
issues.push('Map container not found in DOM');
}
if (issues.length > 0) {
this.warn('Integration issues found:', issues);
}
return issues.length === 0;
}
/**
* Handle initialization errors
*/
handleInitError(error) {
this.error('Map integration initialization failed:', error);
// Emit error event
this.emitEvent('mapIntegrationError', {
error,
errors: this.errors,
timestamp: Date.now()
});
// Try to initialize what we can
this.attemptPartialInit();
}
/**
* Attempt partial initialization
*/
attemptPartialInit() {
this.log('Attempting partial initialization...');
// Try to initialize at least the core map
if (!this.components.maps && window.ThrillWikiMap) {
try {
const mapContainer = document.getElementById('map-container');
if (mapContainer) {
this.components.maps = new ThrillWikiMap('map-container');
this.log('✓ Core map initialized in fallback mode');
}
} catch (error) {
this.error('✗ Fallback map initialization failed:', error);
}
}
}
/**
* Get component by name
*/
getComponent(name) {
return this.components[name] || null;
}
/**
* Get all components
*/
getAllComponents() {
return { ...this.components };
}
/**
* Check if integration is ready
*/
isReady() {
return this.initialized;
}
/**
* Get initialization status
*/
getStatus() {
return {
initialized: this.initialized,
components: Object.keys(this.components),
errors: this.errors,
initTime: this.initStartTime ? performance.now() - this.initStartTime : null
};
}
/**
* Emit custom event
*/
emitEvent(eventName, detail) {
const event = new CustomEvent(eventName, { detail });
document.dispatchEvent(event);
}
/**
* Log performance metrics
*/
logPerformance() {
if (!this.options.enablePerformanceMonitoring) return;
const initTime = performance.now() - this.initStartTime;
const componentCount = Object.keys(this.components).length;
this.log(`Performance: ${initTime.toFixed(2)}ms to initialize ${componentCount} components`);
// Send to analytics if available
if (typeof gtag !== 'undefined') {
gtag('event', 'map_integration_performance', {
event_category: 'performance',
value: Math.round(initTime),
custom_map: {
component_count: componentCount,
errors: this.errors.length
}
});
}
}
/**
* Logging methods
*/
log(message, ...args) {
if (this.options.enableLogging) {
console.log(`[MapIntegration] ${message}`, ...args);
}
}
warn(message, ...args) {
if (this.options.enableLogging) {
console.warn(`[MapIntegration] ${message}`, ...args);
}
}
error(message, ...args) {
if (this.options.enableLogging) {
console.error(`[MapIntegration] ${message}`, ...args);
}
}
/**
* Destroy integration
*/
destroy() {
// Destroy all components
Object.values(this.components).forEach(component => {
if (component && typeof component.destroy === 'function') {
try {
component.destroy();
} catch (error) {
this.error('Error destroying component:', error);
}
}
});
this.components = {};
this.initialized = false;
this.log('Map integration destroyed');
}
}
// Auto-initialize map integration
let mapIntegration;
document.addEventListener('DOMContentLoaded', function() {
// Only initialize if we have map-related elements
const hasMapElements = document.querySelector('#map-container, .map-container, [data-map], [data-roadtrip]');
if (hasMapElements) {
mapIntegration = new MapIntegration();
window.mapIntegration = mapIntegration;
}
});
// Global API for external access
window.ThrillWikiMaps = {
getIntegration: () => mapIntegration,
isReady: () => mapIntegration && mapIntegration.isReady(),
getComponent: (name) => mapIntegration ? mapIntegration.getComponent(name) : null,
getStatus: () => mapIntegration ? mapIntegration.getStatus() : { initialized: false }
};
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = MapIntegration;
} else {
window.MapIntegration = MapIntegration;
}

850
static/js/map-markers.js Normal file
View File

@@ -0,0 +1,850 @@
/**
* ThrillWiki Map Markers - Custom Marker Icons and Rich Popup System
*
* This module handles custom marker icons for different location types,
* rich popup content with location details, and performance optimization
*/
class MapMarkers {
constructor(mapInstance, options = {}) {
this.mapInstance = mapInstance;
this.options = {
enableClustering: true,
clusterDistance: 50,
enableCustomIcons: true,
enableRichPopups: true,
enableMarkerAnimation: true,
popupMaxWidth: 300,
iconTheme: 'modern', // 'modern', 'classic', 'emoji'
apiEndpoints: {
details: '/api/map/location-detail/',
media: '/api/media/'
},
...options
};
this.markerStyles = this.initializeMarkerStyles();
this.iconCache = new Map();
this.popupCache = new Map();
this.activePopup = null;
this.init();
}
/**
* Initialize the marker system
*/
init() {
this.setupMarkerStyles();
this.setupClusterStyles();
}
/**
* Initialize marker style definitions
*/
initializeMarkerStyles() {
return {
park: {
operating: {
color: '#10B981',
emoji: '🎢',
icon: 'fas fa-tree',
size: 'large'
},
closed_temp: {
color: '#F59E0B',
emoji: '🚧',
icon: 'fas fa-clock',
size: 'medium'
},
closed_perm: {
color: '#EF4444',
emoji: '❌',
icon: 'fas fa-times-circle',
size: 'medium'
},
under_construction: {
color: '#8B5CF6',
emoji: '🏗️',
icon: 'fas fa-hard-hat',
size: 'medium'
},
demolished: {
color: '#6B7280',
emoji: '🏚️',
icon: 'fas fa-ban',
size: 'small'
}
},
ride: {
operating: {
color: '#3B82F6',
emoji: '🎠',
icon: 'fas fa-rocket',
size: 'medium'
},
closed_temp: {
color: '#F59E0B',
emoji: '⏸️',
icon: 'fas fa-pause-circle',
size: 'small'
},
closed_perm: {
color: '#EF4444',
emoji: '❌',
icon: 'fas fa-times-circle',
size: 'small'
},
under_construction: {
color: '#8B5CF6',
emoji: '🔨',
icon: 'fas fa-tools',
size: 'small'
},
removed: {
color: '#6B7280',
emoji: '💔',
icon: 'fas fa-trash',
size: 'small'
}
},
company: {
manufacturer: {
color: '#8B5CF6',
emoji: '🏭',
icon: 'fas fa-industry',
size: 'medium'
},
operator: {
color: '#059669',
emoji: '🏢',
icon: 'fas fa-building',
size: 'medium'
},
designer: {
color: '#DC2626',
emoji: '🎨',
icon: 'fas fa-pencil-ruler',
size: 'medium'
}
},
user: {
current: {
color: '#3B82F6',
emoji: '📍',
icon: 'fas fa-crosshairs',
size: 'medium'
}
}
};
}
/**
* Setup marker styles in CSS
*/
setupMarkerStyles() {
if (document.getElementById('map-marker-styles')) return;
const styles = `
<style id="map-marker-styles">
.location-marker {
background: transparent;
border: none;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s ease;
}
.location-marker:hover {
transform: scale(1.1);
z-index: 1000;
}
.location-marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
color: white;
font-weight: bold;
font-size: 12px;
border: 3px solid white;
position: relative;
}
.location-marker-inner::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid inherit;
}
.location-marker.size-small .location-marker-inner {
width: 24px;
height: 24px;
font-size: 10px;
}
.location-marker.size-medium .location-marker-inner {
width: 32px;
height: 32px;
font-size: 12px;
}
.location-marker.size-large .location-marker-inner {
width: 40px;
height: 40px;
font-size: 14px;
}
.location-marker-emoji {
font-size: 1.2em;
line-height: 1;
}
.location-marker-icon {
font-size: 0.9em;
}
/* Cluster markers */
.cluster-marker {
background: transparent;
border: none;
}
.cluster-marker-inner {
background: #3B82F6;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s ease;
}
.cluster-marker:hover .cluster-marker-inner {
transform: scale(1.1);
}
.cluster-marker-small .cluster-marker-inner {
width: 32px;
height: 32px;
font-size: 12px;
}
.cluster-marker-medium .cluster-marker-inner {
width: 40px;
height: 40px;
font-size: 14px;
background: #059669;
}
.cluster-marker-large .cluster-marker-inner {
width: 48px;
height: 48px;
font-size: 16px;
background: #DC2626;
}
/* Popup styles */
.location-popup {
min-width: 200px;
max-width: 300px;
}
.popup-header {
margin-bottom: 10px;
}
.popup-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 5px 0;
color: #1F2937;
}
.popup-subtitle {
font-size: 12px;
color: #6B7280;
margin: 0;
}
.popup-content {
margin-bottom: 12px;
}
.popup-detail {
display: flex;
align-items: center;
margin: 4px 0;
font-size: 13px;
color: #374151;
}
.popup-detail i {
width: 16px;
margin-right: 6px;
color: #6B7280;
}
.popup-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.popup-btn {
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s;
}
.popup-btn-primary {
background: #3B82F6;
color: white;
}
.popup-btn-primary:hover {
background: #2563EB;
}
.popup-btn-secondary {
background: #F3F4F6;
color: #374151;
}
.popup-btn-secondary:hover {
background: #E5E7EB;
}
.popup-image {
width: 100%;
max-height: 120px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
.popup-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.popup-status.operating {
background: #D1FAE5;
color: #065F46;
}
.popup-status.closed {
background: #FEE2E2;
color: #991B1B;
}
.popup-status.construction {
background: #EDE9FE;
color: #5B21B6;
}
/* Dark mode styles */
.dark .popup-title {
color: #F9FAFB;
}
.dark .popup-detail {
color: #D1D5DB;
}
.dark .popup-btn-secondary {
background: #374151;
color: #D1D5DB;
}
.dark .popup-btn-secondary:hover {
background: #4B5563;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
/**
* Setup cluster marker styles
*/
setupClusterStyles() {
// Additional cluster-specific styles if needed
}
/**
* Create a location marker
*/
createLocationMarker(location) {
const iconData = this.getMarkerIconData(location);
const icon = this.createCustomIcon(iconData, location);
const marker = L.marker([location.latitude, location.longitude], {
icon: icon,
locationData: location,
riseOnHover: true
});
// Create popup
if (this.options.enableRichPopups) {
const popupContent = this.createPopupContent(location);
marker.bindPopup(popupContent, {
maxWidth: this.options.popupMaxWidth,
className: 'location-popup-container'
});
}
// Add click handler
marker.on('click', (e) => {
this.handleMarkerClick(marker, location);
});
// Add hover effects if animation is enabled
if (this.options.enableMarkerAnimation) {
marker.on('mouseover', () => {
const iconElement = marker.getElement();
if (iconElement) {
iconElement.style.transform = 'scale(1.1)';
iconElement.style.zIndex = '1000';
}
});
marker.on('mouseout', () => {
const iconElement = marker.getElement();
if (iconElement) {
iconElement.style.transform = 'scale(1)';
iconElement.style.zIndex = '';
}
});
}
return marker;
}
/**
* Get marker icon data based on location type and status
*/
getMarkerIconData(location) {
const type = location.type || 'generic';
const status = location.status || 'operating';
// Get style data
const typeStyles = this.markerStyles[type];
if (!typeStyles) {
return this.markerStyles.park.operating;
}
const statusStyle = typeStyles[status.toLowerCase()];
if (!statusStyle) {
// Fallback to first available status for this type
const firstStatus = Object.keys(typeStyles)[0];
return typeStyles[firstStatus];
}
return statusStyle;
}
/**
* Create custom icon
*/
createCustomIcon(iconData, location) {
const cacheKey = `${location.type}-${location.status}-${this.options.iconTheme}`;
if (this.iconCache.has(cacheKey)) {
return this.iconCache.get(cacheKey);
}
let iconHtml;
switch (this.options.iconTheme) {
case 'emoji':
iconHtml = `<span class="location-marker-emoji">${iconData.emoji}</span>`;
break;
case 'classic':
iconHtml = `<i class="location-marker-icon ${iconData.icon}"></i>`;
break;
case 'modern':
default:
iconHtml = location.featured_image ?
`<img src="${location.featured_image}" alt="${location.name}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` :
`<i class="location-marker-icon ${iconData.icon}"></i>`;
break;
}
const sizeClass = iconData.size || 'medium';
const size = sizeClass === 'small' ? 24 : sizeClass === 'large' ? 40 : 32;
const icon = L.divIcon({
className: `location-marker size-${sizeClass}`,
html: `<div class="location-marker-inner" style="background-color: ${iconData.color}">${iconHtml}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2) - 8]
});
this.iconCache.set(cacheKey, icon);
return icon;
}
/**
* Create rich popup content
*/
createPopupContent(location) {
const cacheKey = `popup-${location.type}-${location.id}`;
if (this.popupCache.has(cacheKey)) {
return this.popupCache.get(cacheKey);
}
const statusClass = this.getStatusClass(location.status);
const content = `
<div class="location-popup">
${location.featured_image ? `
<img src="${location.featured_image}" alt="${location.name}" class="popup-image">
` : ''}
<div class="popup-header">
<h3 class="popup-title">${this.escapeHtml(location.name)}</h3>
${location.type ? `<p class="popup-subtitle">${this.capitalizeFirst(location.type)}</p>` : ''}
${location.status ? `<span class="popup-status ${statusClass}">${this.formatStatus(location.status)}</span>` : ''}
</div>
<div class="popup-content">
${this.createPopupDetails(location)}
</div>
<div class="popup-actions">
${this.createPopupActions(location)}
</div>
</div>
`;
this.popupCache.set(cacheKey, content);
return content;
}
/**
* Create popup detail items
*/
createPopupDetails(location) {
const details = [];
if (location.formatted_location) {
details.push(`
<div class="popup-detail">
<i class="fas fa-map-marker-alt"></i>
<span>${this.escapeHtml(location.formatted_location)}</span>
</div>
`);
}
if (location.operator) {
details.push(`
<div class="popup-detail">
<i class="fas fa-building"></i>
<span>${this.escapeHtml(location.operator)}</span>
</div>
`);
}
if (location.ride_count && location.ride_count > 0) {
details.push(`
<div class="popup-detail">
<i class="fas fa-rocket"></i>
<span>${location.ride_count} ride${location.ride_count === 1 ? '' : 's'}</span>
</div>
`);
}
if (location.opened_date) {
details.push(`
<div class="popup-detail">
<i class="fas fa-calendar"></i>
<span>Opened ${this.formatDate(location.opened_date)}</span>
</div>
`);
}
if (location.manufacturer) {
details.push(`
<div class="popup-detail">
<i class="fas fa-industry"></i>
<span>${this.escapeHtml(location.manufacturer)}</span>
</div>
`);
}
if (location.designer) {
details.push(`
<div class="popup-detail">
<i class="fas fa-pencil-ruler"></i>
<span>${this.escapeHtml(location.designer)}</span>
</div>
`);
}
return details.join('');
}
/**
* Create popup action buttons
*/
createPopupActions(location) {
const actions = [];
// View details button
actions.push(`
<button onclick="mapMarkers.showLocationDetails('${location.type}', ${location.id})"
class="popup-btn popup-btn-primary">
<i class="fas fa-eye"></i>
View Details
</button>
`);
// Add to road trip (for parks)
if (location.type === 'park' && window.roadTripPlanner) {
actions.push(`
<button onclick="roadTripPlanner.addPark(${location.id})"
class="popup-btn popup-btn-secondary">
<i class="fas fa-route"></i>
Add to Trip
</button>
`);
}
// Get directions
if (location.latitude && location.longitude) {
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${location.latitude},${location.longitude}`;
actions.push(`
<a href="${mapsUrl}" target="_blank"
class="popup-btn popup-btn-secondary">
<i class="fas fa-directions"></i>
Directions
</a>
`);
}
return actions.join('');
}
/**
* Handle marker click events
*/
handleMarkerClick(marker, location) {
this.activePopup = marker.getPopup();
// Load additional data if needed
this.loadLocationDetails(location);
// Track click event
if (typeof gtag !== 'undefined') {
gtag('event', 'marker_click', {
event_category: 'map',
event_label: `${location.type}:${location.id}`,
custom_map: {
location_type: location.type,
location_name: location.name
}
});
}
}
/**
* Load additional location details
*/
async loadLocationDetails(location) {
try {
const response = await fetch(`${this.options.apiEndpoints.details}${location.type}/${location.id}/`);
const data = await response.json();
if (data.status === 'success') {
// Update popup with additional details if popup is still open
if (this.activePopup && this.activePopup.isOpen()) {
const updatedContent = this.createPopupContent(data.data);
this.activePopup.setContent(updatedContent);
}
}
} catch (error) {
console.error('Failed to load location details:', error);
}
}
/**
* Show location details modal/page
*/
showLocationDetails(type, id) {
const url = `/${type}/${id}/`;
if (typeof htmx !== 'undefined') {
htmx.ajax('GET', url, {
target: '#location-modal',
swap: 'innerHTML'
}).then(() => {
const modal = document.getElementById('location-modal');
if (modal) {
modal.classList.remove('hidden');
}
});
} else {
window.location.href = url;
}
}
/**
* Get CSS class for status
*/
getStatusClass(status) {
if (!status) return '';
const statusLower = status.toLowerCase();
if (statusLower.includes('operating') || statusLower.includes('open')) {
return 'operating';
} else if (statusLower.includes('closed') || statusLower.includes('temp')) {
return 'closed';
} else if (statusLower.includes('construction') || statusLower.includes('building')) {
return 'construction';
}
return '';
}
/**
* Format status for display
*/
formatStatus(status) {
return status.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
/**
* Format date for display
*/
formatDate(dateString) {
try {
const date = new Date(dateString);
return date.getFullYear();
} catch (error) {
return dateString;
}
}
/**
* Capitalize first letter
*/
capitalizeFirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Escape HTML
*/
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Create cluster marker
*/
createClusterMarker(cluster) {
const count = cluster.getChildCount();
let sizeClass = 'small';
if (count > 100) sizeClass = 'large';
else if (count > 10) sizeClass = 'medium';
return L.divIcon({
html: `<div class="cluster-marker-inner">${count}</div>`,
className: `cluster-marker cluster-marker-${sizeClass}`,
iconSize: L.point(sizeClass === 'small' ? 32 : sizeClass === 'medium' ? 40 : 48,
sizeClass === 'small' ? 32 : sizeClass === 'medium' ? 40 : 48)
});
}
/**
* Update marker theme
*/
setIconTheme(theme) {
this.options.iconTheme = theme;
this.iconCache.clear();
// Re-render all markers if map instance is available
if (this.mapInstance && this.mapInstance.markers) {
// This would need to be implemented in the main map class
console.log(`Icon theme changed to: ${theme}`);
}
}
/**
* Clear popup cache
*/
clearPopupCache() {
this.popupCache.clear();
}
/**
* Clear icon cache
*/
clearIconCache() {
this.iconCache.clear();
}
/**
* Get marker statistics
*/
getMarkerStats() {
return {
iconCacheSize: this.iconCache.size,
popupCacheSize: this.popupCache.size,
iconTheme: this.options.iconTheme
};
}
}
// Auto-initialize with map instance if available
document.addEventListener('DOMContentLoaded', function() {
if (window.thrillwikiMap) {
window.mapMarkers = new MapMarkers(window.thrillwikiMap);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MapMarkers;
} else {
window.MapMarkers = MapMarkers;
}

656
static/js/maps.js Normal file
View File

@@ -0,0 +1,656 @@
/**
* ThrillWiki Maps - Core Map Functionality
*
* This module provides the main map functionality for ThrillWiki using Leaflet.js
* Includes clustering, filtering, dark mode support, and HTMX integration
*/
class ThrillWikiMap {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
center: [39.8283, -98.5795], // Center of USA
zoom: 4,
minZoom: 2,
maxZoom: 18,
enableClustering: true,
enableDarkMode: true,
enableGeolocation: false,
apiEndpoints: {
locations: '/api/map/locations/',
details: '/api/map/location-detail/'
},
...options
};
this.map = null;
this.markers = null;
this.currentData = [];
this.userLocation = null;
this.currentTileLayer = null;
this.boundsUpdateTimeout = null;
// Event handlers
this.eventHandlers = {
locationClick: [],
boundsChange: [],
dataLoad: []
};
this.init();
}
/**
* Initialize the map
*/
init() {
const container = document.getElementById(this.containerId);
if (!container) {
console.error(`Map container with ID '${this.containerId}' not found`);
return;
}
try {
this.initializeMap();
this.setupTileLayers();
this.setupClustering();
this.bindEvents();
this.loadInitialData();
} catch (error) {
console.error('Failed to initialize map:', error);
}
}
/**
* Initialize the Leaflet map instance
*/
initializeMap() {
this.map = L.map(this.containerId, {
center: this.options.center,
zoom: this.options.zoom,
minZoom: this.options.minZoom,
maxZoom: this.options.maxZoom,
zoomControl: false,
attributionControl: false
});
// Add custom zoom control
L.control.zoom({
position: 'bottomright'
}).addTo(this.map);
// Add attribution control
L.control.attribution({
position: 'bottomleft',
prefix: false
}).addTo(this.map);
}
/**
* Setup tile layers with dark mode support
*/
setupTileLayers() {
this.tileLayers = {
light: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
className: 'map-tiles-light'
}),
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
className: 'map-tiles-dark'
}),
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '© Esri, DigitalGlobe, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community',
className: 'map-tiles-satellite'
})
};
// Set initial tile layer based on theme
this.updateTileLayer();
// Listen for theme changes if dark mode is enabled
if (this.options.enableDarkMode) {
this.observeThemeChanges();
}
}
/**
* Setup marker clustering
*/
setupClustering() {
if (this.options.enableClustering) {
this.markers = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
let className = 'cluster-marker-small';
if (count > 100) className = 'cluster-marker-large';
else if (count > 10) className = 'cluster-marker-medium';
return L.divIcon({
html: `<div class="cluster-marker-inner">${count}</div>`,
className: `cluster-marker ${className}`,
iconSize: L.point(40, 40)
});
}
});
} else {
this.markers = L.layerGroup();
}
this.map.addLayer(this.markers);
}
/**
* Bind map events
*/
bindEvents() {
// Map movement events
this.map.on('moveend zoomend', () => {
this.handleBoundsChange();
});
// Marker click events
this.markers.on('click', (e) => {
if (e.layer.options && e.layer.options.locationData) {
this.handleLocationClick(e.layer.options.locationData);
}
});
// Custom event handlers
this.map.on('locationfound', (e) => {
this.handleLocationFound(e);
});
this.map.on('locationerror', (e) => {
this.handleLocationError(e);
});
}
/**
* Observe theme changes for automatic tile layer switching
*/
observeThemeChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
this.updateTileLayer();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
/**
* Update tile layer based on current theme and settings
*/
updateTileLayer() {
// Remove current tile layer
if (this.currentTileLayer) {
this.map.removeLayer(this.currentTileLayer);
}
// Determine which layer to use
let layerType = 'light';
if (document.documentElement.classList.contains('dark')) {
layerType = 'dark';
}
// Check for satellite mode toggle
const satelliteToggle = document.querySelector('input[name="satellite"]');
if (satelliteToggle && satelliteToggle.checked) {
layerType = 'satellite';
}
// Add the appropriate tile layer
this.currentTileLayer = this.tileLayers[layerType];
this.map.addLayer(this.currentTileLayer);
}
/**
* Load initial map data
*/
async loadInitialData() {
const bounds = this.map.getBounds();
await this.loadLocations(bounds, {});
}
/**
* Load locations with optional bounds and filters
*/
async loadLocations(bounds = null, filters = {}) {
try {
this.showLoading(true);
const params = new URLSearchParams();
// Add bounds if provided
if (bounds) {
params.append('north', bounds.getNorth());
params.append('south', bounds.getSouth());
params.append('east', bounds.getEast());
params.append('west', bounds.getWest());
}
// Add zoom level
params.append('zoom', this.map.getZoom());
// Add filters
Object.entries(filters).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => params.append(key, v));
} else if (value !== null && value !== undefined && value !== '') {
params.append(key, value);
}
});
const response = await fetch(`${this.options.apiEndpoints.locations}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.updateMarkers(data.data);
this.triggerEvent('dataLoad', data.data);
} else {
console.error('Map data error:', data.message);
}
} catch (error) {
console.error('Failed to load map data:', error);
} finally {
this.showLoading(false);
}
}
/**
* Update map markers with new data
*/
updateMarkers(data) {
// Clear existing markers
this.markers.clearLayers();
this.currentData = data;
// Add location markers
if (data.locations) {
data.locations.forEach(location => {
this.addLocationMarker(location);
});
}
// Add cluster markers (if not using Leaflet clustering)
if (data.clusters && !this.options.enableClustering) {
data.clusters.forEach(cluster => {
this.addClusterMarker(cluster);
});
}
}
/**
* Add a location marker to the map
*/
addLocationMarker(location) {
const icon = this.createLocationIcon(location);
const marker = L.marker([location.latitude, location.longitude], {
icon: icon,
locationData: location
});
// Create popup content
const popupContent = this.createPopupContent(location);
marker.bindPopup(popupContent, {
maxWidth: 300,
className: 'location-popup'
});
this.markers.addLayer(marker);
}
/**
* Add a cluster marker (for server-side clustering)
*/
addClusterMarker(cluster) {
const marker = L.marker([cluster.latitude, cluster.longitude], {
icon: L.divIcon({
className: 'cluster-marker server-cluster',
html: `<div class="cluster-marker-inner">${cluster.count}</div>`,
iconSize: [40, 40]
})
});
marker.bindPopup(`${cluster.count} locations in this area`);
this.markers.addLayer(marker);
}
/**
* Create location icon based on type
*/
createLocationIcon(location) {
const iconMap = {
'park': { emoji: '🎢', color: '#10B981' },
'ride': { emoji: '🎠', color: '#3B82F6' },
'company': { emoji: '🏢', color: '#8B5CF6' },
'generic': { emoji: '📍', color: '#6B7280' }
};
const iconData = iconMap[location.type] || iconMap.generic;
return L.divIcon({
className: 'location-marker',
html: `
<div class="location-marker-inner" style="background-color: ${iconData.color}">
<span class="location-marker-emoji">${iconData.emoji}</span>
</div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
});
}
/**
* Create popup content for a location
*/
createPopupContent(location) {
return `
<div class="location-info-popup">
<h3 class="popup-title">${location.name}</h3>
${location.formatted_location ? `<p class="popup-location"><i class="fas fa-map-marker-alt"></i>${location.formatted_location}</p>` : ''}
${location.operator ? `<p class="popup-operator"><i class="fas fa-building"></i>${location.operator}</p>` : ''}
${location.ride_count ? `<p class="popup-rides"><i class="fas fa-rocket"></i>${location.ride_count} rides</p>` : ''}
${location.status ? `<p class="popup-status"><i class="fas fa-info-circle"></i>${location.status}</p>` : ''}
<div class="popup-actions">
<button onclick="window.thrillwikiMap.showLocationDetails('${location.type}', ${location.id})"
class="btn btn-primary btn-sm">
<i class="fas fa-eye"></i> View Details
</button>
</div>
</div>
`;
}
/**
* Show/hide loading indicator
*/
showLoading(show) {
const loadingElement = document.getElementById(`${this.containerId}-loading`) ||
document.getElementById('map-loading');
if (loadingElement) {
loadingElement.style.display = show ? 'flex' : 'none';
}
}
/**
* Handle map bounds change
*/
handleBoundsChange() {
clearTimeout(this.boundsUpdateTimeout);
this.boundsUpdateTimeout = setTimeout(() => {
const bounds = this.map.getBounds();
this.triggerEvent('boundsChange', bounds);
// Auto-reload data on significant bounds change
if (this.shouldReloadData()) {
this.loadLocations(bounds, this.getCurrentFilters());
}
}, 1000);
}
/**
* Handle location click
*/
handleLocationClick(location) {
this.triggerEvent('locationClick', location);
}
/**
* Show location details (integrate with HTMX)
*/
showLocationDetails(type, id) {
const url = `${this.options.apiEndpoints.details}${type}/${id}/`;
if (typeof htmx !== 'undefined') {
htmx.ajax('GET', url, {
target: '#location-modal',
swap: 'innerHTML'
}).then(() => {
const modal = document.getElementById('location-modal');
if (modal) {
modal.classList.remove('hidden');
}
});
} else {
// Fallback to regular navigation
window.location.href = url;
}
}
/**
* Get current filters from form
*/
getCurrentFilters() {
const form = document.getElementById('map-filters');
if (!form) return {};
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;
}
}
return filters;
}
/**
* Update filters and reload data
*/
updateFilters(filters) {
const bounds = this.map.getBounds();
this.loadLocations(bounds, filters);
}
/**
* Enable user location features
*/
enableGeolocation() {
this.options.enableGeolocation = true;
this.map.locate({ setView: false, maxZoom: 16 });
}
/**
* Handle location found
*/
handleLocationFound(e) {
if (this.userLocation) {
this.map.removeLayer(this.userLocation);
}
this.userLocation = L.marker(e.latlng, {
icon: L.divIcon({
className: 'user-location-marker',
html: '<div class="user-location-inner"><i class="fas fa-crosshairs"></i></div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
})
}).addTo(this.map);
this.userLocation.bindPopup('Your Location');
}
/**
* Handle location error
*/
handleLocationError(e) {
console.warn('Location access denied or unavailable:', e.message);
}
/**
* Determine if data should be reloaded based on map movement
*/
shouldReloadData() {
// Simple heuristic: reload if zoom changed or moved significantly
return true; // For now, always reload
}
/**
* Add event listener
*/
on(event, handler) {
if (!this.eventHandlers[event]) {
this.eventHandlers[event] = [];
}
this.eventHandlers[event].push(handler);
}
/**
* Remove event listener
*/
off(event, handler) {
if (this.eventHandlers[event]) {
const index = this.eventHandlers[event].indexOf(handler);
if (index > -1) {
this.eventHandlers[event].splice(index, 1);
}
}
}
/**
* Trigger event
*/
triggerEvent(event, data) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(`Error in ${event} handler:`, error);
}
});
}
}
/**
* Export map view as image (requires html2canvas)
*/
async exportMap() {
if (typeof html2canvas === 'undefined') {
console.warn('html2canvas library not loaded, cannot export map');
return null;
}
try {
const canvas = await html2canvas(document.getElementById(this.containerId));
return canvas.toDataURL('image/png');
} catch (error) {
console.error('Failed to export map:', error);
return null;
}
}
/**
* Resize map (call when container size changes)
*/
invalidateSize() {
if (this.map) {
this.map.invalidateSize();
}
}
/**
* Get map bounds
*/
getBounds() {
return this.map ? this.map.getBounds() : null;
}
/**
* Set map view
*/
setView(latlng, zoom) {
if (this.map) {
this.map.setView(latlng, zoom);
}
}
/**
* Fit map to bounds
*/
fitBounds(bounds, options = {}) {
if (this.map) {
this.map.fitBounds(bounds, options);
}
}
/**
* Destroy map instance
*/
destroy() {
if (this.map) {
this.map.remove();
this.map = null;
}
// Clear timeouts
if (this.boundsUpdateTimeout) {
clearTimeout(this.boundsUpdateTimeout);
}
// Clear event handlers
this.eventHandlers = {};
}
}
// Auto-initialize maps with data attributes
document.addEventListener('DOMContentLoaded', function() {
// Find all elements with map-container class
const mapContainers = document.querySelectorAll('[data-map="auto"]');
mapContainers.forEach(container => {
const mapId = container.id;
const options = {};
// Parse data attributes for configuration
Object.keys(container.dataset).forEach(key => {
if (key.startsWith('map')) {
const optionKey = key.replace('map', '').toLowerCase();
let value = container.dataset[key];
// Try to parse as JSON for complex values
try {
value = JSON.parse(value);
} catch (e) {
// Keep as string if not valid JSON
}
options[optionKey] = value;
}
});
// Create map instance
window[`${mapId}Instance`] = new ThrillWikiMap(mapId, options);
});
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = ThrillWikiMap;
} else {
window.ThrillWikiMap = ThrillWikiMap;
}

881
static/js/mobile-touch.js Normal file
View File

@@ -0,0 +1,881 @@
/**
* ThrillWiki Mobile Touch Support - Enhanced Mobile and Touch Experience
*
* This module provides mobile-optimized interactions, touch-friendly controls,
* responsive map sizing, and battery-conscious features for mobile devices
*/
class MobileTouchSupport {
constructor(options = {}) {
this.options = {
enableTouchOptimizations: true,
enableSwipeGestures: true,
enablePinchZoom: true,
enableResponsiveResize: true,
enableBatteryOptimization: true,
touchDebounceDelay: 150,
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
maxTouchPoints: 2,
orientationChangeDelay: 300,
...options
};
this.isMobile = this.detectMobileDevice();
this.isTouch = this.detectTouchSupport();
this.orientation = this.getOrientation();
this.mapInstances = new Set();
this.touchHandlers = new Map();
this.gestureState = {
isActive: false,
startDistance: 0,
startCenter: null,
lastTouchTime: 0
};
this.init();
}
/**
* Initialize mobile touch support
*/
init() {
if (!this.isTouch && !this.isMobile) {
console.log('Mobile touch support not needed for this device');
return;
}
this.setupTouchOptimizations();
this.setupSwipeGestures();
this.setupResponsiveHandling();
this.setupBatteryOptimization();
this.setupAccessibilityEnhancements();
this.bindEventHandlers();
console.log('Mobile touch support initialized');
}
/**
* Detect if device is mobile
*/
detectMobileDevice() {
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = ['android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone'];
return mobileKeywords.some(keyword => userAgent.includes(keyword)) ||
window.innerWidth <= 768 ||
(typeof window.orientation !== 'undefined');
}
/**
* Detect touch support
*/
detectTouchSupport() {
return 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0;
}
/**
* Get current orientation
*/
getOrientation() {
if (screen.orientation) {
return screen.orientation.angle;
} else if (window.orientation !== undefined) {
return window.orientation;
}
return window.innerWidth > window.innerHeight ? 90 : 0;
}
/**
* Setup touch optimizations
*/
setupTouchOptimizations() {
if (!this.options.enableTouchOptimizations) return;
// Add touch-optimized styles
this.addTouchStyles();
// Enhance touch targets
this.enhanceTouchTargets();
// Optimize scroll behavior
this.optimizeScrollBehavior();
// Setup touch feedback
this.setupTouchFeedback();
}
/**
* Add touch-optimized CSS styles
*/
addTouchStyles() {
if (document.getElementById('mobile-touch-styles')) return;
const styles = `
<style id="mobile-touch-styles">
@media (max-width: 768px) {
/* Touch-friendly button sizes */
.btn, button, .filter-chip, .filter-pill {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
font-size: 16px;
}
/* Larger touch targets for map controls */
.leaflet-control-zoom a {
width: 44px !important;
height: 44px !important;
line-height: 44px !important;
font-size: 18px !important;
}
/* Mobile-optimized map containers */
.map-container {
height: 60vh !important;
min-height: 300px !important;
}
/* Touch-friendly popup styling */
.leaflet-popup-content-wrapper {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.leaflet-popup-content {
margin: 16px 20px;
font-size: 16px;
line-height: 1.5;
}
/* Improved form controls */
input, select, textarea {
font-size: 16px !important; /* Prevents zoom on iOS */
padding: 12px;
border-radius: 8px;
}
/* Touch-friendly filter panels */
.filter-panel {
padding: 16px;
border-radius: 12px;
}
/* Mobile navigation improvements */
.roadtrip-planner {
padding: 16px;
}
.parks-list .park-item {
padding: 16px;
margin-bottom: 12px;
border-radius: 12px;
touch-action: manipulation;
}
/* Swipe indicators */
.swipe-indicator {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 40px;
background: rgba(59, 130, 246, 0.5);
border-radius: 2px;
transition: opacity 0.3s ease;
}
.swipe-indicator.left {
left: 8px;
}
.swipe-indicator.right {
right: 8px;
}
}
@media (max-width: 480px) {
/* Extra small screens */
.map-container {
height: 50vh !important;
}
.filter-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
max-height: 50vh;
overflow-y: auto;
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
}
/* Touch feedback */
.touch-feedback {
position: relative;
overflow: hidden;
}
.touch-feedback::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
pointer-events: none;
}
.touch-feedback.active::after {
width: 100px;
height: 100px;
}
/* Prevent text selection on mobile */
.no-select {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Optimize touch scrolling */
.touch-scroll {
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
/**
* Enhance touch targets for better accessibility
*/
enhanceTouchTargets() {
const smallTargets = document.querySelectorAll('button, .btn, a, input[type="checkbox"], input[type="radio"]');
smallTargets.forEach(target => {
const rect = target.getBoundingClientRect();
// If target is smaller than 44px (Apple's recommended minimum), enhance it
if (rect.width < 44 || rect.height < 44) {
target.classList.add('touch-enhanced');
target.style.minWidth = '44px';
target.style.minHeight = '44px';
target.style.display = 'inline-flex';
target.style.alignItems = 'center';
target.style.justifyContent = 'center';
}
});
}
/**
* Optimize scroll behavior for mobile
*/
optimizeScrollBehavior() {
// Add momentum scrolling to scrollable elements
const scrollableElements = document.querySelectorAll('.scrollable, .overflow-auto, .overflow-y-auto');
scrollableElements.forEach(element => {
element.classList.add('touch-scroll');
element.style.webkitOverflowScrolling = 'touch';
});
// Prevent body scroll when interacting with maps
document.addEventListener('touchstart', (e) => {
if (e.target.closest('.leaflet-container')) {
e.preventDefault();
}
}, { passive: false });
}
/**
* Setup touch feedback for interactive elements
*/
setupTouchFeedback() {
const interactiveElements = document.querySelectorAll('button, .btn, .filter-chip, .filter-pill, .park-item');
interactiveElements.forEach(element => {
element.classList.add('touch-feedback');
element.addEventListener('touchstart', (e) => {
element.classList.add('active');
setTimeout(() => {
element.classList.remove('active');
}, 300);
}, { passive: true });
});
}
/**
* Setup swipe gesture support
*/
setupSwipeGestures() {
if (!this.options.enableSwipeGestures) return;
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}
}, { passive: true });
document.addEventListener('touchend', (e) => {
if (e.changedTouches.length === 1) {
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const touchEndTime = Date.now();
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const deltaTime = touchEndTime - touchStartTime;
const velocity = Math.abs(deltaX) / deltaTime;
// Check if this is a swipe gesture
if (Math.abs(deltaX) > this.options.swipeThreshold &&
Math.abs(deltaY) < Math.abs(deltaX) &&
velocity > this.options.swipeVelocityThreshold) {
const direction = deltaX > 0 ? 'right' : 'left';
this.handleSwipeGesture(direction, e.target);
}
}
}, { passive: true });
}
/**
* Handle swipe gestures
*/
handleSwipeGesture(direction, target) {
// Handle swipe on filter panels
if (target.closest('.filter-panel')) {
if (direction === 'down' || direction === 'up') {
this.toggleFilterPanel();
}
}
// Handle swipe on road trip list
if (target.closest('.parks-list')) {
if (direction === 'left') {
this.showParkActions(target);
} else if (direction === 'right') {
this.hideParkActions(target);
}
}
// Emit custom swipe event
const swipeEvent = new CustomEvent('swipe', {
detail: { direction, target }
});
document.dispatchEvent(swipeEvent);
}
/**
* Setup responsive handling for orientation changes
*/
setupResponsiveHandling() {
if (!this.options.enableResponsiveResize) return;
// Handle orientation changes
window.addEventListener('orientationchange', () => {
setTimeout(() => {
this.handleOrientationChange();
}, this.options.orientationChangeDelay);
});
// Handle window resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
this.handleWindowResize();
}, 250);
});
// Handle viewport changes (for mobile browsers with dynamic toolbars)
this.setupViewportHandler();
}
/**
* Handle orientation change
*/
handleOrientationChange() {
const newOrientation = this.getOrientation();
if (newOrientation !== this.orientation) {
this.orientation = newOrientation;
// Resize all map instances
this.mapInstances.forEach(mapInstance => {
if (mapInstance.invalidateSize) {
mapInstance.invalidateSize();
}
});
// Emit orientation change event
const orientationEvent = new CustomEvent('orientationChanged', {
detail: { orientation: this.orientation }
});
document.dispatchEvent(orientationEvent);
}
}
/**
* Handle window resize
*/
handleWindowResize() {
// Update mobile detection
this.isMobile = this.detectMobileDevice();
// Resize map instances
this.mapInstances.forEach(mapInstance => {
if (mapInstance.invalidateSize) {
mapInstance.invalidateSize();
}
});
// Update touch targets
this.enhanceTouchTargets();
}
/**
* Setup viewport handler for dynamic mobile toolbars
*/
setupViewportHandler() {
// Use visual viewport API if available
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
this.handleViewportChange();
});
}
// Fallback for older browsers
let lastHeight = window.innerHeight;
const checkViewportChange = () => {
if (Math.abs(window.innerHeight - lastHeight) > 100) {
lastHeight = window.innerHeight;
this.handleViewportChange();
}
};
window.addEventListener('resize', checkViewportChange);
document.addEventListener('focusin', checkViewportChange);
document.addEventListener('focusout', checkViewportChange);
}
/**
* Handle viewport changes
*/
handleViewportChange() {
// Adjust map container heights
const mapContainers = document.querySelectorAll('.map-container');
mapContainers.forEach(container => {
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
if (viewportHeight < 500) {
container.style.height = '40vh';
} else {
container.style.height = ''; // Reset to CSS default
}
});
}
/**
* Setup battery optimization
*/
setupBatteryOptimization() {
if (!this.options.enableBatteryOptimization) return;
// Reduce update frequency when battery is low
if ('getBattery' in navigator) {
navigator.getBattery().then(battery => {
const optimizeBattery = () => {
if (battery.level < 0.2) { // Battery below 20%
this.enableBatterySaveMode();
} else {
this.disableBatterySaveMode();
}
};
battery.addEventListener('levelchange', optimizeBattery);
optimizeBattery();
});
}
// Reduce activity when page is not visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.pauseNonEssentialFeatures();
} else {
this.resumeNonEssentialFeatures();
}
});
}
/**
* Enable battery save mode
*/
enableBatterySaveMode() {
console.log('Enabling battery save mode');
// Reduce map update frequency
this.mapInstances.forEach(mapInstance => {
if (mapInstance.options) {
mapInstance.options.updateInterval = 5000; // Increase to 5 seconds
}
});
// Disable animations
document.body.classList.add('battery-save-mode');
}
/**
* Disable battery save mode
*/
disableBatterySaveMode() {
console.log('Disabling battery save mode');
// Restore normal update frequency
this.mapInstances.forEach(mapInstance => {
if (mapInstance.options) {
mapInstance.options.updateInterval = 1000; // Restore to 1 second
}
});
// Re-enable animations
document.body.classList.remove('battery-save-mode');
}
/**
* Pause non-essential features
*/
pauseNonEssentialFeatures() {
// Pause location watching
if (window.userLocation && window.userLocation.stopWatching) {
window.userLocation.stopWatching();
}
// Reduce map updates
this.mapInstances.forEach(mapInstance => {
if (mapInstance.pauseUpdates) {
mapInstance.pauseUpdates();
}
});
}
/**
* Resume non-essential features
*/
resumeNonEssentialFeatures() {
// Resume location watching if it was active
if (window.userLocation && window.userLocation.options.watchPosition) {
window.userLocation.startWatching();
}
// Resume map updates
this.mapInstances.forEach(mapInstance => {
if (mapInstance.resumeUpdates) {
mapInstance.resumeUpdates();
}
});
}
/**
* Setup accessibility enhancements for mobile
*/
setupAccessibilityEnhancements() {
// Add focus indicators for touch navigation
const focusableElements = document.querySelectorAll('button, a, input, select, textarea, [tabindex]');
focusableElements.forEach(element => {
element.addEventListener('focus', () => {
element.classList.add('touch-focused');
});
element.addEventListener('blur', () => {
element.classList.remove('touch-focused');
});
});
// Enhance keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
document.body.classList.add('keyboard-navigation');
}
});
document.addEventListener('mousedown', () => {
document.body.classList.remove('keyboard-navigation');
});
}
/**
* Bind event handlers
*/
bindEventHandlers() {
// Handle double-tap to zoom
this.setupDoubleTapZoom();
// Handle long press
this.setupLongPress();
// Handle pinch gestures
if (this.options.enablePinchZoom) {
this.setupPinchZoom();
}
}
/**
* Setup double-tap to zoom
*/
setupDoubleTapZoom() {
let lastTapTime = 0;
document.addEventListener('touchend', (e) => {
const currentTime = Date.now();
if (currentTime - lastTapTime < 300) {
// Double tap detected
const target = e.target;
if (target.closest('.leaflet-container')) {
this.handleDoubleTapZoom(e);
}
}
lastTapTime = currentTime;
}, { passive: true });
}
/**
* Handle double-tap zoom
*/
handleDoubleTapZoom(e) {
const mapContainer = e.target.closest('.leaflet-container');
if (!mapContainer) return;
// Find associated map instance
this.mapInstances.forEach(mapInstance => {
if (mapInstance.getContainer() === mapContainer) {
const currentZoom = mapInstance.getZoom();
const newZoom = currentZoom < mapInstance.getMaxZoom() ? currentZoom + 2 : mapInstance.getMinZoom();
mapInstance.setZoom(newZoom, {
animate: true,
duration: 0.3
});
}
});
}
/**
* Setup long press detection
*/
setupLongPress() {
let pressTimer;
document.addEventListener('touchstart', (e) => {
pressTimer = setTimeout(() => {
this.handleLongPress(e);
}, 750); // 750ms for long press
}, { passive: true });
document.addEventListener('touchend', () => {
clearTimeout(pressTimer);
}, { passive: true });
document.addEventListener('touchmove', () => {
clearTimeout(pressTimer);
}, { passive: true });
}
/**
* Handle long press
*/
handleLongPress(e) {
const target = e.target;
// Emit long press event
const longPressEvent = new CustomEvent('longPress', {
detail: { target, touches: e.touches }
});
target.dispatchEvent(longPressEvent);
// Provide haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
/**
* Setup pinch zoom for maps
*/
setupPinchZoom() {
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
this.gestureState.isActive = true;
this.gestureState.startDistance = this.getDistance(e.touches[0], e.touches[1]);
this.gestureState.startCenter = this.getCenter(e.touches[0], e.touches[1]);
}
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (this.gestureState.isActive && e.touches.length === 2) {
this.handlePinchZoom(e);
}
}, { passive: false });
document.addEventListener('touchend', () => {
this.gestureState.isActive = false;
}, { passive: true });
}
/**
* Handle pinch zoom gesture
*/
handlePinchZoom(e) {
if (!e.target.closest('.leaflet-container')) return;
const currentDistance = this.getDistance(e.touches[0], e.touches[1]);
const scale = currentDistance / this.gestureState.startDistance;
// Emit pinch event
const pinchEvent = new CustomEvent('pinch', {
detail: { scale, center: this.gestureState.startCenter }
});
e.target.dispatchEvent(pinchEvent);
}
/**
* Get distance between two touch points
*/
getDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Get center point between two touches
*/
getCenter(touch1, touch2) {
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
};
}
/**
* Register map instance for mobile optimizations
*/
registerMapInstance(mapInstance) {
this.mapInstances.add(mapInstance);
// Apply mobile-specific map options
if (this.isMobile && mapInstance.options) {
mapInstance.options.zoomControl = false; // Use custom larger controls
mapInstance.options.attributionControl = false; // Save space
}
}
/**
* Unregister map instance
*/
unregisterMapInstance(mapInstance) {
this.mapInstances.delete(mapInstance);
}
/**
* Toggle filter panel for mobile
*/
toggleFilterPanel() {
const filterPanel = document.querySelector('.filter-panel');
if (filterPanel) {
filterPanel.classList.toggle('mobile-expanded');
}
}
/**
* Show park actions on swipe
*/
showParkActions(target) {
const parkItem = target.closest('.park-item');
if (parkItem) {
parkItem.classList.add('actions-visible');
}
}
/**
* Hide park actions
*/
hideParkActions(target) {
const parkItem = target.closest('.park-item');
if (parkItem) {
parkItem.classList.remove('actions-visible');
}
}
/**
* Check if device is mobile
*/
isMobileDevice() {
return this.isMobile;
}
/**
* Check if device supports touch
*/
isTouchDevice() {
return this.isTouch;
}
/**
* Get device info
*/
getDeviceInfo() {
return {
isMobile: this.isMobile,
isTouch: this.isTouch,
orientation: this.orientation,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1
};
}
}
// Auto-initialize mobile touch support
document.addEventListener('DOMContentLoaded', function() {
window.mobileTouchSupport = new MobileTouchSupport();
// Register existing map instances
if (window.thrillwikiMap) {
window.mobileTouchSupport.registerMapInstance(window.thrillwikiMap);
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MobileTouchSupport;
} else {
window.MobileTouchSupport = MobileTouchSupport;
}

29
static/js/park-map.js Normal file
View File

@@ -0,0 +1,29 @@
// Only declare parkMap if it doesn't exist
window.parkMap = window.parkMap || null;
function initParkMap(latitude, longitude, name) {
const mapContainer = document.getElementById('park-map');
// Only initialize if container exists and map hasn't been initialized
if (mapContainer && !window.parkMap) {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap = L.map('park-map').setView([latitude, longitude], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(window.parkMap);
L.marker([latitude, longitude])
.addTo(window.parkMap)
.bindPopup(name);
// Update map size when window is resized
window.addEventListener('resize', function() {
const width = mapContainer.offsetWidth;
mapContainer.style.height = width + 'px';
window.parkMap.invalidateSize();
});
}
}

View File

@@ -0,0 +1,91 @@
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});

774
static/js/roadtrip.js Normal file
View File

@@ -0,0 +1,774 @@
/**
* ThrillWiki Road Trip Planner - Multi-park Route Planning
*
* This module provides road trip planning functionality with multi-park selection,
* route visualization, distance calculations, and export capabilities
*/
class RoadTripPlanner {
constructor(containerId, options = {}) {
this.containerId = containerId;
this.options = {
mapInstance: null,
maxParks: 20,
enableOptimization: true,
enableExport: true,
apiEndpoints: {
parks: '/api/parks/',
route: '/api/roadtrip/route/',
optimize: '/api/roadtrip/optimize/',
export: '/api/roadtrip/export/'
},
routeOptions: {
color: '#3B82F6',
weight: 4,
opacity: 0.8
},
...options
};
this.container = null;
this.mapInstance = null;
this.selectedParks = [];
this.routeLayer = null;
this.parkMarkers = new Map();
this.routePolyline = null;
this.routeData = null;
this.init();
}
/**
* Initialize the road trip planner
*/
init() {
this.container = document.getElementById(this.containerId);
if (!this.container) {
console.error(`Road trip container with ID '${this.containerId}' not found`);
return;
}
this.setupUI();
this.bindEvents();
// Connect to map instance if provided
if (this.options.mapInstance) {
this.connectToMap(this.options.mapInstance);
}
this.loadInitialData();
}
/**
* Setup the UI components
*/
setupUI() {
const html = `
<div class="roadtrip-planner">
<div class="roadtrip-header">
<h3 class="roadtrip-title">
<i class="fas fa-route"></i>
Road Trip Planner
</h3>
<div class="roadtrip-controls">
<button id="optimize-route" class="btn btn-secondary btn-sm" disabled>
<i class="fas fa-magic"></i> Optimize Route
</button>
<button id="clear-route" class="btn btn-outline btn-sm" disabled>
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
<div class="roadtrip-content">
<div class="park-selection">
<div class="search-parks">
<input type="text" id="park-search"
placeholder="Search parks to add..."
class="form-input">
<div id="park-search-results" class="search-results"></div>
</div>
</div>
<div class="selected-parks">
<h4 class="section-title">Your Route (<span id="park-count">0</span>/${this.options.maxParks})</h4>
<div id="parks-list" class="parks-list sortable">
<div class="empty-state">
<i class="fas fa-map-marked-alt"></i>
<p>Search and select parks to build your road trip route</p>
</div>
</div>
</div>
<div class="route-summary" id="route-summary" style="display: none;">
<h4 class="section-title">Trip Summary</h4>
<div class="summary-stats">
<div class="stat">
<span class="stat-label">Total Distance:</span>
<span id="total-distance" class="stat-value">-</span>
</div>
<div class="stat">
<span class="stat-label">Driving Time:</span>
<span id="total-time" class="stat-value">-</span>
</div>
<div class="stat">
<span class="stat-label">Parks:</span>
<span id="total-parks" class="stat-value">0</span>
</div>
</div>
<div class="export-options">
<button id="export-gpx" class="btn btn-outline btn-sm">
<i class="fas fa-download"></i> Export GPX
</button>
<button id="export-kml" class="btn btn-outline btn-sm">
<i class="fas fa-download"></i> Export KML
</button>
<button id="share-route" class="btn btn-primary btn-sm">
<i class="fas fa-share"></i> Share Route
</button>
</div>
</div>
</div>
</div>
`;
this.container.innerHTML = html;
}
/**
* Bind event handlers
*/
bindEvents() {
// Park search
const searchInput = document.getElementById('park-search');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.searchParks(e.target.value);
}, 300);
});
}
// Route controls
const optimizeBtn = document.getElementById('optimize-route');
if (optimizeBtn) {
optimizeBtn.addEventListener('click', () => this.optimizeRoute());
}
const clearBtn = document.getElementById('clear-route');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearRoute());
}
// Export buttons
const exportGpxBtn = document.getElementById('export-gpx');
if (exportGpxBtn) {
exportGpxBtn.addEventListener('click', () => this.exportRoute('gpx'));
}
const exportKmlBtn = document.getElementById('export-kml');
if (exportKmlBtn) {
exportKmlBtn.addEventListener('click', () => this.exportRoute('kml'));
}
const shareBtn = document.getElementById('share-route');
if (shareBtn) {
shareBtn.addEventListener('click', () => this.shareRoute());
}
// Make parks list sortable
this.initializeSortable();
}
/**
* Initialize drag-and-drop sorting for parks list
*/
initializeSortable() {
const parksList = document.getElementById('parks-list');
if (!parksList) return;
// Simple drag and drop implementation
let draggedElement = null;
parksList.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('park-item')) {
draggedElement = e.target;
e.target.style.opacity = '0.5';
}
});
parksList.addEventListener('dragend', (e) => {
if (e.target.classList.contains('park-item')) {
e.target.style.opacity = '1';
draggedElement = null;
}
});
parksList.addEventListener('dragover', (e) => {
e.preventDefault();
});
parksList.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedElement && e.target.classList.contains('park-item')) {
const afterElement = this.getDragAfterElement(parksList, e.clientY);
if (afterElement == null) {
parksList.appendChild(draggedElement);
} else {
parksList.insertBefore(draggedElement, afterElement);
}
this.reorderParks();
}
});
}
/**
* Get the element to insert after during drag and drop
*/
getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.park-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
/**
* Search for parks
*/
async searchParks(query) {
if (!query.trim()) {
document.getElementById('park-search-results').innerHTML = '';
return;
}
try {
const response = await fetch(`${this.options.apiEndpoints.parks}?q=${encodeURIComponent(query)}&limit=10`);
const data = await response.json();
if (data.status === 'success') {
this.displaySearchResults(data.data);
}
} catch (error) {
console.error('Failed to search parks:', error);
}
}
/**
* Display park search results
*/
displaySearchResults(parks) {
const resultsContainer = document.getElementById('park-search-results');
if (parks.length === 0) {
resultsContainer.innerHTML = '<div class="no-results">No parks found</div>';
return;
}
const html = parks
.filter(park => !this.isParkSelected(park.id))
.map(park => `
<div class="search-result-item" data-park-id="${park.id}">
<div class="park-info">
<div class="park-name">${park.name}</div>
<div class="park-location">${park.formatted_location || ''}</div>
</div>
<button class="add-park-btn" onclick="roadTripPlanner.addPark(${park.id})">
<i class="fas fa-plus"></i>
</button>
</div>
`).join('');
resultsContainer.innerHTML = html;
}
/**
* Check if a park is already selected
*/
isParkSelected(parkId) {
return this.selectedParks.some(park => park.id === parkId);
}
/**
* Add a park to the route
*/
async addPark(parkId) {
if (this.selectedParks.length >= this.options.maxParks) {
this.showMessage(`Maximum ${this.options.maxParks} parks allowed`, 'warning');
return;
}
try {
const response = await fetch(`${this.options.apiEndpoints.parks}${parkId}/`);
const data = await response.json();
if (data.status === 'success') {
const park = data.data;
this.selectedParks.push(park);
this.updateParksDisplay();
this.addParkMarker(park);
this.updateRoute();
// Clear search
document.getElementById('park-search').value = '';
document.getElementById('park-search-results').innerHTML = '';
}
} catch (error) {
console.error('Failed to add park:', error);
}
}
/**
* Remove a park from the route
*/
removePark(parkId) {
const index = this.selectedParks.findIndex(park => park.id === parkId);
if (index > -1) {
this.selectedParks.splice(index, 1);
this.updateParksDisplay();
this.removeParkMarker(parkId);
this.updateRoute();
}
}
/**
* Update the parks display
*/
updateParksDisplay() {
const parksList = document.getElementById('parks-list');
const parkCount = document.getElementById('park-count');
parkCount.textContent = this.selectedParks.length;
if (this.selectedParks.length === 0) {
parksList.innerHTML = `
<div class="empty-state">
<i class="fas fa-map-marked-alt"></i>
<p>Search and select parks to build your road trip route</p>
</div>
`;
this.updateControls();
return;
}
const html = this.selectedParks.map((park, index) => `
<div class="park-item" draggable="true" data-park-id="${park.id}">
<div class="park-number">${index + 1}</div>
<div class="park-details">
<div class="park-name">${park.name}</div>
<div class="park-location">${park.formatted_location || ''}</div>
${park.distance_from_previous ? `<div class="park-distance">${park.distance_from_previous}</div>` : ''}
</div>
<div class="park-actions">
<button class="btn-icon" onclick="roadTripPlanner.removePark(${park.id})" title="Remove park">
<i class="fas fa-times"></i>
</button>
</div>
</div>
`).join('');
parksList.innerHTML = html;
this.updateControls();
}
/**
* Update control buttons state
*/
updateControls() {
const optimizeBtn = document.getElementById('optimize-route');
const clearBtn = document.getElementById('clear-route');
const hasParks = this.selectedParks.length > 0;
const canOptimize = this.selectedParks.length > 2;
if (optimizeBtn) optimizeBtn.disabled = !canOptimize;
if (clearBtn) clearBtn.disabled = !hasParks;
}
/**
* Reorder parks after drag and drop
*/
reorderParks() {
const parkItems = document.querySelectorAll('.park-item');
const newOrder = [];
parkItems.forEach(item => {
const parkId = parseInt(item.dataset.parkId);
const park = this.selectedParks.find(p => p.id === parkId);
if (park) {
newOrder.push(park);
}
});
this.selectedParks = newOrder;
this.updateRoute();
}
/**
* Update the route visualization
*/
async updateRoute() {
if (this.selectedParks.length < 2) {
this.clearRouteVisualization();
this.updateRouteSummary(null);
return;
}
try {
const parkIds = this.selectedParks.map(park => park.id);
const response = await fetch(`${this.options.apiEndpoints.route}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ parks: parkIds })
});
const data = await response.json();
if (data.status === 'success') {
this.routeData = data.data;
this.visualizeRoute(data.data);
this.updateRouteSummary(data.data);
}
} catch (error) {
console.error('Failed to calculate route:', error);
}
}
/**
* Visualize the route on the map
*/
visualizeRoute(routeData) {
if (!this.mapInstance) return;
// Clear existing route
this.clearRouteVisualization();
if (routeData.coordinates) {
// Create polyline from coordinates
this.routePolyline = L.polyline(routeData.coordinates, this.options.routeOptions);
this.routePolyline.addTo(this.mapInstance);
// Fit map to route bounds
if (routeData.coordinates.length > 0) {
this.mapInstance.fitBounds(this.routePolyline.getBounds(), { padding: [20, 20] });
}
}
}
/**
* Clear route visualization
*/
clearRouteVisualization() {
if (this.routePolyline && this.mapInstance) {
this.mapInstance.removeLayer(this.routePolyline);
this.routePolyline = null;
}
}
/**
* Update route summary display
*/
updateRouteSummary(routeData) {
const summarySection = document.getElementById('route-summary');
if (!routeData || this.selectedParks.length < 2) {
summarySection.style.display = 'none';
return;
}
summarySection.style.display = 'block';
document.getElementById('total-distance').textContent = routeData.total_distance || '-';
document.getElementById('total-time').textContent = routeData.total_time || '-';
document.getElementById('total-parks').textContent = this.selectedParks.length;
}
/**
* Optimize the route order
*/
async optimizeRoute() {
if (this.selectedParks.length < 3) return;
try {
const parkIds = this.selectedParks.map(park => park.id);
const response = await fetch(`${this.options.apiEndpoints.optimize}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ parks: parkIds })
});
const data = await response.json();
if (data.status === 'success') {
// Reorder parks based on optimization
const optimizedOrder = data.data.optimized_order;
this.selectedParks = optimizedOrder.map(id =>
this.selectedParks.find(park => park.id === id)
).filter(Boolean);
this.updateParksDisplay();
this.updateRoute();
this.showMessage('Route optimized for shortest distance', 'success');
}
} catch (error) {
console.error('Failed to optimize route:', error);
this.showMessage('Failed to optimize route', 'error');
}
}
/**
* Clear the entire route
*/
clearRoute() {
this.selectedParks = [];
this.clearAllParkMarkers();
this.clearRouteVisualization();
this.updateParksDisplay();
this.updateRouteSummary(null);
}
/**
* Export route in specified format
*/
async exportRoute(format) {
if (!this.routeData) {
this.showMessage('No route to export', 'warning');
return;
}
try {
const response = await fetch(`${this.options.apiEndpoints.export}${format}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
parks: this.selectedParks.map(p => p.id),
route_data: this.routeData
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `thrillwiki-roadtrip.${format}`;
a.click();
window.URL.revokeObjectURL(url);
}
} catch (error) {
console.error('Failed to export route:', error);
this.showMessage('Failed to export route', 'error');
}
}
/**
* Share the route
*/
shareRoute() {
if (this.selectedParks.length === 0) {
this.showMessage('No route to share', 'warning');
return;
}
const parkIds = this.selectedParks.map(p => p.id).join(',');
const url = `${window.location.origin}/roadtrip/?parks=${parkIds}`;
if (navigator.share) {
navigator.share({
title: 'ThrillWiki Road Trip',
text: `Check out this ${this.selectedParks.length}-park road trip!`,
url: url
});
} else {
// Fallback to clipboard
navigator.clipboard.writeText(url).then(() => {
this.showMessage('Route URL copied to clipboard', 'success');
}).catch(() => {
// Manual selection fallback
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
this.showMessage('Route URL copied to clipboard', 'success');
});
}
}
/**
* Add park marker to map
*/
addParkMarker(park) {
if (!this.mapInstance) return;
const marker = L.marker([park.latitude, park.longitude], {
icon: this.createParkIcon(park)
});
marker.bindPopup(`
<div class="park-popup">
<h4>${park.name}</h4>
<p>${park.formatted_location || ''}</p>
<button onclick="roadTripPlanner.removePark(${park.id})" class="btn btn-sm btn-outline">
Remove from Route
</button>
</div>
`);
marker.addTo(this.mapInstance);
this.parkMarkers.set(park.id, marker);
}
/**
* Remove park marker from map
*/
removeParkMarker(parkId) {
if (this.parkMarkers.has(parkId) && this.mapInstance) {
this.mapInstance.removeLayer(this.parkMarkers.get(parkId));
this.parkMarkers.delete(parkId);
}
}
/**
* Clear all park markers
*/
clearAllParkMarkers() {
this.parkMarkers.forEach(marker => {
if (this.mapInstance) {
this.mapInstance.removeLayer(marker);
}
});
this.parkMarkers.clear();
}
/**
* Create custom icon for park marker
*/
createParkIcon(park) {
const index = this.selectedParks.findIndex(p => p.id === park.id) + 1;
return L.divIcon({
className: 'roadtrip-park-marker',
html: `<div class="park-marker-inner">${index}</div>`,
iconSize: [30, 30],
iconAnchor: [15, 15]
});
}
/**
* Connect to a map instance
*/
connectToMap(mapInstance) {
this.mapInstance = mapInstance;
this.options.mapInstance = mapInstance;
}
/**
* Load initial data (from URL parameters)
*/
loadInitialData() {
const urlParams = new URLSearchParams(window.location.search);
const parkIds = urlParams.get('parks');
if (parkIds) {
const ids = parkIds.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
this.loadParksById(ids);
}
}
/**
* Load parks by IDs
*/
async loadParksById(parkIds) {
try {
const promises = parkIds.map(id =>
fetch(`${this.options.apiEndpoints.parks}${id}/`)
.then(res => res.json())
.then(data => data.status === 'success' ? data.data : null)
);
const parks = (await Promise.all(promises)).filter(Boolean);
this.selectedParks = parks;
this.updateParksDisplay();
// Add markers and update route
parks.forEach(park => this.addParkMarker(park));
this.updateRoute();
} catch (error) {
console.error('Failed to load parks:', error);
}
}
/**
* Get CSRF token for POST requests
*/
getCsrfToken() {
const token = document.querySelector('[name=csrfmiddlewaretoken]');
return token ? token.value : '';
}
/**
* Show message to user
*/
showMessage(message, type = 'info') {
// Create or update message element
let messageEl = this.container.querySelector('.roadtrip-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.className = 'roadtrip-message';
this.container.insertBefore(messageEl, this.container.firstChild);
}
messageEl.textContent = message;
messageEl.className = `roadtrip-message roadtrip-message-${type}`;
// Auto-hide after delay
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
}, 5000);
}
}
// Auto-initialize road trip planner
document.addEventListener('DOMContentLoaded', function() {
const roadtripContainer = document.getElementById('roadtrip-planner');
if (roadtripContainer) {
window.roadTripPlanner = new RoadTripPlanner('roadtrip-planner', {
mapInstance: window.thrillwikiMap || null
});
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = RoadTripPlanner;
} else {
window.RoadTripPlanner = RoadTripPlanner;
}

42
static/js/search.js Normal file
View File

@@ -0,0 +1,42 @@
function parkSearch() {
return {
query: '',
results: [],
loading: false,
selectedId: null,
async search() {
if (!this.query.trim()) {
this.results = [];
return;
}
this.loading = true;
try {
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.loading = false;
}
},
clear() {
this.query = '';
this.results = [];
this.selectedId = null;
},
selectPark(park) {
this.query = park.name;
this.selectedId = park.id;
this.results = [];
// Trigger filter update
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
};
}