Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/app-context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NewsItem, Monitor, PanelConfig, MapLayers, InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryFlightCluster, MilitaryVessel, MilitaryVesselCluster, CyberThreat, USNIFleetReport } from '@/types';
import type { AirportDelayAlert } from '@/services/aviation';
import type { IranEvent } from '@/generated/client/worldmonitor/conflict/v1/service_client';
import type { SecurityAdvisory } from '@/services/security-advisories';
import type { MapContainer, Panel, NewsPanel, SignalModal, StatusPanel, SearchModal } from '@/components';
import type { IntelligenceGapBadge } from '@/components';
import type { MarketData, ClusteredEvent } from '@/types';
Expand Down Expand Up @@ -38,6 +39,8 @@ export interface CountryBriefSignals {
orefSirens: number;
orefHistory24h: number;
aviationDisruptions: number;
travelAdvisories: number;
travelAdvisoryMaxLevel: string | null;
isTier1: boolean;
}

Expand All @@ -50,6 +53,7 @@ export interface IntelligenceCache {
usniFleet?: USNIFleetReport;
iranEvents?: IranEvent[];
orefAlerts?: { alertCount: number; historyCount24h: number };
advisories?: SecurityAdvisory[];
}

export interface AppModule {
Expand Down
24 changes: 24 additions & 0 deletions src/app/country-intel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ export class CountryIntelManager implements AppModule {
context.regionalConvergence = convergences.map((r) => r.description);
}

if (this.ctx.intelligenceCache.advisories) {
const countryAdvisories = this.ctx.intelligenceCache.advisories.filter(a => a.country === code);
if (countryAdvisories.length > 0) {
context.travelAdvisories = countryAdvisories.map(a => ({ source: a.source, level: a.level, title: a.title }));
}
}

const headlines = filteredNews.slice(0, 15).map((n) => n.title);
if (headlines.length) context.headlines = headlines;

Expand Down Expand Up @@ -237,6 +244,8 @@ export class CountryIntelManager implements AppModule {
if (signals.militaryFlights > 0) lines.push(t('countryBrief.fallback.aircraftTracked', { count: String(signals.militaryFlights) }));
if (signals.militaryVessels > 0) lines.push(t('countryBrief.fallback.vesselsTracked', { count: String(signals.militaryVessels) }));
if (signals.activeStrikes > 0) lines.push(t('countryBrief.fallback.activeStrikes', { count: String(signals.activeStrikes) }));
if (signals.travelAdvisoryMaxLevel === 'do-not-travel') lines.push(`⚠️ Travel advisory: Do Not Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`);
else if (signals.travelAdvisoryMaxLevel === 'reconsider') lines.push(`⚠️ Travel advisory: Reconsider Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`);
if (signals.outages > 0) lines.push(t('countryBrief.fallback.internetOutages', { count: String(signals.outages) }));
if (signals.earthquakes > 0) lines.push(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) }));
if (context.stockIndex) lines.push(t('countryBrief.fallback.stockIndex', { value: context.stockIndex }));
Expand Down Expand Up @@ -402,6 +411,19 @@ export class CountryIntelManager implements AppModule {
orefHistory24h = this.ctx.intelligenceCache.orefAlerts.historyCount24h;
}

let travelAdvisories = 0;
let travelAdvisoryMaxLevel: string | null = null;
const advisoryLevelRank: Record<string, number> = { 'do-not-travel': 4, 'reconsider': 3, 'caution': 2, 'normal': 1, 'info': 0 };
if (this.ctx.intelligenceCache.advisories) {
const countryAdvisories = this.ctx.intelligenceCache.advisories.filter(a => a.country === code);
travelAdvisories = countryAdvisories.length;
for (const a of countryAdvisories) {
if (a.level && (advisoryLevelRank[a.level] || 0) > (advisoryLevelRank[travelAdvisoryMaxLevel || ''] || 0)) {
travelAdvisoryMaxLevel = a.level;
}
}
}

