const TWO_PI = Math.PI * 2; const WGS84_SEMI_MAJOR_METERS = 6_378_137; const WGS84_FLATTENING = 1 / 298.257_223_563; const WGS84_SEMI_MINOR_METERS = WGS84_SEMI_MAJOR_METERS * (1 - WGS84_FLATTENING); // This survey mode is deliberately regional. Radially projecting an // Archimedean spiral onto WGS84 stays within a negligible speed/tangent error // at this radius; a future continental mode needs an ellipsoid-arc // reparameterization instead of silently pretending the flat formula is exact. export const MAX_SPIRAL_RADIUS_METERS = 250_000; export type GeodeticRadians = { longitude: number; latitude: number; }; export type SpiralSurfaceFrame = GeodeticRadians & { angle: number; radiusMeters: number; radialBearing: number; tangentHeading: number; }; export function normalizeRadians(value: number) { const normalized = ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; return normalized === -Math.PI ? Math.PI : normalized; } export function spiralRadiusAtAngle(angle: number, pitchMetersPerTurn: number) { const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid")); const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid"); return (pitch / TWO_PI) * safeAngle; } export function spiralArcLengthAtAngle(angle: number, pitchMetersPerTurn: number) { const safeAngle = Math.max(0, finiteNumber(angle, "spiral_angle_invalid")); const b = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid") / TWO_PI; return (b / 2) * ( safeAngle * Math.sqrt(1 + safeAngle * safeAngle) + Math.asinh(safeAngle) ); } /** * Inverts the Archimedean spiral arc-length function. This keeps the camera's * configured surface speed constant instead of accelerating as the radius * grows. Newton is monotone here; the bounded fallback protects the first * samples near the origin from numerical overshoot. */ export function spiralAngleAtArcLength(surfaceDistanceMeters: number, pitchMetersPerTurn: number) { const distance = Math.max(0, finiteNumber(surfaceDistanceMeters, "spiral_distance_invalid")); const pitch = positiveNumber(pitchMetersPerTurn, "spiral_pitch_invalid"); if (distance === 0) return 0; const b = pitch / TWO_PI; let angle = distance <= b ? distance / b : Math.sqrt((2 * distance) / b); for (let iteration = 0; iteration < 10; iteration += 1) { const error = spiralArcLengthAtAngle(angle, pitch) - distance; if (Math.abs(error) <= Math.max(1e-7, distance * 1e-12)) break; const derivative = b * Math.sqrt(1 + angle * angle); const next = angle - error / derivative; angle = Number.isFinite(next) && next >= 0 ? next : angle / 2; } return angle; } /** * Vincenty's WGS84 direct solution: move from a geodetic point along an * initial bearing by an exact ellipsoid surface distance. Longitude/latitude * are radians. The animation therefore remains stable at datelines and high * latitudes and never treats degrees as a flat Cartesian plane. */ export function directGeodesicDestination( origin: GeodeticRadians, initialBearing: number, distanceMeters: number, ): GeodeticRadians { const latitude1 = finiteNumber(origin.latitude, "spiral_origin_invalid"); const longitude1 = finiteNumber(origin.longitude, "spiral_origin_invalid"); const bearing = finiteNumber(initialBearing, "spiral_bearing_invalid"); const distance = Math.max(0, finiteNumber(distanceMeters, "spiral_distance_invalid")); if (Math.abs(latitude1) > Math.PI / 2 + 1e-12) throw new Error("spiral_origin_invalid"); if (distance === 0) return { longitude: normalizeRadians(longitude1), latitude: latitude1 }; const sinBearing = Math.sin(bearing); const cosBearing = Math.cos(bearing); const tanReducedLatitude1 = (1 - WGS84_FLATTENING) * Math.tan(latitude1); const cosReducedLatitude1 = 1 / Math.sqrt(1 + tanReducedLatitude1 * tanReducedLatitude1); const sinReducedLatitude1 = tanReducedLatitude1 * cosReducedLatitude1; const sigma1 = Math.atan2(tanReducedLatitude1, cosBearing); const sinAlpha = cosReducedLatitude1 * sinBearing; const cosSquaredAlpha = 1 - sinAlpha * sinAlpha; const uSquared = cosSquaredAlpha * (WGS84_SEMI_MAJOR_METERS ** 2 - WGS84_SEMI_MINOR_METERS ** 2) / (WGS84_SEMI_MINOR_METERS ** 2); const coefficientA = 1 + (uSquared / 16_384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared))); const coefficientB = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared))); let sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA); let previousSigma = Number.POSITIVE_INFINITY; let sinSigma = 0; let cosSigma = 1; let cosTwoSigmaMiddle = 0; for (let iteration = 0; iteration < 24 && Math.abs(sigma - previousSigma) > 1e-12; iteration += 1) { cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma); sinSigma = Math.sin(sigma); cosSigma = Math.cos(sigma); const deltaSigma = coefficientB * sinSigma * ( cosTwoSigmaMiddle + (coefficientB / 4) * ( cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2) - (coefficientB / 6) * cosTwoSigmaMiddle * (-3 + 4 * sinSigma ** 2) * (-3 + 4 * cosTwoSigmaMiddle ** 2) ) ); previousSigma = sigma; sigma = distance / (WGS84_SEMI_MINOR_METERS * coefficientA) + deltaSigma; } sinSigma = Math.sin(sigma); cosSigma = Math.cos(sigma); cosTwoSigmaMiddle = Math.cos(2 * sigma1 + sigma); const temporary = sinReducedLatitude1 * sinSigma - cosReducedLatitude1 * cosSigma * cosBearing; const latitude2 = Math.atan2( sinReducedLatitude1 * cosSigma + cosReducedLatitude1 * sinSigma * cosBearing, (1 - WGS84_FLATTENING) * Math.sqrt(sinAlpha ** 2 + temporary ** 2), ); const lambda = Math.atan2( sinSigma * sinBearing, cosReducedLatitude1 * cosSigma - sinReducedLatitude1 * sinSigma * cosBearing, ); const coefficientC = (WGS84_FLATTENING / 16) * cosSquaredAlpha * (4 + WGS84_FLATTENING * (4 - 3 * cosSquaredAlpha)); const longitudeDelta = lambda - (1 - coefficientC) * WGS84_FLATTENING * sinAlpha * ( sigma + coefficientC * sinSigma * ( cosTwoSigmaMiddle + coefficientC * cosSigma * (-1 + 2 * cosTwoSigmaMiddle ** 2) ) ); return { longitude: normalizeRadians(longitude1 + longitudeDelta), latitude: latitude2, }; } export function spiralSurfaceFrame( origin: GeodeticRadians, initialHeading: number, surfaceDistanceMeters: number, pitchMetersPerTurn: number, ): SpiralSurfaceFrame { const angle = spiralAngleAtArcLength(surfaceDistanceMeters, pitchMetersPerTurn); const radiusMeters = spiralRadiusAtAngle(angle, pitchMetersPerTurn); if (radiusMeters > MAX_SPIRAL_RADIUS_METERS) throw new Error("spiral_extent_limit"); const radialBearing = normalizeRadians(initialHeading + angle); const destination = directGeodesicDestination(origin, radialBearing, radiusMeters); // In polar coordinates dr/dθ=b and r=bθ. The path tangent is therefore // rotated atan2(r, dr/dθ)=atan(θ) from the outward radial direction. const tangentHeading = normalizeRadians(radialBearing + Math.atan(angle)); return { ...destination, angle, radiusMeters, radialBearing, tangentHeading, }; } function finiteNumber(value: number, errorCode: string) { if (!Number.isFinite(value)) throw new Error(errorCode); return value; } function positiveNumber(value: number, errorCode: string) { const finite = finiteNumber(value, errorCode); if (finite <= 0) throw new Error(errorCode); return finite; }