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