mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.playwright-mcp/
|
||||
379
FEATURE_GAP_ANALYSIS.md
Normal file
379
FEATURE_GAP_ANALYSIS.md
Normal 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
|
||||
548
SITUATION_MONITOR_ANALYSIS.md
Normal file
548
SITUATION_MONITOR_ANALYSIS.md
Normal 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
13
index.html
Normal 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
1916
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
508
src/App.ts
Normal 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
781
src/components/Map.ts
Normal 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
221
src/components/MapPopup.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
130
src/components/MarketPanel.ts
Normal file
130
src/components/MarketPanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
150
src/components/MonitorPanel.ts
Normal file
150
src/components/MonitorPanel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
35
src/components/NewsPanel.ts
Normal file
35
src/components/NewsPanel.ts
Normal 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
82
src/components/Panel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
31
src/components/PredictionPanel.ts
Normal file
31
src/components/PredictionPanel.ts
Normal 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
7
src/components/index.ts
Normal 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
72
src/config/feeds.ts
Normal 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
407
src/config/geo.ts
Normal 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
20
src/config/index.ts
Normal 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
64
src/config/markets.ts
Normal 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
50
src/config/panels.ts
Normal 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
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import './styles/main.css';
|
||||
import { App } from './App';
|
||||
|
||||
const app = new App('app');
|
||||
app.init().catch(console.error);
|
||||
40
src/services/earthquakes.ts
Normal file
40
src/services/earthquakes.ts
Normal 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
4
src/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './rss';
|
||||
export * from './markets';
|
||||
export * from './polymarket';
|
||||
export * from './earthquakes';
|
||||
90
src/services/markets.ts
Normal file
90
src/services/markets.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
38
src/services/polymarket.ts
Normal file
38
src/services/polymarket.ts
Normal 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
52
src/services/rss.ts
Normal 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
1420
src/styles/main.css
Normal file
File diff suppressed because it is too large
Load Diff
187
src/types/index.ts
Normal file
187
src/types/index.ts
Normal 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
75
src/utils/index.ts
Normal 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
26
tsconfig.json
Normal 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
257
vite.config.ts
Normal 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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user