The smokesignal.events web application
at main 277 lines 6.9 kB view raw
1/** 2 * Map Utilities 3 * 4 * Shared utilities for MapLibre GL maps with H3 hexagon visualization. 5 * Handles coordinate conversion between H3 (lat, lng) and GeoJSON (lng, lat). 6 */ 7 8import type { Feature, FeatureCollection, Polygon } from 'geojson' 9import type { 10 GeoJSONSource, 11 LngLatBoundsLike, 12 Map as MaplibreMap, 13 MapOptions, 14 StyleSpecification, 15} from 'maplibre-gl' 16import type * as h3Lib from 'h3-js' 17 18// Module-level cache for lazy-loaded libraries 19let maplibreModule: typeof import('maplibre-gl') | null = null 20let h3Module: typeof h3Lib | null = null 21 22export interface MapLibraries { 23 maplibregl: typeof import('maplibre-gl') 24 h3: typeof h3Lib 25} 26 27/** 28 * Lazy load MapLibre GL and H3 libraries 29 */ 30export async function loadMapLibraries(): Promise<MapLibraries> { 31 if (!maplibreModule || !h3Module) { 32 const [maplibre, h3] = await Promise.all([import('maplibre-gl'), import('h3-js')]) 33 34 // Import MapLibre GL CSS 35 await import('maplibre-gl/dist/maplibre-gl.css') 36 37 maplibreModule = maplibre 38 h3Module = h3 39 } 40 41 return { maplibregl: maplibreModule, h3: h3Module } 42} 43 44/** 45 * Get the cached H3 module (must call loadMapLibraries first) 46 */ 47export function getH3Module(): typeof h3Lib | null { 48 return h3Module 49} 50 51/** 52 * Base style for flat maps (non-globe, OSM tiles) 53 */ 54export const flatMapStyle: StyleSpecification = { 55 version: 8, 56 sources: { 57 osm: { 58 type: 'raster', 59 tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], 60 tileSize: 256, 61 attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', 62 maxzoom: 19, 63 }, 64 }, 65 layers: [ 66 { 67 id: 'osm-tiles', 68 type: 'raster', 69 source: 'osm', 70 }, 71 ], 72} 73 74export interface CreateMapOptions { 75 container: string | HTMLElement 76 center?: [number, number] // [lng, lat] 77 zoom?: number 78 interactive?: boolean 79 scrollZoom?: boolean 80 maxZoom?: number 81 minZoom?: number 82} 83 84/** 85 * Create a standard MapLibre map with OSM tiles 86 */ 87export function createMap( 88 maplibregl: typeof import('maplibre-gl'), 89 options: CreateMapOptions 90): MaplibreMap { 91 const { container, center = [0, 0], zoom = 12, interactive = true, scrollZoom = false } = options 92 93 const mapOptions: MapOptions = { 94 container, 95 style: flatMapStyle, 96 center, 97 zoom, 98 maxZoom: options.maxZoom, 99 minZoom: options.minZoom, 100 } 101 102 if (!interactive) { 103 mapOptions.interactive = false 104 // Attribution control is shown by default when not set to false 105 } else { 106 // For interactive maps, disable scroll zoom by default 107 mapOptions.scrollZoom = scrollZoom 108 } 109 110 return new maplibregl.Map(mapOptions) 111} 112 113export interface HexFeatureProperties { 114 id: string 115 fillColor: string 116 fillOpacity: number 117 strokeColor: string 118 strokeWidth: number 119 [key: string]: unknown 120} 121 122/** 123 * Convert H3 cell to GeoJSON Feature 124 * Handles coordinate order conversion from H3 [lat, lng] to GeoJSON [lng, lat] 125 */ 126export function h3ToGeoJsonFeature( 127 h3: typeof h3Lib, 128 h3Index: string, 129 properties: Partial<HexFeatureProperties> = {} 130): Feature<Polygon> { 131 const boundary = h3.cellToBoundary(h3Index) 132 133 // Convert H3 [lat, lng] to GeoJSON [lng, lat] 134 const coordinates: [number, number][] = boundary.map(([lat, lng]) => [lng, lat]) 135 coordinates.push(coordinates[0]) // Close the ring 136 137 return { 138 type: 'Feature', 139 properties: { 140 id: h3Index, 141 fillColor: properties.fillColor ?? '#3273dc', 142 fillOpacity: properties.fillOpacity ?? 0.3, 143 strokeColor: properties.strokeColor ?? '#3273dc', 144 strokeWidth: properties.strokeWidth ?? 2, 145 ...properties, 146 }, 147 geometry: { 148 type: 'Polygon', 149 coordinates: [coordinates], 150 }, 151 } 152} 153 154/** 155 * Add hexagon layers to the map (fill + outline) 156 * Uses data-driven styling from feature properties 157 */ 158export function addHexagonLayers(map: MaplibreMap, sourceId = 'hexagons'): void { 159 // Add empty GeoJSON source 160 map.addSource(sourceId, { 161 type: 'geojson', 162 data: { type: 'FeatureCollection', features: [] }, 163 }) 164 165 // Add fill layer 166 map.addLayer({ 167 id: `${sourceId}-fill`, 168 type: 'fill', 169 source: sourceId, 170 paint: { 171 'fill-color': ['get', 'fillColor'], 172 'fill-opacity': ['get', 'fillOpacity'], 173 }, 174 }) 175 176 // Add outline layer 177 map.addLayer({ 178 id: `${sourceId}-outline`, 179 type: 'line', 180 source: sourceId, 181 paint: { 182 'line-color': ['get', 'strokeColor'], 183 'line-width': ['get', 'strokeWidth'], 184 }, 185 }) 186} 187 188/** 189 * Update the hexagon source data 190 */ 191export function updateHexagonSource( 192 map: MaplibreMap, 193 features: Feature<Polygon>[], 194 sourceId = 'hexagons' 195): void { 196 const source = map.getSource(sourceId) as GeoJSONSource | undefined 197 if (source) { 198 const data: FeatureCollection<Polygon> = { 199 type: 'FeatureCollection', 200 features, 201 } 202 source.setData(data) 203 } 204} 205 206/** 207 * Calculate bounds from GeoJSON features 208 * Returns bounds in [sw, ne] format: [[minLng, minLat], [maxLng, maxLat]] 209 */ 210export function calculateBounds(features: Feature<Polygon>[]): LngLatBoundsLike | null { 211 if (features.length === 0) return null 212 213 let minLng = Infinity 214 let minLat = Infinity 215 let maxLng = -Infinity 216 let maxLat = -Infinity 217 218 for (const feature of features) { 219 const coords = feature.geometry.coordinates[0] 220 for (const [lng, lat] of coords) { 221 if (lng < minLng) minLng = lng 222 if (lng > maxLng) maxLng = lng 223 if (lat < minLat) minLat = lat 224 if (lat > maxLat) maxLat = lat 225 } 226 } 227 228 return [ 229 [minLng, minLat], 230 [maxLng, maxLat], 231 ] 232} 233 234/** 235 * Linear interpolation between two hex colors 236 */ 237export function lerpColor(a: string, b: string, t: number): string { 238 const ah = parseInt(a.replace('#', ''), 16) 239 const bh = parseInt(b.replace('#', ''), 16) 240 const ar = ah >> 16, 241 ag = (ah >> 8) & 0xff, 242 ab = ah & 0xff 243 const br = bh >> 16, 244 bg = (bh >> 8) & 0xff, 245 bb = bh & 0xff 246 const rr = Math.round(ar + (br - ar) * t) 247 const rg = Math.round(ag + (bg - ag) * t) 248 const rb = Math.round(ab + (bb - ab) * t) 249 return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb).toString(16).slice(1) 250} 251 252/** 253 * Get heatmap gradient color based on value 254 * Blue (#3273dc) -> Purple (#8957e5) -> Orange (#f39c12) -> Red (#e74c3c) 255 */ 256export function heatmapGradientColor(value: number, min: number, max: number): string { 257 if (max === min) return '#3273dc' 258 const ratio = (value - min) / (max - min) 259 260 if (ratio < 0.33) { 261 const t = ratio / 0.33 262 return lerpColor('#3273dc', '#8957e5', t) 263 } else if (ratio < 0.66) { 264 const t = (ratio - 0.33) / 0.33 265 return lerpColor('#8957e5', '#f39c12', t) 266 } else { 267 const t = (ratio - 0.66) / 0.34 268 return lerpColor('#f39c12', '#e74c3c', t) 269 } 270} 271 272/** 273 * Convert lat/lng to MapLibre center format [lng, lat] 274 */ 275export function toMapCenter(lat: number, lng: number): [number, number] { 276 return [lng, lat] 277}