The smokesignal.events web application
at main 174 lines 5.3 kB view raw
1/** 2 * LFG Heatmap Feature 3 * 4 * Renders the LFG activity heatmap showing event and people distribution. 5 * Uses MapLibre GL for map rendering and H3 for hexagon visualization. 6 */ 7 8import type { Feature, Polygon } from 'geojson' 9import type { MapMouseEvent, Popup as MaplibrePopup } from 'maplibre-gl' 10import { 11 loadMapLibraries, 12 createMap, 13 h3ToGeoJsonFeature, 14 addHexagonLayers, 15 updateHexagonSource, 16 toMapCenter, 17} from '../maps/map-utils' 18 19interface HeatmapBucket { 20 key: string 21 count: number 22} 23 24export async function initLfgHeatmap(): Promise<void> { 25 const mapContainer = document.getElementById('lfg-heatmap') 26 if (!mapContainer) return 27 28 // Skip if already initialized or currently initializing 29 if ( 30 mapContainer.dataset.mapInitialized === 'true' || 31 mapContainer.dataset.mapInitializing === 'true' 32 ) 33 return 34 mapContainer.dataset.mapInitializing = 'true' 35 36 try { 37 // Parse data from data attributes 38 const centerLat = parseFloat(mapContainer.dataset.lat ?? '40.7128') 39 const centerLon = parseFloat(mapContainer.dataset.lon ?? '-74.006') 40 const eventBucketsData = mapContainer.dataset.eventBuckets 41 const profileBucketsData = mapContainer.dataset.profileBuckets 42 43 // Lazy load MapLibre and H3 44 const { maplibregl, h3 } = await loadMapLibraries() 45 46 // Create map 47 const map = createMap(maplibregl, { 48 container: 'lfg-heatmap', 49 center: toMapCenter(centerLat, centerLon), 50 zoom: 12, 51 }) 52 53 // Parse bucket data 54 let eventBuckets: HeatmapBucket[] = [] 55 let profileBuckets: HeatmapBucket[] = [] 56 57 try { 58 if (eventBucketsData) { 59 eventBuckets = JSON.parse(eventBucketsData) 60 } 61 if (profileBucketsData) { 62 profileBuckets = JSON.parse(profileBucketsData) 63 } 64 } catch (e) { 65 console.warn('Failed to parse heatmap buckets:', e) 66 } 67 68 // Merge buckets by H3 key 69 const combinedBuckets = new Map<string, { events: number; people: number }>() 70 71 for (const bucket of eventBuckets) { 72 const existing = combinedBuckets.get(bucket.key) || { events: 0, people: 0 } 73 existing.events = bucket.count 74 combinedBuckets.set(bucket.key, existing) 75 } 76 77 for (const bucket of profileBuckets) { 78 const existing = combinedBuckets.get(bucket.key) || { events: 0, people: 0 } 79 existing.people = bucket.count 80 combinedBuckets.set(bucket.key, existing) 81 } 82 83 // Calculate max combined count for intensity scaling 84 let maxCount = 0 85 combinedBuckets.forEach((value) => { 86 const total = value.events + value.people 87 if (total > maxCount) maxCount = total 88 }) 89 90 map.on('load', () => { 91 // Add hexagon layers 92 addHexagonLayers(map) 93 94 // Build features with tooltip data 95 const features: Feature<Polygon>[] = [] 96 97 combinedBuckets.forEach((value, key) => { 98 try { 99 const total = value.events + value.people 100 const intensity = Math.min(total / Math.max(maxCount, 1), 1) 101 const opacity = 0.2 + intensity * 0.5 102 103 // Build tooltip text 104 const parts: string[] = [] 105 if (value.events > 0) parts.push(`${value.events} event${value.events !== 1 ? 's' : ''}`) 106 if (value.people > 0) 107 parts.push(`${value.people} ${value.people !== 1 ? 'people' : 'person'}`) 108 const tooltipText = parts.join(', ') 109 110 // Get center for popup positioning 111 const center = h3.cellToLatLng(key) 112 113 features.push( 114 h3ToGeoJsonFeature(h3, key, { 115 fillColor: '#3273dc', 116 fillOpacity: opacity, 117 strokeColor: '#3273dc', 118 strokeWidth: 1, 119 tooltipText, 120 centerLat: center[0], 121 centerLng: center[1], 122 }) 123 ) 124 } catch { 125 console.warn('Invalid H3 cell:', key) 126 } 127 }) 128 129 // Update the source with features 130 updateHexagonSource(map, features) 131 132 // Create popup for hover 133 let popup: MaplibrePopup | null = null 134 135 // Show popup on hover 136 map.on('mouseenter', 'hexagons-fill', (e: MapMouseEvent & { features?: Feature[] }) => { 137 const feature = e.features?.[0] 138 if (!feature?.properties) return 139 140 const props = feature.properties as Record<string, unknown> 141 const text = props.tooltipText as string 142 const centerLat = props.centerLat as number 143 const centerLng = props.centerLng as number 144 145 if (text && centerLat !== undefined && centerLng !== undefined) { 146 map.getCanvas().style.cursor = 'pointer' 147 148 popup = new maplibregl.Popup({ 149 closeButton: false, 150 closeOnClick: false, 151 }) 152 .setLngLat([centerLng, centerLat]) 153 .setText(text) 154 .addTo(map) 155 } 156 }) 157 158 // Remove popup on leave 159 map.on('mouseleave', 'hexagons-fill', () => { 160 map.getCanvas().style.cursor = '' 161 if (popup) { 162 popup.remove() 163 popup = null 164 } 165 }) 166 }) 167 168 mapContainer.dataset.mapInitialized = 'true' 169 } catch (err) { 170 console.error('Failed to initialize LFG heatmap:', err) 171 } finally { 172 mapContainer.dataset.mapInitializing = 'false' 173 } 174}