The smokesignal.events web application
at main 126 lines 3.8 kB view raw
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}