File size: 5,314 Bytes
fa9c65f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | /**
* Population exposure core — shared between the dashboard
* (src/services/population-exposure.ts) and the server RPC handler
* (server/worldmonitor/displacement/v1/get-population-exposure.ts).
*
* Country-density approximation: nearest priority-country centroid supplies a
* pop/area density, multiplied by the event radius disc. Deliberately coarse —
* there is no city-level population dataset in the repo.
*
* Dependency-free: importable from Vite client code, Vercel Edge bundles,
* server handlers, and tsx tests alike.
*/
export interface PriorityCountryInfo {
name: string;
pop: number;
area: number;
}
export interface ExposureEstimate {
exposedPopulation: number;
exposureRadiusKm: number;
nearestCountry: string;
densityPerKm2: number;
}
export interface CountryPopulation {
code: string;
name: string;
population: number;
densityPerKm2: number;
}
export const PRIORITY_COUNTRIES: Record<string, PriorityCountryInfo> = {
UKR: { name: 'Ukraine', pop: 37000000, area: 603550 },
RUS: { name: 'Russia', pop: 144100000, area: 17098242 },
ISR: { name: 'Israel', pop: 9800000, area: 22072 },
PSE: { name: 'Palestine', pop: 5400000, area: 6020 },
SYR: { name: 'Syria', pop: 22100000, area: 185180 },
IRN: { name: 'Iran', pop: 88600000, area: 1648195 },
TWN: { name: 'Taiwan', pop: 23600000, area: 36193 },
ETH: { name: 'Ethiopia', pop: 126500000, area: 1104300 },
SDN: { name: 'Sudan', pop: 48100000, area: 1861484 },
SSD: { name: 'South Sudan', pop: 11400000, area: 619745 },
SOM: { name: 'Somalia', pop: 18100000, area: 637657 },
YEM: { name: 'Yemen', pop: 34400000, area: 527968 },
AFG: { name: 'Afghanistan', pop: 42200000, area: 652230 },
PAK: { name: 'Pakistan', pop: 240500000, area: 881913 },
IND: { name: 'India', pop: 1428600000, area: 3287263 },
MMR: { name: 'Myanmar', pop: 54200000, area: 676578 },
COD: { name: 'DR Congo', pop: 102300000, area: 2344858 },
NGA: { name: 'Nigeria', pop: 223800000, area: 923768 },
MLI: { name: 'Mali', pop: 22600000, area: 1240192 },
BFA: { name: 'Burkina Faso', pop: 22700000, area: 274200 },
};
export const EXPOSURE_CENTROIDS: Record<string, [number, number]> = {
UKR: [48.4, 31.2], RUS: [61.5, 105.3], ISR: [31.0, 34.8], PSE: [31.9, 35.2],
SYR: [35.0, 38.0], IRN: [32.4, 53.7], TWN: [23.7, 121.0], ETH: [9.1, 40.5],
SDN: [15.5, 32.5], SSD: [6.9, 31.3], SOM: [5.2, 46.2], YEM: [15.6, 48.5],
AFG: [33.9, 67.7], PAK: [30.4, 69.3], IND: [20.6, 79.0], MMR: [19.8, 96.7],
COD: [-4.0, 21.8], NGA: [9.1, 7.5], MLI: [17.6, -4.0], BFA: [12.3, -1.6],
};
const FALLBACK_INFO: PriorityCountryInfo = { name: '', pop: 50_000_000, area: 500_000 };
/**
* Ceiling for new agent-facing exposure requests.
*
* The estimate multiplies ONE country's average density across the whole
* disc, so it is only meaningful while the disc stays inside terrain that
* density describes. The event radii this model ships with top out at 100 km;
* 1000 km is a generous bound that still keeps the arithmetic defensible.
* Without it an unclamped caller radius produces a confidently-wrong number —
* radius 20000 yields ~5.6e11 people, ~69x world population — which is worse
* than refusing, because it looks like an answer.
*
* The existing v1 REST RPC predates this ceiling and keeps its unbounded
* contract. New callers that accept free-form radii use
* `computeBoundedExposure`; callers with already-bounded domain radii use
* `computeExposure`.
*/
export const MAX_EXPOSURE_RADIUS_KM = 1000;
export function computeExposure(lat: number, lon: number, radiusKm: number): ExposureEstimate {
const effectiveRadiusKm = Math.max(0, Number.isFinite(radiusKm) ? radiusKm : 0);
let bestMatch: string | null = null;
let bestDist = Infinity;
for (const [code, [cLat, cLon]] of Object.entries(EXPOSURE_CENTROIDS)) {
const dist = Math.sqrt((lat - cLat) ** 2 + (lon - cLon) ** 2);
if (dist < bestDist) {
bestDist = dist;
bestMatch = code;
}
}
const info = bestMatch ? PRIORITY_COUNTRIES[bestMatch] ?? FALLBACK_INFO : FALLBACK_INFO;
const density = info.pop / info.area;
const areaKm2 = Math.PI * effectiveRadiusKm * effectiveRadiusKm;
return {
exposedPopulation: Math.round(density * areaKm2),
exposureRadiusKm: effectiveRadiusKm,
nearestCountry: bestMatch || '',
densityPerKm2: Math.round(density),
};
}
export function computeBoundedExposure(
lat: number,
lon: number,
radiusKm: number,
): ExposureEstimate {
const boundedRadiusKm = Number.isFinite(radiusKm)
? Math.min(MAX_EXPOSURE_RADIUS_KM, Math.max(0, radiusKm))
: 0;
return computeExposure(lat, lon, boundedRadiusKm);
}
export function getRadiusForEventType(type: string): number {
switch (type) {
case 'conflict':
case 'battle':
case 'state-based':
case 'non-state':
case 'one-sided':
return 50;
case 'earthquake':
return 100;
case 'flood':
return 100;
case 'fire':
case 'wildfire':
return 30;
default:
return 50;
}
}
export function listCountryPopulations(): CountryPopulation[] {
return Object.entries(PRIORITY_COUNTRIES).map(([code, info]) => ({
code,
name: info.name,
population: info.pop,
densityPerKm2: Math.round(info.pop / info.area),
}));
}
|