From 8d37661219f7fbe6592e75b16cefda9b317d0e11 Mon Sep 17 00:00:00 2001 From: Arianna Method Date: Fri, 16 Oct 2026 21:33:01 +0300 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 6 ++ docs/CURRENT-STATE.md | 12 ++++ src/layers/perimeters/cards.js | 27 +++++--- src/layers/perimeters/cards.test.mjs | 69 ++++++++++++++++++- src/tools/area.js | 25 +++++-- src/tools/area.test.mjs | 75 +++++++++++++++++++++ src/tools/queries/environment.js | 13 ++-- src/tools/queries/environment.test.mjs | 91 ++++++++++++++++++++++++++ 8 files changed, 300 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee00d839..2d38adaf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5ea0b581e..7fc40b1e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index d5505ec8d..aef5913ce 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -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>>} 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 }; } /** diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs index 1b32a5e06..0e064bff0 100644 --- a/src/layers/perimeters/cards.test.mjs +++ b/src/layers/perimeters/cards.test.mjs @@ -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( diff --git a/src/tools/area.js b/src/tools/area.js index 4af62a03c..e83db33eb 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -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; } diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index 7ec551aae..15e4bd006 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -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)); +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 4f653ff32..b1d0c6a86 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -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({ diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 9dd3d0f8c..694b9718e 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -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) { From 7ec0b14cab80031bdcbe6e4522c15af4c23222e3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 15:26:29 -0700 Subject: [PATCH 2/2] Move the changelog entry into changelog.d Main now takes release notes as one fragment per pull request (#787). --- CHANGELOG.md | 6 ------ changelog.d/1142-antimeridian-fire-perimeters.md | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1142-antimeridian-fire-perimeters.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d38adaf5..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,5 @@ # 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). diff --git a/changelog.d/1142-antimeridian-fire-perimeters.md b/changelog.d/1142-antimeridian-fire-perimeters.md new file mode 100644 index 000000000..8b847163e --- /dev/null +++ b/changelog.d/1142-antimeridian-fire-perimeters.md @@ -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).