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