mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-15 11:36:20 +02:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ dist/
|
||||
.vercel
|
||||
.claude/
|
||||
.cursor/
|
||||
.env.vercel-backup
|
||||
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -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
530
README.md
@@ -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
271
api/groq-summarize.js
Normal 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
260
api/openrouter-summarize.js
Normal 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
279
api/risk-scores.js
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
4191
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
462
src/App.ts
462
src/App.ts
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
2570
src/components/DeckGLMap.ts
Normal file
File diff suppressed because it is too large
Load Diff
561
src/components/InsightsPanel.ts
Normal file
561
src/components/InsightsPanel.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
480
src/components/MapContainer.ts
Normal file
480
src/components/MapContainer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
27
src/config/countries.ts
Normal 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',
|
||||
};
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
82
src/config/ml-config.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
107
src/services/cached-risk-scores.ts
Normal file
107
src/services/cached-risk-scores.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
461
src/services/focal-point-detector.ts
Normal file
461
src/services/focal-point-detector.ts
Normal 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
114
src/services/gdacs.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
124
src/services/ml-capabilities.ts
Normal file
124
src/services/ml-capabilities.ts
Normal 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
364
src/services/ml-worker.ts
Normal 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();
|
||||
583
src/services/parallel-analysis.ts
Normal file
583
src/services/parallel-analysis.ts
Normal 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();
|
||||
@@ -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,
|
||||
|
||||
412
src/services/signal-aggregator.ts
Normal file
412
src/services/signal-aggregator.ts
Normal 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();
|
||||
}
|
||||
143
src/services/summarization.ts
Normal file
143
src/services/summarization.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
1748
src/styles/main.css
1748
src/styles/main.css
File diff suppressed because it is too large
Load Diff
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const LAYER_KEYS: (keyof MapLayers)[] = [
|
||||
'sanctions',
|
||||
'weather',
|
||||
'economic',
|
||||
'countries',
|
||||
'waterways',
|
||||
'outages',
|
||||
'datacenters',
|
||||
|
||||
377
src/workers/ml.worker.ts
Normal file
377
src/workers/ml.worker.ts
Normal 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' });
|
||||
@@ -23,5 +23,6 @@
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/workers/ml.worker.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user