Merge branch 'claude/add-deckgl-visualization-D1VHX' into main

Adds DeckGL WebGL map rendering for desktop, comprehensive README
documentation, and intelligence synthesis features.
This commit is contained in:
Elie Habib
2026-01-25 22:29:25 +04:00
56 changed files with 14895 additions and 1121 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ dist/
.vercel
.claude/
.cursor/
.env.vercel-backup

View File

@@ -62,6 +62,48 @@ Some sources don't provide RSS feeds. Custom scrapers are in `/api/`:
3. Add to feeds.ts: `{ name: 'Source', url: '/api/source-name' }`
4. No need to add to rss-proxy allowlist (direct API, not proxied)
## AI Summarization & Caching
The AI Insights panel uses a server-side Redis cache to deduplicate API calls across users.
### Required Environment Variables
```bash
# Groq API (primary summarization)
GROQ_API_KEY=gsk_xxx
# OpenRouter API (fallback)
OPENROUTER_API_KEY=sk-or-xxx
# Upstash Redis (cross-user caching)
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxx
```
### How It Works
1. User visits → `/api/groq-summarize` receives headlines
2. Server hashes headlines → checks Redis cache
3. **Cache hit** → return immediately (no API call)
4. **Cache miss** → call Groq API → store in Redis (24h TTL) → return
### Model Selection
- **llama-3.1-8b-instant**: 14,400 req/day (used for summaries)
- **llama-3.3-70b-versatile**: 1,000 req/day (quality but limited)
### Fallback Chain
1. Groq (fast, 14.4K/day) → Redis cache
2. OpenRouter (50/day) → Redis cache
3. Browser T5 (unlimited, slower, no cache)
### Setup Upstash
1. Create free account at [upstash.com](https://upstash.com)
2. Create a new Redis database
3. Copy REST URL and Token to Vercel env vars
## Service Status Panel
Status page URLs in `api/service-status.js` must match the actual status page endpoint. Common formats:

530
README.md
View File

@@ -427,6 +427,46 @@ The entity registry spans strategically significant sectors:
This broad coverage enables correlation detection across diverse geopolitical and market events.
### Entity Registry Architecture
The entity registry is a knowledge base of 600+ entities with rich metadata for intelligent correlation:
```typescript
{
id: 'NVDA', // Unique identifier
name: 'Nvidia', // Display name
type: 'company', // company | country | index | commodity | currency
sector: 'semiconductors',
searchTerms: ['Nvidia', 'NVDA', 'Jensen Huang', 'H100', 'CUDA'],
aliases: ['nvidia', 'nvda'],
competitors: ['AMD', 'INTC'],
related: ['AVGO', 'TSM', 'ASML'], // Related entities
country: 'US', // Headquarters/origin
}
```
**Entity Types**:
| Type | Count | Use Case |
|------|-------|----------|
| `company` | 100+ | Market-news correlation, sector analysis |
| `country` | 200+ | Focal point detection, CII scoring |
| `index` | 20+ | Market overview, regional tracking |
| `commodity` | 15+ | Energy and mineral correlation |
| `currency` | 10+ | FX market tracking |
**Lookup Indexes**:
The registry provides multiple lookup paths for fast entity resolution:
| Index | Query Example | Use Case |
|-------|---------------|----------|
| `byId` | `'NVDA'` → Nvidia entity | Direct lookup from ticker |
| `byAlias` | `'nvidia'` → Nvidia entity | Case-insensitive name match |
| `byKeyword` | `'AI chips'` → [Nvidia, AMD, Intel] | News keyword extraction |
| `bySector` | `'semiconductors'` → all chip companies | Sector cascade analysis |
| `byCountry` | `'US'` → all US entities | Country-level aggregation |
### Signal Deduplication
To prevent alert fatigue, signals use **type-specific TTL (time-to-live)** values for deduplication:
@@ -2345,6 +2385,114 @@ Early detection of flow drops—especially when markets haven't reacted—provid
---
## Signal Aggregator
The Signal Aggregator is the central nervous system that collects, groups, and summarizes intelligence signals from all data sources.
### What It Aggregates
| Signal Type | Source | Frequency |
|-------------|--------|-----------|
| `military_flight` | OpenSky ADS-B | Real-time |
| `military_vessel` | AIS WebSocket | Real-time |
| `protest` | ACLED + GDELT | Hourly |
| `internet_outage` | Cloudflare Radar | 5 min |
| `ais_disruption` | AIS analysis | Real-time |
### Country-Level Grouping
All signals are grouped by country code, creating a unified view:
```typescript
{
country: 'UA', // Ukraine
countryName: 'Ukraine',
totalCount: 15,
highSeverityCount: 3,
signalTypes: Set(['military_flight', 'protest', 'internet_outage']),
signals: [/* all signals for this country */]
}
```
### Regional Convergence Detection
The aggregator identifies geographic convergence—when multiple signal types cluster in the same region:
| Convergence Level | Criteria | Alert Priority |
|-------------------|----------|----------------|
| **Critical** | 4+ signal types within 200km | Immediate |
| **High** | 3 signal types within 200km | High |
| **Medium** | 2 signal types within 200km | Normal |
### Summary Output
The aggregator provides a real-time summary for dashboards and AI context:
```
[SIGNAL SUMMARY]
Top Countries: Ukraine (15 signals), Iran (12), Taiwan (8)
Convergence Zones: Baltic Sea (military_flight + military_vessel),
Tehran (protest + internet_outage)
Active Signal Types: 5 of 5
Total Signals: 47
```
---
## Browser-Based Machine Learning
For offline resilience and reduced API costs, the system includes browser-based ML capabilities using ONNX Runtime Web.
### Available Models
| Model | Task | Size | Use Case |
|-------|------|------|----------|
| **T5-small** | Text summarization | ~60MB | Offline briefing generation |
| **DistilBERT** | Sentiment analysis | ~67MB | News tone classification |
### Fallback Strategy
Browser ML serves as the final fallback when cloud APIs are unavailable:
```
User requests summary
1. Try Groq API (fast, free tier)
↓ (rate limited or error)
2. Try OpenRouter API (fallback provider)
↓ (unavailable)
3. Use Browser T5 (offline, always available)
```
### Lazy Loading
Models are loaded on-demand to minimize initial page load:
- Models download only when first needed
- Progress indicator shows download status
- Once cached, models load instantly from IndexedDB
### Worker Isolation
All ML inference runs in a dedicated Web Worker:
- Main thread remains responsive during inference
- 30-second timeout prevents hanging
- Automatic cleanup on errors
### Limitations
Browser ML has constraints compared to cloud models:
| Aspect | Cloud (Llama 3.3) | Browser (T5) |
|--------|-------------------|--------------|
| Context window | 128K tokens | 512 tokens |
| Output quality | High | Moderate |
| Inference speed | 2-3 seconds | 5-10 seconds |
| Offline support | No | Yes |
Browser summarization is intentionally limited to 6 headlines × 80 characters to stay within model constraints.
---
## Cross-Module Integration
Intelligence modules don't operate in isolation. Data flows between systems to enable composite analysis.
@@ -2391,6 +2539,325 @@ This ensures a single escalation (e.g., Ukraine military flights + protests + ne
---
## AI Insights Panel
The Insights Panel provides AI-powered analysis of the current news landscape, transforming raw headlines into actionable intelligence briefings.
### World Brief Generation
Every 2 minutes (with rate limiting), the system generates a concise situation brief using a multi-provider fallback chain:
| Priority | Provider | Model | Latency | Use Case |
|----------|----------|-------|---------|----------|
| 1 | Groq | Llama 3.3 70B | ~2s | Primary provider (fast inference) |
| 2 | OpenRouter | Llama 3.3 70B | ~3s | Fallback when Groq rate-limited |
| 3 | Browser | T5 (ONNX) | ~5s | Offline fallback (local ML) |
**Caching Strategy**: Redis server-side caching prevents redundant API calls. When the same headline set has been summarized recently, the cached result is returned immediately.
### Focal Point Detection
The AI receives enriched context about **focal points**—entities that appear in both news coverage AND map signals. This enables intelligence-grade analysis:
```
[INTELLIGENCE SYNTHESIS]
FOCAL POINTS (entities across news + signals):
- IRAN [CRITICAL]: 12 news mentions + 5 map signals (military_flight, protest, internet_outage)
KEY: "Iran protests continue..." | SIGNALS: military activity, outage detected
- TAIWAN [ELEVATED]: 8 news mentions + 3 map signals (military_vessel, military_flight)
KEY: "Taiwan tensions rise..." | SIGNALS: naval vessels detected
```
### Headline Scoring Algorithm
Not all news is equally important. Headlines are scored to identify the most significant stories for the briefing:
**Score Boosters** (high weight):
- Military keywords: war, invasion, airstrike, missile, deployment, mobilization
- Violence indicators: killed, casualties, clashes, massacre, crackdown
- Civil unrest: protest, uprising, coup, riot, martial law
**Geopolitical Multipliers**:
- Flashpoint regions: Iran, Russia, China, Taiwan, Ukraine, North Korea, Gaza
- Critical actors: NATO, Pentagon, Kremlin, Hezbollah, Hamas, Wagner
**Score Reducers** (demoted):
- Business context: CEO, earnings, stock, revenue, startup, data center
- Entertainment: celebrity, movie, streaming
This ensures military conflicts and humanitarian crises surface above routine business news.
### Sentiment Analysis
Headlines are analyzed for overall sentiment distribution:
| Sentiment | Detection Method | Display |
|-----------|------------------|---------|
| **Negative** | Crisis, conflict, death keywords | Red percentage |
| **Positive** | Agreement, growth, peace keywords | Green percentage |
| **Neutral** | Neither detected | Gray percentage |
The overall sentiment balance provides a quick read on whether the news cycle is trending toward escalation or de-escalation.
### Velocity Detection
Fast-moving stories are flagged when the same topic appears in multiple recent headlines:
- Headlines are grouped by shared keywords and entities
- Topics with 3+ mentions in 6 hours are marked as "high velocity"
- Displayed separately to highlight developing situations
---
## Focal Point Detector
The Focal Point Detector is the intelligence synthesis layer that correlates news entities with map signals to identify "main characters" driving current events.
### The Problem It Solves
Without synthesis, intelligence streams operate in silos:
- News feeds show 80+ sources with thousands of headlines
- Map layers display military flights, protests, outages independently
- No automated way to see that IRAN appears in news AND has military activity AND an internet outage
### How It Works
1. **Entity Extraction**: Extract countries, companies, and organizations from all news clusters using the entity registry (600+ entities with aliases)
2. **Signal Aggregation**: Collect all map signals (military flights, protests, outages, vessels) and group by country
3. **Cross-Reference**: Match news entities with signal countries
4. **Score & Rank**: Calculate focal scores based on correlation strength
### Focal Point Scoring
```
FocalScore = NewsScore + SignalScore + CorrelationBonus
NewsScore (0-40):
base = min(20, mentionCount × 4)
velocity = min(10, newsVelocity × 2)
confidence = avgConfidence × 10
SignalScore (0-40):
types = signalTypes.count × 10
count = min(15, signalCount × 3)
severity = highSeverityCount × 5
CorrelationBonus (0-20):
+10 if entity appears in BOTH news AND signals
+5 if news keywords match signal types (e.g., "military" + military_flight)
+5 if related entities also have signals
```
### Urgency Classification
| Urgency | Criteria | Visual |
|---------|----------|--------|
| **Critical** | Score > 70 OR 3+ signal types | Red badge |
| **Elevated** | Score > 50 OR 2+ signal types | Orange badge |
| **Watch** | Default | Yellow badge |
### Signal Type Icons
Focal points display icons indicating which signal types are active:
| Icon | Signal Type | Meaning |
|------|-------------|---------|
| ✈️ | military_flight | Military aircraft detected nearby |
| ⚓ | military_vessel | Naval vessels in waters |
| 📢 | protest | Civil unrest events |
| 🌐 | internet_outage | Network disruption |
| 🚢 | ais_disruption | Shipping anomaly |
### Example Output
A focal point for IRAN might show:
- **Display**: "Iran [CRITICAL] ✈️📢🌐"
- **News**: 12 mentions, velocity 0.5/hour
- **Signals**: 5 military flights, 3 protests, 1 outage
- **Narrative**: "12 news mentions | 5 military flights, 3 protests, 1 internet outage | 'Iran protests continue amid...'"
- **Correlation Evidence**: "Iran appears in both news (12) and map signals (9)"
### Integration with CII
Focal point urgency levels feed into the Country Instability Index:
- **Critical** focal point → CII score boost for that country
- Ensures countries with multi-source convergence are properly flagged
- Prevents "silent" instability when news alone wouldn't trigger alerts
---
## Natural Disaster Tracking
The Natural layer combines two authoritative sources for comprehensive disaster monitoring.
### GDACS (Global Disaster Alert and Coordination System)
UN-backed disaster alert system providing official severity assessments:
| Event Type | Code | Icon | Sources |
|------------|------|------|---------|
| Earthquake | EQ | 🔴 | USGS, EMSC |
| Flood | FL | 🌊 | Satellite imagery |
| Tropical Cyclone | TC | 🌀 | NOAA, JMA |
| Volcano | VO | 🌋 | Smithsonian GVP |
| Wildfire | WF | 🔥 | MODIS, VIIRS |
| Drought | DR | ☀️ | Multiple sources |
**Alert Levels**:
| Level | Color | Meaning |
|-------|-------|---------|
| **Red** | Critical | Significant humanitarian impact expected |
| **Orange** | Alert | Moderate impact, monitoring required |
| **Green** | Advisory | Minor event, localized impact |
### NASA EONET (Earth Observatory Natural Event Tracker)
Near-real-time natural event detection from satellite observation:
| Category | Detection Method | Typical Delay |
|----------|------------------|---------------|
| Severe Storms | GOES/Himawari imagery | Minutes |
| Wildfires | MODIS thermal anomalies | 4-6 hours |
| Volcanoes | Thermal + SO2 emissions | Hours |
| Floods | SAR imagery + gauges | Hours to days |
| Sea/Lake Ice | Passive microwave | Daily |
| Dust/Haze | Aerosol optical depth | Hours |
### Multi-Source Deduplication
When both GDACS and EONET report the same event:
1. Events within 100km and 48 hours are considered duplicates
2. GDACS severity takes precedence (human-verified)
3. EONET geometry provides more precise coordinates
4. Combined entry shows both source attributions
### Filtering Logic
To prevent map clutter, natural events are filtered:
- **Wildfires**: Only events < 48 hours old (older fires are either contained or well-known)
- **Earthquakes**: M4.5+ globally, lower threshold for populated areas
- **Storms**: Only named storms or those with warnings
---
## Military Surge Detection
The system detects unusual concentrations of military activity using two complementary algorithms.
### Baseline-Based Surge Detection
Surges are detected by comparing current aircraft counts to historical baselines within defined military theaters:
| Parameter | Value | Purpose |
|-----------|-------|---------|
| Surge threshold | 2.0× baseline | Minimum multiplier to trigger alert |
| Baseline window | 48 hours | Historical data used for comparison |
| Minimum samples | 6 observations | Required data points for valid baseline |
**Aircraft Categories Tracked**:
| Category | Examples | Minimum Count |
|----------|----------|---------------|
| Transport/Airlift | C-17, C-130, KC-135, REACH flights | 5 aircraft |
| Fighter | F-15, F-16, F-22, Typhoon | 4 aircraft |
| Reconnaissance | RC-135, E-3 AWACS, U-2 | 3 aircraft |
### Surge Severity
| Severity | Criteria | Meaning |
|----------|----------|---------|
| **Critical** | 4× baseline or higher | Major deployment |
| **High** | 3× baseline | Significant increase |
| **Medium** | 2× baseline | Elevated activity |
### Military Theaters
Surge detection groups activity into strategic theaters:
| Theater | Center | Key Bases |
|---------|--------|-----------|
| Middle East | Persian Gulf | Al Udeid, Al Dhafra, Incirlik |
| Eastern Europe | Poland | Ramstein, Spangdahlem, Łask |
| Pacific | Guam/Japan | Andersen, Kadena, Yokota |
| Horn of Africa | Djibouti | Camp Lemonnier |
### Foreign Presence Detection
A separate system monitors for military operators outside their normal operating areas:
| Operator | Home Regions | Alert When Found In |
|----------|--------------|---------------------|
| USAF/USN | Alaska ADIZ | Persian Gulf, Taiwan Strait |
| Russian VKS | Kaliningrad, Arctic, Black Sea | Baltic Region, Alaska ADIZ |
| PLAAF/PLAN | Taiwan Strait, South China Sea | (alerts when increased) |
| Israeli IAF | Eastern Med | Iran border region |
**Example alert**:
```
FOREIGN MILITARY PRESENCE: Persian Gulf
USAF: 3 aircraft detected (KC-135, RC-135W, E-3)
Severity: HIGH - Operator outside normal home regions
```
### News Correlation
Both surge and foreign presence alerts query the Focal Point Detector for context:
1. Identify countries involved (aircraft operators, region countries)
2. Check focal points for those countries
3. If news correlation exists, attach headlines and evidence
**Example with correlation**:
```
MILITARY AIRLIFT SURGE: Middle East Theater
Current: 8 transport aircraft (2.5× baseline)
Aircraft: C-17 (3), KC-135 (3), C-130J (2)
NEWS CORRELATION:
Iran: "Iran protests continue amid military..."
→ Iran appears in both news (12) and map signals (9)
```
---
## Service Status Monitoring
The Service Status panel tracks the operational health of external services that WorldMonitor users may depend on.
### Monitored Services
| Service | Status Endpoint | Parser |
|---------|-----------------|--------|
| Anthropic (Claude) | status.claude.com | Statuspage.io |
| OpenAI | status.openai.com | Statuspage.io |
| Vercel | vercel-status.com | Statuspage.io |
| Cloudflare | cloudflarestatus.com | Statuspage.io |
| AWS | health.aws.amazon.com | Custom |
| GitHub | githubstatus.com | Statuspage.io |
### Status Levels
| Status | Color | Meaning |
|--------|-------|---------|
| **Operational** | Green | All systems functioning normally |
| **Degraded** | Yellow | Partial outage or performance issues |
| **Partial Outage** | Orange | Some components unavailable |
| **Major Outage** | Red | Significant service disruption |
### Why This Matters
External service outages can affect:
- AI summarization (Groq, OpenRouter outages)
- Deployment pipelines (Vercel, GitHub outages)
- API availability (Cloudflare, AWS outages)
Monitoring these services provides context when dashboard features behave unexpectedly.
---
## Refresh Intervals
Different data sources update at different frequencies based on volatility and API constraints.
@@ -2438,24 +2905,45 @@ Historical filtering is client-side—all data is fetched but filtered for displ
| Layer | Technology | Purpose |
|-------|------------|---------|
| **Language** | TypeScript 5.x | Type safety across 50+ source files |
| **Language** | TypeScript 5.x | Type safety across 60+ source files |
| **Build** | Vite | Fast HMR, optimized production builds |
| **Visualization** | D3.js + TopoJSON | SVG map rendering, zoom/pan, animations |
| **Map (Desktop)** | deck.gl + MapLibre GL | WebGL-accelerated rendering for large datasets |
| **Map (Mobile)** | D3.js + TopoJSON | SVG fallback for battery efficiency |
| **Concurrency** | Web Workers | Off-main-thread clustering and correlation |
| **AI/ML** | ONNX Runtime Web | Browser-based inference for offline summarization |
| **Networking** | WebSocket + REST | Real-time AIS stream, HTTP for other APIs |
| **Storage** | IndexedDB | Snapshots, baselines (megabytes of state) |
| **Preferences** | LocalStorage | User settings, monitors, panel order |
| **Deployment** | Vercel Edge | Serverless proxies with global distribution |
### Map Rendering Architecture
The map uses a hybrid rendering strategy optimized for each platform:
**Desktop (deck.gl + MapLibre GL)**:
- WebGL-accelerated layers handle thousands of markers smoothly
- MapLibre GL provides base map tiles (OpenStreetMap)
- GeoJSON, Scatterplot, Path, and Icon layers for different data types
- GPU-based clustering and picking for responsive interaction
**Mobile (D3.js + TopoJSON)**:
- SVG rendering for battery efficiency
- Reduced marker count and simplified layers
- Touch-optimized interaction with larger hit targets
- Automatic fallback when WebGL unavailable
### Key Libraries
- **D3.js**: Map projection, SVG rendering, zoom behavior
- **deck.gl**: High-performance WebGL visualization layers
- **MapLibre GL**: Open-source map rendering engine
- **D3.js**: SVG map rendering, zoom behavior (mobile fallback)
- **TopoJSON**: Efficient geographic data encoding
- **DOMPurify pattern**: HTML escaping (custom implementation)
- **ONNX Runtime**: Browser-based ML inference
- **Custom HTML escaping**: XSS prevention (DOMPurify pattern)
### No External UI Frameworks
The entire UI is hand-crafted DOM manipulation—no React, Vue, or Angular. This keeps the bundle small (~200KB gzipped) and provides fine-grained control over rendering performance.
The entire UI is hand-crafted DOM manipulation—no React, Vue, or Angular. This keeps the bundle small (~250KB gzipped) and provides fine-grained control over rendering performance.
### Build-Time Configuration
@@ -2548,12 +3036,15 @@ src/
├── App.ts # Main application orchestrator
├── main.ts # Entry point
├── components/
│ ├── Map.ts # D3.js map with 20+ toggleable layers
│ ├── DeckGLMap.ts # WebGL map with deck.gl + MapLibre (desktop)
│ ├── Map.ts # D3.js SVG map (mobile fallback)
│ ├── MapContainer.ts # Map wrapper with platform detection
│ ├── MapPopup.ts # Contextual info popups
│ ├── SearchModal.ts # Universal search (⌘K)
│ ├── SignalModal.ts # Signal intelligence display
│ ├── SignalModal.ts # Signal intelligence display with focal points
│ ├── PizzIntIndicator.ts # Pentagon Pizza Index display
│ ├── VirtualList.ts # Virtual/windowed scrolling
│ ├── InsightsPanel.ts # AI briefings + focal point display
│ ├── EconomicPanel.ts # FRED economic indicators
│ ├── GdeltIntelPanel.ts # Topic-based intelligence (cyber, military, etc.)
│ ├── LiveNewsPanel.ts # YouTube live news streams with channel switching
@@ -2563,6 +3054,7 @@ src/
│ ├── CIIPanel.ts # Country Instability Index display
│ ├── CascadePanel.ts # Infrastructure cascade analysis
│ ├── StrategicRiskPanel.ts # Strategic risk overview dashboard
│ ├── ServiceStatusPanel.ts # External service health monitoring
│ └── ...
├── config/
│ ├── feeds.ts # 70+ RSS feeds, source tiers, regional sources
@@ -2578,19 +3070,21 @@ src/
│ ├── entities.ts # 100+ entity definitions (companies, indices, commodities, countries)
│ └── panels.ts # Panel configs, layer defaults, mobile optimizations
├── services/
│ ├── ais.ts # WebSocket vessel tracking
│ ├── military-vessels.ts # Naval vessel identification
│ ├── military-flights.ts # Aircraft tracking via OpenSky
│ ├── ais.ts # WebSocket vessel tracking with density analysis
│ ├── military-vessels.ts # Naval vessel identification and tracking
│ ├── military-flights.ts # Aircraft tracking via OpenSky relay
│ ├── military-surge.ts # Surge detection with news correlation
│ ├── wingbits.ts # Aircraft enrichment (owner, operator, type)
│ ├── pizzint.ts # Pentagon Pizza Index + GDELT tensions
│ ├── protests.ts # ACLED + GDELT integration
│ ├── gdelt-intel.ts # GDELT Doc API topic intelligence
│ ├── gdacs.ts # UN GDACS disaster alerts
│ ├── eonet.ts # NASA EONET natural events + GDACS merge
│ ├── flights.ts # FAA delay parsing
│ ├── outages.ts # Cloudflare Radar integration
│ ├── rss.ts # RSS parsing with circuit breakers
│ ├── markets.ts # Finnhub, Yahoo Finance, CoinGecko
│ ├── earthquakes.ts # USGS integration
│ ├── eonet.ts # NASA EONET natural events
│ ├── weather.ts # NWS alerts
│ ├── fred.ts # Federal Reserve data
│ ├── oil-analytics.ts # EIA oil prices, production, inventory
@@ -2602,7 +3096,13 @@ src/
│ ├── related-assets.ts # Infrastructure near news events
│ ├── activity-tracker.ts # New item detection & highlighting
│ ├── analysis-worker.ts # Web Worker manager
│ ├── ml-worker.ts # Browser ML inference (ONNX)
│ ├── summarization.ts # AI briefings with fallback chain
│ ├── parallel-analysis.ts # Concurrent headline analysis
│ ├── storage.ts # IndexedDB snapshots & baselines
│ ├── data-freshness.ts # Real-time data staleness tracking
│ ├── signal-aggregator.ts # Central signal collection & grouping
│ ├── focal-point-detector.ts # Intelligence synthesis layer
│ ├── entity-index.ts # Entity lookup maps (by alias, keyword, sector)
│ ├── entity-extraction.ts # News-to-entity matching for market correlation
│ ├── country-instability.ts # CII scoring algorithm
@@ -2795,6 +3295,13 @@ See [ROADMAP.md](ROADMAP.md) for detailed planning. Recent intelligence enhancem
### Completed
-**Focal Point Detection** - Intelligence synthesis correlating news entities with map signals
-**AI-Powered Briefings** - Groq/OpenRouter/Browser ML fallback chain for summarization
-**Military Surge Detection** - Alerts when multiple operators converge on regions
-**News-Signal Correlation** - Surge alerts include related focal point context
-**GDACS Integration** - UN disaster alert system for earthquakes, floods, cyclones, volcanoes
-**WebGL Map (deck.gl)** - High-performance rendering for desktop users
-**Browser ML Fallback** - ONNX Runtime for offline summarization capability
-**Multi-Signal Geographic Convergence** - Alerts when 3+ data types converge on same region within 24h
-**Country Instability Index (CII)** - Real-time composite risk score for 20 Tier-1 countries
-**Infrastructure Cascade Visualization** - Dependency graph showing downstream effects of disruptions
@@ -2813,6 +3320,7 @@ See [ROADMAP.md](ROADMAP.md) for detailed planning. Recent intelligence enhancem
-**Variant Switcher UI** - Compact orbital navigation between World Monitor and Tech Monitor
-**CII Learning Mode** - 15-minute calibration period with visual progress indicator
-**Regional Tech Coverage** - Verified tech HQ data for MENA, Europe, Asia-Pacific hubs
-**Service Status Panel** - External service health monitoring (AI providers, cloud platforms)
### Planned

271
api/groq-summarize.js Normal file
View File

@@ -0,0 +1,271 @@
/**
* Groq API Summarization Endpoint with Redis Caching
* Uses Llama 3.1 8B Instant for high-throughput summarization
* Free tier: 14,400 requests/day (14x more than 70B model)
* Server-side Redis cache for cross-user deduplication
*/
import { Redis } from '@upstash/redis';
export const config = {
runtime: 'edge',
};
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
const MODEL = 'llama-3.1-8b-instant'; // 14.4K RPD vs 1K for 70b
const CACHE_TTL_SECONDS = 86400; // 24 hours
// Initialize Redis (lazy - only if env vars present)
let redis = null;
let redisInitFailed = false;
function getRedis() {
if (redis) return redis;
if (redisInitFailed) return null;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (url && token) {
try {
redis = new Redis({ url, token });
} catch (err) {
console.warn('[Groq] Redis init failed:', err.message);
redisInitFailed = true;
return null;
}
}
return redis;
}
// Generate cache key from headlines and geoContext
function getCacheKey(headlines, mode, geoContext = '') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
return `summary:${hash}${geoHash}`;
}
// Simple hash function for cache keys
function hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
// Deduplicate similar headlines (same story from different sources)
function deduplicateHeadlines(headlines) {
const seen = new Set();
const unique = [];
for (const headline of headlines) {
// Normalize: lowercase, remove punctuation, collapse whitespace
const normalized = headline.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
// Extract key words (4+ chars) for similarity check
const words = new Set(normalized.split(' ').filter(w => w.length >= 4));
// Check if this headline is too similar to any we've seen
let isDuplicate = false;
for (const seenWords of seen) {
const intersection = [...words].filter(w => seenWords.has(w));
const similarity = intersection.length / Math.min(words.size, seenWords.size);
if (similarity > 0.6) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
seen.add(words);
unique.push(headline);
}
}
return unique;
}
export default async function handler(request) {
// Only allow POST
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ error: 'Groq API key not configured', fallback: true }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const { headlines, mode = 'brief', geoContext = '' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Check Redis cache first
const redisClient = getRedis();
const cacheKey = getCacheKey(headlines, mode, geoContext);
if (redisClient) {
try {
const cached = await redisClient.get(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[Groq] Cache hit:', cacheKey);
return new Response(JSON.stringify({
summary: cached.summary,
model: cached.model || MODEL,
provider: 'cache',
cached: true,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
} catch (cacheError) {
console.warn('[Groq] Cache read error:', cacheError.message);
}
}
// Deduplicate similar headlines (same story from multiple sources)
const uniqueHeadlines = deduplicateHeadlines(headlines.slice(0, 8));
const headlineText = uniqueHeadlines.map((h, i) => `${i + 1}. ${h}`).join('\n');
let systemPrompt, userPrompt;
// Include intelligence synthesis context in prompt if available
const intelSection = geoContext ? `\n\n${geoContext}` : '';
// Current date context for LLM (models may have outdated knowledge)
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}. Donald Trump is the current US President (second term, inaugurated Jan 2025).`;
if (mode === 'brief') {
systemPrompt = `${dateContext}
Summarize the key development in 2-3 sentences.
Rules:
- Lead with WHAT happened and WHERE - be specific
- NEVER start with "Breaking news", "Good evening", "Tonight", or TV-style openings
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary`;
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
systemPrompt = `${dateContext}
Provide analysis in 2-3 sentences. Be direct and specific.
Rules:
- Lead with the insight - what's significant and why
- NEVER start with "Breaking news", "Tonight", "The key/dominant narrative is"
- Start with substance: "Iran faces...", "The escalation in...", "Multiple signals suggest..."
- CRITICAL FOCAL POINTS are your main actors - explain WHY they matter
- If focal points show news-signal correlation, flag as escalation
- Connect dots, be specific about implications`;
userPrompt = `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else {
systemPrompt = `${dateContext}
Synthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
const response = await fetch(GROQ_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
max_tokens: 150,
top_p: 0.9,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Groq] API error:', response.status, errorText);
// Return fallback signal for rate limiting
if (response.status === 429) {
return new Response(JSON.stringify({ error: 'Rate limited', fallback: true }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Groq API error', fallback: true }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
const data = await response.json();
const summary = data.choices?.[0]?.message?.content?.trim();
if (!summary) {
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Store in Redis cache
if (redisClient) {
try {
await redisClient.set(cacheKey, {
summary,
model: MODEL,
timestamp: Date.now(),
}, { ex: CACHE_TTL_SECONDS });
console.log('[Groq] Cached:', cacheKey);
} catch (cacheError) {
console.warn('[Groq] Cache write error:', cacheError.message);
}
}
return new Response(JSON.stringify({
summary,
model: MODEL,
provider: 'groq',
cached: false,
tokens: data.usage?.total_tokens || 0,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800',
},
});
} catch (error) {
console.error('[Groq] Error:', error.name, error.message, error.stack?.split('\n')[1]);
return new Response(JSON.stringify({
error: error.message,
errorType: error.name,
fallback: true
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

260
api/openrouter-summarize.js Normal file
View File

@@ -0,0 +1,260 @@
/**
* OpenRouter API Summarization Endpoint with Redis Caching
* Fallback when Groq is rate-limited
* Uses Llama 3.3 70B free model
* Free tier: 50 requests/day (20/min)
* Server-side Redis cache for cross-user deduplication
*/
import { Redis } from '@upstash/redis';
export const config = {
runtime: 'edge',
};
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'meta-llama/llama-3.3-70b-instruct:free';
const CACHE_TTL_SECONDS = 86400; // 24 hours
// Initialize Redis (lazy - only if env vars present)
let redis = null;
function getRedis() {
if (redis) return redis;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (url && token) {
redis = new Redis({ url, token });
}
return redis;
}
// Generate cache key from headlines and geoContext (same as groq endpoint)
function getCacheKey(headlines, mode, geoContext = '') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
return `summary:${hash}${geoHash}`;
}
function hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
// Deduplicate similar headlines (same story from different sources)
function deduplicateHeadlines(headlines) {
const seen = new Set();
const unique = [];
for (const headline of headlines) {
// Normalize: lowercase, remove punctuation, collapse whitespace
const normalized = headline.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
// Extract key words (4+ chars) for similarity check
const words = new Set(normalized.split(' ').filter(w => w.length >= 4));
// Check if this headline is too similar to any we've seen
let isDuplicate = false;
for (const seenWords of seen) {
const intersection = [...words].filter(w => seenWords.has(w));
const similarity = intersection.length / Math.min(words.size, seenWords.size);
if (similarity > 0.6) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
seen.add(words);
unique.push(headline);
}
}
return unique;
}
export default async function handler(request) {
// Only allow POST
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ error: 'OpenRouter API key not configured', fallback: true }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const { headlines, mode = 'brief', geoContext = '' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Check Redis cache first (shared with Groq endpoint)
const redisClient = getRedis();
const cacheKey = getCacheKey(headlines, mode, geoContext);
if (redisClient) {
try {
const cached = await redisClient.get(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[OpenRouter] Cache hit:', cacheKey);
return new Response(JSON.stringify({
summary: cached.summary,
model: cached.model || MODEL,
provider: 'cache',
cached: true,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
} catch (cacheError) {
console.warn('[OpenRouter] Cache read error:', cacheError.message);
}
}
// Deduplicate similar headlines (same story from different sources)
const uniqueHeadlines = deduplicateHeadlines(headlines.slice(0, 8));
const headlineText = uniqueHeadlines.map((h, i) => `${i + 1}. ${h}`).join('\n');
let systemPrompt, userPrompt;
// Include intelligence synthesis context in prompt if available
const intelSection = geoContext ? `\n\n${geoContext}` : '';
// Current date context for LLM (models may have outdated knowledge)
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}. Donald Trump is the current US President (second term, inaugurated Jan 2025).`;
if (mode === 'brief') {
systemPrompt = `${dateContext}
Summarize the key development in 2-3 sentences.
Rules:
- Lead with WHAT happened and WHERE - be specific
- NEVER start with "Breaking news", "Good evening", "Tonight", or TV-style openings
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary`;
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
systemPrompt = `${dateContext}
Provide analysis in 2-3 sentences. Be direct and specific.
Rules:
- Lead with the insight - what's significant and why
- NEVER start with "Breaking news", "Tonight", "The key/dominant narrative is"
- Start with substance: "Iran faces...", "The escalation in...", "Multiple signals suggest..."
- CRITICAL FOCAL POINTS are your main actors - explain WHY they matter
- If focal points show news-signal correlation, flag as escalation
- Connect dots, be specific about implications`;
userPrompt = `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else {
systemPrompt = `${dateContext}
Synthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://worldmonitor.app',
'X-Title': 'WorldMonitor',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
max_tokens: 150,
top_p: 0.9,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[OpenRouter] API error:', response.status, errorText);
// Return fallback signal for rate limiting
if (response.status === 429) {
return new Response(JSON.stringify({ error: 'Rate limited', fallback: true }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'OpenRouter API error', fallback: true }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
const data = await response.json();
const summary = data.choices?.[0]?.message?.content?.trim();
if (!summary) {
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Store in Redis cache (shared with Groq endpoint)
if (redisClient) {
try {
await redisClient.set(cacheKey, {
summary,
model: MODEL,
timestamp: Date.now(),
}, { ex: CACHE_TTL_SECONDS });
console.log('[OpenRouter] Cached:', cacheKey);
} catch (cacheError) {
console.warn('[OpenRouter] Cache write error:', cacheError.message);
}
}
return new Response(JSON.stringify({
summary,
model: MODEL,
provider: 'openrouter',
cached: false,
tokens: data.usage?.total_tokens || 0,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800',
},
});
} catch (error) {
console.error('[OpenRouter] Error:', error);
return new Response(JSON.stringify({ error: error.message, fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

279
api/risk-scores.js Normal file
View File

@@ -0,0 +1,279 @@
/**
* Risk Scores API - Cached CII and Strategic Risk computation
* Eliminates 15-minute "learning mode" for users by pre-computing scores
* Uses Upstash Redis for cross-user caching (10-minute TTL)
*/
import { Redis } from '@upstash/redis';
export const config = {
runtime: 'edge',
};
const CACHE_TTL_SECONDS = 600; // 10 minutes
const CACHE_KEY = 'risk:scores:v1';
// Tier 1 countries for CII
const TIER1_COUNTRIES = {
US: 'United States', RU: 'Russia', CN: 'China', UA: 'Ukraine', IR: 'Iran',
IL: 'Israel', TW: 'Taiwan', KP: 'North Korea', SA: 'Saudi Arabia', TR: 'Turkey',
PL: 'Poland', DE: 'Germany', FR: 'France', GB: 'United Kingdom', IN: 'India',
PK: 'Pakistan', SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela',
};
// Baseline geopolitical risk (0-50)
const BASELINE_RISK = {
US: 5, RU: 35, CN: 25, UA: 50, IR: 40, IL: 45, TW: 30, KP: 45,
SA: 20, TR: 25, PL: 10, DE: 5, FR: 10, GB: 5, IN: 20, PK: 35,
SY: 50, YE: 50, MM: 45, VE: 40,
};
// Event significance multipliers
const EVENT_MULTIPLIER = {
US: 0.3, RU: 2.0, CN: 2.5, UA: 0.8, IR: 2.0, IL: 0.7, TW: 1.5, KP: 3.0,
SA: 2.0, TR: 1.2, PL: 0.8, DE: 0.5, FR: 0.6, GB: 0.5, IN: 0.8, PK: 1.5,
SY: 0.7, YE: 0.7, MM: 1.8, VE: 1.8,
};
// Country keywords for matching
const COUNTRY_KEYWORDS = {
US: ['united states', 'usa', 'america', 'washington', 'biden', 'trump', 'pentagon'],
RU: ['russia', 'moscow', 'kremlin', 'putin'],
CN: ['china', 'beijing', 'xi jinping', 'prc'],
UA: ['ukraine', 'kyiv', 'zelensky', 'donbas'],
IR: ['iran', 'tehran', 'khamenei', 'irgc'],
IL: ['israel', 'tel aviv', 'netanyahu', 'idf', 'gaza'],
TW: ['taiwan', 'taipei'],
KP: ['north korea', 'pyongyang', 'kim jong'],
SA: ['saudi arabia', 'riyadh'],
TR: ['turkey', 'ankara', 'erdogan'],
PL: ['poland', 'warsaw'],
DE: ['germany', 'berlin'],
FR: ['france', 'paris', 'macron'],
GB: ['britain', 'uk', 'london'],
IN: ['india', 'delhi', 'modi'],
PK: ['pakistan', 'islamabad'],
SY: ['syria', 'damascus'],
YE: ['yemen', 'sanaa', 'houthi'],
MM: ['myanmar', 'burma'],
VE: ['venezuela', 'caracas', 'maduro'],
};
// Initialize Redis (lazy)
let redis = null;
function getRedis() {
if (redis) return redis;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (url && token) {
redis = new Redis({ url, token });
}
return redis;
}
function normalizeCountryName(text) {
const lower = text.toLowerCase();
for (const [code, keywords] of Object.entries(COUNTRY_KEYWORDS)) {
if (keywords.some(kw => lower.includes(kw))) return code;
}
return null;
}
function getScoreLevel(score) {
if (score >= 70) return 'critical';
if (score >= 55) return 'high';
if (score >= 40) return 'elevated';
if (score >= 25) return 'normal';
return 'low';
}
async function fetchACLEDProtests() {
try {
// Fetch recent protests from ACLED (last 7 days)
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const response = await fetch(
`https://api.acleddata.com/acled/read?event_type=Protests&event_type=Riots&event_date=${startDate}|${endDate}&event_date_where=BETWEEN&limit=500`,
{ headers: { 'Accept': 'application/json' } }
);
if (!response.ok) {
console.warn('[RiskScores] ACLED fetch failed:', response.status);
return [];
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.warn('[RiskScores] ACLED error:', error.message);
return [];
}
}
function computeCIIScores(protests) {
const countryEvents = new Map();
// Count events per country
for (const event of protests) {
const country = event.country;
const code = normalizeCountryName(country);
if (code && TIER1_COUNTRIES[code]) {
const count = countryEvents.get(code) || { protests: 0, riots: 0 };
if (event.event_type === 'Riots') {
count.riots++;
} else {
count.protests++;
}
countryEvents.set(code, count);
}
}
// Compute scores for all Tier 1 countries
const scores = [];
const now = new Date();
for (const [code, name] of Object.entries(TIER1_COUNTRIES)) {
const events = countryEvents.get(code) || { protests: 0, riots: 0 };
const baseline = BASELINE_RISK[code] || 20;
const multiplier = EVENT_MULTIPLIER[code] || 1.0;
// Unrest component: protests + riots (riots weighted 2x)
const unrestRaw = (events.protests + events.riots * 2) * multiplier;
const unrest = Math.min(100, Math.round(unrestRaw * 2));
// Security component: baseline + riot contribution
const security = Math.min(100, baseline + events.riots * multiplier * 5);
// Information component: based on event count (proxy for news coverage)
const totalEvents = events.protests + events.riots;
const information = Math.min(100, totalEvents * multiplier * 3);
// Composite score: weighted average + baseline
const composite = Math.min(100, Math.round(
baseline +
(unrest * 0.4 + security * 0.35 + information * 0.25) * 0.5
));
scores.push({
code,
name,
score: composite,
level: getScoreLevel(composite),
trend: 'stable', // Would need historical data for real trend
change24h: 0,
components: { unrest, security, information },
lastUpdated: now.toISOString(),
});
}
// Sort by score descending
scores.sort((a, b) => b.score - a.score);
return scores;
}
function computeStrategicRisk(ciiScores) {
// Top 5 CII scores weighted average
const top5 = ciiScores.slice(0, 5);
const ciiComponent = top5.reduce((sum, s, i) => {
const weight = 1 - (i * 0.15); // 1.0, 0.85, 0.70, 0.55, 0.40
return sum + s.score * weight;
}, 0) / top5.reduce((_, __, i) => 1 - (i * 0.15), 0);
// Overall strategic risk
const overallScore = Math.round(ciiComponent * 0.7 + 15); // 30% baseline
return {
score: Math.min(100, overallScore),
level: getScoreLevel(overallScore),
trend: 'stable',
lastUpdated: new Date().toISOString(),
contributors: top5.map(s => ({
country: s.name,
code: s.code,
score: s.score,
level: s.level,
})),
};
}
export default async function handler(request) {
// Allow GET only
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
}
const redisClient = getRedis();
// Check cache first
if (redisClient) {
try {
const cached = await redisClient.get(CACHE_KEY);
if (cached && typeof cached === 'object') {
console.log('[RiskScores] Cache hit');
return new Response(JSON.stringify({
...cached,
cached: true,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300',
},
});
}
} catch (cacheError) {
console.warn('[RiskScores] Cache read error:', cacheError.message);
}
}
try {
// Fetch ACLED protests
console.log('[RiskScores] Computing scores...');
const protests = await fetchACLEDProtests();
// Compute CII scores
const ciiScores = computeCIIScores(protests);
// Compute strategic risk
const strategicRisk = computeStrategicRisk(ciiScores);
const result = {
cii: ciiScores,
strategicRisk,
protestCount: protests.length,
computedAt: new Date().toISOString(),
};
// Cache in Redis
if (redisClient) {
try {
await redisClient.set(CACHE_KEY, result, { ex: CACHE_TTL_SECONDS });
console.log('[RiskScores] Cached scores');
} catch (cacheError) {
console.warn('[RiskScores] Cache write error:', cacheError.message);
}
}
return new Response(JSON.stringify({
...result,
cached: false,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300',
},
});
} catch (error) {
console.error('[RiskScores] Error:', error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@@ -117,16 +117,60 @@ const ALLOWED_DOMAINS = [
'www.cbinsights.com',
// Accelerators
'www.techstars.com',
// Middle East & Regional News
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
'feeds.24.com',
'feeds.capi24.com', // News24 redirect destination
// International Organizations
'news.un.org',
'www.iaea.org',
'www.who.int',
'www.cisa.gov',
'www.crisisgroup.org',
// Additional
'news.ycombinator.com',
];
// CORS helper - allow worldmonitor.app and Vercel preview domains
function getCorsHeaders(req) {
const origin = req.headers.get('origin') || '*';
const allowedPatterns = [
/^https:\/\/(.*\.)?worldmonitor\.app$/, // Matches worldmonitor.app and *.worldmonitor.app
/^https:\/\/.*-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor.*\.vercel\.app$/,
/^http:\/\/localhost(:\d+)?$/,
];
const isAllowed = origin === '*' || allowedPatterns.some(p => p.test(origin));
return {
'Access-Control-Allow-Origin': isAllowed ? origin : 'https://worldmonitor.app',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req);
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
const requestUrl = new URL(req.url);
const feedUrl = requestUrl.searchParams.get('url');
if (!feedUrl) {
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
@@ -137,7 +181,7 @@ export default async function handler(req) {
if (!ALLOWED_DOMAINS.includes(parsedUrl.hostname)) {
return new Response(JSON.stringify({ error: 'Domain not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
@@ -158,8 +202,8 @@ export default async function handler(req) {
status: response.status,
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
...corsHeaders,
},
});
} catch (error) {
@@ -171,7 +215,7 @@ export default async function handler(req) {
url: feedUrl
}), {
status: isTimeout ? 504 : 502,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}

4191
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "world-monitor",
"private": true,
"version": "1.7.3",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,6 +14,7 @@
},
"devDependencies": {
"@types/d3": "^7.4.3",
"@types/maplibre-gl": "^1.13.2",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"typescript": "^5.7.2",
@@ -21,8 +22,17 @@
"ws": "^8.19.0"
},
"dependencies": {
"@deck.gl/core": "^9.2.6",
"@deck.gl/geo-layers": "^9.2.6",
"@deck.gl/layers": "^9.2.6",
"@deck.gl/mapbox": "^9.2.6",
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@xenova/transformers": "^2.17.2",
"d3": "^7.9.0",
"deck.gl": "^9.2.6",
"maplibre-gl": "^5.16.0",
"onnxruntime-web": "^1.23.2",
"topojson-client": "^3.1.0",
"youtubei.js": "^16.0.1"
}

View File

@@ -199,13 +199,28 @@ const server = http.createServer(async (req, res) => {
return res.end(JSON.stringify({ error: 'Missing url parameter' }));
}
// Only allow specific blocked domains
// Allow domains that block Vercel IPs (must match feeds.ts railwayRss usage)
const allowedDomains = [
// Original
'rss.cnn.com',
'www.defensenews.com',
'layoffs.fyi',
// International Organizations
'news.un.org',
'www.cisa.gov',
'www.iaea.org',
'www.who.int',
'www.crisisgroup.org',
// Middle East & Regional News
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
// Africa
'feeds.24.com',
'feeds.capi24.com', // News24 redirect destination
];
const parsed = new URL(feedUrl);
if (!allowedDomains.includes(parsed.hostname)) {
@@ -216,39 +231,84 @@ const server = http.createServer(async (req, res) => {
console.log('[Relay] RSS request:', feedUrl);
const https = require('https');
const request = https.get(feedUrl, {
headers: {
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
timeout: 15000
}, (response) => {
let data = '';
response.on('data', chunk => data += chunk);
response.on('end', () => {
res.writeHead(response.statusCode, {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=300'
const http = require('http');
// Helper to fetch with redirect following (max 3 redirects)
let responseHandled = false;
const sendError = (statusCode, message) => {
if (responseHandled || res.headersSent) return;
responseHandled = true;
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
};
const fetchWithRedirects = (url, redirectCount = 0) => {
if (redirectCount > 3) {
return sendError(502, 'Too many redirects');
}
const protocol = url.startsWith('https') ? https : http;
const request = protocol.get(url, {
headers: {
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
timeout: 15000
}, (response) => {
// Handle redirects
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
const redirectUrl = response.headers.location.startsWith('http')
? response.headers.location
: new URL(response.headers.location, url).href;
console.log(`[Relay] Following redirect to: ${redirectUrl}`);
return fetchWithRedirects(redirectUrl, redirectCount + 1);
}
// Handle gzip/deflate compressed responses from upstream
const encoding = response.headers['content-encoding'];
let stream = response;
if (encoding === 'gzip' || encoding === 'deflate') {
const zlib = require('zlib');
stream = encoding === 'gzip' ? response.pipe(zlib.createGunzip()) : response.pipe(zlib.createInflate());
}
const chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('end', () => {
if (responseHandled || res.headersSent) return;
responseHandled = true;
const data = Buffer.concat(chunks);
res.writeHead(response.statusCode, {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=300'
});
res.end(data);
});
stream.on('error', (err) => {
console.error('[Relay] Decompression error:', err.message);
sendError(502, 'Decompression failed: ' + err.message);
});
res.end(data);
});
});
request.on('error', (err) => {
console.error('[Relay] RSS error:', err.message);
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
request.on('error', (err) => {
console.error('[Relay] RSS error:', err.message);
sendError(502, err.message);
});
request.on('timeout', () => {
request.destroy();
res.writeHead(504, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Request timeout' }));
});
request.on('timeout', () => {
request.destroy();
sendError(504, 'Request timeout');
});
};
fetchWithRedirects(feedUrl);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
}
} else if (req.url.startsWith('/opensky')) {
// Proxy OpenSky API requests with OAuth2 authentication

View File

@@ -1,4 +1,4 @@
import type { NewsItem, Monitor, PanelConfig, MapLayers, RelatedAsset } from '@/types';
import type { NewsItem, Monitor, PanelConfig, MapLayers, RelatedAsset, InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster } from '@/types';
import {
FEEDS,
INTEL_SOURCES,
@@ -12,8 +12,11 @@ import {
STORAGE_KEYS,
SITE_VARIANT,
} from '@/config';
import { fetchCategoryFeeds, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics, aggregateTechActivity, aggregateGeoActivity } from '@/services';
import { fetchCategoryFeeds, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics } from '@/services';
import { mlWorker } from '@/services/ml-worker';
import { clusterNewsHybrid } from '@/services/clustering';
import { ingestProtests, ingestFlights, ingestVessels, ingestEarthquakes, detectGeoConvergence, geoConvergenceToSignal } from '@/services/geo-convergence';
import { signalAggregator } from '@/services/signal-aggregator';
import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresence, foreignPresenceToSignal } from '@/services/military-surge';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, startLearning, isInLearningMode } from '@/services/country-instability';
import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
@@ -21,7 +24,8 @@ import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage
import { escapeHtml } from '@/utils/sanitize';
import type { ParsedMapUrlState } from '@/utils';
import {
MapComponent,
MapContainer,
type MapView,
NewsPanel,
MarketPanel,
HeatmapPanel,
@@ -45,11 +49,8 @@ import {
IntelligenceGapBadge,
TechEventsPanel,
ServiceStatusPanel,
TechHubsPanel,
TechReadinessPanel,
GeoHubsPanel,
InsightsPanel,
} from '@/components';
import type { MapView } from '@/components';
import type { SearchResult } from '@/components/SearchModal';
import { INTEL_HOTSPOTS, CONFLICT_ZONES, MILITARY_BASES, UNDERSEA_CABLES, NUCLEAR_FACILITIES } from '@/config/geo';
import { PIPELINES } from '@/config/pipelines';
@@ -63,7 +64,7 @@ import type { PredictionMarket, MarketData, ClusteredEvent } from '@/types';
export class App {
private container: HTMLElement;
private map: MapComponent | null = null;
private map: MapContainer | null = null;
private panels: Record<string, Panel> = {};
private newsPanels: Record<string, NewsPanel> = {};
private allNews: NewsItem[] = [];
@@ -80,9 +81,6 @@ export class App {
private latestPredictions: PredictionMarket[] = [];
private latestMarkets: MarketData[] = [];
private latestClusters: ClusteredEvent[] = [];
private techHubsPanel: TechHubsPanel | null = null;
private techReadinessPanel: TechReadinessPanel | null = null;
private geoHubsPanel: GeoHubsPanel | null = null;
private isPlaybackMode = false;
private initialUrlState: ParsedMapUrlState | null = null;
private inFlight: Set<string> = new Set();
@@ -118,8 +116,10 @@ export class App {
// Check if variant changed - reset all settings to variant defaults
const storedVariant = localStorage.getItem('worldmonitor-variant');
const currentVariant = SITE_VARIANT;
console.log(`[App] Variant check: stored="${storedVariant}", current="${currentVariant}"`);
if (storedVariant !== currentVariant) {
// Variant changed - use defaults for new variant, clear old settings
console.log('[App] Variant changed - resetting to defaults');
localStorage.setItem('worldmonitor-variant', currentVariant);
localStorage.removeItem(STORAGE_KEYS.mapLayers);
localStorage.removeItem(STORAGE_KEYS.panels);
@@ -132,6 +132,34 @@ export class App {
STORAGE_KEYS.panels,
DEFAULT_PANELS
);
console.log('[App] Loaded panel settings from storage:', Object.entries(this.panelSettings).filter(([_, v]) => !v.enabled).map(([k]) => k));
// One-time migration: reorder panels for existing users (v1.8 panel layout)
// Puts live-news, insights, cii, strategic-risk at the top
const PANEL_ORDER_MIGRATION_KEY = 'worldmonitor-panel-order-v1.8';
if (!localStorage.getItem(PANEL_ORDER_MIGRATION_KEY)) {
const savedOrder = localStorage.getItem('panel-order');
if (savedOrder) {
try {
const order: string[] = JSON.parse(savedOrder);
// Priority panels that should be at the top (after live-news which is handled separately)
const priorityPanels = ['insights', 'cii', 'strategic-risk'];
// Remove priority panels from their current positions
const filtered = order.filter(k => !priorityPanels.includes(k) && k !== 'live-news');
// Find live-news position (should be first, but just in case)
const liveNewsIdx = order.indexOf('live-news');
// Build new order: live-news first, then priority panels, then rest
const newOrder = liveNewsIdx !== -1 ? ['live-news'] : [];
newOrder.push(...priorityPanels.filter(p => order.includes(p)));
newOrder.push(...filtered);
localStorage.setItem('panel-order', JSON.stringify(newOrder));
console.log('[App] Migrated panel order to v1.8 layout');
} catch {
// Invalid saved order, will use defaults
}
}
localStorage.setItem(PANEL_ORDER_MIGRATION_KEY, 'done');
}
}
this.initialUrlState = parseMapUrlState(window.location.search, this.mapLayers);
@@ -152,6 +180,9 @@ export class App {
public async init(): Promise<void> {
await initDB();
// Initialize ML worker (desktop only - automatically disabled on mobile)
await mlWorker.init();
// Check AIS configuration before init
if (!isAisConfigured()) {
this.mapLayers.ais = false;
@@ -657,13 +688,15 @@ export class App {
private updateSearchIndex(): void {
if (!this.searchModal) return;
// Update news sources (use link as unique id)
this.searchModal.registerSource('news', this.allNews.slice(0, 200).map(n => ({
// Update news sources (use link as unique id) - index up to 500 items for better search coverage
const newsItems = this.allNews.slice(0, 500).map(n => ({
id: n.link,
title: n.title,
subtitle: n.source,
data: n,
})));
}));
console.log(`[Search] Indexing ${newsItems.length} news items (allNews total: ${this.allNews.length})`);
this.searchModal.registerSource('news', newsItems);
// Update predictions if available
if (this.latestPredictions.length > 0) {
@@ -784,19 +817,18 @@ export class App {
<span class="status-dot"></span>
<span>LIVE</span>
</div>
</div>
<div class="header-center">
<label class="focus-label">FOCUS</label>
<select class="focus-select" id="focusSelect">
<option value="global">GLOBAL</option>
<option value="america">AMERICA</option>
<option value="eu">EUROPE</option>
<option value="mena">MENA</option>
<option value="asia">ASIA</option>
<option value="africa">AFRICA</option>
<option value="latam">LAT AM</option>
<option value="oceania">OCEANIA</option>
</select>
<div class="region-selector">
<select id="regionSelect" class="region-select">
<option value="global">Global</option>
<option value="america">Americas</option>
<option value="mena">MENA</option>
<option value="eu">Europe</option>
<option value="asia">Asia</option>
<option value="latam">Latin America</option>
<option value="africa">Africa</option>
<option value="oceania">Oceania</option>
</select>
</div>
</div>
<div class="header-right">
<button class="search-btn" id="searchBtn"><kbd>⌘K</kbd> Search</button>
@@ -922,8 +954,9 @@ export class App {
// Initialize map in the map section
// Default to MENA view on mobile for better focus
// Uses deck.gl (WebGL) on desktop, falls back to D3/SVG on mobile
const mapContainer = document.getElementById('mapContainer') as HTMLElement;
this.map = new MapComponent(mapContainer, {
this.map = new MapContainer(mapContainer, {
zoom: this.isMobile ? 2.5 : 1.0,
pan: { x: 0, y: 0 }, // Centered view to show full world
view: this.isMobile ? 'mena' : 'global',
@@ -1124,33 +1157,9 @@ export class App {
const serviceStatusPanel = new ServiceStatusPanel();
this.panels['service-status'] = serviceStatusPanel;
// Tech Hubs Panel - shows active tech hub activity (all variants)
this.techHubsPanel = new TechHubsPanel();
this.panels['tech-hubs'] = this.techHubsPanel;
// Set up hub click handler to zoom map to hub location
this.techHubsPanel.setOnHubClick((hub) => {
this.map?.setCenter(hub.lat, hub.lon);
this.map?.flashLocation(hub.lat, hub.lon);
});
// Geo Hubs Panel - shows geopolitical activity hotspots (full variant only)
if (SITE_VARIANT === 'full') {
this.geoHubsPanel = new GeoHubsPanel();
this.panels['geo-hubs'] = this.geoHubsPanel;
this.geoHubsPanel.setOnHubClick((hub) => {
this.map?.setCenter(hub.lat, hub.lon);
this.map?.flashLocation(hub.lat, hub.lon);
});
}
// Tech Readiness Panel - World Bank tech indicators (tech variant only)
if (SITE_VARIANT === 'tech') {
this.techReadinessPanel = new TechReadinessPanel();
this.panels['tech-readiness'] = this.techReadinessPanel;
this.techReadinessPanel.refresh();
}
// AI Insights Panel (desktop only - hides itself on mobile)
const insightsPanel = new InsightsPanel();
this.panels['insights'] = insightsPanel;
// Add panels to grid in saved order
// Use DEFAULT_PANELS keys for variant-aware panel order
@@ -1193,11 +1202,6 @@ export class App {
this.applyPanelSettings();
this.applyInitialUrlState();
// Set correct view button state (especially for mobile defaults)
const currentView = this.map?.getState().view;
if (currentView) {
this.setActiveFocusRegion(currentView);
}
}
private applyInitialUrlState(): void {
@@ -1207,7 +1211,6 @@ export class App {
if (view) {
this.map.setView(view);
this.setActiveFocusRegion(view);
}
if (timeRange) {
@@ -1233,6 +1236,13 @@ export class App {
this.map.setCenter(lat, lon);
}
}
// Sync header region selector with initial view
const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement;
const currentView = this.map.getState().view;
if (regionSelect && currentView) {
regionSelect.value = currentView;
}
}
private getSavedPanelOrder(): string[] {
@@ -1303,6 +1313,17 @@ export class App {
el.dataset.panel = key;
el.addEventListener('dragstart', (e) => {
const target = e.target as HTMLElement;
// Don't start drag if panel is being resized
if (el.dataset.resizing === 'true') {
e.preventDefault();
return;
}
// Don't start drag if target is the resize handle
if (target.classList.contains('panel-resize-handle') || target.closest('.panel-resize-handle')) {
e.preventDefault();
return;
}
el.classList.add('dragging');
e.dataTransfer?.setData('text/plain', key);
});
@@ -1335,13 +1356,6 @@ export class App {
}
private setupEventListeners(): void {
// Focus region selector
const focusSelect = document.getElementById('focusSelect') as HTMLSelectElement;
focusSelect?.addEventListener('change', () => {
const view = focusSelect.value as MapView;
this.map?.setView(view);
});
// Search button
document.getElementById('searchBtn')?.addEventListener('click', () => {
this.updateSearchIndex();
@@ -1389,6 +1403,12 @@ export class App {
};
document.addEventListener('fullscreenchange', this.boundFullscreenHandler);
// Region selector
const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement;
regionSelect?.addEventListener('change', () => {
this.map?.setView(regionSelect.value as MapView);
});
// Window resize
this.boundResizeHandler = () => {
this.map?.render();
@@ -1401,16 +1421,22 @@ export class App {
// Map pin toggle
this.setupMapPin();
// Pause animations when tab is hidden
// Pause animations when tab is hidden, unload ML models to free memory
this.boundVisibilityHandler = () => {
document.body.classList.toggle('animations-paused', document.hidden);
// Also reset idle timer when tab becomes visible
if (!document.hidden) {
if (document.hidden) {
mlWorker.unloadOptionalModels();
} else {
this.resetIdleTimer();
}
};
document.addEventListener('visibilitychange', this.boundVisibilityHandler);
// Refresh CII when focal points are ready (ensures focal point urgency is factored in)
window.addEventListener('focal-points-ready', () => {
(this.panels['cii'] as CIIPanel)?.refresh(true); // forceLocal to use focal point data
});
// Idle detection - pause animations after 2 minutes of inactivity
this.setupIdleDetection();
}
@@ -1455,9 +1481,16 @@ export class App {
history.replaceState(null, '', shareUrl);
}, 250);
this.map.onStateChanged((state) => {
this.map.onStateChanged(() => {
update();
this.setActiveFocusRegion(state.view);
// Sync header region selector with map view
const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement;
if (regionSelect && this.map) {
const state = this.map.getState();
if (regionSelect.value !== state.view) {
regionSelect.value = state.view;
}
}
});
update();
}
@@ -1502,13 +1535,6 @@ export class App {
}, 1500);
}
private setActiveFocusRegion(view: MapView): void {
const focusSelect = document.getElementById('focusSelect') as HTMLSelectElement;
if (focusSelect) {
focusSelect.value = view;
}
}
private toggleFullscreen(): void {
if (document.fullscreenElement) {
document.exitFullscreen();
@@ -1544,7 +1570,7 @@ export class App {
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaY = e.clientY - startY;
const newHeight = Math.max(400, Math.min(startHeight + deltaY, window.innerHeight * 0.85));
const newHeight = Math.max(400, Math.min(startHeight + deltaY, window.innerHeight - 60));
mapSection.style.height = `${newHeight}px`;
this.map?.render();
});
@@ -1739,17 +1765,19 @@ export class App {
{ name: 'fred', task: runGuarded('fred', () => this.loadFredData()) },
{ name: 'oil', task: runGuarded('oil', () => this.loadOilAnalytics()) },
{ name: 'spending', task: runGuarded('spending', () => this.loadGovernmentSpending()) },
// ALWAYS load intelligence signals for CII calculation (protests, military, outages)
// This ensures CII scores are accurate even when map layers are disabled
{ name: 'intelligence', task: runGuarded('intelligence', () => this.loadIntelligenceSignals()) },
];
// Conditionally load based on layer settings
// Conditionally load non-intelligence layers
// NOTE: outages, protests, military are handled by loadIntelligenceSignals() above
// They update the map when layers are enabled, so no duplicate tasks needed here
if (this.mapLayers.natural) tasks.push({ name: 'natural', task: runGuarded('natural', () => this.loadNatural()) });
if (this.mapLayers.weather) tasks.push({ name: 'weather', task: runGuarded('weather', () => this.loadWeatherAlerts()) });
if (this.mapLayers.outages) tasks.push({ name: 'outages', task: runGuarded('outages', () => this.loadOutages()) });
if (this.mapLayers.ais) tasks.push({ name: 'ais', task: runGuarded('ais', () => this.loadAisSignals()) });
if (this.mapLayers.cables) tasks.push({ name: 'cables', task: runGuarded('cables', () => this.loadCableActivity()) });
if (this.mapLayers.protests) tasks.push({ name: 'protests', task: runGuarded('protests', () => this.loadProtests()) });
if (this.mapLayers.flights) tasks.push({ name: 'flights', task: runGuarded('flights', () => this.loadFlightDelays()) });
if (this.mapLayers.military) tasks.push({ name: 'military', task: runGuarded('military', () => this.loadMilitary()) });
if (this.mapLayers.techEvents || SITE_VARIANT === 'tech') tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
// Use allSettled to ensure all tasks complete and search index always updates
@@ -1980,7 +2008,6 @@ export class App {
{ key: 'dev', feeds: FEEDS.dev },
{ key: 'github', feeds: FEEDS.github },
{ key: 'ipo', feeds: FEEDS.ipo },
{ key: 'podcasts', feeds: FEEDS.podcasts },
];
// Filter to only categories that have feeds defined
const categories = allCategories.filter(c => c.feeds && c.feeds.length > 0);
@@ -2032,25 +2059,19 @@ export class App {
// Update monitors
this.updateMonitorResults();
// Update clusters for correlation analysis (off main thread via Web Worker)
// Update clusters for correlation analysis (hybrid: semantic + Jaccard when ML available)
try {
this.latestClusters = await analysisWorker.clusterNews(this.allNews);
this.latestClusters = mlWorker.isAvailable
? await clusterNewsHybrid(this.allNews)
: await analysisWorker.clusterNews(this.allNews);
// Aggregate tech hub activity from news clusters (all variants)
if (this.latestClusters.length > 0) {
const techActivity = aggregateTechActivity(this.latestClusters);
this.map?.setTechActivity(techActivity);
this.techHubsPanel?.setActivities(techActivity);
// Aggregate geopolitical hub activity (full variant only)
if (SITE_VARIANT === 'full') {
const geoActivity = aggregateGeoActivity(this.latestClusters);
this.map?.setGeoActivity(geoActivity);
this.geoHubsPanel?.setActivities(geoActivity);
}
// Update AI Insights panel with new clusters (if ML available)
if (mlWorker.isAvailable && this.latestClusters.length > 0) {
const insightsPanel = this.panels['insights'] as InsightsPanel | undefined;
insightsPanel?.updateInsights(this.latestClusters);
}
} catch (error) {
console.error('[App] Worker clustering failed, clusters unchanged:', error);
console.error('[App] Clustering failed, clusters unchanged:', error);
}
}
@@ -2242,12 +2263,146 @@ export class App {
}
}
// Cache for intelligence data - allows CII to work even when layers are disabled
private intelligenceCache: {
outages?: InternetOutage[];
protests?: { events: SocialUnrestEvent[]; sources: { acled: number; gdelt: number } };
military?: { flights: MilitaryFlight[]; flightClusters: MilitaryFlightCluster[]; vessels: MilitaryVessel[]; vesselClusters: MilitaryVesselCluster[] };
} = {};
/**
* Load intelligence-critical signals for CII/focal point calculation
* This runs ALWAYS, regardless of layer visibility
* Map rendering is separate and still gated by layer visibility
*/
private async loadIntelligenceSignals(): Promise<void> {
const tasks: Promise<void>[] = [];
// Always fetch outages for CII (internet blackouts = major instability signal)
tasks.push((async () => {
try {
const outages = await fetchInternetOutages();
this.intelligenceCache.outages = outages;
ingestOutagesForCII(outages);
signalAggregator.ingestOutages(outages);
dataFreshness.recordUpdate('outages', outages.length);
// Update map only if layer is visible
if (this.mapLayers.outages) {
this.map?.setOutages(outages);
this.map?.setLayerReady('outages', outages.length > 0);
this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length });
}
} catch (error) {
console.error('[Intelligence] Outages fetch failed:', error);
dataFreshness.recordError('outages', String(error));
}
})());
// Always fetch protests for CII (unrest = core instability metric)
tasks.push((async () => {
try {
const protestData = await fetchProtestEvents();
this.intelligenceCache.protests = protestData;
ingestProtests(protestData.events);
ingestProtestsForCII(protestData.events);
signalAggregator.ingestProtests(protestData.events);
const protestCount = protestData.sources.acled + protestData.sources.gdelt;
if (protestCount > 0) dataFreshness.recordUpdate('acled', protestCount);
if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt);
// Update map only if layer is visible
if (this.mapLayers.protests) {
this.map?.setProtests(protestData.events);
this.map?.setLayerReady('protests', protestData.events.length > 0);
const status = getProtestStatus();
this.statusPanel?.updateFeed('Protests', {
status: 'ok',
itemCount: protestData.events.length,
errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined,
});
}
} catch (error) {
console.error('[Intelligence] Protests fetch failed:', error);
dataFreshness.recordError('acled', String(error));
}
})());
// Always fetch military for CII (security = core instability metric)
tasks.push((async () => {
try {
if (isMilitaryVesselTrackingConfigured()) {
initMilitaryVesselStream();
}
const [flightData, vesselData] = await Promise.all([
fetchMilitaryFlights(),
fetchMilitaryVessels(),
]);
this.intelligenceCache.military = {
flights: flightData.flights,
flightClusters: flightData.clusters,
vessels: vesselData.vessels,
vesselClusters: vesselData.clusters,
};
ingestFlights(flightData.flights);
ingestVessels(vesselData.vessels);
ingestMilitaryForCII(flightData.flights, vesselData.vessels);
signalAggregator.ingestFlights(flightData.flights);
signalAggregator.ingestVessels(vesselData.vessels);
dataFreshness.recordUpdate('opensky', flightData.flights.length);
// Update map only if layer is visible
if (this.mapLayers.military) {
this.map?.setMilitaryFlights(flightData.flights, flightData.clusters);
this.map?.setMilitaryVessels(vesselData.vessels, vesselData.clusters);
this.map?.updateMilitaryForEscalation(flightData.flights, vesselData.vessels);
const militaryCount = flightData.flights.length + vesselData.vessels.length;
this.statusPanel?.updateFeed('Military', {
status: militaryCount > 0 ? 'ok' : 'warning',
itemCount: militaryCount,
});
}
// Detect military airlift surges and foreign presence (suppress during learning mode)
if (!isInLearningMode()) {
const surgeAlerts = analyzeFlightsForSurge(flightData.flights);
if (surgeAlerts.length > 0) {
const surgeSignals = surgeAlerts.map(surgeAlertToSignal);
addToSignalHistory(surgeSignals);
this.signalModal?.show(surgeSignals);
}
const foreignAlerts = detectForeignMilitaryPresence(flightData.flights);
if (foreignAlerts.length > 0) {
const foreignSignals = foreignAlerts.map(foreignPresenceToSignal);
addToSignalHistory(foreignSignals);
this.signalModal?.show(foreignSignals);
}
}
} catch (error) {
console.error('[Intelligence] Military fetch failed:', error);
dataFreshness.recordError('opensky', String(error));
}
})());
await Promise.allSettled(tasks);
// Now trigger CII refresh with all intelligence data
(this.panels['cii'] as CIIPanel)?.refresh();
console.log('[Intelligence] All signals loaded for CII calculation');
}
private async loadOutages(): Promise<void> {
// Use cached data if available
if (this.intelligenceCache.outages) {
const outages = this.intelligenceCache.outages;
this.map?.setOutages(outages);
this.map?.setLayerReady('outages', outages.length > 0);
this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length });
return;
}
try {
const outages = await fetchInternetOutages();
this.intelligenceCache.outages = outages;
this.map?.setOutages(outages);
this.map?.setLayerReady('outages', outages.length > 0);
ingestOutagesForCII(outages);
signalAggregator.ingestOutages(outages);
this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length });
dataFreshness.recordUpdate('outages', outages.length);
} catch (error) {
@@ -2263,6 +2418,7 @@ export class App {
const aisStatus = getAisStatus();
console.log('[Ships] Events:', { disruptions: disruptions.length, density: density.length, vessels: aisStatus.vessels });
this.map?.setAisData(disruptions, density);
signalAggregator.ingestAisDisruptions(disruptions);
const hasData = disruptions.length > 0 || density.length > 0;
this.map?.setLayerReady('ais', hasData);
@@ -2330,32 +2486,43 @@ export class App {
}
private async loadProtests(): Promise<void> {
try {
const protestData = await fetchProtestEvents();
// Use cached data if available (from loadIntelligenceSignals)
if (this.intelligenceCache.protests) {
const protestData = this.intelligenceCache.protests;
this.map?.setProtests(protestData.events);
this.map?.setLayerReady('protests', protestData.events.length > 0);
ingestProtests(protestData.events);
ingestProtestsForCII(protestData.events);
// Record data freshness AFTER CII ingestion to avoid race conditions
// For 'acled' source: count GDELT protests too since GDELT serves as fallback
const protestCount = protestData.sources.acled + protestData.sources.gdelt;
if (protestCount > 0) {
dataFreshness.recordUpdate('acled', protestCount);
}
if (protestData.sources.gdelt > 0) {
dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt);
}
(this.panels['cii'] as CIIPanel)?.refresh();
const status = getProtestStatus();
this.statusPanel?.updateFeed('Protests', {
status: 'ok',
itemCount: protestData.events.length,
errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined,
});
if (status.acledConfigured === true) {
this.statusPanel?.updateApi('ACLED', { status: 'ok' });
} else if (status.acledConfigured === null) {
this.statusPanel?.updateApi('ACLED', { status: 'warning' });
}
this.statusPanel?.updateApi('GDELT', { status: 'ok' });
return;
}
try {
const protestData = await fetchProtestEvents();
this.intelligenceCache.protests = protestData;
this.map?.setProtests(protestData.events);
this.map?.setLayerReady('protests', protestData.events.length > 0);
ingestProtests(protestData.events);
ingestProtestsForCII(protestData.events);
signalAggregator.ingestProtests(protestData.events);
const protestCount = protestData.sources.acled + protestData.sources.gdelt;
if (protestCount > 0) dataFreshness.recordUpdate('acled', protestCount);
if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt);
(this.panels['cii'] as CIIPanel)?.refresh();
const status = getProtestStatus();
this.statusPanel?.updateFeed('Protests', {
status: 'ok',
itemCount: protestData.events.length,
errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined,
});
if (status.acledConfigured === true) {
this.statusPanel?.updateApi('ACLED', { status: 'ok' });
} else if (status.acledConfigured === null) {
@@ -2388,27 +2555,46 @@ export class App {
}
private async loadMilitary(): Promise<void> {
// Use cached data if available (from loadIntelligenceSignals)
if (this.intelligenceCache.military) {
const { flights, flightClusters, vessels, vesselClusters } = this.intelligenceCache.military;
this.map?.setMilitaryFlights(flights, flightClusters);
this.map?.setMilitaryVessels(vessels, vesselClusters);
this.map?.updateMilitaryForEscalation(flights, vessels);
const hasData = flights.length > 0 || vessels.length > 0;
this.map?.setLayerReady('military', hasData);
const militaryCount = flights.length + vessels.length;
this.statusPanel?.updateFeed('Military', {
status: militaryCount > 0 ? 'ok' : 'warning',
itemCount: militaryCount,
errorMessage: militaryCount === 0 ? 'No military activity in view' : undefined,
});
this.statusPanel?.updateApi('OpenSky', { status: 'ok' });
return;
}
try {
// Initialize vessel stream if not already running
if (isMilitaryVesselTrackingConfigured()) {
initMilitaryVesselStream();
}
// Load both flights and vessels in parallel
const [flightData, vesselData] = await Promise.all([
fetchMilitaryFlights(),
fetchMilitaryVessels(),
]);
this.intelligenceCache.military = {
flights: flightData.flights,
flightClusters: flightData.clusters,
vessels: vesselData.vessels,
vesselClusters: vesselData.clusters,
};
this.map?.setMilitaryFlights(flightData.flights, flightData.clusters);
this.map?.setMilitaryVessels(vesselData.vessels, vesselData.clusters);
ingestFlights(flightData.flights);
ingestVessels(vesselData.vessels);
ingestMilitaryForCII(flightData.flights, vesselData.vessels);
signalAggregator.ingestFlights(flightData.flights);
signalAggregator.ingestVessels(vesselData.vessels);
this.map?.updateMilitaryForEscalation(flightData.flights, vesselData.vessels);
(this.panels['cii'] as CIIPanel)?.refresh();
// Detect military airlift surges and foreign presence (suppress during learning mode)
if (!isInLearningMode()) {
const surgeAlerts = analyzeFlightsForSurge(flightData.flights);
if (surgeAlerts.length > 0) {
@@ -2416,8 +2602,6 @@ export class App {
addToSignalHistory(surgeSignals);
this.signalModal?.show(surgeSignals);
}
// Detect foreign military concentration in sensitive regions (immediate, no baseline needed)
const foreignAlerts = detectForeignMilitaryPresence(flightData.flights);
if (foreignAlerts.length > 0) {
const foreignSignals = foreignAlerts.map(foreignPresenceToSignal);
@@ -2425,17 +2609,15 @@ export class App {
this.signalModal?.show(foreignSignals);
}
}
const hasData = flightData.flights.length > 0 || vesselData.vessels.length > 0;
this.map?.setLayerReady('military', hasData);
const militaryCount = flightData.flights.length + vesselData.vessels.length;
this.statusPanel?.updateFeed('Military', {
status: militaryCount > 0 ? 'ok' : 'warning',
itemCount: militaryCount,
errorMessage: militaryCount === 0 ? 'No military activity in view' : undefined,
});
this.statusPanel?.updateApi('OpenSky', { status: 'ok' }); // API worked, just no data in view
this.statusPanel?.updateApi('OpenSky', { status: 'ok' });
dataFreshness.recordUpdate('opensky', flightData.flights.length);
} catch (error) {
this.map?.setLayerReady('military', false);
@@ -2476,6 +2658,7 @@ export class App {
economicPanel?.setErrorState(false);
economicPanel?.update(data);
this.statusPanel?.updateApi('FRED', { status: 'ok' });
dataFreshness.recordUpdate('economic', data.length);
} catch {
this.statusPanel?.updateApi('FRED', { status: 'error' });
economicPanel?.setErrorState(true, 'Failed to load data');
@@ -2510,9 +2693,11 @@ export class App {
private async runCorrelationAnalysis(): Promise<void> {
try {
// Ensure we have clusters (compute via worker if needed)
// Ensure we have clusters (hybrid: semantic + Jaccard when ML available)
if (this.latestClusters.length === 0 && this.allNews.length > 0) {
this.latestClusters = await analysisWorker.clusterNews(this.allNews);
this.latestClusters = mlWorker.isAvailable
? await clusterNewsHybrid(this.allNews)
: await analysisWorker.clusterNews(this.allNews);
}
// Ingest news clusters for CII
@@ -2607,11 +2792,18 @@ export class App {
this.scheduleRefresh('fred', () => this.loadFredData(), 30 * 60 * 1000);
this.scheduleRefresh('oil', () => this.loadOilAnalytics(), 30 * 60 * 1000);
this.scheduleRefresh('spending', () => this.loadGovernmentSpending(), 60 * 60 * 1000);
this.scheduleRefresh('outages', () => this.loadOutages(), 60 * 60 * 1000, () => this.mapLayers.outages);
// ALWAYS refresh intelligence signals for CII (no layer condition)
// This handles outages, protests, military - updates map when layers enabled
this.scheduleRefresh('intelligence', () => {
this.intelligenceCache = {}; // Clear cache to force fresh fetch
return this.loadIntelligenceSignals();
}, 5 * 60 * 1000);
// Non-intelligence layer refreshes only
// NOTE: outages, protests, military are refreshed by intelligence schedule above
this.scheduleRefresh('ais', () => this.loadAisSignals(), REFRESH_INTERVALS.ais, () => this.mapLayers.ais);
this.scheduleRefresh('cables', () => this.loadCableActivity(), 30 * 60 * 1000, () => this.mapLayers.cables);
this.scheduleRefresh('protests', () => this.loadProtests(), 15 * 60 * 1000, () => this.mapLayers.protests);
this.scheduleRefresh('flights', () => this.loadFlightDelays(), 10 * 60 * 1000, () => this.mapLayers.flights);
this.scheduleRefresh('military', () => this.loadMilitary(), 5 * 60 * 1000, () => this.mapLayers.military);
}
}

View File

@@ -1,9 +1,10 @@
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
import { calculateCII, getLearningProgress, type CountryScore } from '@/services/country-instability';
import { calculateCII, type CountryScore } from '@/services/country-instability';
export class CIIPanel extends Panel {
private scores: CountryScore[] = [];
private focalPointsReady = false;
constructor() {
super({
@@ -15,14 +16,22 @@ export class CIIPanel extends Panel {
Score (0-100) per country based on:
<ul>
<li>40% baseline geopolitical risk</li>
<li>Unrest: protests, fatalities, internet outages</li>
<li>Security: military flights/vessels over territory</li>
<li>Information: news velocity and alerts</li>
<li><strong>U</strong>nrest: protests, fatalities, internet outages</li>
<li><strong>S</strong>ecurity: military flights/vessels over territory</li>
<li><strong>I</strong>nformation: news velocity and focal point correlation</li>
<li>Hotspot proximity boost (strategic locations)</li>
</ul>
Event multipliers adjust for media coverage bias.`,
<em>U:S:I values show component scores.</em>
Focal Point Detection correlates news entities with map signals for accurate scoring.`,
});
this.refresh();
// Show loading state until focal points ready
this.content.innerHTML = `
<div class="cii-awaiting">
<div class="cii-awaiting-icon">📊</div>
<div class="cii-awaiting-text">Analyzing intelligence...</div>
<div class="cii-awaiting-sub">Correlating news with map signals</div>
</div>
`;
}
private getLevelColor(level: CountryScore['level']): string {
@@ -51,24 +60,6 @@ export class CIIPanel extends Panel {
return '<span class="trend-stable">→</span>';
}
private renderLearningBanner(): string {
const { inLearning, remainingMinutes, progress } = getLearningProgress();
if (!inLearning) return '';
return `
<div class="cii-learning-banner">
<div class="learning-icon">📊</div>
<div class="learning-text">
<div class="learning-title">Learning Mode</div>
<div class="learning-desc">Calibrating scores (~${remainingMinutes}m remaining)</div>
</div>
<div class="learning-progress">
<div class="learning-bar" style="width: ${progress}%"></div>
</div>
</div>
`;
}
private renderCountry(country: CountryScore): string {
const barWidth = country.score;
const color = this.getLevelColor(country.level);
@@ -95,25 +86,36 @@ export class CIIPanel extends Panel {
`;
}
public async refresh(): Promise<void> {
public async refresh(forceLocal = false): Promise<void> {
// Don't show scores until focal points are ready (avoids misleading preliminary data)
if (!this.focalPointsReady && !forceLocal) {
return; // Keep showing "Analyzing intelligence..." state
}
if (forceLocal) {
this.focalPointsReady = true;
console.log('[CIIPanel] Focal points ready, calculating scores...');
}
this.showLoading();
try {
this.scores = calculateCII();
// Calculate with focal point data
const localScores = calculateCII();
const localWithData = localScores.filter(s => s.score > 0).length;
this.scores = localScores;
console.log(`[CIIPanel] Calculated ${localWithData} countries with focal point intelligence`);
const withData = this.scores.filter(s => s.score > 0);
this.setCount(withData.length);
const { inLearning } = getLearningProgress();
const learningBanner = this.renderLearningBanner();
const learningClass = inLearning ? 'cii-learning' : '';
if (withData.length === 0) {
this.content.innerHTML = learningBanner + '<div class="empty-state">Collecting data...</div>';
this.content.innerHTML = '<div class="empty-state">No instability signals detected</div>';
return;
}
const html = withData.map(s => this.renderCountry(s)).join('');
this.content.innerHTML = learningBanner + `<div class="cii-list ${learningClass}">${html}</div>`;
this.content.innerHTML = `<div class="cii-list">${html}</div>`;
} catch (error) {
console.error('[CIIPanel] Refresh error:', error);
this.showError('Failed to calculate CII');

2570
src/components/DeckGLMap.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,561 @@
import { Panel } from './Panel';
import { mlWorker } from '@/services/ml-worker';
import { generateSummary, type SummarizationProvider } from '@/services/summarization';
import { parallelAnalysis, type AnalyzedHeadline } from '@/services/parallel-analysis';
import { signalAggregator, logSignalSummary, type RegionalConvergence } from '@/services/signal-aggregator';
import { focalPointDetector } from '@/services/focal-point-detector';
import { ingestNewsForCII } from '@/services/country-instability';
import { isMobileDevice } from '@/utils';
import { escapeHtml } from '@/utils/sanitize';
import type { ClusteredEvent, FocalPoint } from '@/types';
export class InsightsPanel extends Panel {
private isHidden = false;
private lastBriefUpdate = 0;
private cachedBrief: string | null = null;
private briefProvider: SummarizationProvider | null = null;
private lastMissedStories: AnalyzedHeadline[] = [];
private lastConvergenceZones: RegionalConvergence[] = [];
private lastFocalPoints: FocalPoint[] = [];
private static readonly BRIEF_COOLDOWN_MS = 120000; // 2 min cooldown (API has limits)
constructor() {
super({
id: 'insights',
title: 'AI INSIGHTS',
showCount: false,
infoTooltip: `
<strong>AI-Powered Analysis</strong><br>
• <strong>World Brief</strong>: AI summary (Groq/OpenRouter)<br>
• <strong>Sentiment</strong>: News tone analysis<br>
• <strong>Velocity</strong>: Fast-moving stories<br>
• <strong>Focal Points</strong>: Correlates news entities with map signals (military, protests, outages)<br>
<em>Desktop only • Powered by Llama 3.3 + Focal Point Detection</em>
`,
});
if (isMobileDevice()) {
this.hide();
this.isHidden = true;
}
}
// High-priority military/conflict keywords (huge boost)
private static readonly MILITARY_KEYWORDS = [
'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops',
'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship',
'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack',
];
// Violence/casualty keywords (huge boost - human cost stories)
private static readonly VIOLENCE_KEYWORDS = [
'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter',
'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution',
'crackdown', 'violent', 'clashes', 'gunfire', 'shooting',
];
// Civil unrest keywords (high boost)
private static readonly UNREST_KEYWORDS = [
'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots',
'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow',
'coup', 'martial law', 'curfew', 'shutdown', 'blackout',
];
// Geopolitical flashpoints (major boost)
private static readonly FLASHPOINT_KEYWORDS = [
'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv',
'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus',
'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner',
];
// Crisis keywords (moderate boost)
private static readonly CRISIS_KEYWORDS = [
'crisis', 'emergency', 'catastrophe', 'disaster', 'collapse', 'humanitarian',
'sanctions', 'ultimatum', 'threat', 'retaliation', 'escalation', 'tensions',
'breaking', 'urgent', 'developing', 'exclusive',
];
// Business/tech context that should REDUCE score (demote business news with military words)
private static readonly DEMOTE_KEYWORDS = [
'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue',
'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation',
];
private getImportanceScore(cluster: ClusteredEvent): number {
let score = 0;
const titleLower = cluster.primaryTitle.toLowerCase();
// Source confirmation (base signal)
score += cluster.sourceCount * 10;
// Violence/casualty keywords: highest priority (+100 base, +25 per match)
// "Pools of blood" type stories should always surface
const violenceMatches = InsightsPanel.VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw));
if (violenceMatches.length > 0) {
score += 100 + (violenceMatches.length * 25);
}
// Military keywords: highest priority (+80 base, +20 per match)
const militaryMatches = InsightsPanel.MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw));
if (militaryMatches.length > 0) {
score += 80 + (militaryMatches.length * 20);
}
// Civil unrest: high priority (+70 base, +18 per match)
const unrestMatches = InsightsPanel.UNREST_KEYWORDS.filter(kw => titleLower.includes(kw));
if (unrestMatches.length > 0) {
score += 70 + (unrestMatches.length * 18);
}
// Flashpoint keywords: high priority (+60 base, +15 per match)
const flashpointMatches = InsightsPanel.FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw));
if (flashpointMatches.length > 0) {
score += 60 + (flashpointMatches.length * 15);
}
// COMBO BONUS: Violence/unrest + flashpoint location = critical story
// e.g., "Iran protests" + "blood" = huge boost
if ((violenceMatches.length > 0 || unrestMatches.length > 0) && flashpointMatches.length > 0) {
score *= 1.5; // 50% bonus for flashpoint unrest
}
// Crisis keywords: moderate priority (+30 base, +10 per match)
const crisisMatches = InsightsPanel.CRISIS_KEYWORDS.filter(kw => titleLower.includes(kw));
if (crisisMatches.length > 0) {
score += 30 + (crisisMatches.length * 10);
}
// Demote business/tech news that happens to contain military words
const demoteMatches = InsightsPanel.DEMOTE_KEYWORDS.filter(kw => titleLower.includes(kw));
if (demoteMatches.length > 0) {
score *= 0.3; // Heavy penalty for business context
}
// Velocity multiplier
const velMultiplier: Record<string, number> = {
'viral': 3,
'spike': 2.5,
'elevated': 1.5,
'normal': 1
};
score *= velMultiplier[cluster.velocity?.level ?? 'normal'] ?? 1;
// Alert bonus
if (cluster.isAlert) score += 50;
// Recency bonus (decay over 12 hours)
const ageMs = Date.now() - cluster.firstSeen.getTime();
const ageHours = ageMs / 3600000;
const recencyMultiplier = Math.max(0.5, 1 - (ageHours / 12));
score *= recencyMultiplier;
return score;
}
private selectTopStories(clusters: ClusteredEvent[], maxCount: number): ClusteredEvent[] {
// Score ALL clusters first - high-scoring stories override source requirements
const allScored = clusters
.map(c => ({ cluster: c, score: this.getImportanceScore(c) }));
// Filter: require at least 2 sources OR alert OR elevated velocity OR high score
// High score (>100) means critical keywords were matched - don't require multi-source
const candidates = allScored.filter(({ cluster: c, score }) =>
c.sourceCount >= 2 ||
c.isAlert ||
(c.velocity && c.velocity.level !== 'normal') ||
score > 100 // Critical stories bypass source requirement
);
// Sort by score
const scored = candidates.sort((a, b) => b.score - a.score);
// Select with source diversity (max 3 from same primary source)
const selected: ClusteredEvent[] = [];
const sourceCount = new Map<string, number>();
const MAX_PER_SOURCE = 3;
for (const { cluster } of scored) {
const source = cluster.primarySource;
const count = sourceCount.get(source) || 0;
if (count < MAX_PER_SOURCE) {
selected.push(cluster);
sourceCount.set(source, count + 1);
}
if (selected.length >= maxCount) break;
}
return selected;
}
private setProgress(step: number, total: number, message: string): void {
const percent = Math.round((step / total) * 100);
this.setContent(`
<div class="insights-progress">
<div class="insights-progress-bar">
<div class="insights-progress-fill" style="width: ${percent}%"></div>
</div>
<div class="insights-progress-info">
<span class="insights-progress-step">Step ${step}/${total}</span>
<span class="insights-progress-message">${message}</span>
</div>
</div>
`);
}
public async updateInsights(clusters: ClusteredEvent[]): Promise<void> {
if (this.isHidden) return;
if (clusters.length === 0) {
this.setContent('<div class="insights-empty">Waiting for news data...</div>');
return;
}
const totalSteps = 4;
try {
// Step 1: Filter and rank stories by composite importance score
this.setProgress(1, totalSteps, 'Ranking important stories...');
const importantClusters = this.selectTopStories(clusters, 8);
// Run parallel multi-perspective analysis in background (logs to console)
// This analyzes ALL clusters, not just the keyword-filtered ones
const parallelPromise = parallelAnalysis.analyzeHeadlines(clusters).then(report => {
this.lastMissedStories = report.missedByKeywords;
const suggestions = parallelAnalysis.getSuggestedImprovements();
if (suggestions.length > 0) {
console.log('%c💡 Improvement Suggestions:', 'color: #f59e0b; font-weight: bold');
suggestions.forEach(s => console.log(`${s}`));
}
}).catch(err => {
console.warn('[ParallelAnalysis] Error:', err);
});
// Get geographic signal correlations
const signalSummary = signalAggregator.getSummary();
this.lastConvergenceZones = signalSummary.convergenceZones;
if (signalSummary.totalSignals > 0) {
logSignalSummary();
}
// Run focal point detection (correlates news entities with map signals)
const focalSummary = focalPointDetector.analyze(clusters, signalSummary);
this.lastFocalPoints = focalSummary.focalPoints;
if (focalSummary.focalPoints.length > 0) {
focalPointDetector.logSummary();
// Ingest news for CII BEFORE signaling (so CII has data when it calculates)
ingestNewsForCII(clusters);
// Signal CII to refresh now that focal points AND news data are available
window.dispatchEvent(new CustomEvent('focal-points-ready'));
}
if (importantClusters.length === 0) {
this.setContent('<div class="insights-empty">No breaking or multi-source stories yet</div>');
return;
}
const titles = importantClusters.map(c => c.primaryTitle);
// Step 2: Analyze sentiment (browser-based, fast)
this.setProgress(2, totalSteps, 'Analyzing sentiment...');
let sentiments: Array<{ label: string; score: number }> | null = null;
if (mlWorker.isAvailable) {
sentiments = await mlWorker.classifySentiment(titles).catch(() => null);
}
// Step 3: Generate World Brief (with cooldown)
let worldBrief = this.cachedBrief;
const now = Date.now();
if (!worldBrief || now - this.lastBriefUpdate > InsightsPanel.BRIEF_COOLDOWN_MS) {
this.setProgress(3, totalSteps, 'Generating world brief...');
// Pass focal point context to AI for correlation-aware summarization
const geoContext = focalSummary.aiContext || signalSummary.aiContext;
const result = await generateSummary(titles, (_step, _total, msg) => {
// Show sub-progress for summarization
this.setProgress(3, totalSteps, `Generating brief: ${msg}`);
}, geoContext);
if (result) {
worldBrief = result.summary;
this.cachedBrief = worldBrief;
this.briefProvider = result.provider;
this.lastBriefUpdate = now;
console.log(`[InsightsPanel] Brief from ${result.provider}${result.cached ? ' (cached)' : ''}${geoContext ? ' (with geo context)' : ''}`);
}
} else {
this.setProgress(3, totalSteps, 'Using cached brief...');
}
// Step 4: Wait for parallel analysis to complete
this.setProgress(4, totalSteps, 'Multi-perspective analysis...');
await parallelPromise;
this.renderInsights(importantClusters, sentiments, worldBrief);
} catch (error) {
console.error('[InsightsPanel] Error:', error);
this.setContent('<div class="insights-error">Analysis failed - retrying...</div>');
}
}
private renderInsights(
clusters: ClusteredEvent[],
sentiments: Array<{ label: string; score: number }> | null,
worldBrief: string | null
): void {
const briefHtml = worldBrief ? this.renderWorldBrief(worldBrief) : '';
const focalPointsHtml = this.renderFocalPoints();
const convergenceHtml = this.renderConvergenceZones();
const sentimentOverview = this.renderSentimentOverview(sentiments);
const breakingHtml = this.renderBreakingStories(clusters, sentiments);
const statsHtml = this.renderStats(clusters);
const missedHtml = this.renderMissedStories();
this.setContent(`
${briefHtml}
${focalPointsHtml}
${convergenceHtml}
${sentimentOverview}
${statsHtml}
<div class="insights-section">
<div class="insights-section-title">BREAKING & CONFIRMED</div>
${breakingHtml}
</div>
${missedHtml}
`);
}
private renderWorldBrief(brief: string): string {
const providerBadge = this.briefProvider && this.briefProvider !== 'cache'
? `<span class="insights-provider">${this.briefProvider}</span>`
: '';
return `
<div class="insights-brief">
<div class="insights-section-title">🌍 WORLD BRIEF ${providerBadge}</div>
<div class="insights-brief-text">${escapeHtml(brief)}</div>
</div>
`;
}
private renderBreakingStories(
clusters: ClusteredEvent[],
sentiments: Array<{ label: string; score: number }> | null
): string {
return clusters.map((cluster, i) => {
const sentiment = sentiments?.[i];
const sentimentClass = sentiment?.label === 'negative' ? 'negative' :
sentiment?.label === 'positive' ? 'positive' : 'neutral';
const badges: string[] = [];
if (cluster.sourceCount >= 3) {
badges.push(`<span class="insight-badge confirmed">✓ ${cluster.sourceCount} sources</span>`);
} else if (cluster.sourceCount >= 2) {
badges.push(`<span class="insight-badge multi">${cluster.sourceCount} sources</span>`);
}
if (cluster.velocity && cluster.velocity.level !== 'normal') {
const velIcon = cluster.velocity.trend === 'rising' ? '↑' : '';
badges.push(`<span class="insight-badge velocity ${cluster.velocity.level}">${velIcon}+${cluster.velocity.sourcesPerHour}/hr</span>`);
}
if (cluster.isAlert) {
badges.push('<span class="insight-badge alert">⚠ ALERT</span>');
}
return `
<div class="insight-story">
<div class="insight-story-header">
<span class="insight-sentiment-dot ${sentimentClass}"></span>
<span class="insight-story-title">${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''}</span>
</div>
${badges.length > 0 ? `<div class="insight-badges">${badges.join('')}</div>` : ''}
</div>
`;
}).join('');
}
private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string {
if (!sentiments || sentiments.length === 0) {
return '';
}
const negative = sentiments.filter(s => s.label === 'negative').length;
const positive = sentiments.filter(s => s.label === 'positive').length;
const neutral = sentiments.length - negative - positive;
const total = sentiments.length;
const negPct = Math.round((negative / total) * 100);
const neuPct = Math.round((neutral / total) * 100);
const posPct = 100 - negPct - neuPct;
let toneLabel = 'Mixed';
let toneClass = 'neutral';
if (negative > positive + neutral) {
toneLabel = 'Negative';
toneClass = 'negative';
} else if (positive > negative + neutral) {
toneLabel = 'Positive';
toneClass = 'positive';
}
return `
<div class="insights-sentiment-bar">
<div class="sentiment-bar-track">
<div class="sentiment-bar-negative" style="width: ${negPct}%"></div>
<div class="sentiment-bar-neutral" style="width: ${neuPct}%"></div>
<div class="sentiment-bar-positive" style="width: ${posPct}%"></div>
</div>
<div class="sentiment-bar-labels">
<span class="sentiment-label negative">${negative}</span>
<span class="sentiment-label neutral">${neutral}</span>
<span class="sentiment-label positive">${positive}</span>
</div>
<div class="sentiment-tone ${toneClass}">Overall: ${toneLabel}</div>
</div>
`;
}
private renderStats(clusters: ClusteredEvent[]): string {
const multiSource = clusters.filter(c => c.sourceCount >= 2).length;
const fastMoving = clusters.filter(c => c.velocity && c.velocity.level !== 'normal').length;
const alerts = clusters.filter(c => c.isAlert).length;
return `
<div class="insights-stats">
<div class="insight-stat">
<span class="insight-stat-value">${multiSource}</span>
<span class="insight-stat-label">Multi-source</span>
</div>
<div class="insight-stat">
<span class="insight-stat-value">${fastMoving}</span>
<span class="insight-stat-label">Fast-moving</span>
</div>
${alerts > 0 ? `
<div class="insight-stat alert">
<span class="insight-stat-value">${alerts}</span>
<span class="insight-stat-label">Alerts</span>
</div>
` : ''}
</div>
`;
}
private renderMissedStories(): string {
if (this.lastMissedStories.length === 0) {
return '';
}
const storiesHtml = this.lastMissedStories.slice(0, 3).map(story => {
const topPerspective = story.perspectives
.filter(p => p.name !== 'keywords')
.sort((a, b) => b.score - a.score)[0];
const perspectiveName = topPerspective?.name ?? 'ml';
const perspectiveScore = topPerspective?.score ?? 0;
return `
<div class="insight-story missed">
<div class="insight-story-header">
<span class="insight-sentiment-dot ml-flagged"></span>
<span class="insight-story-title">${escapeHtml(story.title.slice(0, 80))}${story.title.length > 80 ? '...' : ''}</span>
</div>
<div class="insight-badges">
<span class="insight-badge ml-detected">🔬 ${perspectiveName}: ${(perspectiveScore * 100).toFixed(0)}%</span>
</div>
</div>
`;
}).join('');
return `
<div class="insights-section insights-missed">
<div class="insights-section-title">🎯 ML DETECTED (check console for details)</div>
${storiesHtml}
</div>
`;
}
private renderConvergenceZones(): string {
if (this.lastConvergenceZones.length === 0) {
return '';
}
const zonesHtml = this.lastConvergenceZones.slice(0, 3).map(zone => {
const signalIcons: Record<string, string> = {
internet_outage: '🌐',
military_flight: '✈️',
military_vessel: '🚢',
protest: '🪧',
ais_disruption: '⚓',
};
const icons = zone.signalTypes.map(t => signalIcons[t] || '📍').join('');
return `
<div class="convergence-zone">
<div class="convergence-region">${icons} ${escapeHtml(zone.region)}</div>
<div class="convergence-description">${escapeHtml(zone.description)}</div>
<div class="convergence-stats">${zone.signalTypes.length} signal types • ${zone.totalSignals} events</div>
</div>
`;
}).join('');
return `
<div class="insights-section insights-convergence">
<div class="insights-section-title">📍 GEOGRAPHIC CONVERGENCE</div>
${zonesHtml}
</div>
`;
}
private renderFocalPoints(): string {
// Only show focal points that have both news AND signals (true correlations)
const correlatedFPs = this.lastFocalPoints.filter(
fp => fp.newsMentions > 0 && fp.signalCount > 0
).slice(0, 5);
if (correlatedFPs.length === 0) {
return '';
}
const signalIcons: Record<string, string> = {
internet_outage: '🌐',
military_flight: '✈️',
military_vessel: '⚓',
protest: '📢',
ais_disruption: '🚢',
};
const focalPointsHtml = correlatedFPs.map(fp => {
const urgencyClass = fp.urgency;
const icons = fp.signalTypes.map(t => signalIcons[t] || '').join(' ');
const headline = fp.topHeadlines[0]?.slice(0, 60) || '';
return `
<div class="focal-point ${urgencyClass}">
<div class="focal-point-header">
<span class="focal-point-name">${escapeHtml(fp.displayName)}</span>
<span class="focal-point-urgency ${urgencyClass}">${fp.urgency.toUpperCase()}</span>
</div>
<div class="focal-point-signals">${icons}</div>
<div class="focal-point-stats">
${fp.newsMentions} news • ${fp.signalCount} signals
</div>
${headline ? `<div class="focal-point-headline">"${escapeHtml(headline)}..."</div>` : ''}
</div>
`;
}).join('');
return `
<div class="insights-section insights-focal">
<div class="insights-section-title">🎯 FOCAL POINTS</div>
${focalPointsHtml}
</div>
`;
}
}

View File

@@ -22,7 +22,6 @@ import {
SANCTIONED_COUNTRIES,
STRATEGIC_WATERWAYS,
APT_GROUPS,
COUNTRY_LABELS,
ECONOMIC_CENTERS,
AI_DATA_CENTERS,
PORTS,
@@ -335,13 +334,13 @@ export class MapComponent {
'ais', 'flights', // transport
'natural', 'weather', // natural
'economic', // economic
'countries', 'waterways', // labels
'waterways', // labels
];
const techLayers: (keyof MapLayers)[] = [
'cables', 'datacenters', 'outages', // tech infrastructure
'startupHubs', 'cloudRegions', 'accelerators', 'techHQs', 'techEvents', // tech ecosystem
'natural', 'weather', // natural events
'economic', 'countries', // economic/geographic
'economic', // economic/geographic
];
const layers = SITE_VARIANT === 'tech' ? techLayers : fullLayers;
const layerLabels: Partial<Record<keyof MapLayers, string>> = {
@@ -1164,12 +1163,6 @@ export class MapComponent {
private renderOverlays(projection: d3.GeoProjection): void {
this.overlays.innerHTML = '';
// Country labels (rendered first so they appear behind other overlays)
if (this.state.layers.countries) {
this.renderCountryLabels(projection);
}
// Strategic waterways
if (this.state.layers.waterways) {
this.renderWaterways(projection);
@@ -2339,22 +2332,6 @@ export class MapComponent {
}
}
private renderCountryLabels(projection: d3.GeoProjection): void {
COUNTRY_LABELS.forEach((country) => {
const pos = projection([country.lon, country.lat]);
if (!pos) return;
const div = document.createElement('div');
div.className = 'country-label';
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
div.textContent = country.name;
div.dataset.countryId = String(country.id);
this.overlays.appendChild(div);
});
}
private renderWaterways(projection: d3.GeoProjection): void {
STRATEGIC_WATERWAYS.forEach((waterway) => {
const pos = projection([waterway.lon, waterway.lat]);

View File

@@ -0,0 +1,480 @@
/**
* MapContainer - Conditional map renderer
* Renders DeckGLMap (WebGL) on desktop, fallback to D3/SVG MapComponent on mobile
*/
import { isMobileDevice } from '@/utils';
import { MapComponent } from './Map';
import { DeckGLMap, type DeckMapView } from './DeckGLMap';
import type {
MapLayers,
Hotspot,
NewsItem,
Earthquake,
InternetOutage,
RelatedAsset,
AssetType,
AisDisruptionEvent,
AisDensityZone,
CableAdvisory,
RepairShip,
SocialUnrestEvent,
AirportDelayAlert,
MilitaryFlight,
MilitaryVessel,
MilitaryFlightCluster,
MilitaryVesselCluster,
NaturalEvent,
} from '@/types';
import type { WeatherAlert } from '@/services/weather';
export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all';
export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania';
export interface MapContainerState {
zoom: number;
pan: { x: number; y: number };
view: MapView;
layers: MapLayers;
timeRange: TimeRange;
}
interface TechEventMarker {
id: string;
title: string;
location: string;
lat: number;
lng: number;
country: string;
startDate: string;
endDate: string;
url: string | null;
daysUntil: number;
}
/**
* Unified map interface that delegates to either DeckGLMap or MapComponent
* based on device capabilities
*/
export class MapContainer {
private container: HTMLElement;
private isMobile: boolean;
private deckGLMap: DeckGLMap | null = null;
private svgMap: MapComponent | null = null;
private initialState: MapContainerState;
private useDeckGL: boolean;
constructor(container: HTMLElement, initialState: MapContainerState) {
this.container = container;
this.initialState = initialState;
this.isMobile = isMobileDevice();
// Use deck.gl on desktop with WebGL support, SVG on mobile
this.useDeckGL = !this.isMobile && this.hasWebGLSupport();
this.init();
}
private hasWebGLSupport(): boolean {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
return !!gl;
} catch {
return false;
}
}
private init(): void {
if (this.useDeckGL) {
console.log('[MapContainer] Initializing deck.gl map (desktop mode)');
this.container.classList.add('deckgl-mode');
this.deckGLMap = new DeckGLMap(this.container, {
...this.initialState,
view: this.initialState.view as DeckMapView,
});
} else {
console.log('[MapContainer] Initializing SVG map (mobile/fallback mode)');
this.container.classList.add('svg-mode');
this.svgMap = new MapComponent(this.container, this.initialState);
}
}
// Unified public API - delegates to active map implementation
public render(): void {
if (this.useDeckGL) {
this.deckGLMap?.render();
} else {
this.svgMap?.render();
}
}
public setView(view: MapView): void {
if (this.useDeckGL) {
this.deckGLMap?.setView(view as DeckMapView);
} else {
this.svgMap?.setView(view);
}
}
public setZoom(zoom: number): void {
if (this.useDeckGL) {
this.deckGLMap?.setZoom(zoom);
} else {
this.svgMap?.setZoom(zoom);
}
}
public setCenter(lat: number, lon: number): void {
if (this.useDeckGL) {
this.deckGLMap?.setCenter(lat, lon);
} else {
this.svgMap?.setCenter(lat, lon);
}
}
public getCenter(): { lat: number; lon: number } | null {
if (this.useDeckGL) {
return this.deckGLMap?.getCenter() ?? null;
}
return this.svgMap?.getCenter() ?? null;
}
public setTimeRange(range: TimeRange): void {
if (this.useDeckGL) {
this.deckGLMap?.setTimeRange(range);
} else {
this.svgMap?.setTimeRange(range);
}
}
public getTimeRange(): TimeRange {
if (this.useDeckGL) {
return this.deckGLMap?.getTimeRange() ?? '7d';
}
return this.svgMap?.getTimeRange() ?? '7d';
}
public setLayers(layers: MapLayers): void {
if (this.useDeckGL) {
this.deckGLMap?.setLayers(layers);
} else {
this.svgMap?.setLayers(layers);
}
}
public getState(): MapContainerState {
if (this.useDeckGL) {
const state = this.deckGLMap?.getState();
return state ? { ...state, view: state.view as MapView } : this.initialState;
}
return this.svgMap?.getState() ?? this.initialState;
}
// Data setters
public setEarthquakes(earthquakes: Earthquake[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setEarthquakes(earthquakes);
} else {
this.svgMap?.setEarthquakes(earthquakes);
}
}
public setWeatherAlerts(alerts: WeatherAlert[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setWeatherAlerts(alerts);
} else {
this.svgMap?.setWeatherAlerts(alerts);
}
}
public setOutages(outages: InternetOutage[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setOutages(outages);
} else {
this.svgMap?.setOutages(outages);
}
}
public setAisData(disruptions: AisDisruptionEvent[], density: AisDensityZone[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setAisData(disruptions, density);
} else {
this.svgMap?.setAisData(disruptions, density);
}
}
public setCableActivity(advisories: CableAdvisory[], repairShips: RepairShip[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setCableActivity(advisories, repairShips);
} else {
this.svgMap?.setCableActivity(advisories, repairShips);
}
}
public setProtests(events: SocialUnrestEvent[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setProtests(events);
} else {
this.svgMap?.setProtests(events);
}
}
public setFlightDelays(delays: AirportDelayAlert[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setFlightDelays(delays);
} else {
this.svgMap?.setFlightDelays(delays);
}
}
public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void {
if (this.useDeckGL) {
this.deckGLMap?.setMilitaryFlights(flights, clusters);
} else {
this.svgMap?.setMilitaryFlights(flights, clusters);
}
}
public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void {
if (this.useDeckGL) {
this.deckGLMap?.setMilitaryVessels(vessels, clusters);
} else {
this.svgMap?.setMilitaryVessels(vessels, clusters);
}
}
public setNaturalEvents(events: NaturalEvent[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setNaturalEvents(events);
} else {
this.svgMap?.setNaturalEvents(events);
}
}
public setTechEvents(events: TechEventMarker[]): void {
if (this.useDeckGL) {
this.deckGLMap?.setTechEvents(events);
} else {
this.svgMap?.setTechEvents(events);
}
}
public updateHotspotActivity(news: NewsItem[]): void {
if (this.useDeckGL) {
this.deckGLMap?.updateHotspotActivity(news);
} else {
this.svgMap?.updateHotspotActivity(news);
}
}
public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void {
if (this.useDeckGL) {
this.deckGLMap?.updateMilitaryForEscalation(flights, vessels);
} else {
this.svgMap?.updateMilitaryForEscalation(flights, vessels);
}
}
public getHotspotDynamicScore(hotspotId: string) {
if (this.useDeckGL) {
return this.deckGLMap?.getHotspotDynamicScore(hotspotId);
}
return this.svgMap?.getHotspotDynamicScore(hotspotId);
}
public highlightAssets(assets: RelatedAsset[] | null): void {
if (this.useDeckGL) {
this.deckGLMap?.highlightAssets(assets);
} else {
this.svgMap?.highlightAssets(assets);
}
}
// Callback setters - MapComponent uses different names
public onHotspotClicked(callback: (hotspot: Hotspot) => void): void {
if (this.useDeckGL) {
this.deckGLMap?.setOnHotspotClick(callback);
} else {
this.svgMap?.onHotspotClicked(callback);
}
}
public onTimeRangeChanged(callback: (range: TimeRange) => void): void {
if (this.useDeckGL) {
this.deckGLMap?.setOnTimeRangeChange(callback);
} else {
this.svgMap?.onTimeRangeChanged(callback);
}
}
public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean) => void): void {
if (this.useDeckGL) {
this.deckGLMap?.setOnLayerChange(callback);
} else {
this.svgMap?.setOnLayerChange(callback);
}
}
public onStateChanged(callback: (state: MapContainerState) => void): void {
if (this.useDeckGL) {
this.deckGLMap?.setOnStateChange((state) => {
callback({ ...state, view: state.view as MapView });
});
} else {
this.svgMap?.onStateChanged(callback);
}
}
public getHotspotLevels(): Record<string, string> {
if (this.useDeckGL) {
return this.deckGLMap?.getHotspotLevels() ?? {};
}
return this.svgMap?.getHotspotLevels() ?? {};
}
public setHotspotLevels(levels: Record<string, string>): void {
if (this.useDeckGL) {
this.deckGLMap?.setHotspotLevels(levels);
} else {
this.svgMap?.setHotspotLevels(levels);
}
}
public initEscalationGetters(): void {
if (this.useDeckGL) {
this.deckGLMap?.initEscalationGetters();
} else {
this.svgMap?.initEscalationGetters();
}
}
// UI visibility methods
public hideLayerToggle(layer: keyof MapLayers): void {
if (this.useDeckGL) {
this.deckGLMap?.hideLayerToggle(layer);
} else {
this.svgMap?.hideLayerToggle(layer);
}
}
public setLayerLoading(layer: keyof MapLayers, loading: boolean): void {
if (this.useDeckGL) {
this.deckGLMap?.setLayerLoading(layer, loading);
} else {
this.svgMap?.setLayerLoading(layer, loading);
}
}
public setLayerReady(layer: keyof MapLayers, hasData: boolean): void {
if (this.useDeckGL) {
this.deckGLMap?.setLayerReady(layer, hasData);
} else {
this.svgMap?.setLayerReady(layer, hasData);
}
}
public flashAssets(assetType: AssetType, ids: string[]): void {
if (this.useDeckGL) {
this.deckGLMap?.flashAssets(assetType, ids);
}
// SVG map doesn't have flashAssets - only supported in deck.gl mode
}
// Layer enable/disable and trigger methods
public enableLayer(layer: keyof MapLayers): void {
if (this.useDeckGL) {
this.deckGLMap?.enableLayer(layer);
} else {
this.svgMap?.enableLayer(layer);
}
}
public triggerHotspotClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerHotspotClick(id);
} else {
this.svgMap?.triggerHotspotClick(id);
}
}
public triggerConflictClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerConflictClick(id);
} else {
this.svgMap?.triggerConflictClick(id);
}
}
public triggerBaseClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerBaseClick(id);
} else {
this.svgMap?.triggerBaseClick(id);
}
}
public triggerPipelineClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerPipelineClick(id);
} else {
this.svgMap?.triggerPipelineClick(id);
}
}
public triggerCableClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerCableClick(id);
} else {
this.svgMap?.triggerCableClick(id);
}
}
public triggerDatacenterClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerDatacenterClick(id);
} else {
this.svgMap?.triggerDatacenterClick(id);
}
}
public triggerNuclearClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerNuclearClick(id);
} else {
this.svgMap?.triggerNuclearClick(id);
}
}
public triggerIrradiatorClick(id: string): void {
if (this.useDeckGL) {
this.deckGLMap?.triggerIrradiatorClick(id);
} else {
this.svgMap?.triggerIrradiatorClick(id);
}
}
public flashLocation(lat: number, lon: number, durationMs?: number): void {
if (this.useDeckGL) {
this.deckGLMap?.flashLocation(lat, lon, durationMs);
} else {
this.svgMap?.flashLocation(lat, lon, durationMs);
}
}
// Utility methods
public isDeckGLMode(): boolean {
return this.useDeckGL;
}
public isMobileMode(): boolean {
return this.isMobile;
}
public destroy(): void {
if (this.useDeckGL) {
this.deckGLMap?.destroy();
} else {
this.svgMap?.destroy();
}
}
}

View File

@@ -1,7 +1,5 @@
import type { ConflictZone, Hotspot, Earthquake, NewsItem, MilitaryBase, StrategicWaterway, APTGroup, NuclearFacility, EconomicCenter, GammaIrradiator, Pipeline, UnderseaCable, CableAdvisory, RepairShip, InternetOutage, AIDataCenter, AisDisruptionEvent, SocialUnrestEvent, AirportDelayAlert, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, NaturalEvent, Port, Spaceport, CriticalMineralProject } from '@/types';
import type { WeatherAlert } from '@/services/weather';
import type { TechHubActivity } from '@/services/tech-activity';
import type { GeoHubActivity } from '@/services/geo-activity';
import { UNDERSEA_CABLES } from '@/config';
import type { StartupHub, Accelerator, TechHQ, CloudRegion } from '@/config/tech-geo';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
@@ -10,10 +8,7 @@ import { fetchHotspotContext, formatArticleDate, extractDomain, type GdeltArticl
import { getNaturalEventIcon } from '@/services/eonet';
import { getHotspotEscalation, getEscalationChange24h } from '@/services/hotspot-escalation';
export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity';
type TechHubActivityPopup = TechHubActivity;
type GeoHubActivityPopup = GeoHubActivity;
export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster';
interface TechEventPopupData {
id: string;
@@ -45,9 +40,15 @@ interface ProtestClusterData {
country: string;
}
interface DatacenterClusterData {
items: AIDataCenter[];
region: string;
country: string;
}
interface PopupData {
type: PopupType;
data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | TechHubActivityPopup | GeoHubActivityPopup;
data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData;
relatedNews?: NewsItem[];
x: number;
y: number;
@@ -85,10 +86,18 @@ export class MapPopup {
// Desktop: position near click with smart bounds checking
this.popup.style.transform = '';
const popupWidth = 380;
const popupHeight = 500; // Approximate max height for hotspot popups
const bottomBuffer = 50; // Buffer from viewport bottom
const topBuffer = 60; // Header height
// Temporarily append popup off-screen to measure actual height
this.popup.style.visibility = 'hidden';
this.popup.style.top = '0';
this.popup.style.left = '-9999px';
document.body.appendChild(this.popup);
const popupHeight = this.popup.offsetHeight;
document.body.removeChild(this.popup);
this.popup.style.visibility = '';
// Convert container-relative coords to viewport coords
const viewportX = containerRect.left + data.x;
const viewportY = containerRect.top + data.y;
@@ -113,10 +122,13 @@ export class MapPopup {
// Not enough below, but enough above - position above click
top = viewportY - popupHeight - 10;
} else {
// Limited space both ways - position at top with max height
// Limited space both ways - position at top buffer
top = topBuffer;
}
// CRITICAL: Ensure popup never goes above the top buffer (header area)
top = Math.max(topBuffer, top);
this.popup.style.left = `${left}px`;
this.popup.style.top = `${top}px`;
}
@@ -191,6 +203,8 @@ export class MapPopup {
return this.renderOutagePopup(data.data as InternetOutage);
case 'datacenter':
return this.renderDatacenterPopup(data.data as AIDataCenter);
case 'datacenterCluster':
return this.renderDatacenterClusterPopup(data.data as DatacenterClusterData);
case 'ais':
return this.renderAisPopup(data.data as AisDisruptionEvent);
case 'protest':
@@ -229,10 +243,6 @@ export class MapPopup {
return this.renderTechHQClusterPopup(data.data as { items: TechHQ[]; city: string; country: string });
case 'techEventCluster':
return this.renderTechEventClusterPopup(data.data as { items: TechEventPopupData[]; location: string; country: string });
case 'techActivity':
return this.renderTechActivityPopup(data.data as TechHubActivityPopup);
case 'geoActivity':
return this.renderGeoActivityPopup(data.data as GeoHubActivityPopup);
default:
return '';
}
@@ -1290,6 +1300,65 @@ export class MapPopup {
`;
}
private renderDatacenterClusterPopup(data: DatacenterClusterData): string {
const totalChips = data.items.reduce((sum, dc) => sum + dc.chipCount, 0);
const totalPower = data.items.reduce((sum, dc) => sum + (dc.powerMW || 0), 0);
const existingCount = data.items.filter(dc => dc.status === 'existing').length;
const plannedCount = data.items.filter(dc => dc.status === 'planned').length;
const formatNumber = (n: number) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(0)}K`;
return n.toString();
};
const dcListHtml = data.items.slice(0, 8).map(dc => `
<div class="cluster-item">
<span class="cluster-item-icon">${dc.status === 'planned' ? '🔨' : '🖥️'}</span>
<div class="cluster-item-info">
<span class="cluster-item-name">${escapeHtml(dc.name.slice(0, 40))}${dc.name.length > 40 ? '...' : ''}</span>
<span class="cluster-item-detail">${escapeHtml(dc.owner)}${formatNumber(dc.chipCount)} chips</span>
</div>
</div>
`).join('');
return `
<div class="popup-header datacenter cluster">
<span class="popup-title">🖥️ ${data.items.length} Data Centers</span>
<span class="popup-badge elevated">${escapeHtml(data.region)}</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body">
<div class="popup-subtitle">${escapeHtml(data.country)}</div>
<div class="popup-stats">
<div class="popup-stat">
<span class="stat-label">TOTAL CHIPS</span>
<span class="stat-value">${formatNumber(totalChips)}</span>
</div>
${totalPower > 0 ? `
<div class="popup-stat">
<span class="stat-label">TOTAL POWER</span>
<span class="stat-value">${totalPower.toFixed(0)} MW</span>
</div>
` : ''}
<div class="popup-stat">
<span class="stat-label">OPERATIONAL</span>
<span class="stat-value">${existingCount}</span>
</div>
<div class="popup-stat">
<span class="stat-label">PLANNED</span>
<span class="stat-value">${plannedCount}</span>
</div>
</div>
<div class="cluster-list">
${dcListHtml}
</div>
${data.items.length > 8 ? `<p class="popup-more">+ ${data.items.length - 8} more data centers</p>` : ''}
<div class="popup-attribution">Data: Epoch AI GPU Clusters</div>
</div>
`;
}
private renderStartupHubPopup(hub: StartupHub): string {
const tierLabels: Record<string, string> = { 'mega': 'MEGA HUB', 'major': 'MAJOR HUB', 'emerging': 'EMERGING' };
const tierIcons: Record<string, string> = { 'mega': '🦄', 'major': '🚀', 'emerging': '💡' };
@@ -1988,146 +2057,4 @@ export class MapPopup {
</div>
`;
}
private renderTechActivityPopup(activity: TechHubActivityPopup): string {
const levelColors: Record<string, string> = {
high: 'elevated',
elevated: 'low',
low: '',
};
const trendIcons: Record<string, string> = {
rising: '↑',
stable: '→',
falling: '↓',
};
const storiesHtml = activity.topStories.length > 0
? `<div class="popup-section">
<span class="section-label">TOP STORIES</span>
<div class="popup-news-list">
${activity.topStories.map(story => `
<a class="popup-news-item" href="${sanitizeUrl(story.link)}" target="_blank" rel="noopener">
${escapeHtml(story.title.length > 80 ? story.title.slice(0, 77) + '...' : story.title)}
</a>
`).join('')}
</div>
</div>`
: '';
const keywordsHtml = activity.matchedKeywords.length > 0
? `<div class="popup-section">
<span class="section-label">MATCHED KEYWORDS</span>
<div class="popup-tags">
${activity.matchedKeywords.slice(0, 5).map(kw => `<span class="popup-tag">${escapeHtml(kw)}</span>`).join('')}
</div>
</div>`
: '';
return `
<div class="popup-header tech-activity ${activity.activityLevel}">
<span class="popup-title">${escapeHtml(activity.city.toUpperCase())}</span>
<span class="popup-badge ${levelColors[activity.activityLevel]}">${activity.activityLevel.toUpperCase()}</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body">
<div class="popup-subtitle">${escapeHtml(activity.country)}${escapeHtml(activity.tier)} hub</div>
<div class="popup-stats">
<div class="popup-stat">
<span class="stat-label">NEWS COUNT</span>
<span class="stat-value">${activity.newsCount}</span>
</div>
<div class="popup-stat">
<span class="stat-label">ACTIVITY SCORE</span>
<span class="stat-value">${Math.round(activity.score)}</span>
</div>
<div class="popup-stat">
<span class="stat-label">TREND</span>
<span class="stat-value">${trendIcons[activity.trend]} ${activity.trend}</span>
</div>
${activity.hasBreaking ? `
<div class="popup-stat">
<span class="stat-label">STATUS</span>
<span class="stat-value" style="color: var(--red);">BREAKING</span>
</div>
` : ''}
</div>
${storiesHtml}
${keywordsHtml}
</div>
`;
}
private renderGeoActivityPopup(activity: GeoHubActivityPopup): string {
const levelColors: Record<string, string> = {
high: 'high',
elevated: 'elevated',
low: '',
};
const trendIcons: Record<string, string> = {
rising: '↑',
stable: '→',
falling: '↓',
};
const typeLabels: Record<string, string> = {
capital: 'Capital',
conflict: 'Conflict Zone',
strategic: 'Strategic Region',
organization: 'Organization',
};
const storiesHtml = activity.topStories.length > 0
? `<div class="popup-section">
<span class="section-label">TOP STORIES</span>
<div class="popup-news-list">
${activity.topStories.map(story => `
<a class="popup-news-item" href="${sanitizeUrl(story.link)}" target="_blank" rel="noopener">
${escapeHtml(story.title.length > 80 ? story.title.slice(0, 77) + '...' : story.title)}
</a>
`).join('')}
</div>
</div>`
: '';
const keywordsHtml = activity.matchedKeywords.length > 0
? `<div class="popup-section">
<span class="section-label">MATCHED KEYWORDS</span>
<div class="popup-tags">
${activity.matchedKeywords.slice(0, 5).map(kw => `<span class="popup-tag">${escapeHtml(kw)}</span>`).join('')}
</div>
</div>`
: '';
return `
<div class="popup-header geo-activity ${activity.activityLevel}">
<span class="popup-title">${escapeHtml(activity.name.toUpperCase())}</span>
<span class="popup-badge ${levelColors[activity.activityLevel]}">${activity.activityLevel.toUpperCase()}</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body">
<div class="popup-subtitle">${escapeHtml(activity.country)}${typeLabels[activity.type] || activity.type}</div>
<div class="popup-stats">
<div class="popup-stat">
<span class="stat-label">NEWS COUNT</span>
<span class="stat-value">${activity.newsCount}</span>
</div>
<div class="popup-stat">
<span class="stat-label">ACTIVITY SCORE</span>
<span class="stat-value">${Math.round(activity.score)}</span>
</div>
<div class="popup-stat">
<span class="stat-label">TREND</span>
<span class="stat-value">${trendIcons[activity.trend]} ${activity.trend}</span>
</div>
${activity.hasBreaking ? `
<div class="popup-stat">
<span class="stat-label">STATUS</span>
<span class="stat-value" style="color: var(--red);">ALERT</span>
</div>
` : ''}
</div>
${storiesHtml}
${keywordsHtml}
</div>
`;
}
}

View File

@@ -3,12 +3,15 @@ import { WindowedList } from './VirtualList';
import type { NewsItem, ClusteredEvent, DeviationLevel, RelatedAsset, RelatedAssetContext } from '@/types';
import { formatTime } from '@/utils';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
import { analysisWorker, enrichWithVelocity, getClusterAssetContext, getAssetLabel, MAX_DISTANCE_KM, activityTracker } from '@/services';
import { analysisWorker, enrichWithVelocityML, getClusterAssetContext, getAssetLabel, MAX_DISTANCE_KM, activityTracker, generateSummary } from '@/services';
import { getSourcePropagandaRisk, getSourceTier, getSourceType } from '@/config/feeds';
/** Threshold for enabling virtual scrolling */
const VIRTUAL_SCROLL_THRESHOLD = 15;
/** Summary cache TTL in milliseconds (10 minutes) */
const SUMMARY_CACHE_TTL = 10 * 60 * 1000;
/** Prepared cluster data for rendering */
interface PreparedCluster {
cluster: ClusteredEvent;
@@ -31,9 +34,16 @@ export class NewsPanel extends Panel {
private boundScrollHandler: (() => void) | null = null;
private boundClickHandler: (() => void) | null = null;
// Panel summary feature
private summaryBtn: HTMLButtonElement | null = null;
private summaryContainer: HTMLElement | null = null;
private currentHeadlines: string[] = [];
private isSummarizing = false;
constructor(id: string, title: string) {
super({ id, title, showCount: true, trackActivity: true });
this.createDeviationIndicator();
this.createSummarizeButton();
this.setupActivityTracking();
this.initWindowedList();
}
@@ -97,6 +107,108 @@ export class NewsPanel extends Panel {
}
}
private createSummarizeButton(): void {
// Create summary container (inserted between header and content)
this.summaryContainer = document.createElement('div');
this.summaryContainer.className = 'panel-summary';
this.summaryContainer.style.display = 'none';
this.element.insertBefore(this.summaryContainer, this.content);
// Create summarize button
this.summaryBtn = document.createElement('button');
this.summaryBtn.className = 'panel-summarize-btn';
this.summaryBtn.innerHTML = '✨';
this.summaryBtn.title = 'Summarize this panel';
this.summaryBtn.addEventListener('click', () => this.handleSummarize());
// Insert before count element (use inherited this.header directly)
const countEl = this.header.querySelector('.panel-count');
if (countEl) {
this.header.insertBefore(this.summaryBtn, countEl);
} else {
this.header.appendChild(this.summaryBtn);
}
}
private async handleSummarize(): Promise<void> {
if (this.isSummarizing || !this.summaryContainer || !this.summaryBtn) return;
if (this.currentHeadlines.length === 0) return;
// Check cache first
const cacheKey = `panel_summary_${this.panelId}`;
const cached = this.getCachedSummary(cacheKey);
if (cached) {
this.showSummary(cached);
return;
}
// Show loading state
this.isSummarizing = true;
this.summaryBtn.innerHTML = '<span class="panel-summarize-spinner"></span>';
this.summaryBtn.disabled = true;
this.summaryContainer.style.display = 'block';
this.summaryContainer.innerHTML = '<div class="panel-summary-loading">Generating summary...</div>';
try {
const result = await generateSummary(this.currentHeadlines.slice(0, 8));
if (result?.summary) {
this.setCachedSummary(cacheKey, result.summary);
this.showSummary(result.summary);
} else {
this.summaryContainer.innerHTML = '<div class="panel-summary-error">Could not generate summary</div>';
setTimeout(() => this.hideSummary(), 3000);
}
} catch {
this.summaryContainer.innerHTML = '<div class="panel-summary-error">Summary failed</div>';
setTimeout(() => this.hideSummary(), 3000);
} finally {
this.isSummarizing = false;
this.summaryBtn.innerHTML = '✨';
this.summaryBtn.disabled = false;
}
}
private showSummary(summary: string): void {
if (!this.summaryContainer) return;
this.summaryContainer.style.display = 'block';
this.summaryContainer.innerHTML = `
<div class="panel-summary-content">
<span class="panel-summary-text">${escapeHtml(summary)}</span>
<button class="panel-summary-close" title="Close">×</button>
</div>
`;
this.summaryContainer.querySelector('.panel-summary-close')?.addEventListener('click', () => this.hideSummary());
}
private hideSummary(): void {
if (!this.summaryContainer) return;
this.summaryContainer.style.display = 'none';
this.summaryContainer.innerHTML = '';
}
private getCachedSummary(key: string): string | null {
try {
const cached = localStorage.getItem(key);
if (!cached) return null;
const { summary, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > SUMMARY_CACHE_TTL) {
localStorage.removeItem(key);
return null;
}
return summary;
} catch {
return null;
}
}
private setCachedSummary(key: string, summary: string): void {
try {
localStorage.setItem(key, JSON.stringify({ summary, timestamp: Date.now() }));
} catch {
// Storage full, ignore
}
}
public setDeviation(zScore: number, percentChange: number, level: DeviationLevel): void {
if (!this.deviationEl) return;
@@ -132,7 +244,7 @@ export class NewsPanel extends Panel {
try {
const clusters = await analysisWorker.clusterNews(items);
if (requestId !== this.renderRequestId) return;
const enriched = enrichWithVelocity(clusters);
const enriched = await enrichWithVelocityML(clusters);
this.renderClusters(enriched);
} catch (error) {
if (requestId !== this.renderRequestId) return;
@@ -167,6 +279,9 @@ export class NewsPanel extends Panel {
this.setCount(totalItems);
this.relatedAssetContext.clear();
// Store headlines for summarization
this.currentHeadlines = clusters.slice(0, 10).map(c => c.primaryTitle);
// Track items with activity tracker (skip first render to avoid marking everything as "new")
const clusterIds = clusters.map(c => c.id);
let newItemIds: Set<string>;
@@ -241,12 +356,12 @@ export class NewsPanel extends Panel {
? `<span class="propaganda-badge ${primaryPropRisk.risk}" title="${escapeHtml(primaryPropRisk.note || `State-affiliated: ${primaryPropRisk.stateAffiliated || 'Unknown'}`)}">${primaryPropRisk.risk === 'high' ? '⚠ State Media' : '! Caution'}</span>`
: '';
// Source credibility badge for primary source
// Source credibility badge for primary source (T1=Wire, T2=Verified outlet)
const primaryTier = getSourceTier(cluster.primarySource);
const primaryType = getSourceType(cluster.primarySource);
const tierLabel = primaryTier === 1 ? 'Wire' : primaryTier === 2 ? 'Major' : '';
const tierLabel = primaryTier === 1 ? 'Wire' : ''; // Don't show "Major" - confusing with story importance
const tierBadge = primaryTier <= 2
? `<span class="tier-badge tier-${primaryTier}" title="${primaryType === 'wire' ? 'Wire Service - Highest reliability' : primaryType === 'gov' ? 'Official Government Source' : 'Major News Outlet'}">${primaryTier === 1 ? '★' : '●'} ${tierLabel}</span>`
? `<span class="tier-badge tier-${primaryTier}" title="${primaryType === 'wire' ? 'Wire Service - Highest reliability' : primaryType === 'gov' ? 'Official Government Source' : 'Verified News Outlet'}">${primaryTier === 1 ? '★' : '●'}${tierLabel ? ` ${tierLabel}` : ''}</span>`
: '';
// Build "Also reported by" section for multi-source confirmation

View File

@@ -7,6 +7,41 @@ export interface PanelOptions {
infoTooltip?: string;
}
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
function loadPanelSpans(): Record<string, number> {
try {
const stored = localStorage.getItem(PANEL_SPANS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function savePanelSpan(panelId: string, span: number): void {
const spans = loadPanelSpans();
spans[panelId] = span;
localStorage.setItem(PANEL_SPANS_KEY, JSON.stringify(spans));
}
function heightToSpan(height: number): number {
// Much lower thresholds for responsive resizing
// Start at 200px, so:
// - 50px drag → span 2 (250px)
// - 150px drag → span 3 (350px)
// - 300px drag → span 4 (500px)
if (height >= 500) return 4;
if (height >= 350) return 3;
if (height >= 250) return 2;
return 1;
}
function setSpanClass(element: HTMLElement, span: number): void {
element.classList.remove('span-1', 'span-2', 'span-3', 'span-4');
element.classList.add(`span-${span}`);
element.classList.add('resized');
}
export class Panel {
protected element: HTMLElement;
protected content: HTMLElement;
@@ -15,6 +50,13 @@ export class Panel {
protected newBadgeEl: HTMLElement | null = null;
protected panelId: string;
private tooltipCloseHandler: (() => void) | null = null;
private resizeHandle: HTMLElement | null = null;
private isResizing = false;
private startY = 0;
private startHeight = 0;
private onTouchMove: ((e: TouchEvent) => void) | null = null;
private onTouchEnd: (() => void) | null = null;
private onDocMouseUp: (() => void) | null = null;
constructor(options: PanelOptions) {
this.panelId = options.id;
@@ -82,9 +124,135 @@ export class Panel {
this.element.appendChild(this.header);
this.element.appendChild(this.content);
// Add resize handle
this.resizeHandle = document.createElement('div');
this.resizeHandle.className = 'panel-resize-handle';
this.resizeHandle.title = 'Drag to resize (double-click to reset)';
this.resizeHandle.draggable = false; // Prevent parent's drag from capturing
this.element.appendChild(this.resizeHandle);
this.setupResizeHandlers();
// Restore saved span
const savedSpans = loadPanelSpans();
const savedSpan = savedSpans[this.panelId];
if (savedSpan && savedSpan > 1) {
setSpanClass(this.element, savedSpan);
}
this.showLoading();
}
private setupResizeHandlers(): void {
if (!this.resizeHandle) return;
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.isResizing = true;
this.startY = e.clientY;
this.startHeight = this.element.getBoundingClientRect().height;
this.element.classList.add('resizing');
this.element.draggable = false;
this.resizeHandle?.classList.add('active');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
if (!this.isResizing) return;
const deltaY = e.clientY - this.startY;
const newHeight = Math.max(200, this.startHeight + deltaY);
const span = heightToSpan(newHeight);
setSpanClass(this.element, span);
};
const onMouseUp = () => {
if (!this.isResizing) return;
this.isResizing = false;
this.element.classList.remove('resizing');
this.element.draggable = true;
this.resizeHandle?.classList.remove('active');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const currentSpan = this.element.classList.contains('span-4') ? 4 :
this.element.classList.contains('span-3') ? 3 :
this.element.classList.contains('span-2') ? 2 : 1;
savePanelSpan(this.panelId, currentSpan);
};
this.resizeHandle.addEventListener('mousedown', onMouseDown);
// Prevent panel drag when resizing (capture phase runs before App.ts listener)
this.element.addEventListener('dragstart', (e) => {
const target = e.target as HTMLElement;
if (this.isResizing || target === this.resizeHandle || target.closest('.panel-resize-handle')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
}, true);
// Mark element as resizing for external listeners
this.resizeHandle.addEventListener('mousedown', () => {
this.element.dataset.resizing = 'true';
});
// Double-click to reset
this.resizeHandle.addEventListener('dblclick', () => {
this.resetHeight();
});
// Touch support
this.resizeHandle.addEventListener('touchstart', (e: TouchEvent) => {
e.preventDefault();
e.stopPropagation();
const touch = e.touches[0];
if (!touch) return;
this.isResizing = true;
this.startY = touch.clientY;
this.startHeight = this.element.getBoundingClientRect().height;
this.element.classList.add('resizing');
this.element.draggable = false;
this.element.dataset.resizing = 'true';
this.resizeHandle?.classList.add('active');
}, { passive: false });
// Use bound handlers so they can be removed in destroy()
this.onTouchMove = (e: TouchEvent) => {
if (!this.isResizing) return;
const touch = e.touches[0];
if (!touch) return;
const deltaY = touch.clientY - this.startY;
const newHeight = Math.max(200, this.startHeight + deltaY);
const span = heightToSpan(newHeight);
setSpanClass(this.element, span);
};
this.onTouchEnd = () => {
if (!this.isResizing) return;
this.isResizing = false;
this.element.classList.remove('resizing');
this.element.draggable = true;
delete this.element.dataset.resizing;
this.resizeHandle?.classList.remove('active');
const currentSpan = this.element.classList.contains('span-4') ? 4 :
this.element.classList.contains('span-3') ? 3 :
this.element.classList.contains('span-2') ? 2 : 1;
savePanelSpan(this.panelId, currentSpan);
};
this.onDocMouseUp = () => {
if (this.element.dataset.resizing) {
delete this.element.dataset.resizing;
}
};
document.addEventListener('touchmove', this.onTouchMove, { passive: false });
document.addEventListener('touchend', this.onTouchEnd);
document.addEventListener('mouseup', this.onDocMouseUp);
}
public getElement(): HTMLElement {
return this.element;
}
@@ -169,6 +337,16 @@ export class Panel {
return this.panelId;
}
/**
* Reset panel height to default
*/
public resetHeight(): void {
this.element.classList.remove('resized', 'span-1', 'span-2', 'span-3', 'span-4');
const spans = loadPanelSpans();
delete spans[this.panelId];
localStorage.setItem(PANEL_SPANS_KEY, JSON.stringify(spans));
}
/**
* Clean up event listeners and resources
*/
@@ -177,5 +355,17 @@ export class Panel {
document.removeEventListener('click', this.tooltipCloseHandler);
this.tooltipCloseHandler = null;
}
if (this.onTouchMove) {
document.removeEventListener('touchmove', this.onTouchMove);
this.onTouchMove = null;
}
if (this.onTouchEnd) {
document.removeEventListener('touchend', this.onTouchEnd);
this.onTouchEnd = null;
}
if (this.onDocMouseUp) {
document.removeEventListener('mouseup', this.onDocMouseUp);
this.onDocMouseUp = null;
}
}
}

View File

@@ -17,7 +17,7 @@ interface SearchableSource {
const RECENT_SEARCHES_KEY = 'worldmonitor_recent_searches';
const MAX_RECENT = 8;
const MAX_RESULTS = 12;
const MAX_RESULTS = 24;
interface SearchModalOptions {
placeholder?: string;
@@ -119,7 +119,8 @@ export class SearchModal {
return;
}
this.results = [];
// Collect matches grouped by type
const byType = new Map<SearchResultType, (SearchResult & { _score: number })[]>();
for (const source of this.sources) {
for (const item of source.items) {
@@ -128,20 +129,38 @@ export class SearchModal {
if (titleLower.includes(query) || subtitleLower.includes(query)) {
const isPrefix = titleLower.startsWith(query) || subtitleLower.startsWith(query);
this.results.push({
const result = {
type: source.type,
id: item.id,
title: item.title,
subtitle: item.subtitle,
data: item.data,
_score: isPrefix ? 2 : 1,
} as SearchResult & { _score: number });
} as SearchResult & { _score: number };
if (!byType.has(source.type)) byType.set(source.type, []);
byType.get(source.type)!.push(result);
}
}
}
// Sort by score (prefix matches first), then limit
this.results.sort((a, b) => ((b as any)._score || 0) - ((a as any)._score || 0));
// Prioritize: news first, then other dynamic data, then static infrastructure
const priority: SearchResultType[] = [
'news', 'prediction', 'market', 'earthquake', 'outage', // Dynamic/timely
'conflict', 'hotspot', // Current events
'base', 'pipeline', 'cable', 'datacenter', 'nuclear', 'irradiator', // Infrastructure
'techcompany', 'ailab', 'startup', 'techevent', 'techhq', 'accelerator' // Tech
];
// Take top matches from each type, news gets more slots
this.results = [];
for (const type of priority) {
const matches = byType.get(type) || [];
matches.sort((a, b) => b._score - a._score);
const limit = type === 'news' ? 6 : 3; // News gets 6 slots, others get 3
this.results.push(...matches.slice(0, limit));
if (this.results.length >= MAX_RESULTS) break;
}
this.results = this.results.slice(0, MAX_RESULTS);
this.selectedIndex = 0;

View File

@@ -226,10 +226,17 @@ export class SignalModal {
'geo_convergence': '🌐 Geographic Convergence',
'explained_market_move': '✓ Market Move Explained',
'sector_cascade': '📊 Sector Cascade',
'military_surge': '🛩️ Military Surge',
};
const html = this.currentSignals.map(signal => {
const context = getSignalContext(signal.type as SignalType);
// Military surge signals have additional properties in data
const data = signal.data as Record<string, unknown>;
const newsCorrelation = data?.newsCorrelation as string | null;
const focalPoints = data?.focalPointContext as string[] | null;
const locationData = { lat: data?.lat as number | undefined, lon: data?.lon as number | undefined, regionName: data?.regionName as string | undefined };
return `
<div class="signal-item ${escapeHtml(signal.type)}">
<div class="signal-type">${signalTypeLabels[signal.type] || escapeHtml(signal.type)}</div>
@@ -242,6 +249,25 @@ export class SignalModal {
${signal.data.explanation ? `
<div class="signal-explanation">${escapeHtml(signal.data.explanation)}</div>
` : ''}
${focalPoints && focalPoints.length > 0 ? `
<div class="signal-focal-points">
<div class="focal-points-header">📡 CORRELATED FOCAL POINTS</div>
${focalPoints.map(fp => `<div class="focal-point-item">${escapeHtml(fp)}</div>`).join('')}
</div>
` : ''}
${newsCorrelation ? `
<div class="signal-news-correlation">
<div class="news-correlation-header">📰 NEWS CORRELATION</div>
<pre class="news-correlation-text">${escapeHtml(newsCorrelation)}</pre>
</div>
` : ''}
${locationData.lat && locationData.lon ? `
<div class="signal-location">
<button class="location-link" data-lat="${locationData.lat}" data-lon="${locationData.lon}">
📍 View on map: ${locationData.regionName || `${locationData.lat.toFixed(2)}°, ${locationData.lon.toFixed(2)}°`}
</button>
</div>
` : ''}
<div class="signal-context">
<div class="signal-context-item why-matters">
<span class="context-label">Why it matters:</span>

View File

@@ -17,6 +17,7 @@ import {
type DataFreshnessSummary,
} from '@/services/data-freshness';
import { getLearningProgress } from '@/services/country-instability';
import { fetchCachedRiskScores } from '@/services/cached-risk-scores';
export class StrategicRiskPanel extends Panel {
private overview: StrategicRiskOverview | null = null;
@@ -26,6 +27,7 @@ export class StrategicRiskPanel extends Panel {
private refreshInterval: ReturnType<typeof setInterval> | null = null;
private unsubscribeFreshness: (() => void) | null = null;
private onLocationClick?: (lat: number, lon: number) => void;
private usedCachedScores = false;
constructor() {
super({
@@ -74,6 +76,17 @@ export class StrategicRiskPanel extends Panel {
this.convergenceAlerts = detectConvergence();
this.overview = calculateStrategicRiskOverview(this.convergenceAlerts);
this.alerts = getRecentAlerts(24);
// Try to get cached scores during learning mode
const { inLearning } = getLearningProgress();
if (inLearning && !this.usedCachedScores) {
const cached = await fetchCachedRiskScores();
if (cached && cached.strategicRisk) {
this.usedCachedScores = true;
console.log('[StrategicRiskPanel] Using cached scores from backend');
}
}
this.render();
}
@@ -201,9 +214,10 @@ export class StrategicRiskPanel extends Panel {
const scoreDeg = Math.round((score / 100) * 270);
const sources = dataFreshness.getAllSources();
// Check for learning mode - takes priority over limited data warning
// Check for learning mode - skip if using cached scores
const { inLearning, remainingMinutes, progress } = getLearningProgress();
const warningBanner = inLearning
const showLearning = inLearning && !this.usedCachedScores;
const warningBanner = showLearning
? `<div class="risk-warning-banner risk-status-learning">
<span class="risk-warning-icon">📊</span>
<span class="risk-warning-text">Learning Mode - ${remainingMinutes}m until reliable</span>
@@ -277,9 +291,10 @@ export class StrategicRiskPanel extends Panel {
const level = this.getScoreLevel(score);
const scoreDeg = Math.round((score / 100) * 270);
// Check for learning mode
// Check for learning mode - skip if using cached scores
const { inLearning, remainingMinutes, progress } = getLearningProgress();
const statusBanner = inLearning
const showLearning = inLearning && !this.usedCachedScores;
const statusBanner = showLearning
? `<div class="risk-status-banner risk-status-learning">
<span class="risk-status-icon">📊</span>
<span class="risk-status-text">Learning Mode - ${remainingMinutes}m until reliable</span>
@@ -289,7 +304,7 @@ export class StrategicRiskPanel extends Panel {
</div>`
: `<div class="risk-status-banner risk-status-ok">
<span class="risk-status-icon">✓</span>
<span class="risk-status-text">All data sources active</span>
<span class="risk-status-text">${this.usedCachedScores ? 'Using cached scores' : 'All data sources active'}</span>
</div>`;
return `

View File

@@ -1,7 +1,9 @@
export * from './Panel';
export * from './VirtualList';
export * from './Map';
export { MapComponent } from './Map';
export * from './MapPopup';
export { DeckGLMap } from './DeckGLMap';
export { MapContainer, type MapView, type TimeRange, type MapContainerState } from './MapContainer';
export * from './NewsPanel';
export * from './MarketPanel';
export * from './PredictionPanel';
@@ -21,6 +23,4 @@ export * from './StrategicRiskPanel';
export * from './IntelligenceGapBadge';
export * from './TechEventsPanel';
export * from './ServiceStatusPanel';
export * from './TechHubsPanel';
export * from './TechReadinessPanel';
export * from './GeoHubsPanel';
export * from './InsightsPanel';

View File

@@ -11,8 +11,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'OpenAI/Microsoft Mt Pleasant, Wisconsin Phase 2',
owner: 'OpenAI,Microsoft',
country: 'United States of America',
lat: 37.0902,
lon: -95.2129,
lat: 42.6978,
lon: -87.8912,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 700000,
@@ -35,8 +35,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'Meta Prometheus New Albany',
owner: 'Meta AI',
country: 'United States of America',
lat: 37.5631,
lon: -96.229,
lat: 40.0812,
lon: -82.8085,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 500000,
@@ -85,8 +85,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'OpenAI/Microsoft Atlanta',
owner: 'OpenAI,Microsoft',
country: 'United States of America',
lat: 36.1936,
lon: -95.6345,
lat: 33.7490,
lon: -84.3880,
status: 'planned',
chipType: 'NVIDIA B200',
chipCount: 300000,
@@ -131,8 +131,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'Applied Digital Ellendale Possible Phase 3',
owner: 'Applied Digital',
country: 'United States of America',
lat: 37.9629,
lon: -95.0433,
lat: 46.0022,
lon: -98.5267,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 180000,
@@ -142,8 +142,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'Nebius New Jersey',
owner: 'Nebius AI',
country: 'United States of America',
lat: 36.8645,
lon: -96.9932,
lat: 40.0583,
lon: -74.4057,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 150000,
@@ -177,8 +177,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'OpenAI/Microsoft Mt Pleasant, Wisconsin Phase 1',
owner: 'OpenAI,Microsoft',
country: 'United States of America',
lat: 36.2843,
lon: -94.4478,
lat: 42.6978,
lon: -87.8912,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 150000,
@@ -238,8 +238,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'Applied Digital CoreWeave Ellendale Phase 2',
owner: 'Applied Digital',
country: 'United States of America',
lat: 35.4049,
lon: -96.5902,
lat: 46.0022,
lon: -98.5267,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 110000,
@@ -273,8 +273,8 @@ export const AI_DATA_CENTERS: AIDataCenter[] = [
name: 'CoreWeave Denton GB200s OpenAI/Microsoft',
owner: 'CoreWeave',
country: 'United States of America',
lat: 37.8084,
lon: -93.7395,
lat: 33.2148,
lon: -97.1331,
status: 'planned',
chipType: 'NVIDIA GB200',
chipCount: 100000,

27
src/config/countries.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Tier 1 Countries - Core geopolitical focus
* Separated to avoid circular dependencies between services
*/
export const TIER1_COUNTRIES: Record<string, string> = {
US: 'United States',
RU: 'Russia',
CN: 'China',
UA: 'Ukraine',
IR: 'Iran',
IL: 'Israel',
TW: 'Taiwan',
KP: 'North Korea',
SA: 'Saudi Arabia',
TR: 'Turkey',
PL: 'Poland',
DE: 'Germany',
FR: 'France',
GB: 'United Kingdom',
IN: 'India',
PK: 'Pakistan',
SY: 'Syria',
YE: 'Yemen',
MM: 'Myanmar',
VE: 'Venezuela',
};

View File

@@ -325,8 +325,9 @@ const FULL_FEEDS: Record<string, Feed[]> = {
middleeast: [
{ name: 'BBC Middle East', url: rss('https://feeds.bbci.co.uk/news/world/middle_east/rss.xml') },
{ name: 'Al Jazeera', url: rss('https://www.aljazeera.com/xml/rss/all.xml') },
{ name: 'Al Arabiya', url: railwayRss('https://english.alarabiya.net/.mrss/en/News/middle-east.xml') },
{ name: 'Arab News', url: railwayRss('https://www.arabnews.com/cat/1/rss.xml') },
// AlArabiya blocks cloud IPs (Cloudflare), use Google News fallback
{ name: 'Al Arabiya', url: rss('https://news.google.com/rss/search?q=site:english.alarabiya.net+when:2d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Arab News', url: railwayRss('https://www.arabnews.com/cat/2/rss.xml') },
{ name: 'Times of Israel', url: railwayRss('https://www.timesofisrael.com/feed/') },
{ name: 'Guardian ME', url: rss('https://www.theguardian.com/world/middleeast/rss') },
{ name: 'CNN World', url: rss('http://rss.cnn.com/rss/cnn_world.rss') },
@@ -394,7 +395,7 @@ const FULL_FEEDS: Record<string, Feed[]> = {
africa: [
{ name: 'Africa News', url: rss('https://news.google.com/rss/search?q=(Africa+OR+Nigeria+OR+Kenya+OR+"South+Africa"+OR+Ethiopia)+when:2d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Sahel Crisis', url: rss('https://news.google.com/rss/search?q=(Sahel+OR+Mali+OR+Niger+OR+"Burkina+Faso"+OR+Wagner)+when:3d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'News24', url: railwayRss('https://feeds.24.com/articles/news24/Africa/rss') },
{ name: 'News24', url: railwayRss('https://feeds.capi24.com/v1/Search/articles/news24/Africa/rss') },
{ name: 'BBC Africa', url: rss('https://feeds.bbci.co.uk/news/world/africa/rss.xml') },
],
latam: [
@@ -406,7 +407,7 @@ const FULL_FEEDS: Record<string, Feed[]> = {
asia: [
{ name: 'Asia News', url: rss('https://news.google.com/rss/search?q=(China+OR+Japan+OR+Korea+OR+India+OR+ASEAN)+when:2d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'BBC Asia', url: rss('https://feeds.bbci.co.uk/news/world/asia/rss.xml') },
{ name: 'South China Morning Post', url: railwayRss('https://www.scmp.com/rss/91/feed') },
{ name: 'South China Morning Post', url: railwayRss('https://www.scmp.com/rss/91/feed/') },
{ name: 'Reuters Asia', url: rss('https://news.google.com/rss/search?q=site:reuters.com+(China+OR+Japan+OR+Taiwan+OR+Korea)+when:3d&hl=en-US&gl=US&ceid=US:en') },
],
energy: [
@@ -661,9 +662,18 @@ export const INTEL_SOURCES: Feed[] = [
{ name: 'Krebs Security', url: rss('https://krebsonsecurity.com/feed/'), type: 'cyber' },
];
// Keywords that trigger alert status - must be specific to avoid false positives
export const ALERT_KEYWORDS = [
'war', 'invasion', 'military', 'nuclear', 'sanctions', 'missile',
'attack', 'troops', 'conflict', 'strike', 'bomb', 'casualties',
'ceasefire', 'treaty', 'nato', 'coup', 'martial law', 'emergency',
'assassination', 'terrorist', 'hostage', 'evacuation', 'breaking',
'airstrike', 'drone strike', 'troops deployed', 'armed conflict', 'bombing', 'casualties',
'ceasefire', 'peace treaty', 'nato', 'coup', 'martial law',
'assassination', 'terrorist', 'terror attack', 'cyber attack', 'hostage', 'evacuation order',
];
// Patterns that indicate non-alert content (lifestyle, entertainment, etc.)
export const ALERT_EXCLUSIONS = [
'protein', 'couples', 'relationship', 'dating', 'diet', 'fitness',
'recipe', 'cooking', 'shopping', 'fashion', 'celebrity', 'movie',
'tv show', 'sports', 'game', 'concert', 'festival', 'wedding',
'vacation', 'travel tips', 'life hack', 'self-care', 'wellness',
];

View File

@@ -534,8 +534,9 @@ export const CONFLICT_ZONES: ConflictZone[] = [
{
id: 'yemen_redsea',
name: 'Red Sea Crisis',
coords: [[12, 42], [16, 42], [16, 44], [13, 45], [12, 44]],
center: [14, 43],
// Coords in [lon, lat] format for GeoJSON - Red Sea is around 42-45°E, 12-16°N
coords: [[42, 12], [42, 16], [44, 16], [45, 13], [44, 12]],
center: [43, 14],
intensity: 'high',
parties: ['Houthis', 'US/UK Coalition', 'Yemen Govt'],
casualties: 'Unknown (Maritime)',
@@ -549,8 +550,9 @@ export const CONFLICT_ZONES: ConflictZone[] = [
{
id: 'south_lebanon',
name: 'Israel-Lebanon Border',
coords: [[33.0, 35.1], [33.4, 35.1], [33.4, 35.8], [33.0, 35.8]],
center: [33.2, 35.4],
// Coords in [lon, lat] format for GeoJSON - Lebanon border is around 35-36°E, 33-34°N
coords: [[35.1, 33.0], [35.1, 33.4], [35.8, 33.4], [35.8, 33.0]],
center: [35.4, 33.2],
intensity: 'high',
parties: ['Israel (IDF)', 'Hezbollah'],
casualties: '500+ killed',
@@ -1154,111 +1156,6 @@ export const MAP_URLS = {
us: 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json',
};
export interface CountryLabel {
id: number;
name: string;
lat: number;
lon: number;
}
export const COUNTRY_LABELS: CountryLabel[] = [
// Middle East (focus region)
{ id: 368, name: 'Iraq', lat: 33.2, lon: 43.7 },
{ id: 760, name: 'Syria', lat: 35.0, lon: 38.5 },
{ id: 364, name: 'Iran', lat: 32.4, lon: 53.7 },
{ id: 792, name: 'Turkey', lat: 39.0, lon: 35.2 },
{ id: 682, name: 'Saudi Arabia', lat: 24.0, lon: 45.0 },
{ id: 376, name: 'Israel', lat: 31.5, lon: 34.8 },
{ id: 400, name: 'Jordan', lat: 31.2, lon: 36.5 },
{ id: 422, name: 'Lebanon', lat: 33.9, lon: 35.9 },
{ id: 818, name: 'Egypt', lat: 26.8, lon: 30.8 },
{ id: 784, name: 'UAE', lat: 24.0, lon: 54.0 },
{ id: 634, name: 'Qatar', lat: 25.4, lon: 51.2 },
{ id: 414, name: 'Kuwait', lat: 29.3, lon: 47.5 },
{ id: 887, name: 'Yemen', lat: 15.5, lon: 48.5 },
{ id: 512, name: 'Oman', lat: 21.5, lon: 57.0 },
{ id: 48, name: 'Bahrain', lat: 26.0, lon: 50.6 },
{ id: 275, name: 'Palestine', lat: 31.9, lon: 35.2 },
// Major powers
{ id: 840, name: 'USA', lat: 39.0, lon: -98.0 },
{ id: 643, name: 'Russia', lat: 60.0, lon: 100.0 },
{ id: 156, name: 'China', lat: 35.0, lon: 105.0 },
{ id: 356, name: 'India', lat: 22.0, lon: 79.0 },
{ id: 826, name: 'UK', lat: 54.0, lon: -2.0 },
{ id: 250, name: 'France', lat: 46.2, lon: 2.2 },
{ id: 276, name: 'Germany', lat: 51.2, lon: 10.5 },
{ id: 380, name: 'Italy', lat: 42.8, lon: 12.8 },
{ id: 724, name: 'Spain', lat: 40.5, lon: -3.7 },
{ id: 616, name: 'Poland', lat: 52.0, lon: 19.4 },
{ id: 804, name: 'Ukraine', lat: 48.4, lon: 31.2 },
{ id: 392, name: 'Japan', lat: 36.2, lon: 138.3 },
{ id: 410, name: 'S. Korea', lat: 36.5, lon: 128.0 },
{ id: 408, name: 'N. Korea', lat: 40.3, lon: 127.5 },
// Africa - West
{ id: 566, name: 'Nigeria', lat: 9.1, lon: 8.7 },
{ id: 562, name: 'Niger', lat: 17.6, lon: 8.1 },
{ id: 466, name: 'Mali', lat: 17.6, lon: -4.0 },
{ id: 854, name: 'Burkina Faso', lat: 12.2, lon: -1.6 },
{ id: 384, name: 'Côte d\'Ivoire', lat: 7.5, lon: -5.5 },
{ id: 288, name: 'Ghana', lat: 7.9, lon: -1.0 },
{ id: 686, name: 'Senegal', lat: 14.5, lon: -14.5 },
{ id: 324, name: 'Guinea', lat: 9.9, lon: -9.7 },
{ id: 120, name: 'Cameroon', lat: 6.0, lon: 12.3 },
// Africa - Central & East
{ id: 148, name: 'Chad', lat: 15.5, lon: 19.0 },
{ id: 140, name: 'CAR', lat: 6.6, lon: 20.9 },
{ id: 180, name: 'DRC', lat: -4.0, lon: 21.8 },
{ id: 178, name: 'Congo', lat: -1.0, lon: 15.8 },
{ id: 800, name: 'Uganda', lat: 1.4, lon: 32.3 },
{ id: 646, name: 'Rwanda', lat: -2.0, lon: 29.9 },
{ id: 834, name: 'Tanzania', lat: -6.4, lon: 34.9 },
{ id: 508, name: 'Mozambique', lat: -18.7, lon: 35.5 },
{ id: 894, name: 'Zambia', lat: -13.1, lon: 27.8 },
{ id: 716, name: 'Zimbabwe', lat: -19.0, lon: 29.2 },
{ id: 24, name: 'Angola', lat: -11.2, lon: 17.9 },
// Africa - North & South
{ id: 710, name: 'South Africa', lat: -29.0, lon: 25.0 },
{ id: 729, name: 'Sudan', lat: 15.5, lon: 30.0 },
{ id: 728, name: 'S. Sudan', lat: 7.0, lon: 30.0 },
{ id: 231, name: 'Ethiopia', lat: 9.1, lon: 40.5 },
{ id: 404, name: 'Kenya', lat: -0.5, lon: 38.0 },
{ id: 12, name: 'Algeria', lat: 28.0, lon: 2.0 },
{ id: 504, name: 'Morocco', lat: 32.0, lon: -6.0 },
{ id: 434, name: 'Libya', lat: 27.0, lon: 17.0 },
{ id: 788, name: 'Tunisia', lat: 34.0, lon: 9.5 },
{ id: 706, name: 'Somalia', lat: 5.2, lon: 46.2 },
{ id: 232, name: 'Eritrea', lat: 15.2, lon: 39.8 },
// Asia
{ id: 586, name: 'Pakistan', lat: 30.4, lon: 69.3 },
{ id: 4, name: 'Afghanistan', lat: 33.9, lon: 67.7 },
{ id: 104, name: 'Myanmar', lat: 21.9, lon: 96.0 },
{ id: 764, name: 'Thailand', lat: 15.9, lon: 101.0 },
{ id: 704, name: 'Vietnam', lat: 16.0, lon: 108.0 },
{ id: 360, name: 'Indonesia', lat: -2.5, lon: 118.0 },
{ id: 458, name: 'Malaysia', lat: 4.2, lon: 101.5 },
{ id: 608, name: 'Philippines', lat: 12.9, lon: 122.0 },
{ id: 158, name: 'Taiwan', lat: 23.7, lon: 121.0 },
// Americas & Arctic
{ id: 304, name: 'Greenland', lat: 71.7, lon: -42.6 },
{ id: 124, name: 'Canada', lat: 56.0, lon: -106.0 },
{ id: 484, name: 'Mexico', lat: 23.6, lon: -102.5 },
{ id: 76, name: 'Brazil', lat: -14.2, lon: -51.9 },
{ id: 32, name: 'Argentina', lat: -38.4, lon: -63.6 },
{ id: 862, name: 'Venezuela', lat: 6.4, lon: -66.6 },
{ id: 170, name: 'Colombia', lat: 4.6, lon: -74.3 },
// Europe
{ id: 112, name: 'Belarus', lat: 53.7, lon: 28.0 },
{ id: 348, name: 'Hungary', lat: 47.2, lon: 19.5 },
{ id: 642, name: 'Romania', lat: 45.9, lon: 25.0 },
{ id: 300, name: 'Greece', lat: 39.1, lon: 21.8 },
{ id: 752, name: 'Sweden', lat: 62.0, lon: 15.0 },
{ id: 578, name: 'Norway', lat: 61.0, lon: 8.5 },
{ id: 246, name: 'Finland', lat: 64.0, lon: 26.0 },
// Oceania
{ id: 36, name: 'Australia', lat: -25.3, lon: 133.8 },
{ id: 554, name: 'New Zealand', lat: -41.0, lon: 174.9 },
];
// Global Economic Centers - Stock Exchanges, Central Banks, Financial Hubs
export const ECONOMIC_CENTERS: EconomicCenter[] = [
// Americas

View File

@@ -17,7 +17,7 @@ export {
export { SECTORS, COMMODITIES, MARKET_SYMBOLS, CRYPTO_MAP } from './markets';
// Geo data (shared base)
export { UNDERSEA_CABLES, COUNTRY_LABELS, MAP_URLS } from './geo';
export { UNDERSEA_CABLES, MAP_URLS } from './geo';
// AI Datacenters (shared)
export { AI_DATA_CENTERS } from './ai-datacenters';
@@ -30,6 +30,7 @@ export {
getSourceType,
getSourcePropagandaRisk,
ALERT_KEYWORDS,
ALERT_EXCLUSIONS,
type SourceRiskProfile,
type SourceType,
} from './feeds';

82
src/config/ml-config.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* ML Configuration for ONNX Runtime Web integration
* Models are loaded from HuggingFace CDN via @xenova/transformers
*/
export interface ModelConfig {
id: string;
name: string;
hfModel: string;
size: number;
priority: number;
required: boolean;
task: 'feature-extraction' | 'text-classification' | 'text2text-generation' | 'token-classification';
}
export const MODEL_CONFIGS: ModelConfig[] = [
{
id: 'embeddings',
name: 'all-MiniLM-L6-v2',
hfModel: 'Xenova/all-MiniLM-L6-v2',
size: 23_000_000,
priority: 1,
required: true,
task: 'feature-extraction',
},
{
id: 'sentiment',
name: 'DistilBERT-SST2',
hfModel: 'Xenova/distilbert-base-uncased-finetuned-sst-2-english',
size: 65_000_000,
priority: 2,
required: false,
task: 'text-classification',
},
{
id: 'summarization',
name: 'Flan-T5-base',
hfModel: 'Xenova/flan-t5-base',
size: 250_000_000,
priority: 3,
required: false,
task: 'text2text-generation',
},
{
id: 'ner',
name: 'BERT-NER',
hfModel: 'Xenova/bert-base-NER',
size: 65_000_000,
priority: 4,
required: false,
task: 'token-classification',
},
];
export const ML_FEATURE_FLAGS = {
semanticClustering: true,
mlSentiment: true,
summarization: true,
mlNER: true,
insightsPanel: true,
};
export const ML_THRESHOLDS = {
semanticClusterThreshold: 0.75,
minClustersForML: 5,
maxTextsPerBatch: 20, // Reduced from 50 to prevent timeout
modelLoadTimeoutMs: 30000,
inferenceTimeoutMs: 45000, // Reduced - fail faster and fallback to Jaccard
memoryBudgetMB: 200,
};
export function getModelConfig(modelId: string): ModelConfig | undefined {
return MODEL_CONFIGS.find(m => m.id === modelId);
}
export function getRequiredModels(): ModelConfig[] {
return MODEL_CONFIGS.filter(m => m.required);
}
export function getModelsByPriority(): ModelConfig[] {
return [...MODEL_CONFIGS].sort((a, b) => a.priority - b.priority);
}

View File

@@ -5,15 +5,17 @@ const SITE_VARIANT = import.meta.env.VITE_VARIANT || 'full';
// ============================================
// FULL VARIANT (Geopolitical)
// ============================================
// Panel order matters! First panels appear at top of grid.
// Desired order: live-news (video), insights (AI), cii, strategic-risk, then rest
const FULL_PANELS: Record<string, PanelConfig> = {
map: { name: 'Global Map', enabled: true, priority: 1 },
'live-news': { name: 'Live News', enabled: true, priority: 1 },
'geo-hubs': { name: 'Geopolitical Hotspots', enabled: true, priority: 1 },
insights: { name: 'AI Insights', enabled: true, priority: 1 },
cii: { name: 'Country Instability', enabled: true, priority: 1 },
'strategic-risk': { name: 'Strategic Risk Overview', enabled: true, priority: 1 },
intel: { name: 'Intel Feed', enabled: true, priority: 1 },
'gdelt-intel': { name: 'Live Intelligence', enabled: true, priority: 1 },
cii: { name: 'Country Instability', enabled: true, priority: 1 },
cascade: { name: 'Infrastructure Cascade', enabled: true, priority: 1 },
'strategic-risk': { name: 'Strategic Risk Overview', enabled: true, priority: 1 },
politics: { name: 'World News', enabled: true, priority: 1 },
middleeast: { name: 'Middle East', enabled: true, priority: 1 },
africa: { name: 'Africa', enabled: true, priority: 1 },
@@ -47,7 +49,6 @@ const FULL_MAP_LAYERS: MapLayers = {
sanctions: true,
weather: true,
economic: true,
countries: false,
waterways: true,
outages: true,
datacenters: false,
@@ -77,7 +78,6 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = {
sanctions: true,
weather: true,
economic: false,
countries: false,
waterways: false,
outages: true,
datacenters: false,
@@ -101,7 +101,6 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = {
const TECH_PANELS: Record<string, PanelConfig> = {
map: { name: 'Global Tech Map', enabled: true, priority: 1 },
'live-news': { name: 'Tech Headlines', enabled: true, priority: 1 },
'tech-hubs': { name: 'Hot Tech Hubs', enabled: true, priority: 1 },
ai: { name: 'AI/ML News', enabled: true, priority: 1 },
tech: { name: 'Technology', enabled: true, priority: 1 },
startups: { name: 'Startups & VC', enabled: true, priority: 1 },
@@ -109,9 +108,6 @@ const TECH_PANELS: Record<string, PanelConfig> = {
regionalStartups: { name: 'Global Startup News', enabled: true, priority: 1 },
unicorns: { name: 'Unicorn Tracker', enabled: true, priority: 1 },
accelerators: { name: 'Accelerators & Demo Days', enabled: true, priority: 1 },
thinktanks: { name: 'Think Tanks & Research', enabled: true, priority: 1 },
podcasts: { name: 'Tech Podcasts & Newsletters', enabled: true, priority: 1 },
'tech-readiness': { name: 'Tech Readiness Index', enabled: true, priority: 1 },
security: { name: 'Cybersecurity', enabled: true, priority: 1 },
policy: { name: 'AI Policy & Regulation', enabled: true, priority: 1 },
regulation: { name: 'AI Regulation Dashboard', enabled: true, priority: 1 },
@@ -130,6 +126,7 @@ const TECH_PANELS: Record<string, PanelConfig> = {
events: { name: 'Tech Events', enabled: true, priority: 1 },
'service-status': { name: 'Service Status', enabled: true, priority: 2 },
economic: { name: 'Economic Indicators', enabled: true, priority: 2 },
insights: { name: 'AI Insights', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
};
@@ -145,7 +142,6 @@ const TECH_MAP_LAYERS: MapLayers = {
sanctions: false,
weather: true,
economic: true,
countries: false,
waterways: false,
outages: true,
datacenters: true,
@@ -175,7 +171,6 @@ const TECH_MOBILE_MAP_LAYERS: MapLayers = {
sanctions: false,
weather: false,
economic: false,
countries: false,
waterways: false,
outages: true,
datacenters: true,

View File

@@ -3,7 +3,7 @@ import type { PanelConfig, MapLayers } from '@/types';
// Shared exports (re-exported by all variants)
export { SECTORS, COMMODITIES, MARKET_SYMBOLS } from '../markets';
export { UNDERSEA_CABLES, COUNTRY_LABELS } from '../geo';
export { UNDERSEA_CABLES } from '../geo';
export { AI_DATA_CENTERS } from '../ai-datacenters';
// API URLs - shared across all variants

View File

@@ -58,7 +58,6 @@ export const DEFAULT_MAP_LAYERS: MapLayers = {
sanctions: true,
weather: true,
economic: true,
countries: false,
waterways: true,
outages: true,
datacenters: false,
@@ -89,7 +88,6 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
sanctions: true,
weather: true,
economic: false,
countries: false,
waterways: false,
outages: true,
datacenters: false,

View File

@@ -1,17 +1,17 @@
// Tech/AI variant - startups.worldmonitor.app
// This file provides re-exports for tech-specific configuration.
// Actual feeds are in feeds.ts (TECH_FEEDS), panels are in panels.ts (TECH_PANELS).
import type { PanelConfig, MapLayers } from '@/types';
import type { VariantConfig } from './base';
// Re-export base config
export * from './base';
// Tech-specific data exports
// Tech-specific exports
export * from '../tech-companies';
export * from '../ai-research-labs';
export * from '../startup-ecosystems';
export * from '../ai-regulations';
// Feed utilities (shared between variants)
// Tech-focused feeds (subset of full feeds config)
export {
SOURCE_TIERS,
getSourceTier,
@@ -21,3 +21,245 @@ export {
type SourceRiskProfile,
type SourceType,
} from '../feeds';
// Tech-specific FEEDS configuration
import type { Feed } from '@/types';
const rss = (url: string) => `/api/rss-proxy?url=${encodeURIComponent(url)}`;
export const FEEDS: Record<string, Feed[]> = {
// Core Tech News
tech: [
{ name: 'TechCrunch', url: rss('https://techcrunch.com/feed/') },
{ name: 'The Verge', url: rss('https://www.theverge.com/rss/index.xml') },
{ name: 'Ars Technica', url: rss('https://feeds.arstechnica.com/arstechnica/technology-lab') },
{ name: 'Hacker News', url: rss('https://hnrss.org/frontpage') },
{ name: 'MIT Tech Review', url: rss('https://www.technologyreview.com/feed/') },
{ name: 'ZDNet', url: rss('https://www.zdnet.com/news/rss.xml') },
{ name: 'TechMeme', url: rss('https://www.techmeme.com/feed.xml') },
{ name: 'Engadget', url: rss('https://www.engadget.com/rss.xml') },
{ name: 'Fast Company', url: rss('https://feeds.feedburner.com/fastcompany/headlines') },
],
// AI & Machine Learning
ai: [
{ name: 'AI News', url: rss('https://news.google.com/rss/search?q=(OpenAI+OR+Anthropic+OR+Google+AI+OR+"large+language+model"+OR+ChatGPT+OR+Claude+OR+"AI+model")+when:2d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'VentureBeat AI', url: rss('https://venturebeat.com/category/ai/feed/') },
{ name: 'The Verge AI', url: rss('https://www.theverge.com/rss/ai-artificial-intelligence/index.xml') },
{ name: 'MIT Tech Review AI', url: rss('https://www.technologyreview.com/topic/artificial-intelligence/feed') },
{ name: 'MIT Research', url: rss('https://news.mit.edu/rss/research') },
{ name: 'ArXiv AI', url: rss('https://export.arxiv.org/rss/cs.AI') },
{ name: 'ArXiv ML', url: rss('https://export.arxiv.org/rss/cs.LG') },
{ name: 'AI Weekly', url: rss('https://news.google.com/rss/search?q="artificial+intelligence"+OR+"machine+learning"+when:3d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Anthropic News', url: rss('https://news.google.com/rss/search?q=Anthropic+Claude+AI+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'OpenAI News', url: rss('https://news.google.com/rss/search?q=OpenAI+ChatGPT+GPT-4+when:7d&hl=en-US&gl=US&ceid=US:en') },
],
// Startups & VC - Comprehensive coverage
startups: [
{ name: 'TechCrunch Startups', url: rss('https://techcrunch.com/category/startups/feed/') },
{ name: 'VentureBeat', url: rss('https://venturebeat.com/feed/') },
{ name: 'Crunchbase News', url: rss('https://news.crunchbase.com/feed/') },
{ name: 'SaaStr', url: rss('https://www.saastr.com/feed/') },
{ name: 'TechCrunch Venture', url: rss('https://techcrunch.com/category/venture/feed/') },
{ name: 'The Information', url: rss('https://news.google.com/rss/search?q=site:theinformation.com+startup+OR+funding+when:3d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Fortune Term Sheet', url: rss('https://news.google.com/rss/search?q="Term+Sheet"+venture+capital+OR+startup+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'PitchBook News', url: rss('https://pitchbook.com/feed') },
{ name: 'CB Insights', url: rss('https://www.cbinsights.com/research/feed/') },
],
// Accelerator & VC Blogs - Thought leadership
vcblogs: [
{ name: 'Y Combinator Blog', url: rss('https://www.ycombinator.com/blog/rss/') },
{ name: 'a16z Blog', url: rss('https://a16z.com/feed/') },
{ name: 'First Round Review', url: rss('https://review.firstround.com/feed.xml') },
{ name: 'Sequoia Blog', url: rss('https://www.sequoiacap.com/feed/') },
{ name: 'NFX Essays', url: rss('https://www.nfx.com/feed') },
{ name: 'Paul Graham Essays', url: rss('https://www.aaronsw.com/2002/feeds/pgessays.rss') },
{ name: 'Both Sides of Table', url: rss('https://bothsidesofthetable.com/feed') },
{ name: 'Lenny\'s Newsletter', url: rss('https://www.lennysnewsletter.com/feed') },
{ name: 'Stratechery', url: rss('https://stratechery.com/feed/') },
],
// Regional Startup News - Global coverage
regionalStartups: [
{ name: 'EU Startups', url: rss('https://www.eu-startups.com/feed/') },
{ name: 'Tech.eu', url: rss('https://tech.eu/feed/') },
{ name: 'Sifted (Europe)', url: rss('https://sifted.eu/feed') },
{ name: 'Tech in Asia', url: rss('https://www.techinasia.com/feed') },
{ name: 'KrASIA', url: rss('https://kr-asia.com/feed') },
{ name: 'TechCabal (Africa)', url: rss('https://techcabal.com/feed/') },
{ name: 'Disrupt Africa', url: rss('https://disrupt-africa.com/feed/') },
{ name: 'LAVCA (LATAM)', url: rss('https://lavca.org/feed/') },
{ name: 'Contxto (LATAM)', url: rss('https://contxto.com/feed/') },
{ name: 'Inc42 (India)', url: rss('https://inc42.com/feed/') },
{ name: 'YourStory', url: rss('https://yourstory.com/feed') },
],
// Cybersecurity
security: [
{ name: 'Krebs Security', url: rss('https://krebsonsecurity.com/feed/') },
{ name: 'The Hacker News', url: rss('https://feeds.feedburner.com/TheHackersNews') },
{ name: 'Dark Reading', url: rss('https://www.darkreading.com/rss.xml') },
{ name: 'Schneier', url: rss('https://www.schneier.com/feed/') },
{ name: 'CISA Advisories', url: 'https://rss.worldmonitor.app/api/rss-proxy?url=' + encodeURIComponent('https://www.cisa.gov/cybersecurity-advisories/all.xml') },
{ name: 'Cyber Incidents', url: rss('https://news.google.com/rss/search?q=cyber+attack+OR+data+breach+OR+ransomware+OR+hacking+when:3d&hl=en-US&gl=US&ceid=US:en') },
],
// Policy & Regulation
policy: [
{ name: 'Politico Tech', url: rss('https://rss.politico.com/technology.xml') },
{ name: 'AI Regulation', url: rss('https://news.google.com/rss/search?q=AI+regulation+OR+"artificial+intelligence"+law+OR+policy+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Tech Antitrust', url: rss('https://news.google.com/rss/search?q=tech+antitrust+OR+FTC+Google+OR+FTC+Apple+OR+FTC+Amazon+when:7d&hl=en-US&gl=US&ceid=US:en') },
],
// Markets & Finance (tech-focused)
finance: [
{ name: 'CNBC Tech', url: rss('https://www.cnbc.com/id/19854910/device/rss/rss.html') },
{ name: 'MarketWatch Tech', url: rss('https://feeds.marketwatch.com/marketwatch/topstories/') },
{ name: 'Yahoo Finance', url: rss('https://finance.yahoo.com/rss/topstories') },
{ name: 'Seeking Alpha Tech', url: rss('https://seekingalpha.com/market_currents.xml') },
],
// Semiconductors & Hardware
hardware: [
{ name: "Tom's Hardware", url: rss('https://www.tomshardware.com/feeds/all') },
{ name: 'SemiAnalysis', url: rss('https://www.semianalysis.com/feed') },
{ name: 'Semiconductor News', url: rss('https://news.google.com/rss/search?q=semiconductor+OR+chip+OR+TSMC+OR+NVIDIA+OR+Intel+when:3d&hl=en-US&gl=US&ceid=US:en') },
],
// Cloud & Infrastructure
cloud: [
{ name: 'InfoQ', url: rss('https://feed.infoq.com/') },
{ name: 'The New Stack', url: rss('https://thenewstack.io/feed/') },
{ name: 'DevOps.com', url: rss('https://devops.com/feed/') },
],
// Developer Community
dev: [
{ name: 'Dev.to', url: rss('https://dev.to/feed') },
{ name: 'Lobsters', url: rss('https://lobste.rs/rss') },
{ name: 'Changelog', url: rss('https://changelog.com/feed') },
{ name: 'Show HN', url: rss('https://hnrss.org/show') },
{ name: 'YC Launches', url: rss('https://hnrss.org/launches') },
{ name: 'Dev Events', url: rss('https://dev.events/rss.xml') },
],
// Layoffs Tracker
layoffs: [
{ name: 'Layoffs.fyi', url: rss('https://news.google.com/rss/search?q=tech+layoffs+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'TechCrunch Layoffs', url: rss('https://techcrunch.com/tag/layoffs/feed/') },
],
// Unicorn Tracker
unicorns: [
{ name: 'Unicorn News', url: rss('https://news.google.com/rss/search?q=("unicorn+startup"+OR+"unicorn+valuation"+OR+"$1+billion+valuation")+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'CB Insights Unicorn', url: rss('https://news.google.com/rss/search?q=site:cbinsights.com+unicorn+when:14d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Decacorn News', url: rss('https://news.google.com/rss/search?q=("decacorn"+OR+"$10+billion+valuation"+OR+"$10B+valuation")+startup+when:14d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'New Unicorns', url: rss('https://news.google.com/rss/search?q=("becomes+unicorn"+OR+"joins+unicorn"+OR+"reaches+unicorn"+OR+"achieved+unicorn")+when:14d&hl=en-US&gl=US&ceid=US:en') },
],
// Accelerators & Demo Days
accelerators: [
{ name: 'YC News', url: rss('https://news.ycombinator.com/rss') },
{ name: 'YC Blog', url: rss('https://www.ycombinator.com/blog/rss/') },
{ name: 'Techstars Blog', url: rss('https://www.techstars.com/blog/feed/') },
{ name: '500 Global News', url: rss('https://news.google.com/rss/search?q="500+Global"+OR+"500+Startups"+accelerator+when:14d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Demo Day News', url: rss('https://news.google.com/rss/search?q=("demo+day"+OR+"YC+batch"+OR+"accelerator+batch")+startup+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Startup School', url: rss('https://news.google.com/rss/search?q="Startup+School"+OR+"YC+Startup+School"+when:14d&hl=en-US&gl=US&ceid=US:en') },
],
};
// Panel configuration for tech/AI analysis
export const DEFAULT_PANELS: Record<string, PanelConfig> = {
map: { name: 'Global Tech Map', enabled: true, priority: 1 },
'live-news': { name: 'Tech Headlines', enabled: true, priority: 1 },
events: { name: 'Tech Events', enabled: true, priority: 1 },
ai: { name: 'AI/ML News', enabled: true, priority: 1 },
tech: { name: 'Technology', enabled: true, priority: 1 },
startups: { name: 'Startups & VC', enabled: true, priority: 1 },
vcblogs: { name: 'VC Insights & Essays', enabled: true, priority: 1 },
regionalStartups: { name: 'Global Startup News', enabled: true, priority: 1 },
unicorns: { name: 'Unicorn Tracker', enabled: true, priority: 1 },
accelerators: { name: 'Accelerators & Demo Days', enabled: true, priority: 1 },
security: { name: 'Cybersecurity', enabled: true, priority: 1 },
policy: { name: 'AI Policy & Regulation', enabled: true, priority: 1 },
regulation: { name: 'AI Regulation Dashboard', enabled: true, priority: 1 },
layoffs: { name: 'Layoffs Tracker', enabled: true, priority: 1 },
markets: { name: 'Tech Stocks', enabled: true, priority: 2 },
finance: { name: 'Financial News', enabled: true, priority: 2 },
crypto: { name: 'Crypto', enabled: true, priority: 2 },
hardware: { name: 'Semiconductors & Hardware', enabled: true, priority: 2 },
cloud: { name: 'Cloud & Infrastructure', enabled: true, priority: 2 },
dev: { name: 'Developer Community', enabled: true, priority: 2 },
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
};
// Tech-focused map layers (subset)
export const DEFAULT_MAP_LAYERS: MapLayers = {
// Keep only relevant layers, set others to false
conflicts: false,
bases: false,
cables: true,
pipelines: false,
hotspots: false,
ais: false,
nuclear: false,
irradiators: false,
sanctions: false,
weather: true,
economic: true,
waterways: false,
outages: true,
datacenters: true,
protests: false,
flights: false,
military: false,
natural: true,
spaceports: false,
minerals: false,
// Tech-specific layers
startupHubs: true,
cloudRegions: true,
accelerators: false,
techHQs: true,
techEvents: true,
};
// Mobile defaults for tech variant
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
conflicts: false,
bases: false,
cables: false,
pipelines: false,
hotspots: false,
ais: false,
nuclear: false,
irradiators: false,
sanctions: false,
weather: false,
economic: false,
waterways: false,
outages: true,
datacenters: true,
protests: false,
flights: false,
military: false,
natural: true,
spaceports: false,
minerals: false,
// Tech-specific layers (limited on mobile)
startupHubs: true,
cloudRegions: false,
accelerators: false,
techHQs: false,
techEvents: true,
};
export const VARIANT_CONFIG: VariantConfig = {
name: 'tech',
description: 'Tech, AI & Startups intelligence dashboard',
panels: DEFAULT_PANELS,
mapLayers: DEFAULT_MAP_LAYERS,
mobileMapLayers: MOBILE_DEFAULT_MAP_LAYERS,
};

View File

@@ -1,4 +1,5 @@
import './styles/main.css';
import 'maplibre-gl/dist/maplibre-gl.css';
import { inject } from '@vercel/analytics';
import { App } from './App';
import { debugInjectTestEvents, debugGetCells, getCellCount } from '@/services/geo-convergence';

View File

@@ -1,4 +1,5 @@
import type { AisDisruptionEvent, AisDensityZone } from '@/types';
import { dataFreshness } from './data-freshness';
// WebSocket relay for live vessel tracking
// Dev: local relay (node scripts/ais-relay.cjs)
@@ -169,6 +170,8 @@ function handleMessage(event: MessageEvent): void {
processPositionReport(data);
if (messageCount % 100 === 0) {
console.log(`[Shipping] Received ${messageCount} position reports, tracking ${vessels.size} vessels`);
// Update data freshness every 100 messages
dataFreshness.recordUpdate('ais', vessels.size);
}
}
} catch (e) {

View File

@@ -0,0 +1,107 @@
/**
* Cached Risk Scores Service
* Fetches pre-computed CII and Strategic Risk scores from backend
* Eliminates 15-minute learning mode for users
*/
import type { CountryScore, ComponentScores } from './country-instability';
import { setHasCachedScores } from './country-instability';
export interface CachedCIIScore {
code: string;
name: string;
score: number;
level: 'low' | 'normal' | 'elevated' | 'high' | 'critical';
trend: 'rising' | 'stable' | 'falling';
change24h: number;
components: ComponentScores;
lastUpdated: string;
}
export interface CachedStrategicRisk {
score: number;
level: string;
trend: string;
lastUpdated: string;
contributors: Array<{
country: string;
code: string;
score: number;
level: string;
}>;
}
export interface CachedRiskScores {
cii: CachedCIIScore[];
strategicRisk: CachedStrategicRisk;
protestCount: number;
computedAt: string;
cached: boolean;
}
let cachedScores: CachedRiskScores | null = null;
let fetchPromise: Promise<CachedRiskScores | null> | null = null;
let lastFetchTime = 0;
const REFETCH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
export async function fetchCachedRiskScores(): Promise<CachedRiskScores | null> {
const now = Date.now();
// Return cached if fresh
if (cachedScores && now - lastFetchTime < REFETCH_INTERVAL_MS) {
return cachedScores;
}
// Deduplicate concurrent fetches
if (fetchPromise) {
return fetchPromise;
}
fetchPromise = (async () => {
try {
const response = await fetch('/api/risk-scores');
if (!response.ok) {
console.warn('[CachedRiskScores] API error:', response.status);
return cachedScores; // Return stale cache on error
}
const data = await response.json();
cachedScores = data;
lastFetchTime = now;
setHasCachedScores(true); // Bypass 15-min learning mode
console.log('[CachedRiskScores] Loaded', data.cached ? '(from Redis)' : '(computed)');
return cachedScores;
} catch (error) {
console.error('[CachedRiskScores] Fetch error:', error);
return cachedScores; // Return stale cache on error
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
export function getCachedScores(): CachedRiskScores | null {
return cachedScores;
}
export function hasCachedScores(): boolean {
return cachedScores !== null;
}
/**
* Convert cached CII score to CountryScore format
*/
export function toCountryScore(cached: CachedCIIScore): CountryScore {
return {
code: cached.code,
name: cached.name,
score: cached.score,
level: cached.level,
trend: cached.trend,
change24h: cached.change24h,
components: cached.components,
lastUpdated: new Date(cached.lastUpdated),
};
}

View File

@@ -1,12 +1,148 @@
/**
* News clustering service - main thread wrapper.
* Core logic is in analysis-core.ts (shared with worker).
* Hybrid clustering combines Jaccard + semantic similarity when ML is available.
*/
import type { NewsItem, ClusteredEvent } from '@/types';
import { getSourceTier } from '@/config';
import { clusterNewsCore } from './analysis-core';
import { mlWorker } from './ml-worker';
import { ML_THRESHOLDS } from '@/config/ml-config';
export function clusterNews(items: NewsItem[]): ClusteredEvent[] {
return clusterNewsCore(items, getSourceTier) as ClusteredEvent[];
}
/**
* Hybrid clustering: Jaccard first, then semantic refinement if ML available
*/
export async function clusterNewsHybrid(items: NewsItem[]): Promise<ClusteredEvent[]> {
// Step 1: Fast Jaccard clustering
const jaccardClusters = clusterNewsCore(items, getSourceTier) as ClusteredEvent[];
// Step 2: If ML unavailable or too few clusters, return Jaccard results
if (!mlWorker.isAvailable || jaccardClusters.length < ML_THRESHOLDS.minClustersForML) {
return jaccardClusters;
}
try {
// Get cluster primary titles for embedding
const clusterTexts = jaccardClusters.map(c => ({
id: c.id,
text: c.primaryTitle,
}));
// Get semantic groupings
const semanticGroups = await mlWorker.clusterBySemanticSimilarity(
clusterTexts,
ML_THRESHOLDS.semanticClusterThreshold
);
// Merge semantically similar clusters
return mergeSemanticallySimilarClusters(jaccardClusters, semanticGroups);
} catch (error) {
console.warn('[Clustering] Semantic clustering failed, using Jaccard only:', error);
return jaccardClusters;
}
}
/**
* Merge clusters that are semantically similar
*/
function mergeSemanticallySimilarClusters(
clusters: ClusteredEvent[],
semanticGroups: string[][]
): ClusteredEvent[] {
const clusterMap = new Map(clusters.map(c => [c.id, c]));
const merged: ClusteredEvent[] = [];
const usedIds = new Set<string>();
for (const group of semanticGroups) {
if (group.length === 0) continue;
// Get all clusters in this semantic group
const groupClusters = group
.map(id => clusterMap.get(id))
.filter((c): c is ClusteredEvent => c !== undefined && !usedIds.has(c.id));
if (groupClusters.length === 0) continue;
// Mark all as used
groupClusters.forEach(c => usedIds.add(c.id));
const firstCluster = groupClusters[0];
if (!firstCluster) continue;
if (groupClusters.length === 1) {
// No merging needed
merged.push(firstCluster);
continue;
}
// Merge multiple clusters into one
// Use the cluster with the highest-tier primary source as the base
const sortedByTier = [...groupClusters].sort((a, b) => {
const tierA = getSourceTier(a.primarySource);
const tierB = getSourceTier(b.primarySource);
if (tierA !== tierB) return tierA - tierB;
return b.lastUpdated.getTime() - a.lastUpdated.getTime();
});
const primary = sortedByTier[0];
if (!primary) continue;
const others = sortedByTier.slice(1);
// Combine all items, sources, etc.
const allItems = [...primary.allItems];
const topSourcesSet = new Map(primary.topSources.map(s => [s.url, s]));
for (const other of others) {
allItems.push(...other.allItems);
for (const src of other.topSources) {
if (!topSourcesSet.has(src.url)) {
topSourcesSet.set(src.url, src);
}
}
}
// Sort top sources by tier, keep top 5
const sortedTopSources = Array.from(topSourcesSet.values())
.sort((a, b) => a.tier - b.tier)
.slice(0, 5);
// Calculate merged timestamps
const allDates = allItems.map(i => i.pubDate.getTime());
const firstSeen = new Date(Math.min(...allDates));
const lastUpdated = new Date(Math.max(...allDates));
const mergedCluster: ClusteredEvent = {
id: primary.id,
primaryTitle: primary.primaryTitle,
primaryLink: primary.primaryLink,
primarySource: primary.primarySource,
sourceCount: allItems.length,
topSources: sortedTopSources,
allItems,
firstSeen,
lastUpdated,
isAlert: allItems.some(i => i.isAlert),
monitorColor: primary.monitorColor,
velocity: primary.velocity,
};
merged.push(mergedCluster);
}
// Add any clusters that weren't in any semantic group
for (const cluster of clusters) {
if (!usedIds.has(cluster.id)) {
merged.push(cluster);
}
}
// Sort by last updated
merged.sort((a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime());
return merged;
}

View File

@@ -1,5 +1,7 @@
import type { SocialUnrestEvent, MilitaryFlight, MilitaryVessel, ClusteredEvent, InternetOutage } from '@/types';
import { INTEL_HOTSPOTS, CONFLICT_ZONES, STRATEGIC_WATERWAYS } from '@/config/geo';
import { TIER1_COUNTRIES } from '@/config/countries';
import { focalPointDetector } from './focal-point-detector';
export interface CountryScore {
code: string;
@@ -26,33 +28,21 @@ interface CountryData {
outages: InternetOutage[];
}
export const TIER1_COUNTRIES: Record<string, string> = {
US: 'United States',
RU: 'Russia',
CN: 'China',
UA: 'Ukraine',
IR: 'Iran',
IL: 'Israel',
TW: 'Taiwan',
KP: 'North Korea',
SA: 'Saudi Arabia',
TR: 'Turkey',
PL: 'Poland',
DE: 'Germany',
FR: 'France',
GB: 'United Kingdom',
IN: 'India',
PK: 'Pakistan',
SY: 'Syria',
YE: 'Yemen',
MM: 'Myanmar',
VE: 'Venezuela',
};
// Re-export for backwards compatibility
export { TIER1_COUNTRIES } from '@/config/countries';
// Learning Mode - warmup period for reliable data
// Learning Mode - warmup period for reliable data (bypassed when cached scores exist)
const LEARNING_DURATION_MS = 15 * 60 * 1000; // 15 minutes
let learningStartTime: number | null = null;
let isLearningComplete = false;
let hasCachedScoresAvailable = false;
export function setHasCachedScores(hasScores: boolean): void {
hasCachedScoresAvailable = hasScores;
if (hasScores) {
isLearningComplete = true; // Skip learning when cached scores available
}
}
export function startLearning(): void {
if (learningStartTime === null) {
@@ -61,6 +51,7 @@ export function startLearning(): void {
}
export function isInLearningMode(): boolean {
if (hasCachedScoresAvailable) return false; // Bypass if backend has cached scores
if (isLearningComplete) return false;
if (learningStartTime === null) return true;
@@ -73,7 +64,7 @@ export function isInLearningMode(): boolean {
}
export function getLearningProgress(): { inLearning: boolean; remainingMinutes: number; progress: number } {
if (isLearningComplete) {
if (hasCachedScoresAvailable || isLearningComplete) {
return { inLearning: false, remainingMinutes: 0, progress: 100 };
}
if (learningStartTime === null) {
@@ -469,6 +460,7 @@ function getTrend(code: string, current: number): CountryScore['trend'] {
export function calculateCII(): CountryScore[] {
const scores: CountryScore[] = [];
const focalUrgencies = focalPointDetector.getCountryUrgencyMap();
for (const [code, name] of Object.entries(TIER1_COUNTRIES)) {
const data = countryDataMap.get(code) || initCountryData();
@@ -487,11 +479,28 @@ export function calculateCII(): CountryScore[] {
// Hotspot proximity boost - events near strategic locations are more significant
const hotspotBoost = getHotspotBoost(code);
// Blend baseline risk with detected events + hotspot boost
// News urgency boost - high information score means breaking news
// This prevents the score from being diluted when there's major news but no detected signals
// Example: "US sends armada to Iran" should elevate Iran even if no military tracked yet
const newsUrgencyBoost = components.information >= 70 ? 15
: components.information >= 50 ? 10
: components.information >= 30 ? 5
: 0;
// Focal point intelligence boost - FocalPointDetector correlates news entities with map signals
// If Iran is marked "critical" by focal analysis, boost CII score accordingly
const focalUrgency = focalUrgencies.get(code);
const focalBoost = focalUrgency === 'critical' ? 20
: focalUrgency === 'elevated' ? 10
: 0;
// Blend baseline risk with detected events + all boosts
// - 40% baseline risk (geopolitical context always matters)
// - 60% event-based (current detected activity)
// - Hotspot boost adds up to 30 points for activity near strategic locations
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost;
// - News urgency boost ensures breaking news elevates score
// - Focal boost adds intelligence synthesis (news + signals correlation)
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost;
// Active conflict zones have a FLOOR score - they're inherently more unstable
// than peaceful countries regardless of detected events
@@ -541,7 +550,15 @@ export function getCountryScore(code: string): number | null {
const eventScore = components.unrest * 0.4 + components.security * 0.3 + components.information * 0.3;
const hotspotBoost = getHotspotBoost(code);
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost;
const newsUrgencyBoost = components.information >= 70 ? 15
: components.information >= 50 ? 10
: components.information >= 30 ? 5
: 0;
const focalUrgency = focalPointDetector.getCountryUrgency(code);
const focalBoost = focalUrgency === 'critical' ? 20
: focalUrgency === 'elevated' ? 10
: 0;
const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost;
// Active conflict zones have floor scores
const conflictFloor: Record<string, number> = {

View File

@@ -1,4 +1,5 @@
import type { NaturalEvent, NaturalEventCategory } from '@/types';
import { fetchGDACSEvents, type GDACSEvent } from './gdacs';
interface EonetGeometry {
magnitudeValue?: number;
@@ -58,7 +59,63 @@ export function getNaturalEventIcon(category: NaturalEventCategory): string {
// Wildfires older than 48 hours are filtered out (stale data)
const WILDFIRE_MAX_AGE_MS = 48 * 60 * 60 * 1000;
const GDACS_TO_CATEGORY: Record<string, NaturalEventCategory> = {
EQ: 'earthquakes',
FL: 'floods',
TC: 'severeStorms',
VO: 'volcanoes',
WF: 'wildfires',
DR: 'drought',
};
function convertGDACSToNaturalEvent(gdacs: GDACSEvent): NaturalEvent {
const category = GDACS_TO_CATEGORY[gdacs.eventType] || 'manmade';
return {
id: gdacs.id,
title: `${gdacs.alertLevel === 'Red' ? '🔴 ' : gdacs.alertLevel === 'Orange' ? '🟠 ' : ''}${gdacs.name}`,
description: `${gdacs.description}${gdacs.severity ? ` - ${gdacs.severity}` : ''}`,
category,
categoryTitle: gdacs.description,
lat: gdacs.coordinates[1],
lon: gdacs.coordinates[0],
date: gdacs.fromDate,
sourceUrl: gdacs.url,
sourceName: 'GDACS',
closed: false,
};
}
export async function fetchNaturalEvents(days = 30): Promise<NaturalEvent[]> {
const [eonetEvents, gdacsEvents] = await Promise.all([
fetchEonetEvents(days),
fetchGDACSEvents(),
]);
console.log(`[NaturalEvents] EONET: ${eonetEvents.length}, GDACS: ${gdacsEvents.length}`);
const gdacsConverted = gdacsEvents.map(convertGDACSToNaturalEvent);
const seenLocations = new Set<string>();
const merged: NaturalEvent[] = [];
for (const event of gdacsConverted) {
const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
if (!seenLocations.has(key)) {
seenLocations.add(key);
merged.push(event);
}
}
for (const event of eonetEvents) {
const key = `${event.lat.toFixed(1)}-${event.lon.toFixed(1)}-${event.category}`;
if (!seenLocations.has(key)) {
seenLocations.add(key);
merged.push(event);
}
}
return merged;
}
async function fetchEonetEvents(days: number): Promise<NaturalEvent[]> {
try {
const url = `${EONET_API_URL}?status=open&days=${days}`;
const response = await fetch(url);

View File

@@ -0,0 +1,461 @@
/**
* Focal Point Detector - Intelligence Synthesis Layer
*
* Correlates news entities with map signals to identify "main characters"
* that appear across multiple intelligence streams.
*
* Example: IRAN mentioned in 12 news clusters + 5 military flights + internet outage
* = CRITICAL focal point with rich narrative for AI
*/
import type { ClusteredEvent, FocalPoint, FocalPointSummary, EntityMention } from '@/types';
import type { SignalSummary, CountrySignalCluster, SignalType } from './signal-aggregator';
import { extractEntitiesFromClusters, type NewsEntityContext } from './entity-extraction';
import { getEntityIndex } from './entity-index';
const SIGNAL_TYPE_LABELS: Record<SignalType, string> = {
internet_outage: 'internet outage',
military_flight: 'military flights',
military_vessel: 'naval vessels',
protest: 'protests',
ais_disruption: 'shipping disruption',
};
const SIGNAL_TYPE_ICONS: Record<SignalType, string> = {
internet_outage: '🌐',
military_flight: '✈️',
military_vessel: '⚓',
protest: '📢',
ais_disruption: '🚢',
};
class FocalPointDetector {
private lastSummary: FocalPointSummary | null = null;
/**
* Main analysis entry point - correlates news clusters with map signals
*/
analyze(clusters: ClusteredEvent[], signalSummary: SignalSummary): FocalPointSummary {
const entityContexts = extractEntitiesFromClusters(clusters);
const entityMentions = this.aggregateEntities(entityContexts, clusters);
const focalPoints = this.buildFocalPoints(entityMentions, signalSummary);
const aiContext = this.generateAIContext(focalPoints);
this.lastSummary = {
timestamp: new Date(),
focalPoints,
aiContext,
topCountries: focalPoints.filter(fp => fp.entityType === 'country').slice(0, 5),
topCompanies: focalPoints.filter(fp => fp.entityType === 'company').slice(0, 3),
};
return this.lastSummary;
}
/**
* Aggregate entity mentions across all news clusters
*/
private aggregateEntities(
entityContexts: Map<string, NewsEntityContext>,
clusters: ClusteredEvent[]
): Map<string, EntityMention> {
const mentions = new Map<string, EntityMention>();
const index = getEntityIndex();
for (const [clusterId, context] of entityContexts) {
const cluster = clusters.find(c => c.id === clusterId);
if (!cluster) continue;
for (const entity of context.entities) {
const entityEntry = index.byId.get(entity.entityId);
if (!entityEntry) continue;
const existing = mentions.get(entity.entityId);
if (existing) {
existing.mentionCount++;
existing.avgConfidence = (existing.avgConfidence * (existing.mentionCount - 1) + entity.confidence) / existing.mentionCount;
existing.clusterIds.push(clusterId);
if (existing.topHeadlines.length < 3) {
existing.topHeadlines.push(cluster.primaryTitle);
}
} else {
mentions.set(entity.entityId, {
entityId: entity.entityId,
entityType: entityEntry.type,
displayName: entityEntry.name,
mentionCount: 1,
avgConfidence: entity.confidence,
clusterIds: [clusterId],
topHeadlines: [cluster.primaryTitle],
});
}
}
}
return mentions;
}
/**
* Build focal points by correlating news entities with map signals
*/
private buildFocalPoints(
entityMentions: Map<string, EntityMention>,
signalSummary: SignalSummary
): FocalPoint[] {
const focalPoints: FocalPoint[] = [];
const index = getEntityIndex();
const countrySignals = new Map<string, CountrySignalCluster>();
for (const cluster of signalSummary.topCountries) {
countrySignals.set(cluster.country, cluster);
}
for (const [entityId, mention] of entityMentions) {
const entityEntry = index.byId.get(entityId);
if (!entityEntry) continue;
let signals: CountrySignalCluster | undefined;
let signalCountry: string | undefined;
if (entityEntry.type === 'country') {
signals = countrySignals.get(entityId);
signalCountry = entityId;
} else if (entityEntry.related) {
for (const relatedId of entityEntry.related) {
const relatedEntity = index.byId.get(relatedId);
if (relatedEntity?.type === 'country') {
signals = countrySignals.get(relatedId);
if (signals) {
signalCountry = relatedId;
break;
}
}
}
}
const focalPoint = this.createFocalPoint(mention, signals, signalCountry);
focalPoints.push(focalPoint);
}
for (const [countryCode, signals] of countrySignals) {
if (!entityMentions.has(countryCode)) {
const countryEntity = index.byId.get(countryCode);
if (countryEntity) {
const mention: EntityMention = {
entityId: countryCode,
entityType: 'country',
displayName: countryEntity.name,
mentionCount: 0,
avgConfidence: 0,
clusterIds: [],
topHeadlines: [],
};
const focalPoint = this.createFocalPoint(mention, signals, countryCode);
if (focalPoint.focalScore > 20) {
focalPoints.push(focalPoint);
}
}
}
}
return focalPoints.sort((a, b) => b.focalScore - a.focalScore);
}
/**
* Create a focal point with scoring and narrative
*/
private createFocalPoint(
mention: EntityMention,
signals: CountrySignalCluster | undefined,
_signalCountry: string | undefined
): FocalPoint {
const newsScore = this.calculateNewsScore(mention);
const signalScore = signals ? this.calculateSignalScore(signals) : 0;
const correlationBonus = this.calculateCorrelationBonus(mention, signals);
const rawScore = newsScore + signalScore + correlationBonus;
const signalTypes = signals ? Array.from(signals.signalTypes) : [];
const urgency = this.determineUrgency(rawScore, signalTypes.length);
const urgencyMultiplier = urgency === 'critical' ? 1.3 : urgency === 'elevated' ? 1.15 : 1.0;
const focalScore = Math.min(100, rawScore * urgencyMultiplier);
const signalDescriptions = signals
? signalTypes.map(type => {
const count = signals.signals.filter(s => s.type === type).length;
return `${count} ${SIGNAL_TYPE_LABELS[type]}`;
})
: [];
const narrative = this.generateNarrative(mention, signals, signalTypes);
const correlationEvidence = this.getCorrelationEvidence(mention, signals);
return {
id: `fp-${mention.entityId}`,
entityId: mention.entityId,
entityType: mention.entityType,
displayName: mention.displayName,
newsMentions: mention.mentionCount,
newsVelocity: mention.mentionCount / 24,
topHeadlines: mention.topHeadlines,
signalTypes,
signalCount: signals?.totalCount || 0,
highSeverityCount: signals?.highSeverityCount || 0,
signalDescriptions,
focalScore,
urgency,
narrative,
correlationEvidence,
};
}
private calculateNewsScore(mention: EntityMention): number {
const base = Math.min(20, mention.mentionCount * 4);
const velocity = Math.min(10, (mention.mentionCount / 24) * 2);
const confidence = mention.avgConfidence * 10;
return base + velocity + confidence;
}
private calculateSignalScore(signals: CountrySignalCluster): number {
const typeBonus = signals.signalTypes.size * 10;
const countBonus = Math.min(15, signals.totalCount * 3);
const severityBonus = signals.highSeverityCount * 5;
return typeBonus + countBonus + severityBonus;
}
private calculateCorrelationBonus(
mention: EntityMention,
signals: CountrySignalCluster | undefined
): number {
let bonus = 0;
if (mention.mentionCount > 0 && signals && signals.totalCount > 0) {
bonus += 10;
}
if (signals && mention.topHeadlines.some(h => {
const lower = h.toLowerCase();
return (signals.signalTypes.has('military_flight') && /military|troops|forces|army|air force/.test(lower)) ||
(signals.signalTypes.has('military_vessel') && /navy|naval|ships|fleet|carrier/.test(lower)) ||
(signals.signalTypes.has('protest') && /protest|demonstrat|unrest|riot/.test(lower)) ||
(signals.signalTypes.has('internet_outage') && /internet|blackout|outage|connectivity/.test(lower));
})) {
bonus += 5;
}
return bonus;
}
private determineUrgency(score: number, signalTypeCount: number): 'watch' | 'elevated' | 'critical' {
if (score > 70 || signalTypeCount >= 3) return 'critical';
if (score > 50 || signalTypeCount >= 2) return 'elevated';
return 'watch';
}
private generateNarrative(
mention: EntityMention,
signals: CountrySignalCluster | undefined,
signalTypes: SignalType[]
): string {
const parts: string[] = [];
if (mention.mentionCount > 0) {
parts.push(`${mention.mentionCount} news mentions`);
}
if (signals && signalTypes.length > 0) {
const signalParts = signalTypes.map(type => {
const count = signals.signals.filter(s => s.type === type).length;
return `${count} ${SIGNAL_TYPE_LABELS[type]}`;
});
parts.push(signalParts.join(', '));
}
if (mention.topHeadlines.length > 0 && mention.topHeadlines[0]) {
const headline = mention.topHeadlines[0].slice(0, 60);
parts.push(`"${headline}..."`);
}
return parts.join(' | ');
}
private getCorrelationEvidence(
mention: EntityMention,
signals: CountrySignalCluster | undefined
): string[] {
const evidence: string[] = [];
if (mention.mentionCount > 0 && signals && signals.totalCount > 0) {
evidence.push(`${mention.displayName} appears in both news (${mention.mentionCount}) and map signals (${signals.totalCount})`);
}
if (signals && signals.signalTypes.size >= 2) {
const types = Array.from(signals.signalTypes).map(t => SIGNAL_TYPE_LABELS[t]);
evidence.push(`Multiple signal convergence: ${types.join(' + ')}`);
}
if (signals && signals.highSeverityCount > 0) {
evidence.push(`${signals.highSeverityCount} high-severity signals detected`);
}
return evidence;
}
/**
* Generate rich AI context for summarization
*/
private generateAIContext(focalPoints: FocalPoint[]): string {
if (focalPoints.length === 0) {
return '';
}
const lines: string[] = ['[INTELLIGENCE SYNTHESIS]'];
const critical = focalPoints.filter(fp => fp.urgency === 'critical').slice(0, 3);
const elevated = focalPoints.filter(fp => fp.urgency === 'elevated').slice(0, 3);
const correlatedFPs = focalPoints.filter(fp => fp.newsMentions > 0 && fp.signalCount > 0).slice(0, 5);
if (critical.length > 0) {
lines.push('');
lines.push('CRITICAL FOCAL POINTS:');
for (const fp of critical) {
const icons = fp.signalTypes.map(t => SIGNAL_TYPE_ICONS[t as SignalType]).join('');
lines.push(`- ${fp.displayName} [CRITICAL] ${icons}: ${fp.narrative}`);
if (fp.correlationEvidence.length > 0) {
lines.push(`${fp.correlationEvidence[0]}`);
}
}
}
if (elevated.length > 0) {
lines.push('');
lines.push('ELEVATED WATCH:');
for (const fp of elevated) {
lines.push(`- ${fp.displayName}: ${fp.newsMentions} news, ${fp.signalCount} signals`);
}
}
if (correlatedFPs.length > 0) {
lines.push('');
lines.push('NEWS-SIGNAL CORRELATIONS:');
for (const fp of correlatedFPs) {
const signalDesc = fp.signalTypes.map(t => SIGNAL_TYPE_LABELS[t as SignalType]).join(', ');
lines.push(`- ${fp.displayName}: news coverage + ${signalDesc} detected`);
}
}
return lines.join('\n');
}
/**
* Get signal icons for UI display
*/
getSignalIcons(signalTypes: string[]): string {
return signalTypes.map(t => SIGNAL_TYPE_ICONS[t as SignalType] || '').join(' ');
}
/**
* Get last computed summary
*/
getLastSummary(): FocalPointSummary | null {
return this.lastSummary;
}
/**
* Get urgency level for a specific country (for CII integration)
* Returns the focal point urgency if found, null otherwise
*/
getCountryUrgency(countryCode: string): 'watch' | 'elevated' | 'critical' | null {
if (!this.lastSummary) return null;
const fp = this.lastSummary.focalPoints.find(
fp => fp.entityType === 'country' && fp.entityId === countryCode
);
return fp?.urgency || null;
}
/**
* Get all country urgencies as a map (for batch CII calculation)
*/
getCountryUrgencyMap(): Map<string, 'watch' | 'elevated' | 'critical'> {
const map = new Map<string, 'watch' | 'elevated' | 'critical'>();
if (!this.lastSummary) return map;
for (const fp of this.lastSummary.focalPoints) {
if (fp.entityType === 'country') {
map.set(fp.entityId, fp.urgency);
}
}
return map;
}
/**
* Get full focal point data for a country (for military surge integration)
* Returns focal point with news headlines and correlation evidence
*/
getFocalPointForCountry(countryCode: string): FocalPoint | null {
if (!this.lastSummary) return null;
return this.lastSummary.focalPoints.find(
fp => fp.entityType === 'country' && fp.entityId === countryCode
) || null;
}
/**
* Get news correlation context for multiple countries (for surge alerts)
* Returns formatted string describing news-signal correlations
*/
getNewsCorrelationContext(countryCodes: string[]): string | null {
if (!this.lastSummary) return null;
const relevantFPs = this.lastSummary.focalPoints.filter(
fp => fp.entityType === 'country' && countryCodes.includes(fp.entityId) && fp.newsMentions > 0
);
if (relevantFPs.length === 0) return null;
const lines: string[] = [];
for (const fp of relevantFPs.slice(0, 3)) {
const headline = fp.topHeadlines[0];
if (headline) {
lines.push(`${fp.displayName}: "${headline.slice(0, 80)}..."`);
}
const evidence = fp.correlationEvidence[0];
if (evidence) {
lines.push(`${evidence}`);
}
}
return lines.length > 0 ? lines.join('\n') : null;
}
/**
* Log focal point summary to console for debugging
*/
logSummary(): void {
if (!this.lastSummary) {
console.log('[FocalPointDetector] No summary available');
return;
}
console.group('%c[FocalPointDetector]', 'color: #8b5cf6; font-weight: bold');
console.log(`Total focal points: ${this.lastSummary.focalPoints.length}`);
const critical = this.lastSummary.focalPoints.filter(fp => fp.urgency === 'critical');
const elevated = this.lastSummary.focalPoints.filter(fp => fp.urgency === 'elevated');
if (critical.length > 0) {
console.log('%cCritical:', 'color: #ef4444; font-weight: bold');
for (const fp of critical) {
console.log(` ${fp.displayName}: score ${fp.focalScore.toFixed(0)}, ${fp.newsMentions} news, ${fp.signalCount} signals`);
}
}
if (elevated.length > 0) {
console.log('%cElevated:', 'color: #f59e0b; font-weight: bold');
for (const fp of elevated.slice(0, 5)) {
console.log(` ${fp.displayName}: score ${fp.focalScore.toFixed(0)}`);
}
}
console.groupEnd();
}
}
export const focalPointDetector = new FocalPointDetector();

114
src/services/gdacs.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createCircuitBreaker } from '@/utils';
export interface GDACSEvent {
id: string;
eventType: 'EQ' | 'FL' | 'TC' | 'VO' | 'WF' | 'DR';
name: string;
description: string;
alertLevel: 'Green' | 'Orange' | 'Red';
country: string;
coordinates: [number, number];
fromDate: Date;
severity: string;
url: string;
}
interface GDACSFeature {
geometry: {
type: string;
coordinates: [number, number];
};
properties: {
eventtype: string;
eventid: number;
name: string;
description: string;
alertlevel: string;
country: string;
fromdate: string;
severitydata?: {
severity: number;
severitytext: string;
severityunit: string;
};
url: {
report: string;
};
};
}
interface GDACSResponse {
features: GDACSFeature[];
}
const GDACS_API = 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP';
const breaker = createCircuitBreaker<GDACSEvent[]>({ name: 'GDACS' });
const EVENT_TYPE_NAMES: Record<string, string> = {
EQ: 'Earthquake',
FL: 'Flood',
TC: 'Tropical Cyclone',
VO: 'Volcano',
WF: 'Wildfire',
DR: 'Drought',
};
export async function fetchGDACSEvents(): Promise<GDACSEvent[]> {
return breaker.execute(async () => {
const response = await fetch(GDACS_API, {
headers: { 'Accept': 'application/json' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: GDACSResponse = await response.json();
const seen = new Set<string>();
return data.features
.filter(f => {
if (!f.geometry || f.geometry.type !== 'Point') return false;
const key = `${f.properties.eventtype}-${f.properties.eventid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.filter(f => f.properties.alertlevel !== 'Green')
.slice(0, 100)
.map(f => ({
id: `gdacs-${f.properties.eventtype}-${f.properties.eventid}`,
eventType: f.properties.eventtype as GDACSEvent['eventType'],
name: f.properties.name,
description: f.properties.description || EVENT_TYPE_NAMES[f.properties.eventtype] || f.properties.eventtype,
alertLevel: f.properties.alertlevel as GDACSEvent['alertLevel'],
country: f.properties.country,
coordinates: f.geometry.coordinates,
fromDate: new Date(f.properties.fromdate),
severity: f.properties.severitydata?.severitytext || '',
url: f.properties.url?.report || '',
}));
}, []);
}
export function getGDACSStatus(): string {
return breaker.getStatus();
}
export function getEventTypeIcon(type: GDACSEvent['eventType']): string {
switch (type) {
case 'EQ': return '🌍';
case 'FL': return '🌊';
case 'TC': return '🌀';
case 'VO': return '🌋';
case 'WF': return '🔥';
case 'DR': return '☀️';
default: return '⚠️';
}
}
export function getAlertColor(level: GDACSEvent['alertLevel']): [number, number, number, number] {
switch (level) {
case 'Red': return [255, 0, 0, 200];
case 'Orange': return [255, 140, 0, 180];
default: return [255, 200, 0, 160];
}
}

View File

@@ -27,8 +27,4 @@ export * from './cross-module-integration';
export * from './data-freshness';
export * from './usa-spending';
export * from './oil-analytics';
export * from './tech-hub-index';
export * from './tech-activity';
export * from './geo-hub-index';
export * from './geo-activity';
export * from './worldbank';
export { generateSummary } from './summarization';

View File

@@ -1,6 +1,7 @@
import type { MilitaryFlight, MilitaryOperator } from '@/types';
import type { SignalType } from '@/utils/analysis-constants';
import { MILITARY_BASES_EXPANDED } from '@/config/bases-expanded';
import { focalPointDetector } from './focal-point-detector';
// Foreign military concentration detection - immediate alerts, no baseline needed
interface GeoRegion {
@@ -460,6 +461,35 @@ export function detectForeignMilitaryPresence(flights: MilitaryFlight[]): Foreig
return newAlerts;
}
// Map operator country names to ISO codes for focal point lookup
const COUNTRY_TO_ISO: Record<string, string> = {
'USA': 'US',
'Russia': 'RU',
'China': 'CN',
'Israel': 'IL',
'Iran': 'IR',
'UK': 'GB',
'France': 'FR',
'Germany': 'DE',
'Taiwan': 'TW',
'Ukraine': 'UA',
'Saudi Arabia': 'SA',
};
// Map regions to affected countries (for news correlation)
const REGION_AFFECTED_COUNTRIES: Record<string, string[]> = {
'persian-gulf': ['IR', 'SA'],
'strait-hormuz': ['IR'],
'iran-border': ['IR', 'IL'],
'baltics': ['RU', 'UA'],
'poland-border': ['RU', 'UA'],
'black-sea': ['RU', 'UA'],
'taiwan-strait': ['TW', 'CN'],
'south-china-sea': ['CN', 'TW'],
'east-med': ['IL', 'IR'],
'alaska-adiz': ['RU'],
};
export function foreignPresenceToSignal(alert: ForeignPresenceAlert): {
id: string;
type: SignalType;
@@ -505,14 +535,47 @@ export function foreignPresenceToSignal(alert: ForeignPresenceAlert): {
const confidence = Math.min(0.95, 0.7 + alert.aircraftCount * 0.05);
const metadata = {
// Gather relevant countries for focal point lookup
const relevantCountries: string[] = [];
const operatorISO = COUNTRY_TO_ISO[alert.operatorCountry];
if (operatorISO) relevantCountries.push(operatorISO);
const affectedCountries = REGION_AFFECTED_COUNTRIES[alert.region.id] || [];
for (const iso of affectedCountries) {
if (!relevantCountries.includes(iso)) {
relevantCountries.push(iso);
}
}
// Get news correlation from focal point detector
const newsContext = focalPointDetector.getNewsCorrelationContext(relevantCountries);
// Build enhanced description with news correlation
let description = `${alert.aircraftCount} ${alert.operatorCountry} aircraft detected in ${alert.region.name}. ` +
`${aircraftList}. Callsigns: ${callsigns.slice(0, 4).join(', ')}${callsigns.length > 4 ? '...' : ''}`;
// Check for critical focal points in affected region
const focalPointContexts: string[] = [];
for (const iso of relevantCountries) {
const fp = focalPointDetector.getFocalPointForCountry(iso);
if (fp && fp.newsMentions > 0) {
focalPointContexts.push(`${fp.displayName}: ${fp.newsMentions} news mentions (${fp.urgency})`);
}
}
const metadata: Record<string, unknown> = {
operator: alert.operator,
operatorCountry: alert.operatorCountry,
regionId: alert.region.id,
regionName: alert.region.name,
lat: alert.region.lat,
lon: alert.region.lon,
aircraftCount: alert.aircraftCount,
aircraftTypes: Object.fromEntries(aircraftTypes),
callsigns,
relevantCountries,
newsCorrelation: newsContext,
focalPointContext: focalPointContexts.length > 0 ? focalPointContexts : null,
};
return {
@@ -520,8 +583,7 @@ export function foreignPresenceToSignal(alert: ForeignPresenceAlert): {
type: 'military_surge',
source: 'Military Flight Tracking',
title: `🚨 ${alert.operatorCountry} Military in ${alert.region.name}`,
description: `${alert.aircraftCount} ${alert.operatorCountry} aircraft detected in ${alert.region.name}. ` +
`${aircraftList}. Callsigns: ${callsigns.slice(0, 4).join(', ')}${callsigns.length > 4 ? '...' : ''}`,
description,
severity,
confidence,
category: 'military',

View File

@@ -0,0 +1,124 @@
/**
* ML Capabilities Detection
* Detects device capabilities for ONNX Runtime Web
*/
import { isMobileDevice } from '@/utils';
import { ML_THRESHOLDS } from '@/config/ml-config';
export interface MLCapabilities {
isSupported: boolean;
isDesktop: boolean;
hasWebGL: boolean;
hasWebGPU: boolean;
hasSIMD: boolean;
hasThreads: boolean;
estimatedMemoryMB: number;
recommendedExecutionProvider: 'webgpu' | 'webgl' | 'wasm';
recommendedThreads: number;
}
let cachedCapabilities: MLCapabilities | null = null;
export async function detectMLCapabilities(): Promise<MLCapabilities> {
if (cachedCapabilities) return cachedCapabilities;
const isDesktop = !isMobileDevice();
const hasWebGL = checkWebGLSupport();
const hasWebGPU = await checkWebGPUSupport();
const hasSIMD = checkSIMDSupport();
const hasThreads = checkThreadsSupport();
const estimatedMemoryMB = estimateAvailableMemory();
const isSupported = isDesktop &&
(hasWebGL || hasWebGPU) &&
estimatedMemoryMB >= 100;
let recommendedExecutionProvider: 'webgpu' | 'webgl' | 'wasm';
if (hasWebGPU) {
recommendedExecutionProvider = 'webgpu';
} else if (hasWebGL) {
recommendedExecutionProvider = 'webgl';
} else {
recommendedExecutionProvider = 'wasm';
}
const recommendedThreads = hasThreads
? Math.min(navigator.hardwareConcurrency || 4, 4)
: 1;
cachedCapabilities = {
isSupported,
isDesktop,
hasWebGL,
hasWebGPU,
hasSIMD,
hasThreads,
estimatedMemoryMB,
recommendedExecutionProvider,
recommendedThreads,
};
console.log('[MLCapabilities]', cachedCapabilities);
return cachedCapabilities;
}
function checkWebGLSupport(): boolean {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
return !!gl;
} catch {
return false;
}
}
async function checkWebGPUSupport(): Promise<boolean> {
try {
if (!('gpu' in navigator)) return false;
const adapter = await (navigator as Navigator & { gpu?: { requestAdapter(): Promise<unknown> } }).gpu?.requestAdapter();
return adapter !== null && adapter !== undefined;
} catch {
return false;
}
}
function checkSIMDSupport(): boolean {
try {
return typeof WebAssembly.validate === 'function' &&
WebAssembly.validate(new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123,
3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11
]));
} catch {
return false;
}
}
function checkThreadsSupport(): boolean {
return typeof SharedArrayBuffer !== 'undefined';
}
function estimateAvailableMemory(): number {
if (isMobileDevice()) return 0;
const deviceMemory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
if (deviceMemory) {
return Math.min(deviceMemory * 256, ML_THRESHOLDS.memoryBudgetMB);
}
return 256;
}
export function shouldEnableMLFeatures(): boolean {
return cachedCapabilities?.isSupported ?? false;
}
export function getMLCapabilities(): MLCapabilities | null {
return cachedCapabilities;
}
export function clearCapabilitiesCache(): void {
cachedCapabilities = null;
}

364
src/services/ml-worker.ts Normal file
View File

@@ -0,0 +1,364 @@
/**
* ML Worker Manager
* Provides typed async interface to the ML Web Worker for ONNX inference
*/
import { detectMLCapabilities, type MLCapabilities } from './ml-capabilities';
import { ML_THRESHOLDS, MODEL_CONFIGS } from '@/config/ml-config';
// Import worker using Vite's worker syntax
import MLWorkerClass from '@/workers/ml.worker?worker';
interface PendingRequest<T> {
resolve: (value: T) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
interface NEREntity {
text: string;
type: string;
confidence: number;
start: number;
end: number;
}
interface SentimentResult {
label: 'positive' | 'negative' | 'neutral';
score: number;
}
type WorkerResult =
| { type: 'worker-ready' }
| { type: 'ready'; id: string }
| { type: 'model-loaded'; id: string; modelId: string }
| { type: 'model-unloaded'; id: string; modelId: string }
| { type: 'model-progress'; modelId: string; progress: number }
| { type: 'embed-result'; id: string; embeddings: number[][] }
| { type: 'summarize-result'; id: string; summaries: string[] }
| { type: 'sentiment-result'; id: string; results: SentimentResult[] }
| { type: 'entities-result'; id: string; entities: NEREntity[][] }
| { type: 'cluster-semantic-result'; id: string; clusters: number[][] }
| { type: 'status-result'; id: string; loadedModels: string[] }
| { type: 'reset-complete' }
| { type: 'error'; id?: string; error: string };
class MLWorkerManager {
private worker: Worker | null = null;
private pendingRequests: Map<string, PendingRequest<unknown>> = new Map();
private requestIdCounter = 0;
private isReady = false;
private capabilities: MLCapabilities | null = null;
private loadedModels = new Set<string>();
private readyResolve: (() => void) | null = null;
private modelProgressCallbacks: Map<string, (progress: number) => void> = new Map();
private static readonly READY_TIMEOUT_MS = 10000;
/**
* Initialize the ML worker. Returns false if ML is not supported.
*/
async init(): Promise<boolean> {
if (this.isReady) return true;
// Detect capabilities
this.capabilities = await detectMLCapabilities();
if (!this.capabilities.isSupported) {
console.log('[MLWorker] ML features disabled (device not supported)');
return false;
}
return this.initWorker();
}
private initWorker(): Promise<boolean> {
if (this.worker) return Promise.resolve(this.isReady);
return new Promise((resolve) => {
const readyTimeout = setTimeout(() => {
if (!this.isReady) {
console.error('[MLWorker] Worker failed to become ready');
this.cleanup();
resolve(false);
}
}, MLWorkerManager.READY_TIMEOUT_MS);
try {
this.worker = new MLWorkerClass();
} catch (error) {
console.error('[MLWorker] Failed to create worker:', error);
this.cleanup();
resolve(false);
return;
}
this.worker.onmessage = (event: MessageEvent<WorkerResult>) => {
const data = event.data;
if (data.type === 'worker-ready') {
this.isReady = true;
clearTimeout(readyTimeout);
this.readyResolve?.();
resolve(true);
return;
}
if (data.type === 'model-progress') {
const callback = this.modelProgressCallbacks.get(data.modelId);
callback?.(data.progress);
return;
}
if (data.type === 'error') {
const pending = data.id ? this.pendingRequests.get(data.id) : null;
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(data.id!);
pending.reject(new Error(data.error));
} else {
console.error('[MLWorker] Error:', data.error);
}
return;
}
if ('id' in data && data.id) {
const pending = this.pendingRequests.get(data.id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(data.id);
if (data.type === 'model-loaded') {
this.loadedModels.add(data.modelId);
pending.resolve(true);
} else if (data.type === 'model-unloaded') {
this.loadedModels.delete(data.modelId);
pending.resolve(true);
} else if (data.type === 'embed-result') {
pending.resolve(data.embeddings);
} else if (data.type === 'summarize-result') {
pending.resolve(data.summaries);
} else if (data.type === 'sentiment-result') {
pending.resolve(data.results);
} else if (data.type === 'entities-result') {
pending.resolve(data.entities);
} else if (data.type === 'cluster-semantic-result') {
pending.resolve(data.clusters);
} else if (data.type === 'status-result') {
pending.resolve(data.loadedModels);
}
}
}
};
this.worker.onerror = (error) => {
console.error('[MLWorker] Error:', error);
if (!this.isReady) {
clearTimeout(readyTimeout);
this.cleanup();
resolve(false);
return;
}
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error(`Worker error: ${error.message}`));
this.pendingRequests.delete(id);
}
};
});
}
private cleanup(): void {
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.isReady = false;
this.pendingRequests.clear();
this.loadedModels.clear();
}
private generateRequestId(): string {
return `ml-${++this.requestIdCounter}-${Date.now()}`;
}
private request<T>(
type: string,
data: Record<string, unknown>,
timeoutMs = ML_THRESHOLDS.inferenceTimeoutMs
): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.worker || !this.isReady) {
reject(new Error('ML Worker not initialized'));
return;
}
const id = this.generateRequestId();
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`ML request ${type} timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
this.worker.postMessage({ type, id, ...data });
});
}
/**
* Load a model by ID
*/
async loadModel(
modelId: string,
onProgress?: (progress: number) => void
): Promise<boolean> {
if (!this.isReady) return false;
if (this.loadedModels.has(modelId)) return true;
if (onProgress) {
this.modelProgressCallbacks.set(modelId, onProgress);
}
try {
return await this.request<boolean>(
'load-model',
{ modelId },
ML_THRESHOLDS.modelLoadTimeoutMs
);
} finally {
this.modelProgressCallbacks.delete(modelId);
}
}
/**
* Unload a model to free memory
*/
async unloadModel(modelId: string): Promise<boolean> {
if (!this.isReady || !this.loadedModels.has(modelId)) return false;
return this.request<boolean>('unload-model', { modelId });
}
/**
* Unload all optional models (non-required)
*/
async unloadOptionalModels(): Promise<void> {
const optionalModels = MODEL_CONFIGS.filter(m => !m.required);
for (const model of optionalModels) {
if (this.loadedModels.has(model.id)) {
await this.unloadModel(model.id);
}
}
}
/**
* Generate embeddings for texts
*/
async embedTexts(texts: string[]): Promise<number[][]> {
if (!this.isReady) throw new Error('ML Worker not ready');
return this.request<number[][]>('embed', { texts });
}
/**
* Generate summaries for texts
*/
async summarize(texts: string[]): Promise<string[]> {
if (!this.isReady) throw new Error('ML Worker not ready');
return this.request<string[]>('summarize', { texts });
}
/**
* Classify sentiment for texts
*/
async classifySentiment(texts: string[]): Promise<SentimentResult[]> {
if (!this.isReady) throw new Error('ML Worker not ready');
return this.request<SentimentResult[]>('classify-sentiment', { texts });
}
/**
* Extract named entities from texts
*/
async extractEntities(texts: string[]): Promise<NEREntity[][]> {
if (!this.isReady) throw new Error('ML Worker not ready');
return this.request<NEREntity[][]>('extract-entities', { texts });
}
/**
* Perform semantic clustering on embeddings
*/
async semanticCluster(
embeddings: number[][],
threshold = ML_THRESHOLDS.semanticClusterThreshold
): Promise<number[][]> {
if (!this.isReady) throw new Error('ML Worker not ready');
return this.request<number[][]>('cluster-semantic', { embeddings, threshold });
}
/**
* High-level: Cluster items by semantic similarity
*/
async clusterBySemanticSimilarity(
items: Array<{ id: string; text: string }>,
threshold = ML_THRESHOLDS.semanticClusterThreshold
): Promise<string[][]> {
const embeddings = await this.embedTexts(items.map(i => i.text));
const clusterIndices = await this.semanticCluster(embeddings, threshold);
return clusterIndices.map(cluster =>
cluster.map(idx => items[idx]?.id).filter((id): id is string => id !== undefined)
);
}
/**
* Get status of loaded models
*/
async getStatus(): Promise<string[]> {
if (!this.isReady) return [];
return this.request<string[]>('status', {});
}
/**
* Reset the worker (unload all models)
*/
reset(): void {
if (this.worker) {
this.worker.postMessage({ type: 'reset' });
this.loadedModels.clear();
}
}
/**
* Terminate the worker completely
*/
terminate(): void {
this.cleanup();
}
/**
* Check if ML features are available
*/
get isAvailable(): boolean {
return this.isReady && (this.capabilities?.isSupported ?? false);
}
/**
* Get detected capabilities
*/
get mlCapabilities(): MLCapabilities | null {
return this.capabilities;
}
/**
* Get list of currently loaded models
*/
get loadedModelIds(): string[] {
return Array.from(this.loadedModels);
}
}
// Export singleton instance
export const mlWorker = new MLWorkerManager();

View File

@@ -0,0 +1,583 @@
/**
* Parallel Analysis Service
* Runs browser-based ML alongside API summarization
* Multiple "perspectives" score headlines independently
* Logs analysis to console for comparison & improvement
*/
import { mlWorker } from './ml-worker';
import type { ClusteredEvent } from '@/types';
interface NEREntity {
text: string;
type: string;
confidence: number;
}
export interface PerspectiveScore {
name: string;
score: number;
confidence: number;
reasoning: string;
}
export interface AnalyzedHeadline {
id: string;
title: string;
sourceCount: number;
perspectives: PerspectiveScore[];
finalScore: number;
confidence: number;
disagreement: number;
flagged: boolean;
flagReason?: string;
}
export interface AnalysisReport {
timestamp: number;
totalHeadlines: number;
analyzed: AnalyzedHeadline[];
topByConsensus: AnalyzedHeadline[];
topByDisagreement: AnalyzedHeadline[];
missedByKeywords: AnalyzedHeadline[];
perspectiveCorrelations: Record<string, number>;
}
const VIOLENCE_KEYWORDS = [
'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter',
'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution',
'crackdown', 'violent', 'clashes', 'gunfire', 'shooting',
];
const MILITARY_KEYWORDS = [
'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops',
'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship',
'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack',
];
const UNREST_KEYWORDS = [
'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots',
'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow',
'coup', 'martial law', 'curfew', 'shutdown', 'blackout',
];
const FLASHPOINT_KEYWORDS = [
'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv',
'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus',
'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner',
];
const BUSINESS_DEMOTE = [
'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue',
'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation',
];
class ParallelAnalysisService {
private lastReport: AnalysisReport | null = null;
private recentEmbeddings: Map<string, number[]> = new Map();
private analysisCount = 0;
async analyzeHeadlines(clusters: ClusteredEvent[]): Promise<AnalysisReport> {
const startTime = performance.now();
this.analysisCount++;
console.group(`%c🔬 Parallel Analysis #${this.analysisCount}`, 'color: #6b8afd; font-weight: bold; font-size: 14px');
console.log(`%cAnalyzing ${clusters.length} headlines...`, 'color: #888');
const analyzed: AnalyzedHeadline[] = [];
const titles = clusters.map(c => c.primaryTitle);
let sentiments: Array<{ label: string; score: number }> | null = null;
let entities: NEREntity[][] | null = null;
let embeddings: number[][] | null = null;
if (mlWorker.isAvailable) {
const [s, e, emb] = await Promise.all([
mlWorker.classifySentiment(titles).catch(() => null),
mlWorker.extractEntities(titles).catch(() => null),
this.getEmbeddings(titles).catch(() => null),
]);
sentiments = s;
entities = e;
embeddings = emb;
console.log(`%c✓ ML models loaded`, 'color: #4ade80');
if (entities) console.log(`%c → NER extracted ${entities.flat().length} entities`, 'color: #888');
if (embeddings) console.log(`%c → Embeddings computed for ${embeddings.length} headlines`, 'color: #888');
} else {
console.log(`%c⚠ ML not available, using keyword-only analysis`, 'color: #f59e0b');
}
for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i]!;
const title = cluster.primaryTitle;
const titleLower = title.toLowerCase();
const perspectives: PerspectiveScore[] = [];
perspectives.push(this.scoreByKeywords(titleLower, cluster));
const sentiment = sentiments?.[i];
if (sentiment) {
perspectives.push(this.scoreBySentiment(sentiment));
}
const entityList = entities?.[i];
if (entityList) {
perspectives.push(this.scoreByEntities(entityList));
}
const embedding = embeddings?.[i];
if (embedding) {
perspectives.push(await this.scoreByNovelty(title, embedding));
}
perspectives.push(this.scoreByVelocity(cluster));
perspectives.push(this.scoreBySourceDiversity(cluster));
const { finalScore, confidence, disagreement } = this.aggregateScores(perspectives);
const flagged = disagreement > 0.3 || (finalScore > 0.5 && this.isLowKeywordScore(perspectives));
const flagReason = flagged
? disagreement > 0.3
? 'High disagreement between perspectives'
: 'ML scores high but keyword score low - potential missed story'
: undefined;
analyzed.push({
id: cluster.id,
title,
sourceCount: cluster.sourceCount,
perspectives,
finalScore,
confidence,
disagreement,
flagged,
flagReason,
});
}
analyzed.sort((a, b) => b.finalScore - a.finalScore);
const topByConsensus = analyzed
.filter(a => a.confidence > 0.6)
.slice(0, 10);
const topByDisagreement = analyzed
.filter(a => a.disagreement > 0.25)
.sort((a, b) => b.disagreement - a.disagreement)
.slice(0, 5);
const missedByKeywords = analyzed
.filter(a => {
const keywordScore = a.perspectives.find(p => p.name === 'keywords')?.score ?? 0;
const mlAvg = a.perspectives
.filter(p => p.name !== 'keywords')
.reduce((sum, p) => sum + p.score, 0) / Math.max(1, a.perspectives.length - 1);
return mlAvg > 0.5 && keywordScore < 0.3;
})
.slice(0, 5);
const correlations = this.calculateCorrelations(analyzed);
const report: AnalysisReport = {
timestamp: Date.now(),
totalHeadlines: clusters.length,
analyzed,
topByConsensus,
topByDisagreement,
missedByKeywords,
perspectiveCorrelations: correlations,
};
this.lastReport = report;
this.logReport(report, performance.now() - startTime);
console.groupEnd();
return report;
}
private scoreByKeywords(titleLower: string, _cluster: ClusteredEvent): PerspectiveScore {
let score = 0;
const reasons: string[] = [];
const violence = VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw));
if (violence.length > 0) {
score += 0.4 + violence.length * 0.1;
reasons.push(`violence(${violence.join(',')})`);
}
const military = MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw));
if (military.length > 0) {
score += 0.3 + military.length * 0.08;
reasons.push(`military(${military.join(',')})`);
}
const unrest = UNREST_KEYWORDS.filter(kw => titleLower.includes(kw));
if (unrest.length > 0) {
score += 0.25 + unrest.length * 0.07;
reasons.push(`unrest(${unrest.join(',')})`);
}
const flashpoint = FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw));
if (flashpoint.length > 0) {
score += 0.2 + flashpoint.length * 0.05;
reasons.push(`flashpoint(${flashpoint.join(',')})`);
}
if ((violence.length > 0 || unrest.length > 0) && flashpoint.length > 0) {
score *= 1.3;
reasons.push('combo-bonus');
}
const business = BUSINESS_DEMOTE.filter(kw => titleLower.includes(kw));
if (business.length > 0) {
score *= 0.4;
reasons.push(`demoted(${business.join(',')})`);
}
score = Math.min(1, score);
return {
name: 'keywords',
score,
confidence: 0.8,
reasoning: reasons.length > 0 ? reasons.join(' + ') : 'no keywords matched',
};
}
private scoreBySentiment(sentiment: { label: string; score: number }): PerspectiveScore {
const isNegative = sentiment.label === 'negative';
const score = isNegative ? sentiment.score * 0.8 : (1 - sentiment.score) * 0.3;
return {
name: 'sentiment',
score: Math.min(1, score),
confidence: sentiment.score,
reasoning: `${sentiment.label} (${(sentiment.score * 100).toFixed(0)}%) - negative news more important`,
};
}
private scoreByEntities(entities: NEREntity[]): PerspectiveScore {
const locations = entities.filter(e => e.type.includes('LOC'));
const people = entities.filter(e => e.type.includes('PER'));
const orgs = entities.filter(e => e.type.includes('ORG'));
const geopoliticalLocations = locations.filter(e =>
FLASHPOINT_KEYWORDS.some(fp => e.text.toLowerCase().includes(fp))
);
let score = 0;
const reasons: string[] = [];
if (geopoliticalLocations.length > 0) {
score += 0.4;
reasons.push(`geo-locations(${geopoliticalLocations.map(e => e.text).join(',')})`);
} else if (locations.length > 0) {
score += 0.15;
reasons.push(`locations(${locations.length})`);
}
if (people.length > 0) {
score += 0.1 + people.length * 0.05;
reasons.push(`people(${people.map(e => e.text).join(',')})`);
}
if (orgs.length > 0) {
score += 0.1 + orgs.length * 0.05;
reasons.push(`orgs(${orgs.map(e => e.text).join(',')})`);
}
const entityDensity = entities.length;
if (entityDensity > 3) {
score += 0.15;
reasons.push(`high-density(${entityDensity})`);
}
return {
name: 'entities',
score: Math.min(1, score),
confidence: entities.length > 0 ? 0.7 : 0.3,
reasoning: reasons.length > 0 ? reasons.join(' + ') : 'no significant entities',
};
}
private async scoreByNovelty(title: string, embedding: number[]): Promise<PerspectiveScore> {
let maxSimilarity = 0;
let mostSimilar = '';
for (const [recentTitle, recentEmb] of this.recentEmbeddings) {
if (recentTitle === title) continue;
const similarity = this.cosineSimilarity(embedding, recentEmb);
if (similarity > maxSimilarity) {
maxSimilarity = similarity;
mostSimilar = recentTitle.slice(0, 50);
}
}
this.recentEmbeddings.set(title, embedding);
if (this.recentEmbeddings.size > 100) {
const firstKey = this.recentEmbeddings.keys().next().value;
if (firstKey) this.recentEmbeddings.delete(firstKey);
}
const noveltyScore = 1 - maxSimilarity;
const importanceBoost = noveltyScore > 0.5 ? 0.3 : 0;
return {
name: 'novelty',
score: Math.min(1, noveltyScore * 0.7 + importanceBoost),
confidence: 0.6,
reasoning: maxSimilarity > 0.7
? `similar to: "${mostSimilar}..." (${(maxSimilarity * 100).toFixed(0)}%)`
: `novel content (${(noveltyScore * 100).toFixed(0)}% unique)`,
};
}
private scoreByVelocity(cluster: ClusteredEvent): PerspectiveScore {
const velocity = cluster.velocity;
let score = 0;
let reasoning = '';
if (!velocity || velocity.level === 'normal') {
score = 0.2;
reasoning = 'normal velocity';
} else if (velocity.level === 'elevated') {
score = 0.5;
reasoning = `elevated: +${velocity.sourcesPerHour}/hr`;
} else if (velocity.level === 'spike') {
score = 0.7;
reasoning = `spike: +${velocity.sourcesPerHour}/hr`;
} else if (velocity.level === 'viral') {
score = 0.9;
reasoning = `viral: +${velocity.sourcesPerHour}/hr`;
}
if (velocity?.trend === 'rising') {
score += 0.1;
reasoning += ' ↑';
}
return {
name: 'velocity',
score: Math.min(1, score),
confidence: 0.8,
reasoning,
};
}
private scoreBySourceDiversity(cluster: ClusteredEvent): PerspectiveScore {
const sources = cluster.sourceCount;
let score = 0;
let reasoning = '';
if (sources >= 5) {
score = 0.9;
reasoning = `${sources} sources - highly confirmed`;
} else if (sources >= 3) {
score = 0.7;
reasoning = `${sources} sources - confirmed`;
} else if (sources >= 2) {
score = 0.5;
reasoning = `${sources} sources - multi-source`;
} else {
score = 0.2;
reasoning = 'single source';
}
return {
name: 'sources',
score,
confidence: 0.9,
reasoning,
};
}
private aggregateScores(perspectives: PerspectiveScore[]): {
finalScore: number;
confidence: number;
disagreement: number;
} {
if (perspectives.length === 0) {
return { finalScore: 0, confidence: 0, disagreement: 0 };
}
const weights: Record<string, number> = {
keywords: 0.25,
sentiment: 0.15,
entities: 0.20,
novelty: 0.10,
velocity: 0.15,
sources: 0.15,
};
let weightedSum = 0;
let totalWeight = 0;
let confidenceSum = 0;
for (const p of perspectives) {
const weight = weights[p.name] ?? 0.1;
weightedSum += p.score * weight * p.confidence;
totalWeight += weight;
confidenceSum += p.confidence;
}
const finalScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
const avgConfidence = confidenceSum / perspectives.length;
const scores = perspectives.map(p => p.score);
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
const variance = scores.reduce((sum, s) => sum + Math.pow(s - mean, 2), 0) / scores.length;
const disagreement = Math.sqrt(variance);
return {
finalScore,
confidence: avgConfidence * (1 - disagreement * 0.5),
disagreement,
};
}
private isLowKeywordScore(perspectives: PerspectiveScore[]): boolean {
const keywordScore = perspectives.find(p => p.name === 'keywords')?.score ?? 0;
return keywordScore < 0.3;
}
private calculateCorrelations(analyzed: AnalyzedHeadline[]): Record<string, number> {
const perspectiveNames = ['keywords', 'sentiment', 'entities', 'novelty', 'velocity', 'sources'];
const correlations: Record<string, number> = {};
for (let i = 0; i < perspectiveNames.length; i++) {
for (let j = i + 1; j < perspectiveNames.length; j++) {
const name1 = perspectiveNames[i];
const name2 = perspectiveNames[j];
const scores1 = analyzed.map(a => a.perspectives.find(p => p.name === name1)?.score ?? 0);
const scores2 = analyzed.map(a => a.perspectives.find(p => p.name === name2)?.score ?? 0);
const correlation = this.pearsonCorrelation(scores1, scores2);
correlations[`${name1}-${name2}`] = correlation;
}
}
return correlations;
}
private pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0) return 0;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let num = 0;
let denX = 0;
let denY = 0;
for (let i = 0; i < n; i++) {
const xi = x[i] ?? 0;
const yi = y[i] ?? 0;
const dx = xi - meanX;
const dy = yi - meanY;
num += dx * dy;
denX += dx * dx;
denY += dy * dy;
}
const den = Math.sqrt(denX * denY);
return den === 0 ? 0 : num / den;
}
private cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
const ai = a[i] ?? 0;
const bi = b[i] ?? 0;
dot += ai * bi;
normA += ai * ai;
normB += bi * bi;
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
private async getEmbeddings(titles: string[]): Promise<number[][]> {
return mlWorker.embedTexts(titles);
}
private logReport(report: AnalysisReport, durationMs: number): void {
console.log(`%c⏱ Analysis completed in ${durationMs.toFixed(0)}ms`, 'color: #888');
console.log('\n%c📊 TOP BY CONSENSUS (high confidence)', 'color: #4ade80; font-weight: bold');
console.table(report.topByConsensus.slice(0, 5).map(h => ({
score: h.finalScore.toFixed(2),
conf: h.confidence.toFixed(2),
title: h.title.slice(0, 60) + (h.title.length > 60 ? '...' : ''),
topPerspective: h.perspectives.sort((a, b) => b.score - a.score)[0]?.name,
})));
if (report.topByDisagreement.length > 0) {
console.log('\n%c⚠ HIGH DISAGREEMENT (perspectives conflict)', 'color: #f59e0b; font-weight: bold');
console.table(report.topByDisagreement.map(h => ({
disagreement: h.disagreement.toFixed(2),
title: h.title.slice(0, 50) + '...',
perspectives: h.perspectives.map(p => `${p.name}:${p.score.toFixed(1)}`).join(' | '),
})));
}
if (report.missedByKeywords.length > 0) {
console.log('\n%c🎯 POTENTIALLY MISSED (ML says important, keywords say no)', 'color: #ef4444; font-weight: bold');
report.missedByKeywords.forEach(h => {
const keywordScore = h.perspectives.find(p => p.name === 'keywords')?.score ?? 0;
const mlScores = h.perspectives.filter(p => p.name !== 'keywords');
console.log(` %c"${h.title.slice(0, 70)}..."`, 'color: #fff');
console.log(` keywords: ${(keywordScore * 100).toFixed(0)}% | ML avg: ${(mlScores.reduce((s, p) => s + p.score, 0) / mlScores.length * 100).toFixed(0)}%`);
mlScores.forEach(p => {
console.log(` ${p.name}: ${(p.score * 100).toFixed(0)}% - ${p.reasoning}`);
});
});
}
console.log('\n%c📈 Perspective Correlations', 'color: #6b8afd');
const sortedCorr = Object.entries(report.perspectiveCorrelations)
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
sortedCorr.slice(0, 5).forEach(([pair, corr]) => {
const color = corr > 0.5 ? '#4ade80' : corr < -0.2 ? '#ef4444' : '#888';
console.log(` %c${pair}: ${corr.toFixed(2)}`, `color: ${color}`);
});
}
getLastReport(): AnalysisReport | null {
return this.lastReport;
}
getSuggestedImprovements(): string[] {
if (!this.lastReport) return [];
const suggestions: string[] = [];
if (this.lastReport.missedByKeywords.length > 2) {
suggestions.push('Consider adding more keywords to capture ML-detected important stories');
}
const avgDisagreement = this.lastReport.analyzed
.reduce((sum, a) => sum + a.disagreement, 0) / this.lastReport.analyzed.length;
if (avgDisagreement > 0.25) {
suggestions.push('High average disagreement - perspectives may need rebalancing');
}
const { perspectiveCorrelations } = this.lastReport;
const keywordSentiment = perspectiveCorrelations['keywords-sentiment'] ?? 0;
if (keywordSentiment < 0.3) {
suggestions.push('Low keyword-sentiment correlation - keyword list may be missing emotional content');
}
return suggestions;
}
}
export const parallelAnalysis = new ParallelAnalysisService();

View File

@@ -1,5 +1,5 @@
import type { Feed, NewsItem } from '@/types';
import { ALERT_KEYWORDS } from '@/config';
import { ALERT_KEYWORDS, ALERT_EXCLUSIONS } from '@/config';
import { chunkArray, fetchWithProxy } from '@/utils';
// Per-feed circuit breaker: track failures and cooldowns
@@ -127,9 +127,11 @@ export async function fetchFeed(feed: Feed): Promise<NewsItem[]> {
: (item.querySelector('pubDate')?.textContent || '');
const pubDate = pubDateStr ? new Date(pubDateStr) : new Date();
const isAlert = ALERT_KEYWORDS.some((kw) =>
title.toLowerCase().includes(kw)
);
// Check for alert keywords, but exclude lifestyle/entertainment content
const titleLower = title.toLowerCase();
const hasAlertKeyword = ALERT_KEYWORDS.some((kw) => titleLower.includes(kw));
const hasExclusion = ALERT_EXCLUSIONS.some((ex) => titleLower.includes(ex));
const isAlert = hasAlertKeyword && !hasExclusion;
return {
source: feed.name,

View File

@@ -0,0 +1,412 @@
/**
* Signal Aggregator Service
* Collects all map signals and correlates them by country/region
* Feeds geographic context to AI Insights
*/
import type {
InternetOutage,
MilitaryFlight,
MilitaryVessel,
SocialUnrestEvent,
AisDisruptionEvent,
} from '@/types';
import { TIER1_COUNTRIES } from '@/config/countries';
export type SignalType =
| 'internet_outage'
| 'military_flight'
| 'military_vessel'
| 'protest'
| 'ais_disruption';
export interface GeoSignal {
type: SignalType;
country: string;
countryName: string;
lat: number;
lon: number;
severity: 'low' | 'medium' | 'high';
title: string;
timestamp: Date;
}
export interface CountrySignalCluster {
country: string;
countryName: string;
signals: GeoSignal[];
signalTypes: Set<SignalType>;
totalCount: number;
highSeverityCount: number;
convergenceScore: number;
}
export interface RegionalConvergence {
region: string;
countries: string[];
signalTypes: SignalType[];
totalSignals: number;
description: string;
}
export interface SignalSummary {
timestamp: Date;
totalSignals: number;
byType: Record<SignalType, number>;
convergenceZones: RegionalConvergence[];
topCountries: CountrySignalCluster[];
aiContext: string;
}
const REGION_DEFINITIONS: Record<string, { countries: string[]; name: string }> = {
middle_east: {
name: 'Middle East',
countries: ['IR', 'IL', 'SA', 'AE', 'IQ', 'SY', 'YE', 'JO', 'LB', 'KW', 'QA', 'OM', 'BH'],
},
east_asia: {
name: 'East Asia',
countries: ['CN', 'TW', 'JP', 'KR', 'KP', 'HK', 'MN'],
},
south_asia: {
name: 'South Asia',
countries: ['IN', 'PK', 'BD', 'AF', 'NP', 'LK', 'MM'],
},
europe_east: {
name: 'Eastern Europe',
countries: ['UA', 'RU', 'BY', 'PL', 'RO', 'MD', 'HU', 'CZ', 'SK', 'BG'],
},
africa_north: {
name: 'North Africa',
countries: ['EG', 'LY', 'DZ', 'TN', 'MA', 'SD', 'SS'],
},
africa_sahel: {
name: 'Sahel Region',
countries: ['ML', 'NE', 'BF', 'TD', 'NG', 'CM', 'CF'],
},
};
const COUNTRY_TO_CODE: Record<string, string> = {
'Iran': 'IR', 'Israel': 'IL', 'Saudi Arabia': 'SA', 'United Arab Emirates': 'AE',
'Iraq': 'IQ', 'Syria': 'SY', 'Yemen': 'YE', 'Jordan': 'JO', 'Lebanon': 'LB',
'China': 'CN', 'Taiwan': 'TW', 'Japan': 'JP', 'South Korea': 'KR', 'North Korea': 'KP',
'India': 'IN', 'Pakistan': 'PK', 'Bangladesh': 'BD', 'Afghanistan': 'AF',
'Ukraine': 'UA', 'Russia': 'RU', 'Belarus': 'BY', 'Poland': 'PL',
'Egypt': 'EG', 'Libya': 'LY', 'Sudan': 'SD', 'South Sudan': 'SS',
'United States': 'US', 'United Kingdom': 'GB', 'Germany': 'DE', 'France': 'FR',
};
function normalizeCountryCode(country: string): string {
if (country.length === 2) return country.toUpperCase();
return COUNTRY_TO_CODE[country] || country.slice(0, 2).toUpperCase();
}
function getCountryName(code: string): string {
return TIER1_COUNTRIES[code] || code;
}
class SignalAggregator {
private signals: GeoSignal[] = [];
private readonly WINDOW_MS = 24 * 60 * 60 * 1000;
private clearSignalType(type: SignalType): void {
this.signals = this.signals.filter(s => s.type !== type);
}
ingestOutages(outages: InternetOutage[]): void {
this.clearSignalType('internet_outage');
for (const o of outages) {
const code = normalizeCountryCode(o.country);
this.signals.push({
type: 'internet_outage',
country: code,
countryName: o.country,
lat: o.lat,
lon: o.lon,
severity: o.severity === 'total' ? 'high' : o.severity === 'major' ? 'medium' : 'low',
title: o.title,
timestamp: o.pubDate,
});
}
this.pruneOld();
}
ingestFlights(flights: MilitaryFlight[]): void {
this.clearSignalType('military_flight');
const countryCounts = new Map<string, number>();
for (const f of flights) {
const code = this.coordsToCountry(f.lat, f.lon);
const count = countryCounts.get(code) || 0;
countryCounts.set(code, count + 1);
}
for (const [code, count] of countryCounts) {
this.signals.push({
type: 'military_flight',
country: code,
countryName: getCountryName(code),
lat: 0,
lon: 0,
severity: count >= 10 ? 'high' : count >= 5 ? 'medium' : 'low',
title: `${count} military aircraft detected`,
timestamp: new Date(),
});
}
this.pruneOld();
}
ingestVessels(vessels: MilitaryVessel[]): void {
this.clearSignalType('military_vessel');
const regionCounts = new Map<string, { count: number; lat: number; lon: number }>();
for (const v of vessels) {
const code = this.coordsToCountry(v.lat, v.lon);
const existing = regionCounts.get(code);
if (existing) {
existing.count++;
} else {
regionCounts.set(code, { count: 1, lat: v.lat, lon: v.lon });
}
}
for (const [code, data] of regionCounts) {
this.signals.push({
type: 'military_vessel',
country: code,
countryName: getCountryName(code),
lat: data.lat,
lon: data.lon,
severity: data.count >= 5 ? 'high' : data.count >= 2 ? 'medium' : 'low',
title: `${data.count} naval vessels near region`,
timestamp: new Date(),
});
}
this.pruneOld();
}
ingestProtests(events: SocialUnrestEvent[]): void {
this.clearSignalType('protest');
const countryCounts = new Map<string, { count: number; lat: number; lon: number }>();
for (const e of events) {
const code = normalizeCountryCode(e.country) || this.coordsToCountry(e.lat, e.lon);
const existing = countryCounts.get(code);
if (existing) {
existing.count++;
} else {
countryCounts.set(code, { count: 1, lat: e.lat, lon: e.lon });
}
}
for (const [code, data] of countryCounts) {
this.signals.push({
type: 'protest',
country: code,
countryName: getCountryName(code),
lat: data.lat,
lon: data.lon,
severity: data.count >= 10 ? 'high' : data.count >= 5 ? 'medium' : 'low',
title: `${data.count} protest events`,
timestamp: new Date(),
});
}
this.pruneOld();
}
ingestAisDisruptions(events: AisDisruptionEvent[]): void {
this.clearSignalType('ais_disruption');
for (const e of events) {
const code = this.coordsToCountry(e.lat, e.lon);
// Map 'elevated' to 'medium' for our type
const severity: 'low' | 'medium' | 'high' = e.severity === 'elevated' ? 'medium' : e.severity;
this.signals.push({
type: 'ais_disruption',
country: code,
countryName: e.name,
lat: e.lat,
lon: e.lon,
severity,
title: e.description,
timestamp: new Date(),
});
}
this.pruneOld();
}
private coordsToCountry(lat: number, lon: number): string {
if (lat >= 25 && lat <= 40 && lon >= 44 && lon <= 63) return 'IR';
if (lat >= 29 && lat <= 33 && lon >= 34 && lon <= 36) return 'IL';
if (lat >= 15 && lat <= 32 && lon >= 34 && lon <= 55) return 'SA';
if (lat >= 20 && lat <= 55 && lon >= 73 && lon <= 135) return 'CN';
if (lat >= 22 && lat <= 25 && lon >= 120 && lon <= 122) return 'TW';
if (lat >= 8 && lat <= 37 && lon >= 68 && lon <= 97) return 'IN';
if (lat >= 44 && lat <= 52 && lon >= 22 && lon <= 40) return 'UA';
if (lat >= 50 && lat <= 82 && lon >= 20 && lon <= 180) return 'RU';
if (lat >= 22 && lat <= 32 && lon >= 25 && lon <= 35) return 'EG';
return 'XX';
}
private pruneOld(): void {
const cutoff = Date.now() - this.WINDOW_MS;
this.signals = this.signals.filter(s => s.timestamp.getTime() > cutoff);
}
getCountryClusters(): CountrySignalCluster[] {
const byCountry = new Map<string, GeoSignal[]>();
for (const s of this.signals) {
const existing = byCountry.get(s.country) || [];
existing.push(s);
byCountry.set(s.country, existing);
}
const clusters: CountrySignalCluster[] = [];
for (const [country, signals] of byCountry) {
const signalTypes = new Set(signals.map(s => s.type));
const highCount = signals.filter(s => s.severity === 'high').length;
const typeBonus = signalTypes.size * 20;
const countBonus = Math.min(30, signals.length * 5);
const severityBonus = highCount * 10;
const convergenceScore = Math.min(100, typeBonus + countBonus + severityBonus);
clusters.push({
country,
countryName: getCountryName(country),
signals,
signalTypes,
totalCount: signals.length,
highSeverityCount: highCount,
convergenceScore,
});
}
return clusters.sort((a, b) => b.convergenceScore - a.convergenceScore);
}
getRegionalConvergence(): RegionalConvergence[] {
const clusters = this.getCountryClusters();
const convergences: RegionalConvergence[] = [];
for (const [_regionId, def] of Object.entries(REGION_DEFINITIONS)) {
const regionClusters = clusters.filter(c => def.countries.includes(c.country));
if (regionClusters.length < 2) continue;
const allTypes = new Set<SignalType>();
let totalSignals = 0;
for (const cluster of regionClusters) {
cluster.signalTypes.forEach(t => allTypes.add(t));
totalSignals += cluster.totalCount;
}
if (allTypes.size >= 2) {
const typeLabels: Record<SignalType, string> = {
internet_outage: 'internet disruptions',
military_flight: 'military air activity',
military_vessel: 'naval presence',
protest: 'civil unrest',
ais_disruption: 'shipping anomalies',
};
const typeDescriptions = [...allTypes].map(t => typeLabels[t]).join(', ');
const countries = regionClusters.map(c => c.countryName).join(', ');
convergences.push({
region: def.name,
countries: regionClusters.map(c => c.country),
signalTypes: [...allTypes],
totalSignals,
description: `${def.name}: ${typeDescriptions} detected across ${countries}`,
});
}
}
return convergences.sort((a, b) => b.signalTypes.length - a.signalTypes.length);
}
generateAIContext(): string {
const clusters = this.getCountryClusters().slice(0, 5);
const convergences = this.getRegionalConvergence().slice(0, 3);
if (clusters.length === 0 && convergences.length === 0) {
return '';
}
const lines: string[] = ['[GEOGRAPHIC SIGNALS]'];
if (convergences.length > 0) {
lines.push('Regional convergence detected:');
for (const c of convergences) {
lines.push(`- ${c.description}`);
}
}
if (clusters.length > 0) {
lines.push('Top countries by signal activity:');
for (const c of clusters) {
const types = [...c.signalTypes].join(', ');
lines.push(`- ${c.countryName}: ${c.totalCount} signals (${types}), convergence score: ${c.convergenceScore}`);
}
}
return lines.join('\n');
}
getSummary(): SignalSummary {
const byType: Record<SignalType, number> = {
internet_outage: 0,
military_flight: 0,
military_vessel: 0,
protest: 0,
ais_disruption: 0,
};
for (const s of this.signals) {
byType[s.type]++;
}
return {
timestamp: new Date(),
totalSignals: this.signals.length,
byType,
convergenceZones: this.getRegionalConvergence(),
topCountries: this.getCountryClusters().slice(0, 10),
aiContext: this.generateAIContext(),
};
}
clear(): void {
this.signals = [];
}
getSignalCount(): number {
return this.signals.length;
}
}
export const signalAggregator = new SignalAggregator();
export function logSignalSummary(): void {
const summary = signalAggregator.getSummary();
console.group('%c[Signal Aggregator]', 'color: #6b8afd; font-weight: bold');
console.log(`Total signals: ${summary.totalSignals}`);
console.log('By type:', summary.byType);
if (summary.convergenceZones.length > 0) {
console.log('%cConvergence Zones:', 'color: #f59e0b; font-weight: bold');
for (const z of summary.convergenceZones) {
console.log(` ${z.description}`);
}
}
if (summary.topCountries.length > 0) {
console.log('%cTop Countries:', 'color: #4ade80; font-weight: bold');
for (const c of summary.topCountries.slice(0, 5)) {
console.log(` ${c.countryName}: ${c.totalCount} signals, score ${c.convergenceScore}`);
}
}
console.groupEnd();
}

View File

@@ -0,0 +1,143 @@
/**
* Summarization Service with Fallback Chain
* Server-side Redis caching handles cross-user deduplication
* Fallback: Groq -> OpenRouter -> Browser T5
*/
import { mlWorker } from './ml-worker';
export type SummarizationProvider = 'groq' | 'openrouter' | 'browser' | 'cache';
export interface SummarizationResult {
summary: string;
provider: SummarizationProvider;
cached: boolean;
}
export type ProgressCallback = (step: number, total: number, message: string) => void;
async function tryGroq(headlines: string[], geoContext?: string): Promise<SummarizationResult | null> {
try {
const response = await fetch('/api/groq-summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ headlines, mode: 'brief', geoContext }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (data.fallback) return null;
throw new Error(`Groq error: ${response.status}`);
}
const data = await response.json();
const provider = data.cached ? 'cache' : 'groq';
console.log(`[Summarization] ${provider === 'cache' ? 'Redis cache hit' : 'Groq success'}:`, data.model);
return {
summary: data.summary,
provider: provider as SummarizationProvider,
cached: !!data.cached,
};
} catch (error) {
console.warn('[Summarization] Groq failed:', error);
return null;
}
}
async function tryOpenRouter(headlines: string[], geoContext?: string): Promise<SummarizationResult | null> {
try {
const response = await fetch('/api/openrouter-summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ headlines, mode: 'brief', geoContext }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (data.fallback) return null;
throw new Error(`OpenRouter error: ${response.status}`);
}
const data = await response.json();
const provider = data.cached ? 'cache' : 'openrouter';
console.log(`[Summarization] ${provider === 'cache' ? 'Redis cache hit' : 'OpenRouter success'}:`, data.model);
return {
summary: data.summary,
provider: provider as SummarizationProvider,
cached: !!data.cached,
};
} catch (error) {
console.warn('[Summarization] OpenRouter failed:', error);
return null;
}
}
async function tryBrowserT5(headlines: string[]): Promise<SummarizationResult | null> {
try {
if (!mlWorker.isAvailable) {
console.log('[Summarization] Browser ML not available');
return null;
}
const combinedText = headlines.slice(0, 6).map(h => h.slice(0, 80)).join('. ');
const prompt = `Summarize the main themes from these news headlines in 2 sentences: ${combinedText}`;
const [summary] = await mlWorker.summarize([prompt]);
if (!summary || summary.length < 20 || summary.toLowerCase().includes('summarize')) {
return null;
}
console.log('[Summarization] Browser T5 success');
return {
summary,
provider: 'browser',
cached: false,
};
} catch (error) {
console.warn('[Summarization] Browser T5 failed:', error);
return null;
}
}
/**
* Generate a summary using the fallback chain: Groq -> OpenRouter -> Browser T5
* Server-side Redis caching is handled by the API endpoints
* @param geoContext Optional geographic signal context to include in the prompt
*/
export async function generateSummary(
headlines: string[],
onProgress?: ProgressCallback,
geoContext?: string
): Promise<SummarizationResult | null> {
if (!headlines || headlines.length < 2) {
return null;
}
const totalSteps = 3;
// Step 1: Try Groq (fast, 14.4K/day with 8b-instant + Redis cache)
onProgress?.(1, totalSteps, 'Connecting to Groq AI...');
const groqResult = await tryGroq(headlines, geoContext);
if (groqResult) {
return groqResult;
}
// Step 2: Try OpenRouter (fallback, 50/day + Redis cache)
onProgress?.(2, totalSteps, 'Trying OpenRouter...');
const openRouterResult = await tryOpenRouter(headlines, geoContext);
if (openRouterResult) {
return openRouterResult;
}
// Step 3: Try Browser T5 (local, unlimited but slower)
onProgress?.(3, totalSteps, 'Loading local AI model...');
const browserResult = await tryBrowserT5(headlines);
if (browserResult) {
return browserResult;
}
console.warn('[Summarization] All providers failed');
return null;
}

View File

@@ -1,4 +1,5 @@
import type { ClusteredEvent, VelocityMetrics, VelocityLevel, SentimentType } from '@/types';
import { mlWorker } from './ml-worker';
const HOUR_MS = 60 * 60 * 1000;
const ELEVATED_THRESHOLD = 3;
@@ -82,3 +83,60 @@ export function enrichWithVelocity(clusters: ClusteredEvent[]): ClusteredEvent[]
velocity: calculateVelocity(cluster),
}));
}
export async function calculateVelocityWithML(cluster: ClusteredEvent): Promise<VelocityMetrics> {
const baseMetrics = calculateVelocity(cluster);
if (!mlWorker.isAvailable) return baseMetrics;
try {
const results = await mlWorker.classifySentiment([cluster.primaryTitle]);
const sentiment = results[0];
if (!sentiment) return baseMetrics;
const mlSentiment: SentimentType = sentiment.label === 'positive' ? 'positive' :
sentiment.label === 'negative' ? 'negative' : 'neutral';
const mlScore = sentiment.label === 'negative' ? -sentiment.score : sentiment.score;
return {
...baseMetrics,
sentiment: mlSentiment,
sentimentScore: mlScore,
};
} catch {
return baseMetrics;
}
}
export async function enrichWithVelocityML(clusters: ClusteredEvent[]): Promise<ClusteredEvent[]> {
if (!mlWorker.isAvailable) {
return enrichWithVelocity(clusters);
}
try {
const titles = clusters.map(c => c.primaryTitle);
const sentiments = await mlWorker.classifySentiment(titles);
return clusters.map((cluster, i) => {
const baseMetrics = calculateVelocity(cluster);
const sentiment = sentiments[i];
if (!sentiment) {
return { ...cluster, velocity: baseMetrics };
}
const mlSentiment: SentimentType = sentiment.label === 'positive' ? 'positive' :
sentiment.label === 'negative' ? 'negative' : 'neutral';
return {
...cluster,
velocity: {
...baseMetrics,
sentiment: mlSentiment,
sentimentScore: sentiment.label === 'negative' ? -sentiment.score : sentiment.score,
},
};
});
} catch {
return enrichWithVelocity(clusters);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -397,7 +397,6 @@ export interface MapLayers {
sanctions: boolean;
weather: boolean;
economic: boolean;
countries: boolean;
waterways: boolean;
outages: boolean;
datacenters: boolean;
@@ -970,3 +969,53 @@ export interface StartupEcosystem {
avgSeriesA?: number;
description?: string;
}
// ============================================================================
// FOCAL POINT DETECTION (Intelligence Synthesis)
// ============================================================================
export type FocalPointUrgency = 'watch' | 'elevated' | 'critical';
export interface EntityMention {
entityId: string;
entityType: 'country' | 'company' | 'index' | 'commodity' | 'crypto' | 'sector';
displayName: string;
mentionCount: number;
avgConfidence: number;
clusterIds: string[];
topHeadlines: string[];
}
export interface FocalPoint {
id: string;
entityId: string;
entityType: 'country' | 'company' | 'index' | 'commodity' | 'crypto' | 'sector';
displayName: string;
// News dimension
newsMentions: number;
newsVelocity: number;
topHeadlines: string[];
// Signal dimension
signalTypes: string[];
signalCount: number;
highSeverityCount: number;
signalDescriptions: string[];
// Scoring
focalScore: number;
urgency: FocalPointUrgency;
// For AI context
narrative: string;
correlationEvidence: string[];
}
export interface FocalPointSummary {
timestamp: Date;
focalPoints: FocalPoint[];
aiContext: string;
topCountries: FocalPoint[];
topCompanies: FocalPoint[];
}

View File

@@ -12,7 +12,6 @@ const LAYER_KEYS: (keyof MapLayers)[] = [
'sanctions',
'weather',
'economic',
'countries',
'waterways',
'outages',
'datacenters',

377
src/workers/ml.worker.ts Normal file
View File

@@ -0,0 +1,377 @@
/**
* ML Web Worker for ONNX inference using @xenova/transformers
* Handles embeddings, sentiment analysis, summarization, and NER
*/
import { pipeline, env } from '@xenova/transformers';
import { MODEL_CONFIGS, type ModelConfig } from '@/config/ml-config';
// Configure transformers.js
env.allowLocalModels = false;
env.useBrowserCache = true;
// Message types
interface InitMessage {
type: 'init';
id: string;
}
interface LoadModelMessage {
type: 'load-model';
id: string;
modelId: string;
}
interface UnloadModelMessage {
type: 'unload-model';
id: string;
modelId: string;
}
interface EmbedMessage {
type: 'embed';
id: string;
texts: string[];
}
interface SummarizeMessage {
type: 'summarize';
id: string;
texts: string[];
}
interface SentimentMessage {
type: 'classify-sentiment';
id: string;
texts: string[];
}
interface NERMessage {
type: 'extract-entities';
id: string;
texts: string[];
}
interface SemanticClusterMessage {
type: 'cluster-semantic';
id: string;
embeddings: number[][];
threshold: number;
}
interface StatusMessage {
type: 'status';
id: string;
}
interface ResetMessage {
type: 'reset';
}
type MLWorkerMessage =
| InitMessage
| LoadModelMessage
| UnloadModelMessage
| EmbedMessage
| SummarizeMessage
| SentimentMessage
| NERMessage
| SemanticClusterMessage
| StatusMessage
| ResetMessage;
// Loaded pipelines (using unknown since pipeline types vary)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadedPipelines = new Map<string, any>();
const loadingPromises = new Map<string, Promise<void>>();
function getModelConfig(modelId: string): ModelConfig | undefined {
return MODEL_CONFIGS.find(m => m.id === modelId);
}
async function loadModel(modelId: string): Promise<void> {
if (loadedPipelines.has(modelId)) return;
// Prevent concurrent loads - return existing promise if loading
const existing = loadingPromises.get(modelId);
if (existing) return existing;
const config = getModelConfig(modelId);
if (!config) throw new Error(`Unknown model: ${modelId}`);
console.log(`[MLWorker] Loading model: ${config.hfModel}`);
const startTime = Date.now();
const loadPromise = (async () => {
const pipe = await pipeline(config.task, config.hfModel, {
progress_callback: (progress: { status: string; progress?: number }) => {
if (progress.status === 'progress' && progress.progress !== undefined) {
self.postMessage({
type: 'model-progress',
modelId,
progress: progress.progress,
});
}
},
});
loadedPipelines.set(modelId, pipe);
loadingPromises.delete(modelId);
console.log(`[MLWorker] Model loaded in ${Date.now() - startTime}ms: ${modelId}`);
})();
loadingPromises.set(modelId, loadPromise);
return loadPromise;
}
function unloadModel(modelId: string): void {
const pipe = loadedPipelines.get(modelId);
if (pipe) {
loadedPipelines.delete(modelId);
console.log(`[MLWorker] Unloaded model: ${modelId}`);
}
}
async function embedTexts(texts: string[]): Promise<number[][]> {
await loadModel('embeddings');
const pipe = loadedPipelines.get('embeddings')!;
const results: number[][] = [];
for (const text of texts) {
const output = await pipe(text, { pooling: 'mean', normalize: true });
results.push(Array.from(output.data as Float32Array));
}
return results;
}
async function summarizeTexts(texts: string[]): Promise<string[]> {
await loadModel('summarization');
const pipe = loadedPipelines.get('summarization')!;
const results: string[] = [];
for (const text of texts) {
const output = await pipe(`summarize: ${text}`, {
max_new_tokens: 64,
min_length: 10,
});
const result = (output as Array<{ generated_text: string }>)[0];
results.push(result?.generated_text ?? '');
}
return results;
}
async function classifySentiment(texts: string[]): Promise<Array<{ label: string; score: number }>> {
await loadModel('sentiment');
const pipe = loadedPipelines.get('sentiment')!;
const results: Array<{ label: string; score: number }> = [];
for (const text of texts) {
const output = await pipe(text);
const result = (output as Array<{ label: string; score: number }>)[0];
if (result) {
results.push({
label: result.label.toLowerCase() === 'positive' ? 'positive' : 'negative',
score: result.score,
});
}
}
return results;
}
interface NEREntity {
text: string;
type: string;
confidence: number;
start: number;
end: number;
}
async function extractEntities(texts: string[]): Promise<NEREntity[][]> {
await loadModel('ner');
const pipe = loadedPipelines.get('ner')!;
const results: NEREntity[][] = [];
for (const text of texts) {
const output = await pipe(text);
const entities = (output as Array<{
entity_group: string;
score: number;
word: string;
start: number;
end: number;
}>).map(e => ({
text: e.word,
type: e.entity_group,
confidence: e.score,
start: e.start,
end: e.end,
}));
results.push(entities);
}
return results;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
const aVal = a[i] ?? 0;
const bVal = b[i] ?? 0;
dotProduct += aVal * bVal;
normA += aVal * aVal;
normB += bVal * bVal;
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
function semanticCluster(
embeddings: number[][],
threshold: number
): number[][] {
const n = embeddings.length;
const clusters: number[][] = [];
const assigned = new Set<number>();
for (let i = 0; i < n; i++) {
if (assigned.has(i)) continue;
const embeddingI = embeddings[i];
if (!embeddingI) continue;
const cluster = [i];
assigned.add(i);
for (let j = i + 1; j < n; j++) {
if (assigned.has(j)) continue;
const embeddingJ = embeddings[j];
if (!embeddingJ) continue;
const similarity = cosineSimilarity(embeddingI, embeddingJ);
if (similarity >= threshold) {
cluster.push(j);
assigned.add(j);
}
}
clusters.push(cluster);
}
return clusters;
}
// Worker message handler
self.onmessage = async (event: MessageEvent<MLWorkerMessage>) => {
const message = event.data;
try {
switch (message.type) {
case 'init': {
self.postMessage({ type: 'ready', id: message.id });
break;
}
case 'load-model': {
await loadModel(message.modelId);
self.postMessage({
type: 'model-loaded',
id: message.id,
modelId: message.modelId,
});
break;
}
case 'unload-model': {
unloadModel(message.modelId);
self.postMessage({
type: 'model-unloaded',
id: message.id,
modelId: message.modelId,
});
break;
}
case 'embed': {
const embeddings = await embedTexts(message.texts);
self.postMessage({
type: 'embed-result',
id: message.id,
embeddings,
});
break;
}
case 'summarize': {
const summaries = await summarizeTexts(message.texts);
self.postMessage({
type: 'summarize-result',
id: message.id,
summaries,
});
break;
}
case 'classify-sentiment': {
const results = await classifySentiment(message.texts);
self.postMessage({
type: 'sentiment-result',
id: message.id,
results,
});
break;
}
case 'extract-entities': {
const entities = await extractEntities(message.texts);
self.postMessage({
type: 'entities-result',
id: message.id,
entities,
});
break;
}
case 'cluster-semantic': {
const clusters = semanticCluster(message.embeddings, message.threshold);
self.postMessage({
type: 'cluster-semantic-result',
id: message.id,
clusters,
});
break;
}
case 'status': {
self.postMessage({
type: 'status-result',
id: message.id,
loadedModels: Array.from(loadedPipelines.keys()),
});
break;
}
case 'reset': {
loadedPipelines.clear();
self.postMessage({ type: 'reset-complete' });
break;
}
}
} catch (error) {
self.postMessage({
type: 'error',
id: (message as { id?: string }).id,
error: error instanceof Error ? error.message : String(error),
});
}
};
// Signal ready
self.postMessage({ type: 'worker-ready' });

View File

@@ -23,5 +23,6 @@
"@/*": ["src/*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/workers/ml.worker.ts"]
}