/** * Globe Map Feature * * Renders the interactive globe map on the homepage showing global activity. * Uses MapLibre GL for 3D globe rendering and H3 for hexagon visualization. */ import type { Feature, Polygon } from 'geojson' import type { GeoJSONSource, Map as MaplibreMap, Popup as MaplibrePopup, MapMouseEvent, } from 'maplibre-gl' import type { H3Bucket } from '../../types' interface GlobeMapOptions { containerId: string statusElementId: string } export async function initGlobeMap(options?: GlobeMapOptions): Promise { const containerId = options?.containerId ?? 'globe-map' const statusElementId = options?.statusElementId ?? 'globe-status' const mapContainer = document.getElementById(containerId) const statusEl = document.getElementById(statusElementId) if (!mapContainer) return // Skip if already initialized or currently initializing if ( mapContainer.dataset.mapInitialized === 'true' || mapContainer.dataset.mapInitializing === 'true' ) return mapContainer.dataset.mapInitializing = 'true' try { // Lazy load MapLibre GL and H3 const [maplibregl, h3] = await Promise.all([import('maplibre-gl'), import('h3-js')]) // Import MapLibre GL CSS await import('maplibre-gl/dist/maplibre-gl.css') let map: MaplibreMap | null = null let popup: MaplibrePopup | null = null // Convert value to HSL color (blue to yellow gradient) function valueToColor(value: number, maxValue: number): string { if (maxValue === 0) return 'hsl(240, 70%, 50%)' const ratio = value / maxValue const hue = 240 - ratio * 180 // 240 (blue) to 60 (yellow) return `hsl(${hue}, 70%, 50%)` } // Convert H3 cell to GeoJSON Feature function h3ToGeoJsonFeature(bucket: H3Bucket, maxTotal: number): Feature { const boundary = h3.cellToBoundary(bucket.key) const center = h3.cellToLatLng(bucket.key) // Convert H3 [lat, lng] to GeoJSON [lng, lat] const coordinates: [number, number][] = boundary.map(([lat, lng]) => [lng, lat]) coordinates.push(coordinates[0]) // Close the ring return { type: 'Feature', properties: { id: bucket.key, event_count: bucket.event_count ?? 0, lfg_count: bucket.lfg_count ?? 0, total: bucket.total ?? 0, centerLat: center[0], centerLng: center[1], color: valueToColor(bucket.total ?? 0, maxTotal), }, geometry: { type: 'Polygon', coordinates: [coordinates], }, } } function initGlobe(): void { map = new maplibregl.Map({ container: containerId, style: { version: 8, projection: { type: 'globe' }, sources: { 'carto-voyager': { type: 'raster', tiles: [ 'https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', ], tileSize: 256, attribution: '© CARTO', }, }, layers: [ { id: 'background', type: 'background', paint: { 'background-color': '#e8e8e8', }, }, { id: 'carto-voyager', type: 'raster', source: 'carto-voyager', }, ], }, center: [-100, 40], zoom: 4, maxZoom: 8, minZoom: 1, }) map.on('load', () => { if (!map) return // Add empty source for hex data map.addSource('hexagons', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) // Add flat fill layer for hexagons map.addLayer({ id: 'hexagons-fill', type: 'fill', source: 'hexagons', paint: { 'fill-color': ['get', 'color'], 'fill-opacity': 0.4, }, }) // Add outline layer map.addLayer({ id: 'hexagons-outline', type: 'line', source: 'hexagons', paint: { 'line-color': '#333', 'line-width': 0.5, 'line-opacity': 0.4, }, }) // Initialize popup popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, }) // Click handler for hexagons map.on('click', 'hexagons-fill', (e: MapMouseEvent & { features?: Feature[] }) => { const features = e.features if (features && features.length > 0) { const props = features[0].properties as Record let content = `Activity: ${props.total} total
` if ((props.event_count as number) > 0) { content += `${props.event_count} event${props.event_count === 1 ? '' : 's'}
` } if ((props.lfg_count as number) > 0) { content += `${props.lfg_count} ${props.lfg_count === 1 ? 'person' : 'people'} LFG` } popup ?.setLngLat([props.centerLng as number, props.centerLat as number]) .setHTML(content) .addTo(map!) } }) // Cursor style map.on('mouseenter', 'hexagons-fill', () => { map!.getCanvas().style.cursor = 'pointer' }) map.on('mouseleave', 'hexagons-fill', () => { map!.getCanvas().style.cursor = '' }) // Fetch globe aggregation data loadGlobeData() }) // Focus button handlers setupFocusButtons() } function setupFocusButtons(): void { document.getElementById('focus-north-america')?.addEventListener('click', () => { map?.flyTo({ center: [-100, 40], zoom: 4, duration: 1500 }) }) document.getElementById('focus-europe')?.addEventListener('click', () => { map?.flyTo({ center: [10, 50], zoom: 4, duration: 1500 }) }) document.getElementById('focus-world')?.addEventListener('click', () => { map?.flyTo({ center: [0, 20], zoom: 2, duration: 1500 }) }) document.getElementById('focus-my-location')?.addEventListener('click', () => { const btn = document.getElementById('focus-my-location') as HTMLButtonElement | null if (!btn) return const originalHtml = btn.innerHTML btn.innerHTML = 'Locating...' btn.disabled = true if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { map?.flyTo({ center: [position.coords.longitude, position.coords.latitude], zoom: 8, duration: 1500, }) btn.innerHTML = originalHtml btn.disabled = false }, (error) => { console.warn('Geolocation failed:', error.message) if (statusEl) statusEl.textContent = 'Could not get your location.' btn.innerHTML = originalHtml btn.disabled = false }, { timeout: 10000, maximumAge: 300000 } ) } else { if (statusEl) statusEl.textContent = 'Geolocation not supported.' btn.innerHTML = originalHtml btn.disabled = false } }) } function loadGlobeData(): void { fetch('/api/globe-aggregation?resolution=5') .then((response) => response.json()) .then((data: { buckets?: H3Bucket[] }) => { if (!data.buckets || data.buckets.length === 0) { if (statusEl) statusEl.textContent = 'No activity found.' return } // Find max total for color scaling const maxTotal = Math.max(...data.buckets.map((b) => b.total ?? 0)) // Convert buckets to GeoJSON features const features: Feature[] = [] data.buckets.forEach((bucket) => { try { features.push(h3ToGeoJsonFeature(bucket, maxTotal)) } catch (e) { console.warn('Failed to process hex:', bucket.key, e) } }) // Update map source const source = map?.getSource('hexagons') as GeoJSONSource | undefined source?.setData({ type: 'FeatureCollection', features: features, }) const totalEvents = data.buckets.reduce((sum, b) => sum + (b.event_count ?? 0), 0) const totalLfg = data.buckets.reduce((sum, b) => sum + (b.lfg_count ?? 0), 0) if (statusEl) { statusEl.textContent = `${data.buckets.length} active regions: ${totalEvents} events, ${totalLfg} people LFG` } }) .catch((err) => { console.error('Failed to load globe aggregation:', err) if (statusEl) statusEl.textContent = 'Failed to load activity data.' }) } // Initialize the globe initGlobe() mapContainer.dataset.mapInitialized = 'true' } catch (err) { console.error('Failed to initialize globe map:', err) if (statusEl) statusEl.textContent = 'Failed to load map.' } finally { mapContainer.dataset.mapInitializing = 'false' } }