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

+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(