The smokesignal.events web application
1/**
2 * Event Map Feature
3 *
4 * Renders an event location map with H3 hexagons.
5 * Used on the event view page.
6 */
7
8import type { Feature, Polygon } from 'geojson'
9import type { GeoLocation } from '../../types'
10import {
11 loadMapLibraries,
12 createMap,
13 h3ToGeoJsonFeature,
14 addHexagonLayers,
15 updateHexagonSource,
16 calculateBounds,
17 toMapCenter,
18} from './map-utils'
19
20const H3_RESOLUTION = 9
21
22export async function initEventMap(): Promise<void> {
23 const container = document.getElementById('event-map')
24 if (!container) return
25
26 // Skip if already initialized or currently initializing
27 if (container.dataset.mapInitialized === 'true' || container.dataset.mapInitializing === 'true')
28 return
29
30 // Mark as initializing immediately to prevent race conditions
31 container.dataset.mapInitializing = 'true'
32
33 try {
34 // Parse geo locations from data attribute
35 const geoLocationsData = container.dataset.geoLocations
36 if (!geoLocationsData) {
37 container.dataset.mapInitializing = 'false'
38 return
39 }
40
41 let geoLocations: GeoLocation[]
42 try {
43 geoLocations = JSON.parse(geoLocationsData)
44 } catch (e) {
45 console.error('Failed to parse geo locations:', e)
46 container.dataset.mapInitializing = 'false'
47 return
48 }
49
50 // Parse and filter locations - coordinates may be strings from backend
51 const parsedLocations = geoLocations
52 .filter((loc) => loc != null)
53 .map((loc) => ({
54 latitude: typeof loc.latitude === 'string' ? parseFloat(loc.latitude) : loc.latitude,
55 longitude: typeof loc.longitude === 'string' ? parseFloat(loc.longitude) : loc.longitude,
56 name: loc.name,
57 }))
58 .filter((loc) => Number.isFinite(loc.latitude) && Number.isFinite(loc.longitude))
59
60 if (parsedLocations.length === 0) {
61 container.dataset.mapInitializing = 'false'
62 return
63 }
64
65 // Lazy load MapLibre and H3
66 const { maplibregl, h3 } = await loadMapLibraries()
67
68 // Double-check we haven't been initialized while loading
69 if (container.dataset.mapInitialized === 'true') {
70 container.dataset.mapInitializing = 'false'
71 return
72 }
73
74 // Calculate center of all locations for initial map view
75 const avgLat =
76 parsedLocations.reduce((sum, loc) => sum + loc.latitude, 0) / parsedLocations.length
77 const avgLng =
78 parsedLocations.reduce((sum, loc) => sum + loc.longitude, 0) / parsedLocations.length
79
80 // Final validation before creating map
81 if (!Number.isFinite(avgLat) || !Number.isFinite(avgLng)) {
82 container.dataset.mapInitializing = 'false'
83 return
84 }
85
86 // Initialize map
87 const map = createMap(maplibregl, {
88 container: 'event-map',
89 center: toMapCenter(avgLat, avgLng),
90 zoom: 16,
91 scrollZoom: false,
92 })
93
94 map.on('load', () => {
95 // Add hexagon layers
96 addHexagonLayers(map)
97
98 // Build GeoJSON features for each location
99 const features: Feature<Polygon>[] = parsedLocations.map((loc) => {
100 const h3Index = h3.latLngToCell(loc.latitude, loc.longitude, H3_RESOLUTION)
101 return h3ToGeoJsonFeature(h3, h3Index, {
102 fillColor: '#3273dc',
103 fillOpacity: 0.2,
104 strokeColor: '#3273dc',
105 strokeWidth: 2,
106 })
107 })
108
109 // Update the source with features
110 updateHexagonSource(map, features)
111
112 // Fit map to show all hexagons
113 const bounds = calculateBounds(features)
114 if (bounds) {
115 map.fitBounds(bounds, { padding: 20 })
116 }
117 })
118
119 // Mark as successfully initialized
120 container.dataset.mapInitialized = 'true'
121 } catch (e) {
122 console.error('Failed to initialize event map:', e)
123 } finally {
124 container.dataset.mapInitializing = 'false'
125 }
126}