Files
worldmonitor/scripts/fetch-gpsjam.mjs
Elie Habib ff98e3eac7 feat: add GPS/GNSS jamming map layer + CII integration (#570)
* feat: add GPS/GNSS jamming data ingestion from gpsjam.org

- scripts/fetch-gpsjam.mjs: standalone fetcher that downloads daily H3
  hex data, filters medium/high interference, converts to lat/lon via
  h3-js, and writes JSON. Can be run on cron.
- api/gpsjam.js: Vercel Edge Function that proxies gpsjam.org data with
  1hr cache, returns medium/high hexes for frontend consumption.
- src/services/gps-interference.ts: frontend service that fetches from
  the Edge API, converts H3→lat/lon, and classifies by conflict region.
- h3-js added as dependency for hex→coordinate conversion.

* feat: add GPS jamming map layer, CII integration, and country brief signals

Wire gpsjam.org data into map visualization, instability scoring, and
country intelligence. ScatterplotLayer renders high (red) and medium
(orange) interference hexes. CII security score incorporates jamming
counts per country via h3→country geocoding with cache. Country briefs
show jamming zone chip. Full i18n across 18 locales including popup
labels. Data loads with intelligence signals cycle (15min), gated by
1hr client-side cache.
2026-02-28 23:10:15 +04:00

204 lines
7.4 KiB
JavaScript

/**
* Fetches GPS/GNSS interference data from gpsjam.org.
* Outputs medium & high interference hexagons with lat/lon centroids.
*
* Data source: gpsjam.org (ADS-B Exchange derived)
* Format: H3 resolution-4 hexagons with good/bad aircraft counts.
* Levels: Low (0-2%), Medium (2-10%), High (>10%) of aircraft with GPS issues.
*
* Run: node scripts/fetch-gpsjam.mjs [--date YYYY-MM-DD] [--min-aircraft 3] [--output path.json]
* Cron: Can be called daily; data updates once per day.
*/
import { cellToLatLng, getResolution } from 'h3-js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(__dirname, 'data');
const BASE_URL = 'https://gpsjam.org/data';
const UA = 'Mozilla/5.0 (compatible; WorldMonitor/1.0)';
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
function getArg(name, fallback) {
const idx = args.indexOf(`--${name}`);
return idx >= 0 && args[idx + 1] ? args[idx + 1] : fallback;
}
const requestedDate = getArg('date', null);
const minAircraft = parseInt(getArg('min-aircraft', '3'), 10);
const outputPath = getArg('output', null);
// ---------------------------------------------------------------------------
// Fetch helpers
// ---------------------------------------------------------------------------
async function fetchText(url) {
const resp = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept-Encoding': 'gzip, deflate',
},
});
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
return resp.text();
}
// ---------------------------------------------------------------------------
// Get latest available date from manifest
// ---------------------------------------------------------------------------
async function getLatestDate() {
const csv = await fetchText(`${BASE_URL}/manifest.csv`);
const lines = csv.trim().split('\n');
// Last line: date,suspect,num_bad_aircraft_hexes
const last = lines[lines.length - 1];
return last.split(',')[0];
}
// ---------------------------------------------------------------------------
// Fetch & parse hex data
// ---------------------------------------------------------------------------
async function fetchHexData(date) {
const url = `${BASE_URL}/${date}-h3_4.csv`;
console.error(`[gpsjam] Fetching ${url}`);
const csv = await fetchText(url);
const lines = csv.trim().split('\n');
const header = lines[0]; // hex,count_good_aircraft,count_bad_aircraft
if (!header.includes('hex')) throw new Error(`Unexpected CSV header: ${header}`);
const results = [];
let skippedLowSample = 0;
let skippedLow = 0;
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].split(',');
if (parts.length < 3) continue;
const hex = parts[0];
const good = parseInt(parts[1], 10);
const bad = parseInt(parts[2], 10);
const total = good + bad;
// Skip hexes with too few aircraft (noisy data)
if (total < minAircraft) { skippedLowSample++; continue; }
const pct = (bad / total) * 100;
let level;
if (pct > 10) level = 'high';
else if (pct >= 2) level = 'medium';
else { skippedLow++; continue; }
// H3 hex → lat/lon centroid
let lat, lon;
try {
const [lt, ln] = cellToLatLng(hex);
lat = Math.round(lt * 1e5) / 1e5;
lon = Math.round(ln * 1e5) / 1e5;
} catch {
continue; // invalid hex
}
results.push({
h3: hex,
lat,
lon,
level,
pct: Math.round(pct * 10) / 10,
good,
bad,
total,
});
}
// Sort: high first, then by interference % descending
results.sort((a, b) => {
if (a.level !== b.level) return a.level === 'high' ? -1 : 1;
return b.pct - a.pct;
});
return { results, skippedLowSample, skippedLow, totalRows: lines.length - 1 };
}
// ---------------------------------------------------------------------------
// Country lookup (approximate, from lat/lon → nearest known region)
// ---------------------------------------------------------------------------
function classifyRegion(lat, lon) {
// Rough bounding boxes for conflict-relevant regions
if (lat >= 29 && lat <= 42 && lon >= 43 && lon <= 63) return 'iran-iraq';
if (lat >= 31 && lat <= 37 && lon >= 35 && lon <= 43) return 'levant';
if (lat >= 28 && lat <= 34 && lon >= 29 && lon <= 36) return 'israel-sinai';
if (lat >= 44 && lat <= 53 && lon >= 22 && lon <= 41) return 'ukraine-russia';
if (lat >= 54 && lat <= 70 && lon >= 27 && lon <= 60) return 'russia-north';
if (lat >= 36 && lat <= 42 && lon >= 26 && lon <= 45) return 'turkey-caucasus';
if (lat >= 32 && lat <= 38 && lon >= 63 && lon <= 75) return 'afghanistan-pakistan';
if (lat >= 10 && lat <= 20 && lon >= 42 && lon <= 55) return 'yemen-horn';
if (lat >= 0 && lat <= 12 && lon >= 32 && lon <= 48) return 'east-africa';
if (lat >= 15 && lat <= 24 && lon >= 25 && lon <= 40) return 'sudan-sahel';
if (lat >= 50 && lat <= 72 && lon >= -10 && lon <= 25) return 'northern-europe';
if (lat >= 35 && lat <= 50 && lon >= -10 && lon <= 25) return 'western-europe';
if (lat >= 1 && lat <= 8 && lon >= 95 && lon <= 108) return 'southeast-asia';
if (lat >= 20 && lat <= 45 && lon >= 100 && lon <= 145) return 'east-asia';
if (lat >= 25 && lat <= 50 && lon >= -125 && lon <= -65) return 'north-america';
return 'other';
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const date = requestedDate || await getLatestDate();
console.error(`[gpsjam] Date: ${date}, min aircraft: ${minAircraft}`);
const { results, skippedLowSample, skippedLow, totalRows } = await fetchHexData(date);
const highCount = results.filter(r => r.level === 'high').length;
const mediumCount = results.filter(r => r.level === 'medium').length;
// Add region tags
for (const r of results) {
r.region = classifyRegion(r.lat, r.lon);
}
const output = {
date,
fetchedAt: new Date().toISOString(),
source: 'gpsjam.org',
attribution: 'Data derived from ADS-B Exchange via gpsjam.org',
minAircraftThreshold: minAircraft,
stats: {
totalHexes: totalRows,
mediumCount,
highCount,
skippedLowSample,
skippedLow,
},
hexes: results,
};
console.error(`[gpsjam] ${totalRows} total hexes → ${highCount} high, ${mediumCount} medium (skipped: ${skippedLowSample} low-sample, ${skippedLow} low-interference)`);
if (outputPath) {
mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
writeFileSync(path.resolve(outputPath), JSON.stringify(output, null, 2));
console.error(`[gpsjam] Written to ${outputPath}`);
} else {
// Default: write to scripts/data/gpsjam-latest.json and also stdout
mkdirSync(DATA_DIR, { recursive: true });
const defaultPath = path.join(DATA_DIR, 'gpsjam-latest.json');
writeFileSync(defaultPath, JSON.stringify(output, null, 2));
console.error(`[gpsjam] Written to ${defaultPath}`);
// Also output to stdout for piping
process.stdout.write(JSON.stringify(output));
}
}
main().catch(err => {
console.error(`[gpsjam] Fatal: ${err.message}`);
process.exit(1);
});