Initial commit: World Monitor dashboard

Features:
- Real-time geopolitical monitoring dashboard
- Interactive D3.js world map with hotspots, conflicts, bases
- 16 news/data panels: World, Middle East, Tech, AI/ML, Finance, etc.
- Market data via Yahoo Finance (with rate limiting)
- Crypto prices via CoinGecko
- Prediction markets via Polymarket
- Earthquake data via USGS
- RSS feeds from 50+ sources including:
  - News: BBC, NPR, Guardian, Reuters, Al Jazeera
  - AI: OpenAI, Anthropic, Google AI, DeepMind
  - Government: White House, State Dept, Fed, SEC, Treasury
  - Intel: Defense One, Bellingcat, CISA, Krebs
  - Think Tanks: Brookings, CFR, CSIS
- Custom monitors with keyword alerts
- Draggable panel layout with persistence
- Time range filtering for events
- Dark theme optimized for monitoring
This commit is contained in:
Elie Habib
2026-01-08 21:29:47 +04:00
commit 27892b306c
31 changed files with 7637 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
.DS_Store
*.log
.env
.env.local
.playwright-mcp/

379
FEATURE_GAP_ANALYSIS.md Normal file
View File

@@ -0,0 +1,379 @@
# Situation Monitor - Feature Gap Analysis
## Executive Summary
Comprehensive comparison between original site (hipcityreg.github.io/situation-monitor) and our replica.
**Missing Features: 47 items across 4 categories**
---
## A. MISSING PANELS (12 items)
| # | Panel Name | Description | Data Source | Priority |
|---|------------|-------------|-------------|----------|
| 1 | Congress Trades | Congressional stock trading activity | Quiver Quant / House Stock Watcher API | HIGH |
| 2 | Whale Watch | Large crypto wallet movements | Whale Alert API / Etherscan | HIGH |
| 3 | Main Character | Person mentioned most in headlines | Computed from news feeds | MEDIUM |
| 4 | Money Printer | Fed Balance Sheet tracking | Federal Reserve FRED API | HIGH |
| 5 | Gov Contracts | Government contract awards | USASpending.gov API | MEDIUM |
| 6 | AI Arms Race | AI development announcements | Curated RSS feeds | MEDIUM |
| 7 | Layoffs Tracker | Corporate layoff news | Layoffs.fyi / news scraping | MEDIUM |
| 8 | Venezuela Situation | Venezuela-specific monitoring | News filtering | LOW |
| 9 | Greenland Situation | Arctic dispute monitoring | News filtering | LOW |
| 10 | TBPN Live | YouTube livestream embed | YouTube iframe | LOW |
| 11 | Defense/Military | Breaking Defense, War Zone | RSS feeds | MEDIUM |
| 12 | Cyber/OSINT | CISA alerts, Krebs Security | RSS/API | MEDIUM |
---
## B. INTERACTIVE MAP POPUPS (5 types)
### B1. Conflict Zone Popup
**Trigger:** Click on conflict zone (Ukraine, Gaza, Sudan, Myanmar)
**Required Data:**
```typescript
interface ConflictData {
name: string; // "UKRAINE CONFLICT"
severity: 'HIGH' | 'MEDIUM' | 'LOW';
startDate: string; // "Feb 24, 2022"
casualties: string; // "500,000+ (est.)"
displaced: string; // "6.5M+ refugees"
location: string; // "48.0°N, 37.5°E"
description: string; // Full paragraph
belligerents: string[]; // ["Russia", "Ukraine", "NATO (support)"]
keyDevelopments: string[]; // ["Battle of Bakhmut", "Kursk incursion", ...]
}
```
**UI Elements:**
- Red bordered modal
- Severity badge (HIGH/MEDIUM)
- Two-column stats (Start Date | Casualties, Displaced | Location)
- Description paragraph
- Belligerent tags (border buttons)
- Key developments bullet list
### B2. Hotspot City Popup
**Trigger:** Click on hotspot (DC, Moscow, Beijing, etc.)
**Required Data:**
```typescript
interface HotspotData {
name: string; // "BEIJING"
severity: 'HIGH' | 'ELEVATED' | 'LOW';
subtitle: string; // "PLA/MSS Activity"
description: string; // Full paragraph
coordinates: string; // "39.90°N, 116.40°E"
status: string; // "Elevated posture"
keyEntities: string[]; // ["PLA", "MSS", "CCP Politburo"]
relatedHeadlines: NewsItem[]; // Filtered news matching this location
}
```
**UI Elements:**
- Name with severity badge
- Subtitle in accent color
- Description paragraph
- Two-column (Coordinates | Status)
- Key entities as tags
- Related headlines section
### B3. Earthquake Popup
**Trigger:** Click on earthquake marker
**Required Data:**
```typescript
interface EarthquakePopup {
magnitude: number; // 5.0
severity: 'MAJOR' | 'MODERATE' | 'MINOR';
location: string; // "80 km W of Ambunti, Papua New Guinea"
depth: string; // "131.4 km"
coordinates: string; // "-4.12°, 142.10°"
time: string; // "10h ago"
usgsUrl: string; // Link to USGS event page
}
```
**UI Elements:**
- Large magnitude display
- Severity badge
- Location description
- Stats grid (Depth, Coordinates, Time)
- "View on USGS →" link
### B4. Nuclear Facility Popup
**Trigger:** Click on nuclear marker
**Required Data:**
```typescript
interface NuclearFacility {
name: string; // "Zaporizhzhia NPP"
type: 'plant' | 'enrichment' | 'weapons' | 'reprocessing';
status: 'active' | 'contested' | 'inactive';
country: string;
coordinates: string;
description: string;
concerns: string[]; // ["Russian occupation", "Power grid damage"]
}
```
### B5. Military Base Popup
**Trigger:** Click on base marker
**Required Data:**
```typescript
interface MilitaryBase {
name: string; // "Ramstein AB"
country: string; // "Germany"
operator: 'US/NATO' | 'China' | 'Russia';
type: string; // "Air Base"
coordinates: string;
description: string;
units: string[]; // ["USAFE", "86th Airlift Wing"]
}
```
---
## C. ENHANCED MAP FEATURES (15 items)
### C1. Hotspot Subtitles
Each hotspot should display a subtitle below the city name:
- DC → "Pentagon Pizza Index"
- Moscow → "Kremlin Activity"
- Beijing → "PLA/MSS Activity"
- Caracas → "Venezuela Crisis"
- Nuuk → "Arctic Dispute"
- Taipei → "Strait Watch"
- Tehran → "IRGC Activity"
- Tel Aviv → "Mossad/IDF"
- Pyongyang → "DPRK Watch"
### C2. APT/Cyber Threat Markers
Small indicators showing APT groups near their attributed locations:
- APT28/29 near Moscow
- APT41 near Beijing
- Lazarus near Pyongyang
- APT33/35 near Tehran
### C3. Strategic Waterway Labels
Add prominent labels for:
- TAIWAN STRAIT
- MALACCA STRAIT
- BOSPHORUS STRAIT
- STRAIT OF HORMUZ
- SUEZ CANAL
- PANAMA CANAL
### C4. Ship/Maritime Icons (⚓)
Anchor icons at chokepoints showing maritime monitoring:
- Panama Canal
- Suez Canal
- Strait of Hormuz
- Malacca Strait
### C5. "BREAKING" Tags
Dynamic tags appearing on hotspots with recent critical news:
- Red bordered box with "BREAKING" text
- Positioned near active hotspots
- Auto-computed from news freshness + importance
### C6. Grid Lines with Labels
- Latitude lines at 60°N, 30°N, 0°, 30°S, 60°S
- Longitude lines at 120°W, 60°W, 0°, 60°E, 120°E
- Small labels at line intersections
### C7. Bottom Legend Bar
Fixed bar at bottom showing:
`⚓ SHIP | ☢ NUKES | ● BASES | ═ CABLES | 2026-01-08 14:04:41 UTC`
### C8. Classification Indicator
Top-right corner: "CLASSIFICATION: OPEN"
### C9. Zoom Level Indicator
Near zoom controls: "1.0x" / "1.5x" / "2.0x"
### C10. Enhanced Visual Effects
- Red glow/halo effect around conflict zones
- Pulsing animation on high-severity markers
- Better color coding for severity levels
### C11. Time Slider Enhancement
Bottom-center prominent slider:
`TIME ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ LIVE`
### C12. More Strategic Locations
Missing hotspot markers:
- BOSPHORUS STRAIT region
- APT markers for cyber threats
- PANAMA CANAL
- SUEZ CANAL
### C13. Enhanced Conflict Zone Rendering
- Larger conflict zone areas with pulsing borders
- Better visibility of conflict names
- Warning triangles (△) on active conflicts
### C14. Ship Tracking Indicators
Small ship icons showing naval activity at:
- Panama Canal (US control)
- Strait of Hormuz (Iran monitoring)
- Taiwan Strait (PLA Navy)
- South China Sea (contested)
### C15. Enhanced Base Markers
Different icons for:
- Air bases (✈)
- Naval bases (⚓)
- Army installations (★)
---
## D. DATA STRUCTURE ENHANCEMENTS
### D1. Rich Conflict Data
Expand CONFLICT_ZONES in config:
```typescript
const CONFLICT_ZONES = [
{
id: 'ukraine',
name: 'Ukraine Conflict',
bounds: [[44, 22], [52, 40]],
severity: 'HIGH',
startDate: 'Feb 24, 2022',
casualties: '500,000+ (est.)',
displaced: '6.5M+ refugees',
location: '48.0°N, 37.5°E',
description: 'Full-scale Russian invasion of Ukraine...',
belligerents: ['Russia', 'Ukraine', 'NATO (support)'],
keyDevelopments: [
'Battle of Bakhmut',
'Kursk incursion',
'Black Sea drone strikes',
'Infrastructure attacks'
]
},
// ... more conflicts
];
```
### D2. Rich Hotspot Data
Expand HOTSPOTS in config:
```typescript
const HOTSPOTS = [
{
id: 'dc',
name: 'DC',
lat: 38.9,
lon: -77.0,
subtitle: 'Pentagon Pizza Index',
severity: 'ELEVATED',
description: 'US government center...',
status: 'Elevated activity',
keyEntities: ['Pentagon', 'CIA', 'State Dept'],
newsKeywords: ['washington', 'pentagon', 'white house', 'congress']
},
// ... more hotspots
];
```
### D3. Related Headlines Matching
Add logic to match news items to hotspots based on:
- Keywords in title/description
- Geographic mentions
- Entity mentions
### D4. Enhanced Earthquake Data
Add depth and USGS URL to earthquake objects:
```typescript
interface Earthquake {
magnitude: number;
place: string;
time: Date;
lat: number;
lon: number;
depth: number; // NEW
usgsUrl: string; // NEW
severity: string; // Computed from magnitude
}
```
---
## E. IMPLEMENTATION PRIORITY
### Phase 1: Interactive Popups (Critical)
1. Create popup component system
2. Implement ConflictPopup
3. Implement HotspotPopup with related headlines
4. Implement EarthquakePopup
5. Add click handlers to map elements
### Phase 2: Rich Data & Config (High)
1. Expand conflict zone data
2. Expand hotspot data with subtitles/entities
3. Add news keyword matching for related headlines
4. Enhance earthquake data with depth/URL
### Phase 3: Missing Panels (Medium)
1. Congress Trades panel
2. Whale Watch panel
3. Main Character panel
4. Money Printer panel
5. AI Arms Race panel
### Phase 4: Map Enhancements (Medium)
1. Hotspot subtitles
2. Strategic waterway labels
3. BREAKING tags system
4. Grid lines
5. Bottom legend bar
6. APT markers
### Phase 5: Polish (Low)
1. Enhanced visual effects
2. Time slider redesign
3. Classification indicator
4. Zoom level display
5. Ship markers
---
## F. ESTIMATED SCOPE
| Phase | Items | Complexity | Est. Files |
|-------|-------|------------|------------|
| Phase 1 | 5 | High | 3-4 new components |
| Phase 2 | 4 | Medium | Config expansion |
| Phase 3 | 5 | Medium | 5 new panels |
| Phase 4 | 6 | Medium | Map.ts updates |
| Phase 5 | 5 | Low | CSS + minor updates |
**Total: 25 work items across 5 phases**
---
## G. FILES TO CREATE/MODIFY
### New Files:
- `src/components/popups/ConflictPopup.ts`
- `src/components/popups/HotspotPopup.ts`
- `src/components/popups/EarthquakePopup.ts`
- `src/components/popups/BasePopup.ts`
- `src/components/popups/NuclearPopup.ts`
- `src/components/panels/CongressTradesPanel.ts`
- `src/components/panels/WhaleWatchPanel.ts`
- `src/components/panels/MainCharacterPanel.ts`
- `src/components/panels/MoneyPrinterPanel.ts`
- `src/components/panels/AIArmsRacePanel.ts`
- `src/services/congressTrades.ts`
- `src/services/whaleWatch.ts`
### Modify:
- `src/config/hotspots.ts` - Add rich hotspot data
- `src/config/conflicts.ts` - Add rich conflict data
- `src/components/Map.ts` - Add popup triggers, subtitles, APT markers
- `src/styles/main.css` - Popup styles, legend, grid
- `src/App.ts` - New panels integration

View File

