fix(perimeters): handle antimeridian queries and incident card anchors

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.
This commit is contained in:
Arianna Method committed 2026-10-16 21:33:01 +03:00
1 parent e8b3a10a07
commit 8d37661219
8 files changed
+300 -18

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
- 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.
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
band edges, skips the starting station and rejects spectra from an earlier
tune (Aditya, @Rohilalala, #1174).
+12
View File
@@ -105,6 +105,18 @@ Catalog calls check their cancellation signal immediately before executing a
tool. Already-cancelled queries do not start; cancellation after execution starts
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
Display > HUD > Layout includes Cyber, also available through the HUD voice
+18 -9
View File
@@ -11,20 +11,26 @@ export function containmentAccent(containedPct) {
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
/** Shoelace area along short longitude edges (degree², relative sizes only). */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
let [x1, y1] = ring[0];
for (let i = 1; i < ring.length; i++) {
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;
x1 = x2;
y1 = y2;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* Anchor point for the incident card: the vertex mean of the largest polygon's
* outer ring, with circular longitude. Callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
@@ -41,14 +47,17 @@ export function perimeterAnchorDegrees(polygons) {
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let lat = 0;
const count = best.length - 1;
const toRad = Math.PI / 180;
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];
}
return { lon: lon / count, lat: lat / count };
return { lon: Math.atan2(sinLon, cosLon) / toRad, lat: lat / count };
}
/**
+68 -1
View File
@@ -16,12 +16,79 @@ const tiny = [
[-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]]);
assert.ok(Math.abs(anchor.lon - -108.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', () => {
const now = 1758000000000;
const card = buildIncidentCard(
+21 -4
View File
@@ -323,13 +323,30 @@ export function lineTouchesArea(coords, area) {
/** 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 = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
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 &&
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
+75
View File
@@ -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] });
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));
});
+9 -4
View File
@@ -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) {
let lat = 0;
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let count = 0;
const toRad = Math.PI / 180;
for (const polygon of polygons || []) {
for (const [x, y] of polygon?.[0] || []) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
lon += x;
sinLon += Math.sin(x * toRad);
cosLon += Math.cos(x * toRad);
lat += y;
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({
+91
View File
@@ -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 () => {
const terrain = {
async getHeights(points) {