import type { LatLng } from './map.types'; /** * Latitude beyond which the Mercator projection diverges. Web Mercator clamps * here (it is the value that makes the projected world square). */ export const MAX_MERCATOR_LAT = 85.05112878; /** * Mercator's horizontal coordinate: longitude in radians. * * Radians, not degrees — `mercatorY` is a natural log and therefore already in * radian-scale units. Mixing degrees on one axis with log-radians on the other * stretches the map by a factor of about 57. */ export function mercatorX(lng: number): number { return (lng * Math.PI) / 180; } /** Mercator's vertical coordinate. */ export function mercatorY(lat: number): number { const clamped = Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat)); const rad = (clamped * Math.PI) / 180; return Math.log(Math.tan(Math.PI / 4 + rad / 2)); } export interface MapProjection { /** Projects a coordinate to a pixel position inside the container. */ project(point: LatLng): { x: number; y: number }; width: number; height: number; } /** * Builds a Web Mercator projection that fits `points` into a pixel box. * * @description * This is what lets the map plot data with no Google Maps script at all. The * scale is uniform on both axes — the smaller of the horizontal and vertical * fits wins — so the geography keeps its true shape instead of being stretched * to the container. A reference outline drawn through the same projection stays * registered with the points on top of it. * * Returns `null` when there is nothing to fit or the box has no area, which the * caller should treat as "not ready to draw yet". * * @param padding - Pixels of margin kept clear on every side. */ export function createMercatorProjection( points: LatLng[], width: number, height: number, padding = 24 ): MapProjection | null { if (points.length === 0 || width <= 0 || height <= 0) return null; const usableWidth = Math.max(1, width - padding * 2); const usableHeight = Math.max(1, height - padding * 2); const xs = points.map(point => mercatorX(point.lng)); const ys = points.map(point => mercatorY(point.lat)); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const spanX = maxX - minX; const spanY = maxY - minY; /* * A single point — or a perfectly straight line of them — has no extent on at * least one axis. Falling back to a fixed scale centers the data instead of * dividing by zero. */ const scale = spanX === 0 && spanY === 0 ? 1 : Math.min( spanX === 0 ? Infinity : usableWidth / spanX, spanY === 0 ? Infinity : usableHeight / spanY ); const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; // Center whatever is left over after the uniform scale. const offsetX = (width - spanX * safeScale) / 2; const offsetY = (height - spanY * safeScale) / 2; return { width, height, project(point: LatLng) { return { x: (mercatorX(point.lng) - minX) * safeScale + offsetX, // Screen y grows downward; Mercator y grows northward. y: (maxY - mercatorY(point.lat)) * safeScale + offsetY, }; }, }; }