Files
thrillwiki_django_no_react/static/js/maps.js
2025-09-21 20:19:12 -04:00

656 lines
19 KiB
JavaScript

/**
* 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;
}