@@ -0,0 +1,548 @@
# Situation Monitor - Complete Analysis for Replication
## Overview
**URL**: https://hipcityreg.github.io/situation-monitor/
**Repo**: https://github.com/hipcityreg/situation-monitor
**Tech Stack**: Vanilla HTML/CSS/JS + D3.js + TopoJSON
A real-time geopolitical monitoring dashboard combining:
- Interactive world map with multiple data layers
- Multi-source news aggregation
- Financial market data
- Prediction markets
- Custom keyword monitoring
---
## Architecture
### Core Technologies
```
- D3.js v7 - Map rendering, projections, data visualization
- TopoJSON - World map country boundaries
- CORS Proxies - Cross-origin RSS/API fetching
- LocalStorage - Panel settings, custom monitors persistence
- YouTube Embed - Livestream panel
```
### External Dependencies
```html
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3"></script>
```
### Map Data Sources
```
World Countries: https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json
US States: https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json
```
---
## Design System
### CSS Variables (Theme)
```css
:root {
--bg: #0a0a0a; /* Primary background */
--surface: #141414; /* Panel/card background */
--border: #2a2a2a; /* Border color */
--text: #e8e8e8; /* Primary text */
--text-dim: #888; /* Secondary text */
--accent: #fff; /* Accent/highlight */
--red: #ff4444; /* Negative/danger */
--green: #44ff88; /* Positive/success */
--yellow: #ffaa00; /* Warning/alert */
}
```
### Typography
```css
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
```
### Color Palette - Military/Intel Aesthetic
- Map background: `#020a08` (deep dark green-black)
- Map grid: `#0a2a20`, `#0d3a2d`, `#0f4035`
- Country fill: `#0a2018`
- Country stroke: `#0f5040`
- Conflict zones: Red gradient with pulsing animation
- Cables: `#00ffaa` (cyan-green glow)
---
## Panel Configuration
### All Panel Types (21 total)
```javascript
const PANELS = {
map: { name: 'Global Map', priority: 1 },
politics: { name: 'World / Geopolitical', priority: 1 },
tech: { name: 'Technology / AI', priority: 1 },
finance: { name: 'Financial', priority: 1 },
gov: { name: 'Government / Policy', priority: 2 },
heatmap: { name: 'Sector Heatmap', priority: 1 },
markets: { name: 'Markets', priority: 1 },
monitors: { name: 'My Monitors', priority: 1 },
commodities: { name: 'Commodities / VIX', priority: 2 },
polymarket: { name: 'Polymarket', priority: 2 },
congress: { name: 'Congress Trades', priority: 3 },
whales: { name: 'Whale Watch', priority: 3 },
mainchar: { name: 'Main Character', priority: 2 },
printer: { name: 'Money Printer', priority: 2 },
contracts: { name: 'Gov Contracts', priority: 3 },
ai: { name: 'AI Arms Race', priority: 3 },
layoffs: { name: 'Layoffs Tracker', priority: 3 },
venezuela: { name: 'Venezuela Situation', priority: 2 },
greenland: { name: 'Greenland Situation', priority: 2 },
tbpn: { name: 'TBPN Live', priority: 1 },
intel: { name: 'Intel Feed', priority: 2 }
};
```
---
## Data Sources
### RSS Feeds Configuration
```javascript
const FEEDS = {
politics: [
{ name: 'BBC World', url: 'https://feeds.bbci.co.uk/news/world/rss.xml' },
{ name: 'NPR News', url: 'https://feeds.npr.org/1001/rss.xml' },
{ name: 'Guardian World', url: 'https://www.theguardian.com/world/rss' },
{ name: 'Reuters World', url: 'https://www.reutersagency.com/feed/...' }
],
tech: [
{ name: 'Hacker News', url: 'https://hnrss.org/frontpage' },
{ name: 'Ars Technica', url: 'https://feeds.arstechnica.com/arstechnica/technology-lab' },
{ name: 'The Verge', url: 'https://www.theverge.com/rss/index.xml' },
{ name: 'MIT Tech Review', url: 'https://www.technologyreview.com/feed/' },
{ name: 'ArXiv AI', url: 'https://rss.arxiv.org/rss/cs.AI' },
{ name: 'OpenAI Blog', url: 'https://openai.com/blog/rss.xml' }
],
finance: [
{ name: 'CNBC', url: 'https://www.cnbc.com/id/100003114/device/rss/rss.html' },
{ name: 'MarketWatch', url: 'https://feeds.marketwatch.com/marketwatch/topstories' },
{ name: 'Yahoo Finance', url: 'https://finance.yahoo.com/news/rssindex' },
{ name: 'FT', url: 'https://www.ft.com/rss/home' }
],
gov: [
{ name: 'White House', url: 'https://www.whitehouse.gov/feed/' },
{ name: 'Federal Reserve', url: 'https://www.federalreserve.gov/feeds/press_all.xml' },
{ name: 'SEC Announcements', url: 'https://www.sec.gov/news/pressreleases.rss' },
{ name: 'Treasury', url: 'https://home.treasury.gov/system/files/136/treasury-rss.xml' },
{ name: 'State Dept', url: 'https://www.state.gov/rss-feed/press-releases/feed/' }
]
};
```
### Market Data APIs
```javascript
// Stock quotes - Yahoo Finance API
https://query1.finance.yahoo.com/v8/finance/chart/{symbol}
// Crypto - CoinGecko API
https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd&include_24hr_change=true
// Sector ETFs for heatmap
const SECTORS = [
{ symbol: 'XLK', name: 'Tech' },
{ symbol: 'XLF', name: 'Finance' },
{ symbol: 'XLE', name: 'Energy' },
{ symbol: 'XLV', name: 'Health' },
// ... 12 total sectors
];
```
### CORS Proxy Strategy
```javascript
const CORS_PROXIES = [
'https://corsproxy.io/?',
'https://api.allorigins.win/raw?url='
];
async function fetchWithProxy(url) {
for (let i = 0; i < CORS_PROXIES.length; i++) {
try {
const proxy = CORS_PROXIES[i];
const response = await fetch(proxy + encodeURIComponent(url));
if (response.ok) return await response.text();
} catch (e) {
console.log(`Proxy ${i} failed, trying next...`);
}
}
throw new Error('All proxies failed');
}
```
---
## Interactive Map Features
### Map Layers (Toggleable)
```javascript
let mapLayers = {
conflicts: true, // Active conflict zones
bases: true, // Military bases (US/NATO, China, Russia)
nuclear: true, // Nuclear facilities
cables: true, // Undersea fiber cables
sanctions: true, // Sanctioned country highlighting
density: true // News density heatmap
};
```
### Conflict Zones
```javascript
const CONFLICT_ZONES = [
{
id: 'ukraine',
name: 'Ukraine Conflict',
intensity: 'high',
coords: [[37.5, 47.0], [38.5, 47.5], ...], // Polygon points
labelPos: { lat: 48.0, lon: 37.5 },
parties: ['Russia', 'Ukraine', 'NATO (support)'],
casualties: '500,000+ (est.)',
displaced: '6.5M+ refugees',
keywords: ['ukraine', 'russia', 'zelensky', 'putin', ...]
},
// Gaza, Sudan, Myanmar, Taiwan Strait
];
```
### Intelligence Hotspots (12 locations)
```javascript
const INTEL_HOTSPOTS = [
{ id: 'dc', name: 'DC', subtext: 'Pentagon Pizza Index', lat: 38.9, lon: -77.0,
keywords: ['pentagon', 'white house', ...],
agencies: ['Pentagon', 'CIA', 'NSA', 'State Dept'] },
{ id: 'moscow', name: 'Moscow', subtext: 'Kremlin Activity', ... },
{ id: 'beijing', name: 'Beijing', subtext: 'PLA/MSS Activity', ... },
{ id: 'kyiv', name: 'Kyiv', subtext: 'Conflict Zone', ... },
// + Taipei, Tehran, Tel Aviv, Pyongyang, London, Brussels, Caracas, Nuuk
];
```
### Military Bases
```javascript
const MILITARY_BASES = [
// US/NATO (10 bases)
{ id: 'ramstein', name: 'Ramstein AB', lat: 49.44, lon: 7.6, type: 'us-nato' },
{ id: 'diego_garcia', name: 'Diego Garcia', lat: -7.32, lon: 72.42, type: 'us-nato' },
// Chinese (5 bases)
{ id: 'djibouti_cn', name: 'PLA Djibouti', lat: 11.59, lon: 43.05, type: 'china' },
{ id: 'woody_island', name: 'Woody Island', lat: 16.83, lon: 112.33, type: 'china' },
// Russian (5 bases)
{ id: 'kaliningrad', name: 'Kaliningrad', lat: 54.71, lon: 20.51, type: 'russia' },
{ id: 'tartus', name: 'Tartus (Syria)', lat: 34.89, lon: 35.87, type: 'russia' },
];
```
### Nuclear Facilities
```javascript
const NUCLEAR_FACILITIES = [
// Power Plants
{ id: 'zaporizhzhia', name: 'Zaporizhzhia NPP', type: 'plant', status: 'contested' },
// Enrichment
{ id: 'natanz', name: 'Natanz', type: 'enrichment', status: 'active' },
// Weapons
{ id: 'yongbyon', name: 'Yongbyon', type: 'weapons', status: 'active' },
];
```
### Undersea Cables (6 major routes)
```javascript
const UNDERSEA_CABLES = [
{ id: 'transatlantic_1', name: 'Transatlantic (TAT-14)', major: true,
points: [[-74.0, 40.7], [-30.0, 45.0], [-9.0, 52.0]] },
{ id: 'transpacific_1', name: 'Transpacific (Unity)', major: true,
points: [[-122.4, 37.8], [-155.0, 25.0], [139.7, 35.7]] },
// SEA-ME-WE 5, Asia-Africa-Europe 1, Curie, MAREA
];
```
### Shipping Chokepoints
```javascript
const SHIPPING_CHOKEPOINTS = [
{ id: 'suez', name: 'Suez Canal', lat: 30.0, lon: 32.5,
desc: 'Critical waterway. ~12% of global trade.' },
{ id: 'hormuz', name: 'Strait of Hormuz', lat: 26.5, lon: 56.3,
desc: '~21% of global oil passes through daily.' },
// Panama, Malacca, Bosphorus
];
```
### Cyber Threat Regions
```javascript
const CYBER_REGIONS = [
{ id: 'cyber_russia', group: 'APT28/29', aka: 'Fancy Bear / Cozy Bear', sponsor: 'GRU / FSB' },
{ id: 'cyber_china', group: 'APT41', aka: 'Double Dragon / Winnti', sponsor: 'MSS' },
{ id: 'cyber_nk', group: 'Lazarus', aka: 'Hidden Cobra', sponsor: 'RGB' },
{ id: 'cyber_iran', group: 'APT33/35', aka: 'Charming Kitten', sponsor: 'IRGC' },
];
```
### Sanctioned Countries (by ISO code)
```javascript
const SANCTIONED_COUNTRIES = {
408: 'severe', // North Korea
728: 'severe', // South Sudan
760: 'severe', // Syria
364: 'high', // Iran
643: 'high', // Russia
112: 'high', // Belarus
862: 'moderate', // Venezuela
104: 'moderate', // Myanmar
};
```
---
## Map Rendering (D3.js)
### Projection Setup
```javascript
// Global view - Equirectangular
projection = d3.geoEquirectangular()
.scale(width / (2 * Math.PI))
.center([0, 0])
.translate([width / 2, height / 2]);
// US view - Albers USA
projection = d3.geoAlbersUsa()
.scale(width * 1.3)
.translate([width / 2, height / 2]);
```
### Layer Rendering Order
1. Background rectangle (`#020a08`)
2. Grid pattern (small + large)
3. Graticule (lat/lon lines)
4. Countries (TopoJSON)
5. Undersea cables (curved paths)
6. Conflict zone boundaries (animated polygons)
7. State boundaries (US view)
8. HTML Overlays (hotspots, bases, labels)
### Zoom/Pan Implementation
```javascript
let mapZoom = 1;
let mapPan = { x: 0, y: 0 };
const MAP_ZOOM_MIN = 1;
const MAP_ZOOM_MAX = 4;
const MAP_ZOOM_STEP = 0.5;
function applyMapTransform() {
const wrapper = document.getElementById('mapZoomWrapper');
wrapper.style.transform = `scale(${mapZoom}) translate(${mapPan.x}px, ${mapPan.y}px)`;
}
// Mouse wheel zoom
container.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.deltaY < 0) mapZoomIn();
else mapZoomOut();
}, { passive: false });
```
---
## Alert System
### Alert Keywords
```javascript
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'
];
```
### Hotspot Activity Scoring
```javascript
function analyzeHotspotActivity(allNews) {
INTEL_HOTSPOTS.forEach(spot => {
let score = 0;
allNews.forEach(item => {
const matchedKeywords = spot.keywords.filter(kw =>
item.title.toLowerCase().includes(kw)
);
if (matchedKeywords.length > 0) {
score += matchedKeywords.length;
if (item.isAlert) score += 3; // Boost for alert keywords
}
});
let level = 'low';
if (score >= 8) level = 'high';
else if (score >= 3) level = 'elevated';
});
}
```
---
## UI Components
### Panel Structure
```html
<div class="panel" data-panel="politics">
<div class="panel-header">
<div class="panel-header-left">
<span class="panel-title">World / Geopolitical</span>
</div>
<span class="panel-count">15</span>
</div>
<div class="panel-content">
<!-- News items -->
</div>
</div>
```
### News Item Structure
```html
<div class="item alert">
<div class="item-source">
NPR News<span class="alert-tag">ALERT</span>
</div>
<a class="item-title" href="..." target="_blank">
Trump invites Colombian president...
</a>
<div class="item-time">2h ago</div>
</div>
```
### Sector Heatmap
```html
<div class="heatmap">
<div class="heatmap-cell down-2">
<div class="sector-name">Finance</div>
<div class="sector-change">-1.40%</div>
</div>
</div>
```
### Market Item
```html
<div class="market-item">
<div class="market-info">
<span class="market-name">S&P 500</span>
<span class="market-symbol">SPX</span>
</div>
<div class="market-data">
<span class="market-price">$6,921</span>
<span class="market-change down">-0.34%</span>
</div>
</div>
```
---
## Custom Monitors Feature
### Monitor Data Structure
```javascript
{
id: 'monitor-123456',
name: 'TSMC Supply Chain',
keywords: ['tsmc', 'taiwan semiconductor', 'chip'],
color: '#44ff88',
lat: 25.0, // Optional map location
lon: 121.5 // Optional map location
}
```
### Color Palette for Monitors
```javascript
const MONITOR_COLORS = [
'#44ff88', '#ff8844', '#4488ff', '#ff44ff',
'#ffff44', '#ff4444', '#44ffff', '#88ff44',
'#ff88ff', '#88ffff'
];
```
---
## Time Slider (Historical Mode)
```javascript
// Time slider for historical data
<div class="time-slider-container">
<span class="time-label">Time</span>
<input type="range" min="0" max="24" value="0" class="time-slider">
<span class="time-value">LIVE</span>
</div>
// When not at 0, shows "VIEWING HISTORICAL DATA" banner
```
---
## Key Animations
### Conflict Zone Pulse
```css
@keyframes pulse-red {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
.conflict-zone-fill {
animation: pulse-red 2s ease-in-out infinite;
}
```
### Breaking News Badge
```css
@keyframes pulse-breaking {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.hotspot-breaking-badge {
animation: pulse-breaking 1s ease-in-out infinite;
}
```
### Cable Glow
```css
.cable-path {
stroke: #00ffaa;
stroke-width: 1.5;
filter: drop-shadow(0 0 3px #00ffaa);
}
```
---
## Performance Optimizations
1. **Parallel Fetch**: All RSS feeds fetched simultaneously
2. **Caching**: `worldMapData` and `usStatesData` cached after first load
3. **LocalStorage**: Panel settings, order, sizes persisted
4. **Limit Items**: Only 5 items per feed, 20 per category displayed
5. **Debounced Updates**: Auto-refresh with rate limiting
---
## Replication Checklist
- [ ] Set up HTML structure with header, dashboard grid, modals
- [ ] Implement CSS theme with military/intel aesthetic
- [ ] Add D3.js and TopoJSON dependencies
- [ ] Create map rendering with all layers
- [ ] Implement RSS feed fetching with CORS proxies
- [ ] Add Yahoo Finance API integration for markets
- [ ] Create panel toggle/drag/resize functionality
- [ ] Implement custom monitors with keyword matching
- [ ] Add alert keyword detection
- [ ] Create hotspot activity scoring
- [ ] Implement time slider for historical view
- [ ] Add zoom/pan controls for map
- [ ] Implement settings modal
- [ ] Add YouTube livestream embed
- [ ] Persist settings to localStorage

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WORLD MONITOR</title>
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1916
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "situation-monitor",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/d3": "^7.4.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"typescript": "^5.7.2",
"vite": "^6.0.7"
},
"dependencies": {
"d3": "^7.9.0",
"topojson-client": "^3.1.0"
}
}

508
src/App.ts Normal file
View File

