/** * LFG Heatmap Feature * * Renders the LFG activity heatmap showing event and people distribution. * Uses MapLibre GL for map rendering and H3 for hexagon visualization. */ import type { Feature, Polygon } from 'geojson' import type { MapMouseEvent, Popup as MaplibrePopup } from 'maplibre-gl' import { loadMapLibraries, createMap, h3ToGeoJsonFeature, addHexagonLayers, updateHexagonSource, toMapCenter, } from '../maps/map-utils' interface HeatmapBucket { key: string count: number } export async function initLfgHeatmap(): Promise { const mapContainer = document.getElementById('lfg-heatmap') if (!mapContainer) return // Skip if already initialized or currently initializing if ( mapContainer.dataset.mapInitialized === 'true' || mapContainer.dataset.mapInitializing === 'true' ) return mapContainer.dataset.mapInitializing = 'true' try { // Parse data from data attributes const centerLat = parseFloat(mapContainer.dataset.lat ?? '40.7128') const centerLon = parseFloat(mapContainer.dataset.lon ?? '-74.006') const eventBucketsData = mapContainer.dataset.eventBuckets const profileBucketsData = mapContainer.dataset.profileBuckets // Lazy load MapLibre and H3 const { maplibregl, h3 } = await loadMapLibraries() // Create map const map = createMap(maplibregl, { container: 'lfg-heatmap', center: toMapCenter(centerLat, centerLon), zoom: 12, }) // Parse bucket data let eventBuckets: HeatmapBucket[] = [] let profileBuckets: HeatmapBucket[] = [] try { if (eventBucketsData) { eventBuckets = JSON.parse(eventBucketsData) } if (profileBucketsData) { profileBuckets = JSON.parse(profileBucketsData) } } catch (e) { console.warn('Failed to parse heatmap buckets:', e) } // Merge buckets by H3 key const combinedBuckets = new Map() for (const bucket of eventBuckets) { const existing = combinedBuckets.get(bucket.key) || { events: 0, people: 0 } existing.events = bucket.count combinedBuckets.set(bucket.key, existing) } for (const bucket of profileBuckets) { const existing = combinedBuckets.get(bucket.key) || { events: 0, people: 0 } existing.people = bucket.count combinedBuckets.set(bucket.key, existing) } // Calculate max combined count for intensity scaling let maxCount = 0 combinedBuckets.forEach((value) => { const total = value.events + value.people if (total > maxCount) maxCount = total }) map.on('load', () => { // Add hexagon layers addHexagonLayers(map) // Build features with tooltip data const features: Feature[] = [] combinedBuckets.forEach((value, key) => { try { const total = value.events + value.people const intensity = Math.min(total / Math.max(maxCount, 1), 1) const opacity = 0.2 + intensity * 0.5 // Build tooltip text const parts: string[] = [] if (value.events > 0) parts.push(`${value.events} event${value.events !== 1 ? 's' : ''}`) if (value.people > 0) parts.push(`${value.people} ${value.people !== 1 ? 'people' : 'person'}`) const tooltipText = parts.join(', ') // Get center for popup positioning const center = h3.cellToLatLng(key) features.push( h3ToGeoJsonFeature(h3, key, { fillColor: '#3273dc', fillOpacity: opacity, strokeColor: '#3273dc', strokeWidth: 1, tooltipText, centerLat: center[0], centerLng: center[1], }) ) } catch { console.warn('Invalid H3 cell:', key) } }) // Update the source with features updateHexagonSource(map, features) // Create popup for hover let popup: MaplibrePopup | null = null // Show popup on hover map.on('mouseenter', 'hexagons-fill', (e: MapMouseEvent & { features?: Feature[] }) => { const feature = e.features?.[0] if (!feature?.properties) return const props = feature.properties as Record const text = props.tooltipText as string const centerLat = props.centerLat as number const centerLng = props.centerLng as number if (text && centerLat !== undefined && centerLng !== undefined) { map.getCanvas().style.cursor = 'pointer' popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, }) .setLngLat([centerLng, centerLat]) .setText(text) .addTo(map) } }) // Remove popup on leave map.on('mouseleave', 'hexagons-fill', () => { map.getCanvas().style.cursor = '' if (popup) { popup.remove() popup = null } }) }) mapContainer.dataset.mapInitialized = 'true' } catch (err) { console.error('Failed to initialize LFG heatmap:', err) } finally { mapContainer.dataset.mapInitializing = 'false' } }