mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1142 from ariannamethod/codex/fix-antimeridian-polygon-containment
fix(perimeters): handle antimeridian queries and incident card anchors
This commit is contained in:
8 files changed
+300
-18
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
- Fire-perimeter queries handle outlines and holes crossing the antimeridian:
|
||||||
|
areas enclosed near ±180° are included, areas at the opposite meridian are
|
||||||
|
excluded, and returned perimeter coordinates stay near the dateline. Incident
|
||||||
|
cards use the same circular longitude mean and compare multipart outline
|
||||||
|
areas along short longitude edges, keeping the card on the largest polygon
|
||||||
|
(Arianna Method, @ariannamethod, #1142).
|
||||||
@@ -108,6 +108,18 @@ Catalog calls check their cancellation signal immediately before executing a
|
|||||||
tool. Already-cancelled queries do not start; cancellation after execution starts
|
tool. Already-cancelled queries do not start; cancellation after execution starts
|
||||||
continues to follow the existing tool and caller behavior.
|
continues to follow the existing tool and caller behavior.
|
||||||
|
|
||||||
|
Fire-perimeter queries test polygon interiors and holes on continuous unwrapped
|
||||||
|
longitudes, matching the short-edge convention used for boundary intersections.
|
||||||
|
An outline crossing ±180° contains nearby boxes and radius areas without also
|
||||||
|
containing their counterparts near Greenwich. Returned perimeter coordinates
|
||||||
|
use a circular mean for longitude, including multipart outlines. Incident cards
|
||||||
|
also use a circular longitude mean over the largest outer ring (excluding its
|
||||||
|
repeated closing vertex). Their relative-area comparison follows short longitude
|
||||||
|
edges so a small dateline-crossing part cannot outweigh a larger ordinary part.
|
||||||
|
Regression coverage lives in `src/tools/area.test.mjs`,
|
||||||
|
`src/tools/queries/environment.test.mjs` and
|
||||||
|
`src/layers/perimeters/cards.test.mjs`.
|
||||||
|
|
||||||
## Cyber HUD — September 30, 2026
|
## Cyber HUD — September 30, 2026
|
||||||
|
|
||||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||||
|
|||||||
@@ -11,20 +11,26 @@ export function containmentAccent(containedPct) {
|
|||||||
return '#8bc34a';
|
return '#8bc34a';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shoelace area (degree², sign dropped) — relative sizes only. */
|
/** Shoelace area along short longitude edges (degree², relative sizes only). */
|
||||||
function ringArea(ring) {
|
function ringArea(ring) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < ring.length - 1; i++) {
|
let [x1, y1] = ring[0];
|
||||||
const [x1, y1] = ring[i];
|
for (let i = 1; i < ring.length; i++) {
|
||||||
const [x2, y2] = ring[i + 1];
|
let delta = ring[i][0] - ring[i - 1][0];
|
||||||
|
if (delta > 180) delta -= 360;
|
||||||
|
else if (delta < -180) delta += 360;
|
||||||
|
const x2 = x1 + delta;
|
||||||
|
const y2 = ring[i][1];
|
||||||
sum += x1 * y2 - x2 * y1;
|
sum += x1 * y2 - x2 * y1;
|
||||||
|
x1 = x2;
|
||||||
|
y1 = y2;
|
||||||
}
|
}
|
||||||
return Math.abs(sum / 2);
|
return Math.abs(sum / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchor point for the incident card: the centroid of the largest polygon's
|
* Anchor point for the incident card: the vertex mean of the largest polygon's
|
||||||
* outer ring. Degrees, so callers own the Cartesian conversion.
|
* outer ring, with circular longitude. Callers own the Cartesian conversion.
|
||||||
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
|
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
|
||||||
* @returns {{lon: number, lat: number}}
|
* @returns {{lon: number, lat: number}}
|
||||||
*/
|
*/
|
||||||
@@ -41,14 +47,17 @@ export function perimeterAnchorDegrees(polygons) {
|
|||||||
}
|
}
|
||||||
// Vertex mean over the closed ring (first == last point dropped): perimeters
|
// Vertex mean over the closed ring (first == last point dropped): perimeters
|
||||||
// are compact enough that this stays inside or near the polygon.
|
// are compact enough that this stays inside or near the polygon.
|
||||||
let lon = 0;
|
let sinLon = 0;
|
||||||
|
let cosLon = 0;
|
||||||
let lat = 0;
|
let lat = 0;
|
||||||
const count = best.length - 1;
|
const count = best.length - 1;
|
||||||
|
const toRad = Math.PI / 180;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
lon += best[i][0];
|
sinLon += Math.sin(best[i][0] * toRad);
|
||||||
|
cosLon += Math.cos(best[i][0] * toRad);
|
||||||
lat += best[i][1];
|
lat += best[i][1];
|
||||||
}
|
}
|
||||||
return { lon: lon / count, lat: lat / count };
|
return { lon: Math.atan2(sinLon, cosLon) / toRad, lat: lat / count };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,12 +16,79 @@ const tiny = [
|
|||||||
[-107.01, 34.0],
|
[-107.01, 34.0],
|
||||||
];
|
];
|
||||||
|
|
||||||
test('the card anchors at the centroid of the largest polygon', () => {
|
test('the card anchors at the vertex mean of the largest polygon', () => {
|
||||||
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
|
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
|
||||||
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
|
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
|
||||||
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
|
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('incident cards stay at the dateline for closed, reversed and rotated outlines', () => {
|
||||||
|
const vertices = [
|
||||||
|
[179, 10],
|
||||||
|
[-179, 10],
|
||||||
|
[-179, 12],
|
||||||
|
[179, 12],
|
||||||
|
];
|
||||||
|
for (let start = 0; start < vertices.length; start++) {
|
||||||
|
const rotated = [...vertices.slice(start), ...vertices.slice(0, start)];
|
||||||
|
for (const points of [rotated, [...rotated].reverse()]) {
|
||||||
|
const anchor = perimeterAnchorDegrees([[points.concat([points[0]])]]);
|
||||||
|
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
|
||||||
|
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incident card longitude treats vertices at either sign of 180 as the same meridian', () => {
|
||||||
|
for (const boundary of [-180, 180]) {
|
||||||
|
const ring = [
|
||||||
|
[179, 10],
|
||||||
|
[boundary, 10],
|
||||||
|
[-179, 10],
|
||||||
|
[-179, 12],
|
||||||
|
[179, 12],
|
||||||
|
[179, 10],
|
||||||
|
];
|
||||||
|
const anchor = perimeterAnchorDegrees([[ring]]);
|
||||||
|
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
|
||||||
|
assert.ok(Math.abs(anchor.lat - 10.8) < 1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multipart cards compare polygon areas along short dateline edges', () => {
|
||||||
|
const ordinary = [
|
||||||
|
[10, 20],
|
||||||
|
[11, 20],
|
||||||
|
[11, 21],
|
||||||
|
[10, 21],
|
||||||
|
[10, 20],
|
||||||
|
];
|
||||||
|
for (const width of [0.2, 2]) {
|
||||||
|
const crossing = [
|
||||||
|
[180 - width / 2, 10],
|
||||||
|
[-180 + width / 2, 10],
|
||||||
|
[-180 + width / 2, 10 + width],
|
||||||
|
[180 - width / 2, 10 + width],
|
||||||
|
[180 - width / 2, 10],
|
||||||
|
];
|
||||||
|
for (const ring of [crossing, [...crossing].reverse()]) {
|
||||||
|
for (const polygons of [
|
||||||
|
[[ring], [ordinary]],
|
||||||
|
[[ordinary], [ring]],
|
||||||
|
]) {
|
||||||
|
const anchor = perimeterAnchorDegrees(polygons);
|
||||||
|
if (width < 1) {
|
||||||
|
assert.ok(Math.abs(anchor.lon - 10.5) < 1e-9);
|
||||||
|
assert.ok(Math.abs(anchor.lat - 20.5) < 1e-9);
|
||||||
|
} else {
|
||||||
|
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
|
||||||
|
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a full incident row renders name, size, containment, and ages', () => {
|
test('a full incident row renders name, size, containment, and ages', () => {
|
||||||
const now = 1758000000000;
|
const now = 1758000000000;
|
||||||
const card = buildIncidentCard(
|
const card = buildIncidentCard(
|
||||||
|
|||||||
+21
-4
@@ -323,13 +323,30 @@ export function lineTouchesArea(coords, area) {
|
|||||||
|
|
||||||
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
|
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
|
||||||
function ringContains(ring, point) {
|
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;
|
let inside = false;
|
||||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i, i += 1) {
|
||||||
const [xi, yi] = ring[i];
|
const [xi, yi] = vertices[i];
|
||||||
const [xj, yj] = ring[j];
|
const [xj, yj] = vertices[j];
|
||||||
if (
|
if (
|
||||||
yi > point.lat !== yj > point.lat &&
|
yi > point.lat !== yj > point.lat &&
|
||||||
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
|
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
|
||||||
)
|
)
|
||||||
inside = !inside;
|
inside = !inside;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,3 +262,78 @@ test('a box radius reaches every point of the box, however large', async () => {
|
|||||||
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
|
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
|
||||||
assert.ok(areaRadiusKm(small) < 30);
|
assert.ok(areaRadiusKm(small) < 30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('polygon containment follows the short edges across the antimeridian', async () => {
|
||||||
|
const ring = [
|
||||||
|
[179, -2],
|
||||||
|
[-179, -2],
|
||||||
|
[-179, 2],
|
||||||
|
[179, 2],
|
||||||
|
];
|
||||||
|
const areas = [
|
||||||
|
[{ bbox: [179.4, -0.1, 179.6, 0.1] }, true],
|
||||||
|
[{ bbox: [-179.6, -0.1, -179.4, 0.1] }, true],
|
||||||
|
[{ bbox: [179.8, -0.1, -179.8, 0.1] }, true],
|
||||||
|
[{ lat: 0, lon: 179.5, radius_km: 10 }, true],
|
||||||
|
[{ lat: 0, lon: -179.5, radius_km: 10 }, true],
|
||||||
|
[{ lat: 0, lon: 180, radius_km: 10 }, true],
|
||||||
|
[{ lat: 0, lon: -180, radius_km: 10 }, true],
|
||||||
|
[{ bbox: [-0.1, -0.1, 0.1, 0.1] }, false],
|
||||||
|
[{ lat: 0, lon: 0, radius_km: 10 }, false],
|
||||||
|
];
|
||||||
|
for (const vertices of [
|
||||||
|
ring,
|
||||||
|
[...ring, [...ring[0]]],
|
||||||
|
[...ring].reverse(),
|
||||||
|
[...ring.slice(2), ...ring.slice(0, 2)],
|
||||||
|
]) {
|
||||||
|
for (const [input, expected] of areas) {
|
||||||
|
assert.equal(
|
||||||
|
polygonsTouchArea([[vertices]], await resolveArea(input)),
|
||||||
|
expected,
|
||||||
|
JSON.stringify({ vertices, input }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('antimeridian polygons keep holes empty and include their boundaries', async () => {
|
||||||
|
const outer = [
|
||||||
|
[178, -3],
|
||||||
|
[-178, -3],
|
||||||
|
[-178, 3],
|
||||||
|
[178, 3],
|
||||||
|
];
|
||||||
|
const hole = [
|
||||||
|
[179, -1],
|
||||||
|
[-179, -1],
|
||||||
|
[-179, 1],
|
||||||
|
[179, 1],
|
||||||
|
];
|
||||||
|
for (const input of [
|
||||||
|
{ bbox: [179.4, -0.1, 179.6, 0.1] },
|
||||||
|
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
|
||||||
|
{ lat: 0, lon: 180, radius_km: 10 },
|
||||||
|
]) {
|
||||||
|
const area = await resolveArea(input);
|
||||||
|
assert.ok(polygonsTouchArea([[outer]], area));
|
||||||
|
assert.ok(!polygonsTouchArea([[outer, hole]], area));
|
||||||
|
assert.ok(!polygonsTouchArea([[outer, [...hole].reverse()]], area));
|
||||||
|
}
|
||||||
|
const solid = await resolveArea({ bbox: [178.4, -0.1, 178.6, 0.1] });
|
||||||
|
assert.ok(polygonsTouchArea([[outer, hole]], solid));
|
||||||
|
const boundary = await resolveArea({ bbox: [178.9, -0.1, 179.1, 0.1] });
|
||||||
|
assert.ok(polygonsTouchArea([[outer, hole]], boundary));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polygon containment retains rings spanning more than half the globe', async () => {
|
||||||
|
const longitudes = [-170, -110, -50, 10, 70, 130];
|
||||||
|
const ring = [
|
||||||
|
...longitudes.map((lon) => [lon, -5]),
|
||||||
|
...[...longitudes].reverse().map((lon) => [lon, 5]),
|
||||||
|
];
|
||||||
|
const inside = await resolveArea({ bbox: [99, -1, 101, 1] });
|
||||||
|
const outside = await resolveArea({ bbox: [169, -1, 171, 1] });
|
||||||
|
assert.ok(polygonsTouchArea([[ring]], inside));
|
||||||
|
assert.ok(!polygonsTouchArea([[ring]], outside));
|
||||||
|
});
|
||||||
@@ -288,20 +288,25 @@ export const getFirePerimeters = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
|
/** Average outer-ring vertices, using a circular longitude mean at the dateline. */
|
||||||
function polygonCenter(polygons) {
|
function polygonCenter(polygons) {
|
||||||
let lat = 0;
|
let lat = 0;
|
||||||
let lon = 0;
|
let sinLon = 0;
|
||||||
|
let cosLon = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
const toRad = Math.PI / 180;
|
||||||
for (const polygon of polygons || []) {
|
for (const polygon of polygons || []) {
|
||||||
for (const [x, y] of polygon?.[0] || []) {
|
for (const [x, y] of polygon?.[0] || []) {
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||||
lon += x;
|
sinLon += Math.sin(x * toRad);
|
||||||
|
cosLon += Math.cos(x * toRad);
|
||||||
lat += y;
|
lat += y;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count ? { lat: lat / count, lon: lon / count } : null;
|
return count
|
||||||
|
? { lat: lat / count, lon: Math.atan2(sinLon, cosLon) / toRad }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTerrainHeight = defineTool({
|
export const getTerrainHeight = defineTool({
|
||||||
|
|||||||
@@ -227,6 +227,97 @@ test('fire perimeters are placed by their outline and sorted by size', async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fire perimeter queries include dateline interiors and exclude the opposite meridian', async () => {
|
||||||
|
const perimeters = {
|
||||||
|
getSnapshot: async () => [
|
||||||
|
{
|
||||||
|
stableId: 'dateline',
|
||||||
|
name: 'Dateline Fire',
|
||||||
|
acres: 100,
|
||||||
|
polygons: [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
[179, -2],
|
||||||
|
[-179, -2],
|
||||||
|
[-179, 2],
|
||||||
|
[179, 2],
|
||||||
|
[179, -2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const catalog = catalogWith({ perimeters });
|
||||||
|
for (const area of [
|
||||||
|
{ bbox: [179.4, -0.1, 179.6, 0.1] },
|
||||||
|
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
|
||||||
|
{ lat: 0, lon: 180, radius_km: 10 },
|
||||||
|
]) {
|
||||||
|
const result = await catalog.call('get_fire_perimeters', { area });
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.rows.map((row) => row.id),
|
||||||
|
['dateline'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const elsewhere = await catalog.call('get_fire_perimeters', {
|
||||||
|
area: { bbox: [-0.1, -0.1, 0.1, 0.1] },
|
||||||
|
});
|
||||||
|
assert.equal(elsewhere.data.total, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fire perimeter coordinates stay at the dateline for closed and multipart outlines', async () => {
|
||||||
|
const perimeters = {
|
||||||
|
getSnapshot: async () => [
|
||||||
|
{
|
||||||
|
stableId: 'closed',
|
||||||
|
acres: 100,
|
||||||
|
polygons: [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
[179, -2],
|
||||||
|
[-179, -2],
|
||||||
|
[-179, 2],
|
||||||
|
[179, 2],
|
||||||
|
[179, -2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stableId: 'multipart',
|
||||||
|
acres: 50,
|
||||||
|
polygons: [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
[179, -1],
|
||||||
|
[179.5, -1],
|
||||||
|
[179.5, 1],
|
||||||
|
[179, 1],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
[-179.5, -1],
|
||||||
|
[-179, -1],
|
||||||
|
[-179, 1],
|
||||||
|
[-179.5, 1],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||||
|
area: { bbox: [178, -3, -178, 3] },
|
||||||
|
});
|
||||||
|
assert.equal(result.data.total, 2);
|
||||||
|
for (const row of result.data.rows) {
|
||||||
|
assert.ok(Math.abs(row.lon) > 179, `${row.id}: ${row.lon}`);
|
||||||
|
assert.ok(Math.abs(row.lon) <= 180);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('terrain heights are returned in request order', async () => {
|
test('terrain heights are returned in request order', async () => {
|
||||||
const terrain = {
|
const terrain = {
|
||||||
async getHeights(points) {
|
async getHeights(points) {
|
||||||
|
|||||||
Reference in new issue
Block a user