@@ -0,0 +1,508 @@
import type { NewsItem, Monitor, PanelConfig, MapLayers } from '@/types';
import {
FEEDS,
INTEL_SOURCES,
SECTORS,
COMMODITIES,
MARKET_SYMBOLS,
REFRESH_INTERVALS,
DEFAULT_PANELS,
DEFAULT_MAP_LAYERS,
STORAGE_KEYS,
} from '@/config';
import { fetchCategoryFeeds, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes } from '@/services';
import { loadFromStorage, saveToStorage } from '@/utils';
import {
MapComponent,
NewsPanel,
MarketPanel,
HeatmapPanel,
CommoditiesPanel,
CryptoPanel,
PredictionPanel,
MonitorPanel,
Panel,
} from '@/components';
export class App {
private container: HTMLElement;
private map: MapComponent | null = null;
private panels: Record<string, Panel> = {};
private newsPanels: Record<string, NewsPanel> = {};
private allNews: NewsItem[] = [];
private monitors: Monitor[];
private panelSettings: Record<string, PanelConfig>;
private mapLayers: MapLayers;
constructor(containerId: string) {
const el = document.getElementById(containerId);
if (!el) throw new Error(`Container ${containerId} not found`);
this.container = el;
this.monitors = loadFromStorage<Monitor[]>(STORAGE_KEYS.monitors, []);
this.panelSettings = loadFromStorage<Record<string, PanelConfig>>(
STORAGE_KEYS.panels,
DEFAULT_PANELS
);
this.mapLayers = loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, DEFAULT_MAP_LAYERS);
}
public async init(): Promise<void> {
this.renderLayout();
this.setupEventListeners();
await this.loadAllData();
this.setupRefreshIntervals();
}
private renderLayout(): void {
this.container.innerHTML = `
<div class="header">
<div class="header-left">
<span class="logo">WORLD MONITOR</span>
<div class="status-indicator">
<span class="status-dot"></span>
<span>LIVE</span>
</div>
</div>
<div class="header-center">
<button class="view-btn active" data-view="global">GLOBAL</button>
<button class="view-btn" data-view="us">US</button>
<button class="view-btn" data-view="mena">MENA</button>
</div>
<div class="header-right">
<span class="time-display" id="timeDisplay">--:--:-- UTC</span>
<button class="settings-btn" id="settingsBtn">⚙ PANELS</button>
</div>
</div>
<div class="main-content">
<div class="map-section" id="mapSection">
<div class="panel-header">
<div class="panel-header-left">
<span class="panel-title">Global Situation</span>
</div>
</div>
<div class="map-container" id="mapContainer"></div>
<div class="map-resize-handle" id="mapResizeHandle"></div>
</div>
<div class="panels-grid" id="panelsGrid"></div>
</div>
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Panel Settings</span>
<button class="modal-close" id="modalClose">×</button>
</div>
<div class="panel-toggle-grid" id="panelToggles"></div>
</div>
</div>
`;
this.createPanels();
this.renderPanelToggles();
this.updateTime();
setInterval(() => this.updateTime(), 1000);
}
private createPanels(): void {
const panelsGrid = document.getElementById('panelsGrid')!;
// Initialize map in the map section
const mapContainer = document.getElementById('mapContainer') as HTMLElement;
this.map = new MapComponent(mapContainer, {
zoom: 1,
pan: { x: 0, y: 0 },
view: 'global',
layers: this.mapLayers,
timeRange: '7d',
});
// Create all panels
const politicsPanel = new NewsPanel('politics', 'World / Geopolitical');
this.newsPanels['politics'] = politicsPanel;
this.panels['politics'] = politicsPanel;
const techPanel = new NewsPanel('tech', 'Technology / AI');
this.newsPanels['tech'] = techPanel;
this.panels['tech'] = techPanel;
const financePanel = new NewsPanel('finance', 'Financial News');
this.newsPanels['finance'] = financePanel;
this.panels['finance'] = financePanel;
const heatmapPanel = new HeatmapPanel();
this.panels['heatmap'] = heatmapPanel;
const marketsPanel = new MarketPanel();
this.panels['markets'] = marketsPanel;
const monitorPanel = new MonitorPanel(this.monitors);
this.panels['monitors'] = monitorPanel;
monitorPanel.onChanged((monitors) => {
this.monitors = monitors;
saveToStorage(STORAGE_KEYS.monitors, monitors);
this.updateMonitorResults();
});
const commoditiesPanel = new CommoditiesPanel();
this.panels['commodities'] = commoditiesPanel;
const predictionPanel = new PredictionPanel();
this.panels['polymarket'] = predictionPanel;
const govPanel = new NewsPanel('gov', 'Government / Policy');
this.newsPanels['gov'] = govPanel;
this.panels['gov'] = govPanel;
const intelPanel = new NewsPanel('intel', 'Intel Feed');
this.newsPanels['intel'] = intelPanel;
this.panels['intel'] = intelPanel;
const cryptoPanel = new CryptoPanel();
this.panels['crypto'] = cryptoPanel;
const middleeastPanel = new NewsPanel('middleeast', 'Middle East / MENA');
this.newsPanels['middleeast'] = middleeastPanel;
this.panels['middleeast'] = middleeastPanel;
const layoffsPanel = new NewsPanel('layoffs', 'Layoffs Tracker');
this.newsPanels['layoffs'] = layoffsPanel;
this.panels['layoffs'] = layoffsPanel;
const congressPanel = new NewsPanel('congress', 'Congress Trades');
this.newsPanels['congress'] = congressPanel;
this.panels['congress'] = congressPanel;
const aiPanel = new NewsPanel('ai', 'AI / ML');
this.newsPanels['ai'] = aiPanel;
this.panels['ai'] = aiPanel;
const thinktanksPanel = new NewsPanel('thinktanks', 'Think Tanks');
this.newsPanels['thinktanks'] = thinktanksPanel;
this.panels['thinktanks'] = thinktanksPanel;
// Add panels to grid in saved order
const defaultOrder = ['politics', 'middleeast', 'tech', 'ai', 'finance', 'layoffs', 'congress', 'heatmap', 'markets', 'commodities', 'crypto', 'polymarket', 'gov', 'thinktanks', 'intel', 'monitors'];
const savedOrder = this.getSavedPanelOrder();
// Merge saved order with default to include new panels
let panelOrder = defaultOrder;
if (savedOrder.length > 0) {
// Add any missing panels from default that aren't in saved order
const missing = defaultOrder.filter(k => !savedOrder.includes(k));
// Remove any saved panels that no longer exist
const valid = savedOrder.filter(k => defaultOrder.includes(k));
// Insert missing panels after 'politics' (except monitors which goes at end)
const monitorsIdx = valid.indexOf('monitors');
if (monitorsIdx !== -1) valid.splice(monitorsIdx, 1); // Remove monitors temporarily
const insertIdx = valid.indexOf('politics') + 1 || 0;
const newPanels = missing.filter(k => k !== 'monitors');
valid.splice(insertIdx, 0, ...newPanels);
valid.push('monitors'); // Always put monitors last
panelOrder = valid;
}
panelOrder.forEach((key: string) => {
const panel = this.panels[key];
if (panel) {
const el = panel.getElement();
this.makeDraggable(el, key);
panelsGrid.appendChild(el);
}
});
this.applyPanelSettings();
}
private getSavedPanelOrder(): string[] {
try {
const saved = localStorage.getItem('panel-order');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
private savePanelOrder(): void {
const grid = document.getElementById('panelsGrid');
if (!grid) return;
const order = Array.from(grid.children)
.map((el) => (el as HTMLElement).dataset.panel)
.filter((key): key is string => !!key);
localStorage.setItem('panel-order', JSON.stringify(order));
}
private makeDraggable(el: HTMLElement, key: string): void {
el.draggable = true;
el.dataset.panel = key;
el.addEventListener('dragstart', (e) => {
el.classList.add('dragging');
e.dataTransfer?.setData('text/plain', key);
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
this.savePanelOrder();
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = document.querySelector('.dragging');
if (!dragging || dragging === el) return;
const grid = document.getElementById('panelsGrid');
if (!grid) return;
const siblings = Array.from(grid.children).filter((c) => c !== dragging);
const nextSibling = siblings.find((sibling) => {
const rect = sibling.getBoundingClientRect();
return e.clientY < rect.top + rect.height / 2;
});
if (nextSibling) {
grid.insertBefore(dragging, nextSibling);
} else {
grid.appendChild(dragging);
}
});
}
private setupEventListeners(): void {
// View buttons
document.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const view = (btn as HTMLElement).dataset.view as 'global' | 'us' | 'mena';
this.map?.setView(view);
});
});
// Settings modal
document.getElementById('settingsBtn')?.addEventListener('click', () => {
document.getElementById('settingsModal')?.classList.add('active');
});
document.getElementById('modalClose')?.addEventListener('click', () => {
document.getElementById('settingsModal')?.classList.remove('active');
});
document.getElementById('settingsModal')?.addEventListener('click', (e) => {
if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
(e.target as HTMLElement).classList.remove('active');
}
});
// Window resize
window.addEventListener('resize', () => {
this.map?.render();
});
// Map section resize handle
this.setupMapResize();
}
private setupMapResize(): void {
const mapSection = document.getElementById('mapSection');
const resizeHandle = document.getElementById('mapResizeHandle');
if (!mapSection || !resizeHandle) return;
// Load saved height
const savedHeight = localStorage.getItem('map-height');
if (savedHeight) {
mapSection.style.height = savedHeight;
}
let isResizing = false;
let startY = 0;
let startHeight = 0;
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startY = e.clientY;
startHeight = mapSection.offsetHeight;
mapSection.classList.add('resizing');
document.body.style.cursor = 'ns-resize';
e.preventDefault();
});
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));
mapSection.style.height = `${newHeight}px`;
this.map?.render();
});
document.addEventListener('mouseup', () => {
if (!isResizing) return;
isResizing = false;
mapSection.classList.remove('resizing');
document.body.style.cursor = '';
// Save height preference
localStorage.setItem('map-height', mapSection.style.height);
this.map?.render();
});
}
private renderPanelToggles(): void {
const container = document.getElementById('panelToggles')!;
container.innerHTML = Object.entries(this.panelSettings)
.map(
([key, panel]) => `
<div class="panel-toggle-item ${panel.enabled ? 'active' : ''}" data-panel="${key}">
<div class="panel-toggle-checkbox">${panel.enabled ? '✓' : ''}</div>
<span class="panel-toggle-label">${panel.name}</span>
</div>
`
)
.join('');
container.querySelectorAll('.panel-toggle-item').forEach((item) => {
item.addEventListener('click', () => {
const panelKey = (item as HTMLElement).dataset.panel!;
const config = this.panelSettings[panelKey];
if (config) {
config.enabled = !config.enabled;
saveToStorage(STORAGE_KEYS.panels, this.panelSettings);
this.renderPanelToggles();
this.applyPanelSettings();
}
});
});
}
private applyPanelSettings(): void {
Object.entries(this.panelSettings).forEach(([key, config]) => {
if (key === 'map') {
const mapSection = document.getElementById('mapSection');
if (mapSection) {
mapSection.classList.toggle('hidden', !config.enabled);
}
return;
}
const panel = this.panels[key];
panel?.toggle(config.enabled);
});
}
private updateTime(): void {
const now = new Date();
const el = document.getElementById('timeDisplay');
if (el) {
el.textContent = now.toUTCString().split(' ')[4] + ' UTC';
}
}
private async loadAllData(): Promise<void> {
await Promise.all([
this.loadNews(),
this.loadMarkets(),
this.loadPredictions(),
this.loadEarthquakes(),
]);
}
private async loadNews(): Promise<void> {
this.allNews = [];
// Politics
const politics = await fetchCategoryFeeds(FEEDS.politics ?? []);
this.newsPanels['politics']?.renderNews(politics);
this.allNews.push(...politics);
// Tech
const tech = await fetchCategoryFeeds(FEEDS.tech ?? []);
this.newsPanels['tech']?.renderNews(tech);
this.allNews.push(...tech);
// Finance
const finance = await fetchCategoryFeeds(FEEDS.finance ?? []);
this.newsPanels['finance']?.renderNews(finance);
this.allNews.push(...finance);
// Gov
const gov = await fetchCategoryFeeds(FEEDS.gov ?? []);
this.newsPanels['gov']?.renderNews(gov);
this.allNews.push(...gov);
// Middle East
const middleeast = await fetchCategoryFeeds(FEEDS.middleeast ?? []);
this.newsPanels['middleeast']?.renderNews(middleeast);
this.allNews.push(...middleeast);
// Layoffs
const layoffs = await fetchCategoryFeeds(FEEDS.layoffs ?? []);
this.newsPanels['layoffs']?.renderNews(layoffs);
this.allNews.push(...layoffs);
// Congress Trades
const congress = await fetchCategoryFeeds(FEEDS.congress ?? []);
this.newsPanels['congress']?.renderNews(congress);
this.allNews.push(...congress);
// AI / ML
const ai = await fetchCategoryFeeds(FEEDS.ai ?? []);
this.newsPanels['ai']?.renderNews(ai);
this.allNews.push(...ai);
// Think Tanks
const thinktanks = await fetchCategoryFeeds(FEEDS.thinktanks ?? []);
this.newsPanels['thinktanks']?.renderNews(thinktanks);
this.allNews.push(...thinktanks);
// Intel
const intel = await fetchCategoryFeeds(INTEL_SOURCES);
this.newsPanels['intel']?.renderNews(intel);
this.allNews.push(...intel);
// Update map hotspots
this.map?.updateHotspotActivity(this.allNews);
// Update monitors
this.updateMonitorResults();
}
private async loadMarkets(): Promise<void> {
// Stocks
const stocks = await fetchMultipleStocks(MARKET_SYMBOLS);
(this.panels['markets'] as MarketPanel).renderMarkets(stocks);
// Sectors
const sectors = await fetchMultipleStocks(SECTORS.map((s) => ({ ...s, display: s.name })));
(this.panels['heatmap'] as HeatmapPanel).renderHeatmap(
sectors.map((s) => ({ name: s.name, change: s.change }))
);
// Commodities
const commodities = await fetchMultipleStocks(COMMODITIES);
(this.panels['commodities'] as CommoditiesPanel).renderCommodities(
commodities.map((c) => ({ display: c.display, price: c.price, change: c.change }))
);
// Crypto
const crypto = await fetchCrypto();
(this.panels['crypto'] as CryptoPanel).renderCrypto(crypto);
}
private async loadPredictions(): Promise<void> {
const predictions = await fetchPredictions();
(this.panels['polymarket'] as PredictionPanel).renderPredictions(predictions);
}
private async loadEarthquakes(): Promise<void> {
const earthquakes = await fetchEarthquakes();
this.map?.setEarthquakes(earthquakes);
}
private updateMonitorResults(): void {
const monitorPanel = this.panels['monitors'] as MonitorPanel;
monitorPanel.renderResults(this.allNews);
}
private setupRefreshIntervals(): void {
setInterval(() => this.loadNews(), REFRESH_INTERVALS.feeds);
setInterval(() => this.loadMarkets(), REFRESH_INTERVALS.markets);
setInterval(() => this.loadPredictions(), REFRESH_INTERVALS.predictions);
setInterval(() => this.loadEarthquakes(), 5 * 60 * 1000);
}
}

781
src/components/Map.ts Normal file
View File

