rspace-online/modules/trips/lib/projection.ts

72 lines
2.1 KiB
TypeScript

import type { GeoPoint, Point2D } from "./types";
const DEG_TO_RAD = Math.PI / 180;
const METERS_PER_DEG = 111_320; // at equator
export interface Projection {
toSVG(geo: GeoPoint): Point2D;
toGeo(pt: Point2D): GeoPoint;
viewBox: string;
}
/**
* Create an equirectangular projection centered on the centroid of all points.
* Scales so the bounding box fits ~800 SVG units wide with padding.
*/
export function createProjection(allPoints: GeoPoint[], targetWidth = 800, padding = 40): Projection {
if (allPoints.length === 0) {
return { toSVG: () => ({ x: 0, y: 0 }), toGeo: () => ({ lng: 0, lat: 0 }), viewBox: "0 0 800 600" };
}
// Centroid
const refLat = allPoints.reduce((s, p) => s + p.lat, 0) / allPoints.length;
const refLng = allPoints.reduce((s, p) => s + p.lng, 0) / allPoints.length;
const cosLat = Math.cos(refLat * DEG_TO_RAD);
// Project all points to find bounds (meters from centroid)
const projected = allPoints.map((p) => ({
x: (p.lng - refLng) * cosLat * METERS_PER_DEG,
y: -(p.lat - refLat) * METERS_PER_DEG, // SVG y-down
}));
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of projected) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
const scale = (targetWidth - 2 * padding) / Math.max(rangeX, rangeY);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const svgW = rangeX * scale + 2 * padding;
const svgH = rangeY * scale + 2 * padding;
function toSVG(geo: GeoPoint): Point2D {
const mx = (geo.lng - refLng) * cosLat * METERS_PER_DEG;
const my = -(geo.lat - refLat) * METERS_PER_DEG;
return {
x: (mx - cx) * scale + svgW / 2,
y: (my - cy) * scale + svgH / 2,
};
}
function toGeo(pt: Point2D): GeoPoint {
const mx = (pt.x - svgW / 2) / scale + cx;
const my = (pt.y - svgH / 2) / scale + cy;
return {
lng: mx / (cosLat * METERS_PER_DEG) + refLng,
lat: -(my / METERS_PER_DEG) + refLat,
};
}
return {
toSVG,
toGeo,
viewBox: `0 0 ${Math.round(svgW)} ${Math.round(svgH)}`,
};
}