Compare commits

...
30 Commits
Author SHA1 Message Date
Elie Habib add310349b chore: bump version to 2.2.2 2026-02-15 14:10:35 +04:00
Elie Habib 6d100a6db6 Merge country-brief-page: full-page brief, geometry consolidation, news dedup
- Full-page Country Brief replacing modal overlay
- Tightened headline relevance with negative country filter
- Top News section replacing redundant Evidence/Sources
- Shared country-geometry service for local-first detection
- BR/AE added to tier-1, deep-link name resolution
- Prediction Markets moved to right column for balanced layout
2026-02-15 14:10:08 +04:00
Elie Habib 5c91c0ee71 fix: normalize country name from GeoJSON to canonical TIER1 name
GeoJSON uses "United States of America" but COUNTRY_TAG_MAP and
variant matching expect "United States". Normalize at entry point
so polymarket, search terms, and all downstream lookups work.
2026-02-15 12:21:40 +04:00
Elie Habib 30dfbd0a85 refactor: consolidate news into Top News, remove redundant Evidence section
- Remove Evidence/Sources section (duplicate of Top News)
- Top News now shows 8 items with citation anchor IDs
- AI brief [N] citations link to Top News cards with scroll+highlight
- Move Prediction Markets to right column for balanced layout
- Left: Risk + Brief + Top News | Right: Signals + Timeline + Markets + Infra
2026-02-15 12:14:55 +04:00
Elie Habib 4e8758cb54 Consolidate country detection around shared geometry service 2026-02-15 12:02:57 +04:00
Elie Habib 9bda79e10b fix: add BR/AE to tier-1, resolve deep-link names, fix military timeline filter
- Add Brazil and UAE to TIER1_COUNTRIES, BASELINE_RISK, EVENT_MULTIPLIER,
  COUNTRY_KEYWORDS, COUNTRY_BOUNDS, COUNTRY_ALIASES, and Polymarket maps
- Use Intl.DisplayNames to resolve non-tier ISO codes in deep-link URLs
- Guard raw 2-letter codes from becoming overly broad search terms
- Fix military timeline to use same either/or logic as getCountrySignals
2026-02-15 11:42:45 +04:00
Elie Habib e6fe946b34 fix: tighten headline relevance, add Top News section, compact markets
- Add negative country filter: headlines where another country is mentioned
  first in the title are excluded (fixes Venezuela brief showing for US)
- Filtered headlines used for both evidence section AND LLM context
- Add Top News section (5 items) between Intelligence Brief and Markets
- Limit prediction markets to max 3, tighter padding
2026-02-15 11:27:06 +04:00
Elie Habib e663741f4a fix: hide desktop config panel on web, fix irrelevant prediction markets
- RuntimeConfigPanel only created when isDesktopApp is true (was leaking
  full desktop config UI including secret key inputs to web visitors)
- Fix polymarket sub-market selection: when event title doesn't match
  country but a sub-market does, restrict candidates to matching
  sub-markets instead of picking highest-volume globally (was showing
  Xi Jinping market for France because Macron sub-market triggered match)
2026-02-15 11:16:25 +04:00
Elie Habib 458be05a59 feat: add full-page Country Brief Page replacing modal overlay
Replace the small CountryIntelModal with a comprehensive full-page
intelligence product per country. Implements all 5 phases of the plan:

Phase 1 - Full-page shell + bug fixes:
- CountryBriefPage.ts: score ring, component bars, signal chips,
  non-tier fallback, loading states
- Fix hardcoded earthquakes: 0 with bbox + place name filtering
- Export getCountryData, TIER1_COUNTRIES from country-instability
- Expand CountryBriefSignals with displacement, climate, conflict

Phase 2 - D3 multi-lane timeline:
- CountryTimeline.ts: 4 lanes, 7-day filter, severity-sized circles
- XSS-safe tooltip rendering via escapeHtml

Phase 3 - Infrastructure + Evidence:
- Export getNearbyInfrastructure from related-assets
- Local BriefAssetType union, port proximity lookup
- Evidence cards with threat levels and numbered references

Phase 4 - AI brief citations + Export:
- LLM prompt cites headlines as [N], bounded regex parsing
- Export dropdown: Image PNG, JSON, CSV, Print/PDF

Phase 5 - Deep-linking + Polish:
- country field in URL state, pre-capture before sync
- Force URL rewrite on open/close, mobile + print styles

