import type { ViewState, Bounds } from '../types'; import type { StyleSpecification } from 'maplibre-gl'; import { layers, namedFlavor } from '@protomaps/basemaps'; import { GLYPHS_URL, FEATURE_GRADIENT, DENSITY_GRADIENT, ZOOM_TO_RESOLUTION_THRESHOLDS, TWEMOJI_BASE, BUFFER_MULTIPLIER, } from './consts'; // Re-export constants for backwards compatibility export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, POSTCODE_ZOOM_THRESHOLD, } from './consts'; const ROAD_OPACITY = 0.4; 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}`; const baseLayers = layers('protomaps', flavor, { lang: 'en' }); const isDark = theme === 'dark'; // Reduce road layer opacity so hexagons are more visible // In dark mode, make all text white with dark outline const modifiedLayers = baseLayers .filter((layer) => !layer.id.includes('buildings')) .map((layer) => { // Modify road opacity if (layer.id.includes('roads_') || layer.id.includes('road_')) { if (layer.type === 'line') { return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } }; } else if (layer.type === 'fill') { return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } }; } } // Modify text colors in dark mode if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) { return { ...layer, paint: { ...layer.paint, 'text-color': '#ffffff', 'text-halo-color': '#1a1a1a', 'text-halo-width': 1.5, }, }; } return layer; }); return { version: 8, glyphs: GLYPHS_URL, sources: { protomaps: { type: 'vector', tiles: [tileUrl], maxzoom: 15, }, }, layers: modifiedLayers, } as StyleSpecification; } type GradientStop = { t: number; color: [number, number, number] }; // Oklab color space for perceptually uniform interpolation function srgbToLinear(c: number): number { const v = c / 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); } function linearToSrgb(c: number): number { const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; return Math.round(Math.max(0, Math.min(255, v * 255))); } function rgbToOklab(rgb: [number, number, number]): [number, number, number] { const r = srgbToLinear(rgb[0]); const g = srgbToLinear(rgb[1]); const b = srgbToLinear(rgb[2]); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, ]; } function oklabToRgb(lab: [number, number, number]): [number, number, number] { const L = lab[0], a = lab[1], b = lab[2]; const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3); const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3); const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3); return [ linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), ]; } 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); const loLab = rgbToOklab(lo.color); const hiLab = rgbToOklab(hi.color); const interpLab: [number, number, number] = [ loLab[0] + (hiLab[0] - loLab[0]) * frac, loLab[1] + (hiLab[1] - loLab[1]) * frac, loLab[2] + (hiLab[2] - loLab[2]) * frac, ]; return oklabToRgb(interpLab); } } return gradient[gradient.length - 1].color; } export function normalizedToColor(t: number): [number, number, number] { return interpolateGradient(t, FEATURE_GRADIENT); } export function countToColor( t: number, gradient: GradientStop[] = DENSITY_GRADIENT ): [number, number, number] { return interpolateGradient(t, 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 bufferedWidth = width * BUFFER_MULTIPLIER; const bufferedHeight = height * BUFFER_MULTIPLIER; const degreesPerPixelLng = 360 / worldSize; const halfWidthDeg = (bufferedWidth / 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 - bufferedHeight / 2; const bottomPixelY = centerPixelY + bufferedHeight / 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`; }