/** * 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: `
${count}
`, 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: `
${cluster.count}
`, 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: `
${iconData.emoji}
`, iconSize: [30, 30], iconAnchor: [15, 15], popupAnchor: [0, -15] }); } /** * Create popup content for a location */ createPopupContent(location) { return `
${location.formatted_location ? `` : ''} ${location.operator ? `` : ''} ${location.ride_count ? `` : ''} ${location.status ? `` : ''}
`; } /** * 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: '
', 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; }