Bug fixes: URL sync race, async response guards,
briefRequestToken cancellation, listener accumulation,
tier-1 detection via membership not data existence
2026-02-15 11:06:09 +04:00
Elie Habib b0e48f47f8 docs: add download badges and web app links to README
Shield.io badges for Windows, macOS ARM, and macOS Intel desktop downloads
pointing to the new /api/download redirect. Web app links restyled as badges.
2026-02-15 10:42:38 +04:00
Elie Habib d2568576a3 Add download redirect API for platform-specific installers
Edge function that redirects /api/download?platform=macos-arm64 to
the matching asset from the latest GitHub release. Falls back to
the releases page when no match is found.
2026-02-15 10:28:52 +04:00
Elie Habib 77fc5fe4bd fix(macos): hide window on close instead of quitting
Standard macOS behavior — app stays in dock, reopens on dock click.
2026-02-15 10:26:43 +04:00
Elie Habib 3512937658 fix: tone down climate anomalies heatmap to stop obscuring other layers
Halve radius, reduce intensity/opacity so markers and labels remain visible.
2026-02-15 10:22:10 +04:00
Elie Habib 7494e0780d Add auto-generated changelog to GitHub releases
New post-build job generates release notes from git commits between tags
and updates the release body via gh release edit.
2026-02-15 09:53:43 +04:00
Elie Habib a17b1b02e6 Fix release always created as draft on tag push
releaseDraft used != (OR) making tag pushes always draft.
Changed to == (AND) so only workflow_dispatch with draft=true creates drafts.
2026-02-15 09:41:43 +04:00
Elie Habib fd631ca86c perf: harden regression guardrails in CI, cache, and map clustering 2026-02-15 08:45:49 +04:00
Elie Habib 6dfdffc364 ci: set max timeout for desktop build matrix 2026-02-15 08:20:02 +04:00
Elie Habib 441b6f1a90 ci: harden tauri desktop build workflow 2026-02-15 08:14:12 +04:00
Elie Habib 6a293b1f18 Fix settings window show/focus even when init fails 2026-02-15 08:02:27 +04:00
Elie HabibandGitHub 68b3b22ad9 Fix: constrain layers menu height in DeckGLMap (#65)
I noticed a small UI bug where the Layers dropdown menu becomes too tall
causing it to underlap the Global Situation and also overlap the time
slider header. Also I made the scroll bar a little thinner it looks nice

This PR should fix it .
Fixed UI:
<img width="1892" height="537" alt="Screenshot 2026-02-14 235518"
src="https://github.com/user-attachments/assets/01e96b51-13bf-4d8a-89f7-c944ecee6722"
/>
<img width="1887" height="537" alt="Screenshot 2026-02-14 235529"
src="https://github.com/user-attachments/assets/cf605260-d33a-4400-9d26-df80f74193a9"
/>
2026-02-15 00:21:37 +04:00
Elie Habib 1912e248c6 Bump v2.2.1, remove CLAUDE.md from repo and add to .gitignore 2026-02-15 00:16:46 +04:00
Elie Habib e1925e735c Consolidate variant naming and fix PWA tile caching
- Rename variant default from 'world' to 'full' across config files
- Replace all startups.worldmonitor.app references with tech.worldmonitor.app
- Add CARTO basemap tile caching to Workbox runtime config (basemaps.cartocdn.com)
2026-02-15 00:15:33 +04:00
Elie Habib 5b1f980b70 Fix Windows settings window: async command, no menu bar, no white flash
- Make open_settings_window_command async to prevent WebView2 deadlock on Windows
- Create settings window with visible(false) to avoid white flash before content loads
- Remove menu bar from settings window on Windows/Linux (macOS uses screen-level menu)
- Frontend calls plugin:window|show + set_focus after init completes
2026-02-15 00:15:23 +04:00
Elie Habib a439992094 Add 40-minute timeout to desktop build jobs
Prevents builds from hanging indefinitely when Apple notarization
is slow or credentials are misconfigured.
2026-02-14 23:52:49 +04:00
ranzordly fe11a0ed0e fix: html attribute fix 2026-02-15 01:02:33 +05:30
Elie Habib 4a7f9bfdf4 Allow Cloudflare Insights script in CSP
Add https://static.cloudflareinsights.com to script-src directive
to prevent CSP blocking of Cloudflare Web Analytics beacon.
2026-02-14 22:52:26 +04:00
ranzordly 32973b5770 Fix: constrain layers menu height in DeckGLMap 2026-02-15 00:16:55 +05:30
Elie Habib 81559ba2d6 Use native M1 runner for ARM64 macOS builds
Switch ARM64 build from cross-compiling on macos-latest (Intel) to
native compilation on macos-14 (M1). Updates platform checks to
use contains() so signing works on both macOS runner types.
2026-02-14 22:33:29 +04:00
Elie Habib 7254dbb087 Fix macOS build failures when Apple signing secrets are missing
Gracefully handle missing/invalid Apple Developer certificates instead
of failing the entire build. Detects secret availability, validates
certificate file size, makes import failures non-fatal, and splits
each variant into signed/unsigned conditional paths.
2026-02-14 21:46:46 +04:00
Elie Habib a661497bcc Add latest release badge to README 2026-02-14 21:39:37 +04:00
33 changed files with 3044 additions and 481 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ VITE_WS_RELAY_URL=
# ------ Site Configuration ------
# Site variant: "full" (worldmonitor.app) or "tech" (startups.worldmonitor.app)
# Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app)
VITE_VARIANT=full
# Map interaction mode:
+157 -22
View File
@@ -35,35 +35,44 @@ jobs:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
- platform: 'macos-14'
args: '--target aarch64-apple-darwin'
label: 'macOS-ARM64'
timeout: 180
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
label: 'macOS-x64'
timeout: 180
- platform: 'windows-latest'
args: ''
label: 'Windows-x64'
timeout: 120
runs-on: ${{ matrix.platform }}
name: Build (${{ matrix.label }})
timeout-minutes: ${{ matrix.timeout }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Start job timer
shell: bash
run: echo "JOB_START_EPOCH=$(date +%s)" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: lts/*
node-version: '22'
cache: 'npm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
toolchain: stable
targets: ${{ contains(matrix.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db
with:
workspaces: './src-tauri -> target'
cache-on-failure: true
@@ -71,21 +80,46 @@ jobs:
- name: Install frontend dependencies
run: npm ci
# ── macOS Code Signing (only when secrets are configured) ──
# ── Detect whether Apple signing secrets are configured ──
- name: Check Apple signing secrets
if: contains(matrix.platform, 'macos')
id: apple-signing
shell: bash
run: |
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ] && [ -n "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" ] && [ -n "${{ secrets.KEYCHAIN_PASSWORD }}" ]; then
echo "available=true" >> $GITHUB_OUTPUT
echo "Apple signing secrets detected"
else
echo "available=false" >> $GITHUB_OUTPUT
echo "No Apple signing secrets — building unsigned"
fi
# ── macOS Code Signing (only when secrets are valid) ──
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-latest' && env.APPLE_CERTIFICATE != ''
if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
CERT_SIZE=$(wc -c < certificate.p12 | tr -d ' ')
if [ "$CERT_SIZE" -lt 100 ]; then
echo "::warning::Certificate file too small ($CERT_SIZE bytes) — likely invalid. Skipping signing."
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
exit 0
fi
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign || {
echo "::warning::Certificate import failed — building unsigned"
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
exit 0
}
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
@@ -96,7 +130,8 @@ jobs:
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported: $CERT_ID"
else
echo "No Developer ID certificate found"
echo "::warning::No Developer ID certificate found in keychain — building unsigned"
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
fi
# ── Determine variant ──
@@ -110,9 +145,13 @@ jobs:
fi
# ── Build with tauri-action ──
- name: Build Tauri app (full variant)
if: env.BUILD_VARIANT == 'full'
uses: tauri-apps/tauri-action@v0
# Signed builds: only when Apple signing secrets are valid and imported
# Unsigned builds: fallback when no signing (Windows always uses this path)
# ── Build: Full variant (signed) ──
- name: Build Tauri app (full, signed)
if: env.BUILD_VARIANT == 'full' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: full
@@ -126,15 +165,33 @@ jobs:
with:
tagName: v__VERSION__
releaseName: 'World Monitor v__VERSION__'
releaseBody: 'Download the installer for your platform below.'
releaseDraft: ${{ github.event.inputs.draft || true }}
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
args: ${{ matrix.args }}
retryAttempts: 1
- name: Build Tauri app (tech variant)
if: env.BUILD_VARIANT == 'tech'
uses: tauri-apps/tauri-action@v0
# ── Build: Full variant (unsigned — no Apple certs) ──
- name: Build Tauri app (full, unsigned)
if: env.BUILD_VARIANT == 'full' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true')
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: full
VITE_DESKTOP_RUNTIME: '1'
with:
tagName: v__VERSION__
releaseName: 'World Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
args: ${{ matrix.args }}
retryAttempts: 1
# ── Build: Tech variant (signed) ──
- name: Build Tauri app (tech, signed)
if: env.BUILD_VARIANT == 'tech' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: tech
@@ -148,9 +205,87 @@ jobs:
with:
tagName: v__VERSION__-tech
releaseName: 'Tech Monitor v__VERSION__'
releaseBody: 'Download the installer for your platform below.'
releaseDraft: ${{ github.event.inputs.draft || true }}
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
tauriScript: npx tauri
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
# ── Build: Tech variant (unsigned — no Apple certs) ──
- name: Build Tauri app (tech, unsigned)
if: env.BUILD_VARIANT == 'tech' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true')
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: tech
VITE_DESKTOP_RUNTIME: '1'
with:
tagName: v__VERSION__-tech
releaseName: 'Tech Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
tauriScript: npx tauri
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
- name: Cleanup Apple signing materials
if: always() && contains(matrix.platform, 'macos')
shell: bash
run: |
rm -f certificate.p12
security delete-keychain build.keychain || true
- name: Report build duration
if: always()
shell: bash
run: |
if [ -z "${JOB_START_EPOCH:-}" ]; then
echo "::warning::JOB_START_EPOCH missing; duration unavailable."
exit 0
fi
END_EPOCH=$(date +%s)
ELAPSED=$((END_EPOCH - JOB_START_EPOCH))
MINUTES=$((ELAPSED / 60))
SECONDS=$((ELAPSED % 60))
echo "Build duration for ${{ matrix.label }}: ${MINUTES}m ${SECONDS}s"
# ── Update release notes with changelog after all builds complete ──
update-release-notes:
needs: build-tauri
if: always() && contains(needs.build-tauri.result, 'success')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- name: Generate and update release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
VERSION=$(jq -r .version src-tauri/tauri.conf.json)
TAG="v${VERSION}"
PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
COMMITS="Initial release"
else
COMMITS=$(git log "${PREV_TAG}..${TAG}" --oneline --no-merges | sed 's/^[a-f0-9]*//' | sed 's/^ /- /')
fi
BODY=$(cat <<NOTES
## What's Changed
${COMMITS}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG:-initial}...${TAG}
NOTES
)
gh release edit "$TAG" --notes "$BODY"
echo "Updated release notes for $TAG"
+1
View File
@@ -8,6 +8,7 @@ dist/
.vercel
.claude/
.cursor/
CLAUDE.md
.env.vercel-backup
.agent/
.factory/
-171
View File
@@ -1,171 +0,0 @@
# WorldMonitor Development Notes
## 🤖 Model Preferences (Jan 30, 2026)
**For ALL coding tasks in WorldMonitor, ALWAYS use:**
| Task | Model | Alias |
|------|-------|-------|
| **Coding** | `openrouter/anthropic/claude-sonnet-4-5` | `sonnet` |
| **Coding** | `openai/gpt-5-2` | `codex` |
**Never default to MiniMax for coding tasks.**
**How to run with preferred model:**
```bash
# Sonnet for coding
clawdbot --model openrouter/anthropic/claude-sonnet-4-5 "build me..."
# Codex for coding
clawdbot --model openai/gpt-5-2 "build me..."
```
**Set as default:**
```bash
export CLAUDE_MODEL=openrouter/anthropic/claude-sonnet-4-5
```
## CRITICAL: Git Branch Rules
**NEVER merge or push to a different branch without explicit user permission.**
- If on `beta`, only push to `beta` - never merge to `main` without asking
- If on `main`, stay on `main` - never switch branches and push without asking
- NEVER merge branches without explicit request
- Pushing to the CURRENT branch after commits is OK when continuing work
## Critical: RSS Proxy Allowlist
When adding new RSS feeds in `src/config/feeds.ts`, you **MUST** also add the feed domains to the allowlist in `api/rss-proxy.js`.
### Why
The RSS proxy has a security allowlist (`ALLOWED_DOMAINS`) that blocks requests to domains not explicitly listed. Feeds from unlisted domains will return HTTP 403 "Domain not allowed" errors.
### How to Add New Feeds
1. Add the feed to `src/config/feeds.ts`
2. Extract the domain from the feed URL (e.g., `https://www.ycombinator.com/blog/rss/``www.ycombinator.com`)
3. Add the domain to `ALLOWED_DOMAINS` array in `api/rss-proxy.js`
4. Deploy changes to Vercel
### Example
```javascript
// In api/rss-proxy.js
const ALLOWED_DOMAINS = [
// ... existing domains
'www.ycombinator.com', // Add new domain here
];
```
### Debugging Feed Issues
If a panel shows "No news available":
1. Open browser DevTools → Console
2. Look for `HTTP 403` or "Domain not allowed" errors
3. Check if the domain is in `api/rss-proxy.js` allowlist
## Site Variants
Two variants controlled by `VITE_VARIANT` environment variable:
- `full` (default): Geopolitical focus - worldmonitor.app
- `tech`: Tech/startup focus - startups.worldmonitor.app
### Running Locally
```bash
npm run dev # Full variant
npm run dev:tech # Tech variant
```
### Building
```bash
npm run build:full # Production build for worldmonitor.app
npm run build:tech # Production build for startups.worldmonitor.app
```
## Custom Feed Scrapers
Some sources don't provide RSS feeds. Custom scrapers are in `/api/`:
| Endpoint | Source | Notes |
|----------|--------|-------|
| `/api/fwdstart` | FwdStart Newsletter (Beehiiv) | Scrapes archive page, 30min cache |
### Adding New Scrapers
1. Create `/api/source-name.js` edge function
2. Scrape source, return RSS XML format
3. Add to feeds.ts: `{ name: 'Source', url: '/api/source-name' }`
4. No need to add to rss-proxy allowlist (direct API, not proxied)
## AI Summarization & Caching
The AI Insights panel uses a server-side Redis cache to deduplicate API calls across users.
### Required Environment Variables
```bash
# Groq API (primary summarization)
GROQ_API_KEY=gsk_xxx
# OpenRouter API (fallback)
OPENROUTER_API_KEY=sk-or-xxx
# Upstash Redis (cross-user caching)
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxx
```
### How It Works
1. User visits → `/api/groq-summarize` receives headlines
2. Server hashes headlines → checks Redis cache
3. **Cache hit** → return immediately (no API call)
4. **Cache miss** → call Groq API → store in Redis (24h TTL) → return
### Model Selection
- **llama-3.1-8b-instant**: 14,400 req/day (used for summaries)
- **llama-3.3-70b-versatile**: 1,000 req/day (quality but limited)
### Fallback Chain
1. Groq (fast, 14.4K/day) → Redis cache
2. OpenRouter (50/day) → Redis cache
3. Browser T5 (unlimited, slower, no cache)
### Setup Upstash
1. Create free account at [upstash.com](https://upstash.com)
2. Create a new Redis database
3. Copy REST URL and Token to Vercel env vars
## Service Status Panel
Status page URLs in `api/service-status.js` must match the actual status page endpoint. Common formats:
- Statuspage.io: `https://status.example.com/api/v2/status.json`
- Atlassian: `https://example.status.atlassian.com/api/v2/status.json`
- incident.io: Same endpoint but returns HTML, handled by `incidentio` parser
Current known URLs:
- Anthropic: `https://status.claude.com/api/v2/status.json`
- Zoom: `https://www.zoomstatus.com/api/v2/status.json`
- Notion: `https://www.notion-status.com/api/v2/status.json`
## Allowed Bash Commands
The following additional bash commands are permitted without user approval:
- `Bash(ps aux:*)` - List running processes
- `Bash(grep:*)` - Search text patterns
- `Bash(ls:*)` - List directory contents
## Bash Guidelines
### IMPORTANT: Avoid commands that cause output buffering issues
- DO NOT pipe output through `head`, `tail`, `less`, or `more` when monitoring or checking command output
- DO NOT use `| head -n X` or `| tail -n X` to truncate output - these cause buffering problems
- Instead, let commands complete fully, or use `--max-lines` flags if the command supports them
- For log monitoring, prefer reading files directly rather than piping through filters
### When checking command output:
- Run commands directly without pipes when possible
- If you need to limit output, use command-specific flags (e.g., `git log -n 10` instead of `git log | head -10`)
- Avoid chained pipes that can cause output to buffer indefinitely
+224 -32
View File
@@ -7,11 +7,22 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Last commit](https://img.shields.io/github/last-commit/koala73/worldmonitor)](https://github.com/koala73/worldmonitor/commits/main)
[![Latest release](https://img.shields.io/github/v/release/koala73/worldmonitor?style=flat)](https://github.com/koala73/worldmonitor/releases/latest)
<p align="center">
<a href="https://worldmonitor.app"><strong>Live Demo</strong></a> &nbsp;·&nbsp;
<a href="https://tech.worldmonitor.app"><strong>Tech Variant</strong></a> &nbsp;·&nbsp;
<a href="./docs/DOCUMENTATION.md"><strong>Full Documentation</strong></a>
<a href="https://worldmonitor.app"><img src="https://img.shields.io/badge/Web_App-worldmonitor.app-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>&nbsp;
<a href="https://tech.worldmonitor.app"><img src="https://img.shields.io/badge/Tech_Variant-tech.worldmonitor.app-0891b2?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Tech Variant"></a>
</p>
<p align="center">
<a href="https://worldmonitor.app/api/download?platform=windows-exe"><img src="https://img.shields.io/badge/Download-Windows_(.exe)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows"></a>&nbsp;
<a href="https://worldmonitor.app/api/download?platform=macos-arm64"><img src="https://img.shields.io/badge/Download-macOS_Apple_Silicon-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS ARM"></a>&nbsp;
<a href="https://worldmonitor.app/api/download?platform=macos-x64"><img src="https://img.shields.io/badge/Download-macOS_Intel-555555?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS Intel"></a>
</p>
<p align="center">
<a href="./docs/DOCUMENTATION.md"><strong>Full Documentation</strong></a> &nbsp;·&nbsp;
<a href="https://github.com/koala73/worldmonitor/releases/latest"><strong>All Releases</strong></a>
</p>
![World Monitor Dashboard](new-world-monitor.png)
@@ -28,6 +39,8 @@
| Crypto/macro signal noise | **7-signal market radar** with composite BUY/CASH verdict |
| Expensive OSINT tools ($$$) | **100% free & open source** |
| Static news feeds | **Real-time updates** with live video streams |
| Web-only dashboards | **Native desktop app** (Tauri) + installable PWA with offline map support |
| Flat 2D maps | **3D WebGL globe** with deck.gl rendering and 29+ toggleable data layers |
---
@@ -44,13 +57,15 @@ Both variants run from a single codebase — switch between them with one click.
## Key Features
### Interactive Global Map
- **25+ data layers** — conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fire detection, protests, natural disasters, datacenters, and more
- **Smart clustering** — markers intelligently group at low zoom, expand on zoom in
- **Progressive disclosure** — detail layers (bases, nuclear, datacenters) appear only when zoomed in; zoom-adaptive opacity prevents clutter at world view
### Interactive 3D Globe
- **WebGL-accelerated rendering** — deck.gl + MapLibre GL JS for smooth 60fps performance with thousands of concurrent markers. Switchable between **3D globe** (with pitch/rotation) and **flat map** mode via `VITE_MAP_INTERACTION_MODE`
- **29+ data layers** — conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fire detection, protests, natural disasters, datacenters, displacement flows, climate anomalies, and more
- **Smart clustering** — Supercluster groups markers at low zoom, expands on zoom in. Cluster thresholds adapt to zoom level
- **Progressive disclosure** — detail layers (bases, nuclear, datacenters) appear only when zoomed in; zoom-adaptive opacity fades markers from 0.2 at world view to 1.0 at street level
- **Label deconfliction** — overlapping labels (e.g., multiple BREAKING badges) are automatically suppressed by priority, highest-severity first
- **8 regional presets** — Global, Americas, Europe, MENA, Asia, Africa, Oceania, Latin America
- **Time filtering** — 1h, 6h, 24h, 48h, 7d event windows
- **URL state sharing** — map center, zoom, active layers, and time range are encoded in the URL for shareable views (`?view=mena&zoom=4&layers=conflicts,bases`)
### AI-Powered Intelligence
- **World Brief** — LLM-synthesized summary of top global developments (Groq Llama 3.1, Redis-cached)
@@ -64,9 +79,10 @@ Both variants run from a single codebase — switch between them with one click.
<details>
<summary><strong>Geopolitical</strong></summary>
- Active conflict zones with escalation tracking
- Active conflict zones with escalation tracking (UCDP + ACLED)
- Intelligence hotspots with news correlation
- Social unrest events (ACLED + GDELT)
- Social unrest events (dual-source: ACLED protests + GDELT geo-events, Haversine-deduplicated)
- Natural disasters from 3 sources (USGS earthquakes M4.5+, GDACS alerts, NASA EONET events)
- Sanctions regimes
- Weather alerts and severe conditions
@@ -121,10 +137,13 @@ Both variants run from a single codebase — switch between them with one click.
</details>
### Live News & Video
- **100+ RSS feeds** across geopolitics, defense, energy, tech
- **Live video streams** — Bloomberg, Sky News, Al Jazeera, CNBC, and more
- **Custom monitors** — Create keyword-based alerts for any topic
- **100+ RSS feeds** across geopolitics, defense, energy, tech — domain-allowlisted proxy prevents CORS issues
- **8 live video streams** — Bloomberg, Sky News, Al Jazeera, Euronews, DW, France24, CNBC, Al Arabiya — with automatic live detection that scrapes YouTube channel pages every 5 minutes to find active streams
- **Desktop embed bridge** — YouTube's IFrame API restricts playback in native webviews (error 153). The dashboard detects this and transparently routes through a cloud-hosted embed proxy with bidirectional message passing (play/pause/mute/unmute/loadVideo)
- **Idle-aware playback** — video players pause and are removed from the DOM after 5 minutes of inactivity, resuming when the user returns. Tab visibility changes also suspend/resume streams
- **Custom monitors** — Create keyword-based alerts for any topic, color-coded with persistent storage
- **Entity extraction** — Auto-links countries, leaders, organizations
- **Virtual scrolling** — news panels render only visible DOM elements, handling thousands of items without browser lag
### Signal Aggregation & Anomaly Detection
- **Multi-source signal fusion** — internet outages, military flights, naval vessels, protests, AIS disruptions, and satellite fires are aggregated into a unified intelligence picture with per-country and per-region clustering
@@ -137,21 +156,38 @@ Both variants run from a single codebase — switch between them with one click.
- **Deep links** — every story generates a unique URL (`/story?c=<country>&t=<type>`) with dynamic Open Graph meta tags for rich social previews
- **Canvas-based image generation** — stories render as PNG images for visual sharing, with QR codes linking back to the live dashboard
### Desktop Application (Tauri)
- **Native desktop app** for macOS and Windows — packages the full dashboard with a local Node.js sidecar that runs all 45+ API handlers locally
- **OS keychain integration** — API keys stored in the system credential manager (macOS Keychain, Windows Credential Manager), never in plaintext files
- **Token-authenticated sidecar** — a unique session token prevents other local processes from accessing the sidecar on localhost. Generated per launch using randomized hashing
- **Cloud fallback** — when a local API handler fails or is missing, requests transparently fall through to the cloud deployment (worldmonitor.app) with origin headers stripped
- **Settings window** — dedicated configuration UI (Cmd+,) for managing 15 API keys with validation, signup links, and feature-availability indicators
- **Verbose debug mode** — toggle traffic logging with persistent state across restarts. View the last 200 requests with timing, status codes, and error details
- **DevTools toggle** — Cmd+Alt+I opens the embedded web inspector for debugging
### Progressive Web App
- **Installable** — the dashboard can be installed to the home screen on mobile or as a standalone desktop app via Chrome's install prompt. Full-screen `standalone` display mode with custom theme color
- **Offline map support** — MapTiler tiles are cached using a CacheFirst strategy (up to 500 tiles, 30-day TTL), enabling map browsing without a network connection
- **Smart caching strategies** — APIs and RSS feeds use NetworkOnly (real-time data must always be fresh), while fonts (1-year TTL), images (7-day StaleWhileRevalidate), and static assets (1-year immutable) are aggressively cached
- **Auto-updating service worker** — checks for new versions every 60 minutes. Tauri desktop builds skip service worker registration entirely (uses native APIs instead)
- **Offline fallback** — a branded fallback page with retry button is served when the network is unavailable
### Additional Capabilities
- Signal intelligence with "Why It Matters" context
- Infrastructure cascade analysis with proximity correlation
- Maritime & aviation tracking with surge detection
- Prediction market integration (Polymarket) as leading indicators
- Prediction market integration (Polymarket) with 3-tier JA3 bypass (browser-direct → Tauri native TLS → cloud proxy)
- Service status monitoring (cloud providers, AI services)
- Shareable map state via URL parameters (view, zoom, coordinates, time range, active layers)
- Data freshness monitoring across 14 data sources with explicit intelligence gap reporting
- Per-feed circuit breakers with 5-minute cooldowns to prevent cascading failures
- Browser-side ML worker (Transformers.js) for NER and sentiment analysis without server dependency
- **Cmd+K search** — fuzzy search across news headlines, countries, and entities
- **Virtual scrolling** — news panels render only visible DOM elements, handling thousands of items without browser lag
- **Cmd+K search** — fuzzy search across 20+ result types: news headlines, countries, hotspots, markets, military bases, cables, pipelines, datacenters, nuclear facilities, tech companies, and more
- **Historical playback** — dashboard snapshots are stored in IndexedDB. A time slider allows rewinding to any saved state, with live updates paused during playback
- **Mobile detection** — screens below 768px receive a warning modal since the dashboard is designed for multi-panel desktop use
- **UCDP conflict classification** — countries with active wars (1,000+ battle deaths/year) receive automatic CII floor scores, preventing optimistic drift
- **HAPI humanitarian data** — UN OCHA humanitarian access metrics feed into country-level instability scoring
- **Idle-aware resource management** — animations pause after 2 minutes of inactivity and when the tab is hidden, preventing battery drain. Video streams are destroyed from the DOM and recreated on return
---
@@ -285,6 +321,34 @@ The algorithm uses **Welford's online method** for numerically stable streaming
A minimum of 10 historical samples is required before anomalies are reported, preventing false positives during the learning phase. Anomalies are ingested back into the signal aggregator, where they compound with other signals for convergence detection.
### Natural Disaster Monitoring
Three independent sources are merged into a unified disaster picture, then deduplicated on a 0.1° geographic grid:
| Source | Coverage | Types | Update Frequency |
|--------|----------|-------|------------------|
| **USGS** | Global earthquakes M4.5+ | Earthquakes | 5 minutes |
| **GDACS** | UN-coordinated disaster alerts | Earthquakes, floods, cyclones, volcanoes, wildfires, droughts | Real-time |
| **NASA EONET** | Earth observation events | 13 natural event categories (30-day open events) | Real-time |
GDACS events carry color-coded alert levels (Red = critical, Orange = high) and are filtered to exclude low-severity Green alerts. EONET wildfires are filtered to events within 48 hours to prevent stale data. Earthquakes from EONET are excluded since USGS provides higher-quality seismological data.
The merged output feeds into the signal aggregator for geographic convergence detection — e.g., an earthquake near a pipeline triggers an infrastructure cascade alert.
### Dual-Source Protest Tracking
Protest data is sourced from two independent providers to reduce single-source bias:
1. **ACLED** (Armed Conflict Location & Event Data) — 30-day window, tokenized API with Redis caching (10-minute TTL). Covers protests, riots, strikes, and demonstrations with actor attribution and fatality counts.
2. **GDELT** (Global Database of Events, Language, and Tone) — 7-day geospatial event feed filtered to protest keywords. Events with mention count ≥5 are included; those above 30 are marked as `validated`.
Events from both sources are **Haversine-deduplicated** on a 0.5° grid (~50km) with same-day matching. ACLED events take priority due to higher editorial confidence. Severity is classified as:
- **High** — fatalities present or riot/clash keywords
- **Medium** — standard protest/demonstration
- **Low** — default
Protest scoring is regime-aware: democratic countries use logarithmic scaling (routine protests don't trigger instability), while authoritarian states use linear scoring (every protest is significant). Fatalities and concurrent internet outages apply severity boosts.
### Browser-Side ML Pipeline
The dashboard runs a full ML pipeline in the browser via Transformers.js, with no server dependency for core intelligence. This is automatically disabled on mobile devices to conserve memory.
@@ -316,6 +380,18 @@ A singleton tracker monitors 14 data sources (GDELT, RSS, AIS, military flights,
Polymarket geopolitical markets are queried using tag-based filters (Ukraine, Iran, China, Taiwan, etc.) with 5-minute caching. Market probability shifts are correlated with news volume: if a prediction market moves significantly before matching news arrives, this is flagged as a potential early-warning signal.
**Cloudflare JA3 bypass** — Polymarket's API is protected by Cloudflare TLS fingerprinting (JA3) that blocks all server-side requests. The system uses a 3-tier fallback:
| Tier | Method | When It Works |
|------|--------|---------------|
| **1** | Browser-direct fetch | Always (browser TLS passes Cloudflare) |
| **2** | Tauri native TLS (reqwest) | Desktop app (Rust TLS fingerprint differs from Node.js) |
| **3** | Vercel edge proxy | Rarely (edge runtime sometimes passes) |
Once browser-direct succeeds, the system caches this state and skips fallback tiers on subsequent requests. Country-specific markets are fetched by mapping countries to Polymarket tags with name-variant matching (e.g., "Russia" matches titles containing "Russian", "Moscow", "Kremlin", "Putin").
Markets are filtered to exclude sports and entertainment (100+ exclusion keywords), require meaningful price divergence from 50% or volume above $50K, and are ranked by trading volume. Each variant gets different tag sets — geopolitical focus queries politics/world/ukraine/middle-east tags, while tech focus queries ai/crypto/business tags.
### Macro Signal Analysis (Market Radar)
The Market Radar panel computes a composite BUY/CASH verdict from 7 independent signals sourced entirely from free APIs (Yahoo Finance, mempool.space, alternative.me):
@@ -374,8 +450,10 @@ This is an approximation, not a substitute for official flow data, but it captur
| **Browser-first compute** | Analysis (clustering, instability scoring, surge detection) runs client-side — no backend compute dependency for core intelligence. |
| **Multi-signal correlation** | No single data source is trusted alone. Focal points require convergence across news + military + markets + protests before escalating to critical. |
| **Geopolitical grounding** | Hard-coded conflict zones, baseline country risk, and strategic chokepoints prevent statistical noise from generating false alerts in low-data regions. |
| **Defense in depth** | CORS origin allowlist, domain-allowlisted RSS proxy, server-side API key isolation, input sanitization with output encoding, IP rate limiting on AI endpoints. |
| **Cache everything, trust nothing** | Three-tier caching (in-memory → Redis → upstream) with versioned cache keys and stale-on-error fallback. Every API response includes `X-Cache` header for debugging. |
| **Defense in depth** | CORS origin allowlist, domain-allowlisted RSS proxy, server-side API key isolation, token-authenticated desktop sidecar, input sanitization with output encoding, IP rate limiting on AI endpoints. |
| **Cache everything, trust nothing** | Three-tier caching (in-memory → Redis → upstream) with versioned cache keys and stale-on-error fallback. Every API response includes `X-Cache` header for debugging. CDN layer (`s-maxage`) absorbs repeated requests before they reach edge functions. |
| **Bandwidth efficiency** | Gzip compression on all relay responses (80% reduction). Content-hash static assets with 1-year immutable cache. Staggered polling intervals prevent synchronized API storms. Animations and polling pause on hidden tabs. |
| **Run anywhere** | Same codebase deploys to Vercel (web), Railway (relay), Tauri (desktop), and PWA (installable). Desktop sidecar mirrors all cloud API handlers locally. Service worker caches map tiles for offline use while keeping intelligence data always-fresh (NetworkOnly). |
---
@@ -409,9 +487,9 @@ All edge functions include circuit breaker logic and return cached stale data wh
---
## Dual-Deployment Architecture
## Multi-Platform Architecture
World Monitor runs on two platforms that work together:
World Monitor runs on three platforms that work together:
```
┌─────────────────────────────────────┐
@@ -419,15 +497,25 @@ World Monitor runs on two platforms that work together:
│ 45+ edge functions · static SPA │
│ CORS allowlist · Redis cache │
│ AI pipeline · market analytics │
└──────────────┬──────────────────────┘
│ https:// (server-side)
│ wss:// (client-side)
│ CDN caching (s-maxage) · PWA host │
└──────────┬─────────────┬────────────┘
│ │ fallback
│ ┌───────────────────────────────────┐
│ │ Tauri Desktop (Rust + Node) │
│ │ OS keychain · Token-auth sidecar │
│ │ 45+ local API handlers · gzip │
│ │ Cloud fallback · Traffic logging │
│ └───────────────────────────────────┘
│ https:// (server-side)
│ wss:// (client-side)
┌─────────────────────────────────────┐
│ Railway (Relay Server) │
│ WebSocket relay · OpenSky OAuth2 │
│ RSS proxy for blocked domains │
│ AIS vessel stream multiplexer
│ AIS vessel stream · gzip all resp
└─────────────────────────────────────┘
```
@@ -439,6 +527,101 @@ World Monitor runs on two platforms that work together:
The Vercel edge functions connect to Railway via `WS_RELAY_URL` (server-side, HTTPS) while browser clients connect via `VITE_WS_RELAY_URL` (client-side, WSS). This separation keeps the relay URL configurable per deployment without leaking server-side configuration to the browser.
All Railway relay responses are gzip-compressed (zlib `gzipSync`) when the client accepts it and the payload exceeds 1KB, reducing egress by ~80% for JSON and XML responses.
---
## Desktop Application Architecture
The Tauri desktop app wraps the dashboard in a native window with a local Node.js sidecar that runs all API handlers without cloud dependency:
```
┌─────────────────────────────────────────────────┐
│ Tauri (Rust) │
│ Window management · OS keychain · Menu bar │
│ Token generation · Log management │
│ Polymarket native TLS bridge │
└─────────────────────┬───────────────────────────┘
│ spawn + env vars
┌─────────────────────────────────────────────────┐
│ Node.js Sidecar (port 46123) │
│ 45+ API handlers · Gzip compression │
│ Cloud fallback · Traffic logging │
│ Verbose debug mode · Circuit breakers │
└─────────────────────┬───────────────────────────┘
│ fetch (on local failure)
┌─────────────────────────────────────────────────┐
│ Cloud (worldmonitor.app) │
│ Transparent fallback when local handlers fail │
└─────────────────────────────────────────────────┘
```
### Secret Management
API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager) — never in plaintext config files. At sidecar launch, all 15 supported secrets are read from the keyring, trimmed, and injected as environment variables. Empty or whitespace-only values are skipped.
Secrets can also be updated at runtime without restarting the sidecar: saving a key in the Settings window triggers a `POST /api/local-env-update` call that hot-patches `process.env` and clears the module cache so handlers pick up the new value immediately.
### Sidecar Authentication
A unique 32-character hex token is generated per app launch using randomized hash state (`RandomState` from Rust's standard library). The token is:
1. Injected into the sidecar as `LOCAL_API_TOKEN`
2. Retrieved by the frontend via the `get_local_api_token` Tauri command (lazy-loaded on first API request)
3. Attached as `Authorization: Bearer <token>` to every local request
The `/api/service-status` health check endpoint is exempt from token validation to support monitoring tools.
### Cloud Fallback
When a local API handler is missing, throws an error, or returns a 5xx status, the sidecar transparently proxies the request to the cloud deployment. Endpoints that fail are marked as `cloudPreferred` — subsequent requests skip the local handler and go directly to the cloud until the sidecar is restarted. Origin and Referer headers are stripped before proxying to maintain server-to-server parity.
### Observability
- **Traffic log** — a ring buffer of the last 200 requests with method, path, status, and duration (ms), accessible via `GET /api/local-traffic-log`
- **Verbose mode** — togglable via `POST /api/local-debug-toggle`, persists across sidecar restarts in `verbose-mode.json`
- **Dual log files** — `desktop.log` captures Rust-side events (startup, secret injection counts, menu actions), while `local-api.log` captures Node.js stdout/stderr
- **DevTools** — `Cmd+Alt+I` toggles the embedded web inspector
---
## Bandwidth Optimization
The system minimizes egress costs through layered caching and compression across all three deployment targets:
### Vercel CDN Headers
Every API edge function includes `Cache-Control` headers that enable Vercel's CDN to serve cached responses without hitting the origin:
| Data Type | `s-maxage` | `stale-while-revalidate` | Rationale |
|-----------|-----------|--------------------------|-----------|
| Classification results | 3600s (1h) | 600s (10min) | Headlines don't reclassify often |
| Country intelligence | 3600s (1h) | 600s (10min) | Briefs change slowly |
| Risk scores | 300s (5min) | 60s (1min) | Near real-time, low latency |
| Market data | 3600s (1h) | 600s (10min) | Intraday granularity sufficient |
| Fire detection | 600s (10min) | 120s (2min) | VIIRS updates every ~12 hours |
| Economic indicators | 3600s (1h) | 600s (10min) | Monthly/quarterly releases |
Static assets use content-hash filenames with 1-year immutable cache headers. The service worker file (`sw.js`) is never cached (`max-age=0, must-revalidate`) to ensure update detection.
### Railway Relay Compression
All relay server responses pass through `gzipSync` when the client accepts gzip and the payload exceeds 1KB. This applies to OpenSky aircraft JSON, RSS XML feeds, UCDP event data, AIS snapshots, and health checks — reducing wire size by approximately 80%.
### Frontend Polling Intervals
Panels refresh at staggered intervals to avoid synchronized API storms:
| Panel | Interval | Rationale |
|-------|----------|-----------|
| AIS maritime snapshot | 10s | Real-time vessel positions |
| Service status | 60s | Health check cadence |
| Market signals / ETF / Stablecoins | 180s (3min) | Market hours granularity |
| Risk scores / Theater posture | 300s (5min) | Composite scores change slowly |
All animations and polling pause when the tab is hidden or after 2 minutes of inactivity, preventing wasted requests from background tabs.
---
## Caching Architecture
@@ -467,13 +650,15 @@ The AI summarization pipeline adds content-based deduplication: headlines are ha
| Layer | Mechanism |
|-------|-----------|
| **CORS origin allowlist** | Only `worldmonitor.app`, `startups.worldmonitor.app`, and `localhost:*` can call API endpoints. All others receive 403. Implemented in `api/_cors.js`. |
| **CORS origin allowlist** | Only `worldmonitor.app`, `tech.worldmonitor.app`, and `localhost:*` can call API endpoints. All others receive 403. Implemented in `api/_cors.js`. |
| **RSS domain allowlist** | The RSS proxy only fetches from explicitly listed domains (~90+). Requests for unlisted domains are rejected with 403. |
| **Railway domain allowlist** | The Railway relay has a separate, smaller domain allowlist for feeds that need the alternate origin. |
| **API key isolation** | All API keys live server-side in Vercel environment variables. The browser never sees Groq, OpenRouter, ACLED, Finnhub, or other credentials. |
| **Input sanitization** | User-facing content passes through `escapeHtml()` (prevents XSS) and `sanitizeUrl()` (blocks `javascript:` and `data:` URIs). URLs use `escapeAttr()` for attribute context encoding. |
| **Query parameter validation** | API endpoints validate input formats (e.g., stablecoin coin IDs must match `[a-z0-9-]+`, bounding box params are numeric). |
| **IP rate limiting** | AI endpoints use Upstash Redis-backed rate limiting to prevent abuse of Groq/OpenRouter quotas. |
| **Desktop sidecar auth** | The local API sidecar requires a per-session `Bearer` token generated at launch. The token is stored in Rust state and injected into the sidecar environment — only the Tauri frontend can retrieve it via IPC. Health check endpoints are exempt. |
| **OS keychain storage** | Desktop API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager), never in plaintext files or environment variables on disk. |
| **No debug endpoints** | The `api/debug-env.js` endpoint returns 404 in production — it exists only as a disabled placeholder. |
---
@@ -580,14 +765,15 @@ Set `WS_RELAY_URL` (server-side, HTTPS) and `VITE_WS_RELAY_URL` (client-side, WS
| Category | Technologies |
|----------|--------------|
| **Frontend** | TypeScript, Vite, deck.gl (WebGL), MapLibre GL |
| **Frontend** | TypeScript, Vite, deck.gl (WebGL 3D globe), MapLibre GL, vite-plugin-pwa (service worker + manifest) |
| **Desktop** | Tauri 2 (Rust) with Node.js sidecar, OS keychain integration (keyring crate), native TLS (reqwest) |
| **AI/ML** | Groq (Llama 3.1 8B), OpenRouter (fallback), Transformers.js (browser-side T5, NER, embeddings) |
| **Caching** | Redis (Upstash) — 3-tier cache with in-memory + Redis + upstream, cross-user AI deduplication |
| **Geopolitical APIs** | OpenSky, GDELT, ACLED, UCDP, HAPI, USGS, NASA FIRMS, Polymarket, Cloudflare Radar |
| **Caching** | Redis (Upstash) — 3-tier cache with in-memory + Redis + upstream, cross-user AI deduplication. Vercel CDN (s-maxage). Service worker (Workbox) |
| **Geopolitical APIs** | OpenSky, GDELT, ACLED, UCDP, HAPI, USGS, GDACS, NASA EONET, NASA FIRMS, Polymarket, Cloudflare Radar |
| **Market APIs** | Yahoo Finance (equities, forex, crypto), CoinGecko (stablecoins), mempool.space (BTC hashrate), alternative.me (Fear & Greed) |
| **Economic APIs** | FRED (Federal Reserve), EIA (Energy), Finnhub (stock quotes) |
| **Deployment** | Vercel Edge Functions (45+ endpoints) + Railway (WebSocket relay) |
| **Data** | 100+ RSS feeds, ADS-B transponders, AIS maritime data, VIIRS satellite imagery |
| **Deployment** | Vercel Edge Functions (45+ endpoints) + Railway (WebSocket relay) + Tauri (desktop) + PWA (installable) |
| **Data** | 100+ RSS feeds, ADS-B transponders, AIS maritime data, VIIRS satellite imagery, 8 live YouTube streams |
---
@@ -614,7 +800,7 @@ Contributions welcome! See [CONTRIBUTING](./docs/DOCUMENTATION.md#contributing)
```bash
# Development
npm run dev # Full variant (worldmonitor.app)
npm run dev:tech # Tech variant (startups.worldmonitor.app)
npm run dev:tech # Tech variant (tech.worldmonitor.app)
# Production builds
npm run build:full # Build full variant
@@ -649,9 +835,15 @@ Desktop release details, signing hooks, variant outputs, and clean-machine valid
- [x] Market intelligence (macro signals, ETF flows, stablecoin peg monitoring)
- [x] Railway relay for WebSocket and blocked-domain proxying
- [x] CORS origin allowlist and security hardening
- [x] Native desktop application (Tauri) with OS keychain + authenticated sidecar
- [x] Progressive Web App with offline map support and installability
- [x] Bandwidth optimization (CDN caching, gzip relay, staggered polling)
- [x] 3D WebGL globe visualization (deck.gl)
- [x] Natural disaster monitoring (USGS + GDACS + NASA EONET)
- [x] Historical playback via IndexedDB snapshots
- [x] Live YouTube stream detection with desktop embed bridge
- [ ] Mobile-optimized views
- [ ] Push notifications for critical alerts
- [ ] Historical data playback
- [ ] Self-hosted Docker image
See [full roadmap](./docs/DOCUMENTATION.md#roadmap).
+46 -9
View File
@@ -4,7 +4,10 @@ const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar');
const mem = new Map();
let persistPath = null;
let persistTimer = null;
let persistInFlight = false;
let persistQueued = false;
let loaded = false;
const MAX_PERSIST_ENTRIES = Math.max(100, Number(process.env.LOCAL_API_CACHE_PERSIST_MAX || 5000));
async function ensureDesktopCache() {
if (loaded) return;
@@ -31,18 +34,52 @@ async function ensureDesktopCache() {
}, 60_000).unref?.();
}
function buildPersistSnapshot() {
const now = Date.now();
const payload = Object.create(null);
let kept = 0;
for (const [key, entry] of mem) {
if (!entry || entry.expiresAt <= now) continue;
payload[key] = entry;
kept += 1;
if (kept >= MAX_PERSIST_ENTRIES) break;
}
return payload;
}
async function persistToDisk() {
if (!persistPath) return;
if (persistInFlight) {
persistQueued = true;
return;
}
persistInFlight = true;
try {
const snapshot = buildPersistSnapshot();
const json = JSON.stringify(snapshot);
const { writeFile, rename } = await import('node:fs/promises');
const tmp = persistPath + '.tmp';
await writeFile(tmp, json, 'utf8');
await rename(tmp, persistPath);
} catch (err) {
console.warn('[Cache] Persist error:', err.message);
} finally {
persistInFlight = false;
if (persistQueued) {
persistQueued = false;
void persistToDisk();
}
}
}
function debouncedPersist() {
if (!persistPath) return;
clearTimeout(persistTimer);
persistTimer = setTimeout(async () => {
try {
const { writeFileSync, renameSync } = await import('node:fs');
const tmp = persistPath + '.tmp';
writeFileSync(tmp, JSON.stringify(Object.fromEntries(mem)));
renameSync(tmp, persistPath);
} catch (err) {
console.warn('[Cache] Persist error:', err.message);
}
persistTimer = setTimeout(() => {
void persistToDisk();
}, 2000);
if (persistTimer?.unref) persistTimer.unref();
}
+2 -1
View File
@@ -109,7 +109,8 @@ Rules:
- 5-6 paragraphs, 300-400 words.
- No speculation beyond what the data supports.
- Use plain language, not jargon.
- If military assets are 0, don't speculate about military presence — say monitoring shows no current military activity.`;
- If military assets are 0, don't speculate about military presence — say monitoring shows no current military activity.
- When referencing a specific headline from the numbered list, cite it as [N] where N is the headline number (e.g. "tensions escalated [3]"). Only cite headlines you directly reference.`;
const userPrompt = `Country: ${country} (${code})${dataSection}`;
+51
View File
@@ -0,0 +1,51 @@
export const config = { runtime: 'edge' };
const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest';
const RELEASES_PAGE = 'https://github.com/koala73/worldmonitor/releases/latest';
const PLATFORM_PATTERNS = {
'windows-exe': (name) => name.endsWith('_x64-setup.exe'),
'windows-msi': (name) => name.endsWith('_x64_en-US.msi'),
'macos-arm64': (name) => name.endsWith('_aarch64.dmg'),
'macos-x64': (name) => name.endsWith('_x64.dmg') && !name.includes('setup'),
};
export default async function handler(req) {
const url = new URL(req.url);
const platform = url.searchParams.get('platform');
if (!platform || !PLATFORM_PATTERNS[platform]) {
return Response.redirect(RELEASES_PAGE, 302);
}
try {
const res = await fetch(RELEASES_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'WorldMonitor-Download-Redirect',
},
});
if (!res.ok) {
return Response.redirect(RELEASES_PAGE, 302);
}
const release = await res.json();
const matcher = PLATFORM_PATTERNS[platform];
const asset = release.assets?.find((a) => matcher(a.name));
if (!asset) {
return Response.redirect(RELEASES_PAGE, 302);
}
return new Response(null, {
status: 302,
headers: {
'Location': asset.browser_download_url,
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
},
});
} catch {
return Response.redirect(RELEASES_PAGE, 302);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<!-- Primary Meta Tags -->
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "world-monitor",
"private": true,
"version": "2.2.0",
"version": "2.2.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "world-monitor"
version = "2.2.0"
version = "2.2.2"
description = "World Monitor desktop application"
authors = ["World Monitor"]
edition = "2021"
+34 -5
View File
@@ -11,7 +11,7 @@ use std::env;
use keyring::Entry;
use serde_json::{Map, Value};
use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::{AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder};
use tauri::{AppHandle, Manager, RunEvent, WindowEvent, WebviewUrl, WebviewWindowBuilder};
const LOCAL_API_PORT: &str = "46123";
const KEYRING_SERVICE: &str = "world-monitor";
@@ -263,7 +263,7 @@ fn open_sidecar_log_file(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
fn open_settings_window_command(app: AppHandle) -> Result<(), String> {
async fn open_settings_window_command(app: AppHandle) -> Result<(), String> {
open_settings_window(&app)
}
@@ -311,14 +311,20 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> {
return Ok(());
}
WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
let _settings_window = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
.title("World Monitor Settings")
.inner_size(980.0, 760.0)
.min_inner_size(820.0, 620.0)
.resizable(true)
.visible(false)
.build()
.map_err(|e| format!("Failed to create settings window: {e}"))?;
// On Windows/Linux, menus are per-window. Remove the inherited app menu
// from the settings window (macOS uses a shared app-wide menu bar instead).
#[cfg(not(target_os = "macos"))]
let _ = _settings_window.remove_menu();
Ok(())
}
@@ -589,8 +595,31 @@ fn main() {
.build(tauri::generate_context!())
.expect("error while running world-monitor tauri application")
.run(|app, event| {
if matches!(event, RunEvent::ExitRequested { .. } | RunEvent::Exit) {
stop_local_api(&app);
match &event {
// macOS: hide window on close instead of quitting (standard behavior)
#[cfg(target_os = "macos")]
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
..
} if label == "main" => {
api.prevent_close();
if let Some(w) = app.get_webview_window("main") {
let _ = w.hide();
}
}
// macOS: reshow window when dock icon is clicked
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
stop_local_api(app);
}
_ => {}
}
});
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "World Monitor",
"mainBinaryName": "world-monitor",
"version": "2.2.0",
"version": "2.2.2",
"identifier": "app.worldmonitor.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+417 -141
View File
@@ -23,7 +23,7 @@ import { fetchAllFires, flattenFires, computeRegionStats } from '@/services/firm
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresence, foreignPresenceToSignal, type TheaterPostureSummary } from '@/services/military-surge';
import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, startLearning, isInLearningMode, calculateCII } from '@/services/country-instability';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, startLearning, isInLearningMode, calculateCII, getCountryData, TIER1_COUNTRIES } from '@/services/country-instability';
import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
import { fetchConflictEvents } from '@/services/conflicts';
import { fetchUcdpClassifications } from '@/services/ucdp';
@@ -34,7 +34,8 @@ import { fetchClimateAnomalies } from '@/services/climate';
import { enrichEventsWithExposure } from '@/services/population-exposure';
import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage, ExportPanel, getCircuitBreakerCooldownInfo, isMobileDevice } from '@/utils';
import { reverseGeocode } from '@/utils/reverse-geocode';
import { CountryIntelModal } from '@/components/CountryIntelModal';
import { CountryBriefPage } from '@/components/CountryBriefPage';
import { CountryTimeline, type TimelineEvent } from '@/components/CountryTimeline';
import { escapeHtml } from '@/utils/sanitize';
import type { ParsedMapUrlState } from '@/utils';
import {
@@ -77,6 +78,7 @@ import {
} from '@/components';
import type { SearchResult } from '@/components/SearchModal';
import { collectStoryData } from '@/services/story-data';
import { renderStoryToCanvas } from '@/services/story-renderer';
import { openStoryModal } from '@/components/StoryModal';
import { INTEL_HOTSPOTS, CONFLICT_ZONES, MILITARY_BASES, UNDERSEA_CABLES, NUCLEAR_FACILITIES } from '@/config/geo';
import { PIPELINES } from '@/config/pipelines';
@@ -87,9 +89,27 @@ import { AI_RESEARCH_LABS } from '@/config/ai-research-labs';
import { STARTUP_ECOSYSTEMS } from '@/config/startup-ecosystems';
import { TECH_HQS, ACCELERATORS } from '@/config/tech-geo';
import { isDesktopRuntime } from '@/services/runtime';
import { getCountryAtCoordinates, hasCountryGeometry, isCoordinateInCountry, preloadCountryGeometry } from '@/services/country-geometry';
import type { PredictionMarket, MarketData, ClusteredEvent } from '@/types';
type IntlDisplayNamesCtor = new (
locales: string | string[],
options: { type: 'region' }
) => { of: (code: string) => string | undefined };
export interface CountryBriefSignals {
protests: number;
militaryFlights: number;
militaryVessels: number;
outages: number;
earthquakes: number;
displacementOutflow: number;
climateStress: number;
conflictEvents: number;
isTier1: boolean;
}
export class App {
private container: HTMLElement;
private readonly PANEL_ORDER_KEY = 'panel-order';
@@ -132,7 +152,10 @@ export class App {
private readonly MAP_FLASH_COOLDOWN_MS = 10 * 60 * 1000;
private initialLoadComplete = false;
private criticalBannerEl: HTMLElement | null = null;
private countryIntelModal: CountryIntelModal | null = null;
private countryBriefPage: CountryBriefPage | null = null;
private countryTimeline: CountryTimeline | null = null;
private pendingDeepLinkCountry: string | null = null;
private briefRequestToken = 0;
private readonly isDesktopApp = isDesktopRuntime();
constructor(containerId: string) {
@@ -267,11 +290,11 @@ export class App {
});
const findingsBadge = new IntelligenceGapBadge();
findingsBadge.setOnSignalClick((signal) => {
if (this.countryIntelModal?.isVisible()) return;
if (this.countryBriefPage?.isVisible()) return;
this.signalModal?.showSignal(signal);
});
findingsBadge.setOnAlertClick((alert) => {
if (this.countryIntelModal?.isVisible()) return;
if (this.countryBriefPage?.isVisible()) return;
this.signalModal?.showAlert(alert);
});
this.setupMobileWarning();
@@ -283,8 +306,12 @@ export class App {
this.setupMapLayerHandlers();
this.setupCountryIntel();
this.setupEventListeners();
// Capture ?country= BEFORE URL sync overwrites it
const initState = parseMapUrlState(window.location.search, this.mapLayers);
this.pendingDeepLinkCountry = initState.country ?? null;
this.setupUrlStateSync();
this.syncDataFreshnessWithLayers();
await preloadCountryGeometry();
await this.loadAllData();
// Start CII learning mode after first data load
@@ -308,7 +335,7 @@ export class App {
private handleDeepLinks(): void {
const url = new URL(window.location.href);
// Check for story deep link: /story?c=UA&t=ciianalysis
if (url.pathname === '/story' || url.searchParams.has('c')) {
const countryCode = url.searchParams.get('c');
@@ -321,7 +348,7 @@ export class App {
SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela',
};
const countryName = countryNames[countryCode.toUpperCase()] || countryCode;
// Wait for data to load, then open story
const checkAndOpen = () => {
if (dataFreshness.hasSufficientData() && this.latestClusters.length > 0) {
@@ -331,11 +358,27 @@ export class App {
}
};
setTimeout(checkAndOpen, 2000);
// Update URL without reload
history.replaceState(null, '', '/');
return;
}
}
// Check for country brief deep link: ?country=UA (captured before URL sync)
const deepLinkCountry = this.pendingDeepLinkCountry;
this.pendingDeepLinkCountry = null;
if (deepLinkCountry) {
const cName = App.resolveCountryName(deepLinkCountry);
const checkAndOpenBrief = () => {
if (dataFreshness.hasSufficientData()) {
this.openCountryBriefByCode(deepLinkCountry, cName);
} else {
setTimeout(checkAndOpenBrief, 500);
}
};
setTimeout(checkAndOpenBrief, 2000);
}
}
private setupMobileWarning(): void {
@@ -480,142 +523,297 @@ export class App {
private setupCountryIntel(): void {
if (!this.map) return;
this.countryIntelModal = new CountryIntelModal();
this.countryIntelModal.setShareStoryHandler((code, name) => {
this.countryIntelModal?.hide();
this.countryBriefPage = new CountryBriefPage();
this.countryBriefPage.setShareStoryHandler((code, name) => {
this.countryBriefPage?.hide();
this.openCountryStory(code, name);
});
this.map.onCountryClicked(async (lat, lon) => {
this.countryIntelModal!.showLoading();
this.map!.setRenderPaused(true);
const geo = await reverseGeocode(lat, lon);
if (!geo) {
this.countryIntelModal!.hide();
this.map!.setRenderPaused(false);
return;
}
const scores = calculateCII();
const score = scores.find((s) => s.code === geo.code) ?? null;
const signals = this.getCountrySignals(geo.code, geo.country);
this.countryIntelModal!.show(geo.country, geo.code, score, signals);
this.map!.highlightCountry(geo.code);
// Fetch stock index (single request, used for both UI chip and AI context)
const stockPromise = fetch(`/api/stock-index?code=${encodeURIComponent(geo.code)}`)
.then((r) => r.json())
.catch(() => ({ available: false }));
// Update UI chip as soon as stock data arrives
stockPromise.then((stock) => this.countryIntelModal!.updateStock(stock));
// Fetch country prediction markets
fetchCountryMarkets(geo.country)
.then((markets) => this.countryIntelModal!.updateMarkets(markets))
.catch(() => this.countryIntelModal!.updateMarkets([]));
this.countryBriefPage.setExportImageHandler(async (code, name) => {
try {
const context: Record<string, unknown> = {};
if (score) {
context.score = score.score;
context.level = score.level;
context.trend = score.trend;
context.components = score.components;
context.change24h = score.change24h;
}
Object.assign(context, signals);
const countryCluster = signalAggregator.getCountryClusters().find((c) => c.country === geo.code);
if (countryCluster) {
context.convergenceScore = countryCluster.convergenceScore;
context.signalTypes = [...countryCluster.signalTypes];
}
const convergences = signalAggregator.getRegionalConvergence()
.filter((r) => r.countries.includes(geo.code));
if (convergences.length) {
context.regionalConvergence = convergences.map((r) => r.description);
}
const searchTerms = App.getCountrySearchTerms(geo.country, geo.code);
const headlines = this.allNews
.filter((n) => {
const t = n.title.toLowerCase();
return searchTerms.some((term) => t.includes(term));
})
.slice(0, 15)
.map((n) => n.title);
if (headlines.length) context.headlines = headlines;
// Reuse stock data for AI context
const stockData = await stockPromise;
if (stockData.available) {
const pct = parseFloat(stockData.weekChangePercent);
context.stockIndex = `${stockData.indexName}: ${stockData.price} (${pct >= 0 ? '+' : ''}${stockData.weekChangePercent}% week)`;
}
let data: Record<string, unknown> | null = null;
try {
const res = await fetch('/api/country-intel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country: geo.country, code: geo.code, context }),
});
data = await res.json();
} catch { /* server unreachable — will fall through to browser fallback */ }
if (data && data.brief && !data.skipped) {
this.countryIntelModal!.updateBrief({ ...data, code: geo.code } as Parameters<typeof this.countryIntelModal.updateBrief>[0]);
} else {
// Fallback: generate a basic brief from headlines using browser T5
const briefHeadlines = (context.headlines as string[] | undefined) || [];
let fallbackBrief = '';
if (briefHeadlines.length >= 2 && mlWorker.isAvailable) {
try {
const prompt = `Summarize the current situation in ${geo.country} based on these headlines: ${briefHeadlines.slice(0, 8).join('. ')}`;
const [summary] = await mlWorker.summarize([prompt]);
if (summary && summary.length > 20) fallbackBrief = summary;
} catch { /* T5 failed */ }
}
if (fallbackBrief) {
this.countryIntelModal!.updateBrief({ brief: fallbackBrief, country: geo.country, code: geo.code, fallback: true });
} else {
// Build a data-only brief from available context
const lines: string[] = [];
if (score) lines.push(`**Instability Index: ${score.score}/100** (${score.level}, ${score.trend})`);
if (signals.protests > 0) lines.push(`${signals.protests} active protests detected`);
if (signals.militaryFlights > 0) lines.push(`${signals.militaryFlights} military aircraft tracked`);
if (signals.militaryVessels > 0) lines.push(`${signals.militaryVessels} military vessels tracked`);
if (signals.outages > 0) lines.push(`${signals.outages} internet outages`);
if (signals.earthquakes > 0) lines.push(`${signals.earthquakes} recent earthquakes`);
if (context.stockIndex) lines.push(`Stock index: ${context.stockIndex}`);
if (briefHeadlines.length > 0) {
lines.push('', '**Recent headlines:**');
briefHeadlines.slice(0, 5).forEach(h => lines.push(`${h}`));
}
if (lines.length > 0) {
this.countryIntelModal!.updateBrief({ brief: lines.join('\n'), country: geo.country, code: geo.code, fallback: true });
} else {
this.countryIntelModal!.updateBrief({ brief: '', country: geo.country, code: geo.code, error: 'No AI service available. Configure GROQ_API_KEY in Settings for full briefs.' });
}
}
}
const signals = this.getCountrySignals(code, name);
const cluster = signalAggregator.getCountryClusters().find(c => c.country === code);
const regional = signalAggregator.getRegionalConvergence().filter(r => r.countries.includes(code));
const convergence = cluster ? {
score: cluster.convergenceScore,
signalTypes: [...cluster.signalTypes],
regionalDescriptions: regional.map(r => r.description),
} : null;
const posturePanel = this.panels['strategic-posture'] as import('@/components/StrategicPosturePanel').StrategicPosturePanel | undefined;
const postures = posturePanel?.getPostures() || [];
const data = collectStoryData(code, name, this.latestClusters, postures, this.latestPredictions, signals, convergence);
const canvas = await renderStoryToCanvas(data);
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `country-brief-${code.toLowerCase()}-${Date.now()}.png`;
a.click();
} catch (err) {
console.error('[CountryIntel] fetch error:', err);
this.countryIntelModal!.updateBrief({ brief: '', country: geo.country, code: geo.code, error: 'Failed to generate brief' });
console.error('[CountryBrief] Image export failed:', err);
}
});
this.countryIntelModal.onClose(() => {
this.map.onCountryClicked(async (countryClick) => {
if (countryClick.code && countryClick.name) {
this.openCountryBriefByCode(countryClick.code, countryClick.name);
} else {
this.openCountryBrief(countryClick.lat, countryClick.lon);
}
});
this.countryBriefPage.onClose(() => {
this.briefRequestToken++; // invalidate any in-flight reverse-geocode
this.map?.clearCountryHighlight();
this.map?.setRenderPaused(false);
this.countryTimeline?.destroy();
this.countryTimeline = null;
// Force URL rewrite to drop ?country= immediately
const shareUrl = this.getShareUrl();
if (shareUrl) history.replaceState(null, '', shareUrl);
});
}
public async openCountryBrief(lat: number, lon: number): Promise<void> {
if (!this.countryBriefPage) return;
const token = ++this.briefRequestToken;
this.countryBriefPage.showLoading();
this.map?.setRenderPaused(true);
const localGeo = getCountryAtCoordinates(lat, lon);
if (localGeo) {
if (token !== this.briefRequestToken) return; // superseded by newer click
this.openCountryBriefByCode(localGeo.code, localGeo.name);
return;
}
const geo = await reverseGeocode(lat, lon);
if (token !== this.briefRequestToken) return; // superseded by newer click
if (!geo) {
this.countryBriefPage.hide();
this.map?.setRenderPaused(false);
return;
}
this.openCountryBriefByCode(geo.code, geo.country);
}
public async openCountryBriefByCode(code: string, country: string): Promise<void> {
if (!this.countryBriefPage) return;
// Normalize to canonical name (GeoJSON may use "United States of America" etc.)
const canonicalName = TIER1_COUNTRIES[code] || App.resolveCountryName(code);
if (canonicalName !== code) country = canonicalName;
const scores = calculateCII();
const score = scores.find((s) => s.code === code) ?? null;
const signals = this.getCountrySignals(code, country);
this.countryBriefPage.show(country, code, score, signals);
this.map?.highlightCountry(code);
// Force URL to include ?country= immediately
const shareUrl = this.getShareUrl();
if (shareUrl) history.replaceState(null, '', shareUrl);
const stockPromise = fetch(`/api/stock-index?code=${encodeURIComponent(code)}`)
.then((r) => r.json())
.catch(() => ({ available: false }));
stockPromise.then((stock) => {
if (this.countryBriefPage?.getCode() === code) this.countryBriefPage.updateStock(stock);
});
fetchCountryMarkets(country)
.then((markets) => {
if (this.countryBriefPage?.getCode() === code) this.countryBriefPage.updateMarkets(markets);
})
.catch(() => {
if (this.countryBriefPage?.getCode() === code) this.countryBriefPage.updateMarkets([]);
});
// Pass evidence headlines
const searchTerms = App.getCountrySearchTerms(country, code);
const otherCountryTerms = App.getOtherCountryTerms(code);
const matchingNews = this.allNews.filter((n) => {
const t = n.title.toLowerCase();
return searchTerms.some((term) => t.includes(term));
});
const filteredNews = matchingNews.filter((n) => {
const t = n.title.toLowerCase();
const ourPos = App.firstMentionPosition(t, searchTerms);
const otherPos = App.firstMentionPosition(t, otherCountryTerms);
return ourPos !== Infinity && (otherPos === Infinity || ourPos <= otherPos);
});
if (filteredNews.length > 0) {
this.countryBriefPage.updateNews(filteredNews.slice(0, 8));
}
// Infrastructure exposure
this.countryBriefPage.updateInfrastructure(code);
// Timeline
this.mountCountryTimeline(code, country);
try {
const context: Record<string, unknown> = {};
if (score) {
context.score = score.score;
context.level = score.level;
context.trend = score.trend;
context.components = score.components;
context.change24h = score.change24h;
}
Object.assign(context, signals);
const countryCluster = signalAggregator.getCountryClusters().find((c) => c.country === code);
if (countryCluster) {
context.convergenceScore = countryCluster.convergenceScore;
context.signalTypes = [...countryCluster.signalTypes];
}
const convergences = signalAggregator.getRegionalConvergence()
.filter((r) => r.countries.includes(code));
if (convergences.length) {
context.regionalConvergence = convergences.map((r) => r.description);
}
const headlines = filteredNews.slice(0, 15).map((n) => n.title);
if (headlines.length) context.headlines = headlines;
const stockData = await stockPromise;
if (stockData.available) {
const pct = parseFloat(stockData.weekChangePercent);
context.stockIndex = `${stockData.indexName}: ${stockData.price} (${pct >= 0 ? '+' : ''}${stockData.weekChangePercent}% week)`;
}
let data: Record<string, unknown> | null = null;
try {
const res = await fetch('/api/country-intel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ country, code, context }),
});
data = await res.json();
} catch { /* server unreachable */ }
if (data && data.brief && !data.skipped) {
this.countryBriefPage!.updateBrief({ ...data, code } as Parameters<typeof this.countryBriefPage.updateBrief>[0]);
} else {
const briefHeadlines = (context.headlines as string[] | undefined) || [];
let fallbackBrief = '';
if (briefHeadlines.length >= 2 && mlWorker.isAvailable) {
try {
const prompt = `Summarize the current situation in ${country} based on these headlines: ${briefHeadlines.slice(0, 8).join('. ')}`;
const [summary] = await mlWorker.summarize([prompt]);
if (summary && summary.length > 20) fallbackBrief = summary;
} catch { /* T5 failed */ }
}
if (fallbackBrief) {
this.countryBriefPage!.updateBrief({ brief: fallbackBrief, country, code, fallback: true });
} else {
const lines: string[] = [];
if (score) lines.push(`**Instability Index: ${score.score}/100** (${score.level}, ${score.trend})`);
if (signals.protests > 0) lines.push(`${signals.protests} active protests detected`);
if (signals.militaryFlights > 0) lines.push(`${signals.militaryFlights} military aircraft tracked`);
if (signals.militaryVessels > 0) lines.push(`${signals.militaryVessels} military vessels tracked`);
if (signals.outages > 0) lines.push(`${signals.outages} internet outages`);
if (signals.earthquakes > 0) lines.push(`${signals.earthquakes} recent earthquakes`);
if (context.stockIndex) lines.push(`Stock index: ${context.stockIndex}`);
if (briefHeadlines.length > 0) {
lines.push('', '**Recent headlines:**');
briefHeadlines.slice(0, 5).forEach(h => lines.push(`${h}`));
}
if (lines.length > 0) {
this.countryBriefPage!.updateBrief({ brief: lines.join('\n'), country, code, fallback: true });
} else {
this.countryBriefPage!.updateBrief({ brief: '', country, code, error: 'No AI service available. Configure GROQ_API_KEY in Settings for full briefs.' });
}
}
}
} catch (err) {
console.error('[CountryBrief] fetch error:', err);
this.countryBriefPage!.updateBrief({ brief: '', country, code, error: 'Failed to generate brief' });
}
}
private mountCountryTimeline(code: string, country: string): void {
this.countryTimeline?.destroy();
this.countryTimeline = null;
const mount = this.countryBriefPage?.getTimelineMount();
if (!mount) return;
const events: TimelineEvent[] = [];
const countryLower = country.toLowerCase();
const hasGeoShape = hasCountryGeometry(code) || !!App.COUNTRY_BOUNDS[code];
const inCountry = (lat: number, lon: number) => hasGeoShape && this.isInCountry(lat, lon, code);
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
if (this.intelligenceCache.protests?.events) {
for (const e of this.intelligenceCache.protests.events) {
if (e.country?.toLowerCase() === countryLower || inCountry(e.lat, e.lon)) {
events.push({
timestamp: new Date(e.time).getTime(),
lane: 'protest',
label: e.title || `${e.eventType} in ${e.city || e.country}`,
severity: e.severity === 'high' ? 'high' : e.severity === 'medium' ? 'medium' : 'low',
});
}
}
}
if (this.intelligenceCache.earthquakes) {
for (const eq of this.intelligenceCache.earthquakes) {
if (inCountry(eq.lat, eq.lon) || eq.place?.toLowerCase().includes(countryLower)) {
events.push({
timestamp: new Date(eq.time).getTime(),
lane: 'natural',
label: `M${eq.magnitude.toFixed(1)} ${eq.place}`,
severity: eq.magnitude >= 6 ? 'critical' : eq.magnitude >= 5 ? 'high' : eq.magnitude >= 4 ? 'medium' : 'low',
});
}
}
}
if (this.intelligenceCache.military) {
for (const f of this.intelligenceCache.military.flights) {
if (hasGeoShape ? this.isInCountry(f.lat, f.lon, code) : f.operatorCountry?.toUpperCase() === code) {
events.push({
timestamp: new Date(f.lastSeen).getTime(),
lane: 'military',
label: `${f.callsign} (${f.aircraftModel || f.aircraftType})`,
severity: f.isInteresting ? 'high' : 'low',
});
}
}
for (const v of this.intelligenceCache.military.vessels) {
if (hasGeoShape ? this.isInCountry(v.lat, v.lon, code) : v.operatorCountry?.toUpperCase() === code) {
events.push({
timestamp: new Date(v.lastAisUpdate).getTime(),
lane: 'military',
label: `${v.name} (${v.vesselType})`,
severity: v.isDark ? 'high' : 'low',
});
}
}
}
const ciiData = getCountryData(code);
if (ciiData?.conflicts) {
for (const c of ciiData.conflicts) {
events.push({
timestamp: new Date(c.time).getTime(),
lane: 'conflict',
label: `${c.eventType}: ${c.location || c.country}`,
severity: c.fatalities > 0 ? 'critical' : 'high',
});
}
}
this.countryTimeline = new CountryTimeline(mount);
this.countryTimeline.render(events.filter(e => e.timestamp >= sevenDaysAgo));
}
private static COUNTRY_BOUNDS: Record<string, { n: number; s: number; e: number; w: number }> = {
IR: { n: 40, s: 25, e: 63, w: 44 }, IL: { n: 33.3, s: 29.5, e: 35.9, w: 34.3 },
SA: { n: 32, s: 16, e: 55, w: 35 }, AE: { n: 26.1, s: 22.6, e: 56.4, w: 51.6 },
@@ -631,6 +829,7 @@ export class App {
SD: { n: 22, s: 8.7, e: 38.6, w: 21.8 }, US: { n: 49, s: 24.5, e: -66.9, w: -125 },
GB: { n: 58.7, s: 49.9, e: 1.8, w: -8.2 }, DE: { n: 55.1, s: 47.3, e: 15.0, w: 5.9 },
FR: { n: 51.1, s: 41.3, e: 9.6, w: -5.1 }, TR: { n: 42.1, s: 36, e: 44.8, w: 26 },
BR: { n: 5.3, s: -33.8, e: -34.8, w: -73.9 },
};
private static COUNTRY_ALIASES: Record<string, string[]> = {
@@ -654,28 +853,78 @@ export class App {
TR: ['turkey', 'turkish', 'ankara', 'erdogan', 'türkiye'],
US: ['united states', 'american', 'washington', 'pentagon', 'white house'],
GB: ['united kingdom', 'british', 'london', 'uk '],
BR: ['brazil', 'brazilian', 'brasilia', 'lula', 'bolsonaro'],
AE: ['united arab emirates', 'uae', 'emirati', 'dubai', 'abu dhabi'],
};
private static otherCountryTermsCache: Map<string, string[]> = new Map();
private static firstMentionPosition(text: string, terms: string[]): number {
let earliest = Infinity;
for (const term of terms) {
const idx = text.indexOf(term);
if (idx !== -1 && idx < earliest) earliest = idx;
}
return earliest;
}
private static getOtherCountryTerms(code: string): string[] {
const cached = App.otherCountryTermsCache.get(code);
if (cached) return cached;
const dedup = new Set<string>();
Object.entries(App.COUNTRY_ALIASES).forEach(([countryCode, aliases]) => {
if (countryCode === code) return;
aliases.forEach((alias) => {
const normalized = alias.toLowerCase();
if (normalized.trim().length > 0) dedup.add(normalized);
});
});
const terms = [...dedup];
App.otherCountryTermsCache.set(code, terms);
return terms;
}
private static resolveCountryName(code: string): string {
if (TIER1_COUNTRIES[code]) return TIER1_COUNTRIES[code];
try {
const displayNamesCtor = (Intl as unknown as { DisplayNames?: IntlDisplayNamesCtor }).DisplayNames;
if (!displayNamesCtor) return code;
const displayNames = new displayNamesCtor(['en'], { type: 'region' });
const resolved = displayNames.of(code);
if (resolved && resolved.toUpperCase() !== code) return resolved;
} catch {
// Intl.DisplayNames unavailable in older runtimes.
}
return code;
}
private static getCountrySearchTerms(country: string, code: string): string[] {
const aliases = App.COUNTRY_ALIASES[code];
if (aliases) return aliases;
if (/^[A-Z]{2}$/i.test(country.trim())) return [];
return [country.toLowerCase()];
}
private isInCountry(lat: number, lon: number, code: string): boolean {
const precise = isCoordinateInCountry(lat, lon, code);
if (precise != null) return precise;
const b = App.COUNTRY_BOUNDS[code];
if (!b) return false;
return lat >= b.s && lat <= b.n && lon >= b.w && lon <= b.e;
}
private getCountrySignals(code: string, country: string): { protests: number; militaryFlights: number; militaryVessels: number; outages: number; earthquakes: number } {
private getCountrySignals(code: string, country: string): CountryBriefSignals {
const countryLower = country.toLowerCase();
const hasBbox = !!App.COUNTRY_BOUNDS[code];
const hasGeoShape = hasCountryGeometry(code) || !!App.COUNTRY_BOUNDS[code];
let protests = 0;
if (this.intelligenceCache.protests?.events) {
protests = this.intelligenceCache.protests.events.filter((e) =>
e.country?.toLowerCase() === countryLower || (hasBbox && this.isInCountry(e.lat, e.lon, code))
e.country?.toLowerCase() === countryLower || (hasGeoShape && this.isInCountry(e.lat, e.lon, code))
).length;
}
@@ -683,21 +932,42 @@ export class App {
let militaryVessels = 0;
if (this.intelligenceCache.military) {
militaryFlights = this.intelligenceCache.military.flights.filter((f) =>
hasBbox ? this.isInCountry(f.lat, f.lon, code) : f.operatorCountry?.toUpperCase() === code
hasGeoShape ? this.isInCountry(f.lat, f.lon, code) : f.operatorCountry?.toUpperCase() === code
).length;
militaryVessels = this.intelligenceCache.military.vessels.filter((v) =>
hasBbox ? this.isInCountry(v.lat, v.lon, code) : v.operatorCountry?.toUpperCase() === code
hasGeoShape ? this.isInCountry(v.lat, v.lon, code) : v.operatorCountry?.toUpperCase() === code
).length;
}
let outages = 0;
if (this.intelligenceCache.outages) {
outages = this.intelligenceCache.outages.filter((o) =>
o.country?.toLowerCase() === countryLower || (hasBbox && this.isInCountry(o.lat, o.lon, code))
o.country?.toLowerCase() === countryLower || (hasGeoShape && this.isInCountry(o.lat, o.lon, code))
).length;
}
return { protests, militaryFlights, militaryVessels, outages, earthquakes: 0 };
let earthquakes = 0;
if (this.intelligenceCache.earthquakes) {
earthquakes = this.intelligenceCache.earthquakes.filter((eq) => {
if (hasGeoShape) return this.isInCountry(eq.lat, eq.lon, code);
return eq.place?.toLowerCase().includes(countryLower);
}).length;
}
const ciiData = getCountryData(code);
const isTier1 = !!TIER1_COUNTRIES[code];
return {
protests,
militaryFlights,
militaryVessels,
outages,
earthquakes,
displacementOutflow: ciiData?.displacementOutflow ?? 0,
climateStress: ciiData?.climateStress ?? 0,
conflictEvents: ciiData?.conflicts?.length ?? 0,
isTier1,
};
}
private openCountryStory(code: string, name: string): void {
@@ -1619,8 +1889,10 @@ export class App {
const serviceStatusPanel = new ServiceStatusPanel();
this.panels['service-status'] = serviceStatusPanel;
const runtimeConfigPanel = new RuntimeConfigPanel({ mode: this.isDesktopApp ? 'alert' : 'full' });
this.panels['runtime-config'] = runtimeConfigPanel;
if (this.isDesktopApp) {
const runtimeConfigPanel = new RuntimeConfigPanel({ mode: 'alert' });
this.panels['runtime-config'] = runtimeConfigPanel;
}
// Tech Readiness Panel (tech variant only - World Bank tech indicators)
const techReadinessPanel = new TechReadinessPanel();
@@ -2007,6 +2279,7 @@ export class App {
center,
timeRange: state.timeRange,
layers: state.layers,
country: this.countryBriefPage?.isVisible() ? (this.countryBriefPage.getCode() ?? undefined) : undefined,
});
}
@@ -2701,11 +2974,13 @@ export class App {
// Handle earthquakes (USGS)
if (earthquakeResult.status === 'fulfilled') {
this.intelligenceCache.earthquakes = earthquakeResult.value;
this.map?.setEarthquakes(earthquakeResult.value);
ingestEarthquakes(earthquakeResult.value);
this.statusPanel?.updateApi('USGS', { status: 'ok' });
dataFreshness.recordUpdate('usgs', earthquakeResult.value.length);
} else {
this.intelligenceCache.earthquakes = [];
this.map?.setEarthquakes([]);
this.statusPanel?.updateApi('USGS', { status: 'error' });
dataFreshness.recordError('usgs', String(earthquakeResult.reason));
@@ -2809,6 +3084,7 @@ export class App {
outages?: InternetOutage[];
protests?: { events: SocialUnrestEvent[]; sources: { acled: number; gdelt: number } };
military?: { flights: MilitaryFlight[]; flightClusters: MilitaryFlightCluster[]; vessels: MilitaryVessel[]; vesselClusters: MilitaryVesselCluster[] };
earthquakes?: import('@/types').Earthquake[];
} = {};
/**
+622
View File
@@ -0,0 +1,622 @@
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
import type { CountryScore } from '@/services/country-instability';
import type { PredictionMarket, NewsItem } from '@/types';
import type { AssetType } from '@/types';
import type { CountryBriefSignals } from '@/App';
import type { StockIndexData } from '@/components/CountryIntelModal';
import { getNearbyInfrastructure, haversineDistanceKm } from '@/services/related-assets';
import { PORTS } from '@/config/ports';
import type { Port } from '@/config/ports';
import { exportCountryBriefJSON, exportCountryBriefCSV } from '@/utils/export';
import type { CountryBriefExport } from '@/utils/export';
type BriefAssetType = AssetType | 'port';
interface CountryIntelData {
brief: string;
country: string;
code: string;
cached?: boolean;
generatedAt?: string;
error?: string;
skipped?: boolean;
reason?: string;
fallback?: boolean;
}
export class CountryBriefPage {
private static BRIEF_BOUNDS: Record<string, { n: number; s: number; e: number; w: number }> = {
IR: { n: 40, s: 25, e: 63, w: 44 }, IL: { n: 33.3, s: 29.5, e: 35.9, w: 34.3 },
SA: { n: 32, s: 16, e: 55, w: 35 }, AE: { n: 26.1, s: 22.6, e: 56.4, w: 51.6 },
IQ: { n: 37.4, s: 29.1, e: 48.6, w: 38.8 }, SY: { n: 37.3, s: 32.3, e: 42.4, w: 35.7 },
YE: { n: 19, s: 12, e: 54.5, w: 42 }, LB: { n: 34.7, s: 33.1, e: 36.6, w: 35.1 },
CN: { n: 53.6, s: 18.2, e: 134.8, w: 73.5 }, TW: { n: 25.3, s: 21.9, e: 122, w: 120 },
JP: { n: 45.5, s: 24.2, e: 153.9, w: 122.9 }, KR: { n: 38.6, s: 33.1, e: 131.9, w: 124.6 },
KP: { n: 43.0, s: 37.7, e: 130.7, w: 124.2 }, IN: { n: 35.5, s: 6.7, e: 97.4, w: 68.2 },
PK: { n: 37, s: 24, e: 77, w: 61 }, AF: { n: 38.5, s: 29.4, e: 74.9, w: 60.5 },
UA: { n: 52.4, s: 44.4, e: 40.2, w: 22.1 }, RU: { n: 82, s: 41.2, e: 180, w: 19.6 },
BY: { n: 56.2, s: 51.3, e: 32.8, w: 23.2 }, PL: { n: 54.8, s: 49, e: 24.1, w: 14.1 },
EG: { n: 31.7, s: 22, e: 36.9, w: 25 }, LY: { n: 33, s: 19.5, e: 25, w: 9.4 },
SD: { n: 22, s: 8.7, e: 38.6, w: 21.8 }, US: { n: 49, s: 24.5, e: -66.9, w: -125 },
GB: { n: 58.7, s: 49.9, e: 1.8, w: -8.2 }, DE: { n: 55.1, s: 47.3, e: 15.0, w: 5.9 },
FR: { n: 51.1, s: 41.3, e: 9.6, w: -5.1 }, TR: { n: 42.1, s: 36, e: 44.8, w: 26 },
};
private static INFRA_ICONS: Record<BriefAssetType, string> = {
pipeline: '\u{1F50C}',
cable: '\u{1F310}',
datacenter: '\u{1F5A5}\uFE0F',
base: '\u{1F3DB}\uFE0F',
nuclear: '\u2622\uFE0F',
port: '\u2693',
};
private static INFRA_LABELS: Record<BriefAssetType, string> = {
pipeline: 'Pipelines',
cable: 'Undersea Cables',
datacenter: 'Data Centers',
base: 'Military Bases',
nuclear: 'Nuclear Facilities',
port: 'Ports',
};
private overlay: HTMLElement;
private currentCode: string | null = null;
private currentName: string | null = null;
private currentHeadlineCount = 0;
private currentScore: CountryScore | null = null;
private currentSignals: CountryBriefSignals | null = null;
private currentBrief: string | null = null;
private currentHeadlines: NewsItem[] = [];
private onCloseCallback?: () => void;
private onShareStory?: (code: string, name: string) => void;
private onExportImage?: (code: string, name: string) => void;
private boundExportMenuClose: (() => void) | null = null;
private boundCitationClick: ((e: Event) => void) | null = null;
constructor() {
this.overlay = document.createElement('div');
this.overlay.className = 'country-brief-overlay';
document.body.appendChild(this.overlay);
this.overlay.addEventListener('click', (e) => {
if ((e.target as HTMLElement).classList.contains('country-brief-overlay')) this.hide();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.overlay.classList.contains('active')) this.hide();
});
}
private countryFlag(code: string): string {
try {
return code
.toUpperCase()
.split('')
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
.join('');
} catch {
return '🌍';
}
}
private levelColor(level: string): string {
const colors: Record<string, string> = {
critical: '#ff4444',
high: '#ff8800',
elevated: '#ffaa00',
normal: '#44aa44',
low: '#3388ff',
};
return colors[level] || '#888';
}
private levelBadge(level: string): string {
const color = this.levelColor(level);
return `<span class="cb-badge" style="background:${color}20;color:${color};border:1px solid ${color}40">${level.toUpperCase()}</span>`;
}
private trendIndicator(trend: string): string {
const arrow = trend === 'rising' ? '↗' : trend === 'falling' ? '↘' : '→';
const cls = trend === 'rising' ? 'trend-up' : trend === 'falling' ? 'trend-down' : 'trend-stable';
return `<span class="cb-trend ${cls}">${arrow} ${trend}</span>`;
}
private scoreRing(score: number, level: string): string {
const color = this.levelColor(level);
const pct = Math.min(100, Math.max(0, score));
const circumference = 2 * Math.PI * 42;
const dashOffset = circumference * (1 - pct / 100);
return `
<div class="cb-score-ring">
<svg viewBox="0 0 100 100" width="120" height="120">
<circle cx="50" cy="50" r="42" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="6"/>
<circle cx="50" cy="50" r="42" fill="none" stroke="${color}" stroke-width="6"
stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}"
stroke-linecap="round" transform="rotate(-90 50 50)"
style="transition: stroke-dashoffset 0.8s ease"/>
</svg>
<div class="cb-score-value" style="color:${color}">${score}</div>
<div class="cb-score-label">/ 100</div>
</div>`;
}
private componentBars(components: CountryScore['components']): string {
const items = [
{ label: 'Unrest', value: components.unrest, icon: '📢' },
{ label: 'Conflict', value: components.conflict, icon: '⚔' },
{ label: 'Security', value: components.security, icon: '🛡️' },
{ label: 'Information', value: components.information, icon: '📡' },
];
return items.map(({ label, value, icon }) => {
const pct = Math.min(100, Math.max(0, value));
const color = pct >= 70 ? '#ff4444' : pct >= 50 ? '#ff8800' : pct >= 30 ? '#ffaa00' : '#44aa44';
return `
<div class="cb-comp-row">
<span class="cb-comp-icon">${icon}</span>
<span class="cb-comp-label">${label}</span>
<div class="cb-comp-bar"><div class="cb-comp-fill" style="width:${pct}%;background:${color}"></div></div>
<span class="cb-comp-val">${Math.round(value)}</span>
</div>`;
}).join('');
}
private signalChips(signals: CountryBriefSignals): string {
const chips: string[] = [];
if (signals.protests > 0) chips.push(`<span class="signal-chip protest">📢 ${signals.protests} protests</span>`);
if (signals.militaryFlights > 0) chips.push(`<span class="signal-chip military">✈️ ${signals.militaryFlights} mil. aircraft</span>`);
if (signals.militaryVessels > 0) chips.push(`<span class="signal-chip military">⚓ ${signals.militaryVessels} mil. vessels</span>`);
if (signals.outages > 0) chips.push(`<span class="signal-chip outage">🌐 ${signals.outages} outages</span>`);
if (signals.earthquakes > 0) chips.push(`<span class="signal-chip quake">🌍 ${signals.earthquakes} earthquakes</span>`);
if (signals.displacementOutflow > 0) {
const fmt = signals.displacementOutflow >= 1_000_000
? `${(signals.displacementOutflow / 1_000_000).toFixed(1)}M`
: `${(signals.displacementOutflow / 1000).toFixed(0)}K`;
chips.push(`<span class="signal-chip displacement">🌊 ${fmt} displaced</span>`);
}
if (signals.climateStress > 0) chips.push(`<span class="signal-chip climate">🌡️ Climate stress</span>`);
if (signals.conflictEvents > 0) chips.push(`<span class="signal-chip conflict">⚔️ ${signals.conflictEvents} conflict events</span>`);
chips.push(`<span class="signal-chip stock-loading">📈 Loading index...</span>`);
return chips.join('');
}
public setShareStoryHandler(handler: (code: string, name: string) => void): void {
this.onShareStory = handler;
}
public setExportImageHandler(handler: (code: string, name: string) => void): void {
this.onExportImage = handler;
}
public showLoading(): void {
this.currentCode = '__loading__';
this.overlay.innerHTML = `
<div class="country-brief-page">
<div class="cb-header">
<div class="cb-header-left">
<span class="cb-flag">🌍</span>
<span class="cb-country-name">Identifying country...</span>
</div>
<div class="cb-header-right">
<button class="cb-close" aria-label="Close">×</button>
</div>
</div>
<div class="cb-body">
<div class="cb-loading-state">
<div class="intel-skeleton"></div>
<div class="intel-skeleton short"></div>
<span class="intel-loading-text">Locating region...</span>
</div>
</div>
</div>`;
this.overlay.querySelector('.cb-close')?.addEventListener('click', () => this.hide());
this.overlay.classList.add('active');
}
public show(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void {
this.currentCode = code;
this.currentName = country;
this.currentScore = score;
this.currentSignals = signals;
this.currentBrief = null;
this.currentHeadlines = [];
this.currentHeadlineCount = 0;
const flag = this.countryFlag(code);
const tierBadge = !signals.isTier1
? '<span class="cb-tier-badge">Limited coverage</span>'
: '';
this.overlay.innerHTML = `
<div class="country-brief-page">
<div class="cb-header">
<div class="cb-header-left">
<span class="cb-flag">${flag}</span>
<span class="cb-country-name">${escapeHtml(country)}</span>
${score ? this.levelBadge(score.level) : ''}
${score ? this.trendIndicator(score.trend) : ''}
${tierBadge}
</div>
<div class="cb-header-right">
<button class="cb-share-btn" title="Share story">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v7a2 2 0 002 2h12a2 2 0 002-2v-7"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
<button class="cb-print-btn" title="Print / PDF">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</button>
<div style="position:relative;display:inline-block">
<button class="cb-export-btn" title="Export data">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<div class="cb-export-menu hidden">
<button class="cb-export-option" data-format="image">Export Image</button>
<button class="cb-export-option" data-format="json">Export JSON</button>
<button class="cb-export-option" data-format="csv">Export CSV</button>
</div>
</div>
<button class="cb-close" aria-label="Close">×</button>
</div>
</div>
<div class="cb-body">
<div class="cb-grid">
<div class="cb-col-left">
${score ? `
<section class="cb-section cb-risk-section">
<h3 class="cb-section-title">Instability Index</h3>
<div class="cb-risk-content">
${this.scoreRing(score.score, score.level)}
<div class="cb-components">
${this.componentBars(score.components)}
</div>
</div>
</section>` : signals.isTier1 ? '' : `
<section class="cb-section cb-risk-section">
<h3 class="cb-section-title">Instability Index</h3>
<div class="cb-not-tracked">
<span class="cb-not-tracked-icon">📊</span>
<span>Not tracked — ${escapeHtml(country)} is not in the CII tier-1 list</span>
</div>
</section>`}
<section class="cb-section cb-brief-section">
<h3 class="cb-section-title">Intelligence Brief</h3>
<div class="cb-brief-content">
<div class="intel-brief-loading">
<div class="intel-skeleton"></div>
<div class="intel-skeleton short"></div>
<div class="intel-skeleton"></div>
<div class="intel-skeleton short"></div>
<span class="intel-loading-text">Generating intelligence brief...</span>
</div>
</div>
</section>
<section class="cb-section cb-news-section" style="display:none">
<h3 class="cb-section-title">Top News</h3>
<div class="cb-news-content"></div>
</section>
</div>
<div class="cb-col-right">
<section class="cb-section cb-signals-section">
<h3 class="cb-section-title">Active Signals</h3>
<div class="cb-signals-grid">
${this.signalChips(signals)}
</div>
</section>
<section class="cb-section cb-timeline-section">
<h3 class="cb-section-title">7-Day Timeline</h3>
<div class="cb-timeline-mount"></div>
</section>
<section class="cb-section cb-markets-section">
<h3 class="cb-section-title">Prediction Markets</h3>
<div class="cb-markets-content">
<span class="intel-loading-text">Loading prediction markets...</span>
</div>
</section>
<section class="cb-section cb-infra-section" style="display:none">
<h3 class="cb-section-title">Infrastructure Exposure</h3>
<div class="cb-infra-content"></div>
</section>
</div>
</div>
</div>
</div>`;
this.overlay.querySelector('.cb-close')?.addEventListener('click', () => this.hide());
this.overlay.querySelector('.cb-share-btn')?.addEventListener('click', () => {
if (this.onShareStory && this.currentCode && this.currentName) {
this.onShareStory(this.currentCode, this.currentName);
}
});
this.overlay.querySelector('.cb-print-btn')?.addEventListener('click', () => {
window.print();
});
const exportBtn = this.overlay.querySelector('.cb-export-btn');
const exportMenu = this.overlay.querySelector('.cb-export-menu');
exportBtn?.addEventListener('click', (e) => {
e.stopPropagation();
exportMenu?.classList.toggle('hidden');
});
this.overlay.querySelectorAll('.cb-export-option').forEach(opt => {
opt.addEventListener('click', () => {
const format = (opt as HTMLElement).dataset.format;
if (format === 'image') {
if (this.onExportImage && this.currentCode && this.currentName) {
this.onExportImage(this.currentCode, this.currentName);
}
} else {
this.exportBrief(format as 'json' | 'csv');
}
exportMenu?.classList.add('hidden');
});
});
// Remove previous overlay-level listeners to prevent accumulation
if (this.boundExportMenuClose) this.overlay.removeEventListener('click', this.boundExportMenuClose);
if (this.boundCitationClick) this.overlay.removeEventListener('click', this.boundCitationClick);
this.boundExportMenuClose = () => exportMenu?.classList.add('hidden');
this.overlay.addEventListener('click', this.boundExportMenuClose);
this.boundCitationClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains('cb-citation')) {
e.preventDefault();
const href = target.getAttribute('href');
if (href) {
const el = this.overlay.querySelector(href);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
el?.classList.add('cb-news-highlight');
setTimeout(() => el?.classList.remove('cb-news-highlight'), 2000);
}
}
};
this.overlay.addEventListener('click', this.boundCitationClick);
this.overlay.classList.add('active');
}
public updateBrief(data: CountryIntelData): void {
if (data.code !== this.currentCode) return;
const section = this.overlay.querySelector('.cb-brief-content');
if (!section) return;
if (data.error || data.skipped || !data.brief) {
const msg = data.error || data.reason || 'AI brief unavailable — configure GROQ_API_KEY in Settings.';
section.innerHTML = `<div class="intel-error">${escapeHtml(msg)}</div>`;
return;
}
this.currentBrief = data.brief;
const formatted = this.formatBrief(data.brief, this.currentHeadlineCount);
section.innerHTML = `
<div class="cb-brief-text">${formatted}</div>
<div class="cb-brief-footer">
${data.cached ? '<span class="intel-cached">📋 Cached</span>' : '<span class="intel-fresh">✨ Fresh</span>'}
<span class="intel-timestamp">${data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString() : ''}</span>
</div>`;
}
public updateMarkets(markets: PredictionMarket[]): void {
const section = this.overlay.querySelector('.cb-markets-content');
if (!section) return;
if (markets.length === 0) {
section.innerHTML = '<span class="cb-empty">No prediction markets found</span>';
return;
}
section.innerHTML = markets.slice(0, 3).map(m => {
const pct = Math.round(m.yesPrice);
const noPct = 100 - pct;
const vol = m.volume ? `$${(m.volume / 1000).toFixed(0)}k vol` : '';
const safeUrl = sanitizeUrl(m.url || '');
const link = safeUrl ? ` <a href="${safeUrl}" target="_blank" rel="noopener" class="cb-market-link">↗</a>` : '';
return `
<div class="cb-market-item">
<div class="cb-market-title">${escapeHtml(m.title.slice(0, 100))}${link}</div>
<div class="market-bar">
<div class="market-yes" style="width:${pct}%">${pct}%</div>
<div class="market-no" style="width:${noPct}%">${noPct > 15 ? noPct + '%' : ''}</div>
</div>
${vol ? `<div class="market-vol">${vol}</div>` : ''}
</div>`;
}).join('');
}
public updateStock(data: StockIndexData): void {
const el = this.overlay.querySelector('.stock-loading');
if (!el) return;
if (!data.available) {
el.remove();
return;
}
const pct = parseFloat(data.weekChangePercent);
const sign = pct >= 0 ? '+' : '';
const cls = pct >= 0 ? 'stock-up' : 'stock-down';
const arrow = pct >= 0 ? '📈' : '📉';
el.className = `signal-chip stock ${cls}`;
el.innerHTML = `${arrow} ${escapeHtml(data.indexName)}: ${sign}${data.weekChangePercent}% (1W)`;
}
public updateNews(headlines: NewsItem[]): void {
const section = this.overlay.querySelector('.cb-news-section') as HTMLElement | null;
const content = this.overlay.querySelector('.cb-news-content');
if (!section || !content || headlines.length === 0) return;
const items = headlines.slice(0, 8);
this.currentHeadlineCount = items.length;
this.currentHeadlines = items;
section.style.display = '';
content.innerHTML = items.map((item, i) => {
const safeUrl = sanitizeUrl(item.link);
const threatColor = item.threat?.level === 'critical' ? '#ff4444'
: item.threat?.level === 'high' ? '#ff8800'
: item.threat?.level === 'medium' ? '#ffaa00'
: '#64b4ff';
const timeAgo = this.timeAgo(item.pubDate);
const cardBody = `
<span class="cb-news-threat" style="background:${threatColor}"></span>
<div class="cb-news-body">
<div class="cb-news-title">${escapeHtml(item.title)}</div>
<div class="cb-news-meta">${escapeHtml(item.source)} · ${timeAgo}</div>
</div>`;
if (safeUrl) {
return `<a href="${safeUrl}" target="_blank" rel="noopener" class="cb-news-card" id="cb-news-${i + 1}">${cardBody}</a>`;
}
return `<div class="cb-news-card" id="cb-news-${i + 1}">${cardBody}</div>`;
}).join('');
}
public updateInfrastructure(countryCode: string): void {
const bounds = CountryBriefPage.BRIEF_BOUNDS[countryCode];
if (!bounds) return;
const centroidLat = (bounds.n + bounds.s) / 2;
const centroidLon = (bounds.e + bounds.w) / 2;
const assets = getNearbyInfrastructure(centroidLat, centroidLon, ['pipeline', 'cable', 'datacenter', 'base', 'nuclear']);
const nearbyPorts = PORTS
.map((p: Port) => ({ port: p, dist: haversineDistanceKm(centroidLat, centroidLon, p.lat, p.lon) }))
.filter(({ dist }) => dist <= 600)
.sort((a, b) => a.dist - b.dist)
.slice(0, 5);
const grouped = new Map<BriefAssetType, Array<{ name: string; distanceKm: number }>>();
for (const a of assets) {
const list = grouped.get(a.type) || [];
list.push({ name: a.name, distanceKm: a.distanceKm });
grouped.set(a.type, list);
}
if (nearbyPorts.length > 0) {
grouped.set('port', nearbyPorts.map(({ port, dist }) => ({ name: port.name, distanceKm: dist })));
}
if (grouped.size === 0) return;
const section = this.overlay.querySelector('.cb-infra-section') as HTMLElement | null;
const content = this.overlay.querySelector('.cb-infra-content');
if (!section || !content) return;
const order: BriefAssetType[] = ['pipeline', 'cable', 'datacenter', 'base', 'nuclear', 'port'];
let html = '';
for (const type of order) {
const items = grouped.get(type);
if (!items || items.length === 0) continue;
const icon = CountryBriefPage.INFRA_ICONS[type];
const label = CountryBriefPage.INFRA_LABELS[type];
html += `<div class="cb-infra-group">`;
html += `<div class="cb-infra-type">${icon} ${label}</div>`;
for (const item of items) {
html += `<div class="cb-infra-item"><span>${escapeHtml(item.name)}</span><span class="cb-infra-dist">${Math.round(item.distanceKm)} km</span></div>`;
}
html += `</div>`;
}
content.innerHTML = html;
section.style.display = '';
}
public getTimelineMount(): HTMLElement | null {
return this.overlay.querySelector('.cb-timeline-mount');
}
public getCode(): string | null {
return this.currentCode;
}
public getName(): string | null {
return this.currentName;
}
private timeAgo(date: Date): string {
const ms = Date.now() - new Date(date).getTime();
const hours = Math.floor(ms / 3600000);
if (hours < 1) return `${Math.floor(ms / 60000)}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
private formatBrief(text: string, headlineCount = 0): string {
let html = escapeHtml(text)
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^/, '<p>')
.replace(/$/, '</p>');
if (headlineCount > 0) {
html = html.replace(/\[(\d{1,2})\]/g, (_match, numStr) => {
const n = parseInt(numStr, 10);
if (n >= 1 && n <= headlineCount) {
return `<a href="#cb-news-${n}" class="cb-citation" title="Source [${n}]">[${n}]</a>`;
}
return `[${numStr}]`;
});
}
return html;
}
private exportBrief(format: 'json' | 'csv'): void {
if (!this.currentCode || !this.currentName) return;
const data: CountryBriefExport = {
country: this.currentName,
code: this.currentCode,
generatedAt: new Date().toISOString(),
};
if (this.currentScore) {
data.score = this.currentScore.score;
data.level = this.currentScore.level;
data.trend = this.currentScore.trend;
data.components = this.currentScore.components;
}
if (this.currentSignals) {
data.signals = {
protests: this.currentSignals.protests,
militaryFlights: this.currentSignals.militaryFlights,
militaryVessels: this.currentSignals.militaryVessels,
outages: this.currentSignals.outages,
earthquakes: this.currentSignals.earthquakes,
displacementOutflow: this.currentSignals.displacementOutflow,
climateStress: this.currentSignals.climateStress,
conflictEvents: this.currentSignals.conflictEvents,
};
}
if (this.currentBrief) data.brief = this.currentBrief;
if (this.currentHeadlines.length > 0) {
data.headlines = this.currentHeadlines.map(h => ({
title: h.title,
source: h.source,
link: h.link,
pubDate: h.pubDate ? new Date(h.pubDate).toISOString() : undefined,
}));
}
if (format === 'json') exportCountryBriefJSON(data);
else exportCountryBriefCSV(data);
}
public hide(): void {
this.overlay.classList.remove('active');
this.currentCode = null;
this.currentName = null;
this.onCloseCallback?.();
}
public onClose(cb: () => void): void {
this.onCloseCallback = cb;
}
public isVisible(): boolean {
return this.overlay.classList.contains('active');
}
}
+272
View File
@@ -0,0 +1,272 @@
import * as d3 from 'd3';
import { escapeHtml } from '@/utils/sanitize';
export interface TimelineEvent {
timestamp: number;
lane: 'protest' | 'conflict' | 'natural' | 'military';
label: string;
severity?: 'low' | 'medium' | 'high' | 'critical';
}
const LANES: TimelineEvent['lane'][] = ['protest', 'conflict', 'natural', 'military'];
const LANE_COLORS: Record<TimelineEvent['lane'], string> = {
protest: '#ffaa00',
conflict: '#ff4444',
natural: '#b478ff',
military: '#64b4ff',
};
const SEVERITY_RADIUS: Record<string, number> = {
low: 4,
medium: 5,
high: 7,
critical: 9,
};
const MARGIN = { top: 20, right: 20, bottom: 30, left: 80 };
const HEIGHT = 200;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
export class CountryTimeline {
private container: HTMLElement;
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null;
private tooltip: HTMLDivElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private currentEvents: TimelineEvent[] = [];
constructor(container: HTMLElement) {
this.container = container;
this.createTooltip();
this.resizeObserver = new ResizeObserver(() => {
if (this.currentEvents.length > 0) this.render(this.currentEvents);
});
this.resizeObserver.observe(this.container);
}
private createTooltip(): void {
this.tooltip = document.createElement('div');
Object.assign(this.tooltip.style, {
position: 'absolute',
pointerEvents: 'none',
background: 'rgba(20, 20, 30, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.15)',
borderRadius: '6px',
padding: '6px 10px',
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.85)',
zIndex: '9999',
display: 'none',
whiteSpace: 'nowrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
});
this.container.style.position = 'relative';
this.container.appendChild(this.tooltip);
}
render(events: TimelineEvent[]): void {
this.currentEvents = events;
if (this.svg) this.svg.remove();
const width = this.container.clientWidth;
if (width <= 0) return;
const innerW = width - MARGIN.left - MARGIN.right;
const innerH = HEIGHT - MARGIN.top - MARGIN.bottom;
this.svg = d3
.select(this.container)
.append('svg')
.attr('width', width)
.attr('height', HEIGHT)
.attr('style', 'display:block;');
const g = this.svg
.append('g')
.attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);
const now = Date.now();
const xScale = d3
.scaleTime()
.domain([new Date(now - SEVEN_DAYS_MS), new Date(now)])
.range([0, innerW]);
const yScale = d3
.scaleBand<string>()
.domain(LANES)
.range([0, innerH])
.padding(0.2);
this.drawGrid(g, xScale, innerH);
this.drawAxes(g, xScale, yScale, innerH);
this.drawNowMarker(g, xScale, new Date(now), innerH);
this.drawEmptyLaneLabels(g, events, yScale, innerW);
this.drawEvents(g, events, xScale, yScale);
}
private drawGrid(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleTime<number, number>,
innerH: number,
): void {
const ticks = xScale.ticks(6);
g.selectAll('.grid-line')
.data(ticks)
.join('line')
.attr('x1', (d) => xScale(d))
.attr('x2', (d) => xScale(d))
.attr('y1', 0)
.attr('y2', innerH)
.attr('stroke', 'rgba(255,255,255,0.05)')
.attr('stroke-width', 1);
}
private drawAxes(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleTime<number, number>,
yScale: d3.ScaleBand<string>,
innerH: number,
): void {
const xAxis = d3
.axisBottom(xScale)
.ticks(6)
.tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue, i: number) => string);
const xAxisG = g
.append('g')
.attr('transform', `translate(0,${innerH})`)
.call(xAxis);
xAxisG.selectAll('text').attr('fill', 'rgba(255,255,255,0.5)').attr('font-size', '10px');
xAxisG.selectAll('line').attr('stroke', 'rgba(255,255,255,0.15)');
xAxisG.select('.domain').attr('stroke', 'rgba(255,255,255,0.15)');
const laneLabels: Record<string, string> = {
protest: 'Protest',
conflict: 'Conflict',
natural: 'Natural',
military: 'Military',
};
g.selectAll('.lane-label')
.data(LANES)
.join('text')
.attr('x', -10)
.attr('y', (d) => (yScale(d) ?? 0) + yScale.bandwidth() / 2)
.attr('text-anchor', 'end')
.attr('dominant-baseline', 'central')
.attr('fill', (d: TimelineEvent['lane']) => LANE_COLORS[d])
.attr('font-size', '11px')
.attr('font-weight', '500')
.text((d: TimelineEvent['lane']) => laneLabels[d] || d);
}
private drawNowMarker(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
xScale: d3.ScaleTime<number, number>,
now: Date,
innerH: number,
): void {
const x = xScale(now);
g.append('line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', 0)
.attr('y2', innerH)
.attr('stroke', '#ffffff')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '4,3')
.attr('opacity', 0.6);
g.append('text')
.attr('x', x)
.attr('y', -6)
.attr('text-anchor', 'middle')
.attr('fill', 'rgba(255,255,255,0.4)')
.attr('font-size', '9px')
.text('now');
}
private drawEmptyLaneLabels(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
events: TimelineEvent[],
yScale: d3.ScaleBand<string>,
innerW: number,
): void {
const populatedLanes = new Set(events.map((e) => e.lane));
const emptyLanes = LANES.filter((l) => !populatedLanes.has(l));
g.selectAll('.empty-label')
.data(emptyLanes)
.join('text')
.attr('x', innerW / 2)
.attr('y', (d) => (yScale(d) ?? 0) + yScale.bandwidth() / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', 'rgba(255,255,255,0.2)')
.attr('font-size', '10px')
.attr('font-style', 'italic')
.text('No events in 7 days');
}
private drawEvents(
g: d3.Selection<SVGGElement, unknown, null, undefined>,
events: TimelineEvent[],
xScale: d3.ScaleTime<number, number>,
yScale: d3.ScaleBand<string>,
): void {
const tooltip = this.tooltip!;
const container = this.container;
const fmt = d3.timeFormat('%b %d, %H:%M');
g.selectAll('.event-circle')
.data(events)
.join('circle')
.attr('cx', (d) => xScale(new Date(d.timestamp)))
.attr('cy', (d) => (yScale(d.lane) ?? 0) + yScale.bandwidth() / 2)
.attr('r', (d) => SEVERITY_RADIUS[d.severity ?? 'medium'] ?? 5)
.attr('fill', (d) => LANE_COLORS[d.lane])
.attr('opacity', 0.85)
.attr('cursor', 'pointer')
.attr('stroke', 'rgba(0,0,0,0.3)')
.attr('stroke-width', 0.5)
.on('mouseenter', function (event: MouseEvent, d: TimelineEvent) {
d3.select(this).attr('opacity', 1).attr('stroke', '#fff').attr('stroke-width', 1.5);
const dateStr = fmt(new Date(d.timestamp));
tooltip.innerHTML = `<strong>${escapeHtml(d.label)}</strong><br/>${escapeHtml(dateStr)}`;
tooltip.style.display = 'block';
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left + 12;
const y = event.clientY - rect.top - 10;
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
})
.on('mousemove', function (event: MouseEvent) {
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left + 12;
const y = event.clientY - rect.top - 10;
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
})
.on('mouseleave', function () {
d3.select(this).attr('opacity', 0.85).attr('stroke', 'rgba(0,0,0,0.3)').attr('stroke-width', 0.5);
tooltip.style.display = 'none';
});
}
destroy(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.svg) {
this.svg.remove();
this.svg = null;
}
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
this.currentEvents = [];
}
}
+319 -50
View File
@@ -73,11 +73,19 @@ import {
} from '@/services/hotspot-escalation';
import { getCountryScore } from '@/services/country-instability';
import { getAlertsNearLocation } from '@/services/geo-convergence';
import { getCountriesGeoJson, getCountryAtCoordinates } from '@/services/country-geometry';
export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all';
export type DeckMapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania';
type MapInteractionMode = 'flat' | '3d';
export interface CountryClickPayload {
lat: number;
lon: number;
code?: string;
name?: string;
}
interface DeckMapState {
zoom: number;
pan: { x: number; y: number };
@@ -176,6 +184,8 @@ const MARKER_ICONS = {
};
export class DeckGLMap {
private static readonly MAX_CLUSTER_LEAVES = 200;
private container: HTMLElement;
private deckOverlay: MapboxOverlay | null = null;
private maplibreMap: maplibregl.Map | null = null;
@@ -213,7 +223,7 @@ export class DeckGLMap {
// Callbacks
private onHotspotClick?: (hotspot: Hotspot) => void;
private onTimeRangeChange?: (range: TimeRange) => void;
private onCountryClick?: (lat: number, lon: number) => void;
private onCountryClick?: (country: CountryClickPayload) => void;
private onLayerChange?: (layer: keyof MapLayers, enabled: boolean) => void;
private onStateChange?: (state: DeckMapState) => void;
@@ -243,6 +253,8 @@ export class DeckGLMap {
private techEventClusters: MapTechEventCluster[] = [];
private datacenterClusters: MapDatacenterCluster[] = [];
private lastSCZoom = -1;
private lastSCBoundsKey = '';
private lastSCMask = '';
private newsPulseIntervalId: ReturnType<typeof setInterval> | null = null;
private lastCableHighlightSignature = '';
private lastPipelineHighlightSignature = '';
@@ -431,9 +443,36 @@ export class DeckGLMap {
const points = this.protests.map((p, i) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] as [number, number] },
properties: { index: i },
properties: {
index: i,
country: p.country,
severity: p.severity,
eventType: p.eventType,
validated: Boolean(p.validated),
fatalities: Number.isFinite(p.fatalities) ? Number(p.fatalities) : 0,
},
}));
this.protestSC = new Supercluster({ radius: 60, maxZoom: 14 });
this.protestSC = new Supercluster({
radius: 60,
maxZoom: 14,
map: (props: Record<string, unknown>) => ({
index: Number(props.index ?? 0),
country: String(props.country ?? ''),
maxSeverityRank: props.severity === 'high' ? 2 : props.severity === 'medium' ? 1 : 0,
riotCount: props.eventType === 'riot' ? 1 : 0,
highSeverityCount: props.severity === 'high' ? 1 : 0,
verifiedCount: props.validated ? 1 : 0,
totalFatalities: Number(props.fatalities ?? 0) || 0,
}),
reduce: (acc: Record<string, unknown>, props: Record<string, unknown>) => {
acc.maxSeverityRank = Math.max(Number(acc.maxSeverityRank ?? 0), Number(props.maxSeverityRank ?? 0));
acc.riotCount = Number(acc.riotCount ?? 0) + Number(props.riotCount ?? 0);
acc.highSeverityCount = Number(acc.highSeverityCount ?? 0) + Number(props.highSeverityCount ?? 0);
acc.verifiedCount = Number(acc.verifiedCount ?? 0) + Number(props.verifiedCount ?? 0);
acc.totalFatalities = Number(acc.totalFatalities ?? 0) + Number(props.totalFatalities ?? 0);
if (!acc.country && props.country) acc.country = props.country;
},
});
this.protestSC.load(points);
this.lastSCZoom = -1;
}
@@ -442,9 +481,32 @@ export class DeckGLMap {
const points = TECH_HQS.map((h, i) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [h.lon, h.lat] as [number, number] },
properties: { index: i },
properties: {
index: i,
city: h.city,
country: h.country,
type: h.type,
},
}));
this.techHQSC = new Supercluster({ radius: 50, maxZoom: 14 });
this.techHQSC = new Supercluster({
radius: 50,
maxZoom: 14,
map: (props: Record<string, unknown>) => ({
index: Number(props.index ?? 0),
city: String(props.city ?? ''),
country: String(props.country ?? ''),
faangCount: props.type === 'faang' ? 1 : 0,
unicornCount: props.type === 'unicorn' ? 1 : 0,
publicCount: props.type === 'public' ? 1 : 0,
}),
reduce: (acc: Record<string, unknown>, props: Record<string, unknown>) => {
acc.faangCount = Number(acc.faangCount ?? 0) + Number(props.faangCount ?? 0);
acc.unicornCount = Number(acc.unicornCount ?? 0) + Number(props.unicornCount ?? 0);
acc.publicCount = Number(acc.publicCount ?? 0) + Number(props.publicCount ?? 0);
if (!acc.city && props.city) acc.city = props.city;
if (!acc.country && props.country) acc.country = props.country;
},
});
this.techHQSC.load(points);
this.lastSCZoom = -1;
}
@@ -453,9 +515,36 @@ export class DeckGLMap {
const points = this.techEvents.map((e, i) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] as [number, number] },
properties: { index: i },
properties: {
index: i,
location: e.location,
country: e.country,
daysUntil: e.daysUntil,
},
}));
this.techEventSC = new Supercluster({ radius: 50, maxZoom: 14 });
this.techEventSC = new Supercluster({
radius: 50,
maxZoom: 14,
map: (props: Record<string, unknown>) => {
const daysUntil = Number(props.daysUntil ?? Number.MAX_SAFE_INTEGER);
return {
index: Number(props.index ?? 0),
location: String(props.location ?? ''),
country: String(props.country ?? ''),
soonestDaysUntil: Number.isFinite(daysUntil) ? daysUntil : Number.MAX_SAFE_INTEGER,
soonCount: Number.isFinite(daysUntil) && daysUntil <= 14 ? 1 : 0,
};
},
reduce: (acc: Record<string, unknown>, props: Record<string, unknown>) => {
acc.soonestDaysUntil = Math.min(
Number(acc.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER),
Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER),
);
acc.soonCount = Number(acc.soonCount ?? 0) + Number(props.soonCount ?? 0);
if (!acc.location && props.location) acc.location = props.location;
if (!acc.country && props.country) acc.country = props.country;
},
});
this.techEventSC.load(points);
this.lastSCZoom = -1;
}
@@ -465,39 +554,83 @@ export class DeckGLMap {
const points = activeDCs.map((dc, i) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [dc.lon, dc.lat] as [number, number] },
properties: { index: i },
properties: {
index: i,
country: dc.country,
chipCount: dc.chipCount,
powerMW: dc.powerMW ?? 0,
status: dc.status,
},
}));
this.datacenterSC = new Supercluster({ radius: 70, maxZoom: 14 });
this.datacenterSC = new Supercluster({
radius: 70,
maxZoom: 14,
map: (props: Record<string, unknown>) => ({
index: Number(props.index ?? 0),
country: String(props.country ?? ''),
totalChips: Number(props.chipCount ?? 0) || 0,
totalPowerMW: Number(props.powerMW ?? 0) || 0,
existingCount: props.status === 'existing' ? 1 : 0,
plannedCount: props.status === 'planned' ? 1 : 0,
}),
reduce: (acc: Record<string, unknown>, props: Record<string, unknown>) => {
acc.totalChips = Number(acc.totalChips ?? 0) + Number(props.totalChips ?? 0);
acc.totalPowerMW = Number(acc.totalPowerMW ?? 0) + Number(props.totalPowerMW ?? 0);
acc.existingCount = Number(acc.existingCount ?? 0) + Number(props.existingCount ?? 0);
acc.plannedCount = Number(acc.plannedCount ?? 0) + Number(props.plannedCount ?? 0);
if (!acc.country && props.country) acc.country = props.country;
},
});
this.datacenterSC.load(points);
this.lastSCZoom = -1;
}
private updateClusterData(): void {
const zoom = Math.floor(this.maplibreMap?.getZoom() ?? 2);
if (zoom === this.lastSCZoom) return;
this.lastSCZoom = zoom;
const bounds = this.maplibreMap?.getBounds();
if (!bounds) return;
const bbox: [number, number, number, number] = [
bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth(),
];
const boundsKey = `${bbox[0].toFixed(4)}:${bbox[1].toFixed(4)}:${bbox[2].toFixed(4)}:${bbox[3].toFixed(4)}`;
const layers = this.state.layers;
const useProtests = layers.protests && this.protests.length > 0;
const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs;
const useTechEvents = SITE_VARIANT === 'tech' && layers.techEvents && this.techEvents.length > 0;
const useDatacenterClusters = layers.datacenters && zoom < 5;
const layerMask = `${Number(useProtests)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`;
if (zoom === this.lastSCZoom && boundsKey === this.lastSCBoundsKey && layerMask === this.lastSCMask) return;
this.lastSCZoom = zoom;
this.lastSCBoundsKey = boundsKey;
this.lastSCMask = layerMask;
if (this.protestSC) {
if (useProtests && this.protestSC) {
this.protestClusters = this.protestSC.getClusters(bbox, zoom).map(f => {
const coords = f.geometry.coordinates as [number, number];
if (f.properties.cluster) {
const leaves = this.protestSC!.getLeaves(f.properties.cluster_id!, Infinity);
const props = f.properties as Record<string, unknown>;
const leaves = this.protestSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES);
const items = leaves.map(l => this.protests[l.properties.index]).filter((x): x is SocialUnrestEvent => !!x);
const maxSev = items.some(i => i.severity === 'high') ? 'high' : items.some(i => i.severity === 'medium') ? 'medium' : 'low';
const maxSeverityRank = Number(props.maxSeverityRank ?? 0);
const maxSev = maxSeverityRank >= 2 ? 'high' : maxSeverityRank === 1 ? 'medium' : 'low';
const riotCount = Number(props.riotCount ?? 0);
const highSeverityCount = Number(props.highSeverityCount ?? 0);
const verifiedCount = Number(props.verifiedCount ?? 0);
const totalFatalities = Number(props.totalFatalities ?? 0);
const clusterCount = Number(f.properties.point_count ?? items.length);
return {
id: `pc-${f.properties.cluster_id}`,
lat: coords[1], lon: coords[0],
count: f.properties.point_count!,
items, country: items[0]?.country ?? '',
count: clusterCount,
items,
country: String(props.country ?? items[0]?.country ?? ''),
maxSeverity: maxSev as 'low' | 'medium' | 'high',
hasRiot: items.some(i => i.eventType === 'riot'),
totalFatalities: items.reduce((s, i) => s + (i.fatalities ?? 0), 0),
hasRiot: riotCount > 0,
totalFatalities,
riotCount,
highSeverityCount,
verifiedCount,
sampled: items.length < clusterCount,
};
}
const item = this.protests[f.properties.index]!;
@@ -506,22 +639,44 @@ export class DeckGLMap {
count: 1, items: [item], country: item.country,
maxSeverity: item.severity, hasRiot: item.eventType === 'riot',
totalFatalities: item.fatalities ?? 0,
riotCount: item.eventType === 'riot' ? 1 : 0,
highSeverityCount: item.severity === 'high' ? 1 : 0,
verifiedCount: item.validated ? 1 : 0,
sampled: false,
};
});
} else {
this.protestClusters = [];
}
if (this.techHQSC) {
if (useTechHQ && this.techHQSC) {
this.techHQClusters = this.techHQSC.getClusters(bbox, zoom).map(f => {
const coords = f.geometry.coordinates as [number, number];
if (f.properties.cluster) {
const leaves = this.techHQSC!.getLeaves(f.properties.cluster_id!, Infinity);
const props = f.properties as Record<string, unknown>;
const leaves = this.techHQSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES);
const items = leaves.map(l => TECH_HQS[l.properties.index]).filter(Boolean) as typeof TECH_HQS;
const faangCount = Number(props.faangCount ?? 0);
const unicornCount = Number(props.unicornCount ?? 0);
const publicCount = Number(props.publicCount ?? 0);
const clusterCount = Number(f.properties.point_count ?? items.length);
const primaryType = faangCount >= unicornCount && faangCount >= publicCount
? 'faang'
: unicornCount >= publicCount
? 'unicorn'
: 'public';
return {
id: `hc-${f.properties.cluster_id}`,
lat: coords[1], lon: coords[0],
count: f.properties.point_count!,
items, city: items[0]?.city ?? '', country: items[0]?.country ?? '',
primaryType: items[0]?.type ?? 'public',
count: clusterCount,
items,
city: String(props.city ?? items[0]?.city ?? ''),
country: String(props.country ?? items[0]?.country ?? ''),
primaryType,
faangCount,
unicornCount,
publicCount,
sampled: items.length < clusterCount,
};
}
const item = TECH_HQS[f.properties.index]!;
@@ -529,22 +684,36 @@ export class DeckGLMap {
id: `hp-${f.properties.index}`, lat: item.lat, lon: item.lon,
count: 1, items: [item], city: item.city, country: item.country,
primaryType: item.type,
faangCount: item.type === 'faang' ? 1 : 0,
unicornCount: item.type === 'unicorn' ? 1 : 0,
publicCount: item.type === 'public' ? 1 : 0,
sampled: false,
};
});
} else {
this.techHQClusters = [];
}
if (this.techEventSC) {
if (useTechEvents && this.techEventSC) {
this.techEventClusters = this.techEventSC.getClusters(bbox, zoom).map(f => {
const coords = f.geometry.coordinates as [number, number];
if (f.properties.cluster) {
const leaves = this.techEventSC!.getLeaves(f.properties.cluster_id!, Infinity);
const props = f.properties as Record<string, unknown>;
const leaves = this.techEventSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES);
const items = leaves.map(l => this.techEvents[l.properties.index]).filter((x): x is TechEventMarker => !!x);
const clusterCount = Number(f.properties.point_count ?? items.length);
const soonestDaysUntil = Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER);
const soonCount = Number(props.soonCount ?? 0);
return {
id: `ec-${f.properties.cluster_id}`,
lat: coords[1], lon: coords[0],
count: f.properties.point_count!,
items, location: items[0]?.location ?? '', country: items[0]?.country ?? '',
soonestDaysUntil: Math.min(...items.map(i => i.daysUntil)),
count: clusterCount,
items,
location: String(props.location ?? items[0]?.location ?? ''),
country: String(props.country ?? items[0]?.country ?? ''),
soonestDaysUntil: Number.isFinite(soonestDaysUntil) ? soonestDaysUntil : Number.MAX_SAFE_INTEGER,
soonCount,
sampled: items.length < clusterCount,
};
}
const item = this.techEvents[f.properties.index]!;
@@ -552,26 +721,40 @@ export class DeckGLMap {
id: `ep-${f.properties.index}`, lat: item.lat, lon: item.lng,
count: 1, items: [item], location: item.location, country: item.country,
soonestDaysUntil: item.daysUntil,
soonCount: item.daysUntil <= 14 ? 1 : 0,
sampled: false,
};
});
} else {
this.techEventClusters = [];
}
if (this.datacenterSC) {
if (useDatacenterClusters && this.datacenterSC) {
const activeDCs = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned');
this.datacenterClusters = this.datacenterSC.getClusters(bbox, zoom).map(f => {
const coords = f.geometry.coordinates as [number, number];
if (f.properties.cluster) {
const leaves = this.datacenterSC!.getLeaves(f.properties.cluster_id!, Infinity);
const props = f.properties as Record<string, unknown>;
const leaves = this.datacenterSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES);
const items = leaves.map(l => activeDCs[l.properties.index]).filter((x): x is AIDataCenter => !!x);
const existingCount = items.filter(i => i.status === 'existing').length;
const clusterCount = Number(f.properties.point_count ?? items.length);
const existingCount = Number(props.existingCount ?? 0);
const plannedCount = Number(props.plannedCount ?? 0);
const totalChips = Number(props.totalChips ?? 0);
const totalPowerMW = Number(props.totalPowerMW ?? 0);
return {
id: `dc-${f.properties.cluster_id}`,
lat: coords[1], lon: coords[0],
count: f.properties.point_count!,
items, region: items[0]?.country ?? '', country: items[0]?.country ?? '',
totalChips: items.reduce((s, i) => s + i.chipCount, 0),
totalPowerMW: items.reduce((s, i) => s + (i.powerMW ?? 0), 0),
majorityExisting: existingCount >= items.length / 2,
count: clusterCount,
items,
region: String(props.country ?? items[0]?.country ?? ''),
country: String(props.country ?? items[0]?.country ?? ''),
totalChips,
totalPowerMW,
majorityExisting: existingCount >= Math.max(1, clusterCount / 2),
existingCount,
plannedCount,
sampled: items.length < clusterCount,
};
}
const item = activeDCs[f.properties.index]!;
@@ -580,8 +763,13 @@ export class DeckGLMap {
count: 1, items: [item], region: item.country, country: item.country,
totalChips: item.chipCount, totalPowerMW: item.powerMW ?? 0,
majorityExisting: item.status === 'existing',
existingCount: item.status === 'existing' ? 1 : 0,
plannedCount: item.status === 'planned' ? 1 : 0,
sampled: false,
};
});
} else {
this.datacenterClusters = [];
}
}
@@ -1913,7 +2101,12 @@ export class DeckGLMap {
// Empty map click → country detection
if (info.coordinate && this.onCountryClick) {
const [lon, lat] = info.coordinate as [number, number];
this.onCountryClick(lat, lon);
const country = this.resolveCountryFromCoordinate(lon, lat);
this.onCountryClick({
lat,
lon,
...(country ? { code: country.code, name: country.name } : {}),
});
}
return;
}
@@ -1943,7 +2136,21 @@ export class DeckGLMap {
if (cluster.count === 1 && cluster.items[0]) {
this.popup.show({ type: 'protest', data: cluster.items[0], x: info.x, y: info.y });
} else {
this.popup.show({ type: 'protestCluster', data: { items: cluster.items, country: cluster.country }, x: info.x, y: info.y });
this.popup.show({
type: 'protestCluster',
data: {
items: cluster.items,
country: cluster.country,
count: cluster.count,
riotCount: cluster.riotCount,
highSeverityCount: cluster.highSeverityCount,
verifiedCount: cluster.verifiedCount,
totalFatalities: cluster.totalFatalities,
sampled: cluster.sampled,
},
x: info.x,
y: info.y,
});
}
return;
}
@@ -1952,7 +2159,21 @@ export class DeckGLMap {
if (cluster.count === 1 && cluster.items[0]) {
this.popup.show({ type: 'techHQ', data: cluster.items[0], x: info.x, y: info.y });
} else {
this.popup.show({ type: 'techHQCluster', data: { items: cluster.items, city: cluster.city, country: cluster.country }, x: info.x, y: info.y });
this.popup.show({
type: 'techHQCluster',
data: {
items: cluster.items,
city: cluster.city,
country: cluster.country,
count: cluster.count,
faangCount: cluster.faangCount,
unicornCount: cluster.unicornCount,
publicCount: cluster.publicCount,
sampled: cluster.sampled,
},
x: info.x,
y: info.y,
});
}
return;
}
@@ -1961,7 +2182,19 @@ export class DeckGLMap {
if (cluster.count === 1 && cluster.items[0]) {
this.popup.show({ type: 'techEvent', data: cluster.items[0], x: info.x, y: info.y });
} else {
this.popup.show({ type: 'techEventCluster', data: { items: cluster.items, location: cluster.location, country: cluster.country }, x: info.x, y: info.y });
this.popup.show({
type: 'techEventCluster',
data: {
items: cluster.items,
location: cluster.location,
country: cluster.country,
count: cluster.count,
soonCount: cluster.soonCount,
sampled: cluster.sampled,
},
x: info.x,
y: info.y,
});
}
return;
}
@@ -1970,7 +2203,22 @@ export class DeckGLMap {
if (cluster.count === 1 && cluster.items[0]) {
this.popup.show({ type: 'datacenter', data: cluster.items[0], x: info.x, y: info.y });
} else {
this.popup.show({ type: 'datacenterCluster', data: { items: cluster.items, region: cluster.country, country: cluster.country }, x: info.x, y: info.y });
this.popup.show({
type: 'datacenterCluster',
data: {
items: cluster.items,
region: cluster.region || cluster.country,
country: cluster.country,
count: cluster.count,
totalChips: cluster.totalChips,
totalPowerMW: cluster.totalPowerMW,
existingCount: cluster.existingCount,
plannedCount: cluster.plannedCount,
sampled: cluster.sampled,
},
x: info.x,
y: info.y,
});
}
return;
}
@@ -2164,7 +2412,7 @@ export class DeckGLMap {
<button class="layer-help-btn" title="Layer Guide">?</button>
<button class="toggle-collapse">&#9660;</button>
</div>
<div class="toggle-list">
<div class="toggle-list" style="max-height: 32vh; overflow-y: auto; scrollbar-width: thin;">
${layerConfig.map(({ key, label, icon }) => `
<label class="layer-toggle" data-layer="${key}">
<input type="checkbox" ${this.state.layers[key as keyof MapLayers] ? 'checked' : ''}>
@@ -2548,9 +2796,10 @@ export class DeckGLMap {
data: this.climateAnomalies,
getPosition: (d) => [d.lon, d.lat],
getWeight: (d) => Math.abs(d.tempDelta) + Math.abs(d.precipDelta) * 0.1,
radiusPixels: 80,
intensity: 1.5,
threshold: 0.1,
radiusPixels: 40,
intensity: 0.6,
threshold: 0.15,
opacity: 0.45,
colorRange: [
[68, 136, 255],
[100, 200, 255],
@@ -3024,18 +3273,38 @@ export class DeckGLMap {
// --- Country click + highlight ---
public setOnCountryClick(cb: (lat: number, lon: number) => void): void {
public setOnCountryClick(cb: (country: CountryClickPayload) => void): void {
this.onCountryClick = cb;
}
private resolveCountryFromCoordinate(lon: number, lat: number): { code: string; name: string } | null {
const fromGeometry = getCountryAtCoordinates(lat, lon);
if (fromGeometry) return fromGeometry;
if (!this.maplibreMap || !this.countryGeoJsonLoaded) return null;
try {
const point = this.maplibreMap.project([lon, lat]);
const features = this.maplibreMap.queryRenderedFeatures(point, { layers: ['country-interactive'] });
const properties = (features?.[0]?.properties ?? {}) as Record<string, unknown>;
const code = typeof properties['ISO3166-1-Alpha-2'] === 'string'
? properties['ISO3166-1-Alpha-2'].trim().toUpperCase()
: '';
const name = typeof properties.name === 'string'
? properties.name.trim()
: '';
if (!code || !name) return null;
return { code, name };
} catch {
return null;
}
}
private loadCountryBoundaries(): void {
if (!this.maplibreMap || this.countryGeoJsonLoaded) return;
this.countryGeoJsonLoaded = true;
fetch('/data/countries.geojson')
.then((r) => r.json())
getCountriesGeoJson()
.then((geojson) => {
if (!this.maplibreMap) return;
if (!this.maplibreMap || !geojson) return;
this.maplibreMap.addSource('country-boundaries', {
type: 'geojson',
data: geojson,
+2 -2
View File
@@ -4,7 +4,7 @@
*/
import { isMobileDevice } from '@/utils';
import { MapComponent } from './Map';
import { DeckGLMap, type DeckMapView } from './DeckGLMap';
import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap';
import type {
MapLayers,
Hotspot,
@@ -500,7 +500,7 @@ export class MapContainer {
}
// Country click + highlight (deck.gl only)
public onCountryClicked(callback: (lat: number, lon: number) => void): void {
public onCountryClicked(callback: (country: CountryClickPayload) => void): void {
if (this.useDeckGL) {
this.deckGLMap?.setOnCountryClick(callback);
}
+60 -30
View File
@@ -29,23 +29,43 @@ interface TechHQClusterData {
items: TechHQ[];
city: string;
country: string;
count?: number;
faangCount?: number;
unicornCount?: number;
publicCount?: number;
sampled?: boolean;
}
interface TechEventClusterData {
items: TechEventPopupData[];
location: string;
country: string;
count?: number;
soonCount?: number;
sampled?: boolean;
}
interface ProtestClusterData {
items: SocialUnrestEvent[];
country: string;
count?: number;
riotCount?: number;
highSeverityCount?: number;
verifiedCount?: number;
totalFatalities?: number;
sampled?: boolean;
}
interface DatacenterClusterData {
items: AIDataCenter[];
region: string;
country: string;
count?: number;
totalChips?: number;
totalPowerMW?: number;
existingCount?: number;
plannedCount?: number;
sampled?: boolean;
}
interface PopupData {
@@ -242,9 +262,9 @@ export class MapPopup {
case 'techEvent':
return this.renderTechEventPopup(data.data as TechEventPopupData);
case 'techHQCluster':
return this.renderTechHQClusterPopup(data.data as { items: TechHQ[]; city: string; country: string });
return this.renderTechHQClusterPopup(data.data as TechHQClusterData);
case 'techEventCluster':
return this.renderTechEventClusterPopup(data.data as { items: TechEventPopupData[]; location: string; country: string });
return this.renderTechEventClusterPopup(data.data as TechEventClusterData);
default:
return '';
}
@@ -747,10 +767,11 @@ export class MapPopup {
}
private renderProtestClusterPopup(data: ProtestClusterData): string {
const riots = data.items.filter(e => e.eventType === 'riot');
const highSeverity = data.items.filter(e => e.severity === 'high');
const verified = data.items.filter(e => e.validated);
const totalFatalities = data.items.reduce((sum, e) => sum + (e.fatalities || 0), 0);
const totalCount = data.count ?? data.items.length;
const riots = data.riotCount ?? data.items.filter(e => e.eventType === 'riot').length;
const highSeverity = data.highSeverityCount ?? data.items.filter(e => e.severity === 'high').length;
const verified = data.verifiedCount ?? data.items.filter(e => e.validated).length;
const totalFatalities = data.totalFatalities ?? data.items.reduce((sum, e) => sum + (e.fatalities || 0), 0);
const sortedItems = [...data.items].sort((a, b) => {
const severityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
@@ -769,23 +790,26 @@ export class MapPopup {
return `<li class="cluster-item ${sevClass}">${icon} ${dateStr}${city ? `${city}` : ''}${title}</li>`;
}).join('');
const moreCount = data.items.length > 10 ? `<li class="cluster-more">+${data.items.length - 10} more events</li>` : '';
const headerClass = highSeverity.length > 0 ? 'high' : riots.length > 0 ? 'medium' : 'low';
const renderedCount = Math.min(10, data.items.length);
const remainingCount = Math.max(0, totalCount - renderedCount);
const moreCount = remainingCount > 0 ? `<li class="cluster-more">+${remainingCount} more events</li>` : '';
const headerClass = highSeverity > 0 ? 'high' : riots > 0 ? 'medium' : 'low';
return `
<div class="popup-header protest ${headerClass} cluster">
<span class="popup-title">📢 ${escapeHtml(data.country)}</span>
<span class="popup-badge">${data.items.length} EVENTS</span>
<span class="popup-badge">${totalCount} EVENTS</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body cluster-popup">
<div class="cluster-summary">
${riots.length ? `<span class="summary-item riot">🔥 ${riots.length} Riots</span>` : ''}
${highSeverity.length ? `<span class="summary-item high">⚠️ ${highSeverity.length} High Severity</span>` : ''}
${verified.length ? `<span class="summary-item verified">✓ ${verified.length} Verified</span>` : ''}
${riots ? `<span class="summary-item riot">🔥 ${riots} Riots</span>` : ''}
${highSeverity ? `<span class="summary-item high">⚠️ ${highSeverity} High Severity</span>` : ''}
${verified ? `<span class="summary-item verified">✓ ${verified} Verified</span>` : ''}
${totalFatalities > 0 ? `<span class="summary-item fatalities">💀 ${totalFatalities} Fatalities</span>` : ''}
</div>
<ul class="cluster-list">${listItems}${moreCount}</ul>
${data.sampled ? `<p class="popup-more">Showing a sampled list of ${data.items.length} events.</p>` : ''}
</div>
`;
}
@@ -1321,10 +1345,11 @@ export class MapPopup {
}
private renderDatacenterClusterPopup(data: DatacenterClusterData): string {
const totalChips = data.items.reduce((sum, dc) => sum + dc.chipCount, 0);
const totalPower = data.items.reduce((sum, dc) => sum + (dc.powerMW || 0), 0);
const existingCount = data.items.filter(dc => dc.status === 'existing').length;
const plannedCount = data.items.filter(dc => dc.status === 'planned').length;
const totalCount = data.count ?? data.items.length;
const totalChips = data.totalChips ?? data.items.reduce((sum, dc) => sum + dc.chipCount, 0);
const totalPower = data.totalPowerMW ?? data.items.reduce((sum, dc) => sum + (dc.powerMW || 0), 0);
const existingCount = data.existingCount ?? data.items.filter(dc => dc.status === 'existing').length;
const plannedCount = data.plannedCount ?? data.items.filter(dc => dc.status === 'planned').length;
const formatNumber = (n: number) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
@@ -1344,7 +1369,7 @@ export class MapPopup {
return `
<div class="popup-header datacenter cluster">
<span class="popup-title">🖥 ${data.items.length} Data Centers</span>
<span class="popup-title">🖥 ${totalCount} Data Centers</span>
<span class="popup-badge elevated">${escapeHtml(data.region)}</span>
<button class="popup-close">×</button>
</div>
@@ -1373,7 +1398,8 @@ export class MapPopup {
<div class="cluster-list">
${dcListHtml}
</div>
${data.items.length > 8 ? `<p class="popup-more">+ ${data.items.length - 8} more data centers</p>` : ''}
${totalCount > 8 ? `<p class="popup-more">+ ${Math.max(0, totalCount - 8)} more data centers</p>` : ''}
${data.sampled ? `<p class="popup-more">Showing a sampled list of ${data.items.length} sites.</p>` : ''}
<div class="popup-attribution">Data: Epoch AI GPU Clusters</div>
</div>
`;
@@ -1526,10 +1552,11 @@ export class MapPopup {
`;
}
private renderTechHQClusterPopup(data: { items: TechHQ[]; city: string; country: string }): string {
const unicorns = data.items.filter(h => h.type === 'unicorn');
const faangs = data.items.filter(h => h.type === 'faang');
const publics = data.items.filter(h => h.type === 'public');
private renderTechHQClusterPopup(data: TechHQClusterData): string {
const totalCount = data.count ?? data.items.length;
const unicornCount = data.unicornCount ?? data.items.filter(h => h.type === 'unicorn').length;
const faangCount = data.faangCount ?? data.items.filter(h => h.type === 'faang').length;
const publicCount = data.publicCount ?? data.items.filter(h => h.type === 'public').length;
const sortedItems = [...data.items].sort((a, b) => {
const typeOrder = { faang: 0, unicorn: 1, public: 2 };
@@ -1545,23 +1572,25 @@ export class MapPopup {
return `
<div class="popup-header tech-hq cluster">
<span class="popup-title">🏙 ${escapeHtml(data.city)}</span>
<span class="popup-badge">${data.items.length} COMPANIES</span>
<span class="popup-badge">${totalCount} COMPANIES</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body cluster-popup">
<div class="popup-subtitle">📍 ${escapeHtml(data.city)}, ${escapeHtml(data.country)}</div>
<div class="cluster-summary">
${faangs.length ? `<span class="summary-item faang">🏛️ ${faangs.length} Big Tech</span>` : ''}
${unicorns.length ? `<span class="summary-item unicorn">🦄 ${unicorns.length} Unicorns</span>` : ''}
${publics.length ? `<span class="summary-item public">🏢 ${publics.length} Public</span>` : ''}
${faangCount ? `<span class="summary-item faang">🏛️ ${faangCount} Big Tech</span>` : ''}
${unicornCount ? `<span class="summary-item unicorn">🦄 ${unicornCount} Unicorns</span>` : ''}
${publicCount ? `<span class="summary-item public">🏢 ${publicCount} Public</span>` : ''}
</div>
<ul class="cluster-list">${listItems}</ul>
${data.sampled ? `<p class="popup-more">Showing a sampled list of ${data.items.length} companies.</p>` : ''}
</div>
`;
}
private renderTechEventClusterPopup(data: { items: TechEventPopupData[]; location: string; country: string }): string {
const upcomingSoon = data.items.filter(e => e.daysUntil <= 14);
private renderTechEventClusterPopup(data: TechEventClusterData): string {
const totalCount = data.count ?? data.items.length;
const upcomingSoon = data.soonCount ?? data.items.filter(e => e.daysUntil <= 14).length;
const sortedItems = [...data.items].sort((a, b) => a.daysUntil - b.daysUntil);
const listItems = sortedItems.map(event => {
@@ -1574,13 +1603,14 @@ export class MapPopup {
return `
<div class="popup-header tech-event cluster">
<span class="popup-title">📅 ${escapeHtml(data.location)}</span>
<span class="popup-badge">${data.items.length} EVENTS</span>
<span class="popup-badge">${totalCount} EVENTS</span>
<button class="popup-close">×</button>
</div>
<div class="popup-body cluster-popup">
<div class="popup-subtitle">📍 ${escapeHtml(data.location)}, ${escapeHtml(data.country)}</div>
${upcomingSoon.length ? `<div class="cluster-summary"><span class="summary-item soon">⚡ ${upcomingSoon.length} upcoming within 2 weeks</span></div>` : ''}
${upcomingSoon ? `<div class="cluster-summary"><span class="summary-item soon">⚡ ${upcomingSoon} upcoming within 2 weeks</span></div>` : ''}
<ul class="cluster-list">${listItems}</ul>
${data.sampled ? `<p class="popup-more">Showing a sampled list of ${data.items.length} events.</p>` : ''}
</div>
`;
}
+2
View File
@@ -24,4 +24,6 @@ export const TIER1_COUNTRIES: Record<string, string> = {
YE: 'Yemen',
MM: 'Myanmar',
VE: 'Venezuela',
BR: 'Brazil',
AE: 'United Arab Emirates',
};
+1 -1
View File
@@ -1,6 +1,6 @@
// Configuration exports
// For variant-specific builds, set VITE_VARIANT environment variable
// VITE_VARIANT=tech → startups.worldmonitor.app (tech-focused)
// VITE_VARIANT=tech → tech.worldmonitor.app (tech-focused)
// VITE_VARIANT=full → worldmonitor.app (geopolitical)
export { SITE_VARIANT } from './variant';
+1 -1
View File
@@ -1,4 +1,4 @@
// Tech/AI variant - startups.worldmonitor.app
// Tech/AI variant - tech.worldmonitor.app
import type { PanelConfig, MapLayers } from '@/types';
import type { VariantConfig } from './base';
+220
View File
@@ -0,0 +1,220 @@
import type { FeatureCollection, Geometry, GeoJsonProperties, Position } from 'geojson';
interface IndexedCountryGeometry {
code: string;
name: string;
bbox: [number, number, number, number]; // [minLon, minLat, maxLon, maxLat]
polygons: [number, number][][][]; // polygon -> ring -> [lon, lat]
}
interface CountryHit {
code: string;
name: string;
}
const COUNTRY_GEOJSON_URL = '/data/countries.geojson';
let loadPromise: Promise<void> | null = null;
let loadedGeoJson: FeatureCollection<Geometry> | null = null;
const countryIndex = new Map<string, IndexedCountryGeometry>();
let countryList: IndexedCountryGeometry[] = [];
function normalizeCode(properties: GeoJsonProperties | null | undefined): string | null {
if (!properties) return null;
const rawCode = properties['ISO3166-1-Alpha-2'] ?? properties.ISO_A2 ?? properties.iso_a2;
if (typeof rawCode !== 'string') return null;
const code = rawCode.trim().toUpperCase();
return /^[A-Z]{2}$/.test(code) ? code : null;
}
function normalizeName(properties: GeoJsonProperties | null | undefined): string | null {
if (!properties) return null;
const rawName = properties.name ?? properties.NAME ?? properties.admin;
if (typeof rawName !== 'string') return null;
const name = rawName.trim();
return name.length > 0 ? name : null;
}
function toCoord(point: Position): [number, number] | null {
if (!Array.isArray(point) || point.length < 2) return null;
const lon = Number(point[0]);
const lat = Number(point[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
return [lon, lat];
}
function normalizePolygonRings(rings: Position[][]): [number, number][][] {
return rings
.map((ring) => ring.map(toCoord).filter((p): p is [number, number] => p !== null))
.filter((ring) => ring.length >= 3);
}
function normalizeGeometry(geometry: Geometry | null | undefined): [number, number][][][] {
if (!geometry) return [];
if (geometry.type === 'Polygon') {
const polygon = normalizePolygonRings(geometry.coordinates);
return polygon.length > 0 ? [polygon] : [];
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates
.map((polygonCoords) => normalizePolygonRings(polygonCoords))
.filter((polygon) => polygon.length > 0);
}
return [];
}
function computeBbox(polygons: [number, number][][][]): [number, number, number, number] | null {
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
let hasPoint = false;
polygons.forEach((polygon) => {
polygon.forEach((ring) => {
ring.forEach(([lon, lat]) => {
hasPoint = true;
if (lon < minLon) minLon = lon;
if (lat < minLat) minLat = lat;
if (lon > maxLon) maxLon = lon;
if (lat > maxLat) maxLat = lat;
});
});
});
return hasPoint ? [minLon, minLat, maxLon, maxLat] : null;
}
function pointOnSegment(
px: number,
py: number,
x1: number,
y1: number,
x2: number,
y2: number
): boolean {
const cross = (py - y1) * (x2 - x1) - (px - x1) * (y2 - y1);
if (Math.abs(cross) > 1e-9) return false;
const dot = (px - x1) * (px - x2) + (py - y1) * (py - y2);
return dot <= 0;
}
function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const current = ring[i];
const previous = ring[j];
if (!current || !previous) continue;
const [xi, yi] = current;
const [xj, yj] = previous;
if (pointOnSegment(lon, lat, xi, yi, xj, yj)) return true;
const intersects = ((yi > lat) !== (yj > lat))
&& (lon < ((xj - xi) * (lat - yi)) / ((yj - yi) || Number.EPSILON) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function pointInCountryGeometry(country: IndexedCountryGeometry, lon: number, lat: number): boolean {
const [minLon, minLat, maxLon, maxLat] = country.bbox;
if (lon < minLon || lon > maxLon || lat < minLat || lat > maxLat) return false;
for (const polygon of country.polygons) {
const outer = polygon[0];
if (!outer || !pointInRing(lon, lat, outer)) continue;
let inHole = false;
for (let i = 1; i < polygon.length; i++) {
const hole = polygon[i];
if (hole && pointInRing(lon, lat, hole)) {
inHole = true;
break;
}
}
if (!inHole) return true;
}
return false;
}
async function ensureLoaded(): Promise<void> {
if (loadedGeoJson || loadPromise) {
await loadPromise;
return;
}
loadPromise = (async () => {
if (typeof fetch !== 'function') return;
try {
const response = await fetch(COUNTRY_GEOJSON_URL);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json() as FeatureCollection<Geometry>;
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features)) {
return;
}
loadedGeoJson = data;
countryIndex.clear();
countryList = [];
for (const feature of data.features) {
const code = normalizeCode(feature.properties);
const name = normalizeName(feature.properties);
if (!code || !name) continue;
const polygons = normalizeGeometry(feature.geometry);
const bbox = computeBbox(polygons);
if (!bbox || polygons.length === 0) continue;
const indexed: IndexedCountryGeometry = { code, name, polygons, bbox };
countryIndex.set(code, indexed);
countryList.push(indexed);
}
} catch (err) {
console.warn('[country-geometry] Failed to load countries.geojson:', err);
}
})();
await loadPromise;
}
export async function preloadCountryGeometry(): Promise<void> {
await ensureLoaded();
}
export async function getCountriesGeoJson(): Promise<FeatureCollection<Geometry> | null> {
await ensureLoaded();
return loadedGeoJson;
}
export function hasCountryGeometry(code: string): boolean {
// Synchronous API: caller should preload via preloadCountryGeometry().
return countryIndex.has(code.toUpperCase());
}
export function getCountryAtCoordinates(lat: number, lon: number, candidateCodes?: string[]): CountryHit | null {
// Synchronous API: return null until geometry is preloaded.
if (!loadedGeoJson) return null;
const candidates = Array.isArray(candidateCodes) && candidateCodes.length > 0
? candidateCodes
.map((code) => countryIndex.get(code.toUpperCase()))
.filter((country): country is IndexedCountryGeometry => Boolean(country))
: countryList;
for (const country of candidates) {
if (pointInCountryGeometry(country, lon, lat)) {
return { code: country.code, name: country.name };
}
}
return null;
}
export function isCoordinateInCountry(lat: number, lon: number, code: string): boolean | null {
// Synchronous API: return null until geometry is preloaded.
if (!loadedGeoJson) return null;
const country = countryIndex.get(code.toUpperCase());
if (!country) return null;
return pointInCountryGeometry(country, lon, lat);
}
+24
View File
@@ -6,6 +6,7 @@ import type { ConflictEvent } from './conflicts';
import type { UcdpConflictStatus } from './ucdp';
import type { HapiConflictSummary } from './hapi';
import type { CountryDisplacement, ClimateAnomaly } from '@/types';
import { getCountryAtCoordinates } from './country-geometry';
export interface CountryScore {
code: string;
@@ -113,6 +114,8 @@ const COUNTRY_KEYWORDS: Record<string, string[]> = {
YE: ['yemen', 'sanaa', 'houthi'],
MM: ['myanmar', 'burma', 'rangoon'],
VE: ['venezuela', 'caracas', 'maduro'],
BR: ['brazil', 'brasilia', 'lula', 'bolsonaro'],
AE: ['uae', 'emirates', 'dubai', 'abu dhabi'],
};
// Geopolitical baseline risk scores (0-50)
@@ -138,6 +141,8 @@ const BASELINE_RISK: Record<string, number> = {
YE: 50, // Active civil war
MM: 45, // Military coup, civil conflict
VE: 40, // Economic collapse, authoritarian
BR: 15, // Large democracy, social tensions, Amazon deforestation
AE: 10, // Stable, regional hub, low internal unrest
};
// Event significance multipliers
@@ -164,6 +169,8 @@ const EVENT_MULTIPLIER: Record<string, number> = {
YE: 0.7, // War zone, events expected
MM: 1.8, // Military suppression
VE: 1.8, // Suppressed
BR: 0.6, // Large democracy, many events
AE: 1.5, // Events rare, significant when occur
};
const countryDataMap = new Map<string, CountryData>();
@@ -178,6 +185,17 @@ export function clearCountryData(): void {
hotspotActivityMap.clear();
}
export function getCountryData(code: string): CountryData | undefined {
return countryDataMap.get(code);
}
export function getPreviousScores(): Map<string, number> {
return previousScores;
}
export { COUNTRY_BOUNDS };
export type { CountryData };
function normalizeCountryName(name: string): string | null {
const lower = name.toLowerCase();
for (const [code, keywords] of Object.entries(COUNTRY_KEYWORDS)) {
@@ -296,8 +314,14 @@ const COUNTRY_BOUNDS: Record<string, [number, number, number, number]> = {
CN: [18, 54, 73, 135], // China
RU: [41, 82, 19, 180], // Russia (simplified)
};
const LOCATION_COUNTRY_CANDIDATES = Object.keys(TIER1_COUNTRIES);
function getCountryFromLocation(lat: number, lon: number): string | null {
const precise = getCountryAtCoordinates(lat, lon, LOCATION_COUNTRY_CANDIDATES);
if (precise && TIER1_COUNTRIES[precise.code]) {
return precise.code;
}
for (const [code, [minLat, maxLat, minLon, maxLon]] of Object.entries(COUNTRY_BOUNDS)) {
if (lat >= minLat && lat <= maxLat && lon >= minLon && lon <= maxLon) {
return code;
+16 -4
View File
@@ -266,6 +266,7 @@ const COUNTRY_TAG_MAP: Record<string, string[]> = {
'Italy': ['europe', 'politics'],
'Poland': ['europe', 'geopolitics'],
'Brazil': ['world', 'politics'],
'United Arab Emirates': ['middle-east', 'world'],
'Mexico': ['world', 'politics'],
'Argentina': ['world', 'politics'],
'Canada': ['world', 'politics'],
@@ -311,7 +312,8 @@ function getCountryVariants(country: string): string[] {
'turkey': ['turkish', 'ankara', 'erdogan'],
'india': ['indian', 'delhi', 'modi'],
'japan': ['japanese', 'tokyo'],
'brazil': ['brazilian', 'brasilia', 'lula'],
'brazil': ['brazilian', 'brasilia', 'lula', 'bolsonaro'],
'united arab emirates': ['uae', 'emirati', 'dubai', 'abu dhabi'],
'syria': ['syrian', 'damascus', 'assad'],
'yemen': ['yemeni', 'houthi', 'sanaa'],
'lebanon': ['lebanese', 'beirut', 'hezbollah'],
@@ -342,8 +344,8 @@ export async function fetchCountryMarkets(country: string): Promise<PredictionMa
seen.add(event.id);
const titleLower = event.title.toLowerCase();
const matches = variants.some(v => titleLower.includes(v));
if (!matches) {
const eventTitleMatches = variants.some(v => titleLower.includes(v));
if (!eventTitleMatches) {
const marketTitles = (event.markets ?? []).map(m => (m.question ?? '').toLowerCase());
if (!marketTitles.some(mt => variants.some(v => mt.includes(v)))) continue;
}
@@ -351,7 +353,17 @@ export async function fetchCountryMarkets(country: string): Promise<PredictionMa
if (isExcluded(event.title)) continue;
if (event.markets && event.markets.length > 0) {
const topMarket = event.markets.reduce((best, m) => {
// When the event title itself matches (e.g. "French election"), pick
// the highest-volume sub-market. When only a sub-market matched
// (e.g. "Macron" inside a "next leader out" event), restrict to
// the matching sub-markets so we don't surface irrelevant ones.
const candidates = eventTitleMatches
? event.markets
: event.markets.filter(m =>
variants.some(v => (m.question ?? '').toLowerCase().includes(v)));
if (candidates.length === 0) continue;
const topMarket = candidates.reduce((best, m) => {
const vol = m.volumeNum ?? (m.volume ? parseFloat(m.volume) : 0);
const bestVol = best.volumeNum ?? (best.volume ? parseFloat(best.volume) : 0);
return vol > bestVol ? m : best;
+8
View File
@@ -163,4 +163,12 @@ export function getAssetLabel(type: AssetType): string {
return ASSET_LABELS[type];
}
export function getNearbyInfrastructure(
lat: number, lon: number, types: AssetType[]
): RelatedAsset[] {
return findNearbyAssets({ lat, lon, label: 'country-centroid' }, types);
}
export { haversineDistanceKm };
export { MAX_DISTANCE_KM };
+2 -3
View File
@@ -81,8 +81,8 @@ export function getRemoteApiBaseUrl(): string {
return normalizeBaseUrl(configuredRemoteBase);
}
const variant = import.meta.env.VITE_VARIANT || 'world';
return DEFAULT_REMOTE_HOSTS[variant] ?? DEFAULT_REMOTE_HOSTS.world ?? 'https://worldmonitor.app';
const variant = import.meta.env.VITE_VARIANT || 'full';
return DEFAULT_REMOTE_HOSTS[variant] ?? DEFAULT_REMOTE_HOSTS.full ?? 'https://worldmonitor.app';
}
export function toRuntimeUrl(path: string): string {
@@ -102,7 +102,6 @@ const APP_HOSTS = new Set([
'worldmonitor.app',
'www.worldmonitor.app',
'tech.worldmonitor.app',
'startups.worldmonitor.app',
'localhost',
'127.0.0.1',
]);
+4 -1
View File
@@ -200,4 +200,7 @@ function initDiagnostics(): void {
startAutoRefresh();
}
void initSettingsWindow();
void initSettingsWindow().finally(() => {
void tryInvokeTauri<void>('plugin:window|show', { label: 'settings' });
void tryInvokeTauri<void>('plugin:window|set_focus', { label: 'settings' });
});
+462
View File
@@ -11630,3 +11630,465 @@ body.has-critical-banner .panels-grid {
padding: 6px 8px;
font-size: 11px;
}
/* ============================================
Country Brief Page (full-page overlay)
============================================ */
.country-brief-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
overflow-y: auto;
}
.country-brief-overlay.active {
opacity: 1;
pointer-events: auto;
}
.country-brief-page {
background: #12141a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
width: 960px;
max-width: 96vw;
margin: 32px auto;
min-height: calc(100vh - 64px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
transform: translateY(12px);
transition: transform 0.2s ease;
}
.country-brief-overlay.active .country-brief-page {
transform: translateY(0);
}
/* Header */
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
position: sticky;
top: 0;
background: #12141a;
border-radius: 16px 16px 0 0;
z-index: 1;
}
.cb-header-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.cb-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.cb-flag { font-size: 28px; }
.cb-country-name {
font-size: 18px;
font-weight: 700;
color: #fff;
}
.cb-badge {
font-size: 10px;
font-weight: 700;
padding: 3px 10px;
border-radius: 4px;
letter-spacing: 0.5px;
}
.cb-trend {
font-size: 12px;
text-transform: capitalize;
}
.cb-trend.trend-up { color: #ff6644; }
.cb-trend.trend-down { color: #44cc88; }
.cb-trend.trend-stable { color: rgba(255, 255, 255, 0.4); }
.cb-tier-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 170, 0, 0.12);
color: #ffaa00;
border: 1px solid rgba(255, 170, 0, 0.25);
}
.cb-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 28px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
line-height: 1;
transition: all 0.15s;
}
.cb-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.cb-share-btn,
.cb-print-btn,
.cb-export-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: all 0.15s;
display: flex;
align-items: center;
}
.cb-share-btn:hover,
.cb-print-btn:hover,
.cb-export-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
border-color: rgba(255, 255, 255, 0.2);
}
/* Body & Grid */
.cb-body { padding: 24px; }
.cb-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
/* Sections */
.cb-section {
margin-bottom: 24px;
}
.cb-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 12px;
}
/* Risk Score Ring */
.cb-risk-content {
display: flex;
align-items: flex-start;
gap: 20px;
}
.cb-score-ring {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cb-score-value {
position: absolute;
font-size: 28px;
font-weight: 800;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
}
.cb-score-label {
position: absolute;
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
top: 50%;
left: 50%;
transform: translate(-50%, 60%);
}
.cb-components { flex: 1; }
.cb-comp-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.cb-comp-icon { font-size: 14px; width: 20px; text-align: center; }
.cb-comp-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
width: 70px;
}
.cb-comp-bar {
flex: 1;
height: 5px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
}
.cb-comp-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.cb-comp-val {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
width: 24px;
text-align: right;
}
/* Not Tracked State */
.cb-not-tracked {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
}
.cb-not-tracked-icon { font-size: 20px; }
/* Signal Chips */
.cb-signals-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.signal-chip.displacement { border-color: rgba(100, 200, 255, 0.3); color: #64c8ff; }
.signal-chip.climate { border-color: rgba(255, 150, 50, 0.3); color: #ff9632; }
.signal-chip.conflict { border-color: rgba(255, 80, 80, 0.3); color: #ff5050; }
/* Brief Text */
.cb-brief-text {
font-size: 13px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.85);
}
.cb-brief-text p { margin-bottom: 10px; }
.cb-brief-text strong { color: #fff; }
.cb-citation {
color: #64b4ff;
text-decoration: none;
font-size: 10px;
font-weight: 700;
vertical-align: super;
cursor: pointer;
transition: color 0.15s;
}
.cb-citation:hover { color: #90ccff; text-decoration: underline; }
.cb-export-menu {
position: absolute;
right: 0;
top: 100%;
margin-top: 4px;
background: var(--surface, #141414);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px;
z-index: 10;
min-width: 140px;
}
.cb-export-menu.hidden { display: none; }
.cb-export-option {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
font-family: inherit;
font-size: 12px;
text-align: left;
border-radius: 6px;
cursor: pointer;
}
.cb-export-option:hover { background: rgba(255, 255, 255, 0.06); color: #fff; }
.cb-brief-footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
}
/* Top News */
.cb-news-content {
display: grid;
gap: 6px;
}
.cb-news-card {
display: flex;
gap: 8px;
align-items: flex-start;
text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 8px;
background: rgba(255, 255, 255, 0.02);
transition: background 0.15s ease, border-color 0.15s ease;
}
.cb-news-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.14);
}
.cb-news-threat {
width: 7px;
height: 7px;
border-radius: 50%;
margin-top: 6px;
flex-shrink: 0;
}
.cb-news-body {
min-width: 0;
}
.cb-news-title {
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.86);
margin-bottom: 3px;
}
.cb-news-meta {
font-size: 10px;
color: rgba(255, 255, 255, 0.38);
}
/* Markets */
.cb-market-item {
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.cb-market-item:last-child { border-bottom: none; }
.cb-market-title {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 6px;
}
.cb-market-link {
color: #64b4ff;
text-decoration: none;
font-size: 11px;
}
.cb-news-highlight { background: rgba(100, 180, 255, 0.12); border-color: rgba(100, 180, 255, 0.25); transition: background 0.3s, border-color 0.3s; }
/* Infrastructure */
.cb-infra-group {
margin-bottom: 12px;
}
.cb-infra-type {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 6px;
}
.cb-infra-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.cb-infra-dist {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.4);
}
/* Timeline Placeholder */
.cb-timeline-mount {
min-height: 120px;
background: rgba(255, 255, 255, 0.02);
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.2);
font-size: 11px;
}
.cb-timeline-mount:empty::after {
content: 'Timeline loading...';
}
/* Loading / Empty States */
.cb-loading-state { padding: 40px 24px; }
.cb-empty {
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
font-style: italic;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.country-brief-page {
margin: 0;
max-width: 100vw;
min-height: 100vh;
border-radius: 0;
border: none;
}
.cb-header {
border-radius: 0;
padding: 12px 16px;
}
.cb-body { padding: 16px; }
.cb-grid {
grid-template-columns: 1fr;
}
.cb-timeline-section { display: none; }
.cb-risk-content { flex-direction: column; align-items: center; }
.cb-signals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.cb-country-name { font-size: 15px; }
.cb-flag { font-size: 22px; }
}
/* Print styles */
@media print {
.country-brief-overlay {
position: static;
background: none;
backdrop-filter: none;
overflow: visible;
}
.country-brief-page {
width: 100%;
max-width: 100%;
margin: 0;
border: none;
box-shadow: none;
border-radius: 0;
min-height: auto;
}
.cb-header {
position: static;
background: #fff;
border-radius: 0;
}
.cb-close,
.cb-share-btn,
.cb-print-btn,
.cb-export-btn,
.cb-export-menu { display: none; }
.cb-grid { grid-template-columns: 1fr; }
.cb-timeline-section { display: none; }
body > *:not(.country-brief-overlay) { display: none !important; }
}
+13
View File
@@ -1147,6 +1147,10 @@ export interface MapProtestCluster {
maxSeverity: 'low' | 'medium' | 'high';
hasRiot: boolean;
totalFatalities: number;
riotCount?: number;
highSeverityCount?: number;
verifiedCount?: number;
sampled?: boolean;
}
export interface MapTechHQCluster {
@@ -1158,6 +1162,10 @@ export interface MapTechHQCluster {
city: string;
country: string;
primaryType: 'faang' | 'unicorn' | 'public';
faangCount?: number;
unicornCount?: number;
publicCount?: number;
sampled?: boolean;
}
export interface MapTechEventCluster {
@@ -1169,6 +1177,8 @@ export interface MapTechEventCluster {
location: string;
country: string;
soonestDaysUntil: number;
soonCount?: number;
sampled?: boolean;
}
export interface MapDatacenterCluster {
@@ -1182,4 +1192,7 @@ export interface MapDatacenterCluster {
totalChips: number;
totalPowerMW: number;
majorityExisting: boolean;
existingCount?: number;
plannedCount?: number;
sampled?: boolean;
}
+57
View File
@@ -66,6 +66,63 @@ export function exportToCSV(data: ExportData, filename = 'worldmonitor-export'):
downloadFile(lines.join('\n'), `${filename}.csv`, 'text/csv');
}
export interface CountryBriefExport {
country: string;
code: string;
score?: number;
level?: string;
trend?: string;
components?: { unrest: number; conflict: number; security: number; information: number };
signals?: Record<string, number>;
brief?: string;
headlines?: Array<{ title: string; source: string; link: string; pubDate?: string }>;
generatedAt: string;
}
export function exportCountryBriefJSON(data: CountryBriefExport): void {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(JSON.stringify(data, null, 2), `country-brief-${data.code}-${timestamp}.json`, 'application/json');
}
export function exportCountryBriefCSV(data: CountryBriefExport): void {
const lines: string[] = [];
lines.push(`Country Brief: ${data.country} (${data.code})`);
lines.push(`Generated: ${data.generatedAt}`);
lines.push('');
if (data.score != null) {
lines.push(`Score,${data.score}`);
lines.push(`Level,${data.level || ''}`);
lines.push(`Trend,${data.trend || ''}`);
}
if (data.components) {
lines.push('');
lines.push('Component,Value');
lines.push(`Unrest,${data.components.unrest}`);
lines.push(`Conflict,${data.components.conflict}`);
lines.push(`Security,${data.components.security}`);
lines.push(`Information,${data.components.information}`);
}
if (data.signals) {
lines.push('');
lines.push('Signal,Count');
for (const [k, v] of Object.entries(data.signals)) {
lines.push(csvRow([k, String(v)]));
}
}
if (data.headlines && data.headlines.length > 0) {
lines.push('');
lines.push('Title,Source,Link,Published');
data.headlines.forEach(h => lines.push(csvRow([h.title, h.source, h.link, h.pubDate || ''])));
}
if (data.brief) {
lines.push('');
lines.push('Intelligence Brief');
lines.push(`"${data.brief.replace(/"/g, '""')}"`);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(lines.join('\n'), `country-brief-${data.code}-${timestamp}.csv`, 'text/csv');
}
function csvRow(values: string[]): string {
return values.map(v => `"${(v || '').replace(/"/g, '""')}"`).join(',');
}
+10
View File
@@ -43,6 +43,7 @@ export interface ParsedMapUrlState {
lon?: number;
timeRange?: TimeRange;
layers?: MapLayers;
country?: string;
}
const clamp = (value: number, min: number, max: number): number =>
@@ -73,6 +74,9 @@ export function parseMapUrlState(
? (timeRangeParam as TimeRange)
: undefined;
const countryParam = params.get('country');
const country = countryParam && /^[A-Z]{2}$/i.test(countryParam.trim()) ? countryParam.trim().toUpperCase() : undefined;
const layersParam = params.get('layers');
let layers: MapLayers | undefined;
if (layersParam !== null) {
@@ -102,6 +106,7 @@ export function parseMapUrlState(
lon,
timeRange,
layers,
country,
};
}
@@ -113,6 +118,7 @@ export function buildMapUrl(
center?: { lat: number; lon: number } | null;
timeRange: TimeRange;
layers: MapLayers;
country?: string;
}
): string {
const url = new URL(baseUrl);
@@ -130,6 +136,10 @@ export function buildMapUrl(
const activeLayers = LAYER_KEYS.filter((layer) => state.layers[layer]);
params.set('layers', activeLayers.length > 0 ? activeLayers.join(',') : 'none');
if (state.country) {
params.set('country', state.country);
}
url.search = params.toString();
return url.toString();
}
+12 -3
View File
@@ -11,7 +11,7 @@ const VARIANT_META: Record<string, {
siteName: string;
features: string[];
}> = {
world: {
full: {
title: 'World Monitor - Real-Time Global Intelligence Dashboard',
description: 'Real-time global intelligence dashboard with live news, markets, military tracking, infrastructure monitoring, and geopolitical data. OSINT in one view.',
keywords: 'global intelligence, geopolitical dashboard, world news, market data, military bases, nuclear facilities, undersea cables, conflict zones, real-time monitoring, situation awareness, OSINT, flight tracking, AIS ships, earthquake monitor, protest tracker, power outages, oil prices, government spending, polymarket predictions',
@@ -55,8 +55,8 @@ const VARIANT_META: Record<string, {
};
function htmlVariantPlugin(): Plugin {
const variant = process.env.VITE_VARIANT || 'world';
const meta = VARIANT_META[variant] || VARIANT_META.world;
const variant = process.env.VITE_VARIANT || 'full';
const meta = VARIANT_META[variant] || VARIANT_META.full;
return {
name: 'html-variant',
@@ -179,6 +179,15 @@ export default defineConfig({
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/[abc]\.basemaps\.cartocdn\.com\//,
handler: 'CacheFirst',
options: {
cacheName: 'carto-tiles',
expiration: { maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
handler: 'StaleWhileRevalidate',