src/lib/geo-distance.ts
Rama main · réplica de lectura del repositorio original.
// How far apart two recorded positions are, and how to say it.
//
// This exists because the read-only map cannot show it. That map is capped at
// zoom 10 (see DECISIONS.md), where a hundred metres is less than a pixel, so
// an entry and an exit fix a swim apart are drawn as one dot. The distance is
// real and worth seeing, so it is text.
import { FEET_PER_MILE, METERS_PER_FOOT, type UnitSystem } from "@/lib/units";
// A position, in the same two field names everything else in the app uses.
export interface GeoPoint {
latitude: number;
longitude: number;
}
// Mean Earth radius (IUGG), in metres. A sphere, not the WGS-84 ellipsoid:
// the error is a few tenths of a percent, which is nothing beside the tens of
// metres a dive computer's own fix is out by after a swim under water.
const EARTH_RADIUS_M = 6371008.8;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
/**
* Great-circle distance between two positions, in metres.
*
* Haversine, which needs no special case for the antimeridian: it works on the
* *difference* between the longitudes, so 179.9999°E to 179.9999°W is the 22 m
* it looks like on a globe rather than the 40,000 km the two numbers suggest.
*/
export function haversineMeters(a: GeoPoint, b: GeoPoint): number {
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const deltaLat = lat2 - lat1;
const deltaLon = toRadians(b.longitude - a.longitude);
const h =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}
/**
* A distance for display: whole metres up to a kilometre, then one decimal -
* or whole feet up to a mile, then one decimal, for a diver reading in imperial.
*
* No decimals on the small unit, deliberately - a consumer GPS fix is good to
* something like five metres, and "212.4 m" claims a precision the reading
* never had. Past the big unit's threshold the tenth is back, because "1 km" and
* "1.9 km" are a real difference to anyone reading where their drift took them,
* and from a hundred of the big unit it goes again: a site "2345.0 km away" is
* the finder claiming a precision about a distance nobody swims.
*
* Lives here rather than in `lib/units.ts` with the other formatters: the
* kilometre threshold and the tests behind it are this module's.
*/
export function formatDistance(meters: number, units: UnitSystem): string {
if (units === "imperial") {
const feet = meters / METERS_PER_FOOT;
const wholeFeet = Math.round(feet);
if (wholeFeet < FEET_PER_MILE) return `${wholeFeet} ft`;
return bigUnit(feet / FEET_PER_MILE, "mi");
}
// Rounded before the comparison, not after: 999.6 m is a thousand metres once
// the decimals are gone, and "1000 m" beside a "1.0 km" a millimetre further
// on would look like two different units for the same distance.
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return `${wholeMeters} m`;
return bigUnit(meters / 1000, "km");
}
const WHOLE_FROM = 100;
function bigUnit(value: number, unit: string): string {
const tenths = value.toFixed(1);
return Number(tenths) < WHOLE_FROM
? `${tenths} ${unit}`
: `${Math.round(value)} ${unit}`;
}