Files
gods-eye-view/src/data/trafficBounds.js
T

174 lines
6.4 KiB
JavaScript

/**
* @file Pure geometry helpers for the traffic layer's viewport fetch bounds.
*
* C4 fix (2026-07-16): `camera.computeViewRectangle()` spans toward the horizon
* at oblique pitch, and centering the fetch box on the RECTANGLE MIDPOINT put
* road fetches tens of km from what the user is actually looking at. The fetch
* center is now derived from the camera's look-at ground point
* (`camera.pickEllipsoid` at canvas center — the globe is hidden under Google
* 3D tiles, so `scene.globe.pick` is not reliable), falling back to the camera
* nadir, and pulled back toward nadir when the look-at point is beyond a
* horizon-gaze cap. Kept Cesium-free so it can be unit-tested with node:test.
*
* @module data/trafficBounds
*/
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
/**
* Initial bearing (radians) from point 1 toward point 2 along the great circle.
*
* @param {number} lat1 @param {number} lon1 @param {number} lat2 @param {number} lon2
* @returns {number} Bearing in radians (0 = north, clockwise).
*/
function initialBearingRad(lat1, lon1, lat2, lon2) {
const p1 = toRad(lat1);
const p2 = toRad(lat2);
const dl = toRad(lon2 - lon1);
const y = Math.sin(dl) * Math.cos(p2);
const x =
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
return Math.atan2(y, x);
}
/**
* Destination point given a start, an initial bearing, and a distance
* (spherical direct geodesic).
*
* @param {number} lat - Start latitude (degrees).
* @param {number} lon - Start longitude (degrees).
* @param {number} bearingRad - Initial bearing (radians, 0 = north).
* @param {number} distKm - Distance to travel (km).
* @returns {{lat:number, lon:number}} Destination in degrees.
*/
function destinationPoint(lat, lon, bearingRad, distKm) {
const delta = distKm / EARTH_RADIUS_KM;
const p1 = toRad(lat);
const l1 = toRad(lon);
const p2 = Math.asin(
Math.sin(p1) * Math.cos(delta) +
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
);
const l2 =
l1 +
Math.atan2(
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
);
// Normalize longitude to [-180, 180)
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
return { lat: toDeg(p2), lon: lonDeg };
}
/**
* Derive the road-fetch center from the camera's look-at ground point.
*
* Rules:
* - No usable hit (pickEllipsoid returned undefined → non-finite hit coords):
* fall back to the camera nadir.
* - Hit within `maxPullKm` of nadir: use the hit verbatim (normal oblique view).
* - Hit farther than `maxPullKm` (horizon gaze): pull it back toward nadir to
* exactly `maxPullKm` along the nadir→hit great-circle bearing. Traffic only
* activates below 8 km camera altitude, so a look-at point farther than
* ~12 km is horizon-gazing, not something the user can see roads at.
*
* @param {Object} args
* @param {number} args.nadirLat - Camera nadir latitude (degrees).
* @param {number} args.nadirLon - Camera nadir longitude (degrees).
* @param {number} [args.hitLat] - pickEllipsoid ground-hit latitude (degrees), if any.
* @param {number} [args.hitLon] - pickEllipsoid ground-hit longitude (degrees), if any.
* @param {number} [args.maxPullKm=12] - Max great-circle distance from nadir.
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
* The fetch center and which rule produced it.
*/
export function deriveFetchCenter({
nadirLat,
nadirLon,
hitLat,
hitLon,
maxPullKm = 12,
}) {
if (!isGeoPoint({ lat: hitLat, lon: hitLon })) {
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
}
const distKm = greatCircleKm(
{ lat: nadirLat, lon: nadirLon },
{ lat: hitLat, lon: hitLon },
);
if (distKm <= maxPullKm) {
return { lat: hitLat, lon: hitLon, source: 'hit' };
}
const bearing = initialBearingRad(nadirLat, nadirLon, hitLat, hitLon);
const pulled = destinationPoint(nadirLat, nadirLon, bearing, maxPullKm);
return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' };
}
/**
* Wrapped longitude span between two meridians, in degrees.
*
* Longitude is cyclic, so a box crossing the antimeridian (±180°) has
* `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west`
* goes negative in that case; wrap through 360° instead.
*
* @param {number} west - West edge longitude (degrees).
* @param {number} east - East edge longitude (degrees).
* @returns {number} Span in degrees, always >= 0.
*/
function longitudeSpanDeg(west, east) {
const raw = east - west;
return raw >= 0 ? raw : raw + 360;
}
/** Keep a monotonic longitude range inside the traffic source's legal domain. */
function boundedLongitudeRange(center, span) {
let west = center - span / 2;
let east = center + span / 2;
if (east > 180) {
west -= east - 180;
east = 180;
}
if (west < -180) {
east += -180 - west;
west = -180;
}
return { west, east };
}
/**
* Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`.
*
* Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km)
* but centers the box on the derived look-at point instead of the view
* rectangle's midpoint. At the antimeridian the whole box shifts just far
* enough to remain inside [-180, 180], because the road source accepts one
* monotonic Overpass bounding box. Idempotent when `center` is the box's own
* midpoint.
*
* Longitude is handled as a cyclic axis for the span calculation, so the
* input span wraps correctly across the antimeridian. Output remains
* monotonic because callers such as `getBoundsCenter` and `boundsOverlap`
* rely on `west <= east`.
*
* @param {{south:number, west:number, north:number, east:number}} bounds
* Source bounds (span donor).
* @param {{lat:number, lon:number}} center - Fetch center (degrees).
* @param {number} [maxSpanDeg=0.05] - Max span per axis in degrees.
* @returns {{south:number, west:number, north:number, east:number}} Clamped bounds.
*/
export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) {
const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg);
const lonSpan = Math.min(
longitudeSpanDeg(bounds.west, bounds.east),
maxSpanDeg,
);
const longitude = boundedLongitudeRange(center.lon, lonSpan);
return {
south: center.lat - latSpan / 2,
north: center.lat + latSpan / 2,
...longitude,
};
}