72 lines
2.1 KiB
TypeScript
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)}`,
|
|
};
|
|
}
|