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