forked from
smokesignal.events/smokesignal
The smokesignal.events web application
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}