/** * Heatmap visualization module * Renders user activity heatmap using Leaflet.heat */ let heatmapMap = null; let heatLayer = null; /** * Initialize the heatmap on page load */ document.addEventListener('DOMContentLoaded', async function() { // Check authentication if (!FitPubAuth.isAuthenticated()) { window.location.href = '/login'; return; } await loadHeatmap(); }); /** * Load and render the heatmap */ async function loadHeatmap() { const loadingIndicator = document.getElementById('loadingIndicator'); const errorAlert = document.getElementById('errorAlert'); const errorMessage = document.getElementById('errorMessage'); const emptyState = document.getElementById('emptyState'); const heatmapContainer = document.getElementById('heatmapContainer'); const statsCard = document.getElementById('statsCard'); const legend = document.getElementById('legend'); // Show loading loadingIndicator.style.display = 'block'; errorAlert.classList.add('d-none'); emptyState.classList.add('d-none'); heatmapContainer.style.display = 'none'; statsCard.style.display = 'none'; legend.style.display = 'none'; try { // Fetch heatmap data const response = await FitPubAuth.authenticatedFetch('/api/heatmap/me'); if (!response.ok) { throw new Error('Failed to load heatmap data'); } const data = await response.json(); // Hide loading loadingIndicator.style.display = 'none'; // Check if user has any data if (!data.features || data.features.length === 0) { emptyState.classList.remove('d-none'); return; } // Show map and stats heatmapContainer.style.display = 'block'; statsCard.style.display = 'block'; legend.style.display = 'block'; // Update stats document.getElementById('cellCount').textContent = data.features.length.toLocaleString(); document.getElementById('maxIntensity').textContent = data.maxIntensity.toLocaleString(); // Initialize map initializeMap(); // Render heatmap renderHeatmap(data); } catch (error) { console.error('Error loading heatmap:', error); loadingIndicator.style.display = 'none'; errorAlert.classList.remove('d-none'); errorMessage.textContent = 'Failed to load heatmap. Please try again later.'; } } /** * Initialize the Leaflet map */ function initializeMap() { if (heatmapMap) { return; // Already initialized } // Create map centered on world heatmapMap = L.map('heatmapContainer').setView([20, 0], 2); // Add OpenStreetMap tile layer L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 18 }).addTo(heatmapMap); } /** * Render heatmap layer from GeoJSON data */ function renderHeatmap(data) { // Convert GeoJSON features to Leaflet.heat format: [lat, lon, intensity] const heatData = data.features.map(feature => { const lon = feature.geometry.coordinates[0]; const lat = feature.geometry.coordinates[1]; const intensity = feature.properties.intensity; // Normalize intensity to 0-1 range const normalizedIntensity = Math.min(intensity / data.maxIntensity, 1.0); return [lat, lon, normalizedIntensity]; }); // Remove existing heat layer if present if (heatLayer) { heatmapMap.removeLayer(heatLayer); } // Create heat layer heatLayer = L.heatLayer(heatData, { radius: 25, blur: 15, maxZoom: 17, max: 1.0, gradient: { 0.0: 'blue', 0.4: 'cyan', 0.6: 'lime', 0.7: 'yellow', 0.9: 'orange', 1.0: 'red' } }).addTo(heatmapMap); // Fit map bounds to heatmap data fitMapToBounds(data.features); } /** * Fit map to show all heatmap data */ function fitMapToBounds(features) { if (features.length === 0) { return; } // Calculate bounds let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; let maxLon = -Infinity; features.forEach(feature => { const lon = feature.geometry.coordinates[0]; const lat = feature.geometry.coordinates[1]; minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); }); // Add padding const latPadding = (maxLat - minLat) * 0.1; const lonPadding = (maxLon - minLon) * 0.1; const bounds = [ [minLat - latPadding, minLon - lonPadding], [maxLat + latPadding, maxLon + lonPadding] ]; heatmapMap.fitBounds(bounds); }