The smokesignal.events web application
at main 296 lines 9.7 kB view raw
1/** 2 * Globe Map Feature 3 * 4 * Renders the interactive globe map on the homepage showing global activity. 5 * Uses MapLibre GL for 3D globe rendering and H3 for hexagon visualization. 6 */ 7 8import type { Feature, Polygon } from 'geojson' 9import type { 10 GeoJSONSource, 11 Map as MaplibreMap, 12 Popup as MaplibrePopup, 13 MapMouseEvent, 14} from 'maplibre-gl' 15import type { H3Bucket } from '../../types' 16 17interface GlobeMapOptions { 18 containerId: string 19 statusElementId: string 20} 21 22export async function initGlobeMap(options?: GlobeMapOptions): Promise<void> { 23 const containerId = options?.containerId ?? 'globe-map' 24 const statusElementId = options?.statusElementId ?? 'globe-status' 25 26 const mapContainer = document.getElementById(containerId) 27 const statusEl = document.getElementById(statusElementId) 28 if (!mapContainer) return 29 30 // Skip if already initialized or currently initializing 31 if ( 32 mapContainer.dataset.mapInitialized === 'true' || 33 mapContainer.dataset.mapInitializing === 'true' 34 ) 35 return 36 mapContainer.dataset.mapInitializing = 'true' 37 38 try { 39 // Lazy load MapLibre GL and H3 40 const [maplibregl, h3] = await Promise.all([import('maplibre-gl'), import('h3-js')]) 41 42 // Import MapLibre GL CSS 43 await import('maplibre-gl/dist/maplibre-gl.css') 44 45 let map: MaplibreMap | null = null 46 let popup: MaplibrePopup | null = null 47 48 // Convert value to HSL color (blue to yellow gradient) 49 function valueToColor(value: number, maxValue: number): string { 50 if (maxValue === 0) return 'hsl(240, 70%, 50%)' 51 const ratio = value / maxValue 52 const hue = 240 - ratio * 180 // 240 (blue) to 60 (yellow) 53 return `hsl(${hue}, 70%, 50%)` 54 } 55 56 // Convert H3 cell to GeoJSON Feature 57 function h3ToGeoJsonFeature(bucket: H3Bucket, maxTotal: number): Feature<Polygon> { 58 const boundary = h3.cellToBoundary(bucket.key) 59 const center = h3.cellToLatLng(bucket.key) 60 61 // Convert H3 [lat, lng] to GeoJSON [lng, lat] 62 const coordinates: [number, number][] = boundary.map(([lat, lng]) => [lng, lat]) 63 coordinates.push(coordinates[0]) // Close the ring 64 65 return { 66 type: 'Feature', 67 properties: { 68 id: bucket.key, 69 event_count: bucket.event_count ?? 0, 70 lfg_count: bucket.lfg_count ?? 0, 71 total: bucket.total ?? 0, 72 centerLat: center[0], 73 centerLng: center[1], 74 color: valueToColor(bucket.total ?? 0, maxTotal), 75 }, 76 geometry: { 77 type: 'Polygon', 78 coordinates: [coordinates], 79 }, 80 } 81 } 82 83 function initGlobe(): void { 84 map = new maplibregl.Map({ 85 container: containerId, 86 style: { 87 version: 8, 88 projection: { type: 'globe' }, 89 sources: { 90 'carto-voyager': { 91 type: 'raster', 92 tiles: [ 93 'https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', 94 'https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', 95 'https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', 96 ], 97 tileSize: 256, 98 attribution: '&copy; <a href="https://carto.com/attributions">CARTO</a>', 99 }, 100 }, 101 layers: [ 102 { 103 id: 'background', 104 type: 'background', 105 paint: { 106 'background-color': '#e8e8e8', 107 }, 108 }, 109 { 110 id: 'carto-voyager', 111 type: 'raster', 112 source: 'carto-voyager', 113 }, 114 ], 115 }, 116 center: [-100, 40], 117 zoom: 4, 118 maxZoom: 8, 119 minZoom: 1, 120 }) 121 122 map.on('load', () => { 123 if (!map) return 124 125 // Add empty source for hex data 126 map.addSource('hexagons', { 127 type: 'geojson', 128 data: { type: 'FeatureCollection', features: [] }, 129 }) 130 131 // Add flat fill layer for hexagons 132 map.addLayer({ 133 id: 'hexagons-fill', 134 type: 'fill', 135 source: 'hexagons', 136 paint: { 137 'fill-color': ['get', 'color'], 138 'fill-opacity': 0.4, 139 }, 140 }) 141 142 // Add outline layer 143 map.addLayer({ 144 id: 'hexagons-outline', 145 type: 'line', 146 source: 'hexagons', 147 paint: { 148 'line-color': '#333', 149 'line-width': 0.5, 150 'line-opacity': 0.4, 151 }, 152 }) 153 154 // Initialize popup 155 popup = new maplibregl.Popup({ 156 closeButton: true, 157 closeOnClick: false, 158 }) 159 160 // Click handler for hexagons 161 map.on('click', 'hexagons-fill', (e: MapMouseEvent & { features?: Feature[] }) => { 162 const features = e.features 163 if (features && features.length > 0) { 164 const props = features[0].properties as Record<string, unknown> 165 166 let content = `<strong>Activity:</strong> ${props.total} total<br/>` 167 if ((props.event_count as number) > 0) { 168 content += `${props.event_count} event${props.event_count === 1 ? '' : 's'}<br/>` 169 } 170 if ((props.lfg_count as number) > 0) { 171 content += `${props.lfg_count} ${props.lfg_count === 1 ? 'person' : 'people'} LFG` 172 } 173 174 popup 175 ?.setLngLat([props.centerLng as number, props.centerLat as number]) 176 .setHTML(content) 177 .addTo(map!) 178 } 179 }) 180 181 // Cursor style 182 map.on('mouseenter', 'hexagons-fill', () => { 183 map!.getCanvas().style.cursor = 'pointer' 184 }) 185 map.on('mouseleave', 'hexagons-fill', () => { 186 map!.getCanvas().style.cursor = '' 187 }) 188 189 // Fetch globe aggregation data 190 loadGlobeData() 191 }) 192 193 // Focus button handlers 194 setupFocusButtons() 195 } 196 197 function setupFocusButtons(): void { 198 document.getElementById('focus-north-america')?.addEventListener('click', () => { 199 map?.flyTo({ center: [-100, 40], zoom: 4, duration: 1500 }) 200 }) 201 202 document.getElementById('focus-europe')?.addEventListener('click', () => { 203 map?.flyTo({ center: [10, 50], zoom: 4, duration: 1500 }) 204 }) 205 206 document.getElementById('focus-world')?.addEventListener('click', () => { 207 map?.flyTo({ center: [0, 20], zoom: 2, duration: 1500 }) 208 }) 209 210 document.getElementById('focus-my-location')?.addEventListener('click', () => { 211 const btn = document.getElementById('focus-my-location') as HTMLButtonElement | null 212 if (!btn) return 213 214 const originalHtml = btn.innerHTML 215 btn.innerHTML = 216 '<span class="icon is-small"><i class="fas fa-spinner fa-pulse"></i></span><span>Locating...</span>' 217 btn.disabled = true 218 219 if (navigator.geolocation) { 220 navigator.geolocation.getCurrentPosition( 221 (position) => { 222 map?.flyTo({ 223 center: [position.coords.longitude, position.coords.latitude], 224 zoom: 8, 225 duration: 1500, 226 }) 227 btn.innerHTML = originalHtml 228 btn.disabled = false 229 }, 230 (error) => { 231 console.warn('Geolocation failed:', error.message) 232 if (statusEl) statusEl.textContent = 'Could not get your location.' 233 btn.innerHTML = originalHtml 234 btn.disabled = false 235 }, 236 { timeout: 10000, maximumAge: 300000 } 237 ) 238 } else { 239 if (statusEl) statusEl.textContent = 'Geolocation not supported.' 240 btn.innerHTML = originalHtml 241 btn.disabled = false 242 } 243 }) 244 } 245 246 function loadGlobeData(): void { 247 fetch('/api/globe-aggregation?resolution=5') 248 .then((response) => response.json()) 249 .then((data: { buckets?: H3Bucket[] }) => { 250 if (!data.buckets || data.buckets.length === 0) { 251 if (statusEl) statusEl.textContent = 'No activity found.' 252 return 253 } 254 255 // Find max total for color scaling 256 const maxTotal = Math.max(...data.buckets.map((b) => b.total ?? 0)) 257 258 // Convert buckets to GeoJSON features 259 const features: Feature<Polygon>[] = [] 260 data.buckets.forEach((bucket) => { 261 try { 262 features.push(h3ToGeoJsonFeature(bucket, maxTotal)) 263 } catch (e) { 264 console.warn('Failed to process hex:', bucket.key, e) 265 } 266 }) 267 268 // Update map source 269 const source = map?.getSource('hexagons') as GeoJSONSource | undefined 270 source?.setData({ 271 type: 'FeatureCollection', 272 features: features, 273 }) 274 275 const totalEvents = data.buckets.reduce((sum, b) => sum + (b.event_count ?? 0), 0) 276 const totalLfg = data.buckets.reduce((sum, b) => sum + (b.lfg_count ?? 0), 0) 277 if (statusEl) { 278 statusEl.textContent = `${data.buckets.length} active regions: ${totalEvents} events, ${totalLfg} people LFG` 279 } 280 }) 281 .catch((err) => { 282 console.error('Failed to load globe aggregation:', err) 283 if (statusEl) statusEl.textContent = 'Failed to load activity data.' 284 }) 285 } 286 287 // Initialize the globe 288 initGlobe() 289 mapContainer.dataset.mapInitialized = 'true' 290 } catch (err) { 291 console.error('Failed to initialize globe map:', err) 292 if (statusEl) statusEl.textContent = 'Failed to load map.' 293 } finally { 294 mapContainer.dataset.mapInitializing = 'false' 295 } 296}