forked from
smokesignal.events/smokesignal
The smokesignal.events web application
1/**
2 * Location Heatmap Feature
3 *
4 * Renders the location heatmap on the location page showing event distribution.
5 * Uses MapLibre GL for map rendering and H3 for hexagon visualization.
6 */
7
8import type { Feature, Polygon } from 'geojson'
9import type { H3Bucket } from '../../types'
10import {
11 loadMapLibraries,
12 createMap,
13 h3ToGeoJsonFeature,
14 addHexagonLayers,
15 updateHexagonSource,
16 calculateBounds,
17 heatmapGradientColor,
18 toMapCenter,
19} from './map-utils'
20
21export async function initLocationHeatmap(): Promise<void> {
22 const mapContainer = document.getElementById('location-heatmap')
23 if (!mapContainer) return
24
25 // Skip if already initialized or currently initializing
26 if (
27 mapContainer.dataset.mapInitialized === 'true' ||
28 mapContainer.dataset.mapInitializing === 'true'
29 )
30 return
31 mapContainer.dataset.mapInitializing = 'true'
32
33 try {
34 // Parse data from data attributes
35 const centerLat = parseFloat(mapContainer.dataset.centerLat ?? '0')
36 const centerLon = parseFloat(mapContainer.dataset.centerLon ?? '0')
37 const centerCell = mapContainer.dataset.centerCell ?? ''
38 const geoBucketsData = mapContainer.dataset.geoBuckets
39
40 if (!geoBucketsData) return
41
42 let geoBuckets: H3Bucket[]
43 try {
44 geoBuckets = JSON.parse(geoBucketsData)
45 } catch (e) {
46 console.error('Failed to parse geo buckets:', e)
47 return
48 }
49
50 // Lazy load MapLibre and H3
51 const { maplibregl, h3 } = await loadMapLibraries()
52
53 // Create non-interactive map
54 const map = createMap(maplibregl, {
55 container: 'location-heatmap',
56 center: toMapCenter(centerLat, centerLon),
57 zoom: 9,
58 interactive: false,
59 })
60
61 map.on('load', () => {
62 // Add hexagon layers
63 addHexagonLayers(map)
64
65 // Only draw hexes with events
66 if (geoBuckets && geoBuckets.length > 0) {
67 const counts = geoBuckets.map((b) => b.doc_count ?? 0)
68 const minCount = Math.min(...counts)
69 const maxCount = Math.max(...counts)
70
71 const features: Feature<Polygon>[] = []
72
73 geoBuckets.forEach((bucket) => {
74 try {
75 const cellIndex = bucket.key
76 const count = bucket.doc_count ?? 0
77 const isCenter = cellIndex === centerCell
78 const color = heatmapGradientColor(count, minCount, maxCount)
79
80 features.push(
81 h3ToGeoJsonFeature(h3, cellIndex, {
82 fillColor: color,
83 fillOpacity: 0.5,
84 strokeColor: isCenter ? '#1a1a1a' : color,
85 strokeWidth: isCenter ? 3 : 2,
86 })
87 )
88 } catch (e) {
89 console.warn('Failed to draw hex:', bucket.key, e)
90 }
91 })
92
93 // Update the source with features
94 updateHexagonSource(map, features)
95
96 // Fit bounds to show all hexes
97 const bounds = calculateBounds(features)
98 if (bounds) {
99 map.fitBounds(bounds, { padding: 10 })
100 }
101 }
102 })
103
104 mapContainer.dataset.mapInitialized = 'true'
105 } catch (err) {
106 console.error('Failed to initialize location heatmap:', err)
107 } finally {
108 mapContainer.dataset.mapInitializing = 'false'
109 }
110}