Files
NODEDC_DESIGN_GUIDELINE/apps/catalog/src/mapSpiralMath.ts
T

181 lines
7.3 KiB
TypeScript

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