import type { ViewState, Bounds } from '../types'; import type { StyleSpecification } from 'maplibre-gl'; import { layers, namedFlavor } from '@protomaps/basemaps'; import { GLYPHS_URL, SPRITE_URL_BASE, TILE_MAX_ZOOM, OSM_ATTRIBUTION, FEATURE_GRADIENT, DENSITY_GRADIENT, ZOOM_TO_RESOLUTION_THRESHOLDS, TWEMOJI_BASE, } from './consts'; // Re-export constants for backwards compatibility export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts'; export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification { const flavor = namedFlavor(theme); // Use absolute URL for tiles - required by MapLibre const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`; return { version: 8, glyphs: GLYPHS_URL, sprite: `${SPRITE_URL_BASE}/${theme}`, sources: { protomaps: { type: 'vector', tiles: [tileUrl], maxzoom: TILE_MAX_ZOOM, attribution: OSM_ATTRIBUTION, }, }, layers: layers('protomaps', flavor, { lang: 'en' }), } as StyleSpecification; } type GradientStop = { t: number; color: [number, number, number] }; function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] { if (t <= 0) return gradient[0].color; if (t >= 1) return gradient[gradient.length - 1].color; for (let i = 0; i < gradient.length - 1; i++) { const lo = gradient[i]; const hi = gradient[i + 1]; if (t >= lo.t && t <= hi.t) { const frac = (t - lo.t) / (hi.t - lo.t); return [ Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac), Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac), Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac), ]; } } return gradient[gradient.length - 1].color; } export function normalizedToColor(t: number): [number, number, number] { return interpolateGradient(t, FEATURE_GRADIENT); } export function countToColor(t: number): [number, number, number] { return interpolateGradient(t, DENSITY_GRADIENT); } export function zoomToResolution(zoom: number): number { for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) { if (zoom < maxZoom) return resolution; } return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution; } export function getBoundsFromViewState( viewState: ViewState, width: number, height: number ): Bounds { const { longitude, latitude, zoom } = viewState; const clampedLat = Math.max(-85, Math.min(85, latitude)); const TILE_SIZE = 512; const scale = Math.pow(2, zoom); const worldSize = TILE_SIZE * scale; const degreesPerPixelLng = 360 / worldSize; const halfWidthDeg = (width / 2) * degreesPerPixelLng; const latRad = (clampedLat * Math.PI) / 180; const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2; const centerPixelY = mercatorY * worldSize; const topPixelY = centerPixelY - height / 2; const bottomPixelY = centerPixelY + height / 2; const pixelYToLat = (pixelY: number): number => { const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize)); const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY))); return (latRadians * 180) / Math.PI; }; const north = Math.min(85, pixelYToLat(topPixelY)); const south = Math.max(-85, pixelYToLat(bottomPixelY)); const west = Math.max(-180, longitude - halfWidthDeg); const east = Math.min(180, longitude + halfWidthDeg); return { south, west, north, east }; } export function emojiToTwemojiUrl(emoji: string): string { const codePoint = emoji.codePointAt(0); if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; const hex = codePoint.toString(16); return `${TWEMOJI_BASE}${hex}.png`; }