mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:31:08 -05:00
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,711 +0,0 @@
|
||||
/**
|
||||
* 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
5
static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,665 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,720 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
// 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,573 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,850 +0,0 @@
|
||||
/**
|
||||
* 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,881 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
@@ -1,774 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user