mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Fire perimeters were placed at their average vertex, so a fire enclosing the whole requested area was missed. Polygons now count when a vertex is inside the area, an edge crosses it, or the polygon contains it outside any hole.
248 lines
7.3 KiB
JavaScript
248 lines
7.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
areaCenter,
|
|
areaContains,
|
|
distanceKm,
|
|
lineTouchesArea,
|
|
polygonsTouchArea,
|
|
resolveArea,
|
|
} from './area.js';
|
|
import { placeFromGeocodeResult } from './places.js';
|
|
|
|
test('an area needs exactly one form', async () => {
|
|
for (const area of [
|
|
{},
|
|
{ place: 'Oslo', bbox: [0, 0, 1, 1] },
|
|
{ lat: 1, lon: 2 },
|
|
]) {
|
|
await assert.rejects(
|
|
resolveArea(area),
|
|
(error) => error.code === 'invalid_arguments',
|
|
);
|
|
}
|
|
await assert.rejects(
|
|
resolveArea({ bbox: [0, 10, 1, 5] }),
|
|
/south below north/,
|
|
);
|
|
});
|
|
|
|
test('boxes, including antimeridian boxes, contain the expected points', async () => {
|
|
const box = await resolveArea({ bbox: [-10, 40, 10, 50] });
|
|
assert.equal(areaContains(box, { lat: 45, lon: 0 }), true);
|
|
assert.equal(areaContains(box, { lat: 45, lon: 20 }), false);
|
|
const pacific = await resolveArea({ bbox: [170, -20, -170, 0] });
|
|
assert.equal(areaContains(pacific, { lat: -10, lon: 179 }), true);
|
|
assert.equal(areaContains(pacific, { lat: -10, lon: -175 }), true);
|
|
assert.equal(areaContains(pacific, { lat: -10, lon: 0 }), false);
|
|
assert.equal(areaContains(box, { lat: Number.NaN, lon: 0 }), false);
|
|
});
|
|
|
|
test('radius areas use great-circle distance', async () => {
|
|
const circle = await resolveArea({ lat: 37.77, lon: -122.42, radius_km: 50 });
|
|
assert.equal(circle.center.radiusKm, 50);
|
|
assert.equal(areaContains(circle, { lat: 37.8, lon: -122.27 }), true);
|
|
assert.equal(areaContains(circle, { lat: 38.58, lon: -121.49 }), false);
|
|
assert.ok(
|
|
Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.1,
|
|
);
|
|
// A circle across the antimeridian wraps its box.
|
|
const dateline = await resolveArea({ lat: 0, lon: 179.9, radius_km: 100 });
|
|
assert.ok(dateline.west > dateline.east);
|
|
assert.equal(areaContains(dateline, { lat: 0, lon: -179.9 }), true);
|
|
});
|
|
|
|
test('place names resolve through the places service', async () => {
|
|
const places = {
|
|
resolve: async (name) =>
|
|
name === 'Taiwan'
|
|
? {
|
|
name: 'Taiwan',
|
|
bounds: { west: 119, south: 21, east: 123, north: 26 },
|
|
}
|
|
: null,
|
|
};
|
|
const taiwan = await resolveArea(
|
|
{ place: 'Taiwan' },
|
|
{ services: { places } },
|
|
);
|
|
assert.deepEqual(taiwan, {
|
|
label: 'Taiwan',
|
|
west: 119,
|
|
south: 21,
|
|
east: 123,
|
|
north: 26,
|
|
});
|
|
await assert.rejects(
|
|
resolveArea({ place: 'Nowhere' }, { services: { places } }),
|
|
/No place matched "Nowhere"/,
|
|
);
|
|
await assert.rejects(
|
|
resolveArea({ place: 'Taiwan' }),
|
|
(error) => error.code === 'unsupported',
|
|
);
|
|
});
|
|
|
|
test('geocode results become bounded places', () => {
|
|
assert.deepEqual(
|
|
placeFromGeocodeResult({
|
|
formatted_address: 'Oslo, Norway',
|
|
geometry: {
|
|
location: { lat: 59.9, lng: 10.7 },
|
|
viewport: {
|
|
southwest: { lat: 59.8, lng: 10.5 },
|
|
northeast: { lat: 60, lng: 10.9 },
|
|
},
|
|
},
|
|
}),
|
|
{
|
|
name: 'Oslo, Norway',
|
|
bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 },
|
|
point: { lat: 59.9, lon: 10.7 },
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
placeFromGeocodeResult({ geometry: { location: { lat: 0, lng: 179.9 } } })
|
|
.bounds,
|
|
{ west: 179.65, south: -0.25, east: 180, north: 0.25 },
|
|
);
|
|
assert.equal(placeFromGeocodeResult({ geometry: {} }), null);
|
|
});
|
|
|
|
test('a named place centers on its own point, not the middle of its bounds', async () => {
|
|
// Tokyo's prefecture bounds reach remote Pacific islands.
|
|
const places = {
|
|
resolve: async () => ({
|
|
name: 'Tokyo, Japan',
|
|
bounds: { west: 136.07, south: 20.21, east: 153.99, north: 35.9 },
|
|
point: { lat: 35.6769, lon: 139.7639 },
|
|
}),
|
|
};
|
|
const tokyo = await resolveArea({ place: 'Tokyo' }, { services: { places } });
|
|
assert.deepEqual(areaCenter(tokyo), { lat: 35.6769, lon: 139.7639 });
|
|
assert.equal(areaContains(tokyo, { lat: 27.09, lon: 142.19 }), true);
|
|
const box = await resolveArea({ bbox: [0, 0, 10, 10] });
|
|
assert.deepEqual(areaCenter(box), { lat: 5, lon: 5 });
|
|
});
|
|
|
|
test('radius areas include every point within the radius, near the poles too', async () => {
|
|
const polar = await resolveArea({ lat: 80, lon: 0, radius_km: 1500 });
|
|
const across = { lat: 89, lon: 180 };
|
|
assert.ok(distanceKm({ lat: 80, lon: 0 }, across) < 1500);
|
|
assert.ok(areaContains(polar, across));
|
|
assert.equal(polar.west, -180);
|
|
assert.equal(polar.east, 180);
|
|
assert.equal(polar.north, 90);
|
|
// Away from the poles, sample the circle's edge and check each point is kept.
|
|
const center = { lat: 60, lon: 20 };
|
|
const wide = await resolveArea({ ...center, radius_km: 2000 });
|
|
assert.ok(wide.east - wide.west < 360);
|
|
for (let bearing = 0; bearing < 360; bearing += 5) {
|
|
const d = 1999 / 6371.0088;
|
|
const b = (bearing * Math.PI) / 180;
|
|
const lat1 = (center.lat * Math.PI) / 180;
|
|
const lat2 = Math.asin(
|
|
Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(b),
|
|
);
|
|
const lon2 =
|
|
(center.lon * Math.PI) / 180 +
|
|
Math.atan2(
|
|
Math.sin(b) * Math.sin(d) * Math.cos(lat1),
|
|
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
|
|
);
|
|
const point = { lat: (lat2 * 180) / Math.PI, lon: (lon2 * 180) / Math.PI };
|
|
assert.ok(areaContains(wide, point), `bearing ${bearing}`);
|
|
}
|
|
});
|
|
|
|
test('lines touch an area when a segment crosses it with no vertex inside', async () => {
|
|
const box = await resolveArea({ bbox: [-1, -1, 1, 1] });
|
|
assert.ok(
|
|
lineTouchesArea(
|
|
[
|
|
[-2, 0],
|
|
[2, 0],
|
|
],
|
|
box,
|
|
),
|
|
);
|
|
assert.ok(
|
|
!lineTouchesArea(
|
|
[
|
|
[-2, 2],
|
|
[2, 2],
|
|
],
|
|
box,
|
|
),
|
|
);
|
|
const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 50 });
|
|
assert.ok(
|
|
lineTouchesArea(
|
|
[
|
|
[-2, 0.3],
|
|
[2, 0.3],
|
|
],
|
|
circle,
|
|
),
|
|
);
|
|
// Inside the circle's box but beyond its radius.
|
|
assert.ok(
|
|
!lineTouchesArea(
|
|
[
|
|
[0.3, 0.42],
|
|
[0.6, 0.42],
|
|
],
|
|
circle,
|
|
),
|
|
);
|
|
const dateline = await resolveArea({ bbox: [179, -1, -179, 1] });
|
|
assert.ok(
|
|
lineTouchesArea(
|
|
[
|
|
[178, 0],
|
|
[-178, 0],
|
|
],
|
|
dateline,
|
|
),
|
|
);
|
|
const nearDateline = await resolveArea({ bbox: [179.5, -1, 179.9, 1] });
|
|
assert.ok(
|
|
lineTouchesArea(
|
|
[
|
|
[179, 0],
|
|
[-179, 0],
|
|
],
|
|
nearDateline,
|
|
),
|
|
);
|
|
assert.ok(
|
|
!lineTouchesArea(
|
|
[
|
|
[170, 0],
|
|
[175, 0],
|
|
],
|
|
nearDateline,
|
|
),
|
|
);
|
|
});
|
|
|
|
test('polygons touch an area by vertex, crossing edge or enclosing it', async () => {
|
|
const box = await resolveArea({ bbox: [-1, -1, 1, 1] });
|
|
const square = (half, x = 0) => [
|
|
[
|
|
[x - half, -half],
|
|
[x + half, -half],
|
|
[x + half, half],
|
|
[x - half, half],
|
|
],
|
|
];
|
|
assert.ok(polygonsTouchArea([square(5)], box), 'encloses the area');
|
|
assert.ok(polygonsTouchArea([square(0.5)], box), 'inside the area');
|
|
assert.ok(polygonsTouchArea([square(1, 1.5)], box), 'overlaps an edge');
|
|
assert.ok(!polygonsTouchArea([square(0.5, 5)], box), 'elsewhere');
|
|
const ring = [...square(5), ...square(3)];
|
|
assert.ok(!polygonsTouchArea([ring], box), 'the area sits in a hole');
|
|
const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 });
|
|
assert.ok(polygonsTouchArea([square(5)], circle));
|
|
});
|