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)); }