perfect-postcode/frontend/src/lib/map-utils.ts

114 lines
3.7 KiB
TypeScript

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`;
}