/** * Map Utilities * * Shared utilities for MapLibre GL maps with H3 hexagon visualization. * Handles coordinate conversion between H3 (lat, lng) and GeoJSON (lng, lat). */ import type { Feature, FeatureCollection, Polygon } from 'geojson' import type { GeoJSONSource, LngLatBoundsLike, Map as MaplibreMap, MapOptions, StyleSpecification, } from 'maplibre-gl' import type * as h3Lib from 'h3-js' // Module-level cache for lazy-loaded libraries let maplibreModule: typeof import('maplibre-gl') | null = null let h3Module: typeof h3Lib | null = null export interface MapLibraries { maplibregl: typeof import('maplibre-gl') h3: typeof h3Lib } /** * Lazy load MapLibre GL and H3 libraries */ export async function loadMapLibraries(): Promise { if (!maplibreModule || !h3Module) { const [maplibre, h3] = await Promise.all([import('maplibre-gl'), import('h3-js')]) // Import MapLibre GL CSS await import('maplibre-gl/dist/maplibre-gl.css') maplibreModule = maplibre h3Module = h3 } return { maplibregl: maplibreModule, h3: h3Module } } /** * Get the cached H3 module (must call loadMapLibraries first) */ export function getH3Module(): typeof h3Lib | null { return h3Module } /** * Base style for flat maps (non-globe, OSM tiles) */ export const flatMapStyle: StyleSpecification = { version: 8, sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap', maxzoom: 19, }, }, layers: [ { id: 'osm-tiles', type: 'raster', source: 'osm', }, ], } export interface CreateMapOptions { container: string | HTMLElement center?: [number, number] // [lng, lat] zoom?: number interactive?: boolean scrollZoom?: boolean maxZoom?: number minZoom?: number } /** * Create a standard MapLibre map with OSM tiles */ export function createMap( maplibregl: typeof import('maplibre-gl'), options: CreateMapOptions ): MaplibreMap { const { container, center = [0, 0], zoom = 12, interactive = true, scrollZoom = false } = options const mapOptions: MapOptions = { container, style: flatMapStyle, center, zoom, maxZoom: options.maxZoom, minZoom: options.minZoom, } if (!interactive) { mapOptions.interactive = false // Attribution control is shown by default when not set to false } else { // For interactive maps, disable scroll zoom by default mapOptions.scrollZoom = scrollZoom } return new maplibregl.Map(mapOptions) } export interface HexFeatureProperties { id: string fillColor: string fillOpacity: number strokeColor: string strokeWidth: number [key: string]: unknown } /** * Convert H3 cell to GeoJSON Feature * Handles coordinate order conversion from H3 [lat, lng] to GeoJSON [lng, lat] */ export function h3ToGeoJsonFeature( h3: typeof h3Lib, h3Index: string, properties: Partial = {} ): Feature { const boundary = h3.cellToBoundary(h3Index) // 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: h3Index, fillColor: properties.fillColor ?? '#3273dc', fillOpacity: properties.fillOpacity ?? 0.3, strokeColor: properties.strokeColor ?? '#3273dc', strokeWidth: properties.strokeWidth ?? 2, ...properties, }, geometry: { type: 'Polygon', coordinates: [coordinates], }, } } /** * Add hexagon layers to the map (fill + outline) * Uses data-driven styling from feature properties */ export function addHexagonLayers(map: MaplibreMap, sourceId = 'hexagons'): void { // Add empty GeoJSON source map.addSource(sourceId, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) // Add fill layer map.addLayer({ id: `${sourceId}-fill`, type: 'fill', source: sourceId, paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': ['get', 'fillOpacity'], }, }) // Add outline layer map.addLayer({ id: `${sourceId}-outline`, type: 'line', source: sourceId, paint: { 'line-color': ['get', 'strokeColor'], 'line-width': ['get', 'strokeWidth'], }, }) } /** * Update the hexagon source data */ export function updateHexagonSource( map: MaplibreMap, features: Feature[], sourceId = 'hexagons' ): void { const source = map.getSource(sourceId) as GeoJSONSource | undefined if (source) { const data: FeatureCollection = { type: 'FeatureCollection', features, } source.setData(data) } } /** * Calculate bounds from GeoJSON features * Returns bounds in [sw, ne] format: [[minLng, minLat], [maxLng, maxLat]] */ export function calculateBounds(features: Feature[]): LngLatBoundsLike | null { if (features.length === 0) return null let minLng = Infinity let minLat = Infinity let maxLng = -Infinity let maxLat = -Infinity for (const feature of features) { const coords = feature.geometry.coordinates[0] for (const [lng, lat] of coords) { if (lng < minLng) minLng = lng if (lng > maxLng) maxLng = lng if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } } return [ [minLng, minLat], [maxLng, maxLat], ] } /** * Linear interpolation between two hex colors */ export function lerpColor(a: string, b: string, t: number): string { const ah = parseInt(a.replace('#', ''), 16) const bh = parseInt(b.replace('#', ''), 16) const ar = ah >> 16, ag = (ah >> 8) & 0xff, ab = ah & 0xff const br = bh >> 16, bg = (bh >> 8) & 0xff, bb = bh & 0xff const rr = Math.round(ar + (br - ar) * t) const rg = Math.round(ag + (bg - ag) * t) const rb = Math.round(ab + (bb - ab) * t) return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb).toString(16).slice(1) } /** * Get heatmap gradient color based on value * Blue (#3273dc) -> Purple (#8957e5) -> Orange (#f39c12) -> Red (#e74c3c) */ export function heatmapGradientColor(value: number, min: number, max: number): string { if (max === min) return '#3273dc' const ratio = (value - min) / (max - min) if (ratio < 0.33) { const t = ratio / 0.33 return lerpColor('#3273dc', '#8957e5', t) } else if (ratio < 0.66) { const t = (ratio - 0.33) / 0.33 return lerpColor('#8957e5', '#f39c12', t) } else { const t = (ratio - 0.66) / 0.34 return lerpColor('#f39c12', '#e74c3c', t) } } /** * Convert lat/lng to MapLibre center format [lng, lat] */ export function toMapCenter(lat: number, lng: number): [number, number] { return [lng, lat] }