113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
import type { MapGeoPoint, MapGhostPin } from "./contracts.js";
|
|
|
|
const EARTH_RADIUS_METERS = 6_371_008.8;
|
|
|
|
export function advanceGhostPins(
|
|
pins: readonly MapGhostPin[],
|
|
anchor: MapGeoPoint | null,
|
|
radiusMeters: number,
|
|
elapsedSeconds: number,
|
|
): MapGhostPin[] {
|
|
const safeElapsed = clamp(elapsedSeconds, 0, 1);
|
|
const safeRadius = Math.max(1, radiusMeters);
|
|
if (safeElapsed === 0) {
|
|
return pins.map((pin) => ({ ...pin }));
|
|
}
|
|
return pins.map((pin) => {
|
|
let heading = normalizeHeading(pin.heading_degrees);
|
|
if (anchor) {
|
|
const distanceFromAnchor = geodesicDistanceMeters(anchor, pin);
|
|
const projectedDistance =
|
|
distanceFromAnchor + Math.max(0, pin.speed_meters_per_second) * safeElapsed;
|
|
if (projectedDistance >= safeRadius * 0.98) {
|
|
heading = initialBearingDegrees(pin, anchor);
|
|
}
|
|
}
|
|
const position = destinationPoint(
|
|
pin,
|
|
heading,
|
|
Math.max(0, pin.speed_meters_per_second) * safeElapsed,
|
|
);
|
|
return {
|
|
...pin,
|
|
...position,
|
|
heading_degrees: heading,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function destinationPoint(
|
|
origin: MapGeoPoint,
|
|
headingDegrees: number,
|
|
distanceMeters: number,
|
|
): MapGeoPoint {
|
|
const angularDistance = Math.max(0, distanceMeters) / EARTH_RADIUS_METERS;
|
|
const bearing = toRadians(normalizeHeading(headingDegrees));
|
|
const latitude = toRadians(origin.latitude);
|
|
const longitude = toRadians(origin.longitude);
|
|
const nextLatitude = Math.asin(
|
|
Math.sin(latitude) * Math.cos(angularDistance)
|
|
+ Math.cos(latitude) * Math.sin(angularDistance) * Math.cos(bearing),
|
|
);
|
|
const nextLongitude = longitude + Math.atan2(
|
|
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitude),
|
|
Math.cos(angularDistance) - Math.sin(latitude) * Math.sin(nextLatitude),
|
|
);
|
|
return {
|
|
longitude: normalizeLongitude(toDegrees(nextLongitude)),
|
|
latitude: clamp(toDegrees(nextLatitude), -90, 90),
|
|
};
|
|
}
|
|
|
|
export function geodesicDistanceMeters(
|
|
first: MapGeoPoint,
|
|
second: MapGeoPoint,
|
|
): number {
|
|
const latitudeDelta = toRadians(second.latitude - first.latitude);
|
|
const longitudeDelta = toRadians(second.longitude - first.longitude);
|
|
const firstLatitude = toRadians(first.latitude);
|
|
const secondLatitude = toRadians(second.latitude);
|
|
const haversine =
|
|
Math.sin(latitudeDelta / 2) ** 2
|
|
+ Math.cos(firstLatitude)
|
|
* Math.cos(secondLatitude)
|
|
* Math.sin(longitudeDelta / 2) ** 2;
|
|
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(haversine)));
|
|
}
|
|
|
|
function initialBearingDegrees(
|
|
origin: MapGeoPoint,
|
|
destination: MapGeoPoint,
|
|
): number {
|
|
const firstLatitude = toRadians(origin.latitude);
|
|
const secondLatitude = toRadians(destination.latitude);
|
|
const longitudeDelta = toRadians(destination.longitude - origin.longitude);
|
|
const y = Math.sin(longitudeDelta) * Math.cos(secondLatitude);
|
|
const x =
|
|
Math.cos(firstLatitude) * Math.sin(secondLatitude)
|
|
- Math.sin(firstLatitude)
|
|
* Math.cos(secondLatitude)
|
|
* Math.cos(longitudeDelta);
|
|
return normalizeHeading(toDegrees(Math.atan2(y, x)));
|
|
}
|
|
|
|
function normalizeHeading(value: number): number {
|
|
return ((value % 360) + 360) % 360;
|
|
}
|
|
|
|
function normalizeLongitude(value: number): number {
|
|
return ((value + 540) % 360) - 180;
|
|
}
|
|
|
|
function toRadians(value: number): number {
|
|
return value * Math.PI / 180;
|
|
}
|
|
|
|
function toDegrees(value: number): number {
|
|
return value * 180 / Math.PI;
|
|
}
|
|
|
|
function clamp(value: number, minimum: number, maximum: number): number {
|
|
return Math.min(maximum, Math.max(minimum, value));
|
|
}
|