return {
protests,
militaryFlights,
Expand All @@ -415,6 +437,8 @@ export class CountryIntelManager implements AppModule {
orefSirens,
orefHistory24h,
aviationDisruptions,
travelAdvisories,
travelAdvisoryMaxLevel,
isTier1,
};
}
Expand Down
4 changes: 3 additions & 1 deletion src/app/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import { updateAndCheck } from '@/services/temporal-baseline';
import { fetchAllFires, flattenFires, computeRegionStats, toMapFires } from '@/services/wildfires';
import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresence, foreignPresenceToSignal, type TheaterPostureSummary } from '@/services/military-surge';
import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, ingestStrikesForCII, ingestOrefForCII, ingestAviationForCII, isInLearningMode } from '@/services/country-instability';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, ingestStrikesForCII, ingestOrefForCII, ingestAviationForCII, ingestAdvisoriesForCII, isInLearningMode } from '@/services/country-instability';
import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchUcdpEvents, deduplicateAgainstAcled, fetchIranEvents } from '@/services/conflict';
import { fetchUnhcrPopulation } from '@/services/displacement';
Expand Down Expand Up @@ -1940,6 +1940,8 @@ export class DataLoaderManager implements AppModule {
const result = await fetchSecurityAdvisories();
if (result.ok) {
(this.ctx.panels['security-advisories'] as SecurityAdvisoriesPanel)?.setData(result.advisories);
this.ctx.intelligenceCache.advisories = result.advisories;
ingestAdvisoriesForCII(result.advisories);
}
} catch (error) {
console.error('[App] Security advisories fetch failed:', error);
Expand Down
11 changes: 11 additions & 0 deletions src/components/CountryBriefPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ export class CountryBriefPage {
if (signals.climateStress > 0) chips.push(`<span class="signal-chip climate">🌡️ ${t('modals.countryBrief.signals.climate')}</span>`);
if (signals.conflictEvents > 0) chips.push(`<span class="signal-chip conflict">⚔️ ${signals.conflictEvents} ${t('modals.countryBrief.signals.conflictEvents')}</span>`);
if (signals.activeStrikes > 0) chips.push(`<span class="signal-chip conflict">\u{1F4A5} ${signals.activeStrikes} ${t('modals.countryBrief.signals.activeStrikes')}</span>`);
if (signals.travelAdvisories > 0 && signals.travelAdvisoryMaxLevel) {
const advisoryClass = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? 'conflict'
: signals.travelAdvisoryMaxLevel === 'reconsider' ? 'outage'
: 'military';
const advisoryLabel = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? 'Do Not Travel'
: signals.travelAdvisoryMaxLevel === 'reconsider' ? 'Reconsider Travel'
: 'Exercise Caution';
chips.push(`<span class="signal-chip ${advisoryClass}">\u26A0\uFE0F ${signals.travelAdvisories} Advisory: ${advisoryLabel}</span>`);
}
if (signals.orefSirens > 0) chips.push(`<span class="signal-chip conflict">\u{1F6A8} ${signals.orefSirens} Active Sirens</span>`);
if (signals.aviationDisruptions > 0) chips.push(`<span class="signal-chip outage">\u{1F6AB} ${signals.aviationDisruptions} ${t('modals.countryBrief.signals.aviationDisruptions')}</span>`);
chips.push(`<span class="signal-chip stock-loading">📈 ${t('modals.countryBrief.loadingIndex')}</span>`);
Expand Down Expand Up @@ -613,6 +622,8 @@ export class CountryBriefPage {
orefSirens: this.currentSignals.orefSirens,
orefHistory24h: this.currentSignals.orefHistory24h,
aviationDisruptions: this.currentSignals.aviationDisruptions,
travelAdvisories: this.currentSignals.travelAdvisories,
travelAdvisoryMaxLevel: this.currentSignals.travelAdvisoryMaxLevel,
};
}
if (this.currentBrief) data.brief = this.currentBrief;
Expand Down
62 changes: 57 additions & 5 deletions src/services/country-instability.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SocialUnrestEvent, MilitaryFlight, MilitaryVessel, ClusteredEvent, InternetOutage } from '@/types';
import type { AirportDelayAlert } from '@/services/aviation';
import type { SecurityAdvisory } from '@/services/security-advisories';
import { tokenizeForMatch, matchKeyword } from '@/utils/keyword-match';
import { INTEL_HOTSPOTS, CONFLICT_ZONES, STRATEGIC_WATERWAYS } from '@/config/geo';
import { CURATED_COUNTRIES, DEFAULT_BASELINE_RISK, DEFAULT_EVENT_MULTIPLIER, getHotspotCountries } from '@/config/countries';
Expand Down Expand Up @@ -42,6 +43,9 @@ interface CountryData {
climateStress: number;
orefAlertCount: number;
orefHistoryCount24h: number;
advisoryMaxLevel: SecurityAdvisory['level'] | null;
advisoryCount: number;
advisorySources: Set<string>;
}

