mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
174 lines
6.4 KiB
JavaScript
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,
|
|
};
|
|
}
|