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),
  }));
}