export { TIER1_COUNTRIES } from '@/config/countries';
Expand Down Expand Up @@ -123,7 +127,7 @@ const countryDataMap = new Map<string, CountryData>();
const previousScores = new Map<string, number>();

function initCountryData(): CountryData {
return { protests: [], conflicts: [], ucdpStatus: null, hapiSummary: null, militaryFlights: [], militaryVessels: [], newsEvents: [], outages: [], strikes: [], aviationDisruptions: [], displacementOutflow: 0, climateStress: 0, orefAlertCount: 0, orefHistoryCount24h: 0 };
return { protests: [], conflicts: [], ucdpStatus: null, hapiSummary: null, militaryFlights: [], militaryVessels: [], newsEvents: [], outages: [], strikes: [], aviationDisruptions: [], displacementOutflow: 0, climateStress: 0, orefAlertCount: 0, orefHistoryCount24h: 0, advisoryMaxLevel: null, advisoryCount: 0, advisorySources: new Set() };
}

const newsEventIndexMap = new Map<string, Map<string, number>>();
Expand Down Expand Up @@ -445,6 +449,52 @@ export function ingestAviationForCII(alerts: AirportDelayAlert[]): void {
}
}

const TRAVEL_ADVISORY_SOURCES = new Set(['US', 'AU', 'UK', 'NZ']);
const ADVISORY_LEVEL_RANK: Record<string, number> = { 'do-not-travel': 4, 'reconsider': 3, 'caution': 2, 'normal': 1, 'info': 0 };

export function ingestAdvisoriesForCII(advisories: SecurityAdvisory[]): void {
for (const data of countryDataMap.values()) {
data.advisoryMaxLevel = null;
data.advisoryCount = 0;
data.advisorySources = new Set();
}

const travelAdvisories = advisories.filter(a =>
a.country && TRAVEL_ADVISORY_SOURCES.has(a.sourceCountry) && a.level && a.level !== 'info'
);

for (const a of travelAdvisories) {
const code = a.country!;
if (!countryDataMap.has(code)) countryDataMap.set(code, initCountryData());
const data = countryDataMap.get(code)!;
data.advisoryCount++;
data.advisorySources.add(a.sourceCountry);
const currentRank = ADVISORY_LEVEL_RANK[data.advisoryMaxLevel || ''] || 0;
const newRank = ADVISORY_LEVEL_RANK[a.level!] || 0;
if (newRank > currentRank) data.advisoryMaxLevel = a.level!;
}
}

function getAdvisoryBoost(data: CountryData): number {
if (!data.advisoryMaxLevel) return 0;
let boost = 0;
switch (data.advisoryMaxLevel) {
case 'do-not-travel': boost = 15; break;
case 'reconsider': boost = 10; break;
case 'caution': boost = 5; break;
default: return 0;
}
if (data.advisorySources.size >= 3) boost += 5;
else if (data.advisorySources.size >= 2) boost += 3;
return boost;
}

