Ctrl K
ComunidadGuías…

scripts/active-wars.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// "What wars are happening right now?" answered from UCDP's latest month.
//
// conflict:ucdp-events:v1 holds UCDP's newest monthly candidate release, a
// complete calendar month, so the answer reads that month. It counts the
// countries where state-based fighting (a government on at least one side)
// killed at least ACTIVE_WAR_DEATH_THRESHOLD people: UCDP's armed-conflict
// threshold, applied to one month instead of a year.
import fs from 'node:fs';
import path from 'node:path';

import { feature as topojsonFeature } from 'topojson-client';

import { resolveIso2 } from './_country-resolver.mjs';

export const ACTIVE_WAR_DEATH_THRESHOLD = 25;
export const ACTIVE_WARS_SOURCE = 'UCDP Georeferenced Event Dataset (candidate release)';
const STATE_BASED = 'UCDP_VIOLENCE_TYPE_STATE_BASED';
// UCDP publishes a month roughly four weeks after it ends. A reference month
// that ended longer ago than this means the candidate feed stopped, and the
// list would answer "right now" with a stale war count.
export const MAX_REFERENCE_MONTH_AGE_DAYS = 75;
const DAY_MS = 24 * 60 * 60 * 1000;

function isoDay(ms) {
  return new Date(ms).toISOString().slice(0, 10);
}

// UCDP appends the historical name: "Yemen (North Yemen)", "Government of
// Russia (Soviet Union)". Readers know the current one.
function withoutHistoricalName(name) {
  return String(name ?? '').replace(/\s*\([^)]*\)\s*$/, '').trim();
}

// Candidate releases code actors UCDP has not identified yet as "XXX" plus
// the Gleditsch-Ward country number (XXX475 is Nigeria).
function actorName(side) {
  const name = withoutHistoricalName(side);
  return /^XXX\d+$/.test(name) ? '' : name;
}

function inRing(lon, lat, ring) {
  let inside = false;
  for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
    const [xi, yi] = ring[i];
    const [xj, yj] = ring[j];
    if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
  }
  return inside;
}

/**
 * UCDP files fighting in Gaza and the West Bank under Israel. World Monitor
 * counts an event where it happens (owner ruling 2026-10-23, CII v15 B7), so
 * the freeze passes this test and those events count for Palestine.
 */
// Natural Earth 1:50m. The 1:110m countries.geojson draws Gaza as a triangle
// that leaves out Gaza City.
const PALESTINE_ISO_NUMERIC = '275';

export function palestineLocator(rootDir) {
  const topology = JSON.parse(fs.readFileSync(path.join(rootDir, 'public', 'data', 'countries-50m.json'), 'utf8'));
  const geometry = topology.objects.countries.geometries.find((entry) => entry.id === PALESTINE_ISO_NUMERIC);
  if (!geometry) throw new Error('countries-50m.json has no Palestine geometry');
  const { geometry: shape } = topojsonFeature(topology, geometry);
  const polygons = shape.type === 'MultiPolygon' ? shape.coordinates : [shape.coordinates];
  return (lat, lon) => Number.isFinite(lat) && Number.isFinite(lon) && polygons.some(([outer, ...holes]) => (
    inRing(lon, lat, outer) && !holes.some((hole) => inRing(lon, lat, hole))
  ));
}

function monthBounds(ms) {
  const date = new Date(ms);
  const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
  const end = Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
  return { start, end, month: new Date(start).toISOString().slice(0, 7) };
}

/**
 * @param {Array<object>} events ListUcdpEventsResponse.events
 * @param {number} capturedAtMs
 * @param {{ inPalestine?: (lat: number, lon: number) => boolean }} [options]
 */
export function activeWarsReading(events, capturedAtMs, { inPalestine = () => false } = {}) {
  const dated = (Array.isArray(events) ? events : [])
    .filter((event) => Number.isFinite(Number(event?.dateStart)));
  if (dated.length === 0) throw new Error('UCDP returned no dated events');
  const latestMs = Math.max(...dated.map((event) => Number(event.dateStart)));
  const { start, end, month } = monthBounds(latestMs);
  const ageDays = (capturedAtMs - end) / DAY_MS;
  if (ageDays > MAX_REFERENCE_MONTH_AGE_DAYS) {
    throw new Error(`UCDP latest month ${month} ended ${Math.floor(ageDays)} days before capture`);
  }

  const byCountry = new Map();
  for (const event of dated) {
    const startMs = Number(event.dateStart);
    if (startMs < start || startMs >= end || event.violenceType !== STATE_BASED) continue;
    const filedName = withoutHistoricalName(event.country);
    if (!filedName) continue;
    const name = filedName === 'Israel'
      && inPalestine(Number(event.location?.latitude), Number(event.location?.longitude))
      ? 'Palestine'
      : filedName;
    const deaths = Math.max(0, Math.round(Number(event.deathsBest) || 0));
    const row = byCountry.get(name) ?? { name, deaths: 0, events: 0, dyads: new Map() };
    row.deaths += deaths;
    row.events += 1;
    const sideA = actorName(event.sideA);
    const sideB = actorName(event.sideB);
    const dyadKey = `${sideA}\u0000${sideB}`;
    const dyad = row.dyads.get(dyadKey) ?? { sideA, sideB, deaths: 0 };
    dyad.deaths += deaths;
    row.dyads.set(dyadKey, dyad);
    byCountry.set(name, row);
  }
  if (byCountry.size === 0) throw new Error(`UCDP latest month ${month} has no state-based events`);

  const rows = [...byCountry.values()].sort((a, b) => b.deaths - a.deaths || a.name.localeCompare(b.name, 'en-US'));
  const conflicts = rows
    .filter((row) => row.deaths >= ACTIVE_WAR_DEATH_THRESHOLD)
    .map((row) => {
      // The deadliest pair, left unnamed when UCDP has not identified it: a
      // minor named pair would misstate who is doing most of the fighting.
      const [dyad] = [...row.dyads.values()].sort((a, b) => b.deaths - a.deaths);
      const identified = Boolean(dyad.sideA && dyad.sideB);
      return {
        name: row.name,
        code: resolveIso2({ name: row.name }),
        deaths: row.deaths,
        events: row.events,
        sideA: identified ? dyad.sideA : '',
        sideB: identified ? dyad.sideB : '',
      };
    });
  return {
    source: ACTIVE_WARS_SOURCE,
    referenceMonth: month,
    latestEventDate: isoDay(latestMs),
    threshold: ACTIVE_WAR_DEATH_THRESHOLD,
    countryCount: conflicts.length,
    belowThresholdCount: rows.length - conflicts.length,
    conflicts,
  };
}