mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Rebase #1142 onto current main, keeping both sides of the changelog and runtime documentation conflicts. Retain unwrapped containment and circular query coordinates. Address review feedback on perimeterAnchorDegrees: use circular longitude for the selected outer ring, excluding its closing duplicate. Compare multipart areas along short longitude edges so a small dateline part cannot steal the card anchor from a larger polygon. Three new card regressions fail before the fix and pass afterward. Coverage includes reversed/rotated rings, vertices at +/-180 and both multipart selection directions. Local validation: 43 focused tests; full suite 7,636 passed, one skipped, zero failures; production build, formatting, module boundaries and whitespace checks pass on Node 24.19.0. Browser tracking validation follows on the QA branch.
375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
/**
|
||
* The `area` argument shared by location-scoped tools: a bounding box, a
|
||
* center point with radius, or a place name resolved through the `places`
|
||
* service.
|
||
*/
|
||
|
||
import { ToolError } from './catalog.js';
|
||
|
||
const EARTH_RADIUS_KM = 6371.0088;
|
||
const MAX_RADIUS_KM = 2000;
|
||
|
||
/** JSON Schema for the `area` argument. */
|
||
export const AREA_SCHEMA = Object.freeze({
|
||
type: 'object',
|
||
description:
|
||
'Where to look. Give exactly one of: place (a name such as "Taiwan" or ' +
|
||
'"San Francisco Bay"), bbox ([west, south, east, north] in degrees), or ' +
|
||
'lat and lon with radius_km.',
|
||
properties: {
|
||
place: { type: 'string', minLength: 1, maxLength: 200 },
|
||
bbox: {
|
||
type: 'array',
|
||
items: { type: 'number' },
|
||
minItems: 4,
|
||
maxItems: 4,
|
||
},
|
||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||
radius_km: { type: 'number', minimum: 0.1, maximum: MAX_RADIUS_KM },
|
||
},
|
||
additionalProperties: false,
|
||
});
|
||
|
||
/**
|
||
* Resolve an `area` argument to `{ label, west, south, east, north, center? }`.
|
||
* A box whose west edge exceeds its east edge crosses the antimeridian.
|
||
*/
|
||
export async function resolveArea(area, { services, signal } = {}) {
|
||
const given = ['place', 'bbox', 'lat'].filter((key) => area?.[key] != null);
|
||
if (given.length !== 1)
|
||
throw new ToolError(
|
||
'invalid_arguments',
|
||
'area needs exactly one of place, bbox, or lat/lon with radius_km',
|
||
);
|
||
if (area.bbox) {
|
||
const [west, south, east, north] = area.bbox;
|
||
if (
|
||
[west, east].some((value) => Math.abs(value) > 180) ||
|
||
[south, north].some((value) => Math.abs(value) > 90) ||
|
||
south >= north
|
||
)
|
||
throw new ToolError(
|
||
'invalid_arguments',
|
||
'area.bbox must be [west, south, east, north] with south below north',
|
||
);
|
||
return { label: 'the requested box', west, south, east, north };
|
||
}
|
||
if (area.lat != null) {
|
||
if (area.lon == null || area.radius_km == null)
|
||
throw new ToolError(
|
||
'invalid_arguments',
|
||
'area with lat also needs lon and radius_km',
|
||
);
|
||
return circleArea(area.lat, area.lon, area.radius_km);
|
||
}
|
||
const places = services?.places;
|
||
if (!places)
|
||
throw new ToolError(
|
||
'unsupported',
|
||
'Place names cannot be resolved here; give a bbox or lat/lon instead',
|
||
);
|
||
const place = await places.resolve(area.place, { signal });
|
||
if (!place)
|
||
throw new ToolError(
|
||
'invalid_arguments',
|
||
`No place matched "${area.place}"; try a more specific name or a bbox`,
|
||
);
|
||
return {
|
||
label: place.name || area.place,
|
||
...place.bounds,
|
||
...(place.point ? { point: place.point } : {}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The bounding box of a spherical cap. When the cap reaches a pole it spans
|
||
* every longitude; otherwise the longitude span is the exact tangent bound,
|
||
* asin(sin(d) / cos(lat)) for angular radius d.
|
||
*/
|
||
function circleArea(lat, lon, radiusKm) {
|
||
const angular = radiusKm / EARTH_RADIUS_KM;
|
||
const latSpan = angular * (180 / Math.PI);
|
||
const reachesPole = lat + latSpan >= 90 || lat - latSpan <= -90;
|
||
const ratio = Math.sin(angular) / Math.cos((lat * Math.PI) / 180);
|
||
const lonSpan =
|
||
reachesPole || ratio >= 1 ? 180 : Math.asin(ratio) * (180 / Math.PI);
|
||
const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180;
|
||
return {
|
||
label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`,
|
||
west: lonSpan >= 180 ? -180 : wrap(lon - lonSpan),
|
||
east: lonSpan >= 180 ? 180 : wrap(lon + lonSpan),
|
||
south: Math.max(-90, lat - latSpan),
|
||
north: Math.min(90, lat + latSpan),
|
||
center: { lat, lon, radiusKm },
|
||
};
|
||
}
|
||
|
||
/** JSON Schema for a single location: a place name, or lat and lon. */
|
||
export const POINT_SCHEMA = Object.freeze({
|
||
type: 'object',
|
||
description: 'A place name, or lat and lon.',
|
||
properties: {
|
||
place: { type: 'string', minLength: 1, maxLength: 200 },
|
||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||
},
|
||
additionalProperties: false,
|
||
});
|
||
|
||
/** Resolve a point argument to `{ label, lat, lon }`. */
|
||
export async function resolvePoint(point, { services, signal } = {}) {
|
||
if (point?.place != null && point.lat == null && point.lon == null) {
|
||
const area = await resolveArea(
|
||
{ place: point.place },
|
||
{ services, signal },
|
||
);
|
||
return { label: area.label, ...areaCenter(area) };
|
||
}
|
||
if (point?.place == null && point?.lat != null && point?.lon != null)
|
||
return {
|
||
label: `${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
|
||
lat: point.lat,
|
||
lon: point.lon,
|
||
};
|
||
throw new ToolError(
|
||
'invalid_arguments',
|
||
'A location needs either place, or lat and lon',
|
||
);
|
||
}
|
||
|
||
/** Distance in kilometers from an area's center to its farthest edge point. */
|
||
export function areaRadiusKm(area) {
|
||
if (area.center) return area.center.radiusKm;
|
||
const center = areaCenter(area);
|
||
// A box holding the point opposite its center reaches half the globe.
|
||
const antipode = {
|
||
lat: -center.lat,
|
||
lon: center.lon > 0 ? center.lon - 180 : center.lon + 180,
|
||
};
|
||
if (areaContains(area, antipode)) return Math.PI * EARTH_RADIUS_KM;
|
||
// Otherwise the farthest point is on the boundary: sample each edge, and
|
||
// include where each parallel edge comes nearest the antipode's longitude.
|
||
const width =
|
||
area.west <= area.east
|
||
? area.east - area.west
|
||
: 360 - (area.west - area.east);
|
||
const lonAt = (t) => area.west + width * t;
|
||
const steps = 64;
|
||
const points = [];
|
||
for (let step = 0; step <= steps; step += 1) {
|
||
const t = step / steps;
|
||
const lat = area.south + (area.north - area.south) * t;
|
||
points.push(
|
||
{ lat, lon: area.west },
|
||
{ lat, lon: area.east },
|
||
{ lat: area.north, lon: lonAt(t) },
|
||
{ lat: area.south, lon: lonAt(t) },
|
||
);
|
||
}
|
||
const offset = (((antipode.lon - area.west) % 360) + 360) % 360;
|
||
if (offset <= width)
|
||
points.push(
|
||
{ lat: area.north, lon: antipode.lon },
|
||
{ lat: area.south, lon: antipode.lon },
|
||
);
|
||
return Math.max(...points.map((point) => distanceKm(center, point)));
|
||
}
|
||
|
||
/** Great-circle distance in kilometers. */
|
||
export function distanceKm(a, b) {
|
||
const toRad = Math.PI / 180;
|
||
const dLat = (b.lat - a.lat) * toRad;
|
||
const dLon = (b.lon - a.lon) * toRad;
|
||
const h =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||
}
|
||
|
||
/**
|
||
* The center of a resolved area: a radius area's center, a named place's own
|
||
* point, or the middle of a box (handling boxes across the antimeridian).
|
||
*/
|
||
export function areaCenter(area) {
|
||
if (area.center) return { lat: area.center.lat, lon: area.center.lon };
|
||
if (area.point) return { lat: area.point.lat, lon: area.point.lon };
|
||
const width =
|
||
area.west <= area.east
|
||
? area.east - area.west
|
||
: area.east + 360 - area.west;
|
||
const lon = area.west + width / 2;
|
||
return {
|
||
lat: (area.south + area.north) / 2,
|
||
lon: lon > 180 ? lon - 360 : lon,
|
||
};
|
||
}
|
||
|
||
/** Whether a point lies inside a resolved area, including radius areas. */
|
||
export function areaContains(area, point) {
|
||
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
|
||
return false;
|
||
if (point.lat < area.south || point.lat > area.north) return false;
|
||
const inLongitude =
|
||
area.west <= area.east
|
||
? point.lon >= area.west && point.lon <= area.east
|
||
: point.lon >= area.west || point.lon <= area.east;
|
||
if (!inLongitude) return false;
|
||
return area.center
|
||
? distanceKm(area.center, point) <= area.center.radiusKm
|
||
: true;
|
||
}
|
||
|
||
/** Whether a straight lon/lat segment meets a plain box (Liang–Barsky). */
|
||
function segmentMeetsBox(a, b, west, south, east, north) {
|
||
const dx = b.lon - a.lon;
|
||
const dy = b.lat - a.lat;
|
||
let low = 0;
|
||
let high = 1;
|
||
for (const [p, q] of [
|
||
[-dx, a.lon - west],
|
||
[dx, east - a.lon],
|
||
[-dy, a.lat - south],
|
||
[dy, north - a.lat],
|
||
]) {
|
||
if (p === 0) {
|
||
if (q < 0) return false;
|
||
} else {
|
||
const t = q / p;
|
||
if (p < 0) low = Math.max(low, t);
|
||
else high = Math.min(high, t);
|
||
if (low > high) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Whether a segment meets an area's bounding box, across the antimeridian too. */
|
||
function segmentMeetsBounds(a, b, area) {
|
||
// A segment longer than half the globe in longitude crosses the antimeridian.
|
||
const end =
|
||
Math.abs(b.lon - a.lon) > 180
|
||
? { lat: b.lat, lon: b.lon + (b.lon < a.lon ? 360 : -360) }
|
||
: b;
|
||
const boxes =
|
||
area.west <= area.east
|
||
? [[area.west, area.east]]
|
||
: [
|
||
[area.west, 180],
|
||
[-180, area.east],
|
||
];
|
||
return boxes.some(([west, east]) =>
|
||
[-360, 0, 360].some((shift) =>
|
||
segmentMeetsBox(
|
||
a,
|
||
end,
|
||
west + shift,
|
||
area.south,
|
||
east + shift,
|
||
area.north,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/** Closest great-circle distance in km from `point` to the segment a–b. */
|
||
function segmentDistanceKm(point, a, b) {
|
||
const toRad = Math.PI / 180;
|
||
const bearing = (from, to) => {
|
||
const lat1 = from.lat * toRad;
|
||
const lat2 = to.lat * toRad;
|
||
const dLon = (to.lon - from.lon) * toRad;
|
||
return Math.atan2(
|
||
Math.sin(dLon) * Math.cos(lat2),
|
||
Math.cos(lat1) * Math.sin(lat2) -
|
||
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon),
|
||
);
|
||
};
|
||
const toPoint = distanceKm(a, point) / EARTH_RADIUS_KM;
|
||
const length = distanceKm(a, b) / EARTH_RADIUS_KM;
|
||
const angle = bearing(a, point) - bearing(a, b);
|
||
if (length === 0 || Math.cos(angle) <= 0) return distanceKm(a, point);
|
||
const cross = Math.asin(Math.sin(toPoint) * Math.sin(angle));
|
||
const along = Math.acos(
|
||
Math.min(1, Math.cos(toPoint) / Math.max(1e-12, Math.cos(cross))),
|
||
);
|
||
if (along >= length) return distanceKm(b, point);
|
||
return Math.abs(cross) * EARTH_RADIUS_KM;
|
||
}
|
||
|
||
/**
|
||
* Whether a line of `[lon, lat]` vertices passes through an area, including
|
||
* segments that cross it with no vertex inside.
|
||
*/
|
||
export function lineTouchesArea(coords, area) {
|
||
const points = coords
|
||
.map(([lon, lat]) => ({ lat, lon }))
|
||
.filter(
|
||
(point) => Number.isFinite(point.lat) && Number.isFinite(point.lon),
|
||
);
|
||
if (points.some((point) => areaContains(area, point))) return true;
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const a = points[index - 1];
|
||
const b = points[index];
|
||
if (!segmentMeetsBounds(a, b, area)) continue;
|
||
if (
|
||
!area.center ||
|
||
segmentDistanceKm(area.center, a, b) <= area.center.radiusKm
|
||
)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
|
||
function ringContains(ring, point) {
|
||
if (ring.length < 3) return false;
|
||
// Keep adjacent vertices on the same longitude sheet, as the edge tests do.
|
||
// Wrapping each vertex around the query point would split a dateline ring
|
||
// when queried from Greenwich and mistake its complement for its interior.
|
||
let previous = ring[0][0];
|
||
let west = previous;
|
||
let east = previous;
|
||
const vertices = ring.map(([lon, lat]) => {
|
||
if (Math.abs(lon - previous) > 180)
|
||
lon += 360 * Math.round((previous - lon) / 360);
|
||
previous = lon;
|
||
west = Math.min(west, lon);
|
||
east = Math.max(east, lon);
|
||
return [lon, lat];
|
||
});
|
||
const middle = (west + east) / 2;
|
||
const lon = point.lon + 360 * Math.round((middle - point.lon) / 360);
|
||
let inside = false;
|
||
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i, i += 1) {
|
||
const [xi, yi] = vertices[i];
|
||
const [xj, yj] = vertices[j];
|
||
if (
|
||
yi > point.lat !== yj > point.lat &&
|
||
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
|
||
)
|
||
inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/**
|
||
* Whether polygons (each a list of `[lon, lat]` rings, outer ring first)
|
||
* overlap an area: a vertex inside it, an edge crossing it, or the area's
|
||
* center inside a polygon and outside its holes.
|
||
*/
|
||
export function polygonsTouchArea(polygons, area) {
|
||
const center = areaCenter(area);
|
||
return (polygons || []).some((rings) => {
|
||
if (!Array.isArray(rings) || !rings.length) return false;
|
||
const closed = rings.map((ring) =>
|
||
ring.length && ring[0] !== ring.at(-1) ? [...ring, ring[0]] : ring,
|
||
);
|
||
if (closed.some((ring) => lineTouchesArea(ring, area))) return true;
|
||
return (
|
||
ringContains(rings[0], center) &&
|
||
!rings.slice(1).some((hole) => ringContains(hole, center))
|
||
);
|
||
});
|
||
}
|