function getAdvisoryFloor(data: CountryData): number {
if (data.advisoryMaxLevel === 'do-not-travel') return 60;
if (data.advisoryMaxLevel === 'reconsider') return 50;
return 0;
}

function calcUnrestScore(data: CountryData, countryCode: string): number {
const protestCount = data.protests.length;
const multiplier = CURATED_COUNTRIES[countryCode]?.eventMultiplier ?? DEFAULT_EVENT_MULTIPLIER;
Expand Down Expand Up @@ -662,9 +712,10 @@ export function calculateCII(): CountryScore[] {
: 0;
const climateBoost = data.climateStress;

const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data);
const advisoryBoost = getAdvisoryBoost(data);
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data) + advisoryBoost;

const floor = getUcdpFloor(data);
const floor = Math.max(getUcdpFloor(data), getAdvisoryFloor(data));
const score = Math.round(Math.min(100, Math.max(floor, blendedScore)));

const prev = previousScores.get(code) ?? score;
Expand Down Expand Up @@ -715,8 +766,9 @@ export function getCountryScore(code: string): number | null {
: data.displacementOutflow >= 100_000 ? 4
: 0;
const climateBoost = data.climateStress;
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data);
const advisoryBoost = getAdvisoryBoost(data);
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data) + advisoryBoost;

const floor = getUcdpFloor(data);
const floor = Math.max(getUcdpFloor(data), getAdvisoryFloor(data));
return Math.round(Math.min(100, Math.max(floor, blendedScore)));
}
43 changes: 30 additions & 13 deletions src/services/security-advisories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isDesktopRuntime } from '@/services/runtime';
import { proxyUrl } from '@/utils';
import { getPersistentCache, setPersistentCache } from './persistent-cache';
import { dataFreshness } from './data-freshness';
import { nameToCountryCode, matchCountryNamesInText } from './country-geometry';