@@ -0,0 +1,781 @@
import * as d3 from 'd3';
import * as topojson from 'topojson-client';
import type { Topology, GeometryCollection } from 'topojson-specification';
import type { MapLayers, Hotspot, NewsItem, Earthquake } from '@/types';
import {
MAP_URLS,
INTEL_HOTSPOTS,
CONFLICT_ZONES,
MILITARY_BASES,
UNDERSEA_CABLES,
NUCLEAR_FACILITIES,
SANCTIONED_COUNTRIES,
STRATEGIC_WATERWAYS,
APT_GROUPS,
} from '@/config';
import { MapPopup } from './MapPopup';
type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all';
type MapView = 'global' | 'us' | 'mena';
interface MapState {
zoom: number;
pan: { x: number; y: number };
view: MapView;
layers: MapLayers;
timeRange: TimeRange;
}
interface HotspotWithBreaking extends Hotspot {
hasBreaking?: boolean;
}
interface WorldTopology extends Topology {
objects: {
countries: GeometryCollection;
};
}
interface USTopology extends Topology {
objects: {
states: GeometryCollection;
};
}
export class MapComponent {
private container: HTMLElement;
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
private wrapper: HTMLElement;
private overlays: HTMLElement;
private state: MapState;
private worldData: WorldTopology | null = null;
private usData: USTopology | null = null;
private hotspots: HotspotWithBreaking[];
private earthquakes: Earthquake[] = [];
private news: NewsItem[] = [];
private popup: MapPopup;
private onHotspotClick?: (hotspot: Hotspot) => void;
private onTimeRangeChange?: (range: TimeRange) => void;
constructor(container: HTMLElement, initialState: MapState) {
this.container = container;
this.state = initialState;
this.hotspots = [...INTEL_HOTSPOTS];
this.wrapper = document.createElement('div');
this.wrapper.className = 'map-wrapper';
this.wrapper.id = 'mapWrapper';
const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgElement.classList.add('map-svg');
svgElement.id = 'mapSvg';
this.wrapper.appendChild(svgElement);
// Overlays inside wrapper so they transform together on zoom/pan
this.overlays = document.createElement('div');
this.overlays.id = 'mapOverlays';
this.wrapper.appendChild(this.overlays);
container.appendChild(this.wrapper);
container.appendChild(this.createControls());
container.appendChild(this.createTimeSlider());
container.appendChild(this.createLayerToggles());
container.appendChild(this.createLegend());
container.appendChild(this.createTimestamp());
this.svg = d3.select(svgElement);
this.popup = new MapPopup(container);
this.setupZoomHandlers();
this.loadMapData();
}
private createControls(): HTMLElement {
const controls = document.createElement('div');
controls.className = 'map-controls';
controls.innerHTML = `
<button class="map-control-btn" data-action="zoom-in">+</button>
<button class="map-control-btn" data-action="zoom-out"></button>
<button class="map-control-btn" data-action="reset">⟲</button>
`;
controls.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.dataset.action;
if (action === 'zoom-in') this.zoomIn();
else if (action === 'zoom-out') this.zoomOut();
else if (action === 'reset') this.reset();
});
return controls;
}
private createTimeSlider(): HTMLElement {
const slider = document.createElement('div');
slider.className = 'time-slider';
slider.id = 'timeSlider';
const ranges: { value: TimeRange; label: string }[] = [
{ value: '1h', label: '1H' },
{ value: '6h', label: '6H' },
{ value: '24h', label: '24H' },
{ value: '48h', label: '48H' },
{ value: '7d', label: '7D' },
{ value: 'all', label: 'ALL' },
];
slider.innerHTML = `
<span class="time-slider-label">TIME RANGE</span>
<div class="time-slider-buttons">
${ranges
.map(
(r) =>
`<button class="time-btn ${this.state.timeRange === r.value ? 'active' : ''}" data-range="${r.value}">${r.label}</button>`
)
.join('')}
</div>
`;
slider.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('time-btn')) {
const range = target.dataset.range as TimeRange;
this.setTimeRange(range);
slider.querySelectorAll('.time-btn').forEach((btn) => btn.classList.remove('active'));
target.classList.add('active');
}
});
return slider;
}
private setTimeRange(range: TimeRange): void {
this.state.timeRange = range;
this.onTimeRangeChange?.(range);
this.render();
}
private getTimeRangeMs(): number {
const ranges: Record<TimeRange, number> = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'48h': 48 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'all': Infinity,
};
return ranges[this.state.timeRange];
}
private filterByTime<T extends { time?: Date }>(items: T[]): T[] {
if (this.state.timeRange === 'all') return items;
const now = Date.now();
const cutoff = now - this.getTimeRangeMs();
return items.filter((item) => {
if (!item.time) return true;
return item.time.getTime() >= cutoff;
});
}
private createLayerToggles(): HTMLElement {
const toggles = document.createElement('div');
toggles.className = 'layer-toggles';
toggles.id = 'layerToggles';
const layers: (keyof MapLayers)[] = ['conflicts', 'bases', 'cables', 'hotspots', 'earthquakes', 'nuclear', 'sanctions'];
layers.forEach((layer) => {
const btn = document.createElement('button');
btn.className = `layer-toggle ${this.state.layers[layer] ? 'active' : ''}`;
btn.dataset.layer = layer;
btn.textContent = layer;
btn.addEventListener('click', () => this.toggleLayer(layer));
toggles.appendChild(btn);
});
return toggles;
}
private createLegend(): HTMLElement {
const legend = document.createElement('div');
legend.className = 'map-legend';
legend.innerHTML = `
<div class="map-legend-item"><span class="legend-dot high"></span>HIGH ALERT</div>
<div class="map-legend-item"><span class="legend-dot elevated"></span>ELEVATED</div>
<div class="map-legend-item"><span class="legend-dot low"></span>MONITORING</div>
<div class="map-legend-item"><span class="map-legend-icon conflict">⚔</span>CONFLICT</div>
<div class="map-legend-item"><span class="map-legend-icon earthquake">●</span>EARTHQUAKE</div>
<div class="map-legend-item"><span class="map-legend-icon apt">⚠</span>APT</div>
`;
return legend;
}
private createTimestamp(): HTMLElement {
const timestamp = document.createElement('div');
timestamp.className = 'map-timestamp';
timestamp.id = 'mapTimestamp';
this.updateTimestamp(timestamp);
setInterval(() => this.updateTimestamp(timestamp), 60000);
return timestamp;
}
private updateTimestamp(el: HTMLElement): void {
const now = new Date();
el.innerHTML = `LAST UPDATE: ${now.toUTCString().replace('GMT', 'UTC')}`;
}
private setupZoomHandlers(): void {
this.container.addEventListener(
'wheel',
(e) => {
e.preventDefault();
if (e.deltaY < 0) this.zoomIn();
else this.zoomOut();
},
{ passive: false }
);
}
private async loadMapData(): Promise<void> {
try {
const [worldResponse, usResponse] = await Promise.all([
fetch(MAP_URLS.world),
fetch(MAP_URLS.us),
]);
this.worldData = await worldResponse.json();
this.usData = await usResponse.json();
this.render();
} catch (e) {
console.error('Failed to load map data:', e);
}
}
public render(): void {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
this.svg.attr('viewBox', `0 0 ${width} ${height}`);
this.svg.selectAll('*').remove();
// Background
this.svg
.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', '#020a08');
// Grid
this.renderGrid(width, height);
// Setup projection
const projection = this.getProjection(width, height);
const path = d3.geoPath().projection(projection);
// Graticule
this.renderGraticule(path);
// Countries
this.renderCountries(path);
// Layers (show on global and mena views)
const showGlobalLayers = this.state.view === 'global' || this.state.view === 'mena';
if (this.state.layers.cables && showGlobalLayers) {
this.renderCables(projection);
}
if (this.state.layers.conflicts && showGlobalLayers) {
this.renderConflicts(projection);
}
if (this.state.layers.sanctions && showGlobalLayers) {
this.renderSanctions();
}
// Overlays
this.renderOverlays(projection);
this.applyTransform();
}
private renderGrid(width: number, height: number): void {
const gridGroup = this.svg.append('g').attr('class', 'grid');
for (let x = 0; x < width; x += 20) {
gridGroup
.append('line')
.attr('x1', x)
.attr('y1', 0)
.attr('x2', x)
.attr('y2', height)
.attr('stroke', '#0a2a20')
.attr('stroke-width', 0.5);
}
for (let y = 0; y < height; y += 20) {
gridGroup
.append('line')
.attr('x1', 0)
.attr('y1', y)
.attr('x2', width)
.attr('y2', y)
.attr('stroke', '#0a2a20')
.attr('stroke-width', 0.5);
}
}
private getProjection(width: number, height: number): d3.GeoProjection {
if (this.state.view === 'global' || this.state.view === 'mena') {
return d3
.geoEquirectangular()
.scale(width / (2 * Math.PI))
.center([0, 0])
.translate([width / 2, height / 2]);
}
return d3
.geoAlbersUsa()
.scale(width * 1.3)
.translate([width / 2, height / 2]);
}
private renderGraticule(path: d3.GeoPath): void {
const graticule = d3.geoGraticule();
this.svg
.append('path')
.datum(graticule())
.attr('class', 'graticule')
.attr('d', path)
.attr('fill', 'none')
.attr('stroke', '#1a5045')
.attr('stroke-width', 0.4);
}
private renderCountries(path: d3.GeoPath): void {
if ((this.state.view === 'global' || this.state.view === 'mena') && this.worldData) {
const countries = topojson.feature(
this.worldData,
this.worldData.objects.countries
);
const features = 'features' in countries ? countries.features : [countries];
this.svg
.selectAll('.country')
.data(features)
.enter()
.append('path')
.attr('class', 'country')
.attr('d', path as unknown as string)
.attr('fill', '#0d3028')
.attr('stroke', '#1a8060')
.attr('stroke-width', 0.7);
} else if (this.state.view === 'us' && this.usData) {
const states = topojson.feature(
this.usData,
this.usData.objects.states
);
const features = 'features' in states ? states.features : [states];
this.svg
.selectAll('.state')
.data(features)
.enter()
.append('path')
.attr('class', 'state')
.attr('d', path as unknown as string)
.attr('fill', '#0d3028')
.attr('stroke', '#1a8060')
.attr('stroke-width', 0.7);
}
}
private renderCables(projection: d3.GeoProjection): void {
const cableGroup = this.svg.append('g').attr('class', 'cables');
UNDERSEA_CABLES.forEach((cable) => {
const lineGenerator = d3
.line<[number, number]>()
.x((d) => projection(d)?.[0] ?? 0)
.y((d) => projection(d)?.[1] ?? 0)
.curve(d3.curveCardinal);
cableGroup
.append('path')
.attr('class', 'cable-path')
.attr('d', lineGenerator(cable.points));
});
}
private renderConflicts(projection: d3.GeoProjection): void {
const conflictGroup = this.svg.append('g').attr('class', 'conflicts');
CONFLICT_ZONES.forEach((zone) => {
const points = zone.coords
.map((c) => projection(c as [number, number]))
.filter((p): p is [number, number] => p !== null);
if (points.length > 0) {
conflictGroup
.append('polygon')
.attr('class', 'conflict-zone')
.attr('points', points.map((p) => p.join(',')).join(' '));
const centerPos = projection(zone.center as [number, number]);
if (centerPos) {
conflictGroup
.append('text')
.attr('class', 'conflict-label')
.attr('x', centerPos[0])
.attr('y', centerPos[1])
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.text(zone.name);
}
}
});
}
private renderSanctions(): void {
if (!this.worldData) return;
const sanctionColors: Record<string, string> = {
severe: 'rgba(255, 0, 0, 0.35)',
high: 'rgba(255, 100, 0, 0.25)',
moderate: 'rgba(255, 200, 0, 0.2)',
};
this.svg.selectAll('.country').each(function () {
const el = d3.select(this);
const id = el.datum() as { id?: number };
if (id?.id !== undefined && SANCTIONED_COUNTRIES[id.id]) {
const level = SANCTIONED_COUNTRIES[id.id];
if (level) {
el.attr('fill', sanctionColors[level] || '#0a2018');
}
}
});
}
private renderOverlays(projection: d3.GeoProjection): void {
this.overlays.innerHTML = '';
if (this.state.view !== 'global' && this.state.view !== 'mena') return;
// Strategic waterways
this.renderWaterways(projection);
// APT groups
this.renderAPTMarkers(projection);
// Nuclear facilities
if (this.state.layers.nuclear) {
NUCLEAR_FACILITIES.forEach((facility) => {
const pos = projection([facility.lon, facility.lat]);
if (!pos) return;
const div = document.createElement('div');
div.className = `nuclear-marker ${facility.status}`;
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
div.title = `${facility.name} (${facility.type})`;
const label = document.createElement('div');
label.className = 'nuclear-label';
label.textContent = facility.name;
div.appendChild(label);
this.overlays.appendChild(div);
});
}
// Conflict zone click areas
if (this.state.layers.conflicts) {
CONFLICT_ZONES.forEach((zone) => {
const centerPos = projection(zone.center as [number, number]);
if (!centerPos) return;
const clickArea = document.createElement('div');
clickArea.className = 'conflict-click-area';
clickArea.style.left = `${centerPos[0] - 40}px`;
clickArea.style.top = `${centerPos[1] - 20}px`;
clickArea.style.width = '80px';
clickArea.style.height = '40px';
clickArea.style.cursor = 'pointer';
clickArea.addEventListener('click', (e) => {
e.stopPropagation();
const rect = this.container.getBoundingClientRect();
this.popup.show({
type: 'conflict',
data: zone,
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
});
this.overlays.appendChild(clickArea);
});
}
// Hotspots
if (this.state.layers.hotspots) {
this.hotspots.forEach((spot) => {
const pos = projection([spot.lon, spot.lat]);
if (!pos) return;
const div = document.createElement('div');
div.className = 'hotspot';
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
const breakingBadge = spot.hasBreaking
? '<div class="hotspot-breaking">BREAKING</div>'
: '';
const subtextHtml = spot.subtext
? `<div class="hotspot-subtext">${spot.subtext}</div>`
: '';
div.innerHTML = `
${breakingBadge}
<div class="hotspot-marker ${spot.level || 'low'}"></div>
<div class="hotspot-label">${spot.name}</div>
${subtextHtml}
`;
div.addEventListener('click', (e) => {
e.stopPropagation();
const relatedNews = this.getRelatedNews(spot);
const rect = this.container.getBoundingClientRect();
this.popup.show({
type: 'hotspot',
data: spot,
relatedNews,
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
this.onHotspotClick?.(spot);
});
this.overlays.appendChild(div);
});
}
// Military bases
if (this.state.layers.bases) {
MILITARY_BASES.forEach((base) => {
const pos = projection([base.lon, base.lat]);
if (!pos) return;
const div = document.createElement('div');
div.className = `base-marker ${base.type}`;
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
div.title = base.name;
this.overlays.appendChild(div);
});
}
// Earthquakes
if (this.state.layers.earthquakes) {
const filteredQuakes = this.filterByTime(this.earthquakes);
filteredQuakes.forEach((eq) => {
const pos = projection([eq.lon, eq.lat]);
if (!pos) return;
const size = Math.max(8, eq.magnitude * 3);
const div = document.createElement('div');
div.className = 'earthquake-marker';
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
div.style.width = `${size}px`;
div.style.height = `${size}px`;
div.title = `M${eq.magnitude.toFixed(1)} - ${eq.place}`;
const label = document.createElement('div');
label.className = 'earthquake-label';
label.textContent = `M${eq.magnitude.toFixed(1)}`;
div.appendChild(label);
div.addEventListener('click', (e) => {
e.stopPropagation();
const rect = this.container.getBoundingClientRect();
this.popup.show({
type: 'earthquake',
data: eq,
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
});
this.overlays.appendChild(div);
});
}
}
private renderWaterways(projection: d3.GeoProjection): void {
STRATEGIC_WATERWAYS.forEach((waterway) => {
const pos = projection([waterway.lon, waterway.lat]);
if (!pos) return;
const div = document.createElement('div');
div.className = 'waterway-label';
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
div.innerHTML = `
<span class="waterway-name">${waterway.name}</span>
${waterway.description ? `<span class="waterway-desc">${waterway.description}</span>` : ''}
`;
div.title = waterway.description || waterway.name;
this.overlays.appendChild(div);
});
}
private renderAPTMarkers(projection: d3.GeoProjection): void {
APT_GROUPS.forEach((apt) => {
const pos = projection([apt.lon, apt.lat]);
if (!pos) return;
const div = document.createElement('div');
div.className = 'apt-marker';
div.style.left = `${pos[0]}px`;
div.style.top = `${pos[1]}px`;
div.innerHTML = `
<div class="apt-icon">⚠</div>
<div class="apt-label">${apt.name}</div>
`;
div.title = `${apt.name} (${apt.aka}) - ${apt.sponsor}`;
this.overlays.appendChild(div);
});
}
private getRelatedNews(hotspot: Hotspot): NewsItem[] {
return this.news.filter((item) => {
const titleLower = item.title.toLowerCase();
return hotspot.keywords.some((kw) => titleLower.includes(kw.toLowerCase()));
}).slice(0, 5);
}
public updateHotspotActivity(news: NewsItem[]): void {
this.news = news; // Store for related news lookup
this.hotspots.forEach((spot) => {
let score = 0;
let hasBreaking = false;
let matchedCount = 0;
news.forEach((item) => {
const titleLower = item.title.toLowerCase();
const matches = spot.keywords.filter((kw) => titleLower.includes(kw.toLowerCase()));
if (matches.length > 0) {
matchedCount++;
// Base score per match
score += matches.length * 2;
// Breaking news is critical
if (item.isAlert) {
score += 5;
hasBreaking = true;
}
// Recent news (last 6 hours) weighted higher
if (item.pubDate) {
const hoursAgo = (Date.now() - item.pubDate.getTime()) / (1000 * 60 * 60);
if (hoursAgo < 1) score += 3; // Last hour
else if (hoursAgo < 6) score += 2; // Last 6 hours
else if (hoursAgo < 24) score += 1; // Last day
}
}
});
spot.hasBreaking = hasBreaking;
// Dynamic level calculation - sensitive to real activity
// HIGH: Breaking news OR 4+ matching articles OR score >= 10
// ELEVATED: 2+ matching articles OR score >= 4
// LOW: Default when no significant activity
if (hasBreaking || matchedCount >= 4 || score >= 10) {
spot.level = 'high';
spot.status = hasBreaking ? 'BREAKING NEWS' : 'High activity';
} else if (matchedCount >= 2 || score >= 4) {
spot.level = 'elevated';
spot.status = 'Elevated activity';
} else if (matchedCount >= 1) {
spot.level = 'low';
spot.status = 'Recent mentions';
} else {
spot.level = 'low';
spot.status = 'Monitoring';
}
});
this.render();
}
public setView(view: MapView): void {
this.state.view = view;
// Reset zoom when changing views for better UX
this.state.zoom = view === 'mena' ? 2.5 : 1;
this.state.pan = view === 'mena' ? { x: -180, y: 60 } : { x: 0, y: 0 };
this.applyTransform();
this.render();
}
public toggleLayer(layer: keyof MapLayers): void {
this.state.layers[layer] = !this.state.layers[layer];
const btn = document.querySelector(`[data-layer="${layer}"]`);
btn?.classList.toggle('active');
this.render();
}
public zoomIn(): void {
this.state.zoom = Math.min(this.state.zoom + 0.5, 4);
this.applyTransform();
}
public zoomOut(): void {
this.state.zoom = Math.max(this.state.zoom - 0.5, 1);
this.applyTransform();
}
public reset(): void {
this.state.zoom = 1;
this.state.pan = { x: 0, y: 0 };
this.applyTransform();
}
private applyTransform(): void {
this.wrapper.style.transform = `scale(${this.state.zoom}) translate(${this.state.pan.x}px, ${this.state.pan.y}px)`;
}
public onHotspotClicked(callback: (hotspot: Hotspot) => void): void {
this.onHotspotClick = callback;
}
public onTimeRangeChanged(callback: (range: TimeRange) => void): void {
this.onTimeRangeChange = callback;
}
public getState(): MapState {
return { ...this.state };
}
public getTimeRange(): TimeRange {
return this.state.timeRange;
}
public setEarthquakes(earthquakes: Earthquake[]): void {
this.earthquakes = earthquakes;
this.render();
}
}
export type { TimeRange };

221
src/components/MapPopup.ts Normal file
View File

@@ -0,0 +1,221 @@
import type { ConflictZone, Hotspot, Earthquake, NewsItem } from '@/types';
export type PopupType = 'conflict' | 'hotspot' | 'earthquake';
interface PopupData {
type: PopupType;
data: ConflictZone | Hotspot | Earthquake;
relatedNews?: NewsItem[];
x: number;
y: number;
}
export class MapPopup {
private container: HTMLElement;
private popup: HTMLElement | null = null;
private onClose?: () => void;
constructor(container: HTMLElement) {
this.container = container;
}
public show(data: PopupData): void {
this.hide();
this.popup = document.createElement('div');
this.popup.className = 'map-popup';
const content = this.renderContent(data);
this.popup.innerHTML = content;
// Position popup
const maxX = this.container.clientWidth - 400;
const maxY = this.container.clientHeight - 300;
this.popup.style.left = `${Math.min(data.x + 20, maxX)}px`;
this.popup.style.top = `${Math.min(data.y - 20, maxY)}px`;
this.container.appendChild(this.popup);
// Close button handler
this.popup.querySelector('.popup-close')?.addEventListener('click', () => this.hide());
// Click outside to close
setTimeout(() => {
document.addEventListener('click', this.handleOutsideClick);
}, 100);
}
private handleOutsideClick = (e: MouseEvent) => {
if (this.popup && !this.popup.contains(e.target as Node)) {
this.hide();
}
};
public hide(): void {
if (this.popup) {
this.popup.remove();
this.popup = null;
document.removeEventListener('click', this.handleOutsideClick);
this.onClose?.();
}
}
public setOnClose(callback: () => void): void {
this.onClose = callback;
}
private renderContent(data: PopupData): string {
switch (data.type) {
case 'conflict':
return this.renderConflictPopup(data.data as ConflictZone);
case 'hotspot':
return this.renderHotspotPopup(data.data as Hotspot, data.relatedNews);
case 'earthquake':
return this.renderEarthquakePopup(data.data as Earthquake);
default:
return '';
}
}
private renderConflictPopup(conflict: ConflictZone): string {
const severityClass = conflict.intensity === 'high' ? 'high' : conflict.intensity === 'medium' ? 'medium' : 'low';
const severityLabel = conflict.intensity?.toUpperCase() || 'UNKNOWN';
return `
<div class="popup-header conflict">
<span class="popup-title">${conflict.name.toUpperCase()}</span>
<span class="popup-badge ${severityClass}">${severityLabel}</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body">
<div class="popup-stats">
<div class="popup-stat">
<span class="stat-label">START DATE</span>
<span class="stat-value">${conflict.startDate || 'Unknown'}</span>
</div>
<div class="popup-stat">
<span class="stat-label">CASUALTIES</span>
<span class="stat-value">${conflict.casualties || 'Unknown'}</span>
</div>
<div class="popup-stat">
<span class="stat-label">DISPLACED</span>
<span class="stat-value">${conflict.displaced || 'Unknown'}</span>
</div>
<div class="popup-stat">
<span class="stat-label">LOCATION</span>
<span class="stat-value">${conflict.location || `${conflict.center[1]}°N, ${conflict.center[0]}°E`}</span>
</div>
</div>
${conflict.description ? `<p class="popup-description">${conflict.description}</p>` : ''}
${conflict.parties && conflict.parties.length > 0 ? `
<div class="popup-section">
<span class="section-label">BELLIGERENTS</span>
<div class="popup-tags">
${conflict.parties.map(p => `<span class="popup-tag">${p}</span>`).join('')}
</div>
</div>
` : ''}
${conflict.keyDevelopments && conflict.keyDevelopments.length > 0 ? `
<div class="popup-section">
<span class="section-label">KEY DEVELOPMENTS</span>
<ul class="popup-list">
${conflict.keyDevelopments.map(d => `<li>${d}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
`;
}
private renderHotspotPopup(hotspot: Hotspot, relatedNews?: NewsItem[]): string {
const severityClass = hotspot.level || 'low';
const severityLabel = (hotspot.level || 'low').toUpperCase();
return `
<div class="popup-header hotspot">
<span class="popup-title">${hotspot.name.toUpperCase()}</span>
<span class="popup-badge ${severityClass}">${severityLabel}</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body">
${hotspot.subtext ? `<div class="popup-subtitle">${hotspot.subtext}</div>` : ''}
${hotspot.description ? `<p class="popup-description">${hotspot.description}</p>` : ''}
<div class="popup-stats">
<div class="popup-stat">
<span class="stat-label">COORDINATES</span>
<span class="stat-value">${hotspot.lat.toFixed(2)}°N, ${hotspot.lon.toFixed(2)}°E</span>
</div>
<div class="popup-stat">
<span class="stat-label">STATUS</span>
<span class="stat-value">${hotspot.status || 'Monitoring'}</span>
</div>
</div>
${hotspot.agencies && hotspot.agencies.length > 0 ? `
<div class="popup-section">
<span class="section-label">KEY ENTITIES</span>
<div class="popup-tags">
${hotspot.agencies.map(a => `<span class="popup-tag">${a}</span>`).join('')}
</div>
</div>
` : ''}
${relatedNews && relatedNews.length > 0 ? `
<div class="popup-section">
<span class="section-label">RELATED HEADLINES</span>
<div class="popup-news">
${relatedNews.slice(0, 5).map(n => `
<div class="popup-news-item">
<span class="news-source">${n.source}</span>
<a href="${n.link}" target="_blank" class="news-title">${n.title}</a>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
}
private renderEarthquakePopup(earthquake: Earthquake): string {
const severity = earthquake.magnitude >= 6 ? 'high' : earthquake.magnitude >= 5 ? 'medium' : 'low';
const severityLabel = earthquake.magnitude >= 6 ? 'MAJOR' : earthquake.magnitude >= 5 ? 'MODERATE' : 'MINOR';
const timeAgo = this.getTimeAgo(earthquake.time);
return `
<div class="popup-header earthquake">
<span class="popup-title magnitude">M${earthquake.magnitude.toFixed(1)}</span>
<span class="popup-badge ${severity}">${severityLabel}</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body">
<p class="popup-location">${earthquake.place}</p>
<div class="popup-stats">
<div class="popup-stat">
<span class="stat-label">Depth</span>
<span class="stat-value">${earthquake.depth.toFixed(1)} km</span>
</div>
<div class="popup-stat">
<span class="stat-label">Coordinates</span>
<span class="stat-value">${earthquake.lat.toFixed(2)}°, ${earthquake.lon.toFixed(2)}°</span>
</div>
<div class="popup-stat">
<span class="stat-label">Time</span>
<span class="stat-value">${timeAgo}</span>
</div>
</div>
<a href="${earthquake.url}" target="_blank" class="popup-link">View on USGS →</a>
</div>
`;
}
private getTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
}

View File

@@ -0,0 +1,130 @@
import { Panel } from './Panel';
import type { MarketData, CryptoData } from '@/types';
import { formatPrice, formatChange, getChangeClass, getHeatmapClass } from '@/utils';
export class MarketPanel extends Panel {
constructor() {
super({ id: 'markets', title: 'Markets' });
}
public renderMarkets(data: MarketData[]): void {
if (data.length === 0) {
this.showError('Failed to load market data');
return;
}
const html = data
.map(
(stock) => `
<div class="market-item">
<div class="market-info">
<span class="market-name">${stock.name}</span>
<span class="market-symbol">${stock.display}</span>
</div>
<div class="market-data">
<span class="market-price">${formatPrice(stock.price!)}</span>
<span class="market-change ${getChangeClass(stock.change!)}">${formatChange(stock.change!)}</span>
</div>
</div>
`
)
.join('');
this.setContent(html);
}
}
export class HeatmapPanel extends Panel {
constructor() {
super({ id: 'heatmap', title: 'Sector Heatmap' });
}
public renderHeatmap(data: Array<{ name: string; change: number | null }>): void {
const validData = data.filter((d) => d.change !== null);
if (validData.length === 0) {
this.showError('Failed to load sector data');
return;
}
const html =
'<div class="heatmap">' +
validData
.map(
(sector) => `
<div class="heatmap-cell ${getHeatmapClass(sector.change!)}">
<div class="sector-name">${sector.name}</div>
<div class="sector-change ${getChangeClass(sector.change!)}">${formatChange(sector.change!)}</div>
</div>
`
)
.join('') +
'</div>';
this.setContent(html);
}
}
export class CommoditiesPanel extends Panel {
constructor() {
super({ id: 'commodities', title: 'Commodities / VIX' });
}
public renderCommodities(data: Array<{ display: string; price: number | null; change: number | null }>): void {
const validData = data.filter((d) => d.price !== null);
if (validData.length === 0) {
this.showError('Failed to load commodities');
return;
}
const html =
'<div class="commodities-grid">' +
validData
.map(
(c) => `
<div class="commodity-item">
<div class="commodity-name">${c.display}</div>
<div class="commodity-price">${formatPrice(c.price!)}</div>
<div class="commodity-change ${getChangeClass(c.change!)}">${formatChange(c.change!)}</div>
</div>
`
)
.join('') +
'</div>';
this.setContent(html);
}
}
export class CryptoPanel extends Panel {
constructor() {
super({ id: 'crypto', title: 'Crypto' });
}
public renderCrypto(data: CryptoData[]): void {
if (data.length === 0) {
this.showError('Failed to load crypto data');
return;
}
const html = data
.map(
(coin) => `
<div class="market-item">
<div class="market-info">
<span class="market-name">${coin.name}</span>
<span class="market-symbol">${coin.symbol}</span>
</div>
<div class="market-data">
<span class="market-price">$${coin.price.toLocaleString()}</span>
<span class="market-change ${getChangeClass(coin.change)}">${formatChange(coin.change)}</span>
</div>
</div>
`
)
.join('');
this.setContent(html);
}
}

View File

@@ -0,0 +1,150 @@
import { Panel } from './Panel';
import type { Monitor, NewsItem } from '@/types';
import { MONITOR_COLORS } from '@/config';
import { generateId, formatTime } from '@/utils';
export class MonitorPanel extends Panel {
private monitors: Monitor[] = [];
private onMonitorsChange?: (monitors: Monitor[]) => void;
constructor(initialMonitors: Monitor[] = []) {
super({ id: 'monitors', title: 'My Monitors' });
this.monitors = initialMonitors;
this.renderInput();
}
private renderInput(): void {
this.content.innerHTML = '';
const inputContainer = document.createElement('div');
inputContainer.className = 'monitor-input-container';
inputContainer.innerHTML = `
<input type="text" class="monitor-input" id="monitorKeywords" placeholder="Keywords (comma separated)">
<button class="monitor-add-btn" id="addMonitorBtn">+ Add Monitor</button>
`;
this.content.appendChild(inputContainer);
const monitorsList = document.createElement('div');
monitorsList.id = 'monitorsList';
this.content.appendChild(monitorsList);
const monitorsResults = document.createElement('div');
monitorsResults.id = 'monitorsResults';
this.content.appendChild(monitorsResults);
inputContainer.querySelector('#addMonitorBtn')?.addEventListener('click', () => {
this.addMonitor();
});
const input = inputContainer.querySelector('#monitorKeywords') as HTMLInputElement;
input?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.addMonitor();
});
this.renderMonitorsList();
}
private addMonitor(): void {
const input = document.getElementById('monitorKeywords') as HTMLInputElement;
const keywords = input.value.trim();
if (!keywords) return;
const monitor: Monitor = {
id: generateId(),
keywords: keywords.split(',').map((k) => k.trim().toLowerCase()),
color: MONITOR_COLORS[this.monitors.length % MONITOR_COLORS.length] ?? '#44ff88',
};
this.monitors.push(monitor);
input.value = '';
this.renderMonitorsList();
this.onMonitorsChange?.(this.monitors);
}
public removeMonitor(id: string): void {
this.monitors = this.monitors.filter((m) => m.id !== id);
this.renderMonitorsList();
this.onMonitorsChange?.(this.monitors);
}
private renderMonitorsList(): void {
const list = document.getElementById('monitorsList');
if (!list) return;
list.innerHTML = this.monitors
.map(
(m) => `
<span class="monitor-tag">
<span class="monitor-tag-color" style="background: ${m.color}"></span>
${m.keywords.join(', ')}
<span class="monitor-tag-remove" data-id="${m.id}">×</span>
</span>
`
)
.join('');
list.querySelectorAll('.monitor-tag-remove').forEach((el) => {
el.addEventListener('click', (e) => {
const id = (e.target as HTMLElement).dataset.id;
if (id) this.removeMonitor(id);
});
});
}
public renderResults(news: NewsItem[]): void {
const results = document.getElementById('monitorsResults');
if (!results) return;
if (this.monitors.length === 0) {
results.innerHTML =
'<div style="color: var(--text-dim); font-size: 10px; margin-top: 12px;">Add keywords to monitor news</div>';
return;
}
const matchedItems: NewsItem[] = [];
news.forEach((item) => {
this.monitors.forEach((monitor) => {
const matched = monitor.keywords.some((kw) =>
item.title.toLowerCase().includes(kw)
);
if (matched) {
matchedItems.push({ ...item, monitorColor: monitor.color });
}
});
});
if (matchedItems.length === 0) {
results.innerHTML =
'<div style="color: var(--text-dim); font-size: 10px; margin-top: 12px;">No matches found</div>';
return;
}
results.innerHTML = matchedItems
.slice(0, 10)
.map(
(item) => `
<div class="item" style="border-left: 2px solid ${item.monitorColor}; padding-left: 8px; margin-left: -8px;">
<div class="item-source">${item.source}</div>
<a class="item-title" href="${item.link}" target="_blank" rel="noopener">${item.title}</a>
<div class="item-time">${formatTime(item.pubDate)}</div>
</div>
`
)
.join('');
}
public onChanged(callback: (monitors: Monitor[]) => void): void {
this.onMonitorsChange = callback;
}
public getMonitors(): Monitor[] {
return [...this.monitors];
}
public setMonitors(monitors: Monitor[]): void {
this.monitors = monitors;
this.renderMonitorsList();
}
}

View File

@@ -0,0 +1,35 @@
import { Panel } from './Panel';
import type { NewsItem } from '@/types';
import { formatTime } from '@/utils';
export class NewsPanel extends Panel {
constructor(id: string, title: string) {
super({ id, title, showCount: true });
}
public renderNews(items: NewsItem[]): void {
if (items.length === 0) {
this.showError('No news available');
return;
}
this.setCount(items.length);
const html = items
.map(
(item) => `
<div class="item ${item.isAlert ? 'alert' : ''}" ${item.monitorColor ? `style="border-left-color: ${item.monitorColor}"` : ''}>
<div class="item-source">
${item.source}
${item.isAlert ? '<span class="alert-tag">ALERT</span>' : ''}
</div>
<a class="item-title" href="${item.link}" target="_blank" rel="noopener">${item.title}</a>
<div class="item-time">${formatTime(item.pubDate)}</div>
</div>
`
)
.join('');
this.setContent(html);
}
}

82
src/components/Panel.ts Normal file
View File

@@ -0,0 +1,82 @@
export interface PanelOptions {
id: string;
title: string;
showCount?: boolean;
className?: string;
}
export class Panel {
protected element: HTMLElement;
protected content: HTMLElement;
protected countEl: HTMLElement | null = null;
constructor(options: PanelOptions) {
this.element = document.createElement('div');
this.element.className = `panel ${options.className || ''}`;
this.element.dataset.panel = options.id;
const header = document.createElement('div');
header.className = 'panel-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'panel-header-left';
const title = document.createElement('span');
title.className = 'panel-title';
title.textContent = options.title;
headerLeft.appendChild(title);
header.appendChild(headerLeft);
if (options.showCount) {
this.countEl = document.createElement('span');
this.countEl.className = 'panel-count';
this.countEl.textContent = '0';
header.appendChild(this.countEl);
}
this.content = document.createElement('div');
this.content.className = 'panel-content';
this.content.id = `${options.id}Content`;
this.element.appendChild(header);
this.element.appendChild(this.content);
this.showLoading();
}
public getElement(): HTMLElement {
return this.element;
}
public showLoading(): void {
this.content.innerHTML = '<div class="loading">Loading</div>';
}
public showError(message = 'Failed to load data'): void {
this.content.innerHTML = `<div class="error-message">${message}</div>`;
}
public setCount(count: number): void {
if (this.countEl) {
this.countEl.textContent = count.toString();
}
}
public setContent(html: string): void {
this.content.innerHTML = html;
}
public show(): void {
this.element.classList.remove('hidden');
}
public hide(): void {
this.element.classList.add('hidden');
}
public toggle(visible: boolean): void {
if (visible) this.show();
else this.hide();
}
}

View File

@@ -0,0 +1,31 @@
import { Panel } from './Panel';
import type { PredictionMarket } from '@/types';
export class PredictionPanel extends Panel {
constructor() {
super({ id: 'polymarket', title: 'Prediction Markets' });
}
public renderPredictions(data: PredictionMarket[]): void {
if (data.length === 0) {
this.showError('Failed to load predictions');
return;
}
const html = data
.map(
(p) => `
<div class="prediction-item">
<div class="prediction-question">${p.title}</div>
<div class="prediction-bar">
<div class="prediction-yes" style="width: ${p.yesPrice}%">${p.yesPrice.toFixed(0)}%</div>
<div class="prediction-no">${(100 - p.yesPrice).toFixed(0)}%</div>
</div>
</div>
`
)
.join('');
this.setContent(html);
}
}

7
src/components/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './Panel';
export * from './Map';
export * from './MapPopup';
export * from './NewsPanel';
export * from './MarketPanel';
export * from './PredictionPanel';
export * from './MonitorPanel';

72
src/config/feeds.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { Feed } from '@/types';
export const FEEDS: Record<string, Feed[]> = {
politics: [
{ name: 'BBC World', url: '/rss/bbc/news/world/rss.xml' },
{ name: 'NPR News', url: '/rss/npr/1001/rss.xml' },
{ name: 'Guardian World', url: '/rss/guardian/world/rss' },
{ name: 'Reuters', url: '/rss/reuters/feed/?taxonomy=best-sectors&post_type=best' },
{ name: 'The Diplomat', url: '/rss/diplomat/feed/' },
],
middleeast: [
{ name: 'BBC Middle East', url: '/rss/bbc/news/world/middle_east/rss.xml' },
{ name: 'Al Jazeera', url: '/rss/aljazeera/xml/rss/all.xml' },
{ name: 'Guardian ME', url: '/rss/guardian/world/middleeast/rss' },
{ name: 'CNN Middle East', url: '/rss/cnn/rss/edition_meast.rss' },
],
tech: [
{ name: 'Hacker News', url: '/rss/hn/frontpage' },
{ name: 'Ars Technica', url: '/rss/arstechnica/arstechnica/technology-lab' },
{ name: 'The Verge', url: '/rss/verge/rss/index.xml' },
{ name: 'MIT Tech Review', url: '/rss/techreview/feed/' },
],
ai: [
{ name: 'OpenAI Blog', url: '/rss/openai/blog/rss.xml' },
{ name: 'Anthropic', url: '/rss/anthropic/rss.xml' },
{ name: 'Google AI', url: '/rss/googleai/technology/ai/rss/' },
{ name: 'DeepMind', url: '/rss/deepmind/blog/rss.xml' },
{ name: 'Hugging Face', url: '/rss/huggingface/blog/feed.xml' },
{ name: 'ArXiv AI', url: '/rss/arxiv/rss/cs.AI' },
],
finance: [
{ name: 'CNBC', url: '/rss/cnbc/id/100003114/device/rss/rss.html' },
{ name: 'MarketWatch', url: '/rss/marketwatch/marketwatch/topstories' },
{ name: 'Yahoo Finance', url: '/rss/yahoonews/news/rssindex' },
],
gov: [
{ name: 'White House', url: '/rss/whitehouse/feed/' },
{ name: 'State Dept', url: '/rss/statedept/rss-feed/press-releases/feed/' },
{ name: 'Federal Reserve', url: '/rss/fedreserve/feeds/press_all.xml' },
{ name: 'SEC', url: '/rss/sec/news/pressreleases.rss' },
{ name: 'Treasury', url: '/rss/treasury/system/files/136/treasury-rss.xml' },
],
layoffs: [
{ name: 'TechCrunch Layoffs', url: '/rss/techcrunch/tag/layoffs/feed/' },
{ name: 'Layoffs News', url: '/rss/googlenews/rss/search?q=tech+layoffs+2025+job+cuts&hl=en-US&gl=US&ceid=US:en' },
],
congress: [
{ name: 'Congress Trades', url: '/rss/googlenews/rss/search?q=congress+stock+trading+pelosi+tuberville&hl=en-US&gl=US&ceid=US:en' },
],
thinktanks: [
{ name: 'Brookings', url: '/rss/brookings/feed/' },
{ name: 'CFR', url: '/rss/cfr/rss.xml' },
{ name: 'CSIS', url: '/rss/csis/analysis/feed' },
],
};
export const INTEL_SOURCES: Feed[] = [
{ name: 'Defense One', url: '/rss/defenseone/rss/all/', type: 'defense' },
{ name: 'War on Rocks', url: '/rss/warontherocks/feed/', type: 'defense' },
{ name: 'Breaking Defense', url: '/rss/breakingdefense/feed/', type: 'defense' },
{ name: 'The War Zone', url: '/rss/warzone/the-war-zone/feed', type: 'defense' },
{ name: 'Bellingcat', url: '/rss/bellingcat/feed/', type: 'osint' },
{ name: 'CISA Alerts', url: '/rss/cisa/uscert/ncas/alerts.xml', type: 'cyber' },
{ name: 'Krebs Security', url: '/rss/krebs/feed/', type: 'cyber' },
];
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',
];

407
src/config/geo.ts Normal file
View File

@@ -0,0 +1,407 @@
import type { Hotspot, ConflictZone, MilitaryBase, UnderseaCable, NuclearFacility, StrategicWaterway, APTGroup } from '@/types';
// Hotspot levels are NOT hardcoded - they are dynamically calculated based on news activity
// All hotspots start at 'low' and rise to 'elevated' or 'high' based on matching news items
export const INTEL_HOTSPOTS: Hotspot[] = [
{
id: 'dc',
name: 'DC',
subtext: 'Pentagon Pizza Index',
lat: 38.9,
lon: -77.0,
keywords: ['pentagon', 'white house', 'congress', 'cia', 'nsa', 'washington', 'biden', 'trump', 'house', 'senate', 'supreme court', 'vance', 'elon', 'us '],
agencies: ['Pentagon', 'CIA', 'NSA', 'State Dept'],
description: 'US government and military headquarters. Intelligence community center.',
status: 'Monitoring',
},
{
id: 'moscow',
name: 'Moscow',
subtext: 'Kremlin Activity',
lat: 55.75,
lon: 37.6,
keywords: ['kremlin', 'putin', 'russia', 'fsb', 'moscow', 'russian'],
agencies: ['Kremlin', 'FSB', 'GRU', 'SVR'],
description: 'Russian Federation command center. Military operations hub.',
status: 'Monitoring',
},
{
id: 'beijing',
name: 'Beijing',
subtext: 'PLA/MSS Activity',
lat: 39.9,
lon: 116.4,
keywords: ['beijing', 'xi', 'china', 'pla', 'ccp', 'chinese', 'jinping'],
agencies: ['PLA', 'MSS', 'CCP Politburo'],
description: 'Chinese Communist Party headquarters. PLA command center.',
status: 'Monitoring',
},
{
id: 'kyiv',
name: 'Kyiv',
subtext: 'Conflict Zone',
lat: 50.45,
lon: 30.5,
keywords: ['kyiv', 'ukraine', 'zelensky', 'ukrainian', 'kiev'],
agencies: ['Ukrainian Armed Forces', 'SBU'],
description: 'Active conflict zone. NATO support operations.',
status: 'Monitoring',
},
{
id: 'taipei',
name: 'Taipei',
subtext: 'Strait Watch',
lat: 25.03,
lon: 121.5,
keywords: ['taiwan', 'taipei', 'tsmc', 'strait', 'taiwanese'],
agencies: ['ROC Military', 'TSMC'],
description: 'Taiwan Strait tensions. Semiconductor supply chain.',
status: 'Monitoring',
},
{
id: 'tehran',
name: 'Tehran',
subtext: 'IRGC Activity',
lat: 35.7,
lon: 51.4,
keywords: ['iran', 'tehran', 'irgc', 'khamenei', 'persian', 'iranian'],
agencies: ['IRGC', 'Quds Force', 'MOIS'],
description: 'Iranian nuclear program. Regional proxy operations.',
status: 'Monitoring',
},
{
id: 'telaviv',
name: 'Tel Aviv',
subtext: 'Mossad/IDF',
lat: 32.1,
lon: 34.8,
keywords: ['israel', 'idf', 'mossad', 'gaza', 'netanyahu', 'israeli', 'hamas', 'hezbollah'],
agencies: ['IDF', 'Mossad', 'Shin Bet'],
description: 'Military operations. Regional security. Intelligence activities.',
status: 'Monitoring',
},
{
id: 'pyongyang',
name: 'Pyongyang',
subtext: 'DPRK Watch',
lat: 39.0,
lon: 125.75,
keywords: ['north korea', 'kim', 'pyongyang', 'dprk', 'korean'],
agencies: ['KPA', 'RGB', 'Lazarus Group'],
description: 'Nuclear weapons program. Missile testing. Cyber operations.',
status: 'Monitoring',
},
{
id: 'london',
name: 'London',
subtext: 'GCHQ/MI6',
lat: 51.5,
lon: -0.12,
keywords: ['london', 'uk', 'britain', 'gchq', 'mi6', 'british'],
agencies: ['MI6', 'GCHQ', 'MI5'],
description: 'UK intelligence headquarters. Five Eyes member.',
status: 'Monitoring',
},
{
id: 'brussels',
name: 'Brussels',
subtext: 'NATO HQ',
lat: 50.85,
lon: 4.35,
keywords: ['nato', 'brussels', 'eu', 'european union', 'europe'],
agencies: ['NATO', 'EU Commission'],
description: 'NATO alliance headquarters. European Union center.',
status: 'Monitoring',
},
{
id: 'caracas',
name: 'Caracas',
subtext: 'Venezuela Crisis',
lat: 10.5,
lon: -66.9,
keywords: ['venezuela', 'maduro', 'caracas', 'venezuelan'],
agencies: ['Maduro Govt', 'SEBIN'],
description: 'Political crisis. Economic sanctions. Regional instability.',
status: 'Monitoring',
},
{
id: 'nuuk',
name: 'Nuuk',
subtext: 'Arctic Dispute',
lat: 64.18,
lon: -51.7,
keywords: ['greenland', 'nuuk', 'arctic', 'denmark', 'danish'],
agencies: ['Danish Defence', 'US Space Force', 'Arctic Council'],
description: 'Arctic strategic territory. US military presence, sovereignty questions.',
status: 'Monitoring',
},
// Middle East hotspots
{
id: 'riyadh',
name: 'Riyadh',
subtext: 'Saudi GIP/MBS',
lat: 24.7,
lon: 46.7,
keywords: ['saudi', 'riyadh', 'mbs', 'aramco', 'opec', 'saudi arabia'],
agencies: ['GIP', 'Saudi Royal Court', 'Aramco'],
description: 'Saudi Arabia power center. OPEC+ decisions. Regional influence.',
status: 'Monitoring',
},
{
id: 'cairo',
name: 'Cairo',
subtext: 'Egypt/GIS',
lat: 30.0,
lon: 31.2,
keywords: ['egypt', 'cairo', 'sisi', 'egyptian', 'suez'],
agencies: ['GIS', 'Egyptian Armed Forces'],
description: 'Egyptian command. Gaza border control. Suez Canal security.',
status: 'Monitoring',
},
{
id: 'baghdad',
name: 'Baghdad',
subtext: 'Iraq/PMF',
lat: 33.3,
lon: 44.4,
keywords: ['iraq', 'baghdad', 'iraqi', 'pmf', 'militia'],
agencies: ['Iraqi Security Forces', 'PMF', 'US Embassy'],
description: 'Iraqi government. Iran-backed militias. US military presence.',
status: 'Monitoring',
},
{
id: 'damascus',
name: 'Damascus',
subtext: 'Syria Crisis',
lat: 33.5,
lon: 36.3,
keywords: ['syria', 'damascus', 'assad', 'syrian', 'hts'],
agencies: ['Syrian Govt', 'HTS', 'Russian Forces', 'Turkish Forces'],
description: 'Syrian civil war aftermath. Multiple foreign interventions.',
status: 'Monitoring',
},
{
id: 'doha',
name: 'Doha',
subtext: 'Qatar/Al Udeid',
lat: 25.3,
lon: 51.5,
keywords: ['qatar', 'doha', 'qatari', 'al jazeera'],
agencies: ['Qatari State Security', 'CENTCOM Forward HQ'],
description: 'Qatar diplomatic hub. US CENTCOM base. Al Jazeera HQ.',
status: 'Monitoring',
},
{
id: 'ankara',
name: 'Ankara',
subtext: 'Turkey/MIT',
lat: 39.9,
lon: 32.9,
keywords: ['turkey', 'ankara', 'erdogan', 'turkish', 'mit'],
agencies: ['MIT', 'Turkish Armed Forces', 'AKP'],
description: 'NATO member. Kurdish conflict. Syria/Libya operations.',
status: 'Monitoring',
},
{
id: 'beirut',
name: 'Beirut',
subtext: 'Lebanon/Hezbollah',
lat: 33.9,
lon: 35.5,
keywords: ['lebanon', 'beirut', 'hezbollah', 'lebanese', 'nasrallah'],
agencies: ['LAF', 'Hezbollah', 'UNIFIL'],
description: 'Lebanon crisis. Hezbollah stronghold. Israel border tensions.',
status: 'Monitoring',
},
{
id: 'sanaa',
name: "Sana'a",
subtext: 'Yemen/Houthis',
lat: 15.4,
lon: 44.2,
keywords: ['yemen', 'houthi', 'sanaa', 'yemeni', 'red sea'],
agencies: ['Houthi Forces', 'Saudi Coalition', 'US Navy'],
description: 'Yemen conflict. Houthi Red Sea attacks. Shipping disruption.',
status: 'Monitoring',
},
{
id: 'abudhabi',
name: 'Abu Dhabi',
subtext: 'UAE/ECSR',
lat: 24.5,
lon: 54.4,
keywords: ['uae', 'abu dhabi', 'emirates', 'emirati', 'dubai'],
agencies: ['ECSR', 'UAE Armed Forces'],
description: 'UAE strategic hub. Regional military operations.',
status: 'Monitoring',
},
];
export const STRATEGIC_WATERWAYS: StrategicWaterway[] = [
{ id: 'taiwan_strait', name: 'TAIWAN STRAIT', lat: 24.0, lon: 119.5, description: 'Critical shipping lane, PLA activity' },
{ id: 'malacca_strait', name: 'MALACCA STRAIT', lat: 2.5, lon: 101.5, description: 'Major oil shipping route' },
{ id: 'hormuz_strait', name: 'STRAIT OF HORMUZ', lat: 26.5, lon: 56.5, description: 'Oil chokepoint, Iran control' },
{ id: 'bosphorus', name: 'BOSPHORUS STRAIT', lat: 41.1, lon: 29.0, description: 'Black Sea access, Turkey control' },
{ id: 'suez', name: 'SUEZ CANAL', lat: 30.5, lon: 32.3, description: 'Europe-Asia shipping' },
{ id: 'panama', name: 'PANAMA CANAL', lat: 9.1, lon: -79.7, description: 'Americas shipping route' },
];
export const APT_GROUPS: APTGroup[] = [
{ id: 'apt28', name: 'APT28/29', aka: 'Fancy Bear/Cozy Bear', sponsor: 'Russia (GRU/FSB)', lat: 55.0, lon: 40.0 },
{ id: 'apt41', name: 'APT41', aka: 'Double Dragon', sponsor: 'China (MSS)', lat: 38.0, lon: 118.0 },
{ id: 'lazarus', name: 'Lazarus', aka: 'Hidden Cobra', sponsor: 'North Korea (RGB)', lat: 38.5, lon: 127.0 },
{ id: 'apt33', name: 'APT33/35', aka: 'Elfin/Charming Kitten', sponsor: 'Iran (IRGC)', lat: 34.0, lon: 53.0 },
];
export const CONFLICT_ZONES: ConflictZone[] = [
{
id: 'ukraine',
name: 'Ukraine Conflict',
coords: [[30, 52], [40, 52], [40, 44], [30, 44]],
center: [35, 48],
intensity: 'high',
parties: ['Russia', 'Ukraine', 'NATO (support)'],
casualties: '500,000+ (est.)',
displaced: '6.5M+ refugees',
keywords: ['ukraine', 'russia', 'zelensky', 'putin', 'donbas', 'crimea'],
startDate: 'Feb 24, 2022',
location: '48.0°N, 37.5°E',
description: 'Full-scale Russian invasion of Ukraine. Active frontlines in Donetsk, Luhansk, Zaporizhzhia, and Kherson oblasts. Heavy artillery, drone warfare, and trench combat.',
keyDevelopments: ['Battle of Bakhmut', 'Kursk incursion', 'Black Sea drone strikes', 'Infrastructure attacks'],
},
{
id: 'gaza',
name: 'Gaza Conflict',
coords: [[34, 32], [35, 32], [35, 31], [34, 31]],
center: [34.5, 31.5],
intensity: 'high',
parties: ['Israel', 'Hamas', 'Hezbollah', 'PIJ'],
casualties: '40,000+ (Gaza)',
displaced: '2M+ displaced',
keywords: ['gaza', 'israel', 'hamas', 'palestinian'],
startDate: 'Oct 7, 2023',
location: '31.5°N, 34.5°E',
description: 'Israeli military operations in Gaza following October 7 attacks. Ground invasion, aerial bombardment. Humanitarian crisis. Regional escalation with Hezbollah.',
keyDevelopments: ['Rafah ground operation', 'Humanitarian crisis', 'Hostage negotiations', 'Iran-backed attacks'],
},
{
id: 'sudan',
name: 'Sudan Civil War',
coords: [[22, 22], [38, 22], [38, 8], [22, 8]],
center: [30, 15],
intensity: 'high',
parties: ['Sudanese Armed Forces (SAF)', 'Rapid Support Forces (RSF)'],
casualties: '15,000+ killed',
displaced: '10M+ displaced',
keywords: ['sudan', 'khartoum', 'darfur'],
startDate: 'Apr 15, 2023',
location: '15.0°N, 32.5°E',
description: 'Power struggle between SAF and RSF paramilitary. Fighting centered around Khartoum, Darfur. Major humanitarian catastrophe with famine conditions.',
keyDevelopments: ['Khartoum battle', 'Darfur massacres', 'El Fasher siege', 'Famine declared'],
},
{
id: 'myanmar',
name: 'Myanmar Civil War',
coords: [[92, 28], [101, 28], [101, 10], [92, 10]],
center: [96, 19],
intensity: 'medium',
parties: ['Military junta', 'NUG', 'Ethnic armed groups'],
casualties: '50,000+ (est.)',
displaced: '2.6M+ displaced',
keywords: ['myanmar', 'burma', 'rohingya'],
startDate: 'Feb 1, 2021',
location: '19.0°N, 96.0°E',
description: 'Civil war following military coup. Resistance forces gaining ground. Multiple ethnic armed organizations. Humanitarian crisis.',
keyDevelopments: ['Operation 1027', 'Junta airstrikes', 'Border clashes', 'Resistance advances'],
},
];
export const MILITARY_BASES: MilitaryBase[] = [
// US/NATO
{ id: 'ramstein', name: 'Ramstein AB', lat: 49.44, lon: 7.6, type: 'us-nato' },
{ id: 'diego_garcia', name: 'Diego Garcia', lat: -7.32, lon: 72.42, type: 'us-nato' },
{ id: 'okinawa', name: 'Okinawa', lat: 26.5, lon: 127.9, type: 'us-nato' },
{ id: 'guam', name: 'Guam', lat: 13.45, lon: 144.8, type: 'us-nato' },
{ id: 'qatar', name: 'Al Udeid AB', lat: 25.12, lon: 51.32, type: 'us-nato' },
{ id: 'djibouti_us', name: 'Camp Lemonnier', lat: 11.55, lon: 43.15, type: 'us-nato' },
{ id: 'bahrain', name: 'NSA Bahrain', lat: 26.23, lon: 50.58, type: 'us-nato' },
{ id: 'yokosuka', name: 'Yokosuka', lat: 35.28, lon: 139.67, type: 'us-nato' },
{ id: 'rota', name: 'Naval Rota', lat: 36.62, lon: -6.35, type: 'us-nato' },
{ id: 'incirlik', name: 'Incirlik AB', lat: 37.0, lon: 35.43, type: 'us-nato' },
// China
{ id: 'djibouti_cn', name: 'PLA Djibouti', lat: 11.59, lon: 43.05, type: 'china' },
{ id: 'woody_island', name: 'Woody Island', lat: 16.83, lon: 112.33, type: 'china' },
{ id: 'fiery_cross', name: 'Fiery Cross', lat: 9.55, lon: 112.89, type: 'china' },
{ id: 'mischief_reef', name: 'Mischief Reef', lat: 9.9, lon: 115.53, type: 'china' },
{ id: 'subi_reef', name: 'Subi Reef', lat: 10.92, lon: 114.08, type: 'china' },
// Russia
{ id: 'kaliningrad', name: 'Kaliningrad', lat: 54.71, lon: 20.51, type: 'russia' },
{ id: 'tartus', name: 'Tartus (Syria)', lat: 34.89, lon: 35.87, type: 'russia' },
{ id: 'sevastopol', name: 'Sevastopol', lat: 44.6, lon: 33.5, type: 'russia' },
{ id: 'vladivostok', name: 'Vladivostok', lat: 43.12, lon: 131.9, type: 'russia' },
{ id: 'murmansk', name: 'Murmansk', lat: 68.97, lon: 33.09, type: 'russia' },
];
export const UNDERSEA_CABLES: UnderseaCable[] = [
{
id: 'transatlantic_1',
name: 'TAT-14',
points: [[-74.0, 40.7], [-30.0, 45.0], [-9.0, 52.0]],
major: true,
},
{
id: 'transpacific_1',
name: 'Unity',
points: [[-122.4, 37.8], [-155.0, 25.0], [139.7, 35.7]],
major: true,
},
{
id: 'seamewe5',
name: 'SEA-ME-WE 5',
points: [[103.8, 1.3], [73.0, 15.0], [43.0, 12.0], [32.5, 30.0], [12.5, 42.0]],
major: true,
},
{
id: 'aae1',
name: 'AAE-1',
points: [[103.8, 1.3], [80.0, 6.0], [55.0, 15.0], [43.0, 12.0], [36.0, 32.0]],
major: true,
},
{
id: 'curie',
name: 'Curie',
points: [[-122.4, 37.8], [-90.0, 10.0], [-77.0, -12.0]],
major: true,
},
{
id: 'marea',
name: 'MAREA',
points: [[-73.0, 39.0], [-30.0, 42.0], [-9.0, 37.0]],
major: true,
},
];
export const NUCLEAR_FACILITIES: NuclearFacility[] = [
{ id: 'zaporizhzhia', name: 'Zaporizhzhia NPP', lat: 47.51, lon: 34.58, type: 'plant', status: 'contested' },
{ id: 'natanz', name: 'Natanz', lat: 33.72, lon: 51.73, type: 'enrichment', status: 'active' },
{ id: 'fordow', name: 'Fordow', lat: 34.88, lon: 51.0, type: 'enrichment', status: 'active' },
{ id: 'yongbyon', name: 'Yongbyon', lat: 39.8, lon: 125.75, type: 'weapons', status: 'active' },
{ id: 'dimona', name: 'Dimona', lat: 31.0, lon: 35.15, type: 'weapons', status: 'active' },
];
export const SANCTIONED_COUNTRIES: Record<number, 'severe' | 'high' | 'moderate'> = {
408: 'severe', // North Korea
728: 'severe', // South Sudan
760: 'severe', // Syria
364: 'high', // Iran
643: 'high', // Russia
112: 'high', // Belarus
862: 'moderate', // Venezuela
104: 'moderate', // Myanmar
178: 'moderate', // Congo
};
export const MAP_URLS = {
world: 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json',
us: 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json',
};

20
src/config/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export * from './feeds';
export * from './markets';
export * from './geo';
export * from './panels';
export const API_URLS = {
yahooFinance: (symbol: string) =>
`/api/yahoo/v8/finance/chart/${encodeURIComponent(symbol)}`,
coingecko:
'/api/coingecko/api/v3/simple/price?ids=bitcoin,ethereum,solana&vs_currencies=usd&include_24hr_change=true',
polymarket: '/api/polymarket/events?closed=false&limit=20',
earthquakes: '/api/earthquake/earthquakes/feed/v1.0/summary/4.5_day.geojson',
};
export const REFRESH_INTERVALS = {
feeds: 5 * 60 * 1000, // 5 minutes
markets: 60 * 1000, // 1 minute
crypto: 60 * 1000, // 1 minute
predictions: 5 * 60 * 1000, // 5 minutes
};

64
src/config/markets.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { Sector, Commodity, MarketSymbol } from '@/types';
export const SECTORS: Sector[] = [
{ symbol: 'XLK', name: 'Tech' },
{ symbol: 'XLF', name: 'Finance' },
{ symbol: 'XLE', name: 'Energy' },
{ symbol: 'XLV', name: 'Health' },
{ symbol: 'XLY', name: 'Consumer' },
{ symbol: 'XLI', name: 'Industrial' },
{ symbol: 'XLP', name: 'Staples' },
{ symbol: 'XLU', name: 'Utilities' },
{ symbol: 'XLB', name: 'Materials' },
{ symbol: 'XLRE', name: 'Real Est' },
{ symbol: 'XLC', name: 'Comms' },
{ symbol: 'SMH', name: 'Semis' },
];
export const COMMODITIES: Commodity[] = [
{ symbol: '^VIX', name: 'VIX', display: 'VIX' },
{ symbol: 'GC=F', name: 'Gold', display: 'GOLD' },
{ symbol: 'CL=F', name: 'Crude Oil', display: 'OIL' },
{ symbol: 'NG=F', name: 'Natural Gas', display: 'NATGAS' },
{ symbol: 'SI=F', name: 'Silver', display: 'SILVER' },
{ symbol: 'HG=F', name: 'Copper', display: 'COPPER' },
];
export const MARKET_SYMBOLS: MarketSymbol[] = [
{ symbol: '^GSPC', name: 'S&P 500', display: 'SPX' },
{ symbol: '^DJI', name: 'Dow Jones', display: 'DOW' },
{ symbol: '^IXIC', name: 'NASDAQ', display: 'NDX' },
{ symbol: 'AAPL', name: 'Apple', display: 'AAPL' },
{ symbol: 'MSFT', name: 'Microsoft', display: 'MSFT' },
{ symbol: 'NVDA', name: 'NVIDIA', display: 'NVDA' },
{ symbol: 'GOOGL', name: 'Alphabet', display: 'GOOGL' },
{ symbol: 'AMZN', name: 'Amazon', display: 'AMZN' },
{ symbol: 'META', name: 'Meta', display: 'META' },
{ symbol: 'BRK-B', name: 'Berkshire', display: 'BRK.B' },
{ symbol: 'TSM', name: 'TSMC', display: 'TSM' },
{ symbol: 'LLY', name: 'Eli Lilly', display: 'LLY' },
{ symbol: 'TSLA', name: 'Tesla', display: 'TSLA' },
{ symbol: 'AVGO', name: 'Broadcom', display: 'AVGO' },
{ symbol: 'WMT', name: 'Walmart', display: 'WMT' },
{ symbol: 'JPM', name: 'JPMorgan', display: 'JPM' },
{ symbol: 'V', name: 'Visa', display: 'V' },
{ symbol: 'UNH', name: 'UnitedHealth', display: 'UNH' },
{ symbol: 'NVO', name: 'Novo Nordisk', display: 'NVO' },
{ symbol: 'XOM', name: 'Exxon', display: 'XOM' },
{ symbol: 'MA', name: 'Mastercard', display: 'MA' },
{ symbol: 'ORCL', name: 'Oracle', display: 'ORCL' },
{ symbol: 'PG', name: 'P&G', display: 'PG' },
{ symbol: 'COST', name: 'Costco', display: 'COST' },
{ symbol: 'JNJ', name: 'J&J', display: 'JNJ' },
{ symbol: 'HD', name: 'Home Depot', display: 'HD' },
{ symbol: 'NFLX', name: 'Netflix', display: 'NFLX' },
{ symbol: 'BAC', name: 'BofA', display: 'BAC' },
];
export const CRYPTO_IDS = ['bitcoin', 'ethereum', 'solana'] as const;
export const CRYPTO_MAP: Record<string, { name: string; symbol: string }> = {
bitcoin: { name: 'Bitcoin', symbol: 'BTC' },
ethereum: { name: 'Ethereum', symbol: 'ETH' },
solana: { name: 'Solana', symbol: 'SOL' },
};

50
src/config/panels.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { PanelConfig, MapLayers } from '@/types';
export const DEFAULT_PANELS: Record<string, PanelConfig> = {
map: { name: 'Global Map', enabled: true, priority: 1 },
politics: { name: 'World News', enabled: true, priority: 1 },
middleeast: { name: 'Middle East', enabled: true, priority: 1 },
tech: { name: 'Technology', enabled: true, priority: 1 },
ai: { name: 'AI/ML', enabled: true, priority: 1 },
finance: { name: 'Financial', enabled: true, priority: 1 },
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
markets: { name: 'Markets', enabled: true, priority: 1 },
monitors: { name: 'My Monitors', enabled: true, priority: 1 },
commodities: { name: 'Commodities', enabled: true, priority: 2 },
polymarket: { name: 'Predictions', enabled: true, priority: 2 },
gov: { name: 'Government', enabled: true, priority: 2 },
intel: { name: 'Intel Feed', enabled: true, priority: 2 },
crypto: { name: 'Crypto', enabled: true, priority: 2 },
layoffs: { name: 'Layoffs Tracker', enabled: true, priority: 2 },
congress: { name: 'Congress Trades', enabled: true, priority: 2 },
thinktanks: { name: 'Think Tanks', enabled: true, priority: 2 },
};
export const DEFAULT_MAP_LAYERS: MapLayers = {
conflicts: true,
bases: true,
cables: true,
hotspots: true,
nuclear: true,
sanctions: true,
earthquakes: true,
};
export const MONITOR_COLORS = [
'#44ff88',
'#ff8844',
'#4488ff',
'#ff44ff',
'#ffff44',
'#ff4444',
'#44ffff',
'#88ff44',
'#ff88ff',
'#88ffff',
];
export const STORAGE_KEYS = {
panels: 'situation-monitor-panels',
monitors: 'situation-monitor-monitors',
mapLayers: 'situation-monitor-layers',
} as const;

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import './styles/main.css';
import { App } from './App';
const app = new App('app');
app.init().catch(console.error);

View File

@@ -0,0 +1,40 @@
import type { Earthquake } from '@/types';
import { API_URLS } from '@/config';
interface USGSFeature {
id: string;
properties: {
place: string;
mag: number;
time: number;
url: string;
};
geometry: {
coordinates: [number, number, number];
};
}
interface USGSResponse {
features: USGSFeature[];
}
export async function fetchEarthquakes(): Promise<Earthquake[]> {
try {
const response = await fetch(API_URLS.earthquakes);
const data: USGSResponse = await response.json();
return data.features.map((feature) => ({
id: feature.id,
place: feature.properties.place || 'Unknown',
magnitude: feature.properties.mag,
lon: feature.geometry.coordinates[0],
lat: feature.geometry.coordinates[1],
depth: feature.geometry.coordinates[2],
time: new Date(feature.properties.time),
url: feature.properties.url,
}));
} catch (e) {
console.error('Failed to fetch earthquakes:', e);
return [];
}
}

4
src/services/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './rss';
export * from './markets';
export * from './polymarket';
export * from './earthquakes';

90
src/services/markets.ts Normal file
View File

@@ -0,0 +1,90 @@
import type { MarketData, CryptoData } from '@/types';
import { API_URLS, CRYPTO_MAP } from '@/config';
interface YahooFinanceResponse {
chart: {
result: Array<{
meta: {
regularMarketPrice: number;
chartPreviousClose?: number;
previousClose?: number;
};
}>;
};
}
interface CoinGeckoResponse {
[key: string]: {
usd: number;
usd_24h_change: number;
};
}
export async function fetchStockQuote(
symbol: string,
name: string,
display: string
): Promise<MarketData> {
try {
const url = API_URLS.yahooFinance(symbol);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: YahooFinanceResponse = await response.json();
const meta = data.chart.result[0]?.meta;
if (!meta) {
return { symbol, name, display, price: null, change: null };
}
const price = meta.regularMarketPrice;
const prevClose = meta.chartPreviousClose || meta.previousClose || price;
const change = ((price - prevClose) / prevClose) * 100;
return {
symbol,
name,
display,
price,
change,
};
} catch (e) {
console.error(`Failed to fetch ${symbol}:`, e);
return { symbol, name, display, price: null, change: null };
}
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export async function fetchMultipleStocks(
symbols: Array<{ symbol: string; name: string; display: string }>
): Promise<MarketData[]> {
const results: MarketData[] = [];
// Sequential fetch with delay to avoid rate limiting
for (const s of symbols) {
const result = await fetchStockQuote(s.symbol, s.name, s.display);
results.push(result);
await delay(500); // 500ms delay between requests
}
return results.filter((r) => r.price !== null);
}
export async function fetchCrypto(): Promise<CryptoData[]> {
try {
const response = await fetch(API_URLS.coingecko);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: CoinGeckoResponse = await response.json();
return Object.entries(CRYPTO_MAP).map(([id, info]) => {
const coinData = data[id];
return {
name: info.name,
symbol: info.symbol,
price: coinData?.usd ?? 0,
change: coinData?.usd_24h_change ?? 0,
};
});
} catch (e) {
console.error('Failed to fetch crypto:', e);
return [];
}
}

View File

@@ -0,0 +1,38 @@
import type { PredictionMarket } from '@/types';
import { API_URLS } from '@/config';
interface PolymarketEvent {
title: string;
markets?: Array<{
outcomePrices?: string[];
volume?: string;
}>;
}
export async function fetchPredictions(): Promise<PredictionMarket[]> {
try {
const response = await fetch(API_URLS.polymarket);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: PolymarketEvent[] = await response.json();
return data
.slice(0, 10)
.map((event) => {
const market = event.markets?.[0];
const rawPrice = market?.outcomePrices?.[0];
const parsed = rawPrice ? parseFloat(rawPrice) : NaN;
const yesPrice = isNaN(parsed) ? 50 : parsed * 100;
const volume = market?.volume ? parseFloat(market.volume) : undefined;
return {
title: event.title,
yesPrice,
volume,
};
})
.filter((p) => p.title && !isNaN(p.yesPrice));
} catch (e) {
console.error('Failed to fetch predictions:', e);
return [];
}
}

52
src/services/rss.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Feed, NewsItem } from '@/types';
import { ALERT_KEYWORDS } from '@/config';
export async function fetchFeed(feed: Feed): Promise<NewsItem[]> {
try {
const response = await fetch(feed.url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/xml');
const parseError = doc.querySelector('parsererror');
if (parseError) {
console.warn(`Parse error for ${feed.name}`);
return [];
}
const items = doc.querySelectorAll('item');
return Array.from(items)
.slice(0, 5)
.map((item) => {
const title = item.querySelector('title')?.textContent || '';
const link = item.querySelector('link')?.textContent || '';
const pubDateStr = item.querySelector('pubDate')?.textContent || '';
const pubDate = pubDateStr ? new Date(pubDateStr) : new Date();
const isAlert = ALERT_KEYWORDS.some((kw) =>
title.toLowerCase().includes(kw)
);
return {
source: feed.name,
title,
link,
pubDate,
isAlert,
};
});
} catch (e) {
console.error(`Failed to fetch ${feed.name}:`, e);
return [];
}
}
export async function fetchCategoryFeeds(feeds: Feed[]): Promise<NewsItem[]> {
const results = await Promise.all(feeds.map(fetchFeed));
const items = results.flat();
items.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
return items.slice(0, 20);
}

1420
src/styles/main.css Normal file

File diff suppressed because it is too large Load Diff

187
src/types/index.ts Normal file
View File

@@ -0,0 +1,187 @@
export interface Feed {
name: string;
url: string;
type?: string;
region?: string;
}
export interface NewsItem {
source: string;
title: string;
link: string;
pubDate: Date;
isAlert: boolean;
monitorColor?: string;
}
export interface Sector {
symbol: string;
name: string;
}
export interface Commodity {
symbol: string;
name: string;
display: string;
}
export interface MarketSymbol {
symbol: string;
name: string;
display: string;
}
export interface MarketData {
symbol: string;
name: string;
display: string;
price: number | null;
change: number | null;
}
export interface CryptoData {
name: string;
symbol: string;
price: number;
change: number;
}
export interface Hotspot {
id: string;
name: string;
lat: number;
lon: number;
keywords: string[];
subtext?: string;
agencies?: string[];
level?: 'low' | 'elevated' | 'high';
description?: string;
status?: string;
}
export interface StrategicWaterway {
id: string;
name: string;
lat: number;
lon: number;
description?: string;
}
export interface APTGroup {
id: string;
name: string;
aka: string;
sponsor: string;
lat: number;
lon: number;
}
export interface ConflictZone {
id: string;
name: string;
coords: [number, number][];
center: [number, number];
intensity?: 'high' | 'medium' | 'low';
parties?: string[];
casualties?: string;
displaced?: string;
keywords?: string[];
startDate?: string;
location?: string;
description?: string;
keyDevelopments?: string[];
}
export interface MilitaryBase {
id: string;
name: string;
lat: number;
lon: number;
type: 'us-nato' | 'china' | 'russia';
}
export interface UnderseaCable {
id: string;
name: string;
points: [number, number][];
major?: boolean;
}
export interface ShippingChokepoint {
id: string;
name: string;
lat: number;
lon: number;
desc: string;
}
export interface CyberRegion {
id: string;
group: string;
aka: string;
sponsor: string;
}
export interface NuclearFacility {
id: string;
name: string;
lat: number;
lon: number;
type: 'plant' | 'enrichment' | 'weapons';
status: 'active' | 'contested' | 'inactive';
}
export interface Earthquake {
id: string;
place: string;
magnitude: number;
lat: number;
lon: number;
depth: number;
time: Date;
url: string;
}
export interface Monitor {
id: string;
keywords: string[];
color: string;
name?: string;
lat?: number;
lon?: number;
}
export interface PanelConfig {
name: string;
enabled: boolean;
priority?: number;
}
export interface MapLayers {
conflicts: boolean;
bases: boolean;
cables: boolean;
hotspots: boolean;
nuclear: boolean;
sanctions: boolean;
earthquakes: boolean;
}
export interface PredictionMarket {
title: string;
yesPrice: number;
volume?: number;
}
export interface AppState {
currentView: 'global' | 'us';
mapZoom: number;
mapPan: { x: number; y: number };
mapLayers: MapLayers;
panels: Record<string, PanelConfig>;
monitors: Monitor[];
allNews: NewsItem[];
isLoading: boolean;
}
export type FeedCategory = 'politics' | 'tech' | 'finance' | 'gov' | 'intel';

75
src/utils/index.ts Normal file
View File

@@ -0,0 +1,75 @@
export function formatTime(date: Date): string {
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
export function formatPrice(price: number): string {
if (price >= 1000) {
return `$${price.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`;
}
return `$${price.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}
export function formatChange(change: number): string {
const sign = change >= 0 ? '+' : '';
return `${sign}${change.toFixed(2)}%`;
}
export function getChangeClass(change: number): string {
return change >= 0 ? 'up' : 'down';
}
export function getHeatmapClass(change: number): string {
const abs = Math.abs(change);
const direction = change >= 0 ? 'up' : 'down';
if (abs >= 2) return `${direction}-3`;
if (abs >= 1) return `${direction}-2`;
return `${direction}-1`;
}
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function loadFromStorage<T>(key: string, defaultValue: T): T {
try {
const stored = localStorage.getItem(key);
if (stored) {
return JSON.parse(stored) as T;
}
} catch (e) {
console.warn(`Failed to load ${key} from storage:`, e);
}
return defaultValue;
}
export function saveToStorage<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.warn(`Failed to save ${key} to storage:`, e);
}
}
export function generateId(): string {
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

257
vite.config.ts Normal file
View File

@@ -0,0 +1,257 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
open: true,
proxy: {
// Yahoo Finance API
'/api/yahoo': {
target: 'https://query1.finance.yahoo.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/yahoo/, ''),
},
// CoinGecko API
'/api/coingecko': {
target: 'https://api.coingecko.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/coingecko/, ''),
},
// Polymarket API
'/api/polymarket': {
target: 'https://gamma-api.polymarket.com',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api\/polymarket/, ''),
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('Polymarket proxy error:', err.message);
});
},
},
// USGS Earthquake API
'/api/earthquake': {
target: 'https://earthquake.usgs.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/earthquake/, ''),
},
// RSS Feeds - BBC
'/rss/bbc': {
target: 'https://feeds.bbci.co.uk',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/bbc/, ''),
},
// RSS Feeds - Guardian
'/rss/guardian': {
target: 'https://www.theguardian.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/guardian/, ''),
},
// RSS Feeds - NPR
'/rss/npr': {
target: 'https://feeds.npr.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/npr/, ''),
},
// RSS Feeds - Reuters
'/rss/reuters': {
target: 'https://www.reutersagency.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/reuters/, ''),
},
// RSS Feeds - Al Jazeera
'/rss/aljazeera': {
target: 'https://www.aljazeera.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/aljazeera/, ''),
},
// RSS Feeds - CNN
'/rss/cnn': {
target: 'http://rss.cnn.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cnn/, ''),
},
// RSS Feeds - Hacker News
'/rss/hn': {
target: 'https://hnrss.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/hn/, ''),
},
// RSS Feeds - Ars Technica
'/rss/arstechnica': {
target: 'https://feeds.arstechnica.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/arstechnica/, ''),
},
// RSS Feeds - The Verge
'/rss/verge': {
target: 'https://www.theverge.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/verge/, ''),
},
// RSS Feeds - CNBC
'/rss/cnbc': {
target: 'https://www.cnbc.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cnbc/, ''),
},
// RSS Feeds - MarketWatch
'/rss/marketwatch': {
target: 'https://feeds.marketwatch.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/marketwatch/, ''),
},
// RSS Feeds - Defense/Intel sources
'/rss/defenseone': {
target: 'https://www.defenseone.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/defenseone/, ''),
},
'/rss/warontherocks': {
target: 'https://warontherocks.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/warontherocks/, ''),
},
'/rss/breakingdefense': {
target: 'https://breakingdefense.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/breakingdefense/, ''),
},
'/rss/bellingcat': {
target: 'https://www.bellingcat.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/bellingcat/, ''),
},
// RSS Feeds - TechCrunch (layoffs)
'/rss/techcrunch': {
target: 'https://techcrunch.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/techcrunch/, ''),
},
// Google News RSS
'/rss/googlenews': {
target: 'https://news.google.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/googlenews/, ''),
},
// AI Company Blogs
'/rss/openai': {
target: 'https://openai.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/openai/, ''),
},
'/rss/anthropic': {
target: 'https://www.anthropic.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/anthropic/, ''),
},
'/rss/googleai': {
target: 'https://blog.google',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/googleai/, ''),
},
'/rss/deepmind': {
target: 'https://deepmind.google',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/deepmind/, ''),
},
'/rss/huggingface': {
target: 'https://huggingface.co',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/huggingface/, ''),
},
'/rss/techreview': {
target: 'https://www.technologyreview.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/techreview/, ''),
},
'/rss/arxiv': {
target: 'https://rss.arxiv.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/arxiv/, ''),
},
// Government
'/rss/whitehouse': {
target: 'https://www.whitehouse.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/whitehouse/, ''),
},
'/rss/statedept': {
target: 'https://www.state.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/statedept/, ''),
},
'/rss/fedreserve': {
target: 'https://www.federalreserve.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/fedreserve/, ''),
},
'/rss/sec': {
target: 'https://www.sec.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/sec/, ''),
},
'/rss/treasury': {
target: 'https://home.treasury.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/treasury/, ''),
},
'/rss/cisa': {
target: 'https://www.cisa.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cisa/, ''),
},
// Think Tanks
'/rss/brookings': {
target: 'https://www.brookings.edu',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/brookings/, ''),
},
'/rss/cfr': {
target: 'https://www.cfr.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/cfr/, ''),
},
'/rss/csis': {
target: 'https://www.csis.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/csis/, ''),
},
// Defense
'/rss/warzone': {
target: 'https://www.thedrive.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/warzone/, ''),
},
'/rss/defensegov': {
target: 'https://www.defense.gov',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/defensegov/, ''),
},
// Security
'/rss/krebs': {
target: 'https://krebsonsecurity.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/krebs/, ''),
},
// Finance
'/rss/yahoonews': {
target: 'https://finance.yahoo.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/yahoonews/, ''),
},
// Diplomat
'/rss/diplomat': {
target: 'https://thediplomat.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/rss\/diplomat/, ''),
},
},
},
});