mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
656 lines
19 KiB
JavaScript
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;
|
|
} |