function advisoryFeedUrl(feedUrl: string): string {
if (isDesktopRuntime()) return proxyUrl(feedUrl);
Expand Down Expand Up @@ -29,6 +30,7 @@ interface AdvisoryFeed {
sourceCountry: string;
url: string;
parseLevel?: (title: string) => SecurityAdvisory['level'];
targetCountry?: string;
}

const US_LEVEL_RE = /Level (\d)/i;
Expand Down Expand Up @@ -96,19 +98,19 @@ const ADVISORY_FEEDS: AdvisoryFeed[] = [
url: 'https://www.gov.uk/foreign-travel-advice.atom',
},
// US Embassy security alerts (per-country)
{ name: 'US Embassy Thailand', sourceCountry: 'US', url: 'https://th.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy UAE', sourceCountry: 'US', url: 'https://ae.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Germany', sourceCountry: 'US', url: 'https://de.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Ukraine', sourceCountry: 'US', url: 'https://ua.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Mexico', sourceCountry: 'US', url: 'https://mx.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy India', sourceCountry: 'US', url: 'https://in.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Pakistan', sourceCountry: 'US', url: 'https://pk.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Colombia', sourceCountry: 'US', url: 'https://co.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Poland', sourceCountry: 'US', url: 'https://pl.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Bangladesh', sourceCountry: 'US', url: 'https://bd.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Italy', sourceCountry: 'US', url: 'https://it.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Dominican Republic', sourceCountry: 'US', url: 'https://do.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Myanmar', sourceCountry: 'US', url: 'https://mm.usembassy.gov/category/alert/feed/' },
{ name: 'US Embassy Thailand', sourceCountry: 'US', url: 'https://th.usembassy.gov/category/alert/feed/', targetCountry: 'TH' },
{ name: 'US Embassy UAE', sourceCountry: 'US', url: 'https://ae.usembassy.gov/category/alert/feed/', targetCountry: 'AE' },
{ name: 'US Embassy Germany', sourceCountry: 'US', url: 'https://de.usembassy.gov/category/alert/feed/', targetCountry: 'DE' },
{ name: 'US Embassy Ukraine', sourceCountry: 'US', url: 'https://ua.usembassy.gov/category/alert/feed/', targetCountry: 'UA' },
{ name: 'US Embassy Mexico', sourceCountry: 'US', url: 'https://mx.usembassy.gov/category/alert/feed/', targetCountry: 'MX' },
{ name: 'US Embassy India', sourceCountry: 'US', url: 'https://in.usembassy.gov/category/alert/feed/', targetCountry: 'IN' },
{ name: 'US Embassy Pakistan', sourceCountry: 'US', url: 'https://pk.usembassy.gov/category/alert/feed/', targetCountry: 'PK' },
{ name: 'US Embassy Colombia', sourceCountry: 'US', url: 'https://co.usembassy.gov/category/alert/feed/', targetCountry: 'CO' },
{ name: 'US Embassy Poland', sourceCountry: 'US', url: 'https://pl.usembassy.gov/category/alert/feed/', targetCountry: 'PL' },
{ name: 'US Embassy Bangladesh', sourceCountry: 'US', url: 'https://bd.usembassy.gov/category/alert/feed/', targetCountry: 'BD' },
{ name: 'US Embassy Italy', sourceCountry: 'US', url: 'https://it.usembassy.gov/category/alert/feed/', targetCountry: 'IT' },
{ name: 'US Embassy Dominican Republic', sourceCountry: 'US', url: 'https://do.usembassy.gov/category/alert/feed/', targetCountry: 'DO' },
{ name: 'US Embassy Myanmar', sourceCountry: 'US', url: 'https://mm.usembassy.gov/category/alert/feed/', targetCountry: 'MM' },
// Health advisories
{ name: 'CDC Travel Notices', sourceCountry: 'US', url: 'https://wwwnc.cdc.gov/travel/rss/notices.xml' },
{ name: 'ECDC Epidemiological Updates', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/1310/feed' },
Expand All @@ -120,6 +122,19 @@ const ADVISORY_FEEDS: AdvisoryFeed[] = [
{ name: 'WHO Africa Emergencies', sourceCountry: 'INT', url: 'https://www.afro.who.int/rss/emergencies.xml' },
];

function extractTargetCountry(title: string, feed: AdvisoryFeed): string | undefined {
if (feed.targetCountry) return feed.targetCountry;
if (feed.sourceCountry === 'EU' || feed.sourceCountry === 'INT') return undefined;
const parts = title.split(/\s*[–—-]\s*/);
const firstPart = parts[0];
if (parts.length >= 2 && firstPart) {
const code = nameToCountryCode(firstPart.trim().toLowerCase());
if (code) return code;
}
const matches = matchCountryNamesInText(title.toLowerCase());
return matches[0] || undefined;
}

const CACHE_KEY = 'security-advisories';
const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
let lastFetch = 0;
Expand Down Expand Up @@ -157,6 +172,7 @@ function parseFeedXml(
const pubDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed;

const level = feed.parseLevel ? feed.parseLevel(title) : 'info';
const country = extractTargetCountry(title, feed);

return {
title,
Expand All @@ -165,6 +181,7 @@ function parseFeedXml(
source: feed.name,
sourceCountry: feed.sourceCountry,
level,
...(country ? { country } : {}),
};
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/utils/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface CountryBriefExport {
level?: string;
trend?: string;
components?: { unrest: number; conflict: number; security: number; information: number };
signals?: Record<string, number>;
signals?: Record<string, number | string | null>;
brief?: string;
headlines?: Array<{ title: string; source: string; link: string; pubDate?: string }>;
generatedAt: string;
Expand Down