Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add310349b | ||
|
|
6d100a6db6 | ||
|
|
5c91c0ee71 | ||
|
|
30dfbd0a85 | ||
|
|
4e8758cb54 | ||
|
|
9bda79e10b | ||
|
|
e6fe946b34 | ||
|
|
e663741f4a | ||
|
|
458be05a59 | ||
|
|
b0e48f47f8 | ||
|
|
d2568576a3 | ||
|
|
77fc5fe4bd | ||
|
|
3512937658 | ||
|
|
7494e0780d | ||
|
|
a17b1b02e6 | ||
|
|
fd631ca86c | ||
|
|
6dfdffc364 | ||
|
|
441b6f1a90 | ||
|
|
6a293b1f18 | ||
|
|
68b3b22ad9 | ||
|
|
1912e248c6 | ||
|
|
e1925e735c | ||
|
|
5b1f980b70 | ||
|
|
a439992094 | ||
|
|
fe11a0ed0e | ||
|
|
4a7f9bfdf4 | ||
|
|
32973b5770 | ||
|
|
81559ba2d6 | ||
|
|
7254dbb087 | ||
|
|
a661497bcc |
+1
-1
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ dist/
|
||||
.vercel
|
||||
.claude/
|
||||
.cursor/
|
||||
CLAUDE.md
|
||||
.env.vercel-backup
|
||||
.agent/
|
||||
.factory/
|
||||
|
||||
@@ -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
|
||||
@@ -7,11 +7,22 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://github.com/koala73/worldmonitor/commits/main)
|
||||
[](https://github.com/koala73/worldmonitor/releases/latest)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://worldmonitor.app"><strong>Live Demo</strong></a> ·
|
||||
<a href="https://tech.worldmonitor.app"><strong>Tech Variant</strong></a> ·
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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> ·
|
||||
<a href="https://github.com/koala73/worldmonitor/releases/latest"><strong>All Releases</strong></a>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "world-monitor",
|
||||
"private": true,
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
} = {};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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">▼</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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user