mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(geo): add Pak-Afghan conflict zone and country boundary override system (#1150)
* Add Pakistan–Afghanistan hotspot and conflict zone Introduce a new INTEL_HOTSPOTS entry (pak_afghan) to track Pakistan–Afghanistan border tensions, including location, keywords, agencies, status, escalation indicators, and humanitarian significance. Also add a CONFLICT_ZONES polygon for 'Pakistan–Afghanistan War' with center, intensity, parties, startDate (Feb 21, 2026), key developments, and displacement/casualty notes to enable monitoring of cross-border strikes, TTP activity, and regional instability. * Update conflict zone center coordinates Adjust the center coordinates for the specified conflict zone in src/config/geo.ts from [50, 30] to [69, 31.8] to better reflect the actual Pakistan/Afghanistan border region and improve map centering/visualization accuracy. * Add country boundary overrides (Pakistan) Support optional country boundary overrides by loading public/data/country-boundary-overrides.geojson and replacing main country geometries when ISO codes match. Add a script (scripts/fetch-pakistan-boundary-override.mjs) to fetch Pakistan's de facto boundary from Natural Earth and write the override file, and document the override workflow in CONTRIBUTING.md. The country-geometry service now attempts to apply overrides and updates cached polygons/bboxes; failures are ignored since overrides are optional. * fix: neutralize language, parallel override loading, fetch timeout - Rename conflict zone from "War" to "Border Conflict", intensity high→medium - Rewrite description to factual language (no "open war" claim) - Load country boundary overrides in parallel with main GeoJSON - Neutralize comments/docs: reference Natural Earth source, remove political terms - Add 60s timeout to Natural Earth fetch script (~24MB download) - Add trailing newline to GeoJSON override file * refactor: serve country boundary overrides from R2 CDN Move country-boundary-overrides.geojson from public/data/ to R2 bucket (worldmonitor-maps) to avoid serving large static files through Vercel. Update fetch URL, docs, and script with rclone upload instructions. * fix: use maps.worldmonitor.app for R2 override URL (CF-proxied) * fix(geo): bound optional country override fetch --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
@@ -259,10 +259,11 @@ For endpoints that deal with non-JSON payloads (XML feeds, binary data, HTML emb
|
||||
|
||||
### Country boundary overrides
|
||||
|
||||
Country outlines are loaded from `public/data/countries.geojson`. Optional higher-resolution overrides (sourced from [Natural Earth](https://www.naturalearthdata.com/)) live in `public/data/country-boundary-overrides.geojson`. The app loads overrides after the main file and replaces geometry for any country whose `ISO3166-1-Alpha-2` (or `ISO_A2`) matches. To refresh the Pakistan boundary from Natural Earth, run:
|
||||
Country outlines are loaded from `public/data/countries.geojson`. Optional higher-resolution overrides (sourced from [Natural Earth](https://www.naturalearthdata.com/)) are served from R2 CDN. The app loads overrides after the main file and replaces geometry for any country whose `ISO3166-1-Alpha-2` (or `ISO_A2`) matches. To refresh the Pakistan boundary from Natural Earth, run:
|
||||
|
||||
```bash
|
||||
node scripts/fetch-pakistan-boundary-override.mjs
|
||||
rclone copy public/data/country-boundary-overrides.geojson r2:worldmonitor-maps/
|
||||
```
|
||||
|
||||
## Adding RSS Feeds
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetches Pakistan's boundary from Natural Earth 50m Admin 0 Countries and writes
|
||||
* public/data/country-boundary-overrides.geojson.
|
||||
* country-boundary-overrides.geojson locally. After running, upload to R2:
|
||||
* rclone copy public/data/country-boundary-overrides.geojson r2:worldmonitor-maps/
|
||||
*
|
||||
* Note: downloads the full NE 50m countries file (~24 MB) to extract Pakistan.
|
||||
*
|
||||
|
||||
@@ -14,8 +14,9 @@ interface CountryHit {
|
||||
|
||||
const COUNTRY_GEOJSON_URL = 'https://maps.worldmonitor.app/countries.geojson';
|
||||
|
||||
/** Optional higher-resolution boundary overrides sourced from Natural Earth. */
|
||||
const COUNTRY_OVERRIDES_URL = '/data/country-boundary-overrides.geojson';
|
||||
/** Optional higher-resolution boundary overrides sourced from Natural Earth (served from R2 CDN). */
|
||||
const COUNTRY_OVERRIDES_URL = 'https://maps.worldmonitor.app/country-boundary-overrides.geojson';
|
||||
const COUNTRY_OVERRIDE_TIMEOUT_MS = 3_000;
|
||||
|
||||
const POLITICAL_OVERRIDES: Record<string, string> = { 'CN-TW': 'TW' };
|
||||
|
||||
@@ -169,6 +170,78 @@ function buildCountryNameMatchers(): void {
|
||||
}));
|
||||
}
|
||||
|
||||
function makeTimeout(ms: number): AbortSignal {
|
||||
if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(ms);
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), ms);
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
||||
function rebuildCountryIndex(data: FeatureCollection<Geometry>): void {
|
||||
countryIndex.clear();
|
||||
countryList = [];
|
||||
iso3ToIso2.clear();
|
||||
nameToIso2.clear();
|
||||
codeToName.clear();
|
||||
|
||||
for (const feature of data.features) {
|
||||
const code = normalizeCode(feature.properties);
|
||||
const name = normalizeName(feature.properties);
|
||||
if (!code || !name) continue;
|
||||
|
||||
const iso3 = feature.properties?.['ISO3166-1-Alpha-3'];
|
||||
if (typeof iso3 === 'string' && /^[A-Z]{3}$/i.test(iso3.trim())) {
|
||||
iso3ToIso2.set(iso3.trim().toUpperCase(), code);
|
||||
}
|
||||
nameToIso2.set(name.toLowerCase(), code);
|
||||
if (!codeToName.has(code)) codeToName.set(code, name);
|
||||
|
||||
const polygons = normalizeGeometry(feature.geometry);
|
||||
const bbox = computeBbox(polygons);
|
||||
if (!bbox || polygons.length === 0) continue;
|
||||
|
||||
const indexed: IndexedCountryGeometry = { code, name, polygons, bbox };
|
||||
countryIndex.set(code, indexed);
|
||||
countryList.push(indexed);
|
||||
}
|
||||
|
||||
for (const [alias, code] of Object.entries(NAME_ALIASES)) {
|
||||
if (!nameToIso2.has(alias)) {
|
||||
nameToIso2.set(alias, code);
|
||||
}
|
||||
}
|
||||
|
||||
buildCountryNameMatchers();
|
||||
}
|
||||
|
||||
function applyCountryGeometryOverrides(
|
||||
data: FeatureCollection<Geometry>,
|
||||
overrideData: FeatureCollection<Geometry>,
|
||||
): void {
|
||||
const featureByCode = new Map<string, (typeof data.features)[number]>();
|
||||
for (const feature of data.features) {
|
||||
const code = normalizeCode(feature.properties);
|
||||
if (code) featureByCode.set(code, feature);
|
||||
}
|
||||
|
||||
for (const overrideFeature of overrideData.features) {
|
||||
const code = normalizeCode(overrideFeature.properties);
|
||||
if (!code || !overrideFeature.geometry) continue;
|
||||
const mainFeature = featureByCode.get(code);
|
||||
if (!mainFeature) continue;
|
||||
|
||||
mainFeature.geometry = overrideFeature.geometry;
|
||||
const polygons = normalizeGeometry(overrideFeature.geometry);
|
||||
const bbox = computeBbox(polygons);
|
||||
if (!bbox || polygons.length === 0) continue;
|
||||
|
||||
const existing = countryIndex.get(code);
|
||||
if (!existing) continue;
|
||||
existing.polygons = polygons;
|
||||
existing.bbox = bbox;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loadedGeoJson || loadPromise) {
|
||||
await loadPromise;
|
||||
@@ -179,10 +252,7 @@ async function ensureLoaded(): Promise<void> {
|
||||
if (typeof fetch !== 'function') return;
|
||||
|
||||
try {
|
||||
const [response, overrideResp] = await Promise.all([
|
||||
fetch(COUNTRY_GEOJSON_URL),
|
||||
fetch(COUNTRY_OVERRIDES_URL).catch(() => null),
|
||||
]);
|
||||
const response = await fetch(COUNTRY_GEOJSON_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -193,67 +263,22 @@ async function ensureLoaded(): Promise<void> {
|
||||
}
|
||||
|
||||
loadedGeoJson = data;
|
||||
countryIndex.clear();
|
||||
countryList = [];
|
||||
iso3ToIso2.clear();
|
||||
nameToIso2.clear();
|
||||
codeToName.clear();
|
||||
|
||||
for (const feature of data.features) {
|
||||
const code = normalizeCode(feature.properties);
|
||||
const name = normalizeName(feature.properties);
|
||||
if (!code || !name) continue;
|
||||
|
||||
const iso3 = feature.properties?.['ISO3166-1-Alpha-3'];
|
||||
if (typeof iso3 === 'string' && /^[A-Z]{3}$/i.test(iso3.trim())) {
|
||||
iso3ToIso2.set(iso3.trim().toUpperCase(), code);
|
||||
}
|
||||
nameToIso2.set(name.toLowerCase(), code);
|
||||
if (!codeToName.has(code)) codeToName.set(code, name);
|
||||
|
||||
const polygons = normalizeGeometry(feature.geometry);
|
||||
const bbox = computeBbox(polygons);
|
||||
if (!bbox || polygons.length === 0) continue;
|
||||
|
||||
const indexed: IndexedCountryGeometry = { code, name, polygons, bbox };
|
||||
countryIndex.set(code, indexed);
|
||||
countryList.push(indexed);
|
||||
}
|
||||
|
||||
for (const [alias, code] of Object.entries(NAME_ALIASES)) {
|
||||
if (!nameToIso2.has(alias)) {
|
||||
nameToIso2.set(alias, code);
|
||||
}
|
||||
}
|
||||
rebuildCountryIndex(data);
|
||||
|
||||
// Apply optional higher-resolution boundary overrides (sourced from Natural Earth)
|
||||
try {
|
||||
if (overrideResp?.ok) {
|
||||
const overrideResp = await fetch(COUNTRY_OVERRIDES_URL, {
|
||||
signal: makeTimeout(COUNTRY_OVERRIDE_TIMEOUT_MS),
|
||||
});
|
||||
if (overrideResp.ok) {
|
||||
const overrideData = (await overrideResp.json()) as FeatureCollection<Geometry>;
|
||||
if (overrideData?.type === 'FeatureCollection' && Array.isArray(overrideData.features)) {
|
||||
for (const overrideFeature of overrideData.features) {
|
||||
const code = normalizeCode(overrideFeature.properties);
|
||||
if (!code || !overrideFeature.geometry) continue;
|
||||
const mainFeature = data.features.find((f) => normalizeCode(f.properties) === code);
|
||||
if (!mainFeature) continue;
|
||||
mainFeature.geometry = overrideFeature.geometry;
|
||||
const polygons = normalizeGeometry(overrideFeature.geometry);
|
||||
const bbox = computeBbox(polygons);
|
||||
if (bbox && polygons.length > 0) {
|
||||
const existing = countryIndex.get(code);
|
||||
if (existing) {
|
||||
existing.polygons = polygons;
|
||||
existing.bbox = bbox;
|
||||
}
|
||||
}
|
||||
}
|
||||
applyCountryGeometryOverrides(data, overrideData);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Overrides are optional; ignore fetch/parse errors
|
||||
// Overrides optional; ignore fetch/parse errors
|
||||
}
|
||||
|
||||
buildCountryNameMatchers();
|
||||
} catch (err) {
|
||||
console.warn('[country-geometry] Failed to load countries.geojson:', err);
|
||||
}
|
||||
|
||||
121
tests/country-geometry-overrides.test.mts
Normal file
121
tests/country-geometry-overrides.test.mts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalAbortSignalTimeout = AbortSignal.timeout;
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function installFastAbortTimeout(delayMs = 5): void {
|
||||
Object.defineProperty(AbortSignal, 'timeout', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => {
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), delayMs);
|
||||
return ctrl.signal;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function restoreGlobals(): void {
|
||||
globalThis.fetch = originalFetch;
|
||||
Object.defineProperty(AbortSignal, 'timeout', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalAbortSignalTimeout,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFreshCountryGeometryModule() {
|
||||
return import(`../src/services/country-geometry.ts?test=${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
|
||||
function makeFeatureCollection(maxCoord: number) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: 'Pakistan',
|
||||
'ISO3166-1-Alpha-2': 'PK',
|
||||
'ISO3166-1-Alpha-3': 'PAK',
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[
|
||||
[0, 0],
|
||||
[maxCoord, 0],
|
||||
[maxCoord, maxCoord],
|
||||
[0, maxCoord],
|
||||
[0, 0],
|
||||
]],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
describe('country geometry overrides', () => {
|
||||
it('loads bundled geometry when override fetch times out', async () => {
|
||||
installFastAbortTimeout();
|
||||
let overrideAborted = false;
|
||||
|
||||
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === '/data/countries.geojson') {
|
||||
return Promise.resolve(jsonResponse(makeFeatureCollection(1)));
|
||||
}
|
||||
if (url === 'https://maps.worldmonitor.app/country-boundary-overrides.geojson') {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
overrideAborted = true;
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
const countryGeometry = await loadFreshCountryGeometryModule();
|
||||
const start = Date.now();
|
||||
await countryGeometry.preloadCountryGeometry();
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
assert.equal(overrideAborted, true);
|
||||
assert.ok(elapsedMs < 250, `Expected preload to complete quickly, got ${elapsedMs}ms`);
|
||||
assert.deepEqual(countryGeometry.getCountryBbox('PK'), [0, 0, 1, 1]);
|
||||
});
|
||||
|
||||
it('applies override geometry when the CDN responds in time', async () => {
|
||||
globalThis.fetch = ((input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === '/data/countries.geojson') {
|
||||
return Promise.resolve(jsonResponse(makeFeatureCollection(1)));
|
||||
}
|
||||
if (url === 'https://maps.worldmonitor.app/country-boundary-overrides.geojson') {
|
||||
return Promise.resolve(jsonResponse(makeFeatureCollection(2)));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
}) as typeof fetch;
|
||||
|
||||
const countryGeometry = await loadFreshCountryGeometryModule();
|
||||
await countryGeometry.preloadCountryGeometry();
|
||||
|
||||
assert.deepEqual(countryGeometry.getCountryBbox('PK'), [0, 0, 2, 2]);
|
||||
assert.deepEqual(countryGeometry.getCountryAtCoordinates(1.5, 1.5), {
|
||||
code: 'PK',
|
||||
name: 'Pakistan',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user