Compare commits

..
862 Commits
Author SHA1 Message Date
Elie Habib 939ac3a864 fix: sync package-lock.json with markdownlint-cli2 devDependency
The lock file was out of sync causing npm ci to fail in CI for v2.3.3
and v2.3.4 builds.
2026-02-16 00:50:43 +04:00
Elie Habib 7d3b600364 fix: strip UNC path prefix for Windows sidecar, set explicit CWD & bump v2.3.4
Tauri resource_dir() on Windows returns \\?\ extended-length paths that
Node.js module resolution cannot handle, causing EISDIR: lstat 'C:'.
Strip the prefix before passing to Node.js, set current_dir to the
sidecar directory, and add package.json with "type": "module" to prevent
ESM scope walk-up to drive root.
2026-02-16 00:47:02 +04:00
Elie Habib f3581a5f9b fix: enable macOS Keychain backend for keyring crate & bump v2.3.3
keyring v3 ships with NO default platform backends — API keys were
stored in-memory only, lost on every app restart. Add apple-native
and windows-native features to use real OS credential stores.
2026-02-16 00:31:46 +04:00
Elie Habib d3fb116e16 fix: harden settings key persistence with soft-pass verification & resilient keychain reads 2026-02-16 00:31:46 +04:00
Elie HabibandGitHub 0a9226cc82 chore: lint markdown (#72)
MD012 / no-multiple-blanks Multiple consecutive blank lines
MD022 / blanks-around-headings Headings should be surrounded by blank
lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
2026-02-16 00:11:51 +04:00
Elie HabibandGitHub 751d8589d3 feat: add 6 verified think tank RSS feeds (#71)
FPRI trailing slash fix applied. RUSI was already in SOURCE_TIERS/SOURCE_TYPES from base branch.
2026-02-16 00:09:39 +04:00
Elie Habib 067eabaaff fix: add trailing slash to FPRI feed URL to avoid Cloudflare 403 2026-02-16 00:09:20 +04:00
Elie Habib cf62e169e9 chore: add PR #71 think tank domains to RSS proxy allowlist
warontherocks.com, www.aei.org, responsiblestatecraft.org,
www.fpri.org, jamestown.org
2026-02-15 23:42:06 +04:00
sethandGitHub eaff3e817d chore: lint markdown
MD012 / no-multiple-blanks Multiple consecutive blank lines
MD022 / blanks-around-headings Headings should be surrounded by blank lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
2026-02-15 11:38:22 -08:00
Elie Habib f3fddcb0e8 fix: settings UX — save verified keys, preserve inputs across renders, bump v2.3.2
- Save keys that pass verification even when others fail (was all-or-nothing)
- Capture un-blurred input values before render to prevent loss on checkbox toggle
- Fix missing isDisallowedOrigin import in PIZZINT endpoints
2026-02-15 23:33:19 +04:00
Elie Habib 0cd88a10a3 Integrate ML NER enrichment into trending keywords 2026-02-15 23:24:52 +04:00
InlitX 8fe7d57b92 feat: add 6 verified think tank RSS feeds 2026-02-15 20:18:26 +01:00
Elie Habib ab6dfccdeb fix: add missing tauri script to restore CI builds
@tauri-apps/cli in devDependencies causes tauri-action to run
`npm run tauri` instead of auto-installing globally.
2026-02-15 23:03:24 +04:00
Elie Habib a9b3582ae3 fix: harden sidecar verification, dedupe spikes, and bump v2.3.1 2026-02-15 22:57:09 +04:00
Elie Habib fb51b5bf40 fix: desktop settings UX overhaul & IPv4-safe fetch for sidecar
- Show "Staged" status/pill for buffered secrets instead of "Missing"
- Add macOS Edit menu (Cmd+C/V/X/Z) for WKWebView clipboard support
- Raise settings window when main gains focus (prevent hide-behind)
- Fix Cloudflare verification to probe Radar API (not token/verify)
- Fix EIA verification URL to valid v2 endpoint
- Force IPv4 globally: monkey-patch fetch() to avoid IPv6 ETIMEDOUT
  on government APIs (EIA, NASA FIRMS) with broken AAAA records
- Soft-pass on network errors during secret verification (don't block save)
- Add desktopRequiredSecrets to skip relay URLs on desktop
- Cross-window sync for secrets and feature toggles via localStorage events
- Add @tauri-apps/cli devDependency
2026-02-15 22:35:21 +04:00
Elie Habib b6881d96c0 fix: NER-gate trending spike alerts to suppress common-word noise
Common English verbs like "say" pass frequency thresholds and generate
false intelligence findings. Now runs browser NER (BERT) on spike
headlines before alerting — terms not recognized as named entities
(PER/ORG/LOC/MISC) are suppressed. Falls back gracefully when NER
is unavailable. Also adds ~55 base verb forms to the suppressed list
as hardened fallback for low-spec devices.
2026-02-15 22:28:24 +04:00
Elie Habib ea76446813 fix: make sidecar secret sync best-effort 2026-02-15 21:44:45 +04:00
Elie Habib f64af4c571 fix: harden CORS patterns & URL validation
- Allow hyphens in Vercel preview URL patterns (worldmonitor-xxx-yyy)
- Harden open_url command with proper URL parsing via reqwest::Url
- Update YouTube embed test assertions for quote style change
2026-02-15 21:34:00 +04:00
Elie Habib 0738e38baa settings: verify API keys via provider probes 2026-02-15 21:31:54 +04:00
Elie Habib 723279eedc chore: bump v2.3.0 — security hardening release with changelog
Major security hardening: CORS enforcement on all API endpoints,
sidecar auth bypass fix, postMessage origin validation, CSP
tightening, and service worker stale cache fix.
2026-02-15 20:38:54 +04:00
Elie Habib 7e7ab126c8 fix: force immediate SW activation to prevent stale asset errors
Add skipWaiting, clientsClaim, and cleanupOutdatedCaches to workbox
config. Fixes NS_ERROR_CORRUPTED_CONTENT / MIME type errors when
users have a cached service worker serving old HTML that references
asset hashes no longer on the CDN after redeployment.
2026-02-15 20:37:23 +04:00
Elie Habib a9224254a5 fix: security hardening — CORS, auth bypass, origin validation & bump v2.2.7
- Tighten CORS regex to block worldmonitorEVIL.vercel.app spoofing
- Move sidecar /api/local-env-update behind token auth + add key allowlist
- Add postMessage origin/source validation in LiveNewsPanel
- Replace postMessage wildcard '*' targetOrigin with specific origin
- Add isDisallowedOrigin() check to 25 API endpoints missing it
- Migrate gdelt-geo & EIA from custom CORS to shared _cors.js
- Add CORS to firms-fires, stock-index, youtube/live endpoints
- Tighten youtube/embed.js ALLOWED_ORIGINS regex
- Remove 'unsafe-inline' from CSP script-src
- Add iframe sandbox attribute to YouTube embed
- Validate meta-tags URL query params with regex allowlist
2026-02-15 20:33:20 +04:00
Elie Habib a31f81a0fe fix: filter trending noise, fix sidecar auth & restore tech panels — v2.2.6
- Expand SUPPRESSED_TRENDING_TERMS from 13 to ~170 entries to filter
  common English words (department, state, news, etc.) from intelligence
  findings
- Move sidecar admin endpoints (debug-toggle, traffic-log, env-update,
  local-status) before LOCAL_API_TOKEN auth gate — settings window sends
  bare fetch without token, causing silent 401 failures
- Restore Market Radar and Economic Indicators panels to tech variant
- Remove stale Documentation section from README
- Clean up .env.example cyber threat keys (handled internally)
- Bump v2.2.6
2026-02-15 20:00:17 +04:00
Elie Habib cd84eb1bb2 fix: remove Market Radar and Economic Data panels from tech variant 2026-02-15 19:33:07 +04:00
Elie Habib f16c34b6a4 docs: add developer X/Twitter link to Support section 2026-02-15 19:29:02 +04:00
Elie Habib d93eeaf551 docs: add cyber threat API keys to .env.example 2026-02-15 19:26:55 +04:00
Elie Habib 071836d390 chore: move test harnesses from root to tests/ 2026-02-15 19:22:40 +04:00
Elie Habib ac935d505e fix: migrate all Vercel edge functions to CORS allowlist & bump v2.2.5
Replace Access-Control-Allow-Origin: * with shared getCorsHeaders()
across 20 API edge functions to restrict access to worldmonitor.app,
tech.worldmonitor.app, and authorized Vercel preview URLs.

Version bump to 2.2.5 across package.json, tauri.conf.json, Cargo.toml.
2026-02-15 19:13:54 +04:00
Elie Habib 9f378d533b fix: restrict Railway relay CORS to allowed origins only
Replace Access-Control-Allow-Origin: * with an allowlist of our domains
(worldmonitor.app, tech.worldmonitor.app, localhost dev ports, Tauri,
and *.vercel.app previews). Prevents third parties from using the relay
as a free proxy.
2026-02-15 19:01:06 +04:00
Elie Habib 9c2104d936 fix: hide desktop config panel on web, route World Bank & Polymarket via Railway
Desktop Configuration panel was leaking to web due to being in
FULL_PANELS/TECH_PANELS configs. Removed from static configs (injected
programmatically for Tauri) and filtered from toggle UI on web.

World Bank API returns 403 to Vercel edge IPs. Added /worldbank proxy
route to Railway relay with full response transformation and 30-min cache.
Client tries Railway first, falls back to Vercel.

Polymarket gamma-api suffers Cloudflare JA3 blocking from Vercel.
Added /polymarket proxy route to Railway relay with 2-min cache.
Client fallback chain: browser-direct → Tauri → Railway → Vercel → production.
2026-02-15 18:59:01 +04:00
Elie HabibandGitHub 67d3673926 feat: cyber threat intelligence layer, trending keyword spikes, panel redesigns & docs (#68)
## Summary
- **New map layer** plotting live botnet C2 servers, malware hosts, and
malicious IPs from **5 threat intel sources**
- **Vercel edge API** (`/api/cyber-threats`) with triple-layer caching
(Redis → memory → stale fallback), rate limiting, and graceful
degradation
- **IP geolocation** via ipinfo.io + freeipapi.com fallback —
HTTPS-compatible with Edge runtime, geo results cached in Redis (24h
TTL)
- **Feature-gated** behind `VITE_ENABLE_CYBER_LAYER=true` — entire layer
hidden when disabled
- **Severity color coding**: critical (red) → high (orange) → medium
(amber) → low (yellow)
- **Tauri desktop**: 3 new keychain secrets (URLHAUS_AUTH_KEY,
OTX_API_KEY, ABUSEIPDB_API_KEY) with runtime config UI
- **Geo enrichment timeout**: 12s overall cap prevents slow GeoIP
providers from blocking the request
- **Download banner**: idempotency guard prevents duplicate panels on
repeated calls

## Data Sources
| Source | Auth | What |
|--------|------|------|
| **Feodo Tracker** | None (free) | Botnet C2 server IPs with country |
| **C2IntelFeeds** | None (free) | ~500 C2 IPs from GitHub CSV
(CobaltStrike, Metasploit, Sliver, etc.) |
| **URLhaus** | `URLHAUS_AUTH_KEY` | Malicious URL distribution hosts |
| **AlienVault OTX** | `OTX_API_KEY` | Community threat indicators (APT,
ransomware, botnets) |
| **AbuseIPDB** | `ABUSEIPDB_API_KEY` | Reported malicious IPs with
abuse confidence scores |

Sources with missing API keys gracefully skip (no error, `partial:
false`). Sources that are configured but fail set `partial: true`.

## Test plan
- [x] 7 unit tests pass (`node --test api/cyber-threats.test.mjs`)
- [x] All 5 sources aggregated and deduplicated in API response
- [x] Graceful degradation when API keys missing (free sources only)
- [x] `partial: true` when configured sources fail
- [x] Memory cache hit on repeated requests
- [x] Stale fallback when upstream fails after cache TTL
- [x] Geo enrichment capped at 12s overall timeout
- [x] Set `VITE_ENABLE_CYBER_LAYER=true` → toggle appears in layer
controls
- [x] Enable layer → purple/red dots render on map at threat locations
- [x] Hover → tooltip shows indicator, severity, malware family, source
- [x] Click → popup with full details (type, tags, coordinates,
timestamps)
- [x] TypeScript build passes (`tsc --noEmit`)
- [ ] Without env var → layer toggle hidden, no fetches made
- [ ] `curl /api/cyber-threats` → returns JSON with `success: true`,
`data[]` has lat/lon
- [ ] Second request within 10min → `X-Cache: MEMORY-HIT`
2026-02-15 18:41:37 +04:00
Elie Habib 141bf0f8b3 feat: redesign population exposure panel and reorder UCDP columns
PopulationExposure: replace truncated 4-column table with two-line
card layout (full event name + population/radius meta row).
UCDP: move deaths to column #2 (Country → Deaths → Date → Actors).
2026-02-15 18:39:35 +04:00
Elie Habib f91fb7c8ce docs: document cyber threats, trending keywords, oil analytics, population exposure, and entity index
Update README with 5 new How It Works sections and 10 inline updates
reflecting implemented features. Data layer count 29→30+, data sources
14→22, new Tech Stack and Roadmap entries.
2026-02-15 18:29:25 +04:00
Elie Habib 3a1088ee0e fix: harden trending spike processing and optimize hot paths 2026-02-15 18:21:27 +04:00
Elie HabibandGitHub 6e14e87ec1 fix: resolve z-index conflict between pinned map and panels grid (#67)
**Description:** I fixed a visual bug where the cards were overlapping
the map when using the "Pin" option. Now the cards slide correctly
underneath the map when scrolling.

**Before:** 
<img width="1914" height="947" alt="error"
src="https://github.com/user-attachments/assets/add5b838-b15c-46d9-adea-aff063563af4"
/>

**and After:**
<img width="1919" height="929" alt="Correction"
src="https://github.com/user-attachments/assets/421fb4af-1851-4f8e-a422-3d095ddb9c48"
/>
2026-02-15 18:10:40 +04:00
Elie Habib 0627df95ba feat: add trending keyword spike detection and e2e flow test 2026-02-15 18:03:18 +04:00
Elie Habib d33c19fb95 feat: redesign 4 panels with table layouts and scoped styles
Bring Climate Anomalies, Population Exposure, UCDP Conflict Events,
and UNHCR Displacement panels up to dashboard design standards
matching Fires/Markets/Stablecoins panels.

- Replace div-based layouts with proper <table> structures
- Add scoped <style> blocks (no inline style= attributes)
- CSS class-based severity badges, death colors, displacement badges
- tabular-nums on all numeric columns, right-aligned
- Row hover effects, text-overflow ellipsis for long text
- Stat grid for UNHCR global totals, total deaths summary for UCDP
- Custom tab styling for UCDP and Displacement panels
2026-02-15 17:55:33 +04:00
Elie Habib 0c632b1b57 fix: add Cyber Threats to System Health status panel allowlists
Both WORLD_FEEDS and TECH_FEEDS were missing 'Cyber Threats', causing
updateFeed() calls to be silently rejected by the allowlist check.
2026-02-15 17:49:46 +04:00
Elie Habib af6f321493 feat: dramatically increase cyber threat map density
Increase geo cap 100→250, concurrency 8→16, per-IP timeout 8→3s.
Add country centroid fallback with jitter for IPs with country codes.
2026-02-15 17:40:01 +04:00
Elie Habib 5abb30d420 fix: improve cyber threat tooltip/popup UX and dot visibility
- Tooltip: show "Cyber Threat" + severity + country instead of raw source names
- Popup header: "Cyber Threat" title instead of raw IP address
- Popup: show all 5 source labels and add malware family field
- Increase dot size for better visibility at global zoom level
2026-02-15 17:34:06 +04:00
Elie Habib 4d816c22f8 fix: cap geo enrichment at 12s overall timeout & prevent duplicate download banners
Addresses PR #68 review comments:
- P1: hydrateThreatCoordinates now races workers against a 12s overall
  timeout so slow GeoIP providers can't block the entire request
- P2: DownloadBanner adds module-level guard preventing duplicate panels
2026-02-15 17:25:38 +04:00
Elie Habib f2b650fc81 fix: replace ipwho.is/ipapi.co with ipinfo.io/freeipapi.com for geo enrichment
ipwho.is returns 403 from Node.js/Edge ("CORS not supported on Free plan")
and ipapi.co rate-limits aggressively (429). Both providers only work from
browser-side requests.

Switch to ipinfo.io (HTTPS, 50K/mo free, works from Edge runtime) as primary
with freeipapi.com as fallback.

Validated end-to-end: 20 C2 IPs geo-enriched in <1s with real coordinates.
2026-02-15 17:17:03 +04:00
Elie Habib 6e0dbbd15b feat: add C2IntelFeeds, OTX, and AbuseIPDB as cyber threat sources
Expands from 2 to 5 threat intel sources:
- C2IntelFeeds (free, no auth): ~500 active C2 server IPs (CobaltStrike, Metasploit)
- AlienVault OTX (optional API key): community-sourced IOCs with tags
- AbuseIPDB (optional API key): high-confidence abuse reports with geo
- Feodo: now includes recently-offline entries (still threat-relevant)

All sources fetched in parallel. Graceful degradation when API keys missing.
2026-02-15 17:06:14 +04:00
Elie Habib 62e81642b0 chore: bump version to 2.2.3 2026-02-15 16:52:48 +04:00
Elie Habib 5facae7105 feat: add cyber threat map layer with Feodo Tracker + URLhaus integration
Plot live botnet C2 servers, malware distribution nodes, and malicious IPs
on the globe using free abuse.ch APIs (Feodo Tracker + URLhaus).

- Vercel edge API with triple-layer caching (Redis → memory → stale fallback)
- IP geolocation via ipwho.is + ipapi.co (HTTPS-compatible with Edge runtime)
- Severity-based color coding (critical=red, high=orange, medium=amber, low=yellow)
- Feature-gated behind VITE_ENABLE_CYBER_LAYER=true env var
- Frontend circuit breaker, data sanitization, 10min auto-refresh
- Tauri desktop support: 3 new secret keys (URLHAUS, OTX, AbuseIPDB)
- Full test suite (6 unit tests), e2e harness updates, popup + tooltip rendering
2026-02-15 16:52:24 +04:00
Elie Habib ef389319a9 feat: add download desktop app slide-in banner for web visitors
Side panel slides in 12s after initial load with macOS/Windows download
links. Dismissed per session. Skipped on Tauri desktop runtime.
2026-02-15 16:25:06 +04:00
InlitX 114b5dfac6 fix: resolve z-index conflict between pinned map and panels grid 2026-02-15 13:03:16 +01:00
Elie Habib 3d16f3c4e1 perf: reduce idle CPU from pulse animation loop
- Add 60s startup cooldown to suppress pulse during initial data hydration
- Consolidate pulse lifecycle into syncPulseAnimation() across all setters
- Replace static predicates (h.level=high, c.maxSeverity=high) with dynamic-only
  checks: recent news, recent ACLED riots (<2h), breaking hotspots
- Exclude GDELT riots from pulse gating (noisy, not actionable)
- Reduce pulse interval from 250ms to 500ms (halves layer rebuild rate)
- Pause pulse when render is paused (country brief overlay)
- Add latestRiotEventTimeMs to MapProtestCluster with raw protest fallback
- Add Playwright tests for startup cooldown and lifecycle start/stop/GDELT exclusion
2026-02-15 15:17:07 +04:00
Elie Habib 96c6208bb0 fix: fall back to cloud API when local sidecar returns non-OK status
The runtime fetch patch previously returned local 4xx/5xx responses
as-is. Now falls back to worldmonitor.app cloud API on any non-OK
local response, matching the existing network-error fallback behavior.
2026-02-15 14:49:49 +04:00
Elie Habib 803c015002 Add country briefs to Cmd+K search 2026-02-15 14:44:03 +04:00
Elie Habib add310349b chore: bump version to 2.2.2 2026-02-15 14:10:35 +04:00
Elie Habib 6d100a6db6 Merge country-brief-page: full-page brief, geometry consolidation, news dedup
- Full-page Country Brief replacing modal overlay
- Tightened headline relevance with negative country filter
- Top News section replacing redundant Evidence/Sources
- Shared country-geometry service for local-first detection
- BR/AE added to tier-1, deep-link name resolution
- Prediction Markets moved to right column for balanced layout
2026-02-15 14:10:08 +04:00
Elie Habib 5c91c0ee71 fix: normalize country name from GeoJSON to canonical TIER1 name
GeoJSON uses "United States of America" but COUNTRY_TAG_MAP and
variant matching expect "United States". Normalize at entry point
so polymarket, search terms, and all downstream lookups work.
2026-02-15 12:21:40 +04:00
Elie Habib 30dfbd0a85 refactor: consolidate news into Top News, remove redundant Evidence section
- Remove Evidence/Sources section (duplicate of Top News)
- Top News now shows 8 items with citation anchor IDs
- AI brief [N] citations link to Top News cards with scroll+highlight
- Move Prediction Markets to right column for balanced layout
- Left: Risk + Brief + Top News | Right: Signals + Timeline + Markets + Infra
2026-02-15 12:14:55 +04:00
Elie Habib 4e8758cb54 Consolidate country detection around shared geometry service 2026-02-15 12:02:57 +04:00
Elie Habib 9bda79e10b fix: add BR/AE to tier-1, resolve deep-link names, fix military timeline filter
- Add Brazil and UAE to TIER1_COUNTRIES, BASELINE_RISK, EVENT_MULTIPLIER,
  COUNTRY_KEYWORDS, COUNTRY_BOUNDS, COUNTRY_ALIASES, and Polymarket maps
- Use Intl.DisplayNames to resolve non-tier ISO codes in deep-link URLs
- Guard raw 2-letter codes from becoming overly broad search terms
- Fix military timeline to use same either/or logic as getCountrySignals
2026-02-15 11:42:45 +04:00
Elie Habib e6fe946b34 fix: tighten headline relevance, add Top News section, compact markets
- Add negative country filter: headlines where another country is mentioned
  first in the title are excluded (fixes Venezuela brief showing for US)
- Filtered headlines used for both evidence section AND LLM context
- Add Top News section (5 items) between Intelligence Brief and Markets
- Limit prediction markets to max 3, tighter padding
2026-02-15 11:27:06 +04:00
Elie Habib e663741f4a fix: hide desktop config panel on web, fix irrelevant prediction markets
- RuntimeConfigPanel only created when isDesktopApp is true (was leaking
  full desktop config UI including secret key inputs to web visitors)
- Fix polymarket sub-market selection: when event title doesn't match
  country but a sub-market does, restrict candidates to matching
  sub-markets instead of picking highest-volume globally (was showing
  Xi Jinping market for France because Macron sub-market triggered match)
2026-02-15 11:16:25 +04:00
Elie Habib 458be05a59 feat: add full-page Country Brief Page replacing modal overlay
Replace the small CountryIntelModal with a comprehensive full-page
intelligence product per country. Implements all 5 phases of the plan:

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

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

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

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

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

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

This PR should fix it .
Fixed UI:
<img width="1892" height="537" alt="Screenshot 2026-02-14 235518"
src="https://github.com/user-attachments/assets/01e96b51-13bf-4d8a-89f7-c944ecee6722"
/>
<img width="1887" height="537" alt="Screenshot 2026-02-14 235529"
src="https://github.com/user-attachments/assets/cf605260-d33a-4400-9d26-df80f74193a9"
/>
2026-02-15 00:21:37 +04:00
Elie Habib 1912e248c6 Bump v2.2.1, remove CLAUDE.md from repo and add to .gitignore 2026-02-15 00:16:46 +04:00
Elie Habib e1925e735c Consolidate variant naming and fix PWA tile caching
- Rename variant default from 'world' to 'full' across config files
- Replace all startups.worldmonitor.app references with tech.worldmonitor.app
- Add CARTO basemap tile caching to Workbox runtime config (basemaps.cartocdn.com)
2026-02-15 00:15:33 +04:00
Elie Habib 5b1f980b70 Fix Windows settings window: async command, no menu bar, no white flash
- Make open_settings_window_command async to prevent WebView2 deadlock on Windows
- Create settings window with visible(false) to avoid white flash before content loads
- Remove menu bar from settings window on Windows/Linux (macOS uses screen-level menu)
- Frontend calls plugin:window|show + set_focus after init completes
2026-02-15 00:15:23 +04:00
Elie Habib a439992094 Add 40-minute timeout to desktop build jobs
Prevents builds from hanging indefinitely when Apple notarization
is slow or credentials are misconfigured.
2026-02-14 23:52:49 +04:00
ranzordly fe11a0ed0e fix: html attribute fix 2026-02-15 01:02:33 +05:30
Elie Habib 4a7f9bfdf4 Allow Cloudflare Insights script in CSP
Add https://static.cloudflareinsights.com to script-src directive
to prevent CSP blocking of Cloudflare Web Analytics beacon.
2026-02-14 22:52:26 +04:00
ranzordly 32973b5770 Fix: constrain layers menu height in DeckGLMap 2026-02-15 00:16:55 +05:30
Elie Habib 81559ba2d6 Use native M1 runner for ARM64 macOS builds
Switch ARM64 build from cross-compiling on macos-latest (Intel) to
native compilation on macos-14 (M1). Updates platform checks to
use contains() so signing works on both macOS runner types.
2026-02-14 22:33:29 +04:00
Elie Habib 7254dbb087 Fix macOS build failures when Apple signing secrets are missing
Gracefully handle missing/invalid Apple Developer certificates instead
of failing the entire build. Detects secret availability, validates
certificate file size, makes import failures non-fatal, and splits
each variant into signed/unsigned conditional paths.
2026-02-14 21:46:46 +04:00
Elie Habib a661497bcc Add latest release badge to README 2026-02-14 21:39:37 +04:00
Elie Habib 5e079f891d Add GitHub Actions workflow for cross-platform desktop builds
Builds macOS (ARM64 + x64) and Windows via tauri-apps/tauri-action.
Supports full/tech variants, optional code signing, and automatic
GitHub Release creation on tag push.
2026-02-14 21:31:07 +04:00
Elie Habib 2c2a6dfbc3 Fix YouTube CSP, add devtools menu, improve desktop channel switching
- Add worldmonitor.app to frame-src CSP in index.html (was only in
  tauri.conf.json, causing iframe block)
- Add devtools feature and Help > Toggle Developer Tools menu item
- Try native YouTube JS API first, fall back to cloud bridge on Error 153
- Add pause-then-play workaround for WKWebView channel switching
2026-02-14 21:09:55 +04:00
Elie Habib 9b216843be Fix YouTube playback in Tauri desktop with Player API and postMessage controls
Switch bridge page from raw iframe to YouTube IFrame Player API with
postMessage protocol for play/pause/mute commands. Use youtube-nocookie.com
host, add click-to-play overlay for WKWebView autoplay restrictions, and
route desktop embeds through cloud URL to avoid sidecar auth issues.
2026-02-14 20:22:37 +04:00
Elie HabibandRinZ27 ea4fe718aa Add token-based auth for local API sidecar
Prevents unauthorized local processes from accessing the sidecar on
localhost:46123. Token is generated at Tauri startup using RandomState
hasher, injected into sidecar env, and lazy-loaded by the frontend
fetch patch via get_local_api_token command.

Service-status endpoint remains public for health checks.

Co-authored-by: RinZ27 <RinZ27@users.noreply.github.com>
2026-02-14 20:05:17 +04:00
Elie Habib c353cf2070 Reduce egress costs, add PWA support, fix Polymarket and Railway relay
Egress optimization:
- Add s-maxage + stale-while-revalidate to all API endpoints for Vercel CDN caching
- Add vercel.json with immutable caching for hashed assets
- Add gzip compression to sidecar responses >1KB
- Add gzip to Railway RSS responses (4 paths previously uncompressed)
- Increase polling intervals: markets/crypto 60s→120s, ETF/macro/stablecoins 60s→180s
- Remove hardcoded Railway URL from theater-posture.js (now env-var only)

PWA / Service Worker:
- Add vite-plugin-pwa with autoUpdate strategy
- Cache map tiles (CacheFirst), fonts (StaleWhileRevalidate), static assets
- NetworkOnly for all /api/* routes (real-time data must be fresh)
- Manual SW registration (web only, skip Tauri)
- Add offline fallback page
- Replace manual manifest with plugin-generated manifest

Polymarket fix:
- Route dev proxy through production Vercel (bypasses JA3 blocking)
- Add 4th fallback tier: production URL as absolute fallback

Desktop/Sidecar:
- Dual-backend cache (_upstash-cache.js): Redis cloud + in-memory+file desktop
- Settings window OK/Cancel redesign
- Runtime config and secret injection improvements
2026-02-14 19:53:04 +04:00
Elie Habib 427d9c1f3f Fixing ACLED positions on the map 2026-02-14 19:53:04 +04:00
Elie Habib 24b5188db9 Fix hovering over certain countries highlighting others
GeoJSON had France, Norway, and Kosovo sharing ISO code -99, causing hover
on any one to highlight all three. Fix ISO codes and switch hover filter
to unique name property. Add data validation tests.
2026-02-14 19:53:04 +04:00
Elie HabibandGitHub 5ee050c28d feat: add think tank, arms control, and food security RSS feeds (#62)
- Add RUSI, Wilson Center, GMF, Stimson, CNAS, Lowy Institute feeds
- Add Arms Control Association, Bulletin of Atomic Scientists feeds
- Add FAO GIEWS food security alerts, EU ISS feeds
- Add www.iss.europa.eu to RSS proxy allowlist
- Add SOURCE_TIERS and SOURCE_TYPES entries for new feeds

Please review modified files (2) to ensure we are properly aligned on
goals/expectations. Cheers!

Jas
2026-02-14 19:51:20 +04:00
Admin e0e1b40e1a feat: add think tank, arms control, and food security RSS feeds
- Add RUSI, Wilson Center, GMF, Stimson, CNAS, Lowy Institute feeds
- Add Arms Control Association, Bulletin of Atomic Scientists feeds
- Add FAO GIEWS food security alerts, EU ISS feeds
- Add www.iss.europa.eu to RSS proxy allowlist
- Add SOURCE_TIERS and SOURCE_TYPES entries for new feeds
2026-02-13 15:06:33 -08:00
Elie Habib 75a85ebafc Fix desktop app reliability: YouTube embeds, panel failures, circuit breakers
- Fix YouTube Error 153 by serving embed bridge from cloud URL (origin match)
- Fix channel switching when playerContainer detached from DOM
- Fix Fires panel infinite spinner when API returns 0 or fails
- Make TECH variant button open web URL instead of being disabled
- Fix circuit breaker caching empty results as success in 6 services
  (polymarket, wingbits, military-flights, outages, conflicts, protests)
- Improve sidecar: cloud-preferred routing, failed import caching, log dedup
- Add FINNHUB_API_KEY and NASA_FIRMS_API_KEY to Tauri secret keys
- Add early 503 for missing ACLED token in risk-scores
2026-02-14 00:25:02 +04:00
Elie Habib ad4e52caee Fix Tauri desktop runtime reliability and settings UX 2026-02-13 23:05:51 +04:00
Elie Habib ac370e9a87 Remove @tauri-apps/cli from devDependencies to fix Railway npm ci
The previous commit (00e4d53) claimed to remove it but didn't.
package-lock.json was already missing the entry, causing sync failure.
2026-02-13 22:39:54 +04:00
Elie Habib 178b9563a8 Merge branch 'pr-45'
# Conflicts:
#	LICENSE
2026-02-13 20:41:05 +04:00
Elie Habib 871af119a3 Batch AI classification and Railway-direct AIS routing
- Add /api/classify-batch endpoint: classifies up to 20 headlines per Groq call
  (reduces 182 individual API calls to ~10 batched calls, 90% rate limit savings)
- Update threat-classifier.ts: collect headlines in batch queue, flush every 500ms
  or when batch reaches 20 items
- Route AIS snapshot through Railway directly when VITE_WS_RELAY_URL is set,
  falling back to Vercel — eliminates 503 when WS_RELAY_URL not configured on Vercel
2026-02-13 20:38:20 +04:00
Elie Habib c1584987f5 Route Atlantic Council RSS through Railway to fix 504 timeouts 2026-02-13 19:02:12 +04:00
Elie Habib 15134a4b7a Add gzip compression and WS client cap for further egress reduction
- gzip all JSON responses (OpenSky, UCDP, AIS snapshot): ~80% smaller
- Cap WebSocket clients at 10 (app uses HTTP snapshots, not WS)
2026-02-13 18:42:15 +04:00
Elie Habib 444aff5eb6 Add Vercel fallback when Railway UCDP route unavailable
Try Railway first, fall back to Vercel /api/ucdp-events on failure.
2026-02-13 18:39:44 +04:00
Elie Habib d3f9100f52 Add server-side caching to Railway relay — eliminates ~1.7TB/day egress
Critical cost optimization for 57+ concurrent clients:

- OpenSky response cache (30s TTL per bounding box): ~1.2TB/day saved
- RSS response cache (5min TTL per feed URL): ~30GB/day saved
- AIS WebSocket fanout throttled to 1/10 messages: ~400GB/day saved
- Cache cleanup interval, health stats, stale fallbacks
2026-02-13 18:37:13 +04:00
Elie Habib 0503f20943 Move UCDP proxy to Railway with persistent in-memory cache
- Add /ucdp-events handler to ais-relay.cjs with 6h TTL cache,
  background refresh, version discovery, and 30s per-page timeout
- Route client through Railway when VITE_WS_RELAY_URL is set
- Add 8s AbortController timeout to Vercel fallback fetchGedPage()
2026-02-13 18:29:30 +04:00
Elie Habib 4922ef454b Fix FAO News RSS feed URL
Old URL (www.fao.org/rss/home/en/) returns 404.
Updated to new feed at www.fao.org/feeds/fao-newsroom-rss.
2026-02-13 18:01:35 +04:00
Elie Habib b51fcc641f Fix FAO News RSS feed URL
Old URL (www.fao.org/rss/home/en/) returns 404.
Updated to new feed at www.fao.org/feeds/fao-newsroom-rss.
2026-02-13 18:01:25 +04:00
Elie Habib 2b70374e86 Add MIT LICENSE file
Fixes #57 - the README referenced a LICENSE file that didn't exist,
causing a 404 on GitHub.
2026-02-13 17:28:05 +04:00
Elie Habib 9a258f1a62 Add MIT LICENSE file
Fixes #57 - the README referenced a LICENSE file that didn't exist,
causing a 404 on GitHub.
2026-02-13 17:14:27 +04:00
Elie HabibandGitHub 76f9f470f2 Improve Vite chunk splitting and update documentation metadata (#56)
### Motivation
- Reduce main bundle pressure by splitting large third-party libraries
into logical chunks to improve load/ caching behavior during production
builds.
- Keep documentation accurate and ensure assets referenced from `docs/`
resolve correctly.

### Description
- Replaced static `manualChunks` object in `vite.config.ts` with a
function-based splitter that creates `ml` for ML deps
(`@xenova/transformers`, `onnxruntime-web`), `map` for map/visualization
deps (`@deck.gl`, `maplibre-gl`, `h3-js`), and preserves dedicated `d3`
and `topojson` chunks.
- Updated `docs/DOCUMENTATION.md` to bump the version badge to `2.1.4`
and fixed the dashboard image path to `../new-world-monitor.png` so it
loads when viewing files from the `docs/` folder.
- Changes applied to `vite.config.ts` and `docs/DOCUMENTATION.md` and
committed to the current branch.

### Testing
- Ran `npm run typecheck` and it completed successfully.
- Ran `npm run build` and the production build succeeded; rollup emitted
warnings about `eval` in a dependency and some chunks >500 kB but build
artifacts were produced.
- Ran `npm run test:e2e:full` which failed in this environment because
Playwright browser binaries are not installed (error: `Executable
doesn't exist ... chromium_headless_shell`).

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698f0083f99083338bf9ea2ebbefd191)
2026-02-13 15:26:04 +04:00
Elie Habib 1c90d4d1dd Improve build chunking and fix documentation metadata 2026-02-13 15:20:29 +04:00
Elie HabibandGitHub 7678b13109 docs: add Tauri validation preflight and remediation guidance (#55) 2026-02-13 14:42:29 +04:00
Elie Habib e185d0e6f7 build: fail fast when local tauri cli is missing 2026-02-13 14:40:57 +04:00
Elie Habib 9a803eab46 docs: add tauri network preflight and remediation guidance 2026-02-13 14:08:36 +04:00
Elie HabibandGitHub 3c68349ad2 Add optional Cargo vendoring config and offline packaging documentation (#54)
### Motivation

- Make Tauri/Rust dependency resolution reproducible in
restricted-network CI or offline environments by providing a documented
vendored source option.
- Surface two supported packaging flows (online and restricted-network)
in the release docs so packagers know how to provision offline inputs.
- Improve validation reporting to distinguish external registry outages
from failures caused by missing offline artifacts so QA results are
actionable.
- Allow committing `Cargo.lock` for `src-tauri` once generated by
removing it from the local `.gitignore` so dependency graphs can be
pinned.

### Description

- Added `src-tauri/.cargo/config.toml` defining a `vendored-sources`
replacement that points to `src-tauri/vendor/` and included inline
instructions to `cargo vendor` for populating it.
- Updated `docs/RELEASE_PACKAGING.md` with a new section describing two
Rust dependency modes and example commands for (1) the standard online
build and (2) a restricted-network build that uses vendored crates or an
internal mirror.
- Updated `docs/TAURI_VALIDATION_REPORT.md` to classify failures into
**External environment outage** and **Expected failure: offline mode not
provisioned**, with guidance for both.
- Modified `src-tauri/.gitignore` to stop ignoring `Cargo.lock` so a
lockfile can be generated and committed when network or vendored
artifacts are available.

### Testing

- Ran `cd src-tauri && cargo generate-lockfile`, which failed due to
blocked access to `https://index.crates.io` with `403 CONNECT tunnel
failed` (environmental network restriction).
- Ran `cd src-tauri && cargo generate-lockfile --offline`, which failed
as expected because `src-tauri/vendor/` was not provisioned (`no
matching package named keyring found`).
- No additional automated tests were run on the modified files;
documentation and config changes were validated by applying the edits
and confirming file contents.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ef3d2ac2c8333b576f1c85e6004a1)
2026-02-13 14:01:50 +04:00
Elie Habib 493ceed0e3 docs: refine tauri offline mode workflow 2026-02-13 14:01:03 +04:00
Elie Habib e22f7d8aeb docs: add tauri offline dependency packaging guidance 2026-02-13 13:52:56 +04:00
Elie HabibandGitHub 895de08e07 Codex-generated pull request (#53)
Codex generated this pull request, but encountered an unexpected error
after generation. This is a placeholder PR message.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eecffdf0c833382ac8f5a79fd8090)
2026-02-13 13:46:49 +04:00
Elie Habib cca1a43df6 Harden local Tauri CLI invocation and fix validation report 2026-02-13 13:44:47 +04:00
Elie Habib b3b830f05e Use local Tauri CLI for desktop scripts 2026-02-13 13:28:58 +04:00
Elie HabibandGitHub d4e3ff66a9 Codex-generated pull request (#52)
Codex generated this pull request, but encountered an unexpected error
after generation. This is a placeholder PR message.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ed0c520348333874fc0d1cd6f09a0)
2026-02-13 13:18:02 +04:00
Elie Habib 6d770d8c63 docs: add tauri validation report with environment blockers 2026-02-13 13:15:24 +04:00
Elie HabibandGitHub 5b7b487ca2 Add desktop parity matrix and desktop-readiness fallback visibility (#51)
### Motivation

- Provide a compact, maintainable model for tracking which UI features
are fully local vs require keys or cloud fallback so desktop parity work
can be prioritized.
- Surface desktop-specific acceptance checks and per-feature fallback
behavior in the UI so operators can quickly assess "desktop ready"
status.
- Consolidate the local-sidecar parity audit into a machine-readable
matrix and documented acceptance criteria.

### Description

- Added a new desktop parity service `src/services/desktop-readiness.ts`
that defines `DesktopParityFeature`, encodes high-value features
(LiveNews, Monitor, StrategicRisk, map layers, summaries, markets,
wingbits/relays), and exposes `getDesktopReadinessChecks`,
`getKeyBackedAvailabilitySummary`, and `getNonParityFeatures` helper
functions.
- Updated `src/components/ServiceStatusPanel.ts` to render a new
"Desktop readiness" section that shows acceptance-check progress,
key-backed feature availability (`available/total`), and a collapsible
list of non-parity fallbacks for operator visibility.
- Reworked `docs/local-backend-audit.md` into a prioritized parity
matrix with closure ordering and explicit desktop-ready acceptance
criteria (startup, map rendering, core intelligence panels, summaries,
market panel, and at least one live-tracking mode).

### Testing

- Ran type checking with `npm run typecheck`, which completed
successfully.
- Executed an automated browser smoke run (Playwright) to capture a
Service Status screenshot against the dev server, which completed and
produced a screenshot artifact successfully.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ec64caff0833386d529e611b468f4)
2026-02-13 11:15:27 +04:00
Elie Habib 6602456408 Address parity review comments with service/API mapping fixes 2026-02-13 11:04:02 +04:00
Elie Habib ee304d0e89 Add desktop parity matrix and readiness fallback UI 2026-02-13 10:54:02 +04:00
Elie HabibandGitHub 5db09fc169 Add reproducible Tauri packaging workflow for macOS/Windows variants (#50)
### Motivation
- Provide a single, reproducible local packaging workflow for desktop
artifacts across macOS and Windows that supports both product variants
(full vs tech).
- Make signing/notarization flows explicit and safe by wiring optional
env-based hooks for Apple notarization and Windows Authenticode.
- Ensure outputs are variant-aware and documented so release engineers
can validate artifacts on clean machines.

### Description
- Add a reusable runner `scripts/desktop-package.mjs` that accepts `--os
<macos|windows>`, `--variant <full|tech>`, and an optional `--sign`
flag, pins bundler targets per-OS, and enforces required signing env
vars when signing is requested.
- Wire `package.json` to use the new runner for all `desktop📦*`
scripts and add a generic `desktop:package` entrypoint.
- Update `src-tauri/tauri.conf.json` and
`src-tauri/tauri.tech.conf.json` to keep explicit bundler targets and
align platform bundle settings (`macOS.hardenedRuntime`, and Windows
`digestAlgorithm`/`timestampUrl`).
- Expand `docs/RELEASE_PACKAGING.md` and update `README.md` with
reproducible per-OS commands, example env hooks for Apple notarization
and Windows Authenticode, variant-aware outputs, artifact locations, and
a clean-machine release checklist.

### Testing
- Ran TypeScript checks with `npm run typecheck`, which completed
successfully.
- Executed the packaging runner guard path with `node
scripts/desktop-package.mjs --os macos --variant full --sign`, which
failed as expected due to missing signing env vars and confirmed the
signing-enforcement checks are working.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ec3ac04e0833397294cf0b28ac97e)
2026-02-13 10:34:53 +04:00
Elie Habib e613ea5217 Refine signing guardrails for desktop packaging 2026-02-13 10:33:25 +04:00
Elie Habib a97157b9c5 Add reproducible cross-OS Tauri packaging workflow 2026-02-13 10:27:39 +04:00
Elie HabibandGitHub 0c1d594a42 Add reproducible Tauri packaging workflow for macOS/Windows with release checklist (#49)
### Motivation
- Provide reproducible local packaging commands for both product
variants (full vs tech) across macOS and Windows.
- Make signing/notarization and Authenticode flows optional and
environment-driven so builds can be reproducible unsigned or signed when
keys are available.
- Ensure release artifacts and variant identities are documented and
easy to validate on a clean machine.

### Description
- Added deterministic packaging scripts to `package.json` for macOS
(`.app` + `.dmg`) and Windows (`NSIS .exe` + `.msi`) for both `full` and
`tech` variants and optional signed variants via environment variables.
- Updated `src-tauri/tauri.conf.json` and
`src-tauri/tauri.tech.conf.json` to explicitly target `app`, `dmg`,
`nsis`, and `msi`, and added a Windows signing/timestamp hint (`sha256`
+ timestamp URL).
- Added `docs/RELEASE_PACKAGING.md` containing per-OS packaging
commands, optional signing/notarization env hooks, artifact output
locations, and a clean-machine startup validation checklist.
- Linked the new packaging guide from `README.md` and surfaced the new
`npm run desktop📦*` commands in the contributor command block.

### Testing
- Verified the updated JSON files parse successfully with a `node -e`
JSON parse check (succeeded).
- Ran `npm run -s typecheck` (TypeScript `tsc --noEmit`) to ensure no
type regressions (succeeded).

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ebfa41cbc8333ba9aeeab79aa8a72)
2026-02-13 10:20:02 +04:00
Elie Habib 9bc39ad2d1 Address packaging review feedback for signing hooks and docs clarity 2026-02-13 10:18:45 +04:00
Elie Habib 47f36416ce Add reproducible desktop packaging commands and release checklist 2026-02-13 10:11:16 +04:00
Elie HabibandGitHub fc805a757a Add desktop persistent cache, offline circuit-breaker states, and panel freshness badges (#48)
### Motivation
- Ensure the app remains useful when cloud APIs are unreachable by
providing durable local fallbacks and clearer UI indicators for
stale/offline data.
- Persist recent feed items, latest computed risk snapshots, and
stale-tolerant map overlays for desktop builds so a packaged app can
operate without network.
- Surface data freshness and whether values are `live`, `cached`, or
`unavailable` in panels so analysts understand what they are seeing.

### Description
- Add a desktop-aware persistent cache service
(`src/services/persistent-cache.ts`) that uses Tauri `invoke` commands
(`read_cache_entry` / `write_cache_entry`) with `localStorage` as a
fallback for non-desktop runtimes.
- Extend the Tauri backend (`src-tauri/src/main.rs`) to provide
file-backed JSON cache storage under app data and register
`read_cache_entry` and `write_cache_entry` handlers.
- Wire persistent fallbacks into runtime services: RSS feeds
(`src/services/rss.ts`), cached risk scores
(`src/services/cached-risk-scores.ts`), earthquake overlay data
(`src/services/earthquakes.ts`), and the Insights world-brief snapshot
(`src/components/InsightsPanel.ts`).
- Enhance circuit-breaker semantics in `src/utils/circuit-breaker.ts` to
track `BreakerDataState` (modes: `live` / `cached` / `unavailable`) and
detect desktop offline mode so callers can show more precise status and
use cached fallbacks.
- Add panel-level data-state badges and helpers in
`src/components/Panel.ts` and apply them to `NewsPanel`,
`InsightsPanel`, and `StrategicRiskPanel`, plus styling in
`src/styles/main.css`.
- Include static resources in desktop bundle config by adding `../data`
and `../src/config` to `src-tauri/tauri.conf.json` so pre-bundled
datasets are available in production desktop builds.

### Testing
- Ran `npm run typecheck` (`tsc --noEmit`) which completed successfully.
- Started the dev server and exercised the UI; captured a screenshot
demonstrating panel freshness badges (artifact recorded during the run).
- `cargo check` could not complete in this environment due to
network/crates.io access being blocked (error: CONNECT tunnel failed /
403), so native Rust/Tauri build verification was not possible here.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eb89d5b448333b14174125f76d3c4)
2026-02-13 10:05:35 +04:00
Elie Habib 01731e0690 Fix freshness badge state transitions for cached summaries 2026-02-13 10:04:09 +04:00
Elie Habib 4f6d3396de Add desktop offline cache persistence and freshness badges 2026-02-13 09:46:11 +04:00
Elie HabibandGitHub 9bf128aab2 Codex-generated pull request (#47)
Codex generated this pull request, but encountered an unexpected error
after generation. This is a placeholder PR message.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eb29a0bc48333b9bf8c07362391e5)
2026-02-13 09:36:37 +04:00
Elie Habib 4a75bf1f4d Fix runtime config gating for web and desktop-only secret writes 2026-02-13 09:35:34 +04:00
Elie Habib 124683090d Add desktop runtime config panel and secure secret vault hooks 2026-02-13 09:22:14 +04:00
Elie HabibandGitHub a007d44e67 Add Tauri local API sidecar and desktop runtime routing with cloud fallback (#46)
### Motivation
- Provide a local backend for the Tauri desktop app so core
functionality (news, summarization, markets, telemetry, status) does not
require Vercel edge functions and can run offline or with reduced cloud
dependency.
- Minimize frontend changes by exposing the same `/api/*` paths locally
and failing over to the cloud when handlers are missing or local
execution fails.

### Description
- Add a Node sidecar local API server at
`src-tauri/sidecar/local-api-server.mjs` that dispatches `/api/*` to
existing `api/*.js` handlers when present and proxies to the cloud
(`https://worldmonitor.app`) as a fallback.
- Start/stop the sidecar with the Tauri app lifecycle by launching it
from `src-tauri/src/main.rs` and managing the child process state.
- Update Tauri configuration in `src-tauri/tauri.conf.json` to allow the
local API origin (`http://127.0.0.1:46123`) in the CSP and to include
`../api` and `sidecar/local-api-server.mjs` as bundle resources.
- Desktop runtime routing changes in `src/services/runtime.ts`: default
desktop API base set to `http://127.0.0.1:46123`, added
`getRemoteApiBaseUrl()` and an `installRuntimeFetchPatch()` function
that patches `fetch` to route `/api/*` to the local sidecar with
automatic cloud fallback.
- Enable the runtime fetch patch at app start by calling
`installRuntimeFetchPatch()` from `src/main.ts`.
- Update `ServiceStatusPanel` (`src/components/ServiceStatusPanel.ts`)
to render local backend status and to show clear messaging when the
local backend is unavailable and the UI is using the cloud fallback.
- Add documentation `docs/local-backend-audit.md` listing prioritized
`/api/*` endpoints for desktop parity and describing the localization
strategy.
- Minor formatting run via `cargo fmt` adjusted `src-tauri/build.rs`.

### Testing
- `npm run typecheck` (`tsc --noEmit`) passed successfully.
- `cargo fmt` completed successfully and reformatted
`src-tauri/build.rs` where needed.
- `cargo check` failed in this environment due to network restrictions
while downloading crates index (environment-specific HTTP 403); this is
unrelated to the code changes themselves.
- Local sidecar smoke tests (automated invocation): launched `node
src-tauri/sidecar/local-api-server.mjs` and verified `GET
/api/local-status` and `GET /api/service-status` returned expected JSON
responses (local status included), demonstrating the sidecar dispatch
and health endpoints work in this environment.
- Playwright screenshot attempt failed because the dev server was not
reachable from the test environment (`net::ERR_EMPTY_RESPONSE`), so UI
screenshot validation could not be completed here.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ead50f548833398717fa3b8c92230)
2026-02-13 09:09:08 +04:00
Elie Habib 54b5adb8c7 Harden desktop sidecar route matching and fetch routing 2026-02-13 09:07:33 +04:00
Elie Habib b7ee69dbb7 Add Tauri local API sidecar with desktop routing fallback 2026-02-13 08:59:22 +04:00
Elie Habib 778bc830d6 Refine Tauri variant metadata and runtime detection 2026-02-13 08:58:55 +04:00
Elie Habib eb0f396d16 Add Tauri v2 desktop scaffold and runtime bridge 2026-02-13 08:47:12 +04:00
Elie Habib 19754716c6 feat: add intelligence layers and harden data ingestion 2026-02-13 08:14:53 +04:00
Elie Habib 3c46110632 test: guard cluster cache initialization and document checks 2026-02-13 07:59:00 +04:00
Elie Habib c4509e8a69 docs: add self-hosting section to README
Explains that `vercel dev` is needed instead of `npm run dev` for
the API edge functions to work. Covers three deployment options
(Vercel, local with CLI, static frontend only) and platform notes
for Raspberry Pi/ARM users. Addresses #44.
2026-02-13 07:23:08 +04:00
Elie HabibandGitHub 8a7e8b2230 Fix hotspot overlay positioning during map drag (#43)
## Summary
Fixes visual lag where high-activity hotspot overlays drift from their
geographic positions during map panning operations.

## Problem
HTML overlays for high-activity hotspots (`level=high` or
`hasBreaking=true`) were only updating positions on a throttled 100ms
delay. During map drag operations, this caused overlays to maintain
their screen position while the map moved underneath, resulting in a
"drift and snap" effect where markers would float away from their true
locations then jump back when the update triggered.

## Solution
- Added `updateHotspotPositions()` method that synchronizes overlay
positions with the map viewport in real-time during movement
- Bound method to the map `'move'` event for frame-by-frame position
updates during drag operations
- Eliminated the 100ms throttle delay for position calculations during
active map movement

## Changes
- **Added** `updateHotspotPositions()` method for real-time overlay
position synchronization
- **Added** `'move'` event listener to trigger position updates during
map drag/pan
- **Fixed** drifting issue for high-activity and breaking hotspot
markers
- **Added** dev mode helper to force 3 random hotspots as `breaking` for
easier overlay behavior testing (for some reason they just weren't
appearing on localhost)



https://github.com/user-attachments/assets/a1cfe749-69bb-499c-b396-24054e7891ce



https://github.com/user-attachments/assets/59c4265a-0667-4ea9-a657-a4f67f983f1e
2026-02-13 00:36:50 +04:00
Elie Habib 1716278538 Fix hotspot overlay sync path and harden map harness regressions 2026-02-13 00:34:19 +04:00
Ahmadhamdan47 5f9663c2c7 Fix hotspot overlay positioning during map drag
- Add updateHotspotPositions() method that updates overlay positions in real-time during map movement

- Call updateHotspotPositions() on 'move' event to prevent overlays from lagging behind their geographic positions

- Fixes issue where high-activity/breaking hotspot markers would drift during drag and snap back on release

- Add dev mode helper to force 3 random hotspots as breaking for testing overlay behavior

The HTML overlays for high-activity hotspots (level=high or hasBreaking=true) were only updating positions on a throttled/delayed basis (100ms), causing them to maintain screen position while the map moved underneath, then snap back when the update triggered. Now they update smoothly every frame during map movement.
2026-02-12 19:27:28 +02:00
Elie Habib 6294a43df3 Merge branch 'codex/pr-41' 2026-02-12 18:27:53 +04:00
Elie Habib 2b6add1f0d fix(map): reproject overlay clusters on pan and harden e2e visual determinism 2026-02-12 18:00:54 +04:00
Elie Habib b0e829a84c test(e2e): add variant and pan reprojection regression checks 2026-02-12 17:37:21 +04:00
Elie Habib 0697eeba3a test(e2e): add deterministic per-layer visual baselines 2026-02-12 13:21:45 +04:00
Elie Habib 302f3fba03 Expand Playwright map coverage across full and tech layer sets 2026-02-12 12:57:47 +04:00
Elie Habib a8a648bfd9 Add Playwright map harness smoke tests for layer regressions 2026-02-12 12:34:42 +04:00
Elie Habib 5db303f822 Fix cluster state tracking and stale overlay cache invalidation 2026-02-12 09:05:34 +04:00
Elie Habib dde49b7248 chore: trigger redeploy for PR #41 2026-02-12 08:55:29 +04:00
Elie Habib 08f03f8ed3 Fix tooltip coverage, pulse scheduling, and map interaction defaults 2026-02-12 08:50:49 +04:00
Elie Habib 419285d291 Fix conflict layer lifecycle by avoiding cached GeoJsonLayer instance 2026-02-12 08:29:23 +04:00
Elie Habib c876158abe Fix DeckGL map regressions and preserve perf optimizations 2026-02-12 08:18:01 +04:00
Ahmadhamdan47 2349675a9c Optimize map rendering performance with multi-tier caching
- Implements layer caching to avoid rebuilding unchanged deck.gl layers
- Adds cluster element DOM caching with position-based invalidation
- Adds cluster result caching to prevent redundant spatial computations
- Replaces setInterval with requestAnimationFrame for news pulse animation
- Adds throttle, debounce, and rafSchedule utilities for render optimization
- Removes hover tooltips in favor of click-based interactions (better performance)
- Adds performance monitoring with console warnings for >16ms operations
- Implements smart layer rebuilding only on zoom threshold crossings
- Reduces cluster overlay opacity during map moves for smoother UX
- Configures deck.gl with interleaved=false and useDevicePixels=false for performance

Performance improvements:
- Reduces redundant layer rebuilds by ~90% during map interactions
- Eliminates tooltip hover callback overhead
- Prevents cluster DOM thrashing with element reuse
- 60fps target for map panning/zooming maintained
2026-02-12 02:02:14 +02:00
Elie Habib c80136ffdb Add sparkline charts to Markets, Crypto, and Commodities panels
- Extract close price arrays from Yahoo Finance chart API for indices/commodities
- Switch CoinGecko crypto fetch to /coins/markets endpoint with 7d sparkline data
- Render inline SVG sparklines color-coded green/red by price direction
- Fix Vite dev proxy for CoinGecko (was hitting root instead of /api/v3/simple/price)
- Add endpoint=markets support to CoinGecko edge function
2026-02-12 00:20:44 +04:00
Sebastien MelkiandGitHub 7382f19a47 docs: add stargazers growth to the bottom of the README file (#40) 2026-02-11 21:21:06 +04:00
Elie Habib 06d2068da8 Expand README with market intelligence, architecture, and security docs
- Add Market & Crypto Intelligence data layer section
- Document macro signal analysis (7-signal BUY/CASH verdict, VWAP, Mayer Multiple)
- Document stablecoin peg monitoring and BTC ETF flow estimation algorithms
- Add strategic theater posture assessment (9 theaters)
- Add infrastructure cascade BFS modeling with chokepoint dependencies
- Add browser-side ML pipeline section (Transformers.js capabilities)
- Add dual-deployment architecture diagram (Vercel + Railway)
- Add caching architecture section (3-tier with stale-on-error)
- Add security model section (8-layer defense table)
- Update edge function count (30+ → 45+), tech stack, env vars
- Add Cmd+K search, virtual scrolling, UCDP/HAPI to capabilities
2026-02-11 19:07:39 +04:00
Elie Habib 14c67ff592 Optimize proxy usage with AIS snapshots, Upstash caching, and telemetry 2026-02-11 19:06:00 +04:00
3985def3c7 Add .env.example with all environment variables (#39)
* Add .env.example with all environment variables documented

* Fix .env.example: add missing vars, fix wrong names, organize by deployment

- Remove ghost var VITE_OPENSKY_RELAY_URL (not used in codebase)
- Add missing AISSTREAM_API_KEY (Railway relay)
- Add missing WS_RELAY_URL (Vercel server-side relay connection)
- Add missing VITE_VARIANT (site variant selector)
- Move OPENSKY_CLIENT_ID/SECRET to Railway section (used by relay, not Vercel)
- Label sections with deployment target (Vercel vs Railway)
- Remove stale docs/DOCUMENTATION.md reference from header

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-02-11 18:24:40 +04:00
Elie Habib ee3a3f1c6b Add Market Radar, BTC ETF Tracker, and Stablecoins panels
Three new crypto/market intelligence panels with self-fetching data:

- Market Radar (macro-signals): 7 signals from Yahoo Finance, mempool.space,
  alternative.me — liquidity, flow structure, macro regime, BTC trend with
  30d VWAP, hash rate, mining cost, fear & greed. SVG sparklines and donut gauge.
- BTC ETF Tracker (etf-flows): 10 spot Bitcoin ETFs via Yahoo Finance 5-day
  chart with estimated net flows, volume, and price change per ETF.
- Stablecoins (stablecoin-markets): CoinGecko proxy for peg health monitoring
  (ON PEG / SLIGHT DEPEG / DEPEGGED) and supply/volume breakdown.

All endpoints include CORS origin guard, input validation, in-memory caching
with stale fallback. Panels registered in both full and tech variants.
2026-02-11 14:35:28 +04:00
Elie Habib f7119b9ed6 Harden CORS, XSS, and input validation across all API endpoints and components
- Add CORS origin allowlist (api/_cors.js) replacing Access-Control-Allow-Origin: *
- Add isDisallowedOrigin guard to all API endpoints (acled, cloudflare-outages, finnhub, fred-data, hackernews, wingbits)
- Gut debug-env endpoint to return 404
- Tighten sanitizeUrl() with escapeAttr output and strict relative URL validation
- Add sanitizeUrl() adoption in CountryIntelModal, InsightsPanel, PredictionPanel, RegulationPanel, TechEventsPanel
- Comprehensive escapeHtml() hardening in MapPopup (cables, flights, vessels, clusters)
- Bound HackerNews concurrent fetches (MAX_CONCURRENCY=10), validate story type and limit params
- Add wingbits cache eviction (MAX_LOCAL_CACHE_ENTRIES=2000, sweep on TTL + LRU)
- Fix arxiv http→https, og-story parseInt safety with Number.isFinite + clamping
2026-02-11 14:35:07 +04:00
Elie HabibandGitHub e808371250 Fix live video stopping on tab switch (#37)
## Summary
- Stop destroying the YouTube player when the tab becomes hidden —
audio/video now continues in background tabs
- Suspend the 5-minute idle timer while the tab is hidden so it doesn't
silently kill background playback
- Idle timer resumes when the user returns to the tab

## Test plan
- [ ] Open the live news panel and start a stream
- [ ] Switch to another browser tab — verify audio continues playing
- [ ] Minimize the browser window — verify audio continues
- [ ] Return to the tab after >5 minutes — verify stream is still
playing
- [ ] Leave the tab visible but don't interact for 5 minutes — verify
idle pause still triggers
2026-02-11 07:40:41 +04:00
Elie Habib e5c652c2aa Allow live video to continue playing in background tabs
Previously, switching tabs or minimizing the window would immediately
destroy the YouTube player, cutting audio and video. Now the stream
continues playing when the tab is hidden, with the idle timer suspended
so it doesn't silently kill playback after 5 minutes in the background.
2026-02-11 07:39:22 +04:00
Elie Habib 45dc787224 Fix UCDP proxy: API returns snake_case fields, not PascalCase
UCDP API fields are conflict_id, location, side_a, intensity_level etc.
(snake_case) — proxy was reading ConflictId, Location, IntensityLevel
(PascalCase), causing all values to be empty/0.

Also paginate through all results instead of just first page.
2026-01-31 08:06:18 +04:00
Elie Habib 8d9265903f Pause AI classify queue on 500 errors, not just 429
When Groq returns 500, the queue kept firing requests against a failing
API. Now 500+ errors pause the queue for 30s (429 stays 60s) and flush
pending jobs, preventing the retry storm visible in console.
2026-01-31 08:03:03 +04:00
Elie Habib f7fd4ad24d Fix Chatham House 403 and FAO parse errors
- Chatham House RSS returns 403 from cloud IPs, use Google News fallback
- FAO FPMA feed returns HTML (Angular app), not RSS — replaced with fao.org/rss
- Updated rss-proxy allowlist domain
2026-01-31 08:00:13 +04:00
Elie Habib 8e6b627a66 Fix HAPI 500: correct endpoint URL, auth format, response parsing
- Endpoint was conflict-event (singular), fixed to conflict-events (plural)
- app_identifier must be base64("name:email"), passed as query param
- Response fields are (events, fatalities, event_type) per row, not
  pre-aggregated columns — fixed aggregation logic
- Remove Arab News and Times of Israel feeds (403 from cloud IPs)
2026-01-31 07:30:48 +04:00
Elie Habib c6f1da95c5 Integrate ACLED conflicts, UCDP classification, and HDX HAPI for real conflict detection
Replace hardcoded conflict floor scores with real data from three sources:
- ACLED battles/explosions/violence against civilians (30-day events)
- UCDP conflict classification (war vs minor vs none, data-driven floors)
- HDX HAPI aggregated monthly conflict counts (fallback/validation)

New CII component 'conflict' weighted 30% of event score. Updated formula:
unrest(25%) + conflict(30%) + security(20%) + information(25%)
2026-01-30 23:26:53 +04:00
Elie Habib 4481719db8 Raise conflict floor scores: Ukraine 72, Syria/Yemen 66, Myanmar/Israel 60 2026-01-30 22:58:21 +04:00
Elie Habib 097f850a05 Fix false 'Insufficient Data' on stale sources: extend stale threshold to 2h, count very_stale as active 2026-01-30 20:09:55 +04:00
Elie Habib f3b9cd8fa4 Fix FIRMS API key: accept NASA_FIRMS_API_KEY env var with fallback 2026-01-30 20:05:48 +04:00
Elie Habib c3c44a112d Remove dead/blocked intel RSS feeds (RUSI, CNAS, CFR, Wilson Center, Lowy, GMFUS, Arms Control, Stimson, Bulletin, World Bank, IMF) 2026-01-30 20:03:55 +04:00
Elie Habib 52226949a2 Replace hexagon icon with standard share/upload SVG icon for story buttons 2026-01-30 15:19:37 +04:00
Elie Habib fe36df976c Subtle fullscreen button active state: translucent bg instead of solid fill 2026-01-30 15:12:11 +04:00
Elie Habib 5860e5b5ff Replace bulky megaphone emoji with smaller ✦ symbol for protest markers 2026-01-30 13:04:35 +04:00
Elie Habib c36096261e Redesign story share UI: modern floating bar with SVG icons, X close button 2026-01-30 11:15:03 +04:00
Elie Habib 3db9dbbfc8 Rename Satellite Fires→Fires in UI, fix component bar scale, add more signal type labels 2026-01-30 11:11:13 +04:00
Elie Habib 31d492acfd Add WorldMonitor logo to story card header and footer 2026-01-30 11:03:23 +04:00
Elie Habib 148164915d Add humanizeSignalType for readable convergence signal labels in story cards 2026-01-30 11:01:00 +04:00
Elie Habib 41c52af5ea Fix SVG arc paths: add missing M (moveto) command 2026-01-30 10:54:41 +04:00
Elie Habib beac24c1f1 Redesign OG image as rich intelligence card
- Gradient background with left accent sidebar in level color
- Large 120px CII score with /100 + labeled score bar with tick marks
- Semicircle arc gauge on right with score + level badge
- Data indicator legend (threat, military, markets, convergence, signals)
- No-score fallback shows 4 feature cards with descriptions
- Bottom bar with W logo circle, brand text, and CTA button
- Subtle grid background, status pill, date stamp
2026-01-30 10:51:41 +04:00
Elie Habib 91dbfefd06 Improve story card clarity + fix OG image missing score data
- Story renderer: larger fonts, better contrast (#777 vs #555 headers,
  #e0e0e0 vs #ddd text), visible separators (#222 vs #1a1a2e), removed
  grid background noise, bigger score (72px), bigger headlines (26px)
- OG image: pass score & level params to og-story endpoint, improve
  contrast, larger score text (96px), better fallback when no score
- Meta tags: include s= and l= query params in og:image URL
2026-01-30 10:34:01 +04:00
Elie Habib d296f679a5 Improve story card clarity: larger fonts, better contrast, visible separators 2026-01-30 10:29:44 +04:00
Elie Habib d981c3b180 feat: add OG image and story page edge functions for Twitter card previews
- /api/og-story: generates SVG OG image with country name, CII score, level
- /api/story: serves meta tags to social crawlers, redirects real users to SPA
- Deep links now point to /api/story for proper crawler support
- Pass CII score/level through URL params for dynamic OG images
2026-01-30 06:22:18 +00:00
Elie Habib 0996525539 fix: use dynamic country flag in share texts, remove duplicate URL from twitter 2026-01-30 06:19:22 +00:00
Elie Habib 629b691bb5 fix: resolve TypeScript errors in data-freshness, focal-point, meta-tags, temporal-baseline 2026-01-30 06:14:36 +00:00
Elie Habib e34b1ca0a3 feat: satellite fires layer, temporal baseline, cleanup dead code, update README
- Add NASA FIRMS satellite fire detection map layer and panel
- Add temporal baseline anomaly detection (Welford's algorithm, Redis-backed)
- Wire signal aggregator with fires, temporal anomalies
- Remove 10 dead service files and unused markdown docs
- Deduplicate RSS feeds, clean up story templates
- Fix data freshness, status panel, and verification checklist
- Create og-image.png for social sharing meta tags
- Update README with signal aggregation, source tiering, edge architecture
- Bump version to 2.1.4
2026-01-30 06:07:40 +00:00
Elie Habib 8586c1485c feat: wire new services into signal aggregator
Add signal ingestion methods for all 9 new services:
- ingestSatelliteFires() - NASA FIRMS fire detection
- ingestDefenseActivity() - war analysis military activity
- ingestInfrastructureStatus() - IXP/cable/gateway status
- ingestSentimentAlerts() - social media sentiment shifts

Add 4 new SignalType enums:
- satellite_fire
- defense_activity
- infrastructure_status
- sentiment_shift

Update getSummary() and regional convergence to include new signal types.

This integrates all new intelligence services into the main signal feed.
2026-01-30 04:05:57 +00:00
Elie Habib 1023ac38a3 docs: ALL OSINT research features now implemented - 9/9 complete 2026-01-30 03:54:01 +00:00
Elie Habib 3ae4d66440 feat: add API for third-party integrations
Create src/services/api-server.ts:
- REST API with Express.js
- Authentication via API key (header or query param)
- Rate limiting (100 requests/minute)
- Endpoints: /stories, /signals, /reports, /countries, /categories
- Webhook service for event notifications
- Data export (JSON, CSV, Markdown)
- CORS support for external apps

This completes all features from OSINT research!
2026-01-30 03:53:49 +00:00
Elie Habib a52c21ff6e docs: update OSINT research - all features implemented 2026-01-30 03:43:05 +00:00
Elie Habib ef76e055c9 feat: add automated report generation service
Create src/services/report-generator.ts:
- Daily, weekly, and incident intelligence reports
- Calculate metrics: signal count, severity breakdown, top regions/categories
- Generate sections: executive summary, critical items, regional analysis, trends, recommendations
- Export formats: markdown, JSON
- Quick report for dashboard integration

This addresses Priority 4.1 (Automated report generation) from OSINT research.
2026-01-30 03:42:50 +00:00
Elie Habib 67596dbe07 feat: add war analysis tools (SALW tracking, military equipment)
Create src/services/war-analysis.ts:
- Track military equipment by type: aircraft, tank, artillery, naval, missile, vehicle, drone
- Monitor defense activities: drills, mobilization, deployment, exercise, procurement
- Analyze threat levels based on activity scale + equipment count
- 4 monitored regions: Ukraine, Middle East, East Asia
- Arms transfer tracking (simplified SIPRI-style)
- Convert to threat signals for intelligence feed

This addresses Priority 3.3 from OSINT research.
2026-01-30 03:40:58 +00:00
Elie Habib 451a5a1a6b feat: add internet infrastructure awareness service
Create src/services/infrastructure-map.ts:
- Track IXPs, data centers, cable landings, gateways for 10 regions
- Infrastructure report per region (active/degraded/offline counts)
- Detect cable integrity concerns (Red Sea, Taiwan Strait)
- Convert to threat signals for intelligence feed
- 20+ nodes tracked across monitored countries

This addresses Priority 2.1 from OSINT research.
2026-01-30 03:33:31 +00:00
Elie Habib 43d603a6e8 feat: implement Correlation Engine 2.0
Create src/services/correlation-engine.ts:
- Temporal correlations (events within same time window, shared keywords)
- Spatial correlations (events within 500km)
- Thematic correlations (3+ events sharing theme)
- Cascade detection (A happens → B happens 6-48h later)
- Temporal patterns (recurring events detection)
- Geographic clustering (events within 100km)

Converts correlations to threat signals for intelligence feed.

This is the "brain" that finds hidden connections between events.
2026-01-30 03:32:26 +00:00
Elie Habib a39f10f3a3 feat: add social media sentiment tracking service
Create src/services/sentiment-tracker.ts:
- Tracks sentiment shifts in 8 monitored countries (US, Russia, Ukraine, Iran, Israel, China, Turkey, Saudi Arabia)
- Keyword-based analysis with alert keyword monitoring
- Detects anomalies (sudden negative shifts, trending alert terms)
- Converts to threat signals for intelligence feed
- GetSentimentSummary() for dashboard overview

This addresses Priority 1.3 from OSINT research.
2026-01-30 03:31:29 +00:00
Elie Habib 285c421769 feat: add AI-generated content detection service
Create src/services/ai-detection.ts:
- Check for AI generation artifacts (EXIF, GPS, lighting, edges, patterns)
- Bellingcat\'s seven deadly sins of bad OSINT
- Quick verification checklist for content validation
- Helps users distinguish real news from AI-generated/propaganda

This addresses Priority 1.2 (Disinformation Detection) from OSINT research.
2026-01-30 03:17:46 +00:00
Elie Habib 5b9d232a51 docs: update OSINT research to mark implemented features 2026-01-30 03:15:57 +00:00
Elie Habib b88666c5c5 feat: add verification checklist UI based on Bellingcat OSH framework
- Create src/components/VerificationChecklist.ts
- 8-point verification checklist: recency, geolocation, source, cross-reference, AI detection, recycled footage check, metadata, context
- Score calculation (0-100%) with verdicts: verified/likely/uncertain/unreliable
- Notes section for manual verification notes
- Ready to wire into intelligence feed for content verification

This helps users distinguish real news from propaganda and recycled footage.
2026-01-30 03:15:03 +00:00
Elie Habib 817479c445 feat: implement NASA FIRMS satellite fire detection
- Create src/services/firms-satellite.ts for NASA Fire Information API integration
- Fetch active fires, thermal anomalies in monitored regions (Ukraine, Iran, Israel/Gaza, etc.)
- Detect fire anomalies vs baseline activity
- Convert fire data to threat signals for intelligence feed
- Update OSINT_RESEARCH.md to mark as IMPLEMENTED

This adds satellite imagery capability to detect fires, construction, and thermal
activity in conflict zones - a key differentiator from other OSINT tools.
2026-01-30 03:14:03 +00:00
Elie Habib e25eaa0f5a research: OSINT tools analysis and improvement ideas
Researched top OSINT tools (Spiderfoot, Shodan, Babel X, Censys) and war analysis
methodologies to identify improvement opportunities for World Monitor.

Priority improvements identified:
- Satellite imagery integration (NASA FIRMS)
- Disinformation detection (Bellingcat framework)
- Social media sentiment tracking
- Internet infrastructure awareness
- Verification checklist UI

Created OSINT_RESEARCH.md with detailed analysis and next steps.
2026-01-30 03:09:26 +00:00
Elie Habib 8f4cba06e5 feat(worldmonitor): 10 initiatives - launch prep and enhancements
COMPLETED INITIATIVES:

1. **Critical RSS Feeds** - Added 20+ feeds (RUSI, Chatham House, CFR, FAO, etc.)
2. **CII Trends** - 7-day/30-day rolling baselines with trend detection
3. **Trending Stories** - Analytics panel with localStorage persistence
4. **Launch Copy** - Finalized Twitter, LinkedIn, Reddit, Product Hunt materials
5. **Product Hunt** - Submission ready with tagline, bullets, screenshots
6. **Reddit Posts** - Ready for r/cybersecurity, r/INTELLIGENCE, r/geopolitics
7. **OG Meta Tags** - Dynamic meta tags for story sharing + Twitter Cards
8. **Story Templates** - 6 templates (analysis, crisis, brief, markets, compare, trend)
9. **Deep Link Router** - /story?c=UA routing to open country stories
10. **Self-Review Logging** - DEVELOPMENT_LOG.md per @jumperz pattern

FILES ADDED:
- src/services/meta-tags.ts - Dynamic OG/Twitter meta tags
- src/services/story-templates.ts - Template configurations
- src/services/cii-trends.ts - CII trend tracking
- src/services/trending-stories.ts - Story analytics
- 10_INITIATIVES.md - Initiative tracker
- ENHANCEMENT_PLAN.md - Comprehensive plan
- DEVELOPMENT_LOG.md - Self-review log

FILES MODIFIED:
- src/config/feeds.ts - Added 20+ RSS feeds
- api/rss-proxy.js - Added domains to allowlist
- src/main.ts - Initialize meta tags
- src/App.ts - Deep link handling
- index.html - OG meta tags
- LAUNCH_MATERIALS.md - Final launch copy

READY FOR LAUNCH!
2026-01-29 20:58:17 +00:00
Elie Habib ee18354d3e feat: add temporal anomaly detection service
- Detect when activity levels deviate from historical baselines
- Z-score calculation for military, vessels, protests, news
- Alert when activity is 1.5x-3x normal for the time period
- Supports weekday and month-specific baselines
2026-01-29 20:49:19 +00:00
Elie Habib 653d35c3f3 feat: enhance story sharing with templates, deep links, and multi-platform sharing 2026-01-29 20:48:30 +00:00
Elie Habib 20f5134d96 WhatsApp share: copy image to clipboard + open web.whatsapp.com
Deep links can't send images. Now: mobile uses Web Share API (sends
image natively), desktop copies image to clipboard then opens WhatsApp
Web so user can paste it.
2026-01-30 00:32:00 +04:00
Elie Habib fcc07c66ad Use https:// URL in WhatsApp share for clickable link 2026-01-30 00:30:10 +04:00
Elie Habib f0e495451b Fix WhatsApp deep link: only fallback to wa.me if app didn't open 2026-01-30 00:27:18 +04:00
Elie Habib 2f48d14c47 Fix WhatsApp share: use deep link + auto-download image
Try whatsapp:// deep link first (opens native app), fallback to wa.me.
On desktop: auto-downloads the image then opens WhatsApp so user can attach.
2026-01-30 00:25:51 +04:00
Elie Habib 794ab5fe51 Hide CountryIntelModal before opening story 2026-01-30 00:24:45 +04:00
Elie Habib 7f0c654eeb Prevent toast stacking on repeated clicks 2026-01-30 00:22:09 +04:00
Elie Habib 26309b5641 Gate story generation on data readiness
Block story rendering when data sources haven't loaded yet
(hasSufficientData check + news cluster check). Shows toast
notification instead of generating an incomplete/stale story.
2026-01-30 00:21:20 +04:00
Elie Habib 76f350be55 Fix story modal z-index and convergence score text overlap
- Story modal z-index 9999→10001 so it renders above CountryIntelModal (10000)
- Measure convergence score text width before changing font context
2026-01-30 00:19:31 +04:00
Elie Habib 96d99cf2f2 Improve story cards: fix % bug, add signals/convergence, share to WhatsApp/Instagram
- Fix prediction market % (yesPrice already 0-100, remove *100)
- Add Active Signals section (protests, military flights/vessels, outages)
- Add Signal Convergence section (score, signal types, regional descriptions)
- Show 24h CII change, component mini-bars, grid background
- Share options: Save PNG, WhatsApp, Instagram (Web Share API + fallbacks)
- Add share story button to CountryIntelModal (map country click)
- Fix: slashW measured with correct font, sync blob creation, footer overflow guard
- Bump to v2.1.3
2026-01-30 00:11:27 +04:00
Elie Habib c5b683212f Switch story rendering to client-side Canvas (WASM not allowed in Vercel Edge) 2026-01-29 23:49:48 +04:00
Elie Habib 5adefb1d76 Add World Stories: shareable vertical country intelligence snapshots via @vercel/og 2026-01-29 23:42:03 +04:00
Elie Habib 6f0c111d6c Remove BREAKING badges from hotspots, add 30s pulse ring on news location markers 2026-01-29 22:31:48 +04:00
Elie Habib e84488d971 Rate-limit AI classification queue, cache vessel counts in localStorage
- Throttle classifyWithAI to 2 concurrent requests with 300ms delay
- Pause queue for 60s on 429, flush pending jobs to avoid memory leak
- Cache vessel theater counts in localStorage (30min TTL) so page
  refresh instantly restores vessel data while AIS stream re-accumulates
2026-01-29 22:00:14 +04:00
Elie Habib b63ec5b7fd Add CII boost to theater posture, lower Iran naval thresholds
Theater posture now considers Country Instability Index: CII ≥85 forces
critical, ≥70 forces elevated. Fixes Iran Theater appearing lower than
Baltic despite CII 92 and active crisis reporting. Also lowered Iran
naval thresholds (elevated:2, critical:5) to account for poor AIS
coverage in Persian Gulf where military vessels routinely go dark.
2026-01-29 21:27:17 +04:00
Elie Habib 653b40192a Add category tag badge to news items from threat classification
Shows classified event category (Conflict, Cyber, Protest, etc.) as a
colored badge on each news item, leveraging the Groq LLM classification
pipeline. Color matches threat level. General category hidden to reduce noise.
2026-01-29 19:58:50 +04:00
Elie Habib 79368dc4e0 Add AI threat classification, map progressive disclosure, and bug fixes
- Hybrid keyword + Groq LLM classification with Redis cache (24h TTL)
- Progressive disclosure: bases/nuclear/datacenters hidden at low zoom
- Label deconfliction: BREAKING badges suppress overlaps by priority
- Zoom-adaptive opacity and marker sizing for reduced visual clutter
- Fix unbounded summary growth in alert merging (cap at 3 items)
- Fix Strategic Risk Panel showing "Insufficient Data" on startup
- Remove dead renderLimitedData code
- Expand README with algorithms, architecture, and new features
- Bump version to 2.1.2
2026-01-29 19:29:40 +04:00
Elie Habib a0bc560daf Lower theater posture thresholds for open-source tracking reality, add localStorage caching
Thresholds were calibrated for classified-level data visibility (50+ aircraft for
Iran ELEVATED). Open-source trackers (OpenSky/Wingbits) see ~10-20% of actual
military flights. Lowered all theaters ~5-6x to match real detection rates.

Added localStorage persistence to cached-theater-posture so the Strategic Posture
panel shows last-known data instantly on page reload instead of starting empty.
2026-01-29 16:40:56 +04:00
Elie Habib 08e11c2e17 Add news geo-location map markers and country prediction markets
Wire geo-hub keyword matching into RSS feed parsing to attach lat/lon
to news items, aggregate locations at cluster level, and render as
ScatterplotLayer on the deck.gl map colored by threat level.

Add fetchCountryMarkets() to Polymarket service with country-to-tag
mapping and variant matching, shown in CountryIntelModal with yes/no
bars when clicking a country.
2026-01-29 16:06:28 +04:00
Elie Habib d40dc246ae Improve Polymarket: tag-based event queries, clickable links
Switch from volume-only market fetching to tag-based event queries
for better relevance. Variant-aware tags (geopolitical vs tech).
Deduplicates across tag queries, falls back to top-volume markets
only when needed. Add clickable links to Polymarket event pages.
2026-01-29 15:19:31 +04:00
Elie Habib f5348e319d Update world monitor screenshot 2026-01-28 14:52:12 +04:00
Elie Habib aa1e7ca066 Sort news by threat severity instead of chronological, remove filter UI
Headlines now ranked critical→high→medium→low→info with time as tiebreaker.
Removed filter buttons, threat badges, and sort toggle — cleaner panel.
2026-01-27 23:50:44 +04:00
Elie Habib 7857bb9e2b Add threat level classification system with keyword classifier, UI badges, and filters
- New threat-classifier.ts: 5-level classification (critical/high/medium/low/info) with
  14 event categories, variant-aware keywords (tech adds outage/breach/layoff etc),
  word-boundary regex for short keywords to prevent false positives
- Wire classifier into RSS pipeline, derive isAlert from threat level (backward compat)
- Aggregate threat at cluster level (max level, most frequent category, tier-weighted confidence)
- NewsPanel: colored threat badges, 5-button filter bar, severity sort toggle
- Persist filter/sort prefs in localStorage
2026-01-27 23:34:33 +04:00
Elie Habib 8b58af0452 Remove unreliable Kuwait symbol (only 1 data point on Yahoo) 2026-01-27 18:14:14 +04:00
Elie Habib d804dc7c85 Fix Middle East stock indices: add UAE DFM, fix Kuwait, use 1mo range
- Add DFMGI.AE for UAE (DFM General Index)
- Use range=1mo instead of 5d to handle Sun-Thu trading weeks
- Take last ~5 trading days from monthly data for weekly % change
- Remove broken Qatar symbol (^QSI returns no data)
2026-01-27 17:23:47 +04:00
Elie Habib e28c3f7123 Remove broken Yahoo Finance symbols (AE, VN, NG, KE, CO, PK, BD, GR, RO)
Yahoo Finance doesn't carry indices for UAE, Vietnam, Nigeria, Kenya,
Colombia, Pakistan, Bangladesh, Greece, or Romania.
2026-01-27 17:20:19 +04:00
Elie Habib 67d06ebbf4 Add stock market index to country intel + pause renders during modal
- Stock index API endpoint (Yahoo Finance, Redis 1h cache, ~50 countries)
- Stock chip in modal: shows weekly % change with green/red styling
- Pause deck.gl renders while country modal is open (fixes flickering)
- Suppress signal modal clicks while country modal is visible
- Single stock fetch reused for both UI chip and AI brief context
2026-01-27 17:14:43 +04:00
Elie Habib 97f3d75249 Comprehensive country intel: geo-based military counts, richer context, expanded prompt
- Military signals now counted by geographic bounding box (lat/lon in country)
  instead of operator flag — matches what posture panel shows
- Add country aliases for headline matching (Israel → israeli, gaza, hamas, idf...)
- Feed convergence scores + regional alerts to AI context
- Expanded prompt: 5 sections, 300-400 words, military posture analysis,
  regional context, cross-signal correlation
- Bump max_tokens 500→900, cache version ci-v1→ci-v2
- Add 25+ country bounding boxes for geo-matching
2026-01-27 16:56:39 +04:00
Elie Habib b32674df10 UX: country hover highlight + instant loading feedback on click
- Add transparent interactive fill layer for country hover detection
- Hover shows subtle highlight + pointer cursor so user knows it's clickable
- Show loading modal immediately on click (before geocode completes)
- Dismiss modal if geocode fails (ocean click etc)
2026-01-27 16:49:56 +04:00
Elie Habib 1292f0595f v2.1.0: Country click → AI intelligence brief
Click empty map area to detect country via Nominatim reverse geocoding,
highlight it with GeoJSON boundaries, and show an AI-generated intel
brief (Groq, Redis-cached 2h) with CII score, active signals, and
contextual news headlines.
2026-01-27 16:43:52 +04:00
Elie Habib fd547bbc15 Suppress map location flashes during initial news load
Prevents the jarring effect of all detected locations flashing
simultaneously when the app first loads. Flashes only trigger
after the initial feed collection completes.
2026-01-27 16:03:50 +04:00
Elie Habib cbd55c4d08 Factor naval vessels into theater posture level calculation
Added navalThresholds per theater and recalcPostureWithVessels() that
uses "either triggers" logic: if aircraft OR vessels exceed their
respective thresholds, the theater level escalates. Previously only
aircraft counts were used, so 40 vessels + 10 aircraft showed "normal".
2026-01-27 10:22:00 +04:00
Elie Habib 9399fd89ee Fix map navigation: combine flyTo + zoom into single call
DeckGLMap.setCenter() uses maplibreMap.flyTo() (animated, 500ms) but
callers immediately followed with setZoom() which interrupted the
animation. Added optional zoom param to setCenter() and updated all
callers in App.ts to use setCenter(lat, lon, 4) instead of separate calls.
2026-01-27 09:42:26 +04:00
Elie Habib c94f225a04 Remove SVA (Saudi Arabian Airlines) from military callsign regex
SVA is Saudia's ICAO code - was causing false positive military
classification for civilian Saudi flights.
2026-01-27 09:33:23 +04:00
Elie Habib 60070e8685 Cherry-pick improvements from PRs #33, #34, #35 with fixes
- PR #33: Disambiguate NAVY callsign by origin country (US vs UK)
- PR #34: Use typecode fallback in determineAircraftInfo + Wingbits
  enrichment (guarded: only when type is still 'unknown')
- PR #35: Persist typeCode in enriched data (skip the broken
  pre-filter removal and false-positive regex from original PR)

Closes #33, #34, #35
2026-01-27 09:31:13 +04:00
Elie Habib 3ac09ba37e Add comprehensive debug logging for map navigation handler 2026-01-27 09:25:07 +04:00
Elie Habib 8cebd9e918 Add debug logging to trace handler setup 2026-01-27 09:20:31 +04:00
Elie Habib 766529a9f6 Add debug logging to theater click + map setCenter 2026-01-27 09:09:55 +04:00
Elie Habib 745694e236 Fix map setCenter calculation - remove incorrect zoom division
The pan formula was incorrectly dividing by zoom:
  pan.x = width/(2*zoom) - pos[0]  // Wrong

Corrected to:
  pan.x = width/2 - pos[0]  // Correct

The zoom-independent formula ensures clicking theaters in Strategic
Posture panel correctly centers on that region at any zoom level.
2026-01-27 09:08:49 +04:00
Elie Habib c3c2fd7341 Fix military detection false positives and code quality
- Remove CCA from MILITARY_PREFIXES (Air China airline, not military)
- Remove duplicate IAF entry from Middle East section
- Move AIRLINE_CODES to module level Set for O(1) lookup
- Remove duplicate entries (ELY, THY, SWR)
2026-01-27 08:33:38 +04:00
Elie Habib 2801453057 Add ADS-B Exchange military aircraft database
- 20,396 verified military hex IDs from adsbexchange.com
- Updated daily by ADS-B Exchange community
- O(1) lookup using Set
- Combines with callsign detection for comprehensive coverage
2026-01-27 08:21:20 +04:00
Elie Habib a7126f3ad2 Simplify hex ranges to high-confidence military blocks
Focus on ranges where military/civilian separation is clear:
- US (AE/AF), UK, France, Germany, NATO, Russia, China, Australia, Japan

For other countries, rely on callsign detection since hex ranges
include commercial aircraft mixed with military.
2026-01-27 08:14:29 +04:00
Elie Habib 5719075866 Refine military hex ranges to exclude commercial
Previous ranges were too broad, catching commercial airlines.
Now using confirmed military-only blocks based on OSINT sources.
2026-01-27 08:11:53 +04:00
Elie Habib 8f667b26b1 Add ICAO hex range detection for military aircraft
Military aircraft have specific transponder ID ranges by country.
This matches how OpenSky UI identifies military aircraft.
Now checks both callsign patterns AND hex ranges.

Ranges added for: USA, UK, France, Germany, Italy, NATO, Israel,
Saudi, UAE, Qatar, Turkey, Russia, China, Iran, India, Pakistan,
Australia, Japan, South Korea
2026-01-27 08:09:23 +04:00
Elie Habib aad378bc29 Fix military detection false positives
- Remove UAE from military prefixes (conflicts with Emirates airline)
- Use more specific UAEAF, BAHAF, OMAAF for Gulf military
- Expand airline exclusion list with major carriers worldwide
- This improves accuracy by removing commercial flight false positives
2026-01-27 08:06:37 +04:00
Elie Habib f241adca64 Expand military callsign detection
- Add more US military prefixes (ARMY, NAVY, USAF, OPS, etc.)
- Add Middle East military prefixes (RSAF, UAE, EMIRI, etc.)
- Add NATO tactical callsigns (SWORD, LANCE, etc.)
- Add short tactical pattern (3 letters + 1-2 digits) with airline exclusion
- Exclude known airline codes (SVA, QTR, THY, etc.) from short patterns
2026-01-27 07:59:05 +04:00
Elie Habib cd3369bf29 Fix isMilitaryCallsign - remove overly broad regex patterns
The patterns [A-Z]{2,3}\d{3,4} and [A-Z]{3,4}\d{2,4} were matching
commercial airlines like PGT5873, IAW9011, IZG3020. New pattern only
matches 4+ letter callsigns like DUKE01, VIPER12 which are typical
military tactical callsigns.
2026-01-27 07:52:25 +04:00
Elie Habib 175a34d5cc Fix Wingbits field mapping - use short field names (h, f, la, lo, ab, th, gs)
BUG FIX: Wingbits API returns short field names like 'h' for icao24,
'f' for flight, 'la' for latitude, etc. The previous code was looking
for long names like 'icao24', 'callsign', 'latitude' which don't exist
in the response, causing all flights to be filtered out.
2026-01-27 07:49:40 +04:00
Elie Habib 3f3bbd3e3d Add Wingbits as fallback flight data source when OpenSky fails
- Add /flights and /flights/batch endpoints to wingbits proxy
- Add fetchMilitaryFlightsFromWingbits() to theater-posture API
- Try OpenSky first, fallback to Wingbits on 429/failure
- Transform Wingbits data to match existing flight format
- Add 'source' field to response ('opensky' or 'wingbits')
2026-01-27 07:22:51 +04:00
Elie Habib 2082613c28 Improve theater posture caching and fix Times of Israel feed
- Switch Times of Israel from Railway to Vercel RSS proxy (already allowlisted)
- Increase stale cache TTL from 1 hour to 24 hours
- Add 7-day backup cache as last resort during prolonged outages
- Better error handling: try stale → backup → error
2026-01-27 07:09:33 +04:00
Elie Habib a4059ab2da Unify loading indicators with radar-style across all panels
- Replace base Panel.showLoading() with radar sweep animation
- Add customizable message parameter to showLoading(message)
- Update CIIPanel to use unified loader
- Add panel-loading CSS classes matching Strategic Posture style
- Consistent green radar aesthetic across all loading states
2026-01-27 06:46:28 +04:00
Elie HabibandGitHub 02fbb2335b Use consistent panel-order storage key and consolidate into constant (#32)
### Motivation
- Ensure the variant-change reset removes the same panel-order key used
across the app and centralize the key to avoid future mismatches.

### Description
- Added a `PANEL_ORDER_KEY` constant (`'panel-order'`) to `src/App.ts`
and used `this.PANEL_ORDER_KEY` for reads/writes instead of the
scattered literals; replaced the mistaken `'worldmonitor-panel-order'`
removal with the unified key and updated migration code and
`getSavedPanelOrder`/`savePanelOrder`.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8c537cc832eb4ef5d877cb282d6)
2026-01-26 23:14:10 +04:00
Elie HabibandGitHub 7c591e8504 Ensure map URLs record empty layer selections (#30)
### Motivation
- Map share URLs must unambiguously reflect when every layer is disabled
so viewers get the exact layer state.
- Empty or missing `layers` query values were previously ambiguous, so
the URL builder and parser need to explicitly encode and interpret that
state.

### Description
- `parseMapUrlState` now treats a present `layers` parameter with value
`''` or `'none'` as “all off” and sets every key in `LAYER_KEYS` to
`false`, while preserving the existing fallback behavior when the
parameter is omitted.
- `buildMapUrl` always emits a `layers` query parameter, using `none`
when no layers are active and otherwise joining active keys with commas.
- The parser normalizes and trims the `layers` value before splitting to
robustly handle whitespace and empty tokens.

### Testing
- No automated tests were run.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8cdf7dc832eae02f128875f8fed)
2026-01-26 23:13:11 +04:00
Elie HabibandGitHub d43d698c56 Expand map layer URL keys in parsing and serialization (#31)
### Motivation
- Ensure share URLs fully capture map layer state by including every key
from the `MapLayers` type (geopolitical and tech layers).
- Keep `parseMapUrlState` and `buildMapUrl` consistent so both parsing
and serialization cover the same complete set of layers.

### Description
- Expanded the `LAYER_KEYS` array in `src/utils/urlState.ts` to include
`ais`, `protests`, `military`, `spaceports`, `minerals`, `startupHubs`,
`cloudRegions`, `accelerators`, `techHQs`, and `techEvents` so it
matches `MapLayers`.
- `parseMapUrlState` continues to use `LAYER_KEYS` to populate a
`MapLayers` object from the `layers` query param, now covering the full
set.
- `buildMapUrl` uses `LAYER_KEYS` to serialize active layers into the
`layers` query param, ensuring all layer flags are represented in share
URLs.
- Change is contained to `src/utils/urlState.ts` and committed.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8c992e0832eb1acc9f4dd730f11)
2026-01-26 23:12:57 +04:00
Elie Habib 7d9c61e8ec Fix panel order storage key usage 2026-01-26 23:07:25 +04:00
Elie Habib f507479761 Expand map layer URL keys 2026-01-26 23:06:49 +04:00
Elie Habib 95cb966b9d Ensure map URLs record empty layers 2026-01-26 23:06:13 +04:00
Elie Habib 82c3eb376a Fix coordinate truthy check - 0 is a valid coordinate
The View Region button was navigating to wrong locations because
`if (lat && lon)` returns false when either coordinate is 0.
Changed to typeof check to properly handle all valid coordinates.
2026-01-26 23:02:28 +04:00
Elie Habib 2b66e6b612 Add debug logging for banner View Region click 2026-01-26 22:50:59 +04:00
Elie Habib 31471b19af Fix aircraft type classification defaulting to fighter
- Change default from 'fighter' to 'unknown' for unrecognized callsigns
- Add 'unknown' count to aircraft breakdown
- Show bombers, transport, drones, and 'other' in summary
- Fixes misleading '179 fighters' when most were unclassified
2026-01-26 22:46:39 +04:00
Elie Habib ad4053e069 Streamline README for directory submissions
- Condense README from 3,896 lines to ~230 lines
- Move full documentation to docs/DOCUMENTATION.md
- Add social badges (stars, forks, license, last commit)
- Add 'Why World Monitor?' value proposition table
- Add Quick Start one-liner
- Use collapsible sections for data layers
- Add 'Support the Project' call to action
- Link to full docs throughout
2026-01-26 22:40:32 +04:00
Elie Habib b1b5c35a61 Update live channels: add CNBC to main, remove TBPN from tech 2026-01-26 22:31:51 +04:00
Elie Habib a260c5a82b Update NASA TV live feed video ID 2026-01-26 22:23:38 +04:00
Elie Habib b6739ce100 Fix Al Jazeera and Al Arabiya live feeds
Add useFallbackOnly flag to skip auto-detection for channels
where it returns wrong video IDs. These channels will now
always use their hardcoded fallback video IDs.
2026-01-26 22:22:48 +04:00
Elie Habib c588476dbe Fix Tech Readiness panel: actually call refresh()
Panel was created but refresh() was never called - data never loaded.
Added to loadAllData() tasks for tech variant.
2026-01-26 22:17:36 +04:00
Elie Habib 66cc9faaa3 Add engaging loading state for Tech Readiness panel
- Multi-step progress showing 4 World Bank indicators being fetched
- Animated globe with spinning ring
- Staggered pulse animation on each indicator row
- Shows "Analyzing 200+ countries" context
2026-01-26 22:13:08 +04:00
Elie Habib 9b6415cb7e Fix zoom button click events in DeckGL map
- Use event delegation instead of individual listeners
- Add explicit pointer-events and z-index to buttons
2026-01-26 22:10:00 +04:00
Elie Habib 5e325fa478 Remove AI provider badge from World Brief 2026-01-26 22:05:25 +04:00
Elie Habib 26158f7cbb Fix monitor keyword matching: use word boundaries
"ai" was matching "train", "remains", "afraid" as substrings.
Now uses regex word boundaries so "ai" only matches standalone "AI".
2026-01-26 21:59:26 +04:00
Elie Habib ae23af799b Improve monitors: search descriptions, show match count
- Search both title and description for better keyword coverage
- Deduplicate results (article matching multiple monitors shown once)
- Show match count: "3 matches" or "Showing 10 of 25 matches"
- Show search scope when no matches: "No matches in X articles"
2026-01-26 21:58:46 +04:00
Elie Habib f181f7bfc0 Hide status banner when all is well
Only show banner during learning mode - no need to announce normalcy.
2026-01-26 21:54:38 +04:00
Elie Habib 50ea697a98 Remove cache status from Strategic Risk panel UI
Implementation details like "Using cached scores" shouldn't be shown to users.
2026-01-26 19:43:44 +04:00
Elie Habib a5bacd9c53 Add tooltip hint for clickable theaters 2026-01-26 17:42:45 +04:00
Elie Habib 5a9d72488e Fix panel order migration and emoji display
- Add v1.9 migration with strategic-posture in priority panels
- Add emoji fallback when no typed aircraft/vessel breakdown exists
- Use proper emoji variation selectors for airplane (✈️)
2026-01-26 17:41:28 +04:00
Elie Habib 04f3564788 Modernize panel UI and expand README documentation
UI Improvements:
- CII Panel: Replace dated bar chart emoji with modern radar scan animation
- Strategic Posture: Compact chip-based layout with air+naval on same row
- Add rotating scan ring, pulsing dot, and source chip indicators
- Inline stat display for expanded theaters (AIR/SEA domain rows)

README Documentation:
- Add Strategic Posture Analysis section with 9 theater definitions
- Document strike capability assessment thresholds
- Add naval vessel integration and classification details
- Document server-side risk score API with Redis caching
- Add CII contextual boost documentation (hotspot, news, focal)
- Update completed features list
- Add project structure entries for new components
2026-01-26 17:34:47 +04:00
Elie Habib 3e3c6713a0 Fix stale comment about boost values 2026-01-26 17:22:55 +04:00
Elie Habib 23265229bf Reduce CII boost values to prevent score inflation
- hotspotBoost: 30 → 10 max
- newsUrgencyBoost: 15 → 5 max
- focalBoost: 20 → 8 max
- Total max boost: 65 → 23 points

Prevents multiple countries from hitting 100 simultaneously
2026-01-26 15:32:59 +04:00
Elie Habib 8cd9e4f149 Fix weighted average calculation in strategic risk score
Bug: divisor returned last weight (0.40) instead of sum (3.5)
Fix: compute weights array, sum explicitly, divide correctly
2026-01-26 15:05:49 +04:00
Elie Habib d80e7a5db9 Fix ACLED API: update endpoint URL and add auth + baseline fallback
- ACLED changed API endpoint from api.acleddata.com to acleddata.com/api/
- Add Bearer token authentication via ACLED_ACCESS_TOKEN env var
- Add baseline scores fallback when ACLED is unavailable (no more 500s)
- Better error logging for auth failures
2026-01-26 14:38:28 +04:00
Elie Habib 3b71824869 Fix vessel caching bug + improve Strategic Posture loading UX
Root cause: Circuit breaker's 5-min cache AND vesselCache were both
caching empty results, blocking fresh data from showing for 5 minutes.

Fixes:
- Add clearCache() method to circuit breaker
- Don't cache empty vessel results in vesselCache
- Clear both caches when stream initializes
- Clear both caches when first vessel arrives
- Add re-augmentation at 90s and 120s (in addition to 30s/60s)

UX improvements:
- Add live elapsed time counter during loading
- Add "30-60 seconds" guidance note
- Stop timer on render/error/destroy (no memory leaks)

Also fixes focal point headlines showing irrelevant content by
only including headlines where entity name appears in title.
2026-01-26 14:24:28 +04:00
Elie Habib 9ed33b99ef Add stale cache fallback for risk-scores API when ACLED fails 2026-01-26 13:43:43 +04:00
Elie Habib 43d59d26cc Fix vessel timing: re-augment vessels at 30s and 60s after load 2026-01-26 13:41:34 +04:00
Elie Habib 158925ef41 Rename Strategic Posture to AI Strategic Posture, move after AI Insights 2026-01-26 13:39:37 +04:00
Elie Habib f56b2dcf52 Make focal point headlines clickable - links to source article 2026-01-26 13:38:39 +04:00
Elie Habib 93a432a5c9 Fix vessel tracking: include unknown types in naval count, add debug logging 2026-01-26 13:33:34 +04:00
Elie Habib e9e838b455 Fix bugs found in code review: missing isStale, variable shadowing, stale timestamp 2026-01-26 13:23:03 +04:00
Elie Habib 1b4d9b9157 Fix wingbits API: add explicit routes for nested paths (Vercel catch-all limitation) 2026-01-26 13:18:51 +04:00
Elie Habib 9467f79b15 Use Railway relay for OpenSky to avoid rate limits 2026-01-26 13:14:58 +04:00
Elie Habib 37d60d20c7 Clarify git branch rule: merging requires permission, pushing to current branch is OK 2026-01-26 13:12:55 +04:00
Elie Habib bcd774c888 Improve theater posture resilience and UX
- Add stale cache fallback when OpenSky is rate-limited (1h TTL)
- Return cached data instead of 500 error when API fails
- Improve empty/error state messages with context
- Add retry buttons to no-data states
- Show stale data warning when using cached fallback
2026-01-26 13:09:24 +04:00
Elie Habib 241a6dae85 Add critical git branch rule: never push to different branch without permission 2026-01-26 13:06:53 +04:00
Elie Habib 76af54caa3 Add Yemen/Red Sea theater (Houthi/Bab el-Mandeb) 2026-01-26 13:01:08 +04:00
Elie Habib 7ccbbf2f19 Add Israel/Gaza theater, adjust E.MED bounds 2026-01-26 12:58:21 +04:00
Elie Habib e83ea1a508 Add 3 more strategic theaters for better global coverage
- Korean Peninsula (covers Korean DMZ area)
- South China Sea (disputed waters)
- Eastern Mediterranean (Israel/Syria/Lebanon)

Bump cache key to v2 to invalidate old 4-theater cache.
2026-01-26 12:56:19 +04:00
Elie Habib 8565483826 Remove dead code: REAPER already handled by drone pattern 2026-01-26 12:53:11 +04:00
Elie Habib 7e9251a396 Fix critical bugs in theater posture feature
- Add missing byOperator to API response (was causing crash)
- Deep clone postures to prevent cached data mutation
- Add drone detection patterns (RQ/MQ/REAPER/PREDATOR)
- Separate drone from reconnaissance in callsign detection
2026-01-26 12:41:19 +04:00
Elie Habib 98aa4a1262 Fix posture panel: add missing vessel type and UI rows
- Add 'auxiliary' to vessel type filter (was missing supply ships)
- Add auxiliary vessels row to NAVAL section UI
- Add drones row to AIR section UI
2026-01-26 12:31:56 +04:00
Elie Habib 4e0e88d223 Unify theater posture: combine aircraft + naval vessels
- Add vessel fields to TheaterPostureSummary type (destroyers, frigates,
  carriers, submarines, patrol, auxiliaryVessels, totalVessels)
- Update API to return theater bounds for client-side vessel matching
- Panel fetches vessels client-side (AIS WebSocket) and merges with
  server-side aircraft data (OpenSky API)
- Display shows AIR and NAVAL sections when vessels detected
- Add posture-section-label CSS for section headers
2026-01-26 12:20:44 +04:00
Elie Habib 40cc5d7c7d Add bash guidelines to avoid output buffering issues
Document best practices for avoiding pipe buffering problems
with head/tail/less commands in bash operations.
2026-01-26 11:30:18 +04:00
Elie Habib d0eabdf3c8 Add timeout to OpenSky fetch in theater-posture API 2026-01-26 11:08:58 +04:00
Elie Habib 4247ba70b8 Add server-side caching for theater postures
- Create /api/theater-posture.js endpoint that fetches military flights
  from OpenSky, calculates theater postures, and caches in Upstash Redis
- Add cached-theater-posture.ts client service with deduplication
- Update StrategicPosturePanel to fetch from cached endpoint independently
- Update App.ts to use cached postures for critical alert banner

Benefits: Theater posture calculation shared across all users via Redis
cache (5-min TTL), panel works without military layer enabled
2026-01-26 09:55:36 +04:00
Elie Habib 69662cf28c Add Strategic Posture feature: theater-based military buildup detection
- Add theater posture aggregation to military-surge.ts (Iran, Taiwan, Baltic, Black Sea)
- Add critical alert banner for elevated/critical military buildups
- Create StrategicPosturePanel component showing aircraft breakdown by type
- Integrate theater context into AI Insights prompts
- Enable military layer by default
- Add strike capability detection (tankers + AWACS + fighters threshold)

Theaters detect buildup thresholds: 50 aircraft (elevated), 100 (critical)
Strike capable when tankers >= 10, AWACS >= 2, fighters >= 30
2026-01-26 09:07:49 +04:00
Elie Habib 01ce2ed2a4 Remove debug text from ML DETECTED section title 2026-01-25 23:55:50 +04:00
Elie Habib 4f1c0cf5a2 Change WORLD BRIEF to TECH BRIEF for tech variant
Make InsightsPanel header variant-aware:
- Tech variant: 🚀 TECH BRIEF
- Full variant: 🌍 WORLD BRIEF (unchanged)
2026-01-25 23:50:06 +04:00
Elie Habib 3740359732 Fix panel summary cache: add variant + version to bust stale caches 2026-01-25 23:42:28 +04:00
Elie Habib b6d333c451 Tech variant: use tech-focused AI prompts, ignore politics
- Remove Trump mention from date context for tech variant
- Add variant-aware system prompts:
  - Tech: "Focus ONLY on technology, startups, AI, funding..."
  - Tech: "IGNORE political news, trade policy, tariffs..."
- Use tech-appropriate examples: "OpenAI announced...", "Series B..."
- Bump cache version to v3 to force new summaries
2026-01-25 23:39:38 +04:00
Elie Habib 722bbd5a94 Fix Disrupt Africa feed (redirects to rate-limited URL) 2026-01-25 23:34:32 +04:00
Elie Habib 03a778aa79 Bust stale summary cache with version prefix (v2) 2026-01-25 23:33:03 +04:00
Elie Habib 2870b22bd2 Tech variant: skip Intel sources (defense/military news)
INTEL_SOURCES (Defense One, Breaking Defense, The War Zone, Janes,
Bellingcat) was being fetched for all variants and added to allNews,
which fed geopolitical content to AI insights in tech variant.

Now gated by SITE_VARIANT === 'full'.
2026-01-25 23:23:09 +04:00
Elie Habib 9123a6fbc6 Tech variant: gate geopolitical panels + add TechReadinessPanel
- Gate gdelt-intel, cii, cascade, strategic-risk panels to full variant only
- Wire up TechReadinessPanel (was implemented but never connected):
  - Add to TECH_PANELS config with priority 1
  - Export from components index
  - Instantiate in App.ts
- Reduces console noise and unnecessary data fetching in tech variant
2026-01-25 23:20:30 +04:00
Elie Habib 5541592874 Skip geopolitical intelligence fetching for tech variant
Tech variant was fetching military flights, vessels, protests, and
outages even though it has no CII/focal point panels. This wasted
bandwidth and polluted console logs.

Changes:
- Gate loadIntelligenceSignals() to only run for SITE_VARIANT='full'
- Gate scheduled intelligence refresh to only run for full variant
- This stops: military flights, military vessels, protests (ACLED/GDELT),
  outages, and AIS shipping data fetching for tech variant

Tech variant now only fetches data relevant to tech/startup news.
2026-01-25 23:13:25 +04:00
Elie Habib 737753f949 Fix tech variant AI insights: skip geopolitical data, move to top
Issues fixed:
1. Tech variant insights panel was using geopolitical signal data
   (military flights, protests, AIS) which polluted AI summaries
2. Insights panel was at priority 2 (bottom) for tech variant

Changes:
- panels.ts: Move insights to priority 1 (top) in TECH_PANELS
- InsightsPanel.ts: Skip signalAggregator and focalPointDetector
  for tech variant - only summarize tech news without geo context
- App.ts: Add one-time migration to move insights to top for
  existing tech variant users

Now tech variant AI insights only analyzes tech/startup news without
geopolitical military/protest/outage correlations.
2026-01-25 23:08:38 +04:00
Elie Habib ecf3829ee0 Fix AI summary cache collision between site variants
The Redis cache key for summaries was built from headlines + mode +
geoContext but did NOT include the site variant (full vs tech). This
caused cross-site cache collisions where a summary generated for the
full variant could be returned to tech variant users (and vice versa).

Changes:
- Pass SITE_VARIANT from frontend to summarization API
- Include variant in cache key: `summary:{variant}:{hash}{geoHash}`
- Updated both groq-summarize and openrouter-summarize endpoints

Now cache keys are scoped per variant, preventing incorrect summaries.
2026-01-25 23:03:22 +04:00
Elie Habib 1bcb098b01 Add curated events fallback for major tech conferences
The dev.events RSS feed is limited to 100 items sorted by "date added"
(not event date), causing major events like STEP Dubai to be pushed out
when newer events are added. Added a curated events list as fallback
for important conferences that may fall off the RSS feed:

- STEP Dubai 2026 (Feb 11-12) - 8,000+ attendees, AI economy focus
- GITEX Global 2026 (Dec 7-11) - World's largest tech show
- TOKEN2049 Dubai 2026 (Apr 29-30)
- Collision 2026 (Jun 22-25) - Toronto
- Web Summit 2026 (Nov 2-5) - Lisbon

Curated events are deduplicated with feed data to avoid duplicates.
2026-01-25 22:59:22 +04:00
Elie Habib db0c4a6019 Restructure README: geopolitical variant before tech variant 2026-01-25 22:42:53 +04:00
Elie Habib 8f218428f1 Update branding: World Monitor v2 with AI focus
- README: Title to "World Monitor v2", AI-powered description
- index.html: Title "Global Situation with AI Insights"
- All meta tags updated (og, twitter, JSON-LD)
- Added AI keywords and features
- Updated site.webmanifest with AI branding
2026-01-25 22:38:15 +04:00
Elie Habib b223f59f5b Update README screenshot to new-world-monitor.png 2026-01-25 22:35:43 +04:00
Elie Habib 0c35c5344d Fix TypeScript errors: add TechHubActivity and GeoHubActivity to PopupData
Added missing types to the PopupData data union to support techActivity
and geoActivity popup types used in Map.ts.
2026-01-25 22:31:28 +04:00
Elie Habib bed2479de4 Merge branch 'claude/add-deckgl-visualization-D1VHX' into main
Adds DeckGL WebGL map rendering for desktop, comprehensive README
documentation, and intelligence synthesis features.
2026-01-25 22:29:25 +04:00
Elie Habib 0375796ee4 Fix Military Surge Detection documentation to match actual implementation
The original documentation incorrectly described surge detection as
operator-count based. The actual algorithm uses:
- Baseline-based detection (2x historical activity = surge)
- Separate foreign presence detection for operators outside home regions
- Theater-based grouping with 48-hour baseline window
2026-01-25 22:27:05 +04:00
Elie Habib 3f037ba38a Expand README with comprehensive documentation for intelligence features
- Add AI Insights Panel section: summarization fallback chain, headline scoring, sentiment analysis
- Add Focal Point Detector section: intelligence synthesis, scoring algorithm, urgency classification
- Add Natural Disaster Tracking section: GDACS integration, EONET merge, deduplication
- Add Military Surge Detection section: surge criteria, severity levels, news correlation
- Add Service Status Monitoring section: external service health tracking
- Add Signal Aggregator section: central signal collection, country grouping, convergence
- Add Browser-Based ML section: ONNX Runtime, fallback strategy, lazy loading
- Update Tech Stack: add deck.gl + MapLibre GL for WebGL map rendering
- Update project structure: add new services (focal-point-detector, signal-aggregator, etc.)
- Update components: add DeckGLMap, MapContainer, InsightsPanel, ServiceStatusPanel
- Update Roadmap: add recently completed features
- Add Entity Registry Architecture section: lookup indexes, entity types
2026-01-25 22:08:40 +04:00
Elie Habib 4d9972e031 Fix economic/FRED data freshness tracking in System Health panel 2026-01-25 21:49:04 +04:00
Elie Habib bd3f08614c Fix shipping/AIS data freshness tracking in System Health panel 2026-01-25 21:41:09 +04:00
Elie Habib 544c5869a5 Integrate military surge alerts with focal point correlation
- Add getFocalPointForCountry() and getNewsCorrelationContext() to focal-point-detector
- Enhance foreignPresenceToSignal to include news correlation from focal points
- Map operator countries and affected regions to ISO codes for focal point lookup
- Update SignalModal to display correlated focal points and news headlines
- Add military_surge type label and styling for the signal modal
- Add CSS for focal point and news correlation sections in intelligence findings
2026-01-25 21:38:29 +04:00
Elie Habib 6658945645 Fix map controls z-index to prevent them being covered
Increased z-index from 100 to 500 for map controls to ensure they
always appear above other map elements like overlays and canvases.
2026-01-25 20:52:04 +04:00
Elie Habib 0f495d9c7a Allow map to expand nearly full-screen (viewport - 60px) 2026-01-25 20:42:56 +04:00
Elie Habib 8db227658b Fix map popup header cutoff with sticky positioning
- Make popup header sticky so it stays visible when scrolling
- Add solid backdrop via ::before to prevent content showing through
2026-01-25 20:39:39 +04:00
Elie Habib 7d877b114c Decouple CII intelligence data from map layer visibility
- Add loadIntelligenceSignals() that ALWAYS fetches protests, military,
  outages regardless of layer visibility
- Add intelligenceCache to prevent duplicate API calls when layers enabled
- Remove redundant layer-gated initial load tasks (handled by intelligence)
- Remove redundant refresh schedules for intelligence layers
- Add surge/foreign presence detection to loadIntelligenceSignals()
- Fix missing ACLED warning status in cached loadProtests() path

This ensures CII scores are accurate even when map layers are disabled.
Previously, Iran showed S:0 (no signals) because military layer was off.
2026-01-25 20:27:30 +04:00
Elie Habib 5090c19ecb Fix map resize: increase max-height from 70vh to 90vh 2026-01-25 20:10:34 +04:00
Elie Habib 63b4d2d6bd Fix port layer projection on initial load
Wait for MapLibre 'load' event before initializing deck.gl overlay.
Previously, deck.gl layers were created before MapLibre finished
initializing its view state, causing incorrect coordinate projections
on first render. After any view change, the map would correct itself.
2026-01-25 20:08:12 +04:00
Elie Habib c81305547a Add region selector to header + render throttling for DeckGLMap
Header:
- Add Global/Americas/MENA/EU/Asia/LatAm/Africa/Oceania dropdown
- Sync with map view state bidirectionally
- Hide duplicate selector in map controls

DeckGLMap render throttling:
- Convert all updateLayers() calls to render() for debouncing
- render() uses requestAnimationFrame to batch updates
- Reduces CPU usage when multiple data updates arrive quickly

APT markers: Already present as subtle orange dots (geopolitical variant)
2026-01-25 20:03:46 +04:00
Elie Habib f278b8a0ea Align DeckGLMap with old Map.ts: clusters, ports, AIS density
- Add missing military vessel/flight cluster layers
- Add cluster tooltips and click type mappings
- Fix activity type mismatches (deployment/transport vs patrol/surveillance)
- Add congestion coloring to AIS density (orange for deltaPct >= 15)
- Add port type-based colors and tooltip icons
- Fix getHotspotLevels/setHotspotLevels to use h.name
- Fix setLayerReady to use 'active' class with layer state check
2026-01-25 20:03:46 +04:00
Elie Habib 48d19e7ccf Add GDACS global disaster alerts integration
- Create gdacs.ts service fetching from GDACS API (earthquakes, floods,
  tropical cyclones, volcanoes, wildfires, droughts)
- Filter to Orange/Red alerts only for relevance
- Merge GDACS events with EONET natural events with deduplication
- Color-code markers: red for Red alerts, orange for Orange alerts
- Prioritize GDACS data (more authoritative) over EONET for overlaps
2026-01-25 20:03:46 +04:00
Elie Habib b2505d7d4c Make APT markers smaller and more subtle (orange, no outline) 2026-01-25 19:01:14 +04:00
Elie Habib 497c4abcfb Fix weather layer: use centroid property instead of non-existent lat/lon 2026-01-25 19:00:10 +04:00
Elie Habib 9f6dcd8cbc Fix map canvas corruption on zoom: add ResizeObserver and zoomend resize trigger 2026-01-25 18:57:01 +04:00
Elie Habib 71ca68c523 Fix popup positioning: measure actual height instead of hardcoded 500px 2026-01-25 18:54:32 +04:00
Elie Habib bfb0395724 Add focal point correlation info to AI Insights tooltip 2026-01-25 18:43:55 +04:00
Elie Habib 7a1ef22f4c Improve CII tooltip: explain U:S:I components and focal point detection 2026-01-25 18:42:00 +04:00
Elie Habib 9ba4bb8ab2 Fix AlArabiya feed: use Google News fallback (Cloudflare blocks cloud IPs) 2026-01-25 18:37:58 +04:00
Elie Habib d732a7c80f Fix popup truncation at top of viewport 2026-01-25 18:35:46 +04:00
Elie Habib fdae1a4345 Add critical missing features from old Map.ts to DeckGLMap
Feature parity improvements:
- Add layer help popup with ? button (explains all layer types)
- Add LAYER_ZOOM_THRESHOLDS constant for zoom-based label visibility
- Add toggleLayer() public method to toggle layers on/off
- Make zoomIn()/zoomOut() public methods for external access
- Add military cluster data storage (militaryFlightClusters, militaryVesselClusters)
- Add getMilitaryFlightClusters() and getMilitaryVesselClusters() getters
- Store clusters in setMilitaryFlights/setMilitaryVessels methods
2026-01-25 18:33:24 +04:00
Elie Habib 36ad870fee Restore hotspot popup and pulsating animation in DeckGLMap
- Add news storage for related news lookup on hotspot click
- Add getRelatedNews() method matching old Map.ts behavior
- Fix click handler to show popup with related news + GDELT intel
- Add HTML overlays for high-activity hotspots with CSS pulsating animation
- Filter high-activity hotspots from deck.gl layer to avoid double-render
- Add escapeHtml for safety in HTML templates
- Add debug logging for panel persistence investigation
2026-01-25 18:25:50 +04:00
Elie Habib 2a2bfb8335 Fix CII showing U:0 S:0 I:0 - ingest news before focal-points-ready 2026-01-25 18:05:18 +04:00
Elie Habib 4eff2889da Bump version to 2.0.0 2026-01-25 17:58:43 +04:00
Elie Habib af7dff34ed Trigger deployment 2026-01-25 17:56:47 +04:00
Elie Habib 2dcac9a1fe CII: Wait for focal points before showing scores
Problem: CII showed cached backend scores immediately, then updated
when focal points ready - showing misleading preliminary data.

Fix:
- Show "Analyzing intelligence..." state until focal points ready
- Only calculate and render after focal-points-ready event
- Remove unused cached scores logic (simpler, more accurate)
2026-01-25 17:39:54 +04:00
Elie Habib 5bbdbaea1f Fix CII: always use local scores when forceLocal (focal points) 2026-01-25 17:37:55 +04:00
Elie Habib 53e3da6c0b Fix CII not using focal point data during learning mode
Problem: Once cached scores loaded, usedCachedScores=true forever,
blocking recalculation even when focal points became ready.

Fix: Add forceLocal parameter to refresh() - when focal-points-ready
fires, it now calls refresh(true) to force local recalculation with
focal point urgency boosts applied.
2026-01-25 17:36:34 +04:00
Elie Habib 19e7d9105d Fix LLM knowledge cutoff: add current date context to prompts
LLMs have outdated knowledge (pre-Jan 2025), causing errors like
"former President Trump" when Trump is current president.

Added dateContext to all summarization prompts with:
- Current date
- Trump is current president (second term, Jan 2025)
2026-01-25 17:34:30 +04:00
Elie Habib 3dbf60466b Fix circular dependency: move TIER1_COUNTRIES to config
Moved TIER1_COUNTRIES to src/config/countries.ts to break the cycle:
  country-instability → focal-point-detector → signal-aggregator → country-instability

Now:
  - config/countries.ts (no dependencies)
  - signal-aggregator imports from config/countries
  - country-instability re-exports for backwards compatibility
2026-01-25 17:28:20 +04:00
Elie Habib da16a41053 Fix: add focalBoost to getCountryScore() for consistency
getCountryScore() was missing the focalBoost that calculateCII() has.
This inconsistency would cause single-country lookups to return
different scores than the batch calculation.
2026-01-25 17:22:51 +04:00
Elie Habib f66a6694c1 Fix CII timing: refresh after focal points are ready
Problem: CII was calculating before FocalPointDetector.analyze() ran,
so the urgency map was empty and focal boosts weren't applied.

Fix: InsightsPanel now dispatches 'focal-points-ready' event after
analysis completes. App.ts listens and triggers CII refresh.

This ensures Iran shows elevated/high when FocalPointDetector
marks it as critical, rather than staying at 38 (green).
2026-01-25 17:20:07 +04:00
Elie Habib 13c0e9f771 Fix repetitive 'Breaking news tonight' in all summaries
Problem: Every panel summary started with 'Breaking news tonight:' because
the prompt said 'write like a news anchor opening the evening news'.

Fix: Rewrote prompts to:
- Explicitly forbid 'Breaking news', 'Good evening', 'Tonight' openings
- Remove TV news anchor framing entirely
- Instruct to start directly with subject: 'Iran's regime...', 'The US Treasury...'
- Focus on substance over style

Updated both groq-summarize.js and openrouter-summarize.js
2026-01-25 17:12:23 +04:00
Elie Habib 2ba3b9a135 Integrate FocalPointDetector intelligence into CII scoring
CII now uses focal point urgency (critical/elevated/watch) to boost scores:
- critical focal point: +20 points
- elevated focal point: +10 points

This ensures countries like Iran show 'elevated' or 'high' when:
1. AI insights detect breaking news (I:80)
2. FocalPointDetector marks them as critical (news + signal correlation)

The CII now reflects the same intelligence the AI summarizer uses,
eliminating the disconnect where AI says 'armada, bloodshed' but
CII shows green/normal.
2026-01-25 17:03:03 +04:00
Elie Habib 7d2a60cbc4 Fix CII: add news urgency boost when Information score is high
Problem: CII showed Iran as 'normal' (green, 38) despite AI insights
showing breaking news about 'US armada to Iran, deadly crackdown'.
This happened because S:0 (no military detections) diluted the I:80
(high news velocity).

Fix: Add newsUrgencyBoost that elevates scores when Information is high:
- I >= 70: +15 points (breaking news)
- I >= 50: +10 points (significant news)
- I >= 30: +5 points (moderate news)

Example with fix: Iran with I:80 now scores ~53 (elevated/orange)
instead of 38 (normal/green)
2026-01-25 16:51:45 +04:00
Elie Habib 637bba7273 Reorder panels: live-news, insights, cii, strategic-risk first
- New users get this default order automatically
- Existing users get one-time migration to new layout
- Migration key: worldmonitor-panel-order-v1.8
2026-01-25 16:45:45 +04:00
Elie Habib 49e766b21d Fix RSS proxy corrupting gzip-compressed upstream responses
UN News and other servers return gzip-compressed RSS. The proxy was
concatenating binary chunks as strings, corrupting non-UTF8 bytes.

Fix: Use Buffer.concat() and decompress gzip/deflate responses.
2026-01-25 16:38:47 +04:00
Elie Habib 55fd6cfda3 Add FocalPointDetector intelligence synthesis layer
Correlates news entities with map signals to identify "main characters"
appearing across multiple intelligence streams:

- Extract entities from all 80+ news sources via NER
- Cross-reference with map signals (flights, vessels, outages, protests)
- Score focal points where news + signals converge
- Generate rich AI context for correlation-aware summarization

New files:
- focal-point-detector.ts: Core detection and scoring service
- signal-aggregator.ts: Geographic signal aggregation

UI shows top focal points with urgency badges and signal icons.
AI prompts updated to leverage intelligence synthesis context.
2026-01-25 16:28:29 +04:00
Elie Habib 8348a6782b Fix repetitive summary openings
- Rewrite prompts to vary output (no more "The dominant narrative...")
- Instruct model to lead with substance: location, action, impact
- Add explicit rule: NEVER start with meta-commentary
2026-01-25 15:52:03 +04:00
Elie Habib 6e170d9c6c Add parallel ML analysis system with multi-perspective scoring
- New parallel-analysis.ts: runs browser ML alongside API summarization
- Uses 6 perspectives: keywords, sentiment, entities (NER), novelty, velocity, sources
- Detects disagreement between perspectives (flags potential missed stories)
- Console dashboard logs analysis comparison for debugging
- NER model (previously unused) now extracts entities for geopolitical scoring
- Shows "ML DETECTED" section for stories keywords missed but ML flagged
- Fix CII 15-min learning mode: bypasses when cached scores available
2026-01-25 15:51:06 +04:00
Elie Habib 1a60bdeee6 Add resizable panels, fix Railway crash, improve AI Insights
- Panel resize: drag bottom edge to span 1-4 grid rows, persisted to localStorage
- Fix HTML5 drag conflict with resize using capture-phase listeners
- Fix memory leaks in Panel.ts (document listeners now cleaned up)
- Fix Railway ERR_HTTP_HEADERS_SENT crash with response flag pattern
- Improve AI summarization prompts to focus on ONE dominant narrative
- Add VIOLENCE_KEYWORDS and UNREST_KEYWORDS for better story discovery
- Add combo bonus for flashpoint + unrest stories (e.g., Iran protests)
- Relax 2-source filter for high-scoring critical stories (score > 100)
2026-01-25 15:39:53 +04:00
Elie Habib 0c2a272bf5 Add backend caching for CII and Strategic Risk scores
- New /api/risk-scores endpoint computes and caches scores in Redis
- Uses ACLED protest data + baseline geopolitical risk factors
- 10-minute cache TTL shared across all users
- CIIPanel and StrategicRiskPanel fetch cached scores on load
- Learning Mode banner hidden when cached scores available
- Eliminates 15-minute warmup for users
2026-01-25 14:39:19 +04:00
Elie Habib 53a8a0a851 Add datacenter clustering at low zoom levels
- Cluster datacenters at zoom < 5, show IconLayer at higher zoom
- Add renderDatacenterClusters() with HTML overlays
- Add datacenterCluster popup type showing total chips/power stats
- Add CSS styles for datacenter markers matching protest/techHQ pattern
- Show cluster badge with count, expand to individual on click
2026-01-25 14:28:14 +04:00
Elie Habib 86590cc8b2 Fix AI summarization quality and InsightsPanel prioritization
- Add headline deduplication (60% word similarity threshold)
- Prevent prompt instructions from leaking into output
- Increase max_tokens from 80 to 150 to prevent truncation
- Add multi-tier keyword scoring: military (+80), flashpoint (+60), crisis (+30)
- Demote business/tech news (0.3 penalty) when mixed with conflict keywords
2026-01-25 14:22:16 +04:00
Elie Habib 9ebafb82b1 Remove redundant Country Labels feature
Base map tiles already display country names, so the custom
Country Labels layer was duplicating functionality.

Removed:
- COUNTRY_LABELS array (~80 countries) from geo.ts
- Toggle from layer controls in both Map.ts and DeckGLMap.ts
- renderCountryLabels() and createCountryLabelsLayer() methods
- 'countries' property from MapLayers type
- All countries config entries from panels and variants
- Related CSS styles
2026-01-25 14:16:34 +04:00
Elie Habib 2294f32daa Fix panel summary blocking scroll on news panels
Add max-height, overflow-y auto, and flex-shrink to .panel-summary
to prevent long summaries from pushing content out of view.
2026-01-25 14:05:51 +04:00
Elie Habib 3280a9e616 Improve AI Insights prioritization and shorten summaries
- Add keyword boosting for critical geopolitical terms (war, armada,
  military, iran, russia, ukraine, etc.) - +40 base + 10 per keyword
- Shorten summaries: max 2 sentences, 40 words, max_tokens=80
- Critical terms now outrank generic "alert" stories
2026-01-25 14:00:14 +04:00
Elie Habib 158e285a39 Update Al Arabiya and Arab News RSS feed URLs 2026-01-25 13:56:27 +04:00
Elie Habib 7e1dfcb160 Improve search results and fix RSS feeds
- Double MAX_RESULTS from 12 to 24
- Prioritize news over static infrastructure in search
- Update News24 URL to post-redirect destination (feeds.capi24.com)
- Add trailing slash to SCMP URL
- Add feeds.capi24.com to both proxy allowlists
2026-01-25 13:51:54 +04:00
Elie Habib d62923ee37 Add redirect following (301/302) to Railway RSS proxy 2026-01-25 13:50:00 +04:00
Elie Habib 6f65bcece7 Improve summarization prompts to avoid repetitive opening phrases 2026-01-25 13:47:34 +04:00
Elie Habib efc8e9886b Add debug logging for search news indexing 2026-01-25 13:45:58 +04:00
Elie Habib becf8d381e Fix panel summarize button: use inherited header, improve visibility 2026-01-25 13:30:31 +04:00
Elie Habib 01f5af89b8 Add missing domains to Railway RSS proxy allowlist
Railway's RSS proxy had only 5 domains in allowlist, causing 403 errors.
Added all domains that use railwayRss() routing in feeds.ts:
- Al Arabiya, Arab News, Times of Israel, SCMP
- UN News, CISA, News24
- Plus IAEA, WHO, Crisis Group, Kyiv Independent, Moscow Times for future use
2026-01-25 13:27:19 +04:00
Elie Habib b92c501582 Add 13 missing domains to RSS proxy allowlist
Fixes 403 errors for:
- Middle East: Al Arabiya, Arab News, Times of Israel, SCMP
- Regional: Kyiv Independent, Moscow Times, News24
- Int'l Orgs: UN News, IAEA, WHO, CISA, Crisis Group
- Other: Hacker News (news.ycombinator.com)
2026-01-25 13:13:30 +04:00
Elie Habib 37073b15da Add click-to-summarize button on news panel headers
- Sparkle button () in panel header triggers AI summary
- Summary cached in localStorage for 10 minutes
- Shows loading spinner while generating
- Dismissable summary banner below header
- Uses existing summarization fallback chain (Groq → OpenRouter → Browser T5)
2026-01-25 13:10:01 +04:00
Elie Habib b1b126cd04 Increase news search index from 200 to 500 items 2026-01-25 13:03:22 +04:00
Elie Habib f40e61c528 Add better error handling for Groq Redis initialization 2026-01-25 12:53:57 +04:00
Elie Habib 1245cac966 Fix bugs found in code review
- CORS regex: now matches root domain (worldmonitor.app) not just subdomains
- Alert keywords: re-add important terms (military, drone strike, terror/cyber attack, evacuation order)
2026-01-25 12:40:10 +04:00
Elie Habib 25cfb06bad Remove per-headline summarization (sparkle button)
World Brief in AI Insights already provides panel-level summarization.
Also reduced ML batch size and timeout to prevent embedding timeouts.
2026-01-25 12:38:17 +04:00
Elie Habib ded9663156 Fix false alert detection: remove generic keywords, add lifestyle exclusions
- Remove 'breaking', 'emergency' from alert keywords (too generic)
- Add ALERT_EXCLUSIONS for lifestyle/entertainment content
- Remove confusing 'Major' tier label (was showing for T2 sources)
- Tier badge now just shows dot for T2, 'Wire' for T1
2026-01-25 12:35:23 +04:00
Elie Habib 3e5e6f3dbf Add CORS support for Vercel preview domains in RSS proxy 2026-01-25 12:32:27 +04:00
Elie Habib e9cccf875b Trigger redeploy with preview env vars 2026-01-25 12:22:23 +04:00
Elie Habib fcdf94de62 Add server-side Redis caching for AI summaries + improve story ranking
- Add Upstash Redis caching to groq-summarize.js and openrouter-summarize.js
- Switch to llama-3.1-8b-instant (14.4K/day vs 1K for 70b)
- Cross-user cache deduplication with 24h TTL
- Remove client-side cache (server handles all caching now)

Improve AI Insights story selection:
- Composite importance score: sources × velocity × recency + alert bonus
- Source diversity cap: max 3 stories from same source
- Recency decay: newer stories rank higher (12h half-life)

Scalability: ~144x headroom (was hitting 1K/day limit)
2026-01-25 12:14:16 +04:00
Elie Habib a8e42c8944 Add Groq/OpenRouter fallback chain for AI summaries
- Add /api/groq-summarize.js - Llama 3.3 70B (1000 req/day free)
- Add /api/openrouter-summarize.js - Fallback (50 req/day free)
- Create summarization service with fallback: Groq -> OpenRouter -> Browser T5
- Browser T5 only loads when cloud APIs fail (lazy loading)
- Add detailed progress bar with Step X/Y indicator
- Show provider badge (groq/openrouter) on World Brief
- Cache summaries for 30 min to respect rate limits
- Cooldown increased to 2 min between brief generations

Required env vars for Vercel:
- GROQ_API_KEY (get from console.groq.com)
- OPENROUTER_API_KEY (get from openrouter.ai)
2026-01-25 11:38:52 +04:00
Elie Habib 0f407666da Add loading status messages to AI Insights panel
- Show "Initializing ML models..." when worker not ready
- Show "Analyzing sentiment..." during sentiment classification
- Show "Generating world brief..." during summarization
- Add animated spinner and pulsing text for visual feedback
2026-01-25 11:29:42 +04:00
Elie Habib 45baf5637f Fix ML Worker: re-enable worker import and bundle transformers
- Re-enable the ML worker import that was disabled
- Remove @xenova/transformers from rollup externals so it gets bundled
- Previous config had MLWorkerClass set to null, causing "not a constructor" error
2026-01-25 11:28:02 +04:00
Elie Habib 4415362658 Upgrade to Flan-T5-base and add World Brief summarization
- Switch summarization model from T5-small (45MB) to Flan-T5-base (250MB)
  for significantly better quality summaries
- Add "World Brief" section to InsightsPanel that synthesizes top stories
  into a coherent 2-sentence summary (vs useless per-headline summaries)
- Brief generation has 60s cooldown to avoid excessive model calls
- Add styled World Brief section with accent gradient
2026-01-25 11:22:17 +04:00
Claude 5e77433aca Add missing layers to DeckGLMap for feature parity with D3 Map
Major additions:
- AIS Disruptions layer (spoofing/jamming events)
- Cable Advisories layer (fault/maintenance markers)
- Repair Ships layer
- Country Labels layer
- Flight Delays toggle in layer panel
- Countries toggle in layer panel

Data storage fixes:
- setAisData now stores disruptions (was ignoring them)
- setCableActivity now stores advisories and repair ships

Also:
- Import COUNTRY_LABELS from config
- Add tooltips and click handlers for all new layers
- Temporarily disable ML worker (dependency unavailable)

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 07:21:33 +00:00
Elie Habib fe1ce20bf6 Redesign AI Insights panel for usefulness
- Remove broken T5 summaries (model too weak for abstractive summarization)
- Focus on multi-source confirmed stories (2+ sources)
- Show fast-moving and alert stories
- Add stats bar: multi-source count, fast-moving count, alerts
- Improve sentiment visualization with bar and overall tone
- Filter out single-source noise
2026-01-25 11:14:08 +04:00
Elie Habib 28240d4c94 Fix wingbits API routing for subpaths (/health, /details) 2026-01-25 11:08:50 +04:00
Elie Habib 1c5c4d841f Fix ML worker concurrent model loading and entity null checks
- Add loadingPromises map to prevent duplicate model loads
- Guard against undefined entity type/text in groupEntities
2026-01-25 11:01:00 +04:00
Claude 1b6d95f6f5 Add missing Irradiators and Spaceports toggles to layer panel
These layers existed in buildLayers() with mapLayers.X checks but had
no UI toggles, making them inaccessible. Added toggles for both in the
geopolitical variant layer panel.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:59:09 +00:00
Elie Habib 5c01ff41e6 Add client-side ML features with ONNX Runtime
- Add ML worker infrastructure (@xenova/transformers)
- Implement semantic news clustering (hybrid Jaccard + embeddings)
- Add InsightsPanel with themes, entities, sentiment analysis
- Add click-to-summarize feature in NewsPanel
- Wire up ML-enhanced velocity/sentiment scoring
- Desktop-only activation (mobile excluded)
- Fix division by zero in cosineSimilarity
- Fix dead Promise code in ml-worker.ts
2026-01-25 10:48:55 +04:00
Claude fc7476b26e Add APT Groups and Critical Minerals layers to DeckGLMap
- Import APT_GROUPS and CRITICAL_MINERALS from config
- Add createAPTGroupsLayer() with red markers and yellow outline for cyber threat actors
- Add createMineralsLayer() with color-coded markers by mineral type (Lithium, Cobalt, Rare Earths, Nickel)
- APT Groups always visible in geopolitical variant (no toggle)
- Add minerals toggle to layer panel for geopolitical variant
- Add tooltips and click handlers for both layers

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:43:13 +00:00
Claude 18a7d255bb Add missing layers: irradiators, spaceports, ports, flight delays
- Add gamma irradiators layer (IAEA DIIF facilities)
- Add spaceports layer (launch sites)
- Add strategic ports layer (61 ports, shown with AIS)
- Add flight delays layer (FAA airport delays/ground stops)
- Fix setFlightDelays to store data properly
- Add tooltips and click handlers for all new layers

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:36:44 +00:00
Claude e8a71be570 Add clustering support for Tech HQs, Tech Events, and Protests
- Add clusterMarkers method for grouping nearby markers
- Create HTML overlay container for cluster badges
- Render Tech HQs with emoji icons and cluster count badges
- Render Tech Events with calendar icons and clustering
- Render Protests with severity icons and clustering
- Clusters expand on click to show popup with all items
- Single items show individual popup on click
- Clustering radius adjusts based on zoom level

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:19:52 +00:00
Claude 55a121e5ae Remove unused MapView import
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:09:37 +00:00
Claude d039fcd055 Remove duplicate FOCUS dropdown and improve map legend
- Remove FOCUS region selector from header (keep only map's view selector)
- Redesign legend as compact horizontal bar at bottom center
- Add proper shapes: triangles for bases, squares for datacenters, hexagons for nuclear
- Reduce whitespace in legend layout

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:06:59 +00:00
Claude d60bf0579e Increase marker sizes for better visibility while keeping transparency
- Datacenters: size 10-14px, opacity ~55% (was 6-10px, ~30%)
- Bases: size 11-16px, opacity ~63%
- Nuclear: size 11-15px, opacity ~78%
- Better balance between visibility and layering

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:59:31 +00:00
Claude d11e60b1d7 Reduce datacenter and base marker opacity for better layering
- Datacenter squares: size 6-10px (was 10-14), opacity ~30% (was ~63%)
- Planned datacenters: opacity ~20% for subtle indication
- Military bases: size 8-14px (was 12-20), opacity ~63% (was 100%)
- Improves map readability when multiple layers are overlaid

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:46:52 +00:00
Claude 1a15262d05 Use IconLayer for distinct marker shapes
Replaced ScatterplotLayer circles with IconLayer for distinct shapes:
- Military bases: TRIANGULAR icons (color-coded by operator)
- Nuclear facilities: HEXAGONAL icons (yellow/orange)
- Datacenters: SQUARE icons (purple)
- Hotspots: Remain as circles (appropriate for threat indicators)

Each marker type now has a unique geometric shape for easy identification.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:24:19 +00:00
Claude 974e5c34be Improve visual distinction for map markers
- Nuclear: Yellow/orange hollow RING with inner dot (radiation symbol look)
- Datacenters: Purple filled circles with light border
- Bases: Color-coded by operator (US-NATO blue, Russia red, China orange, etc.)
- Removed non-working TextLayer emoji approach

Each marker type now has a distinct visual style beyond just color.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:20:34 +00:00
Elie Habib 2b0554abc6 Remove Tech Readiness panel from full/geopolitical variant 2026-01-24 08:22:41 +04:00
Claude ff56d705cd Add emoji icons to datacenter and nuclear markers
Restored visual distinction for markers that had icons in the
original D3/SVG implementation:
- Datacenters: 🖥️ icon
- Nuclear facilities: ☢️ icon

Uses TextLayer overlaid on ScatterplotLayer to render emoji icons.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 15:27:52 +00:00
Elie Habib 5ef069efe6 Remove large labels from geo activity markers 2026-01-23 18:11:33 +04:00
Claude f1c4cd8950 Add @deck.gl/mapbox dependency
Required for MapboxOverlay integration with MapLibre.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 14:10:53 +00:00
Claude 744ae82333 Fix deck.gl/MapLibre sync using MapboxOverlay
Replaced separate Deck instance with MapboxOverlay which properly
integrates deck.gl into MapLibre's WebGL context. This fixes:
- Points moving when zooming/panning (view state sync)
- Coordinate offset issues (London appearing in France)
- Cursor issues (MapLibre now handles all interactions)

MapboxOverlay renders deck.gl layers directly into MapLibre's canvas,
eliminating all view state synchronization issues.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 14:10:25 +00:00
Elie Habib a58d6fd423 Bump version to 1.7.3 2026-01-23 18:05:15 +04:00
Claude 3af5a5ebf1 Fix deck.gl/MapLibre view state sync causing offset
The circular sync between deck.gl and MapLibre was causing the
deck.gl layer to be offset from the base map. Fixed by:
- Making deck.gl the single source of truth (has controller enabled)
- Removing MapLibre->deck.gl sync that caused circular updates
- Adding initial sync on map load to ensure alignment

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 14:04:30 +00:00
Elie Habib fc52013d91 Add geo activity markers to map for full variant
Wire up geo hub activity display on the map with glowing markers
similar to tech variant. Includes popup with top stories and click
to view related news.
2026-01-23 18:04:25 +04:00
Claude affbf0cf21 Fix incorrect datacenter coordinates in US
The source dataset had placeholder coordinates clustering all US
datacenters around Kansas/Oklahoma. Fixed actual locations for:
- Mt Pleasant, Wisconsin (42.7°N, -87.9°W)
- Meta New Albany, Ohio (40.1°N, -82.8°W)
- OpenAI/Microsoft Atlanta (33.7°N, -84.4°W)
- Applied Digital Ellendale, ND (46.0°N, -98.5°W)
- Nebius New Jersey (40.1°N, -74.4°W)
- CoreWeave Denton, TX (33.2°N, -97.1°W)

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:59:58 +00:00
Elie Habib 012076d949 Add mobile icon to Tech Readiness display 2026-01-23 17:53:31 +04:00
Elie Habib e520e2b993 Bump version to 1.7.2 2026-01-23 17:46:36 +04:00
Elie Habib 9ad8fe396c Add Geopolitical Hubs panel for full variant, simplify Tech Readiness to 2 metrics
- Add geo-hub-index.ts with 30+ strategic locations (capitals, conflict zones, chokepoints)
- Add geo-activity.ts for news aggregation by geopolitical location
- Add GeoHubsPanel.ts with red/orange color scheme for geopolitical theme
- Wire up geo hubs for full variant only (tech variant keeps tech hubs)
- Remove patents and high-tech exports from Tech Readiness (often stale/irrelevant)
- Tech Readiness now shows: Internet Users, R&D Spending (weights: R&D 35%, Internet 30%, Broadband 20%, Mobile 15%)
- Fix CSS bug: replace undefined var(--orange) with #ff8844
2026-01-23 17:46:14 +04:00
Elie Habib 8e2f21f5f6 Enable Tech Hubs and Tech Readiness panels for all variants 2026-01-23 17:24:27 +04:00
Elie Habib 241b35d90b Fix Brookings Tech feed parse error 2026-01-23 17:20:14 +04:00
Elie Habib 271afe5eb3 Increase Tech Readiness display from 15 to 25 countries 2026-01-23 17:18:25 +04:00
Claude 4ca11560af Remove auto-panning from map interactions
The map now stays fixed on the user's selected region when:
- Clicking items from lists (conflicts, bases, pipelines, etc.)
- Flash location animations
- New data flowing in

Popups now appear at the projected screen position of items
rather than panning the map to center on them. If an item is
off-screen, the popup still appears at its projected position.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:15:11 +00:00
Elie Habib f7ac257966 Fix broken RSS feeds and Tech Readiness panel
- Replace 30+ blocked podcast/regional feeds with Google News fallbacks
- Fix World Bank API date range (7 years for delayed indicators)
- Add 30+ countries to Tech Readiness (UAE, Saudi, Qatar, etc.)
- Improve tooltip explaining metrics (🌐🔬📜📦)
- Fix a16z Blog, EFF News, KrASIA with Google News
2026-01-23 17:13:28 +04:00
Claude 968a402a53 Fix cursor style on deck.gl canvas
Add CSS to override deck.gl's default grab cursor on its canvas,
using the default pointer cursor and only grabbing when dragging.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:03:42 +00:00
Claude 0ec72613ba Enable deck.gl controller for mouse panning/zooming
The deck.gl canvas was blocking mouse events from reaching MapLibre
because it had pointer-events: auto for picking but controller: false.
Enable deck.gl's controller and sync view state changes back to MapLibre.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:00:53 +00:00
Claude d47c2ec5e4 Fix cursor style on deck.gl map to use default pointer
Override MapLibre GL's default grab cursor to use the standard
pointer cursor, only switching to grabbing when actively dragging.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:59:31 +00:00
Claude 728742a31e Fix dark overlay caused by timestamp element stretching
Root cause: The timestamp element had both classes 'map-timestamp' and
'deckgl-timestamp'. These classes set conflicting positioning:
- .map-timestamp: bottom: 8px; right: 10px;
- .deckgl-timestamp: top: 10px; left: 50%;

When all four positioning values (top/bottom/left/right) are set on an
absolutely positioned element without explicit width/height, it stretches
to fill the space between those edges - creating a 724x315px dark overlay.

Fixes:
- Remove 'map-timestamp' class from timestamp element in DeckGLMap.ts
- Add explicit bottom: auto; right: auto; width: auto; height: auto;
  to .deckgl-timestamp CSS to prevent any accidental stretching

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:56:02 +00:00
Claude 05ff0cd658 Attempt fix for dark overlay with z-index and CSS transparency
Changes:
- Add explicit z-index to MapLibre (1) and deck.gl overlay (2)
- Add overflow: hidden to wrapper
- Reverted to letting deck.gl create its own canvas
- Added effects: [] to disable any default deck.gl effects
- Set canvas background to transparent after deck initializes
- Added comprehensive CSS rules to ensure all canvas elements
  and containers have transparent backgrounds

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:54:07 +00:00
Claude 18e6306a31 Fix deck.gl canvas transparency by creating WebGL context with alpha
Instead of relying on deck.gl's default canvas/context creation,
manually create canvas and WebGL context with explicit alpha support:

- Create canvas element with transparent background style
- Get WebGL2/WebGL context with alpha: true, premultipliedAlpha: true
- Set gl.clearColor(0, 0, 0, 0) for transparent clear
- Pass pre-created canvas and gl context to Deck constructor

This ensures the WebGL context is properly configured for alpha
transparency from the start, allowing the MapLibre base map to
show through the deck.gl overlay.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:47:53 +00:00
Elie Habib 0c1c0f4377 Convert World Bank API to Node.js serverless (Edge blocked by WB) 2026-01-23 16:43:11 +04:00
Elie Habib 56868f5c6d Fix World Bank API: restore Edge runtime with browser-like headers 2026-01-23 16:41:42 +04:00
Claude fb6aacef26 Fix dark overlay with WebGL transparency settings
Two-pronged approach to fix the dark semi-transparent overlay:

1. Add MapLibre background layer:
   - Added 'background' layer with dark color (#0a0f0c) to MapLibre style
   - This ensures no transparent gaps while tiles are loading

2. Set WebGL clear color to transparent:
   - Added onWebGLInitialized callback to Deck initialization
   - Explicitly set gl.clearColor(0, 0, 0, 0) for transparent background
   - Enable proper alpha blending with gl.blendFunc

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:39:52 +00:00
Elie Habib 2ce6c8df6b Fix more broken/blocked RSS feeds (TNW, YC News, Techstars) 2026-01-23 16:38:29 +04:00
Elie Habib 5d3761e374 Fix World Bank API 403 and remove broken RSS feeds
- Switch World Bank API from Edge to Node.js runtime (Edge gets 403)
- Add User-Agent header for World Bank requests
- Remove broken feeds: NFX Essays, First Round Review
- Fix a16z Blog URL (use future.a16z.com/feed)
- Fix CSIS Tech URL (use Google News RSS fallback)
2026-01-23 16:37:02 +04:00
Elie Habib 23dabde70b Fix Tech Hubs scoring to use relative scale 2026-01-23 16:33:44 +04:00
Claude c51dd569c5 Fix dark overlay issue in DeckGLMap
Root cause: CONFLICT_ZONES config had inconsistent coordinate formats.
Red Sea Crisis and South Lebanon entries were in [lat, lon] format
while other entries were in [lon, lat] format (GeoJSON standard).
This caused polygons to render in completely wrong locations.

Fixes:
- Fix Red Sea Crisis coords: [[12,42]...] → [[42,12]...] (swap to [lon,lat])
- Fix Red Sea Crisis center: [14,43] → [43,14]
- Fix South Lebanon coords: [[33.0,35.1]...] → [[35.1,33.0]...]
- Fix South Lebanon center: [33.2,35.4] → [35.4,33.2]
- Add explicit transparent background to #deckgl-overlay CSS

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:32:15 +00:00
Elie Habib 41bf703f4c Add Tech Hubs activity tracking and World Bank readiness panel
- Add World Bank API endpoint with 17 tech indicators
- Add Tech Readiness panel showing country rankings by composite score
- Add Tech Hubs panel showing active tech hub activity from news
- Add tech activity map layer with animated markers
- Expand tech variant feeds (VC blogs, regional startups, unicorns, etc.)
- Remove ~250 lines of dead code from tech.ts variant config
- Fix API response shape consistency for no-data case
2026-01-23 16:28:29 +04:00
Claude b75bf4e6a5 Fix popup system in DeckGLMap to use proper PopupData format
The MapPopup.show() method expects a PopupData object with:
- type: PopupType string identifying the popup renderer
- data: the actual data object to display
- x, y: click coordinates for popup positioning

Previously was passing raw objects which resulted in broken/missing popups.

Changes:
- Import PopupType from MapPopup
- Update handleClick to map layer IDs to PopupType and create proper PopupData
- Update all trigger methods (triggerConflictClick, triggerBaseClick, etc.)
  to create proper PopupData with centered x/y coordinates
- Add getContainerCenter() helper for programmatic popup positioning

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:25:49 +00:00
Claude cd8097cc8a Fix coordinate bugs and add missing tooltips in DeckGLMap
- Fix coordinate swap bugs in createCablesLayer, createPipelinesLayer,
  and createConflictZonesLayer - data is already [lon, lat] format
- Fix coordinate order in triggerConflictClick, triggerPipelineClick,
  and triggerCableClick to pass (lat, lon) to setCenter correctly
- Fix handleClick to not show popup for hotspots (they have their own handler)
- Add missing tooltips for all layer types (cables, pipelines, conflicts,
  natural events, weather, outages, AIS density, military flights,
  waterways, economic centers, tech HQs, accelerators, cloud regions,
  tech events)

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:06:17 +00:00
Claude bfe31675ed Add deck.gl WebGL visualization for desktop
Implement Palantir-like interactive map experience using deck.gl with
MapLibre GL as the base map. Key changes:

- Add deck.gl (@deck.gl/core, @deck.gl/layers, @deck.gl/geo-layers) and
  maplibre-gl dependencies for GPU-accelerated rendering
- Create DeckGLMap component with WebGL layers for:
  - Undersea cables and pipelines (PathLayer)
  - Military bases, nuclear facilities, datacenters (ScatterplotLayer)
  - Conflict zones (GeoJsonLayer with polygons)
  - Hotspots, earthquakes, weather, outages, protests
  - AIS density, military vessels and flights
  - Strategic waterways, economic centers
  - Tech variant: startup hubs, tech HQs, accelerators, cloud regions
- Create MapContainer wrapper that conditionally renders:
  - DeckGLMap (WebGL) on desktop with WebGL support
  - Existing D3/SVG MapComponent on mobile for graceful degradation
- Add dark theme CSS styles for deck.gl controls, legend, layer toggles
- Import maplibre-gl CSS in main.ts

Desktop users now get smooth 60fps interactions with large datasets
while mobile users retain the optimized SVG experience.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 11:48:20 +00:00
Elie Habib 9b42ac6f91 Add source credibility badges and attribution
- Show tier badge (★ Wire, ● Major) for Tier 1-2 primary sources
- Add "Also:" prefix for multi-source confirmation
- Enhanced styling for tier badges with gradient/border
- Filter out primary source from "also reported by" list
- Add tooltips explaining source reliability level
2026-01-23 14:53:33 +04:00
Elie Habib 55dc002e1d Add Polymarket to tech variant allowlist 2026-01-23 14:09:37 +04:00
Elie Habib 7c94201409 Filter System Health by variant allowlists
Add allowlist filtering to StatusPanel so only variant-relevant
feeds and APIs are shown. Tech variant no longer shows Polymarket,
PizzINT, RSS2JSON, FRED, Intel, Thinktanks, etc.
2026-01-23 14:07:10 +04:00
Elie Habib 7e150732a9 Fix tech variant layout and System Health feeds
- Move Tech Events panel to position 3 (after live-news)
- Make StatusPanel variant-aware: shows tech-relevant feeds
  for tech variant (startups, vcblogs, cloud, etc.) instead
  of geopolitical feeds (thinktanks, gov, intel, etc.)
2026-01-23 14:03:19 +04:00
Elie Habib 9ec1ceae36 Fix tech variant title and FwdStart date extraction
- Change "Global Situation" to "Global Tech" for tech variant
- Rewrite FwdStart scraper to extract actual dates from HTML
  (was showing "2m ago" because dates defaulted to now)
2026-01-23 13:58:20 +04:00
Elie Habib e5dd019e78 Fix RSS parser to support Atom feeds (Product Hunt) 2026-01-23 13:49:10 +04:00
Elie Habib a9eba9a975 Add RSS proxy domains, FwdStart scraper, fix status URLs
- Add 25+ domains to RSS proxy allowlist (VC blogs, regional startups, funding)
- Create /api/fwdstart.js custom scraper for Beehiiv newsletter
- Fix Anthropic status URL (status.claude.com)
- Fix Zoom status URL (www.zoomstatus.com)
- Remove panel debug logging
- Add CLAUDE.md with dev documentation
2026-01-23 13:46:00 +04:00
Elie Habib 64733fd2c5 Fix: add missing categories to data loading for new panels 2026-01-23 13:25:23 +04:00
Elie Habib b9f22fdfdb Add missing panel instantiation for tech variant
- Add vcblogs, regionalStartups, unicorns, accelerators, funding, producthunt panels
- These panels were added to config in PR #27 but not instantiated in App.ts
- Also enable layoffs panel by default in full variant
2026-01-23 13:21:52 +04:00
Elie Habib b8c33f257f Fix Notion status page URL to correct Atlassian Statuspage 2026-01-23 13:13:45 +04:00
c25385d8ae Expand startup ecosystem coverage with VC blogs, regional news, and unicorn tracking (#27)
* Enhance tech variant as reference startup intelligence dashboard

- Add premium startup/VC feeds: The Information, PitchBook, CB Insights, Fortune Term Sheet
- Add VC blog panel with YC, a16z, First Round, Sequoia, NFX, Paul Graham essays
- Add regional startup news: EU Startups, Sifted, Tech in Asia, TechCabal, Inc42, etc.
- Add Unicorn Tracker panel with feeds tracking new unicorns and decacorns
- Add Accelerators panel with YC, Techstars, 500 Global, Demo Day news
- Expand startup ecosystems with 11 new hubs: Miami, Denver, Chicago, Barcelona,
  Helsinki, Munich, Jakarta, Lagos, Nairobi, Mexico City, Ho Chi Minh City
- Add source tier rankings for startup/VC sources

https://claude.ai/code/session_01SxRf5YSHtV92EArUY2BvaJ

* Fix panel order and remove duplicate feed

- Move live-news to position 2 (after map) in tech variant panels
- Prevents CSS grid overlap issue with panel-wide class
- Remove duplicate EU Startups from funding feeds (already in regionalStartups)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-23 13:13:04 +04:00
Elie Habib a0d69a0008 Improve service status detection for incident.io pages (OpenAI, Linear, Replicate)
- Add dedicated incidentio parser for incident.io status pages
- Use browser-like headers to avoid bot blocking
- Parse HTML fallback if JSON blocked
- Increase timeout to 10s
2026-01-23 13:05:37 +04:00
Elie Habib 42d3721fb3 Bump version to 1.7.1 2026-01-23 13:02:58 +04:00
Elie Habib f29d4c4499 Fix panel-wide to span 2 rows for video content 2026-01-23 13:01:11 +04:00
Elie Habib 5c57a16448 Force live-news panel to first position for proper grid layout
Saved panel order in localStorage was overriding the default order,
causing the wide live-news panel to appear in wrong position and
overlap other panels. Now live-news is always moved to position 0
regardless of saved order.
2026-01-23 12:59:40 +04:00
Elie Habib 24d1e4e653 Fix tech variant panel order - live-news must be first for proper grid layout 2026-01-23 12:52:17 +04:00
Elie Habib eaffd7f3b1 Revert grid changes - restore original panel layout 2026-01-23 12:50:53 +04:00
Elie Habib 922c57fc8c Add global startup funding feeds: Asia, India, MENA, Africa, LATAM 2026-01-23 12:47:15 +04:00
Elie Habib f3e64d413d Remove .claude and .cursor from git tracking 2026-01-23 12:46:12 +04:00
Elie Habib fd3ec349ff Hide country labels on mobile for cleaner map view 2026-01-23 12:45:48 +04:00
Elie HabibandGitHub 0fbe2c13fc Expand startup funding feeds (#26) 2026-01-23 12:43:24 +04:00
Elie HabibandGitHub b356ae2feb Map flash cues for location-tagged RSS items (#25)
* Add map flash cue for location news

* Fix TypeScript closure type narrowing issue in findFlashLocation
2026-01-23 12:42:45 +04:00
Elie Habib 9aa12340fe Fix live-news panel with explicit grid positioning 2026-01-23 12:11:58 +04:00
Elie Habib 10e1e4b525 Add acknowledgment for fai9al's Tech Monitor inspiration 2026-01-23 12:09:28 +04:00
Elie Habib 0ea48df494 Add grid-auto-flow dense to fix panel overlap 2026-01-23 12:06:33 +04:00
Elie Habib bdda657681 Fix live-news panel overlap by spanning 2 grid rows 2026-01-23 12:02:18 +04:00
Elie Habib e970040129 Suppress intelligence modal popups in fullscreen mode 2026-01-23 11:58:32 +04:00
Elie Habib 2d076d091d Add variant-specific meta tags for tech.worldmonitor.app
Transform HTML meta tags (title, description, keywords, OG, Twitter, JSON-LD)
based on VITE_VARIANT during build. Tech Monitor now has distinct branding
focused on AI/tech industry tracking.
2026-01-23 11:53:35 +04:00
Elie Habib 555e5ce158 Improve service status parsing and error handling
- Add HTML response detection (blocked/redirected requests)
- Add JSON parse error handling
- Add alternative Statuspage format parsing
- Improve normalizeStatus to handle more indicator values
- Handle "All Systems Operational" in description
2026-01-23 11:49:30 +04:00
Elie Habib 63cd6ca866 Add comprehensive search for tech variant, cleanup repo
Search improvements:
- Add Tech HQs (unicorns, FAANG, public companies) to search index
- Add Accelerators (Y Combinator, Techstars, etc.) to search index
- Add Tech Events to search index after API load
- Fix search result icons for new types
- Update search hints

Repo cleanup:
- Add .claude/ and .cursor/ to .gitignore
- Remove outdated brainstorming/ folder
- Remove outdated docs/ folder
2026-01-23 11:42:16 +04:00
Elie Habib 99fdcf5a4c Add System Architecture section to README
- Add data flow diagram showing pipeline from sources to rendering
- Document update cycles for different data types
- Add error handling strategy (circuit breakers, graceful degradation)
- Document build-time optimization (code splitting, variant builds)
- Add security considerations section (client-side, API, privacy)
2026-01-23 11:35:45 +04:00
Elie Habib cd1f8caae6 Update README with Tech Monitor docs, bump version to 1.7.0
- Add Platform Variants section documenting World/Tech Monitor
- Add Tech Monitor data layers (Tech HQs, Startup Hubs, Cloud Regions, etc.)
- Add Regional Tech HQ coverage table (Silicon Valley, MENA, Europe, Asia)
- Add Marker Clustering section explaining grouping logic
- Add completed features to Roadmap (Tech Monitor, clustering, Learning Mode)
- Update version badge from 1.5.1 to 1.7.0
- Replace Earthquake with Datacenter in tech variant map legend
2026-01-23 11:34:04 +04:00
Elie Habib 855af52ffc Add protest clustering for busy regions on main variant
- Cluster nearby protests within same country
- Show cluster badge with event count
- Cluster popup shows severity breakdown, fatalities, verified count
- Sort by severity (high→low), then by event type (riots first)
- Adaptive cluster radius based on zoom level
2026-01-23 11:30:15 +04:00
Elie Habib a636a6adeb Add Anghami to Abu Dhabi, bump version to 1.7.0 2026-01-23 11:22:18 +04:00
Elie Habib 351bbee846 Add sleek variant switcher to header
- Compact orbital mode selector with icons
- Expands to show labels on hover
- Active variant has colored glow (green=WORLD, blue=TECH)
- Smooth animated transitions
- Extensible for future variants
2026-01-23 11:20:35 +04:00
Elie Habib aa9a7b9805 Fix marker clustering and MENA unicorn data accuracy
- Fix clustering to group by city (prevents Dubai/Riyadh merging)
- Fix tech event marker sizing (add counter-scale for zoom)
- Sort cluster popups (events by date, HQs by type)
- Update MENA unicorn data:
  - Move Tabby to Riyadh (relocated HQ in 2023)
  - Add Ninja, Kitopi, Property Finder, XPANCEO, Presight.ai, Alef Education
  - Remove Sary, Nana (not unicorns - below $1B valuation)
  - Fix valuations and market caps
2026-01-23 11:16:41 +04:00
Elie Habib 6c75748e1d Add marker clustering for tech HQs and events
- Generic clusterMarkers() function groups nearby markers by pixel distance
- Cluster radius adapts to zoom level (15px at zoom 4+, 25px at 3+, 40px otherwise)
- Clusters show count badge and icon of most prominent type
- Click cluster to see list of all companies/events
- Cluster popups show summary by type (Big Tech, Unicorns, Public)
- Tech events cluster popup shows upcoming events within 2 weeks
2026-01-23 10:55:03 +04:00
Elie Habib 8b0f990187 Fix MENA tech HQs data - remove incorrectly labeled unicorns
Removed non-unicorn companies that were mislabeled:
- UAE: Ziina, XPANCEO, Property Finder, Yallacompare, Beehive, Yelo, Kitopi
- KSA: Morni, TruKKer, Lucidya, Rewaa, Foodics, Salla
- Egypt: Capiter, Vezeeta, Paymob, MaxAB
- Jordan: OpenSooq, Mawdoo3

Kept verified unicorns: Careem, Noon, Talabat, G42, Dubizzle, Tabby,
Tamara, stc pay, Sary, Nana, MNT-Halan
2026-01-23 10:49:15 +04:00
Elie Habib 0acbe07246 Fix tech-event-marker CSS: add position absolute 2026-01-23 10:29:33 +04:00
Elie Habib af02b1a3f7 Add debug logging for techEvents positions 2026-01-23 10:23:29 +04:00
Elie Habib ce25a3bb8a Fix techEvents bounds check - use container dimensions 2026-01-23 10:20:39 +04:00
Elie Habib 11481f64d2 Fix tech events filter: conference not conferences 2026-01-23 10:14:47 +04:00
Elie Habib 7163591cd7 Fix service status URLs and add Stripe parser
- Linear: linearstatus.com (was status.linear.app)
- Replicate: www.replicatestatus.com (was status.replicate.com)
- Stripe: Custom parser for /current endpoint
- Removed: Together AI, PagerDuty, Auth0 (no working JSON APIs)
2026-01-23 10:12:33 +04:00
Elie Habib 44b876b626 Add debug logging for techEvents layer 2026-01-23 10:09:14 +04:00
Elie Habib e2518ad6da Add limit and days filters for tech events API
- API now supports ?limit=N and ?days=N parameters
- Map layer: 50 events within 90 days (reduces clutter)
- Panel: 100 events within 180 days (more context)
2026-01-23 10:02:11 +04:00
Elie Habib 300b9d5b20 Fix service status API and improve search/UI consistency
- Add statusio parser for GitLab and Docker Hub status pages
- Add slack parser for Slack custom status API format
- Add CircleCI back with working Statuspage.io endpoint
- Make SearchModal variant-aware with tech-specific sources
- Add techEvents to async data layers for proper loading state
- Fix panel heights with consistent grid sizing
2026-01-23 09:59:59 +04:00
Elie Habib 47844fc198 Integrate dev.events RSS into Tech Events panel
- Fetch both Techmeme ICS and dev.events RSS in parallel
- Parse dev.events RSS for upcoming developer conferences/meetups
- Merge events from both sources with deduplication
- Extract location from dev.events descriptions for map markers
- Events now show from both sources in the Tech Events panel
2026-01-23 09:41:03 +04:00
Elie Habib 75c1156b29 Fix Service Status panel showing unknown for most services
- Add proper Statuspage.io API URLs for Anthropic, DigitalOcean, Bitbucket,
  CircleCI, Hugging Face, Replicate, PagerDuty, Sentry
- Add custom parsers for AWS, Azure (RSS), and GCP (incidents.json)
- Increase timeout to 8s for slower status pages
- Add User-Agent header to avoid blocking
2026-01-23 09:38:22 +04:00
Elie Habib f3546ffe71 Add cybersecurity feeds to tech variant, keep debug logging
- Add CISA Advisories feed to security category
- Add Cyber Incidents Google News feed for breach/ransomware coverage
- Keep debug logging for panel toggle investigation
- Krebs Security already present in security feeds
2026-01-23 09:35:25 +04:00
Elie Habib c3b330601e Add debug logging for panel toggle and fix variant change reset
- Add console logging to trace TechEvents panel toggle issue
- Clear panel settings when variant changes (previously only cleared map layers)
- Clear panel order when variant changes to prevent stale saved orders
2026-01-23 09:32:40 +04:00
Elie Habib 935382d8dc Fix TechEvents panel toggle and add Accelerators to layer controls 2026-01-23 09:18:23 +04:00
Elie Habib 9fb3c34628 Add service status panel and improve tech variant feeds
- Add ServiceStatusPanel: monitors AWS, Azure, GCP, GitHub, OpenAI, Slack, etc.
- Add new feeds: Engadget, Fast Company, MIT Research, Show HN, YC Launches, Dev Events
- Remove broken feeds: Protocol Policy, Axios AI, AnandTech (shut down)
- Add "SOON" badge for events within 2 days
- Fix GitHub Trending to use actual RSS feed (mshibanami.github.io)
- Add domains to rss-proxy allowlist
2026-01-23 09:11:20 +04:00
Elie Habib 633faae158 Fix broken feeds: add domains to allowlist, replace defunct Protocol/rsshub feeds 2026-01-23 08:51:09 +04:00
Elie Habib 6bca2adf50 Make layer help popup variant-aware, add techEvents toggle 2026-01-23 08:48:50 +04:00
Elie Habib 7c50005c45 Filter predictions for tech/AI/startup topics in tech variant 2026-01-23 08:46:15 +04:00
Elie Habib 1d8d1e238d Zoom in MENA view to better show GCC region 2026-01-23 08:45:08 +04:00
Elie Habib 9cec0bd225 Hide APT markers and use tech-specific legend for tech variant 2026-01-23 08:41:33 +04:00
Elie Habib cb6368410f Add github and ipo feed categories to loadNews() 2026-01-23 08:40:09 +04:00
Elie Habib 8f27089b39 Add missing tech variant feed categories to loadNews() 2026-01-23 08:35:10 +04:00
Elie Habib c47affb3bd Disable countries layer by default in tech variant 2026-01-23 08:32:40 +04:00
Elie Habib f56d44f2f2 Use wildcard CORS for *.worldmonitor.app subdomains 2026-01-23 08:30:15 +04:00
Elie Habib 81c538255d Add tech.worldmonitor.app to API CORS allowlists 2026-01-23 08:29:33 +04:00
Elie Habib f652b22301 Add tech events feature with Techmeme ICS integration
- Add /api/tech-events endpoint parsing Techmeme events ICS feed
- Create TechEventsPanel with view modes (upcoming/conferences/earnings/all)
- Add tech events map layer with conference location markers
- Implement 500+ city geocoding database for worldwide coverage
- Purple markers with yellow glow for events within 14 days
- Click-to-zoom from panel items to map locations
- Integrated in tech variant (disabled in full variant)
2026-01-23 08:25:29 +04:00
Elie Habib 9dd3a403cd Add missing tech variant domains to RSS proxy allowlist 2026-01-23 08:01:52 +04:00
Elie Habib c3dff5eca9 Fix blocked RSS feeds in TECH_FEEDS config
Replace feeds that return 403 from Vercel edge servers:
- tech: Wired/Engadget → ZDNet/TechMeme
- ai: Wired AI/OpenAI Blog/Google AI Blog → Google News searches
- finance: Bloomberg Tech/Reuters Tech → MarketWatch/Yahoo/Seeking Alpha
2026-01-23 07:55:53 +04:00
Elie Habib 1312cb5d85 Fix blocked RSS feeds in tech variant
Replace feeds that return 403 on Vercel edge servers:
- Wired, Engadget → ZDNet, TechMeme
- OpenAI Blog, Google AI Blog → Google News searches
- Bloomberg Tech, Reuters Tech → MarketWatch, Yahoo Finance, Seeking Alpha
2026-01-22 23:35:19 +04:00
Elie Habib eb0044b5d9 Add debug endpoint for env vars 2026-01-22 23:31:58 +04:00
Elie Habib b125e8dd77 Add YouTube live stream detection API endpoint 2026-01-22 23:29:51 +04:00
Elie Habib 5d037c4132 Add tech variant with expanded global tech ecosystem data
- Add variant system (full/tech) with VITE_VARIANT env var
- Create tech-geo.ts with 465 entries:
  - 295 TECH_HQS (FAANG, unicorns across US, Europe, MENA, India, SEA, China, LATAM, Africa)
  - 112 ACCELERATORS (YC, Techstars, 500 Global, regional accelerators)
  - 38 STARTUP_HUBS (mega/major/emerging tiers)
  - 20 CLOUD_REGIONS (AWS, GCP, Azure, Cloudflare)
- Add map layers: startupHubs, cloudRegions, accelerators, techHQs
- Add tech-specific RSS feeds and panels
- Fix YouTube channel fallback IDs (Yahoo Finance, NASA TV, TBPN)
- MENA expansion: 50+ companies (UAE, Saudi, Egypt, Jordan)
- India: 40+ unicorns (Flipkart, PhonePe, Razorpay, etc)
- SEA: 25+ companies (Grab, GoTo, J&T Express, etc)
- LATAM: 35+ companies (Nubank, MercadoLibre, Bitso, etc)
2026-01-22 23:18:32 +04:00
Elie Habib df18589e65 Add immediate foreign military presence detection
Detects when aircraft from foreign nations concentrate in sensitive
regions (Persian Gulf, Taiwan Strait, Baltics, etc.) without waiting
for baseline establishment. Thresholds: 2 aircraft for US/Russia/China,
3 for UK/France/Germany when operating outside home regions.
2026-01-22 20:50:12 +04:00
Elie Habib 6269cf0d1b Add military airlift surge detection
Detect unusual transport activity spikes to military theaters:
- Track 5 theaters: Middle East, E/W Europe, Pacific, Horn of Africa
- Compare current transport flights to 48-hour baseline
- Alert when activity exceeds 2x baseline with 5+ aircraft
- Identify C-17/REACH patterns and nearby bases
- Severity: critical (4x), high (3x), medium (2x)

Suppressed during 15-minute learning mode like other alerts.
2026-01-22 17:03:11 +04:00
Elie Habib f636c2b986 Reduce memory pressure from animations and YouTube iframe
- Add global idle detection (2 min) to pause CSS animations when user inactive
- Destroy YouTube iframe when idle instead of just pausing (saves ~115 kB/s)
- Reinitialize YouTube player when user returns from idle
- Clean up idle detection listeners on destroy

The 30+ CSS animations were causing constant GPU work and memory churn.
YouTube iframe keeps allocating memory even when paused because the
iframe stays loaded. Now we completely destroy it after 5 minutes idle.
2026-01-20 23:19:50 +04:00
Elie Habib d13d539f30 Suppress geo convergence alerts during learning mode
Geographic Convergence alerts were firing with unreliable data during
the 15-minute learning warmup period. Now these alerts are suppressed
until learning mode completes and data stabilizes.
2026-01-20 22:33:39 +04:00
Elie Habib 0f7fda3cef Remove popup-body max-height constraint - let popup handle scrolling 2026-01-20 18:21:39 +04:00
Elie Habib be68744630 Fix popup clipping: append to body with fixed positioning
- Append popup to document.body instead of map container
- Use viewport-relative coordinates for positioning
- Change to position: fixed with z-index: 1000
- Popup no longer clipped by map container overflow:hidden
2026-01-20 18:15:54 +04:00
Elie Habib 04c0410882 Fix popup overflow: enable scroll, dynamic max-height 2026-01-20 18:04:42 +04:00
Elie Habib 887c225e83 Fix hotspot popup positioning to avoid bottom panels
- Smart vertical positioning: flip above click when space below is limited
- Account for bottom panels with 200px buffer
- Handle horizontal overflow by positioning left of click
- Approximate popup height (500px) for hotspot popups with escalation info
2026-01-20 17:53:08 +04:00
Elie Habib ddd2d4a0e7 Add country labels and location info to hotspots
- Add 22 more African country labels (Niger, Mali, Chad, CAR, DRC, etc.)
- Add location field to Hotspot interface
- Add location values to all hotspots (e.g., "Sahel Region (Mali, Burkina Faso, Niger)")
- Display location in hotspot popup before coordinates
2026-01-20 17:35:22 +04:00
Elie Habib 882dd2d4fa Improve conflict locations and add Greenland label
- Add descriptive location names to all conflict zones (e.g., "Eastern Ukraine", "Gaza Strip")
- Add Greenland to country labels list
- Change Nuuk hotspot subtext from "Arctic Dispute" to "Greenland Intel"
2026-01-20 17:14:00 +04:00
Elie Habib ea3f05f551 Filter wildfires to 48h max, remove irradiator labels
- Wildfires older than 48 hours are now filtered out
- Removed city labels from gamma irradiator markers (click for details)
2026-01-20 17:11:09 +04:00
Elie Habib 88621ae937 Fix base layer disappearing with robust DOM verification
- Always refresh d3 selections from DOM before render to prevent stale references
- Use native DOM operations (removeChild) instead of d3.selectAll('*').remove()
- Add post-render verification to catch silent failures
- Add 30-second health check interval to detect and recover missing countries
- Improve ensureBaseLayerIntact to detect stale d3 selections
- Add detailed console logging for debugging layer issues
2026-01-20 17:09:05 +04:00
Elie Habib ebf613db84 Add proactive base layer recovery on zoom/pan
- Add ensureBaseLayerIntact() method to detect missing countries
- Call recovery check after setZoom() and setCenter()
- Triggers render() if base layer is empty but should have content
- Prevents black map after clicking location links
2026-01-20 14:38:32 +04:00
Elie Habib 8a15337492 Improve base layer recovery with native DOM checks
- Use native DOM API instead of d3 selections for safety checks
- Count actual .country elements to verify base layer content
- Add debug logging when base layer needs emergency re-render
- More robust orphan cleanup using querySelectorAll
2026-01-20 14:35:51 +04:00
Elie Habib f6b9f7d5d7 Add missing overlays-svg group for military track lines
- Create overlays-svg group inside dynamicLayerGroup on each render
- Fix references to use dynamicLayerGroup instead of svg root
- This restores military flight/vessel track line rendering
2026-01-20 13:39:18 +04:00
Elie Habib 97d522feb9 Fix map base layer disappearing after extended use
Add safety checks to detect if layer groups were removed from DOM:
- Verify baseLayerGroup/dynamicLayerGroup are still attached to SVG
- Check if base layer has actual content (.country elements)
- Recreate layer groups if missing
- Force base re-render if content is missing

This prevents the map from going blank after 20+ minutes of use.
2026-01-20 13:37:53 +04:00
Elie Habib f68e20db39 Optimize map rendering with base/dynamic layer split
- Cache country features from topojson (avoid repeated conversion)
- Split SVG into baseLayerGroup (static) and dynamicLayerGroup (per-render)
- Only rebuild base layer when container size changes
- Replace renderSanctions with updateCountryFills (toggle without rebuild)
- Refactor renderGrid/renderGraticule/renderCountries to accept target group

This reduces expensive full-SVG rebuilds on every render() call.
2026-01-20 12:52:15 +04:00
Elie Habib 1af535d536 Fix YouTube iframe postMessage origin error
Add origin and enablejsapi parameters to YouTube player config
to resolve cross-origin postMessage security errors.
2026-01-20 12:24:52 +04:00
Elie Habib e05786291d Update README with recent features and expanded documentation
- Update version badge to 1.5.0
- Document CII Learning Mode (15-minute warmup with visual indicators)
- Add Entity Extraction System section (company/country/leader detection)
- Add Signal Context section ("Why It Matters" explanations)
- Expand Data Freshness with core vs optional sources
- Document Live News Stream optimizations (YouTube Player API, idle detection)
2026-01-20 12:02:49 +04:00
Elie Habib b5d80d653b Use YouTube IFrame Player API for LiveNewsPanel
- Replace iframe rebuilding with persistent YT.Player instance
- Load YouTube IFrame API once via static promise
- Use player.playVideo()/pauseVideo() for idle detection
- Use player.mute()/unMute() for sound toggle
- Use player.loadVideoById() for channel switching
- Eliminates iframe reloads on every state change
2026-01-20 11:55:54 +04:00
Elie Habib d5cd174516 Bump version to 1.5.0 2026-01-20 11:36:08 +04:00
Elie Habib 66bbcc69ec Add CII Learning Mode with 15-minute warmup period
- Add learning mode state tracking (startLearning, isInLearningMode, getLearningProgress)
- Show learning banner in CII panel with progress bar and countdown
- Show learning indicator in Strategic Risk panel (both full/limited views)
- Suppress CII spike alerts during learning period
- Fix floating point display by rounding component scores
- Add CSS styles for learning mode UI elements
2026-01-20 11:34:44 +04:00
Elie Habib 62eb586ae2 Add clickable locations in Strategic Risk Overview panel
- Top convergence risk is now clickable to zoom to location
- Recent alerts with location show click icon and zoom on click
- Added CSS styling for clickable risk items and alerts
2026-01-20 10:48:02 +04:00
Elie Habib f4a1834998 Add clickable location in Intelligence Finding modal
- Location coordinates are now a button with arrow icon
- Clicking zooms map to location (zoom level 4) and dismisses modal
- Added CSS styling for location-link button
2026-01-20 10:42:06 +04:00
Elie Habib cc6d01393f Fix duplicate alerts and convergence priority thresholds
- Fix double-add bug: createConvergenceAlert was adding to array,
  then updateAlerts added again. Now uses addAndMergeAlert only.
- Add getPriorityFromConvergence() with proper thresholds:
  4+ types or 90+ score = critical, 3 types or 70+ = high
- Use stable IDs (conv-${cellId}) for convergence deduplication
- Improve alert title to show country name instead of generic text
2026-01-18 18:52:57 +04:00
Elie Habib e79a4d0cdf Fix duplicate alerts in Intelligence Findings
- Use stable ID for CII alerts (cii-${country}) instead of random IDs
- Add ID-based deduplication in addAndMergeAlert() to update existing
  alerts rather than creating duplicates
2026-01-18 18:10:36 +04:00
Elie Habib f6eae5990d Fix memory leaks and align intelligence findings thresholds with CII
- NewsPanel: Store and remove scroll/click activity listeners in destroy()
- App: Add comprehensive cleanup for snapshot interval, refresh timeouts,
  and global event listeners (keydown, fullscreen, resize, visibility)
- App: Add isDestroyed flag to stop refresh chains on destroy
- Cross-module-integration: Align priority thresholds with CII levels
  (critical at 81+, high at 66+, medium at 51+)
2026-01-18 17:55:06 +04:00
Elie Habib ca33bfa90f Fix additional memory leaks: LiveNewsPanel, RSS cache, circuit-breaker
1. LiveNewsPanel: Store bound handlers for idle detection listeners, add destroy()
   - Removes 5 global document event listeners on cleanup
   - Clears idle timeout

2. RSS service: Add cache cleanup to prevent unbounded growth
   - MAX_CACHE_ENTRIES limit (100)
   - cleanupCaches() removes stale entries (>2x TTL old)
   - Auto-cleanup when cache reaches 50% capacity

3. Circuit-breaker: Add removal functions for registry cleanup
   - removeCircuitBreaker(name) for individual cleanup
   - clearAllCircuitBreakers() for full reset
2026-01-18 17:09:25 +04:00
Elie Habib cbf45be223 Fix additional memory leak issues
1. analysis-worker.ts: Clear pending requests on reset() to prevent hanging promises
2. App.ts: Store time interval ID and add destroy() method for cleanup
   - destroy() clears time interval, calls map.destroy(), disconnects AIS stream
2026-01-18 17:06:24 +04:00
Elie Habib b3611f393b Fix structural memory leaks in UI components
1. Map.ts: Store and clear timestamp interval in destroy()
2. Panel.ts: Store tooltip close handler reference and remove in destroy()
3. WindowedList: Add destroy() method to clean up scroll listener
4. ActivityTracker: Add unregister() method to remove panel callbacks
5. NewsPanel: Add destroy() to clean up WindowedList and ActivityTracker
2026-01-18 16:49:31 +04:00
Elie Habib d524974715 Fix memory/UI flickering: add render throttling and pause animations when hidden
- Add render throttling to Map.ts (100ms minimum interval)
- Add scheduleRender() to coalesce rapid render calls
- Add CSS rule to pause all animations when tab is hidden
- Toggle animations-paused class on body via visibility API
2026-01-18 16:46:58 +04:00
Elie Habib 2ef99d2565 Add idle detection to pause YouTube player when tab hidden or inactive 2026-01-18 16:36:33 +04:00
Elie Habib 965f31aefc Fix duplicate CII alerts - remove redundant push 2026-01-18 14:02:12 +04:00
Elie Habib 3cc9b341ba Fix signal modal background to solid black 2026-01-18 14:00:12 +04:00
Elie Habib 4c62341c51 Update README with recent features and documentation
- Update version badge to 1.4.2
- Document Intelligence Findings badge with click-to-detail behavior
- Add CII scoring bias prevention section (log scaling, conflict floors)
- Document alert warmup period preventing startup false positives
- Add protest map filtering (riots/high severity only)
- Document build-time version sync via Vite define
- Add map marker design section (label-free interaction patterns)
- Update Roadmap completed section with 5 new features
2026-01-18 13:56:04 +04:00
Elie Habib d57b27a375 Auto-inject version from package.json into header
- Add __APP_VERSION__ define in vite.config.ts
- Create vite-env.d.ts for TypeScript declaration
- Replace hardcoded version in App.ts with dynamic value
2026-01-18 13:45:43 +04:00
Elie Habib e7a01b7b09 Bump version to 1.4.2 2026-01-18 13:42:46 +04:00
Elie Habib 7d501ebac3 Add click handler for CII alerts in findings badge
- Add showAlert() method to SignalModal
- Display CII change details (country, score, level, driver)
- Display convergence details (location, types, event count)
- Display cascade details (source, countries affected, impact)
- Wire up alert click handler in App.ts
2026-01-18 13:41:36 +04:00
Elie Habib 5978c298e6 Show only high severity and riot protests on map 2026-01-18 13:36:11 +04:00
Elie Habib 0bb435840b Remove protest labels (click for details) 2026-01-18 13:35:21 +04:00
Elie Habib 9c1fd0c080 Remove economic center labels (click for popover) 2026-01-18 13:34:11 +04:00
Elie Habib 36845cebac Bump version to 1.4.1 2026-01-18 13:29:16 +04:00
Elie Habib 3d32aa5f1f Fix CII startup spike and unrest score bias
- Add warmup period (2 cycles) before emitting CII alerts
- Apply log scaling to unrest score for high-volume democracies
- Prevents false "instability spike" alerts on initial data load
2026-01-18 13:28:24 +04:00
Elie Habib 28df083b1f Remove nuclear plant labels, filter protests to significant events
- Remove nuclear facility labels from map (click for popover details)
- Filter protests to show only significant events on map:
  - Always show: riots, high severity, fatalities > 0, validated
  - Show medium at zoom >= 3
  - Hide low severity (still counted in CII analysis)
2026-01-18 13:21:44 +04:00
Elie Habib 30823deb26 Fix CII scoring bias and multiple bug fixes
- Fix confidence scale mismatch (0-1 vs 0-100) in IntelligenceGapBadge
- Fix CII scoring for high-volume news countries (US/UK) using log scaling
- Add conflict zone floor scores (UA=55, SY=50, YE=50, MM=45, IL=45)
- Fix composite summary sign display for negative changes
- Fix SignalModal.show() not rendering/displaying modal
- Add Map visibility listener cleanup and destroy() method
2026-01-18 13:17:14 +04:00
Elie Habib bd6a4f52c0 Make intelligence findings human-readable
- Use full country names instead of codes (SA → Saudi Arabia)
- Improve CII alert titles: "Saudi Arabia Instability Spike"
- Better summaries: "Instability index rose from 8 to 28 (+20)"
- Smarter merged alerts with progression tracking
- Priority-based insights: Critical/Significant/Developing
- Add "more findings" modal with all items scrollable
- Clickable findings open detail view
2026-01-18 13:08:10 +04:00
Elie Habib 35c52989a7 Fix map disappearing after browser idle
- Skip render() when container has 0 dimensions (browser throttling)
- Add visibilitychange listener to re-render when tab becomes active
- Prevents clearing SVG when nothing can be drawn
2026-01-18 13:03:32 +04:00
Elie Habib 9b98006f44 Consolidate alerts and signals into unified findings badge
- Merge CorrelationSignals and UnifiedAlerts into single badge
- Create UnifiedFinding type to normalize both data sources
- Show CII spikes, geo convergence, and cascade alerts alongside signals
- Add critical priority styling (red with pulse animation)
- Priority-based left border colors on finding items
- Configurable alert time window (6 hours)
2026-01-18 12:57:36 +04:00
Elie Habib af1faad002 Consolidate to single intelligence findings badge
- Remove redundant  signal badge from SignalModal
- Keep 🎯 findings badge as single entry point
- Add showSignal() method to open modal for specific signal
- Move sound alerts to findings badge (plays on new signals)
- Connect findings badge clicks to signal modal
- Remove unused .signal-badge CSS (~800 bytes saved)
2026-01-18 12:45:55 +04:00
Elie Habib 10fc81fdb0 Improve IntelligenceFindingsBadge based on code review
- Rename class to IntelligenceFindingsBadge (export alias for compat)
- Add event delegation for finding clicks (removes per-render listeners)
- Properly cleanup document click listener in destroy()
- Extract magic numbers to named constants
- Add missing signal type icons (news_leads_markets, velocity_spike, etc)
- Store signals as class property for event delegation access
2026-01-18 12:40:09 +04:00
Elie Habib fb83704860 Repurpose intelligence badge to show findings instead of gaps
- Renamed from IntelligenceGapBadge to show correlation signals/findings
- Shows detected signals from analysis worker with confidence levels
- Color-coded by signal count and confidence (gray/blue/orange)
- Pulse animation on new signals
- Clicking findings opens signal modal
- Added sector_cascade signal type to analysis-constants
2026-01-18 12:35:17 +04:00
Elie Habib c3f2a5bba9 Add data freshness tracking for all data sources 2026-01-18 12:25:33 +04:00
Elie Habib a0f136c30a Sync data freshness with map layer toggles to fix false intelligence gaps 2026-01-18 12:21:51 +04:00
Elie Habib 34c6172b91 Fix intelligence gap dropdown transparent background 2026-01-18 12:11:59 +04:00
Elie Habib 34c2719c9e Fix intelligence gap badge dropdown positioning 2026-01-18 12:09:51 +04:00
Elie Habib 478bb05902 Update header version to 1.4.0 2026-01-18 12:06:11 +04:00
Elie Habib 5e298ad297 Add country codes for Haiti, Cairo, Doha, Beirut hotspots 2026-01-18 12:03:36 +04:00
Elie Habib 8a060dd1d6 Add dynamic hotspot escalation scoring system
- Create hotspot-escalation.ts service with weighted scoring algorithm
- Components: News (35%), CII (25%), Geo-convergence (25%), Military (15%)
- Formula: combinedScore = staticBaseline × 0.3 + dynamicScore × 0.7
- Add trend detection via linear regression on 24h history
- Add signal emission for threshold crossings, rapid increases, critical levels
- Display component breakdown bars in hotspot popups
- Bump version to 1.4.0
2026-01-18 11:46:58 +04:00
Elie Habib 9401e83f17 Remove conflict zone labels from map overlay
The conflict names were cluttering the map. Conflict zones remain
visible as highlighted regions and are still clickable for details.
2026-01-18 11:26:24 +04:00
Elie Habib f78439eb82 Add geopolitical intelligence quick wins: escalation scores, context, freshness
Assessment & Documentation:
- Add docs/GEOPOLITICAL_ASSESSMENT.md with full platform analysis
- Strategic improvement roadmap with prioritized recommendations

Quick Win #1 - Data Freshness (intelligence gaps):
- Add getIntelligenceGaps() and getIntelligenceGapSummary() to data-freshness.ts
- Add human-readable messages explaining what analysts CAN'T see
- Add hasCriticalGaps() for alert integration

Quick Win #2 - Escalation Scores:
- Add escalationScore (1-5), escalationTrend, escalationIndicators to Hotspot type
- Update 11 major hotspots with scores (Sahel, Haiti, Horn of Africa, Moscow,
  Beijing, Kyiv, Taipei, Tehran, Tel Aviv, Pyongyang, Sana'a)

Quick Win #3 - Signal Context ("Why It Matters"):
- Add SIGNAL_CONTEXT with whyItMatters, actionableInsight, confidenceNote
- Add getSignalContext() helper for all 10 signal types
- Explains analytical significance of each signal type

Quick Win #4 - Historical Context:
- Add HistoricalContext interface with lastMajorEvent, precedentCount,
  cyclicalRisk fields
- Add whyItMatters field to Hotspot type
- Update major hotspots with historical precedents and geopolitical significance

Quick Win #5 - Propaganda Risk Flags:
- Add PropagandaRisk type and SourceRiskProfile interface
- Add SOURCE_PROPAGANDA_RISK mapping for state media (Xinhua, TASS, RT, CGTN)
- Add getSourcePropagandaRisk() and isStateAffiliatedSource() helpers
- Flag medium-risk state-affiliated sources (Al Jazeera, France 24, DW, etc.)
2026-01-18 10:47:42 +04:00
Elie Habib b0c1c0d83c Switch blocked feeds to Railway proxy (News24, SCMP, Arab News, Al Arabiya, Times of Israel) 2026-01-18 10:25:10 +04:00
Elie Habib c2ade79cbd Update README: reflect actual regional feed sources (Google News aggregation) 2026-01-18 10:21:15 +04:00
Elie Habib 4a7942e6ed Update README with comprehensive feature documentation and fix regional feeds
README updates:
- Document spaceports layer and critical minerals deposits
- Add regional intelligence panels section (Africa, LatAm, Asia-Pacific, Energy)
- Document undersea cable activity monitoring feature
- Expand news clustering section with inverted index optimization details
- Add data freshness tracking documentation
- Expand entity coverage to 100+ with sector breakdown
- Document mobile experience optimizations

Feed fixes:
- Replace blocked regional RSS feeds (403 errors) with Google News RSS searches
- Add reliable fallback feeds (BBC regional, News24, SCMP, Guardian)
- Fix Africa, Latin America, Asia-Pacific, and Energy panels news availability
2026-01-18 10:19:26 +04:00
Elie Habib 08a4d6bfe1 Add regional panels, spaceports, and critical minerals layers
- Add Africa, Latin America, Asia-Pacific, Energy & Resources panels
- Add Spaceports layer with launch sites (Cape Canaveral, Baikonur, etc.)
- Add Critical Minerals layer (lithium, rare earths deposits)
- Add new hotspots: Sahel Region, Haiti, Horn of Africa
- Add entity registry entries for defense, semiconductors, minerals
- Add CSS styles for spaceport and mineral markers
- Add mobile touch targets and label hiding for new markers
- Bump version to 1.3.9
2026-01-18 10:09:33 +04:00
Elie Habib 8440a89a3c Fix duplicate market move signals: use symbol-only dedupe key with 6hr TTL 2026-01-18 08:08:31 +04:00
Elie Habib 31b00ab75d Mobile header: replace @eliehabib text with X logo 2026-01-17 23:01:54 +04:00
Elie Habib 2906dcffe7 Remove weather labels from map, keep only icons 2026-01-17 22:58:35 +04:00
Elie Habib 3b8bae3c0a Fix black bars: extend SVG background to cover pan/zoom transforms 2026-01-17 22:53:39 +04:00
Elie Habib 398fe9e290 Fix Europe view: reduce zoom and pan to stay within map bounds 2026-01-17 22:45:57 +04:00
Elie Habib b12b73105c Disable country labels by default 2026-01-17 22:43:27 +04:00
Elie Habib a267fdf805 Revert to fixed pan values for regional views 2026-01-17 22:41:27 +04:00
Elie Habib c19d05e15e Improve map defaults and mobile experience
- Use setCenter() with lat/lon for regional views instead of fixed pan values
  (positioning now relative to container size)
- Remove hotspot labels from map for cleaner view
- Disable protests layer by default, keep bases enabled
- Hide DEFCON indicator and FOCUS region selector on mobile
- Fix URL lat/lon not overriding view presets
2026-01-17 22:37:37 +04:00
Elie Habib 7e3271ef2f Add entity-aware market correlation and signal deduplication
- Entity knowledge base with 45+ entities (companies, commodities, crypto, countries, people)
- Entity index for O(1) lookups by alias, keyword, sector, type
- News-to-entity matching for intelligent market-news correlation
- New signal type: explained_market_move (when news correlates with price action)
- Improved silent_divergence (only fires after exhaustive entity search)
- Per-signal-type TTL deduplication (6hr for market signals vs 30min default)
- README documentation for all new features and algorithms
2026-01-17 22:20:19 +04:00
Elie Habib dc03da50be Fix regional FOCUS view pan settings for new projection
Recalibrated all regional view pan values for the projection
centered at (0°, 8°N) with 72°N to 56°S latitude range:
- Oceania: now shows Australia/Pacific correctly
- Asia: now centered on China/Japan/India
- Europe: zoomed out (2.6) to show full continent including Murmansk
- MENA: centered on Middle East/North Africa
- America: shows full Americas
- Lat Am: focused on South/Central America
- Africa: centered on African continent
2026-01-17 21:25:02 +04:00
Elie Habib 0923e92335 Fix latitude bounds to include Greenland (72°N to 56°S) 2026-01-17 21:12:32 +04:00
Elie Habib db6d7829f1 Improve map layout: crop polar regions, enable scrolling
- Crop latitude range to 60°N-55°S (removes empty Arctic/Antarctic)
- Map now uses full width with cropped height
- Enable page scrolling (overflow-y: auto on main-content)
- Reduce default map height to 50vh for better panel visibility
2026-01-17 21:10:03 +04:00
Elie Habib 13c6bfc7a7 Fix map projection to show full world including polar regions
Scale calculation now considers both width (360° longitude) and height
(180° latitude), using the minimum to prevent clipping. Previously only
width was considered, causing northern regions (Greenland, Alaska,
Scandinavia) and Antarctica to be truncated.
2026-01-17 21:02:22 +04:00
Elie Habib aeeed5c610 Security harden GDELT geo API proxy
- Add CORS allowlist (same pattern as EIA proxy)
- Add HTTP method validation (GET/OPTIONS only)
- Bound maxrecords to 1-500 range
- Validate format against allowlist (geojson, json, csv)
- Validate timespan against allowlist
- Sanitize query parameter (200 char limit, strip dangerous chars)
- Generic error messages (no internal details leaked)
- Add 5-minute cache header
- Proper upstream error handling (502 for bad gateway)
2026-01-16 16:46:58 +04:00
Elie Habib 7ecb1b1597 Security hardening for EIA and USASpending features
Fixes identified by red-team audit:

EIA API Proxy:
- Restrict CORS to allowed origins only (HIGH)
- Add HTTP method validation - GET/OPTIONS only (MEDIUM)
- Remove error message information leakage (HIGH)

USASpending Service:
- Add input validation bounds for daysBack (1-90) and limit (1-50)

EconomicPanel:
- Escape all dynamic values in templates (XSS prevention)
- Escape numeric values, trend colors, icons, dates
2026-01-16 16:18:41 +04:00
Elie Habib 5bbe126484 Fix EIA API routing with Vercel catch-all route 2026-01-16 15:41:48 +04:00
Elie Habib 2532cd2106 Add data freshness tracking for Oil/Spending + update README
- Add 'oil' and 'spending' as separate DataSourceIds in data-freshness
- Update oil-analytics.ts to report to 'oil' source
- Update usa-spending.ts to report to 'spending' source
- Add EIA and USASpending to StatusPanel API list
- Update README: version 1.3.8, new API dependencies, project structure
- Document EIA_API_KEY in optional API keys section
2026-01-16 13:15:43 +04:00
Elie Habib 27d00b85d3 Bump version to 1.3.8 2026-01-16 13:12:33 +04:00
Elie Habib 5b18693704 Improve SEO with comprehensive meta tags and JSON-LD schema
- Shorten description to 153 chars (was 203)
- Add canonical URL
- Add search discovery metas (subject, classification, coverage)
- Expand keywords with all tracked features
- Add og:image dimensions and locale
- Fix Twitter tags (name vs property) and add creator/site
- Add JSON-LD WebApplication structured data with featureList
- Reference new og-image.png (1200x630) for social sharing
2026-01-16 13:09:58 +04:00
Elie Habib 9d2bba6271 chore: bump version to 1.3.7 2026-01-16 13:03:31 +04:00
Elie Habib 8f00e09382 feat: add USASpending.gov and EIA Oil Analytics
- Add USASpending.gov API integration for government contracts/awards
  - Fetches recent federal spending (no API key required)
  - Shows recipient, amount, agency, description

- Add EIA Oil Analytics API integration
  - WTI and Brent crude prices
  - US production and inventory
  - Requires EIA_API_KEY (free from eia.gov/opendata)

- Enhanced EconomicPanel with tabs:
  - Indicators (FRED)
  - Oil (EIA)
  - Gov (USASpending)

- Added api/eia.js Vercel proxy for EIA API
2026-01-16 13:03:16 +04:00
Elie Habib f2d7729bda chore: bump version to 1.3.6
Merged:
- PR #22: RSS batching + throttled per-batch renders
- PR #19: News clustering worker + inverted index (O(n²) → O(n×candidates))
2026-01-15 17:15:37 +04:00
Elie HabibandGitHub 83d55f639a Offload news clustering to worker and reduce O(n²) comparisons (#19)
### Motivation
- Clustering was performed on the main UI thread via `clusterNews` in
`NewsPanel.renderNews`, which can stall rendering for large inputs.
- The core clustering used a nested pairwise loop (O(n²) Jaccard
comparisons) that becomes expensive for larger news lists.

### Description
- Offload UI clustering to the existing analysis worker by calling
`analysisWorker.clusterNews(items)` from `NewsPanel` and render results
only after the worker returns, protecting against stale results with a
`renderRequestId` guard and error handling
(`src/components/NewsPanel.ts`).
- Reduce pairwise comparisons in the clustering core by adding a token
inverted-index and candidate set pass so items only compare against
others that share tokens, preserving Jaccard-based behaviour while
drastically cutting the number of comparisons
(`src/services/analysis-core.ts`).
- Keep tokenization and Jaccard similarity logic intact while
introducing `tokenList` and `invertedIndex` to build candidate buckets
and iterate sorted candidate indices for clustering.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6968b4ebae98832e89e61bbb94e51c95)
2026-01-15 17:14:26 +04:00
Elie HabibandGitHub 30df59251c Optimize RSS batching and throttle per-batch news renders (#22)
### Motivation
- Reduce CPU overhead from repeatedly sorting the full RSS item list
during batch processing in `fetchCategoryFeeds`.
- Reduce expensive repeated UI updates from calling `panel.renderNews`
on every batch in `loadNewsCategory`.

### Description
- Replace full-list sorts with a fixed-size incremental top-N buffer in
`src/services/rss.ts` by adding a `topItems` array, `insertTopItem`
helper, and returning a single sorted top-20 list via
`ensureSortedDescending`.
- Track total RSS items seen with `totalItems` and record freshness
using `dataFreshness.recordUpdate('rss', totalItems)` instead of the
sliced list length.
- Throttle per-batch panel updates in `src/App.ts` by adding
`scheduleRender`, `flushPendingRender`, a `pendingItems` slot, a
`renderTimeout`, and a `renderIntervalMs` (250ms) so `onBatch` calls
only trigger UI updates at most once per interval.
- Ensure any pending throttled render is cleared before the final
`panel.renderNews(items)` to avoid duplicate renders.

### Testing
- No automated tests were run on this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6968b63e5980832ea5b0dd1b98b33247)
2026-01-15 17:14:21 +04:00
Elie Habib 91af3c7666 fix: sync header version to 1.3.5 2026-01-15 16:40:06 +04:00
Elie Habib 766425d4f7 chore: bump version to 1.3.5
Merged:
- PR #18: Visibility-aware and jittered refresh scheduling
- PR #20: Defer cluster HTML rendering for windowed list
2026-01-15 14:12:17 +04:00
Elie HabibandGitHub fc19905b6e Defer cluster HTML rendering for windowed list (#20)
### Motivation
- Virtual scrolling was constructing full HTML for every cluster up
front which negates CPU/memory savings for large lists.
- Defer string construction so only visible chunks produce HTML while
still keeping per-cluster metadata for highlighting and "new" state.

### Description
- Removed the `html` field from `PreparedCluster` and now only store
metadata (`cluster`, `isNew`, `shouldHighlight`, `showNewTag`).
- Updated the `WindowedList` render callback in `initWindowedList` to
call `renderClusterHtml(...)` on-demand instead of using prebuilt HTML.
- Kept the eager HTML generation path for non-virtual (small) lists by
invoking `renderClusterHtml(...)` when `useVirtualScroll` is disabled or
the item count is below `VIRTUAL_SCROLL_THRESHOLD`.
- Preserved the existing `bindRelatedAssetEvents` behavior after
rendering chunks or direct HTML.

### Testing
- No automated tests were run.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6968b5cfb59c832e89e92feb7b296bb9)
2026-01-15 14:11:50 +04:00
Elie HabibandGitHub 980f17fb0d Visibility-aware and jittered refresh scheduling (#18)
### Motivation
- The existing `scheduleRefresh` loop ran at fixed intervals regardless
of tab visibility, causing unnecessary CPU and network usage when the
app was backgrounded.
- Slowing or pausing refreshes for hidden documents and adding jitter
reduces contention and improves background efficiency.

### Description
- Added `computeDelay` helper and constants (`HIDDEN_REFRESH_MULTIPLIER
= 4`, `JITTER_FRACTION = 0.1`, `MIN_REFRESH_MS = 1000`) to compute
jittered delays.
- Use `document.visibilityState === 'hidden'` to slow background
refreshes by the hidden multiplier and avoid running refreshes while
hidden.
- Apply jittered delays to all scheduled `setTimeout` calls including
the initial scheduling, branch where `condition` is false, when an
in-flight job exists, and after a refresh completes.
- Kept existing behavior of skipping refreshes when a provided
`condition` is false or a refresh is already in-flight, but now with
adjusted delays and jitter to stagger fetches.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6968b4689698832eb83312117af1b579)
2026-01-15 14:11:44 +04:00
Elie Habib 054cfeb605 Optimize RSS batching and throttled renders 2026-01-15 13:44:25 +04:00
Elie Habib 32f97f688f Defer cluster HTML rendering for windowed list 2026-01-15 13:43:29 +04:00
Elie Habib f28033abc1 Move news clustering off UI thread 2026-01-15 13:43:24 +04:00
Elie Habib 1fe4acbb03 Adjust refresh scheduling for hidden tabs 2026-01-15 13:42:49 +04:00
Elie Habib 10e479c16a Add Railway relay architecture and fix OpenSky OAuth2 documentation
- Document Railway relay infrastructure (AIS, OpenSky, RSS proxy)
- Correct OpenSky auth from Basic Auth to OAuth2 client credentials
- Add environment variables documentation for Railway deployment
2026-01-15 09:00:06 +04:00
Elie Habib 332c153a5b Expand README with undocumented features and system architecture
- Add Panel Management section (drag-to-reorder, visibility, persistence)
- Add Mobile Experience section (detection, reduced layers, touch gestures)
- Add Energy Flow Detection section (pipeline keywords, flow drop signals)
- Add Cross-Module Integration section (data flow architecture, dependencies)
- Add Refresh Intervals section (polling schedule, WebSocket streams)
2026-01-15 08:59:30 +04:00
Elie Habib f713262bcd Add Wingbits to status panel and update README documentation
- Add Wingbits as tracked data source in data-freshness.ts
- Report Wingbits enrichment status to data freshness tracker
- Add Aircraft Enrichment section to README with classification algorithm
- Add Wingbits to API Dependencies and Optional API Keys
- Add wingbits.ts and wingbits.js to project structure
- Add acknowledgment for Yanal at Wingbits for API access
- Update version badge to 1.3.4
2026-01-15 08:58:15 +04:00
Elie Habib aec86413b6 Fix Wingbits proxy for edge runtime and bump version to 1.3.4
- Remove server-side cache (edge runtime is stateless)
- Use HTTP Cache-Control headers instead (24h for aircraft details)
- Make batch fetching parallel with Promise.all
- Fix health endpoint referencing removed cache
2026-01-15 08:49:21 +04:00
Elie Habib 54b91a80cb Add Wingbits aircraft enrichment for military flight tracking
- Add api/wingbits.js proxy (keeps API key server-side)
- Add src/services/wingbits.ts client with caching
- Integrate enrichment into military-flights.ts
- Add enriched field to MilitaryFlight type
- Batch lookup aircraft details for efficiency
- Analyze owner/operator to confirm military status
- Cache aircraft details (24h server, 1h client)
2026-01-15 08:43:14 +04:00
Elie Habib 6f879d899e Fix data freshness and OpenSky OAuth2 authentication
- Make ACLED optional (GDELT provides fallback protest data)
- Core sources now: gdelt + rss (was: acled + gdelt + rss)
- Update Railway proxy to use OAuth2 client credentials flow
- Cache OAuth2 token with auto-refresh before expiry
- Invalidate token cache on 401 response
2026-01-15 08:12:52 +04:00
Elie Habib 4061a4caf9 Fix app header version to match package.json (v1.3.3) 2026-01-13 23:08:38 +04:00
Elie Habib 650a25ba8c Bump version to 1.3.3 2026-01-13 23:05:18 +04:00
Elie Habib 2964d6d1ee Fix map vertical clipping issue (#16)
- Set transform-origin to top-left (0 0) on map-wrapper
- Update applyTransform to compensate for new origin with proper centering
- Recalibrate all view presets (Global, EU, MENA, Asia, etc.)
- Allow more generous panning at higher zoom levels

The map was being clipped at the top because CSS scale() from center
pushed content off-screen. Using top-left origin with calculated
offset maintains proper centering while preventing clipping.
2026-01-13 22:52:59 +04:00
Elie Habib af6adbf1cf Update README with v1.3.2 improvements
- Add live demo link and version badge
- Document OpenSky authentication requirements
- Add OpenSky credentials to API Dependencies table
- Update Roadmap with recent completed features
2026-01-13 22:34:53 +04:00
Elie Habib 02f8d6bd31 Add OpenSky authentication to Railway proxy
OpenSky blocks unauthenticated cloud IPs. Updated proxy to
use Basic Auth with OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET
environment variables.
2026-01-13 22:29:40 +04:00
Elie Habib ad55e15da6 Fix Strategic Risk alerts - wire up alert population
The alerts array was defined but never populated. Now:
- Convergence alerts are created from geo-convergence data
- CII change alerts are added when country scores change 10+
- Alerts are deduplicated and pruned after 24 hours
- Metrics (Convergence, High Alerts, Infra Events) now work
2026-01-13 22:24:46 +04:00
Elie Habib 089a7b817b Fix status panel logic for Shipping and Military feeds
- Shipping: Show ok if has data (regardless of WebSocket state)
- Military: Show warning (yellow) if 0 items instead of ok
- Base status on data presence, not just connection state
2026-01-13 22:16:38 +04:00
Elie Habib d045e62d42 Show location names instead of coordinates in Top Risks
Export getLocationName() and use it to show human-readable
locations like "East Asia" or "Strait of Hormuz" instead of
raw coordinates like "35°, 136°".
2026-01-13 15:43:12 +04:00
Elie Habib eb64305adf Add 'disabled' status for feeds/APIs in Status panel
- Disabled feeds show gray dot and dimmed row
- Disabled items don't count towards status icon (error/warning)
- Default status is now 'disabled' until data is fetched
- Added setFeedDisabled/setApiDisabled methods
2026-01-13 15:34:28 +04:00
Elie Habib 8dc5d65e60 Remove duplicate unstable countries section from Strategic Risk panel 2026-01-13 15:32:50 +04:00
Elie Habib 79c06af53c Fix protests data freshness when using GDELT fallback
Record GDELT protest count to 'acled' freshness source since
GDELT serves as fallback when ACLED is not configured. This
ensures Strategic Risk panel shows protests as available.
2026-01-13 15:28:40 +04:00
Elie Habib 0c8c80be28 Fix GDELT protests threshold filtering out all events
Lowered count threshold from 200 to 5. The 200+ requirement
was too restrictive, causing empty protest data when ACLED
is not configured.
2026-01-13 15:07:00 +04:00
Elie Habib a48cbb6886 fix(ui): replace confusing '2/10 feeds' with actual source names
- Changed "Limited Assessment (X/10 feeds)" to "Limited Data - [source names]"
- Changed "Full coverage (X feeds active)" to "All data sources active"
- Users now see which data sources are working instead of cryptic counts
- The X/10 was confusing because layer toggles don't map to backend APIs
2026-01-13 14:33:04 +04:00
Elie Habib a9f5b6a72b fix(CII): correct hotspot mapping and country matching bugs
- Add 'usa' keyword to match US military flight data (was being ignored)
- Fix beirut mapping: IL → IR (Beirut is in Lebanon, Iran-backed groups)
- Fix brussels mapping: DE → FR (Brussels is in Belgium, not Germany)
- Remove cairo→IL mapping (Cairo is in Egypt, not Israel)
- Add Iran to Gaza conflict zone (gaza: IL,IR instead of just IL)

These bugs were causing incorrect CII scores:
- Israel was getting undeserved hotspot boosts from Beirut/Cairo activity
- US military flights weren't contributing to Security scores
- Iran wasn't getting credit for Gaza-related activity
2026-01-13 13:56:44 +04:00
Elie Habib 135d694a38 fix(ui): solid opaque background for info tooltips 2026-01-13 13:21:50 +04:00
Elie Habib 5f9ed34e36 feat(ui): add info tooltips (?) to panels with custom methodology
- Add infoTooltip option to Panel base class
- Strategic Risk: explains 50/30/20 weighting blend
- CII: explains baseline risk, components, hotspot boost
- Infrastructure Cascade: explains dependency modeling
- GDELT Intel: explains data source and update frequency
- Prediction Markets: explains probability pricing
- CSS for tooltip popup with arrow indicator
2026-01-13 13:18:44 +04:00
Elie Habib 6102d62b5d fix(cii): improve country scoring with location attribution, outages, and hotspot tracking
- Military flights/vessels now credited to LOCATION country (foreign military = threat)
- Internet outages boost unrest score (total=30pts, major=15pts, partial=5pts)
- Hotspot proximity tracking for INTEL_HOTSPOTS, CONFLICT_ZONES, STRATEGIC_WATERWAYS
- Activity near strategic locations adds up to 30 point boost to CII
- Iran/Taiwan/etc now properly score higher when foreign military operates over them
2026-01-13 13:11:14 +04:00
Elie Habib 08bf71436d fix: viewport coverage - fill entire browser window
- Add html { height/width: 100%; overflow: hidden }
- Add min-height/min-width: 100vh/100vw to body and #app
- Use position: absolute with inset 0 on #app for bulletproof coverage
- Add flex: 1 1 0 and min-height: 0 to main-content for proper flex behavior
- Ensure width: 100% on main-content

This should permanently fix black borders on right/bottom edges.
2026-01-13 12:51:05 +04:00
Elie Habib 1e56e58139 fix: strategic risk score was always 0 due to dilution
Bug: avgCIIDeviation divided by ALL 20 countries, diluting scores
- Israel at 31: deviation (31-30)/20 = 0.05 → ciiScore ≈ 0

Fix: Use weighted top-5 countries instead of average
- Top country: 40%, 2nd: 25%, 3rd: 20%, 4th: 10%, 5th: 5%
- Israel 31 + China 27 + others → weighted ~28 → composite ~14

Also increased CII weight from 35% to 50% (main risk driver)
2026-01-13 12:48:40 +04:00
Elie Habib ab7f609f78 fix: remove duplicate "near" prefix in location names
Bug: "in near Tel Aviv" was grammatically incorrect
Fix: Return just the hotspot name, let caller add "in" prefix
2026-01-13 12:45:04 +04:00
Elie Habib 9d40db01b7 fix: make geo-convergence signals human-readable
- Add reverse geocoding to convert coordinates to location names
- Checks conflict zones, strategic waterways, intel hotspots
- Falls back to regional names (Middle East, East Asia, etc.)
- Example: "(~31.5°, 34.5°)" now shows as "Gaza"
2026-01-13 12:42:51 +04:00
Elie Habib 6102e9558e fix: add ports/chokepoints to cascade panel stats display
- Update getGraphStats() to include port and chokepoint counts
- Add port () and chokepoint (🌊) stats to CascadePanel display
- Infrastructure Cascade now properly shows all node types
2026-01-13 12:39:19 +04:00
Elie Habib 57a49812c8 feat: Wire up Infrastructure Cascade for ports and chokepoints
Ports and chokepoints were added as nodes but had NO edges connecting
them to countries, so cascade analysis always showed 0 impacts.

Added:
- buildPortCountryEdges(): Connects ports to their host country and
  strategic trade partners (Suez ports → EU, Hormuz ports → Asia oil)
- buildChokepointEdges(): Connects chokepoints to nearby ports and
  dependent countries based on trade flows
- getAffectedCountries(): Strategic port impact mapping
- getChokepointDependentCountries(): Chokepoint dependency data

Now selecting Port of Aden correctly shows impacts on:
- Yemen (host country)
- UK, Germany, Italy (Europe-Asia shipping route)
- Saudi Arabia (regional trade)
2026-01-13 12:30:25 +04:00
Elie Habib b989569462 feat: Add geopolitical context to Country Instability Index
The CII was showing misleading results (Iran ≈ USA) because it only
counted detected events without accounting for:
- Reporting bias (US events over-reported, authoritarian states under-reported)
- Baseline geopolitical risk (some countries are inherently unstable)

Changes:
- Add BASELINE_RISK scores (0-50) for each country reflecting geopolitical reality
- Add EVENT_MULTIPLIER to weight events by significance:
  - Lower for open democracies (protests normal, well-covered)
  - Higher for authoritarian states (events suppressed, more significant)
- Blend formula: 40% baseline + 60% detected events

This makes:
- Active war zones (Ukraine, Syria, Yemen) always rank high
- Authoritarian states (Iran, NK, China) reflect actual risk
- Stable democracies (US, UK, DE) not inflated by normal protest activity
2026-01-13 12:19:36 +04:00
Elie Habib f154d44eca fix: Correct earthquakes API path and improve CoinGecko resilience
- Fix earthquakes API URL to use /api/earthquakes proxy (was 404)
- Add in-memory caching to CoinGecko proxy (2 min TTL)
- Return cached data on 429 rate limit instead of error
- Increase Cache-Control to 120s with stale-while-revalidate
2026-01-13 12:16:00 +04:00
Elie Habib 432bd45ff4 fix: Strategic Risk Panel now recalculates when data is ingested
- Move dataFreshness.recordUpdate() from protests.ts to App.ts
- Record freshness AFTER ingestProtestsForCII to avoid race condition
- Strategic Risk Panel subscription now triggers refresh() instead of render()
- Add debounce to freshness subscription to batch rapid updates

The issue was that the panel would refresh before CII data was ingested,
resulting in a score of 0 even when protests were enabled.
2026-01-13 12:13:30 +04:00
Elie Habib f6de43697f feat: data freshness tracking for Strategic Risk panel
- Add DataFreshnessTracker service to monitor data source health
- Shows "Insufficient Data" when no feeds active
- Shows "Limited Assessment" with partial data coverage
- Shows "Full Coverage" when all core feeds reporting
- Wire up protests.ts and rss.ts to report data freshness
- Add CSS for data availability states, source rows, action buttons
- Panel now shows which feeds need enabling
2026-01-13 11:44:15 +04:00
Elie Habib 5c04925a5b chore: add typecheck script and pre-push hook 2026-01-13 11:29:19 +04:00
Elie Habib bb68f94840 fix(ts): strict type errors in cross-module-integration 2026-01-13 11:28:00 +04:00
Elie Habib b2fb457915 feat: cross-module intelligence integration (v1.4)
New Intelligence Services:
- Country Instability Index (CII) - 6-component scoring system
- Geographic Convergence Detection - multi-signal spatial analysis
- Infrastructure Cascade Analysis - dependency graph propagation
- Cross-Module Integration - unified alerts and strategic overview

New Panels:
- CIIPanel - country instability dashboard with trends
- CascadePanel - infrastructure impact visualization
- StrategicRiskPanel - composite risk gauge with ring visualization

UX Improvements:
- Strategic Risk panel: ring gauge, 2x2 metrics grid, better spacing
- Improved empty states and section headers

Documentation:
- Updated README with algorithm details and design principles
- Cleaned up completed brainstorming docs

Bug Fixes:
- CII priority now handles negative changes correctly
- Initial trend shows "stable" instead of "de-escalating"
- Added detectConvergence wrapper function
2026-01-13 11:10:29 +04:00
Elie Habib 7d9d5b3d30 fix(ais-relay): race condition causing WebSocket crash
- Skip if socket is CONNECTING (not just OPEN)
- Use local socket reference to prevent event handler confusion
- Add race condition guards before send/message operations
- Set upstreamSocket=null on close for clean reconnection
2026-01-13 11:09:12 +04:00
Elie Habib 3e5acdb698 feat: add geographic convergence detection (v1.3.2)
- New geo-convergence service detecting 3+ event types in same 1° grid
- Tracks protests, military flights, naval vessels, earthquakes
- 24h rolling window with automatic pruning
- Integrated into signal panel with 🌐 icon
- Fix map SVG not extending to full canvas width
2026-01-13 09:31:07 +04:00
Elie Habib 61f14f7c1d Fix map canvas not filling container on initial load
Add ResizeObserver to detect when container gets proper dimensions
and re-render the map accordingly. Fixes black space appearing in
bottom-right corner on app startup.
2026-01-13 07:51:37 +04:00
Elie Habib edc65f647a Update help panel to mention ports in Shipping layer 2026-01-13 07:45:06 +04:00
Elie Habib ac3d897384 Add comprehensive ROADMAP.md with top 5 intelligence features
Top 5 priority features for geopolitical analysts:
1. Multi-Signal Geographic Convergence (I&W alerting)
2. Country Instability Index (composite risk scoring)
3. Temporal Anomaly Detection (baseline deviation)
4. Trade Route Risk Scoring (supply chain vulnerability)
5. Infrastructure Cascade Visualization (dependency mapping)

Also documents 30+ free APIs/RSS feeds for future integration:
- Economic: World Bank, IMF, UN Comtrade, OECD, BIS
- Sanctions: OFAC SDN, EU, UN, OpenSanctions
- Food security: FAO GIEWS, Food Price Monitor
- Humanitarian: UNHCR, ReliefWeb, IOM DTM
- Think tanks: RUSI, Chatham House, CFR, ECFR, etc.
2026-01-13 07:42:40 +04:00
Elie Habib cb790456b6 Update README with ports layer documentation 2026-01-13 07:36:58 +04:00
Elie Habib c2e52ed487 Add strategic ports layer to Shipping overlay
- Add 61 strategic ports: top container, oil/LNG terminals, naval, chokepoints
- Render ports under Shipping layer with type-colored markers
- Port popup shows name, country, type, world rank, and strategic notes
- CSS styles for 6 port types: container, oil, lng, naval, mixed, bulk
2026-01-13 07:36:04 +04:00
Elie Habib 32f3d9f231 Route UN News + CISA feeds via Railway proxy
- Add news.un.org and www.cisa.gov to Railway RSS allowlist
- Reuse existing VITE_WS_RELAY_URL for RSS proxy (no new env var needed)
- Falls back to Vercel proxy if Railway not configured
2026-01-12 22:29:38 +04:00
Elie Habib 2b9d6eb479 v1.3: Merge earthquakes + NASA EONET into unified NATURAL layer
- Add NASA EONET API integration for storms, wildfires, volcanoes, floods
- Combine USGS earthquakes + EONET events under single NATURAL toggle
- Filter EONET earthquakes to avoid duplicates (USGS is more authoritative)
- Load both data sources in parallel for faster initial render
- Update help text, CSS layer attributes, and zoom thresholds
- Version bump to 1.3.0
2026-01-12 22:01:44 +04:00
Elie Habib ebed082939 Add international org and regional wire RSS feeds
- Add think tanks: CSIS, RAND, Brookings, Carnegie (via Google News)
- Add crisis sources: CrisisWatch, IAEA, WHO, UNHCR
- Add regional wires: Xinhua (China), TASS (Russia)
- Remove broken Stratfor feed (404)
- Use Google News fallback for feeds with blocked direct RSS
2026-01-12 21:38:43 +04:00
Elie Habib 3bed58fe48 Refactor regional views and add intel RSS feeds
- Rename US view to AMERICA with proper North America coverage
- Remove AlbersUSA projection, use equirectangular for all views
- Fix EU view pan settings (was showing Africa)
- Add RSS feeds: Stratfor, CrisisWatch, UN News, CISA
- Remove unused US-specific map data loading
2026-01-12 20:53:32 +04:00
Elie Habib efc23337ca Add Focus region selector with EU, Asia, LatAm, Africa, Oceania views
- Replace view buttons with dropdown selector
- Add 8 region presets with custom zoom/pan settings
- Update URL state to support new views
- All regional views use world projection (except US)
2026-01-12 15:12:55 +04:00
Elie Habib 6360f30e49 Add Vercel Analytics 2026-01-11 19:32:41 +04:00
Elie Habib f1cb3a7d8c Fix blank space at top of map on global view reset
The pan.y=60 value intended to "pan north" actually caused a blank gap
at the top because SVG viewBox clips at its boundaries. CSS translate
shifts the rendered content but doesn't reveal unrendered areas.

Set pan.y=0 for global view to properly center the map.
2026-01-11 18:23:17 +04:00
Elie Habib bbdf2558ea Add Limitations & Caveats section to README
Documents that this is a proof of concept with:
- Data completeness gaps without paid accounts (ACLED, OpenSky)
- AIS coverage bias toward European/Atlantic waters
- Some data sources blocking cloud providers
2026-01-11 17:51:27 +04:00
Elie Habib 1d85eeb003 Document AIS EU coverage bias, update README with feed changes, remove debug logging 2026-01-11 17:45:37 +04:00
Elie Habib 489e16aa9c Lower AIS threshold to 2 vessels per cell for better ME/Asia coverage 2026-01-11 17:38:49 +04:00
Elie Habib 435856d4dd Always log zone distribution when shipping data is requested 2026-01-11 17:33:58 +04:00
Elie Habib 7b9b0b686d Lower AIS density threshold to 3 vessels, add regional debug logging 2026-01-11 17:30:12 +04:00
Elie Habib 9df91b2dd5 Remove The Telegraph feed - blocks all cloud providers
Akamai CDN blocks both Vercel and Railway IPs, making the
feed unreachable from any cloud infrastructure. Guardian
already provides UK news coverage.
2026-01-11 17:29:42 +04:00
Elie Habib 6ad3552a27 Add atlanticcouncil.org to RSS proxy whitelist 2026-01-11 17:24:39 +04:00
Elie Habib f702173609 Fix broken RSS feeds: replace CSIS/Brookings with Atlantic Council, CNN ME with CNN World 2026-01-11 17:22:04 +04:00
Elie Habib 6b3a1f78ff Add Railway RSS proxy for feeds that block Vercel
- Add /rss endpoint to ais-relay.cjs for blocked domains
- Add railwayRss() helper that routes to Railway relay
- Route Telegraph and CNN through Railway proxy
- Keeps security whitelist on Railway side
2026-01-11 17:21:16 +04:00
Elie Habib 37ecc5ebc3 Reduce AIS density circles to small dots (4-12px) to prevent blob effect 2026-01-11 17:18:04 +04:00
Elie Habib f194e16273 Fix broken RSS feeds
- Update Brookings URL to new /feeds/rss/research/ path
- Replace CFR (404) with Foreign Affairs (CFR's publication)
- Remove The Telegraph (blocks Vercel IPs)
- Add foreignaffairs.com to proxy whitelist
2026-01-11 17:16:29 +04:00
Elie Habib 916061dbe4 Increase AIS density threshold to 5 vessels per cell to reduce visual noise 2026-01-11 17:15:19 +04:00
Elie Habib f2989d34c6 Debug shipping density: lower threshold to 1, add logging 2026-01-11 17:03:32 +04:00
Elie Habib aaad2b9ca0 Add missing RSS feed domains to proxy whitelist 2026-01-11 17:00:40 +04:00
Elie Habib a878bbc10e Remove shipping density filter, use moderate opacity/size 2026-01-11 16:55:43 +04:00
Elie Habib 8d557a7696 Lower shipping density threshold to 0.2 for better coverage 2026-01-11 16:53:36 +04:00
Elie Habib abf49e531c Remove hotspot subtext labels from map display 2026-01-11 16:53:00 +04:00
Elie Habib 73f8bdd7ee Reduce shipping density visual noise: higher threshold, lower opacity, smaller circles 2026-01-11 16:51:03 +04:00
Elie Habib 7b045f8a9b Fix map view to show Europe/UK by default
- Revert projection to center at equator (stable)
- Set default pan.y=60 to shift view north, showing Europe/UK
- Ignore URL lat/lon when zoom <= 2 to prevent stale state issues
- Update setView() and reset() with consistent northward pan
2026-01-11 16:43:50 +04:00
Elie Habib 3a3609864a Update README with Live News panel, remove Congress Trades references
- Add Live News Streams section documenting 7 YouTube channels
- Add Politico, The Telegraph, Al Arabiya to news sources list
- Add LiveNewsPanel.ts to project structure
- Remove Congress Trades from StatusPanel feed list
- Update Data Export to mention CSV/JSON
2026-01-11 16:26:44 +04:00
Elie Habib 92d405d6f3 Add Live indicator with play/pause toggle, reorder channels and export options
- Add blinking "Live" dot that toggles video playback on click
- Reorder channels: Bloomberg, SkyNews, Euronews, DW, France24, AlArabiya, AlJazeera
- Show Export CSV above Export JSON in menu
2026-01-11 16:15:01 +04:00
Elie Habib 5328174ab3 Add mute/unmute toggle button to Live News panel header 2026-01-11 16:08:44 +04:00
Elie Habib 6c50bcf8c0 Fix Live News panel: shorten channel names, improve video height 2026-01-11 16:07:11 +04:00
Elie Habib d141fa5876 Add Live News panel with YouTube stream switcher
- Double-width panel with embedded YouTube player
- Channel switcher: Al Jazeera, Sky News, Bloomberg, France 24, DW, Euronews, Al Arabiya
- Auto-plays muted, red indicator on active channel
2026-01-11 16:04:00 +04:00
Elie Habib fcb5cd2656 Add Politico and The Telegraph to politics feeds 2026-01-11 15:52:25 +04:00
Elie Habib d1638606d8 Remove Congress Trades panel (inherently delayed data) 2026-01-11 15:49:35 +04:00
Elie Habib c167b83220 Improve feed recency: add direct RSS feeds, time filters, better sources
- Layoffs: Added layoffs.fyi, 3-day filter on Google News
- Think Tanks: Added direct Brookings, CSIS, CFR feeds
- AI/ML: Better Google News query with 2-day filter, The Verge AI
- Intel: Direct Defense News RSS, Janes, The War Zone direct feed
- Updated source tiers and types for new feeds
2026-01-11 15:44:46 +04:00
Elie Habib 8647da3c69 Improve Congress Trades search to get fresher results (7-day window) 2026-01-11 15:42:11 +04:00
Elie Habib 0fe796f725 Fix GDELT date parsing for compact format (20260111T093000Z) 2026-01-11 15:36:47 +04:00
Elie Habib 1b842d7756 Add fullscreen toggle button for TV setups, update version to 1.2 2026-01-11 15:28:40 +04:00
Elie Habib 52dc327af6 Add chunk splitting for d3 and topojson to reduce bundle size 2026-01-11 15:26:10 +04:00
Elie Habib 4e18adb35c Reduce Live Intelligence timespan to 24h for fresher articles 2026-01-11 15:23:59 +04:00
Elie Habib b79c43c04c Bump version to 1.2.0 2026-01-11 15:22:40 +04:00
Elie Habib a7226f84ad Filter GDELT articles to English only, reorder Military before Cyber 2026-01-11 15:22:00 +04:00
Elie Habib 8fd3e41c21 Fix GDELT article date parsing for ISO 8601 format 2026-01-11 15:16:20 +04:00
Elie Habib 59411c2bbe Update README with GDELT Doc API documentation 2026-01-11 15:14:01 +04:00
Elie Habib e4e8b24471 Add GDELT Doc API integration for live intelligence
- Add new Live Intelligence panel with topic tabs (Cyber, Military,
  Nuclear, Sanctions, Intelligence, Maritime)
- Enrich hotspot popups with real-time GDELT news context
- Create api/gdelt-doc.js proxy endpoint
- Add src/services/gdelt-intel.ts with topic configs and fetch logic
- Add GdeltIntelPanel component with tabbed interface
2026-01-11 15:13:28 +04:00
Elie Habib b4d4cd6041 Fix ACLED API endpoint URL and update docs
- Change ACLED API URL from api.acleddata.com to acleddata.com/api
- Update README to reflect Finnhub as primary stock data source
2026-01-11 15:00:38 +04:00
Elie Habib c167026ee0 Add Finnhub integration for stock quotes
- Add /api/finnhub proxy endpoint with batch symbol support
- Refactor markets.ts to use Finnhub for stocks (60 req/min vs Yahoo's unpredictable limits)
- Keep Yahoo Finance as fallback for indices (^GSPC, ^DJI) and commodities (GC=F, CL=F)
- Remove complex batching/cooldown logic (no longer needed)
- Rename 'Alpha Vantage' to 'Finnhub' in StatusPanel

Benefits:
- Predictable rate limits (60/min free tier)
- Single batch request for all stocks
- More reliable than Yahoo Finance scraping
- Richer data available (high/low/volume) for future use

Requires FINNHUB_API_KEY environment variable (free at finnhub.io)
2026-01-11 14:09:12 +04:00
Elie Habib d6fb040281 Fix z-index for all map markers to be above conflict click areas
All interactive markers now have z-index > 50 (conflict-click-area):
- Infrastructure (base, nuclear, irradiator, datacenter, outage): 51-52
- Cable markers (advisory, repair-ship): 52
- Events (earthquake, protest, ais-disruption, apt, flight-delay): 53
- Military vessels: 54
- Military flights: 55
- Military carriers: 56
- Military clusters: 57
2026-01-11 13:51:12 +04:00
Elie Habib 6084644c5b Rename FLIGHTS layer to DELAYS for clarity
We track airport delays/ground stops, not flights themselves.
Updated layer toggle label and help panel description.
2026-01-11 13:50:13 +04:00
Elie Habib 33499b854b Fix military markers being hidden by conflict click areas
- Increase z-index of military-flight-marker from 25 to 55
- Increase z-index of military-vessel-marker from 24 to 54
- Increase z-index of carrier vessels from 26 to 56
- All now above conflict-click-area (z-index: 50)
2026-01-11 13:47:54 +04:00
Elie Habib c23e42de71 Reorder panels for geopolitical analysis workflow
New order optimized for intelligence analyst workflow:
- Row 1: Intel Feed, World News, MENA, Government, Think Tanks
- Row 2: Predictions, Commodities, Markets, Economic, Financial
- Row 3: Technology, Crypto, Heatmap, Congress, AI/ML, Monitors

Changes:
- Intel Feed moved to top (curated expert sources surface threats faster)
- Prediction Markets elevated (crowd signals often lead news)
- Commodities before Markets (oil/VIX are geopolitical canaries)
- Layoffs Tracker disabled by default (low signal for geopolitical work)
- AI/ML moved lower (supporting context, not primary intelligence)
2026-01-11 13:42:32 +04:00
Elie Habib 63a048d8b8 Extend XSS escaping in MapPopup and StatusPanel
- Fix escapeHtml order (escape after toUpperCase, not before)
- Escape severityLabel in conflict/hotspot popups
- Escape coordinates in hotspot popup
- Escape itemCount, latency, and formatTime output in StatusPanel
2026-01-11 13:35:25 +04:00
Elie Habib e449cc6b62 Allow relative URLs in sanitizeUrl
- Try parsing as absolute URL first (existing behavior)
- Fall back to resolving against window.location.origin
- Return original relative URL if it resolves to http/https
- Still blocks dangerous protocols (javascript:, data:, etc.)
2026-01-11 13:18:04 +04:00
Elie Habib b98c1288d8 Escape dynamic values in StatusPanel innerHTML 2026-01-11 13:15:01 +04:00
Elie Habib 6acccefa46 Add in-flight guard to prevent overlapping refresh calls
- loadAllData now uses runGuarded() wrapper for each task
- loadDataForLayer also checks inFlight before starting
- Both use same inFlight Set as scheduleRefresh
- Prevents duplicate API calls and inconsistent panel state
2026-01-11 13:13:58 +04:00
Elie Habib 72b1fa83d6 Fix ACLED status misreporting null as not configured
Use strict equality (=== false) to distinguish between:
- null: unknown/transient failure → show warning status
- false: explicitly not configured → show 'not configured' message
- true: configured and working → show ok status
2026-01-11 13:13:12 +04:00
Elie Habib 50e16a151d Escape earthquake place and sanitize URL in MapPopup 2026-01-11 13:09:49 +04:00
Elie Habib 348466e191 Refactor: extract isMobileDevice utility and fix popup transform
- Move isMobileDevice() to utils/index.ts for reuse
- Update App.ts and MapPopup.ts to use shared utility
- Reset transform style on desktop to prevent stale mobile transforms
2026-01-11 13:09:03 +04:00
Elie Habib 52313f8442 Fix XSS vulnerabilities in Market, Prediction, Economic panels
- Apply escapeHtml to all external data rendered via innerHTML
- MarketPanel: stock.name, stock.display, sector.name, commodity names, coin data
- PredictionPanel: prediction titles
- EconomicPanel: FRED series id, name, unit, date
- Move isMobileDevice helper to utils for reuse
2026-01-11 13:08:24 +04:00
Claude 5279fd823d Improve mobile usability with touch-optimized defaults and targets
- Add mobile-specific default layers (hotspots, conflicts, outages only)
  to reduce visual clutter on small screens
- Default to MENA view on mobile devices for better regional focus
- Add 44x44px touch targets using ::before pseudo-elements for all
  map markers (hotspots, bases, earthquakes, etc.)
- Update MapPopup to center horizontally on mobile for better visibility
- Add comprehensive mobile CSS with responsive controls and buttons
- Update mobile warning modal messaging to reflect new mobile experience
- Add extra-small screen (480px) specific layout adjustments

Fixes touch target accuracy issues where clicking one marker would
open a different marker's popup.
2026-01-11 08:33:24 +00:00
Elie Habib 61e3bf872f Add v1.1 version indicator in header 2026-01-11 11:45:49 +04:00
Elie Habib 7133f13638 Fix XSS in Map markers - escape hotspot and APT names 2026-01-11 11:43:36 +04:00
Elie Habib fa5b7ab20e Add comprehensive Contributing guide to README
Covers:
- Fork and clone workflow
- Code style conventions (TypeScript, architecture, security, performance)
- PR submission process with examples
- Good PR practices table
- Types of contributions welcome
- Review process
- Development tips for adding layers and proxies
2026-01-11 11:42:02 +04:00
Elie Habib 63585b172b Fix XSS in SearchModal recent searches - use DOM APIs 2026-01-11 11:40:49 +04:00
Elie Habib 34cc197ceb Clean up help panel - remove API mentions and icon details 2026-01-11 11:39:51 +04:00
Elie Habib acf3bc8a63 Expand README with comprehensive feature documentation
New sections:
- Security & Input Validation (XSS prevention, proxy validation)
- Pentagon Pizza Index (DEFCON alerting, GDELT tension pairs)
- Military Tracking (vessels via MMSI, aircraft via OpenSky)
- Related Assets (infrastructure context for news events)
- Activity Tracking (new item detection, visual indicators)
- Performance Optimizations (Web Workers, virtual scrolling)
- Shareable Links (URL state encoding)

Updated:
- Tech Stack table with all technologies
- Project Structure with new files (sanitize, workers, military)
- API Dependencies with OpenSky and PizzINT
- Optional API Keys section
2026-01-11 11:38:15 +04:00
Elie Habib 75a0c50d7d Fix AIS stream lifecycle leaks and reconnect issues
- Guard connect() for both OPEN and CONNECTING states
- socket.onerror now closes socket and schedules reconnect
- initAisStream only creates cleanup interval if not exists
- disconnectAisStream clears cleanup interval
- cleanupOldData now prunes vesselHistory entries
2026-01-11 11:20:32 +04:00
Elie Habib e6e6a8247f Fix loadAllData to always update search index
Problem: Promise.all would reject on any task failure, skipping
updateSearchIndex and leaving stale search results.

Fix:
- Switch to Promise.allSettled to wait for all tasks
- Log individual failures without blocking other tasks
- Always call updateSearchIndex regardless of failures
- Add task names for better error logging
2026-01-11 11:18:37 +04:00
Elie Habib 753e314940 Fix worker readiness hang - add timeout and error handling
The readyPromise could hang indefinitely if worker failed to load.

Changes:
- Add 10-second ready timeout that rejects if worker doesn't initialize
- Reject readyPromise on worker.onerror before ready state
- Add try/catch around worker creation
- Extract cleanup() helper for consistent state reset
- Worker now properly fails fast instead of hanging forever
2026-01-11 11:17:32 +04:00
Elie Habib ce7d22a4a6 Validate proxy endpoint parameters
polymarket.js:
- Allowlist order values (volume, liquidity, startDate, endDate, spread)
- Clamp limit to 1-100 range
- Validate boolean params (closed, ascending)

coingecko.js:
- Limit coin IDs to max 20, validate format (alphanumeric + hyphens)
- Allowlist currencies (usd, eur, gbp, jpy, cny, btc, eth)
- Validate boolean include_24hr_change

yahoo-finance.js:
- Validate symbol format (alphanumeric, dots, hyphens, max 20 chars)
- Return 400 for invalid symbols

Prevents abuse via unbounded params that could trigger upstream rate
limits or inflate egress costs.
2026-01-11 11:14:01 +04:00
Elie Habib 5650ba59ec Fix ACLED token exposure - move to server-side proxy
Security fix: ACLED API token was embedded in client bundle

Changes:
- Create api/acled.js serverless proxy with token kept server-side
- Add 10-minute cache to reduce API calls
- Add rate limiting (10 req/min per IP)
- Return only needed fields to client
- Update protests.ts to use proxy instead of direct API calls
- Rename env var: VITE_ACLED_ACCESS_TOKEN -> ACLED_ACCESS_TOKEN

The token is now only accessible server-side (no VITE_ prefix).
2026-01-11 11:13:26 +04:00
Elie Habib 8c2efd2fea Fix XSS vulnerabilities in UI rendering
- Add src/utils/sanitize.ts with escapeHtml() and sanitizeUrl()
- NewsPanel.ts: Escape RSS titles, sources, links, asset names
- MapPopup.ts: Escape weather alerts, protest data, flight delays,
  outage info, news headlines from external APIs
- SearchModal.ts: Escape search results, fix highlightMatch to escape
  before adding <mark> tags
- MonitorPanel.ts: Escape user-entered keywords and matched news
- PizzIntIndicator.ts: Escape location names and tension labels

sanitizeUrl() validates that URLs use http/https protocol only,
preventing javascript: URI injection.
2026-01-11 11:10:57 +04:00
Elie Habib 7f27b4d755 Extract shared analysis constants to prevent drift
- Create src/utils/analysis-constants.ts with shared clustering/correlation logic
- Update clustering.ts and correlation.ts to import from shared module
- Add sync warning comment to analysis.worker.ts
- Reduces drift risk between main-thread and worker implementations

Constants extracted: SIMILARITY_THRESHOLD, STOP_WORDS, thresholds, keywords
Functions extracted: tokenize, jaccardSimilarity, findRelatedTopics, etc.
2026-01-11 11:07:29 +04:00
Elie Habib b0b49ae7e4 Improve military marker visibility and consistency
- Increase aircraft crosshairs from 12px to 20px with stronger glow
- Replace vessel emoji icons with tactical CSS diamond shapes
- Submarines use circle outline, carriers use filled diamond
- Consistent tactical aesthetic across all military markers
2026-01-11 11:02:01 +04:00
Elie Habib 052585643b Improve help popup - scrollable, time filter docs
- Add scroll event interception to prevent map zoom while scrolling popup
- Add Time Filter section explaining 1H/6H/24H/7D/30D/ALL options
- Clarify data filtering (cables = 20 backbone routes, datacenters ≥10k GPUs)
- Add note about which layers are affected by time filter
2026-01-11 10:39:42 +04:00
Elie Habib 7e87b474d4 Add help icon for layer toggles
- Added (?) button next to layer toggles that opens a popup
- Popup explains each layer grouped by category: Geopolitical, Military & Strategic, Infrastructure, Transport, Natural & Economic, Labels
- Closes on X button or clicking outside
2026-01-11 10:33:55 +04:00
Elie Habib eefa77c66d Fix military popup contrast issues
- Use transparent tinted backgrounds instead of solid colors
- Ensure title text is always white
- Subtitle text white for readability
- Attribution text brighter (50% vs 40%)
- China/Russia headers use lighter tints to avoid dark-on-dark
2026-01-11 10:28:48 +04:00
Elie Habib 9082490e86 Use crosshairs for military aircraft markers
- Replace circles/symbols with CSS crosshairs (+)
- Crosshairs rotate with aircraft heading
- Clusters use targeting bracket corners [ ]
- Much more distinct from shipping circles and bases
- Different glow colors for bombers (pink) and recon (cyan)
- Tactical/targeting aesthetic
2026-01-11 10:25:13 +04:00
Elie Habib 8f6600a85e Increase RSS proxy timeouts for Google News
- Google News feeds: 20s timeout (often slow)
- Other feeds: 12s timeout
- Use more realistic browser User-Agent
2026-01-11 10:21:59 +04:00
Elie Habib 6a073c392c Fix PizzINT routes with explicit endpoint files
- Replace catch-all route with explicit files
- api/pizzint/dashboard-data.js for /api/pizzint/dashboard-data
- api/pizzint/gdelt/batch.js for /api/pizzint/gdelt/batch
- Improve rss-proxy error handling with more details
2026-01-11 10:21:13 +04:00
Elie Habib fc59f087de Make military markers subtle like shipping layer
- Remove heavy black boxes and glows
- Add transparency (0.6-0.7 opacity)
- Smaller icons (10px vs 18px)
- Smaller cluster markers (11px count, 7px label)
- Light pill-style cluster count vs big box
- Subtle hover effect increases opacity
- Reduce pulse animation to opacity change only
2026-01-11 10:16:00 +04:00
Elie Habib 9b321505f0 Fix PizzINT API routes with Vercel catch-all handler
Convert api/pizzint.js to api/pizzint/[...path].js to properly handle
nested routes like /api/pizzint/dashboard-data and /api/pizzint/gdelt/batch
2026-01-11 10:12:21 +04:00
Elie Habib 1c3968d028 Improve military flight tracking UX and fix cluster popups
- Fix cluster popup bug: add missing render methods for militaryFlight,
  militaryVessel, militaryFlightCluster, and militaryVesselCluster types
- Add distinct military styling: black/green tactical theme with colored
  borders by operator (US olive, UK forest green, China/Russia red)
- Expand military coverage: add 25+ country hex ranges (Israel, Turkey,
  Saudi Arabia, UAE, Japan, South Korea, etc.) and callsign patterns
- Shorten cluster labels to military commands (CENTCOM, EUCOM, INDO-PACIFIC)
- Move DEFCON/PizzINT indicator from overlay to header
- Fix promise batching in fetchFromOpenSky to avoid rate limiting
2026-01-11 10:09:16 +04:00
Elie Habib e39eeaa0e5 Expand military aircraft identification coverage
- Added 20+ new ICAO hex ranges: Israel, Turkey, Saudi Arabia, UAE,
  Qatar, Kuwait, Japan, South Korea, Australia, Canada, India,
  Pakistan, Egypt, Poland, Greece, Sweden, Norway, Singapore, etc.
- Added 25+ new callsign patterns for non-US/NATO air forces
- Expanded country filter from 5 to 26 countries
- Should significantly improve Middle East coverage for Iran tensions
2026-01-11 09:43:23 +04:00
Elie Habib 037a2d52b9 Move DEFCON indicator to header
- Changed from fixed positioning (bottom-left) to inline in header-left
- Adjusted sizing to fit header (smaller toggle, dropdown opens downward)
- Panel now drops down from header instead of popping up from bottom
2026-01-11 09:34:05 +04:00
Elie Habib 4bdba55f5d Fix batching to actually stagger OpenSky API requests
Extracted fetchHotspotRegion() so requests only start when
the batch executes, not when defined. Previously all promises
started immediately due to map(async ...) behavior.
2026-01-11 09:29:51 +04:00
Elie Habib 4bc957a362 Route OpenSky through Railway relay (bypasses Vercel block)
- Add /opensky HTTP endpoint to ais-relay.cjs
- Update military-flights.ts to use Railway for OpenSky
- Converts VITE_WS_RELAY_URL to HTTP URL for same server
2026-01-11 09:15:46 +04:00
Elie Habib 922440d7be Restore original opensky endpoint name - OpenSky blocks Vercel 2026-01-11 09:13:09 +04:00
Elie Habib f1711e8325 Update to use opensky2 endpoint 2026-01-11 09:11:38 +04:00
Elie Habib a08892e07d Rename opensky to opensky2 to force new function 2026-01-11 09:11:11 +04:00
Elie Habib 45d015592e Try browser-like headers for OpenSky 2026-01-11 09:09:55 +04:00
Elie Habib 72974c0c22 Force rebuild of OpenSky proxy 2026-01-11 09:07:18 +04:00
Elie Habib d0bea3a4dd Add detailed error messages to OpenSky proxy 2026-01-11 09:03:27 +04:00
Elie Habib 9651e85992 Simplify OpenSky proxy - edge runtime, no auth 2026-01-11 09:01:29 +04:00
Elie Habib c591ce435b Add error details to OpenSky proxy 2026-01-11 09:00:17 +04:00
Elie Habib 7ca0afca17 Fix Node.js runtime handler signature 2026-01-11 08:59:13 +04:00
Elie Habib f3cb183c44 Switch to Node.js runtime for better network to OpenSky 2026-01-11 08:58:16 +04:00
Elie Habib 29f644f16f Switch OpenSky to Basic Auth - faster than OAuth token fetch 2026-01-11 08:57:08 +04:00
Elie Habib 0c75c85960 Fix API timeouts and reduce OpenSky requests
- Add explicit timeouts to OpenSky (12s) and RSS (8s) proxies
- Consolidate 10 military hotspots into 4 larger regions
- Increase military refresh interval from 2 to 5 minutes
- Switch OpenAI feed to Google News (OpenAI blocks proxies)
2026-01-11 08:50:19 +04:00
Elie Habib 0c5713eb0b Rename SHIPS to SHIPPING in layer toggle and status panel 2026-01-11 08:40:19 +04:00
Elie HabibandClaude Opus 4.5 79550c72b4 Merge PR #13: Add military flight and vessel tracking
Features:
- Military aircraft detection via OpenSky Network API
- Callsign pattern matching for military operators (NATO, Russia, China)
- Known military ICAO hex code identification
- Military vessel detection via AIS data (naval ship types)
- Clustering for dense military activity areas
- Flight trail history tracking
- Integration with existing layer toggle system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:29:47 +04:00
Elie HabibandClaude Opus 4.5 bd8d4ea11b Add data attribution section and fix OpenSky OAuth2 endpoint
- Add comprehensive Data Attribution section to README with proper
  citations for all data sources (OpenSky, ACLED, GDELT, CoinGecko,
  Yahoo Finance, USGS, FRED, Cloudflare Radar, etc.)
- Add Acknowledgments section crediting Reggie James (@HipCityReg)
  for the original dashboard concept inspiration
- Fix OpenSky OAuth2 token endpoint URL (was using wrong endpoint)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:28:06 +04:00
Elie HabibandClaude Opus 4.5 fcd3448656 Update OpenSky proxy to use OAuth2 client credentials
- Uses OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET env vars
- Token caching with automatic refresh
- Falls back to anonymous if not configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:46:17 +04:00
Elie HabibandClaude Opus 4.5 6690103965 Add OpenSky proxy for military flight tracking (PR #13 fix)
Serverless proxy for OpenSky Network API with:
- Bounding box parameter forwarding
- Optional auth via OPENSKY_USERNAME/PASSWORD env vars
- Rate limit handling (returns 429 cleanly)
- 10s cache for efficiency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:43:13 +04:00
Claude 64eece5ca1 Consolidate military layers into single 'Military' toggle
User feedback: "it's already crowded" - combine Mil Aircraft and Mil Vessels
into a single layer toggle for cleaner UI.
2026-01-11 03:38:46 +00:00
Elie HabibandClaude Opus 4.5 a928db67ab Replace corsproxy.io with Vercel serverless proxies
- Add /api/yahoo-finance.js for stock quotes
- Add /api/coingecko.js for crypto prices
- Add /api/polymarket.js for prediction markets
- Add /api/rss-proxy.js for RSS feeds (with domain allowlist)
- Add /api/earthquakes.js for USGS data
- Update feeds.ts to use direct URLs with RSS proxy
- Simplify proxy.ts (no external CORS proxy needed)
- Update earthquakes.ts and polymarket.ts to use new endpoints

Eliminates dependency on unreliable third-party CORS proxy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:34:57 +04:00
Claude 551f69bf6a Add military flight and vessel tracking feature
- Add types for military flights, vessels, and clusters (MilitaryFlight,
  MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster)
- Create military-flights.ts service with OpenSky Network API integration
  for tracking military aircraft (fighters, bombers, tankers, AWACS, etc.)
- Create military-vessels.ts service leveraging AIS stream to detect
  military/government vessels (carriers, destroyers, submarines, etc.)
- Add comprehensive military callsign patterns and aircraft type database
  in config/military.ts (US, NATO, Russia, China callsigns)
- Add known naval vessel database with carrier strike groups
- Implement military hotspot detection for strategic regions (Taiwan Strait,
  South China Sea, Persian Gulf, Black Sea, etc.)
- Add map rendering for military flights and vessels with operator-specific
  colors, aircraft type icons, and flight track trails
- Add CSS styles for military markers with pulsing animations for
  interesting activity
- Integrate into App.ts with 2-minute refresh for flights, 5-minute for vessels
- Add OpenSky and ADS-B Exchange API proxies in vite.config.ts
2026-01-11 03:31:17 +00:00
Elie HabibandClaude Opus 4.5 a46d98dae1 Move FRED API key to server-side for security
- Updated api/fred-data.js serverless function to use FRED API with
  server-side API key (via FRED_API_KEY env var)
- Removed hardcoded API key from frontend code
- Frontend now calls /api/fred-data without exposing the API key
- Updated vite proxy config for local dev

IMPORTANT: Add FRED_API_KEY to Vercel environment variables!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:32:03 +04:00
Elie HabibandClaude Opus 4.5 b66f792d69 Fix layer toggle to show loading state before active
- Async data layers (ships, earthquakes, weather, outages, protests,
  flights) now show yellow/loading state when toggled ON
- Button only turns green/active when data is actually rendered
- Added setLayerReady() method to Map component
- Updated all async data loading functions to call setLayerReady

Fixes issue where Ships button turned green immediately even when
no ship data was available to display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:28:59 +04:00
Elie HabibandClaude Opus 4.5 8b08cd26e6 Merge PR #12: Web Worker analysis, virtual scrolling, activity badges
Resolves merge conflicts with PR #11 changes:
- Combined circuit breaker error states with activity tracking
- Merged FRED API updates with worker changes
- Combined virtual scrolling CSS with error state styles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:09:32 +04:00
Elie Habib f1a4cd8ad0 Merge branch 'pr-11-webgl-lod' 2026-01-10 23:54:50 +04:00
Elie HabibandClaude Opus 4.5 b86177beca Fix proxy configs, FRED API, and circuit breaker error states
- Fix economic panel mounting selector (.main → .main-content)
- Add circuit breaker error state to panels (red header on failure)
- Switch FRED to official API with API key (fixes bot protection 404)
- Fix GDELT proxy order (specific /api/gdelt-geo before /api/gdelt)
- Fix click handler violation with requestAnimationFrame
- Add panel error state CSS styling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:54:19 +04:00
Elie HabibandClaude Opus 4.5 aa030469d0 Disable WebGL clustering - preserve all marker shapes
WebGL circles replaced distinct visual features for all layers:
- Earthquakes: magnitude-based sizing + M labels
- Weather: severity-colored emoji icons
- Protests: severity colors + emoji icons + animations
- Outages: severity colors (partial/major/total)
- Flights: delay-severity colors + ✈️ icons
- Irradiators: teal glow effects
- Hotspots: level colors + BREAKING badges
- Bases: nation-specific colors
- Nuclear: status-based styling
- Economic/Datacenters: emoji icons

Also fixed GDELT GEO proxy configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:28:36 +04:00
Elie HabibandClaude Opus 4.5 d6d64f3622 Fix WebGL clustering: preserve distinct marker shapes
Removed shape-critical layers from WebGL clustering:
- bases: nation-specific colors (NATO blue, Russia red, etc.)
- nuclear: status-based styling (active/contested/inactive)
- economic: emoji icons (📈, 🏛, 💰)
- datacenters: 🖥️ icons with status
- hotspots: level colors and BREAKING badges

These layers now always render as HTML markers to preserve
visual identity. Generic layers (earthquakes, weather, protests,
flights, outages, irradiators, ais) still use WebGL clustering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:00:40 +04:00
Claude c49175c29b Add virtual scrolling with windowed rendering for news panels
Implements efficient rendering for large news lists by only rendering
visible chunks of items. This reduces DOM nodes from 200+ to ~24
(3 chunks × 8 items) for panels with many articles.

Implementation:
- Add VirtualList component with two strategies:
  - VirtualList: Fixed-height items with DOM recycling
  - WindowedList: Variable-height items with chunk-based rendering
- Update NewsPanel to use WindowedList for lists > 15 items
- Extract renderClusterHtml() method for per-item rendering
- Add CSS containment hints for scroll performance

Performance benefits:
- Reduced initial render time (fewer DOM nodes)
- Smoother scrolling (CSS contain: strict)
- Lower memory usage (chunks rendered on-demand)
- Event handlers bound only to visible chunks
2026-01-10 15:40:06 +00:00
Claude 29c461b936 Add real-time activity indicators with "new" badges
Adds visual feedback when panels receive new data:

- Panel header badges showing count of new items (e.g., "3 new")
- Pulsing animation to draw attention to updated panels
- Green "NEW" tag on recently added news items (fades after 2 min)
- Highlight glow effect on new items (first 30 seconds)
- Auto-clear badges when user scrolls or clicks panel

Implementation:
- Add ActivityTracker service to track seen items per panel
- Extend Panel base class with badge element and setNewBadge()
- Update NewsPanel to integrate with activity tracking
- Add CSS animations for badges, highlights, and panel glow
2026-01-10 15:14:39 +00:00
Elie Habib 8098b93157 Add WebGL cluster renderer for map LOD 2026-01-10 19:08:31 +04:00
Claude 0d85c386ce Move clustering and correlation analysis to Web Worker
Moves O(n²) Jaccard clustering and correlation signal detection off the
main thread to prevent UI jank during data refresh cycles. The worker
maintains its own state for correlation analysis (previousSnapshot,
recentSignalKeys) and returns serialized results back to the main thread.

Changes:
- Add src/workers/analysis.worker.ts with self-contained clustering and
  correlation logic
- Add src/services/analysis-worker.ts as typed async interface to worker
- Update App.ts to use worker for clusterNews and analyzeCorrelations
- Worker is lazily initialized on first use
2026-01-10 15:07:29 +00:00
Claude d6570b42bc Add mobile warning popup for desktop-optimized experience
Show informational popup on mobile devices notifying users that the
dashboard is optimized for desktop viewing. Users can dismiss the
popup and optionally choose not to see it again (persisted to
localStorage). Detection uses both screen width and pointer type.
2026-01-10 14:03:49 +00:00
Elie HabibandClaude Opus 4.5 7f0899a9b4 Add meta tags and Open Graph for SEO and social sharing
- Primary meta tags: title, description, keywords, author, theme-color
- Open Graph tags for Facebook/LinkedIn sharing
- Twitter Card tags for Twitter sharing
- Uses worldmonitor-icon-1024.png as og:image

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:51:23 +04:00
Elie HabibandClaude Opus 4.5 9a32b08d33 Fix Intel feed status not updating in System Health panel
Intel feed was loading and displaying correctly but wasn't calling
statusPanel.updateFeed() to update the System Health popup status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:49:13 +04:00
Elie HabibandClaude Opus 4.5 ff5d89db81 Add screenshot to README
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:45:11 +04:00
Elie HabibandClaude Opus 4.5 9d60abec49 Add favicon and icon references
- Add favicon links to index.html
- Move favico assets to public/ for Vite static serving
- Add root favicon.ico for default /favicon.ico requests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:36:14 +04:00
Elie HabibandClaude Opus 4.5 3d8d307169 Reduce undersea cables from 55 to 18 strategic routes
Strategic selection covering all major global corridors:
- Trans-Atlantic: 3 (MAREA, Grace Hopper, Havfrue)
- Trans-Pacific: 3 (FASTER, Southern Cross, Curie)
- Asia-Europe: 2 (SEA-ME-WE 6, FLAG)
- Africa: 3 (2Africa, WACS, EASSy)
- Americas: 2 (SAm-1, EllaLink)
- Asia-Pacific: 3 (APG, Indigo, SJC)
- Arctic/Europe: 1 (FARICE)
- Middle East: 1 (Falcon)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:34:14 +04:00
Elie Habib 5cd26dd543 Update default layers: enable countries/waterways, disable cables/flights 2026-01-10 17:26:21 +04:00
Elie HabibandClaude Opus 4.5 6c9015dda0 Comprehensive README documentation update
Added documentation for previously undocumented features:
- Maritime Intelligence: AIS tracking, chokepoint monitoring, dark ship detection
- Social Unrest Tracking: ACLED + GDELT multi-source corroboration
- Aviation Monitoring: FAA delays, ground stops, severity thresholds
- Fault Tolerance: Circuit breaker pattern with graceful degradation
- Conditional Data Loading: Layer-aware API calls
- Prediction Market Filtering: Geopolitical relevance filtering

Updated data counts and structure:
- 55 undersea cables (was 6)
- 111 AI datacenters ≥10k GPUs
- 220+ military bases
- 45+ RSS feeds
- 18 toggleable map layers

Added API key documentation and optional service configuration.

README: 363 → 656 lines (+80%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:16:50 +04:00
Elie HabibandClaude Opus 4.5 b6325088d5 Add loading state for AIS/Ships layer until data arrives
SHIPS toggle now blinks while waiting for WebSocket connection
and vessel data. Polls every second for up to 30s before timeout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:09:19 +04:00
Elie HabibandClaude Opus 4.5 c18515d48b Expand undersea cables from 6 to 55 major routes
Added cables covering all major regions:
- Trans-Atlantic: TAT-14, MAREA, Dunant, Apollo, Grace Hopper, etc.
- Trans-Pacific: Unity, FASTER, Jupiter, APG, AAG, Southern Cross
- Asia-Europe: SEA-ME-WE 3/4/5/6, FLAG, AAE-1, IMEWE
- Africa: WACS, EASSy, SEACOM, 2Africa, ACE
- Americas: SAC, Monet, SACS, SAm-1, ARCOS, MAYA-1
- Asia-Pacific: APCN-2, SJC, Indigo, JGA
- Europe: Celtic Norse, FARICE, Baltic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:07:54 +04:00
Elie Habib eebb492414 Adjust datacenter filter to ≥10k GPUs (313 → 111) 2026-01-10 17:03:49 +04:00
Elie Habib 324570af89 Reorganize layer toggles into logical groups 2026-01-10 16:46:46 +04:00
Elie Habib 64ab6263a4 Add loading/blinking state to layer toggles while fetching data 2026-01-10 16:45:27 +04:00
Elie HabibandClaude Opus 4.5 d8f67f7b88 Filter data centers to ≥50k GPUs (313 → 36)
Only show significant AI supercomputer clusters on map
to reduce visual clutter while keeping strategic sites visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:07:43 +04:00
Elie HabibandClaude Opus 4.5 934a9d9283 Filter Polymarket for geopolitical relevance
- Added keyword filtering for conflicts, leaders, politics, economics
- Exclude sports (NBA, FIFA, NFL) and entertainment
- Fetch 100 markets, filter to 15 relevant ones
- Keywords: war, military, election, sanctions, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:04:43 +04:00
Elie HabibandClaude Opus 4.5 0f6717151b Hide layer toggles for unconfigured services
- AIS (Ships): Hidden if VITE_WS_RELAY_URL not set in production
- Outages: Hidden if CLOUDFLARE_API_TOKEN not configured
- API returns {configured: false} to signal missing config
- Layer toggle buttons hidden via hideLayerToggle() method

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:03:25 +04:00
Elie HabibandClaude Opus 4.5 3553b8cafd Conditional AIS WebSocket + graceful outages API error handling
- Disconnect AIS WebSocket when ships layer is disabled
- Only init AIS stream if layer is enabled on startup
- Cloudflare outages API returns empty result instead of 500 when token missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:56:42 +04:00
Elie Habib fe0d7f7319 Rename SHIPPING to SHIPS, reorder layers (conflicts+protests, ships+flights) 2026-01-10 14:49:53 +04:00
Elie HabibandClaude Opus 4.5 dbdbde84c7 Simplify Vercel API proxies to single-file endpoints
- cloudflare-outages.js - Cloudflare Radar API
- faa-status.js - FAA NASSTATUS
- fred-data.js - FRED economic data
- gdelt-geo.js - GDELT protest data
- nga-warnings.js - NGA maritime warnings

Updated frontend services to use new endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:47:30 +04:00
Elie HabibandClaude Opus 4.5 19712939dc Add all API serverless proxies for Vercel
- FAA, FRED, GDELT, NGA-MSI proxies
- Fix flights.ts to always use proxy path
- All external APIs now go through serverless functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:41:43 +04:00
Elie Habib c69b10e901 Add FAA NASSTATUS serverless proxy for Vercel 2026-01-10 14:40:27 +04:00
Elie HabibandClaude Opus 4.5 a79d6f0224 Add circuit breaker and caching for RSS feeds
- Per-feed cooldown after 2 consecutive failures (5 min)
- Cache successful results for 10 minutes
- Return cached content during cooldown or failures
- Log when feed enters cooldown

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:37:26 +04:00
Elie Habib adeaf58efd Use VITE_WS_RELAY_URL env var for WebSocket relay 2026-01-10 14:33:54 +04:00
Elie Habib 80fee6d625 Update Railway WebSocket URL 2026-01-10 14:33:40 +04:00
Elie HabibandClaude Opus 4.5 995ade3f4b Split architecture: Vercel frontend + Railway WebSocket
- Railway: WebSocket relay only (simpler)
- Vercel: Frontend + API serverless functions
- Added Cloudflare Radar serverless proxy
- Removed auth header from frontend (serverless handles it)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:32:18 +04:00
Elie HabibandClaude Opus 4.5 9e3bcd871e Add shipping debug logging, clean up old API key error
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:28:52 +04:00
Elie HabibandClaude Opus 4.5 2195f678cd Lower shipping thresholds to show chokepoint activity
- Show chokepoints with 5+ vessels (was 40+)
- Show any dark ship activity (was 5+)
- More useful for situation awareness

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:27:45 +04:00
Elie HabibandClaude Opus 4.5 5d7fab64ae Fix: Convert Buffer to string for browser WebSocket clients
Node.js ws library receives messages as Buffer by default.
Browser WebSocket expects string data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:24:12 +04:00
Elie HabibandClaude Opus 4.5 3d1ab4cf58 Serve frontend + WebSocket from single Railway deployment
- Relay now serves static files from dist/
- Frontend connects to WebSocket on same origin
- Railway build: npm install && npm run build && cd scripts && npm install

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:19:39 +04:00
Elie HabibandClaude Opus 4.5 2e07fd5117 Add circuit breaker for Yahoo Finance rate limiting
- Stop all requests for 5 minutes after 429 error
- Return cached results during cooldown period
- Abort batch processing immediately on rate limit
- Log remaining cooldown time

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:15:30 +04:00
Elie HabibandClaude Opus 4.5 bf266580c4 Skip refresh intervals for disabled layers
Refresh intervals now check layer settings before making API calls.
Disabled layers won't trigger any network requests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:13:15 +04:00
Elie HabibandClaude Opus 4.5 9c652abcbf Fix FAA API, rate limiting, and add conditional data loading
- Switch FAA endpoint from defunct soa.smext.faa.gov to nasstatus.faa.gov
- Parse FAA NASSTATUS XML response (single call instead of per-airport)
- Fix Yahoo Finance 429 rate limiting with smaller batches and backoff
- Add conditional API loading based on layer toggle settings
- Load data only when corresponding layer is enabled
- Trigger data load when layer is toggled on

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:11:05 +04:00
Elie HabibandClaude Opus 4.5 fe1bccdd40 Add HTTP server for Railway health checks
Railway requires an HTTP endpoint for health checks. Added HTTP server
that responds to / and /health with status JSON, with WebSocket server
attached to the same HTTP server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:01:33 +04:00
Elie HabibandClaude Opus 4.5 5190a8fe97 Add global flight delay alerts layer
Implement hybrid flight delay monitoring with FAA ASWS API for US airports
and computed delays for 75 global airports across Americas, Europe, APAC,
MENA, and Africa regions.

Features:
- Real-time FAA data for US airports (official, free API)
- 75 monitored airports globally with coordinates
- Severity levels: normal, minor, moderate, major, severe
- Delay types: ground stop, ground delay program, departure/arrival delays
- Color-coded markers with pulsing animation for major/severe
- Popup with airport details, delay reason, and source
- 10-minute refresh interval
- URL state persistence for flights layer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:58:31 +04:00
Elie HabibandClaude Opus 4.5 fb47818f14 Remove unused API key from frontend
Relay handles aisstream.io authentication, frontend just connects to relay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:58:19 +04:00
Elie HabibandClaude Opus 4.5 c0baf8fa6a Enable shipping via Railway WebSocket relay
Connect to wss://worldmonitor-production.up.railway.app in production

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:56:19 +04:00
Elie HabibandClaude Opus 4.5 24b95a4532 Prepare AIS relay for Railway deployment
- Add package.json for standalone deployment
- Use PORT env variable for Railway compatibility
- Support AISSTREAM_API_KEY env variable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:50:59 +04:00
Elie HabibandClaude Opus 4.5 bc0bc41126 Disable shipping WebSocket in production
aisstream.io blocks browser WebSocket connections (Origin header).
In dev: use local relay server (node scripts/ais-relay.cjs)
In prod: disabled until external WebSocket relay is deployed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:48:00 +04:00
Elie HabibandClaude Opus 4.5 93d10b817c Move Cloudflare API token to environment variable
Use VITE_CLOUDFLARE_API_TOKEN instead of hardcoded value

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:46:15 +04:00
Elie HabibandClaude Opus 4.5 70f26c9cf4 Remove hardcoded API key from relay script
Require VITE_AISSTREAM_API_KEY env variable instead of fallback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:44:19 +04:00
Elie HabibandClaude Opus 4.5 a9d67ed7aa Rename AIS to Shipping, add WebSocket relay for live vessel tracking
- Rename AIS layer to "Shipping" throughout UI for clarity
- Add local WebSocket relay server to proxy aisstream.io data
- Fix cable-activity.ts NGA warnings array handling bug
- Add ws package for relay server
- Improve Shipping service logging and error handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 13:29:59 +04:00
Elie HabibandClaude Opus 4.5 f84f0859a0 Reset button now returns to GLOBAL view
- Map reset() now sets view to 'global' in addition to zoom/pan
- View buttons sync via onStateChanged callback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:30:39 +04:00
Elie Habib 1bb46dfa97 Merge remote-tracking branch 'origin/main' 2026-01-10 08:25:15 +04:00
Elie HabibandClaude Opus 4.5 5853601c21 Fix critical map coordinate mismatch bug
Revert viewBox to simple container-matched coordinates (0 0 width height)
instead of using yOffset which caused SVG content to shift while HTML
overlay markers stayed in place, resulting in facilities appearing at
wrong locations (e.g., Dimona appearing far from Israel).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:23:44 +04:00
Elie HabibandClaude Opus 4.5 154031631a Improve map rendering and expand nuclear facilities data
- Add ~120 nuclear power plants and facilities worldwide (was only 11)
- Fix map projection to fill width and allow vertical panning
- Set default zoom to 1.5 for better initial view
- Increase GDELT protest threshold to 200+ reports
- Fix localStorage merge for new layer defaults
- Fix nuclear facility coordinates (South Texas, Laguna Verde)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:19:19 +04:00
Elie HabibandGitHub fa3054c6e0 Merge pull request #2 from koala73/codex/add-smart-zoom-dependent-layer-visibility
Add smart zoom visibility for dense map layers
2026-01-10 08:18:27 +04:00
Elie HabibandGitHub 8edd91d7c7 Merge pull request #1 from koala73/codex/implement-parallel-api-fetching-for-stocks
Batch market and RSS fetching with progressive updates
2026-01-10 08:17:58 +04:00
Elie Habib 8b991b0ef9 Add ACLED + GDELT protest tracking with live geocoded data
Features:
- ACLED API integration for verified protest/riot data with precise coordinates
- GDELT GEO 2.0 API for real-time global event coverage (no auth required)
- Multi-source deduplication and validation scoring
- Severity classification (high/medium/low) based on fatalities and event type
- Protest markers with type-specific icons (riot, strike, demonstration)
- Popup with source info, actors, tags, and nearby intel hotspots
- 15-minute refresh interval matching GDELT update frequency

Data sources:
- ACLED: 200+ countries, daily updates, geocoded to city level
- GDELT: 15-min updates, sentiment analysis, global coverage

Requires VITE_ACLED_API_KEY and VITE_ACLED_EMAIL for full coverage
Falls back to GDELT-only mode if ACLED not configured
2026-01-10 07:15:38 +04:00
Elie Habib 2a76f1f6b0 Merge PR #8: Add subsea cable fault advisories feature
Integrate NGA MSI API for maritime safety warnings
Add cable advisory and repair ship visualization
Resolve conflicts combining AIS and cable features
2026-01-10 07:02:28 +04:00
Elie Habib cfb6ad0395 Merge PR #6: Add AIS disruption and traffic density feature
Integrate aisstream.io WebSocket for live vessel tracking
Resolve conflicts keeping URL state sync and all type imports
2026-01-10 06:58:58 +04:00
Elie Habib afaa269c45 Merge remote-tracking branch 'origin/codex/add-pipeline-flow-disruption-feature' 2026-01-10 06:57:38 +04:00
Elie Habib 38a13c95c0 Merge PR #4: Add cross-layer correlation cards feature
Resolve conflict keeping layerZoomOverrides, onStateChange, and highlightedAssets
2026-01-10 06:57:06 +04:00
Elie Habib 8e6ed341ec Merge PR #3: Add shareable map state URLs and Copy Link button 2026-01-10 06:55:26 +04:00
Elie Habib 7d275bcb57 Merge PR #2: Add smart zoom visibility for dense map layers 2026-01-10 06:54:12 +04:00
Elie Habib e5f4034d67 Replace fake RSS feeds with NGA MSI Navigation Warnings API
- Integrated official NGA Maritime Safety Information API
- Real navigation warnings for cable operations worldwide
- Coordinate parsing from warning text (DMS and decimal formats)
- Cableship name extraction (CS, M/V, CABLESHIP patterns)
- Automatic matching to nearest known undersea cable
- Severity detection (fault vs operational warnings)
- Ship status detection (on-station vs enroute)

Data source: https://msi.nga.mil/api/publications/broadcast-warn
2026-01-10 06:49:00 +04:00
Elie Habib 2e3f0eb13b Replace mock AIS API with live aisstream.io WebSocket integration
- Rewrote AIS service to use aisstream.io free WebSocket API
- Real-time vessel position tracking with in-memory aggregation
- Density zone calculation from 2-degree grid cells
- Chokepoint congestion detection (Hormuz, Suez, Malacca, etc.)
- Dark ship detection via AIS gap analysis
- Graceful handling when no API key is configured
- Requires VITE_AISSTREAM_API_KEY env var for live data

Get free API key at https://aisstream.io
2026-01-10 06:28:39 +04:00
Elie Habib bdec76789e Fetch live cable advisories and repair activity 2026-01-10 05:22:12 +04:00
Elie Habib cbf1f854b3 Fetch AIS signals from API 2026-01-10 05:21:44 +04:00
Elie Habib 850e777689 Add pipeline flow disruption signals 2026-01-10 00:22:01 +04:00
Elie Habib 9db9481b0d Add related asset cards and map highlights 2026-01-10 00:17:50 +04:00
Elie Habib 62bf1cf0f0 Add shareable map state URLs 2026-01-10 00:00:09 +04:00
Elie Habib b2802b6c80 Add zoom-aware layer visibility 2026-01-09 23:59:25 +04:00
Elie Habib 1ca27884db Batch market and RSS fetches 2026-01-09 23:58:40 +04:00
Elie Habib a8b03f39cc Fix alert border highlighting on clustered news items
- Replace undefined --panel-border CSS variable with --border
- Use full border declaration for .item.clustered.alert to ensure
  red borders display correctly regardless of CSS cascade order
2026-01-09 23:24:12 +04:00
Elie Habib 723239a531 Add GitHub icon link to header, add signal detection logging 2026-01-09 23:18:03 +04:00
Elie Habib 730dee0d02 Expand README with comprehensive documentation
- Signal Intelligence: convergence, triangulation, velocity spikes
- Source Intelligence: tier system and type classification
- Algorithms: Jaccard clustering, Z-score deviation, sentiment analysis
- Dynamic hotspot activity explanation
- Custom monitors documentation
- Snapshot system details
- Design philosophy section
2026-01-09 23:07:35 +04:00
Elie Habib 2ad2ad8cbb Rename project to world-monitor 2026-01-09 23:04:33 +04:00
Elie Habib 9b804a41bc Add signal intelligence: convergence and triangulation detection
- Add SOURCE_TYPES categorization (wire/gov/intel/mainstream/market/tech)
- Convergence: detect 3+ source types reporting same story in 30m window
- Triangulation: detect when wire + gov + intel align on same topic
- Extend SignalModal with new signal type labels and styling
2026-01-09 23:02:48 +04:00
Elie Habib b19f026a99 Add comprehensive README documentation 2026-01-09 22:50:32 +04:00
Elie Habib 0db94ee8ad Add comprehensive search across all data sources
- Extended search modal to cover pipelines, cables, datacenters, nuclear facilities, irradiators
- Added trigger methods to MapComponent for all searchable entity types
- Auto-enables relevant map layer when selecting search results
- Added ⌘K search button to header with styling
- Expanded pipeline data to 88 real operating pipelines globally
- Fixed type errors and simplified pipeline status handling
2026-01-09 22:47:55 +04:00
Elie Habib 6769e02608 Add countries toggle to layer controls 2026-01-09 13:24:11 +04:00
Elie Habib 5029c5e295 Increase max zoom to 10x and use higher resolution map (50m)
- Max zoom increased from 4x to 10x for better Middle East detail
- Switched from 110m to 50m resolution world map
- All zoom handlers updated (buttons, wheel, pinch)
2026-01-09 12:38:30 +04:00
Elie Habib 65c3097eab Fix conflict zone labels - use HTML overlay with counter-scaling
- Moved conflict labels from SVG text to HTML overlays
- Labels now counter-scale with zoom like all other labels
- Much smaller, readable text that doesn't dominate the map
2026-01-09 12:34:26 +04:00
Elie Habib ed679fbec4 Add toggleable country labels layer (disabled by default)
- Added 60+ country labels with centroids (focus on Middle East)
- Countries layer toggle in map controls
- Labels counter-scale with zoom like other overlays
- Subtle styling (60% opacity, text shadow) so they don't dominate
2026-01-09 12:30:28 +04:00
Elie Habib cbd13e307c Fix map zoom UX: counter-scaled labels/markers, trackpad gestures, smart overlap hiding
- Labels now scale max 1.5x when zoomed (counter-scaled via CSS variables)
- Markers maintain fixed size regardless of zoom level
- Added full trackpad support: pinch-zoom, two-finger pan, mouse drag
- Smart label hiding: overlapping labels auto-hide based on priority
- High-priority items (breaking news, severe weather) always visible
2026-01-09 12:22:05 +04:00
Elie Habib e2ee79de0b Fix status panel feed names to match actual category names 2026-01-09 12:03:21 +04:00
Elie Habib 37e74f5f47 Always show signal badge in header (shows 0 when empty) 2026-01-09 12:00:13 +04:00
Elie Habib 8af93e133e Improve UX: notification icon, configurable FRED panel
- Signal modal no longer auto-pops; signals accumulate in header badge
- Click badge to view signals; new pulse animation on new signals
- Added 'economic' to MapLayers - FRED panel now toggleable via layer buttons
- Layer changes persist to localStorage
- Monitor panel already forced to last position in sidebar (verified existing logic)
2026-01-09 11:56:46 +04:00
Elie Habib 670b352384 Fix FRED: add CORS proxy and parallel fetching
- Changed FRED URL to use /api/fred proxy path
- Added FRED proxy to vite.config.ts and proxy.ts
- Refactored sequential fetching to use Promise.all for 7x speed improvement
- Fixed TypeScript types for async map
2026-01-09 11:50:46 +04:00
Elie Habib 2dd7f9c95c Add FRED economic indicators panel
- New fred.ts service fetching data from FRED CSV endpoints
- EconomicPanel component displaying key indicators (Fed Assets, Fed Rate, 10Y-2Y Spread, etc.)
- Real-time change tracking with positive/negative indicators
- Auto-refresh every 30 minutes
- Integrated with status panel health monitoring
2026-01-09 11:47:11 +04:00
Elie Habib 7fdb434a26 Add government press releases feeds (White House, State, Pentagon, etc.)
- Added 10 official government RSS sources to gov category
- Added source tier rankings for government sources (Tier 1-2)
- Updated proxy mappings for new government domains
2026-01-09 11:41:11 +04:00
Elie Habib 4f02920540 Add weather/disaster alerts overlay using NWS API
- Fetch active weather alerts from api.weather.gov
- Display weather markers on map with severity colors
- Add weather layer toggle to map controls
- Create weather popup with alert details
- Auto-refresh every 10 minutes
2026-01-09 11:37:10 +04:00
Elie Habib d358299e0f Fix status panel to properly update API status on data load 2026-01-09 11:22:37 +04:00
Elie Habib 63e33e98fc Implement Phase 3: History, alerts, status dashboard, and export
- Add historical playback with IndexedDB snapshots (15min intervals, 7d retention)
- Enhance signal alerts with badge counter, status dot pulse, header flash
- Create status/health dashboard showing feed health and API status
- Add data export functionality (JSON/CSV) with download button
2026-01-09 11:15:26 +04:00
Elie Habib 0029000c37 Implement Phase 1 & 2: Signal detection system
Phase 1 - Foundation:
- Source tier system (Wire > Major > Specialty > Aggregators)
- Event clustering with Jaccard similarity (threshold 0.5)
- Basic velocity detection (sources/hour calculation)
- Clustered news UI with source count badges and tier colors

Phase 2 - Intelligence Layer:
- Sentiment velocity with keyword heuristics (50+ triggers)
- Historical baselines with IndexedDB (7d/30d rolling averages)
- Z-score deviation alerts in panel headers
- Cross-stream correlation engine (news ↔ markets ↔ predictions)
- Signal modal with confidence scores and audio alerts

New services: clustering, velocity, storage, correlation
New components: SignalModal
Updated: SPEC.md with new data sources from HAR analysis
2026-01-09 11:01:44 +04:00
Elie Habib 3804ff7db7 Add comprehensive enhancement spec based on detailed interview 2026-01-09 10:26:15 +04:00
Elie Habib 1c08de099c Add minimal credit: by Elie Habib 2026-01-08 22:16:36 +04:00
Elie Habib 54a3023cce Fix Polymarket: outcomePrices is stringified JSON, needs JSON.parse() 2026-01-08 22:13:24 +04:00
Elie Habib 7f9c1394c0 Fix Polymarket predictions with correct API endpoint and data parsing
- Switch from /events to /markets endpoint for better data
- Order by volume descending to show most active markets
- Filter for interesting markets (>10% discrepancy from 50% OR high volume)
- Correct field mapping: question, outcomePrices array
- Show Yes/No probability bars with proper percentages
2026-01-08 22:07:33 +04:00
Elie Habib 71b35c81b4 Reduce conflict zone sizes for Sudan and Myanmar
- Sudan zone now focused on Khartoum region instead of entire country
- Myanmar zone focused on central conflict area
- Reduces visual noise on the map
2026-01-08 22:02:56 +04:00
Elie Habib afa3eaa084 Add hover effects for undersea cables
- Cables pulse and glow on hover to indicate interactivity
- Show cable name tooltip on hover
- Increased stroke width and brightness on hover
2026-01-08 22:00:11 +04:00
Elie Habib 02537a53b9 Fix prediction market display with Yes/No probability bars
- Show horizontal bar split between Yes (green) and No (red)
- Display volume below question text
- Add proper labels with percentages on each side
2026-01-08 21:57:03 +04:00
Elie Habib 4b130009fb Fix broken RSS feeds and remove non-working sources
- Remove feeds with no working RSS: Reuters, White House, Treasury,
  Anthropic, Google AI, DeepMind, OpenAI, CSIS, CFR, Brookings
- Replace with working alternatives:
  - AP News via RSSHub
  - VentureBeat for AI news
  - Foreign Policy for think tanks
  - Google News RSS searches for AI, gov, think tanks, defense
- Clean up unused proxy mappings
2026-01-08 21:49:00 +04:00
Elie Habib 08785f6a4f Fix TypeScript build: Add vite/client types for import.meta.env 2026-01-08 21:42:58 +04:00
Elie Habib eff3dff966 Fix production CORS: Use proxy for Vercel deployment
- Add proxy utility that detects dev vs production environment
- In development: use Vite proxy (local /api, /rss paths)
- In production: use corsproxy.io to bypass CORS
- Update all services to use fetchWithProxy
2026-01-08 21:37:48 +04:00
Elie Habib 27892b306c Initial commit: World Monitor dashboard
Features:
- Real-time geopolitical monitoring dashboard
- Interactive D3.js world map with hotspots, conflicts, bases
- 16 news/data panels: World, Middle East, Tech, AI/ML, Finance, etc.
- Market data via Yahoo Finance (with rate limiting)
- Crypto prices via CoinGecko
- Prediction markets via Polymarket
- Earthquake data via USGS
- RSS feeds from 50+ sources including:
  - News: BBC, NPR, Guardian, Reuters, Al Jazeera
  - AI: OpenAI, Anthropic, Google AI, DeepMind
  - Government: White House, State Dept, Fed, SEC, Treasury
  - Intel: Defense One, Bellingcat, CISA, Krebs
  - Think Tanks: Brookings, CFR, CSIS
- Custom monitors with keyword alerts
- Draggable panel layout with persistence
- Time range filtering for events
- Dark theme optimized for monitoring
2026-01-08 21:29:47 +04:00
771 changed files with 10593 additions and 191448 deletions
-32
View File
@@ -1,32 +0,0 @@
# Sentry Triage — 2026-02-19
Commit: `09174fd` on `main`
## Issues Triaged (5)
### ACTIONABLE — Fixed in Code
| ID | Title | Events | Users | Fix |
|---|---|---|---|---|
| WORLDMONITOR-1G | `Error: ML request unload-model timed out after 120000ms` | 30 | 27 | Wrapped `unloadModel()` in try/catch; timeout no longer leaks as unhandled rejection. Cleans up `loadedModels` set on failure. |
| WORLDMONITOR-1F | `Error: ML request unload-model timed out after 120000ms` | 9 | 9 | Same root cause as 1G (different release build hash). |
| WORLDMONITOR-1K | `TypeError: this.player.playVideo is not a function` | 1 | 1 | Added optional chaining (`playVideo?.()`, `pauseVideo?.()`) in `LiveNewsPanel.ts`. YT IFrame API player object may not have methods ready during initialization race. |
### NOISE — Filtered
| ID | Title | Events | Users | Filter |
|---|---|---|---|---|
| WORLDMONITOR-1J | `InternalError: too much recursion` | 1 | 1 | i18next internal `translate -> extractFromKey` cycle on Firefox 147. Added `/too much recursion/` to `ignoreErrors`. |
| WORLDMONITOR-1H | `TypeError: Cannot read properties of null (reading 'id')` | 1 | 1 | maplibre-gl internal render crash (`_drawLayers -> renderLayers`). Extended `beforeSend` regex to suppress null `id`/`type` when stack is in map chunk. |
## Files Modified
| File | Change |
|---|---|
| `src/services/ml-worker.ts` | `unloadModel()`: try/catch around `this.request()`, clean `loadedModels` on failure |
| `src/components/LiveNewsPanel.ts` | Optional chaining on `playVideo?.()` and `pauseVideo?.()` |
| `src/main.ts` | Added `/too much recursion/` to `ignoreErrors`; extended maplibre `beforeSend` filter for null `id`/`type` |
## Sentry Status
All 5 issues marked **resolved (in next release)** via API. They will auto-reopen if errors recur after deployment.
-30
View File
@@ -1,30 +0,0 @@
# Build the image from source only — never copy host-built artifacts or deps.
#
# The web stage runs `npm ci` + `npm run build` itself and the go stage copies
# go.mod/cmd/internal explicitly (see Dockerfile), so the host must NOT ship its
# own node_modules/dist into the context: at `COPY . .` a host node_modules
# clobbers the image's fresh `npm ci` tree and poisons BuildKit's node_modules
# cache-mount ("cannot replace directory with file"), which breaks every build.
node_modules
**/node_modules
dist
**/dist
build
out
.next
.git
.gitignore
.github
*.log
npm-debug.log*
.env
.env.*
!.env.example
.DS_Store
.vscode
.idea
coverage
*.tsbuildinfo
-16
View File
@@ -109,22 +109,6 @@ VITE_WS_RELAY_URL=
# WORLDPOP_API_KEY=
# ------ Telemetry (Hanzo Cloud — @hanzo/event) ------
# Telemetry (pageviews, product events, errors) posts to api.hanzo.ai/v1/event,
# fanned server-side into web analytics, product analytics, and error tracking.
# Signed-in visitors are attributed by their IAM bearer automatically. To also
# accept LOGGED-OUT / anonymous traffic on the fail-closed door, ship a write-only
# publishable ingest key (pk_live_…). It is safe to bundle (cannot read, only
# ingest). Mint one per org via POST /v1/ingest/keys. Absent → anonymous events
# are best-effort (dropped by the door unless it allows unauthenticated ingest).
VITE_HANZO_INGEST_KEY=
# Google Tag Manager container id (marketing tags — GA / ad pixels). Orthogonal
# to the telemetry above; no-op until provisioned from KMS.
# VITE_GTM_ID=
# ------ Site Configuration ------
# Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app)
-85
View File
@@ -1,85 +0,0 @@
name: Bug Report
description: Report a bug in World Monitor
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix it.
- type: dropdown
id: variant
attributes:
label: Variant
description: Which variant are you using?
options:
- worldmonitor.app (Full / Geopolitical)
- tech.worldmonitor.app (Tech / Startup)
- finance.worldmonitor.app (Finance)
- Desktop app (Windows)
- Desktop app (macOS)
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
description: Which part of the app is affected?
options:
- Map / Globe
- News panels / RSS feeds
- AI Insights / World Brief
- Market Radar / Crypto
- Service Status
- Trending Keywords
- Country Brief pages
- Live video streams
- Desktop app (Tauri)
- Settings / API keys
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug description
description: A clear description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots / Console errors
description: If applicable, add screenshots or paste browser console errors.
- type: input
id: browser
attributes:
label: Browser & OS
description: e.g. Chrome 120 on Windows 11, Safari 17 on macOS Sonoma
placeholder: Chrome 120 on Windows 11
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/koala73/worldmonitor/blob/main/docs/DOCUMENTATION.md
about: Read the full documentation before opening an issue
- name: Discussions
url: https://github.com/koala73/worldmonitor/discussions
about: Ask questions and share ideas in Discussions
@@ -1,55 +0,0 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have an idea for World Monitor? We'd love to hear it!
- type: dropdown
id: area
attributes:
label: Feature area
description: Which area does this feature relate to?
options:
- Map / Globe / Data layers
- News panels / RSS feeds
- AI / Intelligence analysis
- Market data / Crypto
- Desktop app
- UI / UX
- API / Backend
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the feature you'd like.
placeholder: I'd like to see...
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem it solves
description: What problem does this feature address? What's the use case?
placeholder: This would help with...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Have you considered any alternative solutions or workarounds?
- type: textarea
id: context
attributes:
label: Additional context
description: Any mockups, screenshots, links, or references that help illustrate the idea.
@@ -1,69 +0,0 @@
name: New Data Source
description: Suggest a new RSS feed, API, or map layer
labels: ["data-source"]
body:
- type: markdown
attributes:
value: |
World Monitor aggregates 100+ feeds and data layers. Suggest a new one!
- type: dropdown
id: type
attributes:
label: Source type
description: What kind of data source is this?
options:
- RSS / News feed
- API integration
- Map layer (geospatial data)
- Live video stream
- Status page
- Other
validations:
required: true
- type: dropdown
id: variant
attributes:
label: Target variant
description: Which variant should this appear in?
options:
- Full (Geopolitical)
- Tech (Startup)
- Finance
- All variants
validations:
required: true
- type: input
id: source-name
attributes:
label: Source name
description: Name of the source or organization.
placeholder: e.g. RAND Corporation, CoinDesk, USGS
validations:
required: true
- type: input
id: url
attributes:
label: Feed / API URL
description: Direct URL to the RSS feed, API endpoint, or data source.
placeholder: https://example.com/rss
validations:
required: true
- type: textarea
id: description
attributes:
label: Why add this source?
description: What value does this source bring? What does it cover that existing sources don't?
placeholder: This source provides coverage of...
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional notes
description: Any details about rate limits, authentication requirements, data format, or category placement.
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="world">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">world</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">World Monitor</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-36
View File
@@ -1,36 +0,0 @@
## Summary
<!-- Brief description of what this PR does -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure
## Affected areas
- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->
## Checklist
- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app) variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)
## Screenshots
<!-- If applicable, add screenshots or screen recordings -->
-50
View File
@@ -37,24 +37,16 @@ jobs:
include:
- platform: 'macos-14'
args: '--target aarch64-apple-darwin'
node_target: 'aarch64-apple-darwin'
label: 'macOS-ARM64'
timeout: 180
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
node_target: 'x86_64-apple-darwin'
label: 'macOS-x64'
timeout: 180
- platform: 'windows-latest'
args: ''
node_target: 'x86_64-pc-windows-msvc'
label: 'Windows-x64'
timeout: 120
- platform: 'ubuntu-22.04'
args: ''
node_target: 'x86_64-unknown-linux-gnu'
label: 'Linux-x64'
timeout: 120
runs-on: ${{ matrix.platform }}
name: Build (${{ matrix.label }})
@@ -85,34 +77,9 @@ jobs:
workspaces: './src-tauri -> target'
cache-on-failure: true
- name: Install Linux system dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: npm ci
- name: Bundle Node.js runtime
shell: bash
env:
NODE_VERSION: '22.14.0'
NODE_TARGET: ${{ matrix.node_target }}
run: bash scripts/download-node.sh --target "$NODE_TARGET"
- name: Verify bundled Node.js payload
shell: bash
run: |
if [ "${{ matrix.node_target }}" = "x86_64-pc-windows-msvc" ]; then
test -f src-tauri/sidecar/node/node.exe
ls -lh src-tauri/sidecar/node/node.exe
else
test -f src-tauri/sidecar/node/node
test -x src-tauri/sidecar/node/node
ls -lh src-tauri/sidecar/node/node
fi
# ── Detect whether Apple signing secrets are configured ──
- name: Check Apple signing secrets
if: contains(matrix.platform, 'macos')
@@ -263,23 +230,6 @@ jobs:
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
- name: Verify signed macOS bundle + embedded runtime
if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
shell: bash
run: |
APP_PATH=$(find src-tauri/target -type d -path '*/bundle/macos/*.app' | head -1)
if [ -z "$APP_PATH" ]; then
echo "::error::No macOS .app bundle found after build."
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
NODE_PATH=$(find "$APP_PATH/Contents/Resources" -type f -path '*/sidecar/node/node' | head -1)
if [ -z "$NODE_PATH" ]; then
echo "::error::Bundled Node runtime missing from app resources."
exit 1
fi
echo "Verified signed app bundle and embedded Node runtime: $NODE_PATH"
- name: Cleanup Apple signing materials
if: always() && contains(matrix.platform, 'macos')
shell: bash
-15
View File
@@ -1,15 +0,0 @@
# Neutralized — the canonical build + deploy pipeline is now native on Hanzo Git.
# The image build (BuildKit → ghcr.io/hanzoai/world:<sha>) and the operator deploy
# (kubectl patch app world) run in-cluster from .hanzo/workflows/deploy.yml.
# GitHub is a downstream mirror; this workflow no longer builds or deploys anything.
name: CI/CD
on:
workflow_dispatch:
jobs:
notice:
runs-on: ubuntu-latest
steps:
- name: Sync notice
run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
-19
View File
@@ -1,19 +0,0 @@
name: Lint
on:
pull_request:
paths:
- '**/*.md'
- '.markdownlint-cli2.jsonc'
jobs:
markdown:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint:md
-44
View File
@@ -1,44 +0,0 @@
name: Sync to git.hanzo.ai
# hanzoai/world is NATIVE on git.hanzo.ai (hanzoai/git) — canonical repo + where CI/CD
# runs: build/test/release from .hanzo/workflows/ on the Hanzo git-runner. GitHub is the
# DOWNSTREAM MIRROR. The mirror App already syncs on push; this is the explicit,
# self-documenting belt-and-suspenders — its presence in .github/ signals
# "this repo migrated to git.hanzo.ai."
#
# IDEMPOTENT: mirror-sync just tells Gitea to pull HEAD, so N nudges == 1 (same latest
# state); concurrency coalesces a burst of pushes to a single in-flight sync. FAST: no
# checkout, one bounded curl, hard timeouts. FAIL-SOFT: a missing token or hiccup never
# fails the push (the App webhook still mirrors). Set HANZO_GIT_TOKEN to enable the nudge.
on:
push:
branches: [main]
workflow_dispatch:
# Only the newest push's sync needs to run — cancel any older in-flight one (it would
# pull a now-stale HEAD). This is what makes rapid pushes idempotent + cheap.
concurrency:
group: sync-git-hanzo-ai
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Nudge git.hanzo.ai to pull HEAD (mirror-sync)
continue-on-error: true
env:
HANZO_GIT_TOKEN: ${{ secrets.HANZO_GIT_TOKEN }}
run: |
set -u
if [ -z "${HANZO_GIT_TOKEN:-}" ]; then
echo "HANZO_GIT_TOKEN not set — relying on the mirror App webhook. Skipping nudge."
exit 0
fi
if curl -fsS --max-time 20 --retry 2 --retry-delay 2 -X POST \
-H "Authorization: token ${HANZO_GIT_TOKEN}" \
"https://git.hanzo.ai/api/v1/repos/hanzoai/world/mirror-sync"; then
echo "git.hanzo.ai mirror-sync triggered"
else
echo "mirror-sync nudge failed (non-fatal) — the App webhook still mirrors"
fi
-23
View File
@@ -1,8 +1,5 @@
node_modules/
.idea/
.planning/
dist/
dist-react/
.DS_Store
*.log
.env
@@ -13,28 +10,8 @@ dist-react/
.cursor/
CLAUDE.md
.env.vercel-backup
.env.vercel-export
.agent/
.factory/
.windsurf/
skills/
ideas/
test-results/
src-tauri/sidecar/node/*
!src-tauri/sidecar/node/.gitkeep
# world-model runtime snapshot + history ring (regenerated on boot)
data/world-model.json
data/world-model.json.tmp
data/world-model-history.json.gz
data/world-model-history.json.gz.tmp
# embedded datastore lake + settings (SQLite, WAL mode) — regenerated on boot,
# lands here only when WORLD_DATA_DIR points at the repo (local dev)
world.db
world.db-wal
world.db-shm
data/world.db
data/world.db-wal
data/world.db-shm
/world
-24
View File
@@ -1,24 +0,0 @@
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: hanzo-linux-amd64
steps:
- uses: actions/checkout@v4
- name: Build + push image
run: |
SHA="${GITHUB_SHA::8}"
buildctl-daemonless.sh build --frontend=dockerfile.v0 \
--opt context="${{ github.server_url }}/${{ github.repository }}.git#${GITHUB_SHA}" \
--opt filename=Dockerfile --opt platform=linux/amd64 \
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
--output "type=image,name=ghcr.io/hanzoai/world:${SHA},push=true" --progress=plain
env:
GIT_AUTH_TOKEN: ${{ secrets.GIT_CLONE_TOKEN }}
- name: Deploy — declare tag to operator
run: |
for app in world; do
kubectl -n hanzo patch app "$app" --type=merge -p "{\"spec\":{\"image\":{\"repository\":\"ghcr.io/hanzoai/world\",\"tag\":\"${GITHUB_SHA::8}\"}}}"
done
-116
View File
@@ -1,116 +0,0 @@
name: Release
# Cuts a release of ghcr.io/hanzoai/world — the static SPA frontend image (Vite ->
# hanzoai/static) the operator CR pins. Mirrors hanzoai/app/release.yml:
# a git tag v<X.Y.Z> ⇔ an image ghcr.io/hanzoai/world:v<X.Y.Z>
# Builds on the git.hanzo.ai native runner (git-runner). ghcr auth via GH_PAT / user
# hanzo-dev.
#
# ADDITIVE MIGRATION NOTE: the legacy .github lane (docker-build.yml) does NOT cut
# v-tags — it only REACTS to `tags: ['v*']` to build+deploy. So there is no
# competing tag-cutter and no version runaway. paths-ignore below keeps this native
# release from re-firing on .github/** or doc-only edits.
on:
push:
branches: [main]
# Workflow/doc-only edits must NOT cut a release. Real code changes on main still
# trigger; use workflow_dispatch to force.
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
concurrency:
group: release-world
cancel-in-progress: false
jobs:
build-amd64:
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Checkout (full history + tags — version floor read from tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Compute next version (monotonic patch bump over the 2.4.x deploy line)
id: ver
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --tags --force --quiet
# The deployed lineage is 2.4.x: the newest-by-date tags on main are v2.4.48,
# v2.4.47, ... (HEAD is "release: v2.4.48") and every recent release commit is
# on it. Stray tags exist off that line — notably v2.9.32 (a July-9 relic,
# numerically largest but OLDER than the active 2.4 stream) and the 2.5.x
# cluster. `sort -V` is purely numeric, so an UNCONSTRAINED scan lets the
# v2.9.32 orphan hijack the floor and cut a v2.9.33 the deploy stream can't
# follow. Pin the scan to the 2.4. line so the bump stays monotonic over the
# ACTUAL deploy stream, not the numerically-largest orphan tag.
LINE='2.4.'
git_max="$(git tag -l "v${LINE}[0-9]*" \
| sed 's/^v//' | grep -E "^${LINE//./\\.}[0-9]+$" | sort -V | tail -1 || true)"
# Fold in ALREADY-PUSHED container tags on the same line, so a number that has
# an image (even from a run that died before git-tagging) is never reused.
# Best-effort — never fails the run if the API is down. Use curl (universally
# present) NOT gh. Fold BOTH v-prefixed and legacy non-v 2.9.x image tags.
cont_max=""
if [ -n "${GH_PAT:-}" ]; then
_pkgjson="$(curl -fsSL -H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
'https://api.github.com/orgs/hanzoai/packages/container/world/versions?per_page=100' 2>/dev/null || true)"
cont_max="$(printf '%s' "$_pkgjson" \
| { jq -r '.[].metadata.container.tags[]?' 2>/dev/null || grep -oE '"v?[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"'; } \
| sed 's/^v//' | grep -E "^${LINE//./\\.}[0-9]+$" | sort -V | tail -1 || true)"
fi
max="$(printf '%s\n%s\n%s\n' "${LINE}0" "$git_max" "$cont_max" \
| grep -E "^${LINE//./\\.}[0-9]+$" | sort -V | tail -1)"
major="${max%%.*}"; rest="${max#*.}"; minor="${rest%%.*}"; patch="${rest##*.}"
version="${major}.${minor}.$((patch + 1))"
if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
echo "::error::computed v${version} already exists — aborting to avoid collision"; exit 1
fi
echo "version_v=v${version}" >> "$GITHUB_OUTPUT"
echo "Next release: v${version} (git_max='${git_max:-none}' container_max='${cont_max:-none}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- name: Log in to ghcr.io (GH_PAT, write:packages)
uses: docker/login-action@v3
with:
registry: ghcr.io
username: hanzo-dev
password: ${{ secrets.GH_PAT }}
- name: Build + push ghcr.io/hanzoai/world
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: ghcr.io/hanzoai/world:${{ steps.ver.outputs.version_v }}
provenance: false
- name: Verify the pushed image is pullable (guard the image ⇔ tag invariant)
# build-push-action can exit 0 while the manifest isn't yet resolvable at the
# registry. Prove the image resolves BEFORE creating the git tag, so a bad push
# fails the run instead of leaving a phantom tag → ImagePullBackOff.
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version_v }}"
for i in 1 2 3 4 5 6; do
if docker manifest inspect "ghcr.io/hanzoai/world:${v}" >/dev/null 2>&1; then
echo "image ${v} is resolvable — safe to tag"; exit 0
fi
echo "ghcr manifest ${v} not visible yet (attempt ${i}/6) — retrying"; sleep 5
done
echo "::error::image ${v} not pullable after push — refusing to tag git (prevents ImagePullBackOff)"; exit 1
- name: Tag the release (image ⇔ tag invariant)
run: |
set -euo pipefail
git tag "${{ steps.ver.outputs.version_v }}"
git push origin "${{ steps.ver.outputs.version_v }}"
echo "Pushed image + tag ${{ steps.ver.outputs.version_v }} — bump the operator CR to deploy."
-10
View File
@@ -1,10 +0,0 @@
{
// Only enforce the 3 rules from PR #72. Everything else is off.
"config": {
"default": false,
"MD012": true,
"MD022": true,
"MD032": true
},
"ignores": ["node_modules/**", "dist/**", "src-tauri/target/**"]
}
-45
View File
@@ -1,45 +0,0 @@
# Hanzo World — agent guide
Vite + TypeScript SPA (`world-monitor`). Real-time global-intelligence dashboard
served at `world.hanzo.ai`, shipped by `hanzo.yml` CI/CD onto the `world`
operator Service CR. Same-origin data plane under `/v1/world/*`.
## Browser control — prefer the Hanzo MCP extension over Playwright
When the **Hanzo browser MCP** (the `mcp__hanzo__browser` / `mcp__claude-in-chrome__*`
tools, backed by the local extension in `~/work/hanzo/extension`) is available,
use it by default to drive a real browser for interactive UI work — inspecting
the globe, tweaking layers, checking live feeds, capturing screenshots against a
running dev server. It talks to a real Chrome/Firefox with the real WebGL/deck.gl
context, so what you see is what a user sees.
Fall back to **Playwright only when** the MCP extension is not connected, or for
the deterministic **e2e suite** (`e2e/*.spec.ts`) — those run headless in CI with
mocked `/v1/world/cloud/*` feeds and must stay reproducible offline. E2e is not
interactive editing; keep the two lanes separate.
Order of preference for "look at / poke the UI": Hanzo MCP browser → Playwright.
## The map / globe
- Basemaps: `dark` · `dot` · `satellite` · `terrain`. **`dot` is the default**
for every variant (`DEFAULT_BASEMAP_STYLE` in `src/config/variant.ts`) — the
Kaspersky-style cybermap: land drawn only as a glowing dot-lattice over a black
ocean sphere, no country fills/borders/imagery.
- The lattice is one pure value — `getLandDots()` in `src/services/land-dots.ts`
consumed by BOTH the 2D mercator map (`DeckGLMap`) and the 3D globe
(`GlobeNative`). One source, two projections. Don't fork it.
- Cloud view layers (default-on, `?variant=cloud`): live request-origin dots +
animated traffic arcs (`AnimatedArcLayer`, a travelling pulse advanced on RAF),
validator chain-nodes, BYO-GPU rings, datacenter clusters. Feeds are best-effort
and degrade to honest empty states — never fabricate volume.
- `satellite`/`terrain` need `VITE_MAPBOX_TOKEN` (from KMS `hanzo/deploy/`, never
in git); `dark`/`dot` are keyless CartoDB.
## Release
Bump `package.json` PATCH (x.y.z → x.y.z+1, never a lazy major), tag `v<version>`,
`workflow_dispatch` the `cicd` workflow. The image tag is the version WITHOUT the
`v`. CI's "Deploy" step can false-negative while the operator finishes rolling —
verify the live version, not just the CI square. Test/doc-only changes need no
release (the image is byte-identical).
-200
View File
@@ -2,206 +2,6 @@
All notable changes to World Monitor are documented here.
## [2.4.43]
### Added
- **Finance-terminal alt-asset feeds are live** (`?variant=finance`). The "Art & Collectibles — Auctions" and "Luxury Real Estate" panels now render REAL data instead of "live feed pending":
- `GET /v1/world/auctions` — Christie's public auction results (realized **sale totals**: title, inferred category, price + currency, sale date, location, image, link). Sotheby's gates realized prices behind a login (its sale-results API returns `401 Not signed in`), so the honest public major house is the source.
- `GET /v1/world/luxury-realestate` — LuxuryEstate.com luxury listings (title, location, price + currency, type, image, link). JamesEdition sits behind a Cloudflare JS challenge that blocks datacenter egress, so it cannot be fetched from the pod.
- Both are scraped server-side (`internal/world/handlers_altassets.go`) at most **once an hour** and served from cache to every caller (respectful). On a source failure the last-good copy is served; with no cache yet, an honest empty `{items:[]}` (the panel shows "live feed pending") — **never fabricated data**. The payload attributes its source per feed.
## [2.4.19]
### Fixed
- **Moving/resizing a panel no longer shifts the others** (the "when I shift the 3D map it shifts all other components" report). The dashboard now DEFAULTS to the free layout: every panel owns its own {x,y,w,h}, so a drag or resize leaves every sibling exactly where it was — no more grid reflow. The switch is invisible: the current grid arrangement is frozen as the starting geometry (`grid-config.applyDefaultLayout`), and Grid stays one dropdown-click away for anyone who wants snap-to-grid back.
- **Panels resize granularly** — pixel-exact width AND height from any edge or corner, instead of jumping between coarse column/row spans.
- **Panels can be made much narrower/smaller** ("constrained on min width"): the free-mode floors drop to 96×40px (map 140px), and the grid widget-size slider now reaches a 120px column track (was 140).
### Changed
- A panel shown from the Panels menu / "+ Add widget" while in free mode seeds a tidy default slot below the placed panels (never a full-width block under its absolute siblings). Hidden panels no longer persist 0×0 geometry.
## [2.4.13]
### Fixed
- **Globe overlays now sit ON the sphere with correct occlusion** (the "map spazzes" report). On the native deck.gl GlobeView, data layers (shared with the 2D map) carried no depth parameters, so the far hemisphere showed through and count badges floated above the limb. GlobeNative now seats every data layer against the depth-writing ocean sphere (test, don't write — no inter-marker z-fight), and the far-side billboard cull (`occludeFarSide`) faces the globe's OWN live camera instead of the parked (frozen) mapbox center — extended to `TextLayer` so a back-side "36" count badge disappears instead of hovering over the planet.
- **Terrain striping on the globe**: the draped ESRI imagery tiles each wrote depth, so coplanar neighbours z-fought at the tile seams (horizontal stripes / partial render). Tiles now depth-TEST only; the ocean sphere owns the depth buffer.
- **Live request-geo dots kept polling on the 3D globe**: the `/v1/world/cloud/*` poll was gated on `renderPaused`, which the native GlobeView sets on activation — so the realtime dots froze the moment you entered the default 3D view. It now gates on tab visibility, so the dots stay live (and fade) on the globe.
### Changed
- **Request-origin → serving-region arcs on by default** in the Cloud view: the `/cloud/traffic` arcs derive from the same real native request-geo points as the dots (origin country → nearest Hanzo region) and degrade to empty — never demo.
## [2.4.6]
### Added
- **Enso Flywheel panel (AI variant)**: the router self-improvement loop made visible. New `/v1/world/enso-training` folds the routing-decision ledger tail + reward tail (`export-routing-ledger` / `export-routing-rewards`, super-admin) into ledger growth, engine-vs-heuristic mix, a routing-confidence histogram, and task/model distributions, alongside the latest enso-bench eval scores (an embedded `results/summary.json` snapshot, overridable live via `ENSO_BENCH_URL`). The response is event-typed so future retrain/deploy milestones slot into the same timeline. Eval scores render even signed-out; `state` (live/partial/demo) says which live sources resolved.
## [2.4.5]
### Added
- **AI Compute panel (AI variant)**: live Hanzo inference-plane telemetry over SSE — tokens/s, requests/s, 24h spend, top models by real spend, and the serving fleet (GPUs, machines online, models served). New `/v1/world/ai-pulse` pushes typed `usage`/`fleet`/`status` frames (EventSource) and answers a plain GET with one JSON snapshot as the poll fallback. Honest "connecting"/"unavailable" states — never a zero dressed up as live; the service bearer stays server-side.
## [2.4.4]
### Changed
- **Cloud Pulse is real when a service token is wired**: `/v1/world/cloud-pulse` now folds MEASURED platform-wide 24h request/token volume from the ClickHouse-backed usage ledger (`get-cloud-usages ?org=all`, super-admin) on top of the live model/node/GPU/region counts — dropping both `demo:true` and `volumeModeled:true`. Top models come from real ledger spend. Without a token, or with a non-admin token, it stays honestly demo/modeled — platform numbers are never faked silently. The service bearer stays server-side (never sent to the browser).
## [2.4.2]
### Added
- **Streaming analyst**: answers flow in live over SSE — reasoning shows as dim thinking text, tool calls appear as chips the moment they run, the reply types itself in; the final render and command dispatch are unchanged (done event = the old JSON contract)
- **Model menu**: the composer pill opens a grouped popover (Auto / Zen / GPT / Llama / Claude / Agents) with per-family marks and an active check
### Fixed
- **Model identity**: the Zen ring appears only on zen* models — gpt-oss/llama/claude get their own marks on the pill, menu rows, avatars, and the thinking row
- **Dev server**: `npm run dev` proxies /v1 to production by default (VITE_DEV_API_PROXY overrides)
## [2.4.1]
### Added
- **Western Pacific cyclones**: cross-agency tropical-cyclone attribution (GDACS + HKO warnings via new `/v1/world/hko-warnings` proxy) with per-agency wind observations, canonical dedup, and map popup detail rows
- **China macro snapshot**: `/v1/world/china-macro` — CPI/CLI (OECD), policy rate, USD/CNY (FRED), HKMA context, NBS release calendar + PBoC LPR dates, surfaced with staleness-honest indicator tiles
- **Model roster**: `Best (auto)` leads the analyst model picker — the gateway routing alias that always resolves
### Changed
- **AI default model**: `zen5``best`; a pinned family id goes dark when the inference plane's claim catalog shifts, the routing alias never does
- **Server cache is now stale-while-revalidate**: `cachedJSON`/`passthrough` serve stale instantly and refresh in the background (single-flight); GDELT/theater-posture no longer stall requests ~10s on TTL expiry
- **GDELT cache warmers**: hot keys (analyst grounding, protests layer) refreshed every ~4min so no user ever eats a cold miss
- **Sparkline payloads**: close arrays rounded to 7 significant digits (float32-widening noise stripped, ~40% smaller; scalars untouched)
### Fixed
- **News first paint**: panels no longer gate their first DOM write on a 65MB ML sentiment model download — headlines paint immediately, sentiment refines in place
- **Analyst grounding snapshot**: context fetches bounded at 2.5s so a cold endpoint can't hold the chat send hostage
- **AI errors are honest**: upstream error codes (`insufficient_balance`, `spend_cap_exceeded`, …) surface in the chat instead of a bare `status 402`
## [2.4.0] - 2026-02-19
### Added
- **Live Webcams Panel**: 2x2 grid of live YouTube webcam feeds from global hotspots with region filters (Middle East, Europe, Asia-Pacific, Americas), grid/single view toggle, idle detection, and full i18n support (#111)
- **Linux download**: added `.AppImage` option to download banner
### Changed
- **Mobile detection**: use viewport width only for mobile detection; touch-capable notebooks (e.g. ROG Flow X13) now get desktop layout (#113)
- **Webcam feeds**: curated Tel Aviv, Mecca, LA, Miami; replaced dead Tokyo feed; diverse ALL grid with Jerusalem, Tehran, Kyiv, Washington
### Fixed
- **Le Monde RSS**: English feed URL updated (`/en/rss/full.xml``/en/rss/une.xml`) to fix 404
- **Workbox precache**: added `html` to `globPatterns` so `navigateFallback` works for offline PWA
- **Panel ordering**: one-time migration ensures Live Webcams follows Live News for existing users
- **Mobile popups**: improved sheet/touch/controls layout (#109)
- **Intelligence alerts**: disabled on mobile to reduce noise (#110)
- **RSS proxy**: added 8 missing domains to allowlist
- **HTML tags**: repaired malformed tags in panel template literals
- **ML worker**: wrapped `unloadModel()` in try/catch to prevent unhandled timeout rejections
- **YouTube player**: optional chaining on `playVideo?.()` / `pauseVideo?.()` for initialization race
- **Panel drag**: guarded `.closest()` on non-Element event targets
- **Beta mode**: resolved race condition and timeout failures
- **Sentry noise**: added filters for Firefox `too much recursion`, maplibre `_layers`/`id`/`type` null crashes
## [2.3.9] - 2026-02-18
### Added
- **Full internationalization (14 locales)**: English, French, German, Spanish, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese Simplified, Japanese — each with 1100+ translated keys
- **RTL support**: Arabic locale with `dir="rtl"`, dedicated RTL CSS overrides, regional language code normalization (e.g. `ar-SA` correctly triggers RTL)
- **Language switcher**: in-app locale picker with flag icons, persists to localStorage
- **i18n infrastructure**: i18next with browser language detection and English fallback
- **Community discussion widget**: floating pill linking to GitHub Discussions with delayed appearance and permanent dismiss
- **Linux AppImage**: added `ubuntu-22.04` to CI build matrix with webkit2gtk/appindicator dependencies
- **NHK World and Nikkei Asia**: added RSS feeds for Japan news coverage
- **Intelligence Findings badge toggle**: option to disable the findings badge in the UI
### Changed
- **Zero hardcoded English**: all UI text routed through `t()` — panels, modals, tooltips, popups, map legends, alert templates, signal descriptions
- **Trending proper-noun detection**: improved mid-sentence capitalization heuristic with all-caps fallback when ML classifier is unavailable
- **Stopword suppression**: added missing English stopwords to trending keyword filter
### Fixed
- **Dead UTC clock**: removed `#timeDisplay` element that permanently displayed `--:--:-- UTC`
- **Community widget duplicates**: added DOM idempotency guard preventing duplicate widgets on repeated news refresh cycles
- **Settings help text**: suppressed raw i18n key paths rendering when translation is missing
- **Intelligence Findings badge**: fixed toggle state and listener lifecycle
- **Context menu styles**: restored intel-findings context menu styles
- **CSS theme variables**: defined missing `--panel-bg` and `--panel-border` variables
## [2.3.8] - 2026-02-17
### Added
- **Finance variant**: Added a dedicated market-first variant (`finance.worldmonitor.app`) with finance/trading-focused feeds, panels, and map defaults
- **Finance desktop profile**: Added finance-specific desktop config and build profile for Tauri packaging
### Changed
- **Variant feed loading**: `loadNews` now enumerates categories dynamically and stages category fetches with bounded concurrency across variants
- **Feed resilience**: Replaced direct MarketWatch RSS usage in finance/full/tech paths with Google News-backed fallback queries
- **Classification pressure controls**: Tightened AI classification budgets for tech/full and tuned per-feed caps to reduce startup burst pressure
- **Timeline behavior**: Wired timeline filtering consistently across map and news panels
- **AI summarization defaults**: Switched OpenRouter summarization to auto-routed free-tier model selection
### Fixed
- **Finance panel parity**: Kept data-rich panels while adding news panels for finance instead of removing core data surfaces
- **Desktop finance map parity**: Finance variant now runs first-class Deck.GL map/layer behavior on desktop runtime
- **Polymarket fallback**: Added one-time direct connectivity probe and memoized fallback to prevent repeated `ERR_CONNECTION_RESET` storms
- **FRED fallback behavior**: Missing `FRED_API_KEY` now returns graceful empty payloads instead of repeated hard 500s
- **Preview CSP tooling**: Allowed `https://vercel.live` script in CSP so Vercel preview feedback injection is not blocked
- **Trending quality**: Suppressed noisy generic finance terms in keyword spike detection
- **Mobile UX**: Hidden desktop download prompt on mobile devices
## [2.3.7] - 2026-02-16
### Added
- **Full light mode theme**: Complete light/dark theme system with CSS custom properties, ThemeManager module, FOUC prevention, and `getCSSColor()` utility for theme-aware inline styles
- **Theme-aware maps and charts**: Deck.GL basemap, overlay layers, and CountryTimeline charts respond to theme changes in real time
- **Dark/light mode header toggle**: Sun/moon icon in the header bar for quick theme switching, replacing the duplicate UTC clock
- **Desktop update checker**: Architecture-aware download links for macOS (ARM/Intel) and Windows
- **Node.js bundled in Tauri installer**: Sidecar no longer requires system Node.js
- **Markdown linting**: Added markdownlint config and CI workflow
### Changed
- **Panels modal**: Reverted from "Settings" back to "Panels" — removed redundant Appearance section now that header has theme toggle
- **Default panels**: Enabled UCDP Conflict Events, UNHCR Displacement, Climate Anomalies, and Population Exposure panels by default
### Fixed
- **CORS for Tauri desktop**: Fixed CORS issues for desktop app requests
- **Markets panel**: Keep Yahoo-backed data visible when Finnhub API key is skipped
- **Windows UNC paths**: Preserve extended-length path prefix when sanitizing sidecar script path
- **Light mode readability**: Darkened neon semantic colors and overlay backgrounds for light mode contrast
## [2.3.6] - 2026-02-16
### Fixed
- **Windows console window**: Hide the `node.exe` console window that appeared alongside the desktop app on Windows
## [2.3.5] - 2026-02-16
### Changed
- **Panel error messages**: Differentiated error messages per panel so users see context-specific guidance instead of generic failures
- **Desktop config auto-hide**: Desktop configuration panel automatically hides on web deployments where it is not relevant
## [2.3.4] - 2026-02-16
### Fixed
-78
View File
@@ -1,78 +0,0 @@
# world.hanzo.ai — Vite SPA + same-origin /api/* data backend, one Go binary.
#
# The SPA fetches SAME-ORIGIN /api/* (runtime.ts resolves to the current origin),
# so world.hanzo.ai (and every *.hanzo.app fork) must serve /api/* itself. The
# old static-only image (hanzoai/static) had no /api, so every data + live-video
# request fell through to the SPA index.html — the app showed no data and no
# video. This image fixes that: cmd/world serves BOTH the static build (with SPA
# fallback for client routes) AND the ~48 /api/* endpoints (internal/world),
# each a faithful Go port of the original edge function.
#
# Built on Hanzo's own hardware (platform.hanzo.ai -> arcd / in-cluster
# BuildKit), never on GitHub builders.
#
# Build (BuildKit, on-cluster):
# --opt=context=https://github.com/hanzoai/world.git#<sha>
# --opt=filename=Dockerfile
# --output=type=image,name=ghcr.io/hanzoai/world:<tag>,push=true
#
# Data-source API keys (all optional; a missing key degrades that endpoint to a
# clean empty payload, never a 5xx) are injected as env at deploy time from KMS:
# YOUTUBE_API_KEY (live-video reliability; scrape fallback needs no key),
# FRED_API_KEY, FINNHUB_API_KEY, NASA_FIRMS_API_KEY, EIA_API_KEY,
# ACLED_ACCESS_TOKEN, CLOUDFLARE_API_TOKEN, WINGBITS_API_KEY, WS_RELAY_URL,
# HANZO_AI_KEY (+ HANZO_AI_BASE / HANZO_AI_MODEL) for the AI endpoints.
# ---- web stage: Vite static build (-> /app/dist) -------------------------
FROM node:20-bookworm-slim AS web
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# vite.config.ts: default base '/', default outDir 'dist'. VITE_VARIANT defaults
# to the full layer set; no build-time secrets are required (the runtime API base
# is same-origin, resolved in the browser).
ARG VITE_MAPBOX_TOKEN
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
RUN npm run build
# The @hanzo/gui (Tamagui) React rewrite, built to /app/dist-react as the opt-in
# canary surface. Served only to sessions that pass ?react (see cmd/world:
# canaryHandler); the vanilla dist above stays the default.
RUN npm run build:react
# ---- go stage: build the static server binary (CGO-free) -----------------
# go 1.26: go.mod requires >= 1.26.4 (github.com/hanzoai/sqlite drop-in). The
# binary stays CGO-free — with CGO_ENABLED=0, hanzoai/sqlite selects its vendored
# pure-Go engine (zero modernc.org/* in the module graph). That engine gates FTS5
# behind the `sqlite_fts5` build tag, which the store's items_fts virtual table
# needs — so the build below MUST carry `-tags sqlite_fts5` or Open degrades.
FROM golang:1.26-alpine AS gobuild
WORKDIR /src
# git: go resolves the PRIVATE indirect dep github.com/hanzoai/csqlite 'direct'
# (not via the module proxy), which needs the git binary + an https credential.
# alpine ships neither, so add git and mount the gh_token BuildKit secret that
# hanzoai/ci passes (--secret id=gh_token); the mount is a no-op for public builds.
RUN apk add --no-cache git
ENV GOPRIVATE=github.com/hanzoai,github.com/luxfi,github.com/zooai
# Deps: hanzo-kv client (go-redis) + embedded SQLite (modernc). Download once for
# a cached layer before the source is copied.
COPY go.mod go.sum ./
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi; \
go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build -tags sqlite_fts5 -trimpath -ldflags="-s -w" -o /out/world ./cmd/world
# ---- final stage: minimal image running the Go binary --------------------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -H -u 10001 world
COPY --from=gobuild /out/world /usr/local/bin/world
COPY --from=web /app/dist /srv
COPY --from=web /app/dist-react /srv-react
USER world
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/world", "--root=/srv", "--react-root=/srv-react", "--addr=:3000"]
+3 -4
View File
@@ -1,14 +1,13 @@
MIT License
Copyright (c) 2024-2026 Elie Habib
Copyright (c) 2026 Hanzo AI, Inc.
Copyright (c) 2025-2026 Elie Habib
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
-31
View File
@@ -1,31 +0,0 @@
Hanzo World — Hanzo fork
Copyright (c) 2026 Hanzo AI, Inc. Licensed under the MIT License (see LICENSE).
Forked from koala73/worldmonitor (https://github.com/koala73/worldmonitor) at
v2.4.0 (commit 572f3808) — the last release under the MIT License. This fork
tracks only the MIT-era code and develops forward independently.
Original work © 2024-2026 Elie Habib, used under the MIT License.
DEVIATIONS
Changes made by Hanzo AI, Inc. relative to the upstream v2.4.0 baseline:
- Rebranded the product to "Hanzo World": index.html <title>/meta/OpenGraph,
the vite.config full variant (siteName "Hanzo World"), and canonical host
world.hanzo.ai.
- Added a single Go binary (cmd/world, internal/world; go.mod module
github.com/hanzoai/world) that serves the Vite SPA with SPA fallback AND
ports each upstream Vercel edge function to same-origin /v1/world/* (and
legacy /api/*) in Go. Upstream shipped a Vite SPA plus Vercel edge functions.
- Added Hanzo deploy variants beyond upstream full/tech/finance:
src/config/variants/saas.ts (Hanzo Cloud platform metrics/usage), ai.ts, and
crypto.ts, with matching vite.config entries.
- Added Hanzo CI/CD manifest hanzo.yml: image ghcr.io/hanzoai/world built via
hanzoai/ci, rolled onto the "world" operator Service CR on DOKS
do-sfo3-hanzo-k8s, with secrets fetched from KMS (kms.hanzo.ai).
- Reworked Dockerfile to build the Go binary serving the SPA plus the API
endpoints (replacing the static-only image), built on Hanzo hardware
(platform.hanzo.ai / on-cluster BuildKit) rather than GitHub builders.
- Rebranded package.json (name @hanzo/world, added description) and the
primary Tauri desktop app (productName and window title "Hanzo World").
- Added Copyright (c) 2026 Hanzo AI, Inc. to LICENSE alongside the original.
+44 -122
View File
@@ -1,22 +1,17 @@
<p align="center"><img src=".github/hero.svg" alt="world" width="880"></p>
# Hanzo World
# World Monitor
**Real-time global intelligence dashboard** — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface.
> Hanzo World is Hanzo AI, Inc.'s fork of [World Monitor](https://github.com/koala73/worldmonitor) (MIT) by Elie Habib. See [NOTICE](./NOTICE) for provenance and deviations.
[![GitHub stars](https://img.shields.io/github/stars/hanzoai/world?style=social)](https://github.com/hanzoai/world/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/hanzoai/world?style=social)](https://github.com/hanzoai/world/network/members)
[![GitHub stars](https://img.shields.io/github/stars/koala73/worldmonitor?style=social)](https://github.com/koala73/worldmonitor/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/koala73/worldmonitor?style=social)](https://github.com/koala73/worldmonitor/network/members)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Last commit](https://img.shields.io/github/last-commit/hanzoai/world)](https://github.com/hanzoai/world/commits/main)
[![Latest release](https://img.shields.io/github/v/release/hanzoai/world?style=flat)](https://github.com/hanzoai/world/releases/latest)
[![Last commit](https://img.shields.io/github/last-commit/koala73/worldmonitor)](https://github.com/koala73/worldmonitor/commits/main)
[![Latest release](https://img.shields.io/github/v/release/koala73/worldmonitor?style=flat)](https://github.com/koala73/worldmonitor/releases/latest)
<p align="center">
<a href="https://world.hanzo.ai"><img src="https://img.shields.io/badge/Web_App-world.hanzo.ai-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>&nbsp;
<a href="https://tech.worldmonitor.app"><img src="https://img.shields.io/badge/Tech_Variant-tech.worldmonitor.app-0891b2?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Tech Variant"></a>&nbsp;
<a href="https://finance.worldmonitor.app"><img src="https://img.shields.io/badge/Finance_Variant-finance.worldmonitor.app-059669?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Finance Variant"></a>
<a href="https://worldmonitor.app"><img src="https://img.shields.io/badge/Web_App-worldmonitor.app-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>&nbsp;
<a href="https://tech.worldmonitor.app"><img src="https://img.shields.io/badge/Tech_Variant-tech.worldmonitor.app-0891b2?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Tech Variant"></a>
</p>
<p align="center">
@@ -27,26 +22,25 @@
<p align="center">
<a href="./docs/DOCUMENTATION.md"><strong>Full Documentation</strong></a> &nbsp;·&nbsp;
<a href="https://github.com/hanzoai/world/releases/latest"><strong>All Releases</strong></a>
<a href="https://github.com/koala73/worldmonitor/releases/latest"><strong>All Releases</strong></a>
</p>
![Hanzo World Dashboard](new-world-monitor.png)
![World Monitor Dashboard](new-world-monitor.png)
---
## Why Hanzo World?
## Why World Monitor?
| Problem | Solution |
|---------|----------|
| News scattered across 100+ sources | **Single unified dashboard** with 100+ curated feeds |
| No geospatial context for events | **Interactive map** with 35+ toggleable data layers |
| No geospatial context for events | **Interactive map** with 30+ toggleable data layers |
| Information overload | **AI-synthesized briefs** with focal point detection |
| 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 35+ toggleable data layers |
| Siloed financial data | **Finance variant** with 92 stock exchanges, 19 financial centers, 13 central banks, and Gulf FDI tracking |
| Flat 2D maps | **3D WebGL globe** with deck.gl rendering and 30+ toggleable data layers |
---
@@ -56,26 +50,17 @@
|---------|-----|-------|
| **World Monitor** | [worldmonitor.app](https://worldmonitor.app) | Geopolitics, military, conflicts, infrastructure |
| **Tech Monitor** | [tech.worldmonitor.app](https://tech.worldmonitor.app) | Startups, AI/ML, cloud, cybersecurity |
| **Finance Monitor** | [finance.worldmonitor.app](https://finance.worldmonitor.app) | Global markets, trading, central banks, Gulf FDI |
All three variants run from a single codebase — switch between them with one click via the header bar (🌍 WORLD | 💻 TECH | 📈 FINANCE).
Both variants run from a single codebase — switch between them with one click.
---
## Key Features
### Localization & Regional Support
- **Multilingual UI** — Fully localized interface supporting **English, French, Spanish, German, Italian, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, and Japanese**.
- **RTL Support** — Native right-to-left layout support for Arabic (`ar`) and Hebrew.
- **Localized News Feeds** — Region-specific RSS selection based on language preference (e.g., viewing the app in French loads Le Monde, Jeune Afrique, and France24).
- **AI Translation** — Integrated LLM translation for news headlines and summaries, enabling cross-language intelligence gathering.
- **Regional Intelligence** — Dedicated monitoring panels for Africa, Latin America, Middle East, and Asia with local sources.
### 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`
- **35+ data layers** — conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fire detection, protests, natural disasters, datacenters, displacement flows, climate anomalies, cyber threat IOCs, stock exchanges, financial centers, central banks, commodity hubs, Gulf investments, and more
- **30+ data layers** — conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fire detection, protests, natural disasters, datacenters, displacement flows, climate anomalies, cyber threat IOCs, 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
@@ -126,7 +111,7 @@ All three variants run from a single codebase — switch between them with one c
- Undersea cables with landing points
- Oil & gas pipelines
- AI datacenters (111 major clusters)
- 83 strategic ports across 6 types (container, oil, LNG, naval, mixed, bulk) with throughput rankings
- 84 strategic ports across 6 types (container, oil, LNG, naval, mixed, bulk) with throughput rankings
- Internet outages (Cloudflare Radar)
- Critical mineral deposits
- NASA FIRMS satellite fire detection (VIIRS thermal hotspots)
@@ -157,20 +142,9 @@ All three variants run from a single codebase — switch between them with one c
</details>
<details>
<summary><strong>Finance & Markets</strong> (Finance variant)</summary>
- 92 global stock exchanges — mega (NYSE, NASDAQ, Shanghai, Euronext, Tokyo), major (Hong Kong, London, NSE/BSE, Toronto, Korea, Saudi Tadawul), and emerging markets — with market caps and trading hours
- 19 financial centers — ranked by Global Financial Centres Index (New York #1 through offshore centers: Cayman Islands, Luxembourg, Bermuda, Channel Islands)
- 13 central banks — Federal Reserve, ECB, BoJ, BoE, PBoC, SNB, RBA, BoC, RBI, BoK, BCB, SAMA, plus supranational institutions (BIS, IMF)
- 10 commodity hubs — exchanges (CME Group, ICE, LME, SHFE, DCE, TOCOM, DGCX, MCX) and physical hubs (Rotterdam, Houston)
- Gulf FDI investment layer — 64 Saudi/UAE foreign direct investments plotted globally, color-coded by status (operational, under-construction, announced), sized by investment amount
</details>
### Live News & Video
- **150+ RSS feeds** across geopolitics, defense, energy, tech, and finance — domain-allowlisted proxy prevents CORS issues. Each variant loads its own curated feed set: ~25 categories for geopolitical, ~20 for tech, ~18 for finance
- **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
@@ -193,11 +167,11 @@ All three variants run from a single codebase — switch between them with one c
### Desktop Application (Tauri)
- **Native desktop app** for macOS and Windows — packages the full dashboard with a local Node.js sidecar that runs all 60+ API handlers locally
- **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 17 API keys with validation, signup links, and feature-availability indicators
- **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
@@ -236,10 +210,6 @@ All three variants run from a single codebase — switch between them with one c
- **Download banner** — persistent notification for web users linking to native desktop installers for their detected platform
- **Download API** — `/api/download?platform={windows-exe|windows-msi|macos-arm64|macos-x64}` redirects to the matching GitHub Release asset, with fallback to the releases page
- **Non-tier country support** — clicking countries outside the 22 tier-1 list opens a brief with available data (news, markets, infrastructure) and a "Limited coverage" badge; country names for non-tier countries resolve via `Intl.DisplayNames`
- **Feature toggles** — 14 runtime toggles (AI/Groq, AI/OpenRouter, FRED economic, EIA energy, internet outages, ACLED conflicts, threat intel feeds, AIS relay, OpenSky, Finnhub, NASA FIRMS) stored in `localStorage`, allowing administrators to enable/disable data sources without rebuilding
- **AIS chokepoint detection** — the relay server monitors 8 strategic maritime chokepoints (Strait of Hormuz, Suez Canal, Malacca Strait, Bab el-Mandeb, Panama Canal, Taiwan Strait, South China Sea, Turkish Straits) and classifies transiting vessels by naval candidacy using MMSI prefixes, ship type codes, and name patterns
- **AIS density grid** — vessel positions are aggregated into 2°×2° geographic cells over 30-minute windows, producing a heatmap of maritime traffic density that feeds into convergence detection
- **Panel resizing** — drag handles on panel edges allow height adjustment (span-1 through span-4 grid rows), persisted to localStorage. Double-click resets to default height
---
@@ -516,7 +486,7 @@ API calls to WorldPop are batched concurrently (max 10 parallel requests) to han
### Strategic Port Infrastructure
83 strategic ports are cataloged across six types, reflecting their role in global trade and military posture:
84 strategic ports are cataloged across six types, reflecting their role in global trade and military posture:
| Type | Count | Examples |
|------|-------|---------|
@@ -596,23 +566,6 @@ VWAP = Σ(price × volume) / Σ(volume) for last 30 trading days
The **Mayer Multiple** (BTC price / SMA200) provides a long-term valuation context — historically, values above 2.4 indicate overheating, while values below 0.8 suggest deep undervaluation.
### Gulf FDI Investment Database
The Finance variant includes a curated database of 64 major foreign direct investments by Saudi Arabia and the UAE in global critical infrastructure. Investments are tracked across 12 sectors:
| Sector | Examples |
|--------|---------|
| **Ports** | DP World's 11 global container terminals, AD Ports (Khalifa, Al-Sokhna, Karachi), Saudi Mawani ports |
| **Energy** | ADNOC Ruwais LNG (9.6 mtpa), Aramco's Motiva Port Arthur refinery (630K bpd), ACWA Power renewables |
| **Manufacturing** | Mubadala's GlobalFoundries (82% stake, 3rd-largest chip foundry), Borealis (75%), SABIC (70%) |
| **Renewables** | Masdar wind/solar (UK Hornsea, Zarafshan 500MW, Gulf of Suez), NEOM Green Hydrogen (world's largest) |
| **Megaprojects** | NEOM THE LINE ($500B), Saudi National Cloud ($6B hyperscale datacenters) |
| **Telecoms** | STC's 9.9% stake in Telefónica, PIF's 20% of Telecom Italia NetCo |
Each investment records the investing entity (DP World, Mubadala, PIF, ADNOC, Masdar, Saudi Aramco, ACWA Power, etc.), target country, geographic coordinates, investment amount (USD), ownership stake, operational status, and year. The Investments Panel provides filterable views by country (SA/UAE), sector, entity, and status — clicking any row navigates the map to the investment location.
On the globe, investments appear as scaled bubbles: ≥$50B projects (NEOM) render at maximum size, while sub-$1B investments use smaller markers. Color encodes status: green for operational, amber for under-construction, blue for announced.
### Stablecoin Peg Monitoring
Five major stablecoins (USDT, USDC, DAI, FDUSD, USDe) are monitored via the CoinGecko API with 2-minute caching. Each coin's deviation from the $1.00 peg determines its health status:
@@ -650,25 +603,6 @@ This is an approximation, not a substitute for official flow data, but it captur
---
## Tri-Variant Architecture
A single codebase produces three specialized dashboards, each with distinct feeds, panels, map layers, and branding:
| Aspect | World Monitor | Tech Monitor | Finance Monitor |
|--------|--------------|--------------|-----------------|
| **Domain** | worldmonitor.app | tech.worldmonitor.app | finance.worldmonitor.app |
| **Focus** | Geopolitics, military, conflicts | AI/ML, startups, cybersecurity | Markets, trading, central banks |
| **RSS Feeds** | ~25 categories (politics, MENA, Africa, think tanks) | ~20 categories (AI, VC blogs, startups, GitHub) | ~18 categories (forex, bonds, commodities, IPOs) |
| **Panels** | 44 (strategic posture, CII, cascade) | 31 (AI labs, unicorns, accelerators) | 30 (forex, bonds, derivatives, institutional) |
| **Unique Map Layers** | Military bases, nuclear facilities, hotspots | Tech HQs, cloud regions, startup hubs | Stock exchanges, central banks, Gulf investments |
| **Desktop App** | World Monitor.app / .exe | Tech Monitor.app / .exe | Finance Monitor.app / .exe |
**Build-time selection** — the `VITE_VARIANT` environment variable controls which configuration is bundled. A Vite HTML plugin transforms meta tags, Open Graph data, PWA manifest, and JSON-LD structured data at build time. Each variant tree-shakes unused data files — the finance build excludes military base coordinates and APT group data, while the geopolitical build excludes stock exchange listings.
**Runtime switching** — a variant selector in the header bar (🌍 WORLD | 💻 TECH | 📈 FINANCE) navigates between deployed domains on the web, or sets `localStorage['worldmonitor-variant']` in the desktop app to switch without rebuilding.
---
## Architecture Principles
| Principle | Implementation |
@@ -684,7 +618,7 @@ A single codebase produces three specialized dashboards, each with distinct feed
| **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. |
| **Baseline-aware alerting** | Trending keyword detection uses rolling 2-hour windows against 7-day baselines with per-term spike multipliers, cooldowns, and source diversity requirements — surfacing genuine surges while suppressing noise. |
| **Run anywhere** | Same codebase produces three specialized variants (geopolitical, tech, finance) and 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). |
| **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). |
---
@@ -705,7 +639,7 @@ Feeds also carry a **propaganda risk rating** and **state affiliation flag**. St
## Edge Function Architecture
World Monitor uses 60+ Vercel Edge Functions as a lightweight API layer. Each edge function handles a single data source concern — proxying, caching, or transforming external APIs. This architecture avoids a monolithic backend while keeping API keys server-side:
World Monitor uses 45+ Vercel Edge Functions as a lightweight API layer. Each edge function handles a single data source concern — proxying, caching, or transforming external APIs. This architecture avoids a monolithic backend while keeping API keys server-side:
- **RSS Proxy** — domain-allowlisted proxy for 100+ feeds, preventing CORS issues and hiding origin servers. Feeds from domains that block Vercel IPs are automatically routed through the Railway relay.
- **AI Pipeline** — Groq and OpenRouter edge functions with Redis deduplication, so identical headlines across concurrent users only trigger one LLM call. The classify-event endpoint pauses its queue on 500 errors to avoid wasting API quota.
@@ -713,7 +647,6 @@ World Monitor uses 60+ Vercel Edge Functions as a lightweight API layer. Each ed
- **Market Intelligence** — macro signals, ETF flows, and stablecoin monitors compute derived analytics server-side (VWAP, SMA, peg deviation, flow estimates) and cache results in Redis
- **Temporal Baseline** — Welford's algorithm state is persisted in Redis across requests, building statistical baselines without a traditional database
- **Custom Scrapers** — sources without RSS feeds (FwdStart, GitHub Trending, tech events) are scraped and transformed into RSS-compatible formats
- **Finance Geo Data** — stock exchanges (92), financial centers (19), central banks (13), and commodity hubs (10) are served as static typed datasets with market caps, GFCI rankings, trading hours, and commodity specializations
All edge functions include circuit breaker logic and return cached stale data when upstream APIs are unavailable, ensuring the dashboard never shows blank panels.
@@ -721,12 +654,12 @@ All edge functions include circuit breaker logic and return cached stale data wh
## Multi-Platform Architecture
All three variants run on three platforms that work together:
World Monitor runs on three platforms that work together:
```
┌─────────────────────────────────────┐
│ Vercel (Edge) │
60+ edge functions · static SPA │
45+ edge functions · static SPA │
│ CORS allowlist · Redis cache │
│ AI pipeline · market analytics │
│ CDN caching (s-maxage) · PWA host │
@@ -736,7 +669,7 @@ All three variants run on three platforms that work together:
│ ┌───────────────────────────────────┐
│ │ Tauri Desktop (Rust + Node) │
│ │ OS keychain · Token-auth sidecar │
│ │ 60+ local API handlers · gzip │
│ │ 45+ local API handlers · gzip │
│ │ Cloud fallback · Traffic logging │
│ └───────────────────────────────────┘
@@ -778,7 +711,7 @@ The Tauri desktop app wraps the dashboard in a native window with a local Node.j
┌─────────────────────────────────────────────────┐
│ Node.js Sidecar (port 46123) │
60+ API handlers · Gzip compression │
45+ API handlers · Gzip compression │
│ Cloud fallback · Traffic logging │
│ Verbose debug mode · Circuit breakers │
└─────────────────────┬───────────────────────────┘
@@ -792,7 +725,7 @@ The Tauri desktop app wraps the dashboard in a native window with a local Node.j
### 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 17 supported secrets are read from the keyring, trimmed, and injected as environment variables. Empty or whitespace-only values are skipped.
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.
@@ -883,7 +816,7 @@ The AI summarization pipeline adds content-based deduplication: headlines are ha
| Layer | Mechanism |
|-------|-----------|
| **CORS origin allowlist** | Only `worldmonitor.app`, `tech.worldmonitor.app`, `finance.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. |
@@ -903,7 +836,7 @@ The AI summarization pipeline adds content-based deduplication: headlines are ha
git clone https://github.com/koala73/worldmonitor.git
cd worldmonitor
npm install
vercel dev # Runs frontend + all 60+ API edge functions
vercel dev # Runs frontend + all 45+ API edge functions
```
Open [http://localhost:3000](http://localhost:3000)
@@ -936,7 +869,7 @@ See [`.env.example`](./.env.example) for the complete list with registration lin
## Self-Hosting
World Monitor relies on **60+ Vercel Edge Functions** in the `api/` directory for RSS proxying, data caching, and API key isolation. Running `npm run dev` alone starts only the Vite frontend — the edge functions won't execute, and most panels (news feeds, markets, AI summaries) will be empty.
World Monitor relies on **45+ Vercel Edge Functions** in the `api/` directory for RSS proxying, data caching, and API key isolation. Running `npm run dev` alone starts only the Vite frontend — the edge functions won't execute, and most panels (news feeds, markets, AI summaries) will be empty.
### Option 1: Deploy to Vercel (Recommended)
@@ -947,7 +880,7 @@ npm install -g vercel
vercel # Follow prompts to link/create project
```
Add your API keys in the Vercel dashboard under **Settings → Environment Variables**, then visit your deployment URL. The free Hobby plan supports all 60+ edge functions.
Add your API keys in the Vercel dashboard under **Settings → Environment Variables**, then visit your deployment URL. The free Hobby plan supports all 45+ edge functions.
### Option 2: Local Development with Vercel CLI
@@ -1006,9 +939,8 @@ Set `WS_RELAY_URL` (server-side, HTTPS) and `VITE_WS_RELAY_URL` (client-side, WS
| **Market APIs** | Yahoo Finance (equities, forex, crypto), CoinGecko (stablecoins), mempool.space (BTC hashrate), alternative.me (Fear & Greed) |
| **Threat Intel APIs** | abuse.ch (Feodo Tracker, URLhaus), AlienVault OTX, AbuseIPDB, C2IntelFeeds |
| **Economic APIs** | FRED (Federal Reserve), EIA (Energy), Finnhub (stock quotes) |
| **Deployment** | Vercel Edge Functions (60+ endpoints) + Railway (WebSocket relay) + Tauri (desktop) + PWA (installable) |
| **Finance Data** | 92 stock exchanges, 19 financial centers, 13 central banks, 10 commodity hubs, 64 Gulf FDI investments |
| **Data** | 150+ RSS feeds, ADS-B transponders, AIS maritime data, VIIRS satellite imagery, 8 live YouTube streams |
| **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 |
---
@@ -1022,23 +954,19 @@ Contributions welcome! See [CONTRIBUTING](./docs/DOCUMENTATION.md#contributing)
# Development
npm run dev # Full variant (worldmonitor.app)
npm run dev:tech # Tech variant (tech.worldmonitor.app)
npm run dev:finance # Finance variant (finance.worldmonitor.app)
# Production builds
npm run build:full # Build full variant
npm run build:tech # Build tech variant
npm run build:finance # Build finance variant
npm run build:full # Build full variant
npm run build:tech # Build tech variant
# Quality
npm run typecheck # TypeScript type checking
# Desktop packaging
npm run desktop:package:macos:full # .app + .dmg (World Monitor)
npm run desktop:package:macos:tech # .app + .dmg (Tech Monitor)
npm run desktop:package:macos:finance # .app + .dmg (Finance Monitor)
npm run desktop:package:windows:full # .exe + .msi (World Monitor)
npm run desktop:package:windows:tech # .exe + .msi (Tech Monitor)
npm run desktop:package:windows:finance # .exe + .msi (Finance Monitor)
npm run desktop:package:macos:full # .app + .dmg (World Monitor)
npm run desktop:package:macos:tech # .app + .dmg (Tech Monitor)
npm run desktop:package:windows:full # .exe + .msi (World Monitor)
npm run desktop:package:windows:tech # .exe + .msi (Tech Monitor)
# Generic packaging runner
npm run desktop:package -- --os macos --variant full
@@ -1056,8 +984,8 @@ Desktop release details, signing hooks, variant outputs, and clean-machine valid
## Roadmap
- [x] 60+ API edge functions for programmatic access
- [x] Tri-variant system (geopolitical + tech + finance)
- [x] 45+ API edge functions for programmatic access
- [x] Dual-site variant system (geopolitical + tech)
- [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
@@ -1079,11 +1007,6 @@ Desktop release details, signing hooks, variant outputs, and clean-machine valid
- [x] Population exposure estimation (WorldPop density data)
- [x] Country search in Cmd+K with direct brief navigation
- [x] Entity index with cross-source correlation and confidence scoring
- [x] Finance variant with 92 stock exchanges, 19 financial centers, 13 central banks, and commodity hubs
- [x] Gulf FDI investment database (64 Saudi/UAE infrastructure investments mapped globally)
- [x] AIS maritime chokepoint detection and vessel density grid
- [x] Runtime feature toggles for 14 data sources
- [x] Panel height resizing with persistent layout state
- [ ] Mobile-optimized views
- [ ] Push notifications for critical alerts
- [ ] Self-hosted Docker image
@@ -1094,7 +1017,7 @@ See [full roadmap](./docs/DOCUMENTATION.md#roadmap).
## Support the Project
If you find Hanzo World useful:
If you find World Monitor useful:
- **Star this repo** to help others discover it
- **Share** with colleagues interested in OSINT
@@ -1109,16 +1032,15 @@ MIT License — see [LICENSE](LICENSE) for details.
---
## Authors
## Author
**Hanzo World** is maintained by **Hanzo AI, Inc.** — fork of the original **World Monitor** by **Elie Habib** ([GitHub](https://github.com/koala73)), used under the MIT License. See [NOTICE](./NOTICE).
**Elie Habib** [GitHub](https://github.com/koala73)
---
<p align="center">
<a href="https://worldmonitor.app">worldmonitor.app</a> &nbsp;·&nbsp;
<a href="https://tech.worldmonitor.app">tech.worldmonitor.app</a> &nbsp;·&nbsp;
<a href="https://finance.worldmonitor.app">finance.worldmonitor.app</a>
<a href="https://tech.worldmonitor.app">tech.worldmonitor.app</a>
</p>
## Star History
+408
View File
@@ -0,0 +1,408 @@
# World Monitor Roadmap: Intelligence Correlation Enhancements
This document outlines the top 5 features a geopolitical intelligence analyst would want, focusing on **correlation between existing data points** and leveraging **free APIs/RSS feeds only**.
---
## Current Correlation Capabilities
### What We Already Do Well
| Signal Type | Description | Data Sources |
|------------|-------------|--------------|
| **Convergence** | 3+ source types report same story within 30min | News feeds |
| **Triangulation** | Wire + Gov + Intel sources align on topic | News feeds |
| **Velocity Spike** | Topic mention rate doubles with 6+ sources/hr | News feeds |
| **Prediction Leading** | Polymarket moves 5%+ with low news coverage | Polymarket + News |
| **Silent Divergence** | Market moves 2%+ with minimal related news | Yahoo/Finnhub + News |
| **Flow/Price Divergence** | Energy price spike without pipeline news | Markets + News |
| **Related Assets** | News stories enriched with nearby infrastructure | Hotspots + All assets |
| **GDELT Tensions** | Country-pair tension scores with 7-day trends | GDELT GPR API |
### What's Missing
1. **No cross-layer correlation** - Protests, military movements, and economic data don't talk to each other
2. **No temporal pattern detection** - Can't detect "unusual for this time of year"
3. **No geographic clustering** - Multiple event types in same region not flagged
4. **No country-level aggregation** - No unified risk view per country
5. **No infrastructure dependency mapping** - Don't show cascade effects
---
## Top 5 Priority Features
### 1. Multi-Signal Geographic Convergence
**What:** When 3+ independent data types converge on the same geographic region within 24-48 hours, generate a high-priority alert.
**Why:** The most valuable I&W (Indications & Warning) signals come from multiple independent sources detecting activity in the same area. A protest + military flight activity + shipping disruption in the same region is far more significant than any single event.
**Data Sources (Already Have):**
- Protests (ACLED/GDELT) → lat/lon
- Military flights (OpenSky) → lat/lon
- Military vessels (AIS) → lat/lon
- Earthquakes/natural events → lat/lon
- News hotspots → lat/lon
- Chokepoint congestion → lat/lon
- Pipeline incidents → lat/lon (inferred)
**Implementation:**
```
1. Define 50km grid cells globally
2. Each refresh cycle, tag events to grid cells
3. Track event counts by type per cell over 24h window
4. Alert when: cell has events from 3+ distinct data types
5. Confidence = function(event_count, type_diversity, time_clustering)
```
**Example Alert:**
> ⚠️ **Geographic Convergence: Taiwan Strait**
> - Military flights: 12 (3x normal)
> - Naval vessels: 8 (2x normal)
> - News velocity: Spike (+340%)
> - Confidence: 87%
---
### 2. Country Instability Index
**What:** Real-time composite risk score for each country, aggregating all available signals into a single 0-100 index.
**Why:** Analysts need a quick way to answer "how stable is Country X right now?" without manually checking 10 different data sources.
**Components (Already Have Data):**
| Component | Source | Weight |
|-----------|--------|--------|
| Protest frequency | ACLED/GDELT | 20% |
| Protest severity | ACLED fatalities | 15% |
| Conflict proximity | Conflict zones | 15% |
| News sentiment | Clustered news | 10% |
| News velocity | RSS feeds | 10% |
| GDELT tension (as target) | GDELT GPR | 10% |
| Sanctions status | Static config | 10% |
| Infrastructure incidents | Cables/pipelines | 10% |
**Free Data to Add:**
- **World Bank Governance Indicators** (annual, free API)
- **UN Refugee Data** (UNHCR, RSS feeds)
- **Election proximity** (static calendar)
**Implementation:**
```
1. Map all events to ISO country codes
2. Maintain rolling 7-day and 30-day baselines per country
3. Calculate Z-scores for each component
4. Weight and sum to 0-100 index
5. Track index changes for trend detection
```
**UI:**
- Choropleth map layer showing index by color
- Sortable country list panel
- Click country → drill-down to component breakdown
- Alert when country moves 10+ points in 24h
---
### 3. Trade Route Risk Scoring
**What:** Real-time risk assessment for major shipping routes, showing which supply chains are most vulnerable right now.
**Why:** Supply chain disruptions are the primary economic consequence of geopolitical events. An analyst needs to quickly assess "if X happens, what trade is affected?"
**Major Routes to Score:**
| Route | Chokepoints | Commodities |
|-------|-------------|-------------|
| Asia → Europe (Suez) | Suez, Bab el-Mandeb, Malacca | Containers, oil |
| Asia → US West Coast | Malacca, Taiwan Strait, Panama | Containers, electronics |
| Middle East → Europe | Hormuz, Suez, Bosphorus | Oil, LNG |
| Russia → Europe | Baltic, Bosphorus | Oil, gas, grain |
| South America → Asia | Panama, Magellan | Commodities, grain |
**Risk Components:**
| Factor | Source | Notes |
|--------|--------|-------|
| Chokepoint congestion | AIS density | Real-time |
| Dark ship activity | AIS gaps | Real-time |
| Weather/storms | NASA EONET | Real-time |
| Conflict proximity | Conflict zones | Static + news |
| Piracy indicators | News keywords | Real-time |
| Sanctions impact | Config | Which ports blocked |
| Port delays | Inferred from AIS | Real-time |
**Implementation:**
```
1. Define route polylines with chokepoint waypoints
2. For each chokepoint, calculate: density_change + gap_rate + weather_alerts + conflict_distance
3. Weight by chokepoint criticality (Hormuz > Malacca > Panama for oil)
4. Sum to 0-100 risk score per route
5. Compare to 30-day baseline for trend
```
**UI:**
- Route lines on map colored by risk (green → yellow → red)
- Panel showing route rankings with trends
- Click route → show chokepoint breakdown
- Alert when route risk jumps 20+ points
---
### 4. Infrastructure Cascade Visualization
**What:** When you click any infrastructure asset, show what depends on it and what would be affected by its disruption.
**Why:** Critical infrastructure is interconnected. A submarine cable fault affects countries downstream. A pipeline disruption affects refineries and ports. Analysts need to see the "so what."
**Dependency Mappings:**
**Ports → dependent on:**
- Pipelines (oil/LNG terminals)
- Submarine cables (data for port operations)
- Nearby naval bases (protection)
- Chokepoints (access routes)
**Cables → serve:**
- Countries (list from cable data)
- Data centers (proximity)
- Financial centers (criticality)
**Pipelines → connect:**
- Origin countries
- Transit countries
- Destination ports/refineries
- Alternate routes
**Implementation:**
```
1. Build static dependency graph in config
2. For cables: map landing points to countries
3. For pipelines: map to origin/transit/destination
4. For ports: map to pipelines that terminate there
5. On asset click: traverse graph, highlight dependents on map
6. Show impact panel: "Disruption would affect: X countries, Y trade volume"
```
**Data Enhancement (Free):**
- **TeleGeography** submarine cable landing points (public)
- **Global Energy Monitor** pipeline database (public)
- **UN COMTRADE** for trade flow volumes (free API)
---
### 5. Temporal Anomaly Detection
**What:** Detect when current activity levels deviate significantly from historical norms for the same time period (day of week, month, season).
**Why:** "Unusual activity" only makes sense in context. Military flights on a Tuesday might be normal; the same level on a Sunday might be significant. Activity in December might be normal for end-of-year exercises but unusual in March.
**What to Track:**
| Data Type | Baseline Period | Anomaly Threshold |
|-----------|-----------------|-------------------|
| Military flights per region | Same weekday, 4-week rolling | Z > 2.0 |
| Naval vessels per chokepoint | Same weekday, 4-week rolling | Z > 2.0 |
| Protest count per country | Same month, 3-year average | Z > 1.5 |
| News velocity per topic | Same weekday, 4-week rolling | Z > 2.5 |
| AIS gaps per region | Same weekday, 4-week rolling | Z > 2.0 |
**Implementation:**
```
1. Store hourly/daily counts by category in IndexedDB
2. Maintain separate baselines by: weekday, month, region
3. On refresh: compare current to same-period baseline
4. Calculate Z-score accounting for seasonal patterns
5. Alert format: "Military flights in Baltic 3.2x normal for Tuesday"
```
**Example Alerts:**
> 📊 **Temporal Anomaly: Baltic Region**
> - Military flights: 47 (normal Tuesday avg: 15)
> - Z-score: 2.8 (highly unusual)
> - Last similar: March 2024 (NATO exercise)
> 📊 **Temporal Anomaly: Iran Protests**
> - Events this week: 23 (normal January avg: 8)
> - Z-score: 1.9 (elevated)
> - Note: Anniversary of 2023 protests approaching
---
## Additional Free Data Sources to Integrate
### Economic/Trade APIs (No Key Required)
| Source | Endpoint | Data | Rate Limit |
|--------|----------|------|------------|
| **World Bank API** | `api.worldbank.org/v2/` | 16,000+ indicators, GDP, trade, FDI | None |
| **IMF Data API** | `dataservices.imf.org/REST/SDMX_JSON.svc/` | IFS, trade flows, balance of payments | None |
| **UN Comtrade** | `comtradeapi.un.org/public/v1/` | Bilateral trade flows by HS code | 100/day free |
| **BIS Statistics** | `stats.bis.org/api/v1/` | Global liquidity, cross-border banking | None |
| **OECD Data** | `stats.oecd.org/SDMX-JSON/` | OECD country indicators | None |
### Food Security (Critical for Instability Correlation)
| Source | Endpoint | Data | Notes |
|--------|----------|------|-------|
| **FAO GIEWS RSS** | `fao.org/giews/english/shortnews/rss.xml` | Food price alerts, country briefs | Add to feeds.ts |
| **FAO Food Price Monitor** | `fpma.fao.org/giews/fpmat4/` | Real-time commodity prices | JSON API |
| **FAO STAT API** | `fenixservices.fao.org/faostat/api/v1/` | Food Price Index, production | REST |
### Sanctions Lists (Critical for Risk Scoring)
| Source | Endpoint | Data | Update Frequency |
|--------|----------|------|------------------|
| **OFAC SDN List** | `sanctionslistservice.ofac.treas.gov/api/` | US sanctions | Daily |
| **EU Sanctions** | `webgate.ec.europa.eu/fsd/fsf/public/files/` | EU restrictive measures | Weekly |
| **UN Sanctions** | `scsanctions.un.org/resources/xml/` | Al-Qaida, DPRK, Iran, etc. | Real-time |
| **OpenSanctions** | `api.opensanctions.org/` | Unified 100+ sources | Free tier: 1000/day |
### Migration/Humanitarian (Instability Indicators)
| Source | Endpoint | Data | Notes |
|--------|----------|------|-------|
| **UNHCR API** | `api.unhcr.org/` | Refugee populations, IDPs, asylum | No key |
| **IOM DTM** | `dtm.iom.int/` | Displacement tracking, migration flows | Free registration |
| **ReliefWeb API** | `api.reliefweb.int/v1/` | Humanitarian reports, disasters | No key |
| **INFORM Risk** | `drmkc.jrc.ec.europa.eu/inform-index/` | Hazard/vulnerability scores | CSV download |
### Think Tank RSS Feeds (Add to feeds.ts)
**Security/Defense:**
- RUSI: `rusi.org/rss.xml`
- Chatham House: `chathamhouse.org/rss.xml`
- ECFR: `ecfr.eu/feed/`
- CFR: `cfr.org/rss`
- Wilson Center: `wilsoncenter.org/rss.xml`
- GMF: `gmfus.org/feed`
- Stimson: `stimson.org/feed/`
- CNAS: `cnas.org/rss`
**Nuclear/Arms Control:**
- Arms Control Association: `armscontrol.org/rss/all`
- FAS: `fas.org/feed/`
- NTI: `nti.org/rss/`
- Bulletin of Atomic Scientists: `thebulletin.org/feed/`
**Regional:**
- Middle East Institute: `mei.edu/rss.xml`
- Lowy Institute (Asia-Pacific): `lowyinstitute.org/feed`
- EU ISS: `iss.europa.eu/rss.xml`
### Static Data (Annual/Quarterly Updates)
| Source | Data | Format | Use Case |
|--------|------|--------|----------|
| **SIPRI Arms Transfers** | Weapons exports by country | CSV | Military capability assessment |
| **SIPRI MILEX** | Military spending | CSV | Defense budget trends |
| **V-Dem** | 400+ democracy indicators | CSV | Governance quality |
| **Fragile States Index** | Country risk scores | CSV | Baseline instability |
| **Freedom House** | Democracy/freedom scores | CSV | Political environment |
| **Global Terrorism Database** | Historical incidents | Registration | Pattern analysis |
### Election Calendar (Static Config)
Maintain election calendar in `src/config/elections.ts`. When election date approaches:
- **30 days**: Add to "upcoming events" panel
- **7 days**: Boost country news correlation
- **1 day**: Increase instability index weighting
- **Election day**: Maximum alert sensitivity
```typescript
interface Election {
country: string;
countryCode: string;
type: 'presidential' | 'parliamentary' | 'referendum' | 'local';
date: Date;
significance: 'high' | 'medium' | 'low';
notes?: string;
}
```
---
## Implementation Priority
| Feature | Complexity | Impact | Priority |
|---------|------------|--------|----------|
| Multi-Signal Geographic Convergence | Medium | Very High | 1 |
| Country Instability Index | Medium | High | 2 |
| Temporal Anomaly Detection | Medium | High | 3 |
| Trade Route Risk Scoring | High | High | 4 |
| Infrastructure Cascade Viz | High | Medium | 5 |
**Recommended approach:** Implement features 1-3 first as they primarily leverage existing data with new correlation logic. Features 4-5 require additional data mapping and UI work.
---
## Technical Notes
### IndexedDB Schema Extensions
```typescript
interface TemporalBaseline {
type: 'military_flights' | 'vessels' | 'protests' | 'news' | 'ais_gaps';
region: string;
weekday: number; // 0-6
month: number; // 1-12
hourlyAvg: number[];
dailyAvg: number;
stdDev: number;
sampleCount: number;
lastUpdated: Date;
}
interface CountryRiskSnapshot {
countryCode: string;
timestamp: Date;
components: {
protests: number;
conflict: number;
sentiment: number;
velocity: number;
tension: number;
sanctions: number;
infrastructure: number;
};
index: number;
trend: 'rising' | 'stable' | 'falling';
}
interface GeographicCell {
lat: number;
lon: number;
eventTypes: Set<string>;
eventCount: number;
firstSeen: Date;
lastUpdated: Date;
}
```
### New Signal Types
```typescript
type SignalType =
// Existing
| 'prediction_leads_news'
| 'news_leads_markets'
| 'silent_divergence'
| 'velocity_spike'
| 'convergence'
| 'triangulation'
| 'flow_drop'
| 'flow_price_divergence'
// New
| 'geographic_convergence'
| 'country_risk_spike'
| 'trade_route_risk'
| 'temporal_anomaly'
| 'infrastructure_cascade';
```
---
## Conclusion
The most valuable enhancements for a geopolitical analyst focus on **correlation, not accumulation**. The dashboard already aggregates vast amounts of data; the next step is making that data talk to each other.
Priority 1 (Geographic Convergence) alone would significantly elevate the tool's I&W capability by detecting when multiple independent signals point to the same location—the hallmark of significant events.
All proposed features use **existing data sources** or **free APIs/RSS feeds**, keeping with the project's accessible, open-source philosophy.
-1
View File
@@ -1 +0,0 @@
2.4.39
-94
View File
@@ -1,94 +0,0 @@
// Low-end laptop profiler for world.hanzo.ai
// Usage: node profile.mjs <url> <label> [cpuThrottle=6]
// Measures under Chrome DevTools CPU throttling via CDP.
import { chromium } from 'playwright';
const url = process.argv[2] || 'https://world.hanzo.ai/';
const label = process.argv[3] || 'run';
const throttle = Number(process.argv[4] || 6);
const browser = await chromium.launch({ args: ['--enable-precise-memory-info'] });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
// Track network transfer sizes by type
const transfer = { js: 0, css: 0, other: 0, jsCount: 0, eagerJs: 0 };
const seen = new Set();
page.on('response', async (resp) => {
try {
const u = resp.url();
if (seen.has(u)) return; seen.add(u);
const hdr = resp.headers();
const enc = hdr['content-encoding'] || '';
const len = Number(hdr['content-length'] || 0);
const ct = hdr['content-type'] || '';
let bytes = len;
if (!bytes) { try { bytes = (await resp.body()).length; } catch { bytes = 0; } }
if (/javascript/.test(ct) || u.endsWith('.js')) { transfer.js += bytes; transfer.jsCount++; }
else if (/css/.test(ct) || u.endsWith('.css')) { transfer.css += bytes; }
else transfer.other += bytes;
} catch {}
});
const client = await context.newCDPSession(page);
await client.send('Emulation.setCPUThrottlingRate', { rate: throttle });
const t0 = Date.now();
await page.goto(url, { waitUntil: 'commit', timeout: 120000 });
// Inject longtask + paint observers ASAP
await page.addInitScript(() => {
window.__lt = { total: 0, count: 0, max: 0 };
try {
new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__lt.total += e.duration; window.__lt.count++; window.__lt.max = Math.max(window.__lt.max, e.duration); } }).observe({ entryTypes: ['longtask'] });
} catch {}
});
// Wait for the app shell / map container to exist and network to settle a bit
let ttiApprox = null;
try {
await page.waitForSelector('#app .header, #app .main-content, #mapContainer', { timeout: 60000 });
ttiApprox = Date.now() - t0;
} catch {}
// Let it run to steady state under throttle
await page.waitForTimeout(9000);
// Paint + nav timings
const timings = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] || {};
const fcp = (performance.getEntriesByType('paint').find(p => p.name === 'first-contentful-paint') || {}).startTime || null;
return {
domContentLoaded: nav.domContentLoadedEventEnd || null,
loadEvent: nav.loadEventEnd || null,
fcp,
longtasks: window.__lt || null,
scripts: performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script' || /\.js(\?|$)/.test(r.name)).length,
};
});
// FPS sample over 4s (rAF frame deltas) — the idle/spin steady-state frame cost
const fps = await page.evaluate(() => new Promise((resolve) => {
const deltas = []; let last = performance.now(); let frames = 0; const start = last;
function tick(now) { deltas.push(now - last); last = now; frames++; if (now - start < 4000) requestAnimationFrame(tick); else {
deltas.sort((a,b)=>a-b);
const avg = deltas.reduce((a,b)=>a+b,0)/deltas.length;
const p95 = deltas[Math.floor(deltas.length*0.95)] || avg;
resolve({ fps: +(1000/avg).toFixed(1), avgFrameMs: +avg.toFixed(1), p95FrameMs: +p95.toFixed(1), frames });
} }
requestAnimationFrame(tick);
}));
const out = {
label, url, cpuThrottle: throttle,
transferKB: { js: Math.round(transfer.js/1024), css: Math.round(transfer.css/1024), jsFiles: transfer.jsCount },
fcpMs: timings.fcp ? Math.round(timings.fcp) : null,
domContentLoadedMs: timings.domContentLoaded ? Math.round(timings.domContentLoaded) : null,
loadEventMs: timings.loadEvent ? Math.round(timings.loadEvent) : null,
shellVisibleMs: ttiApprox,
longTasks: timings.longtasks,
steadyState: fps,
};
console.log('PROFILE_JSON ' + JSON.stringify(out));
console.log(JSON.stringify(out, null, 2));
await browser.close();
+3 -2
View File
@@ -1,10 +1,11 @@
const ALLOWED_ORIGIN_PATTERNS = [
/^https:\/\/(.*\.)?worldmonitor\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+\.vercel\.app$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
/^https?:\/\/tauri\.localhost(:\d+)?$/,
/^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
/^https:\/\/tauri\.localhost(:\d+)?$/,
/^https:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
/^tauri:\/\/localhost$/,
/^asset:\/\/localhost$/,
];
-1
View File
@@ -8,7 +8,6 @@ const PLATFORM_PATTERNS = {
'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'),
'linux-appimage': (name) => name.endsWith('_amd64.AppImage'),
};
export default async function handler(req) {
+3 -11
View File
@@ -39,17 +39,9 @@ export default async function handler(req) {
const apiKey = process.env.FRED_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({
observations: [],
skipped: true,
reason: 'FRED_API_KEY not configured',
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
...corsHeaders,
},
return new Response(JSON.stringify({ error: 'FRED_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
+8 -28
View File
@@ -18,19 +18,11 @@ const CACHE_TTL_SECONDS = 86400; // 24 hours
const CACHE_VERSION = 'v3';
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
function getCacheKey(headlines, mode, geoContext = '', variant = 'full') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
const normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
if (mode === 'translate') {
const targetLang = normalizedVariant || normalizedLang;
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
}
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${geoHash}`;
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}`;
}
// Deduplicate similar headlines (same story from different sources)
@@ -106,7 +98,7 @@ export default async function handler(request) {
}
try {
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
const { headlines, mode = 'brief', geoContext = '', variant = 'full' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
@@ -116,7 +108,7 @@ export default async function handler(request) {
}
// Check cache first
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cacheKey = getCacheKey(headlines, mode, geoContext, variant);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[Groq] Cache hit:', cacheKey);
@@ -144,9 +136,6 @@ export default async function handler(request) {
const isTechVariant = variant === 'tech';
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
if (mode === 'brief') {
if (isTechVariant) {
// Tech variant: focus on startups, AI, funding, product launches
@@ -158,7 +147,7 @@ Rules:
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
- Lead with the company/product/technology name
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
- No bullet points, no meta-commentary${langInstruction}`;
- No bullet points, no meta-commentary`;
} else {
// Full variant: geopolitical focus
systemPrompt = `${dateContext}
@@ -170,7 +159,7 @@ Rules:
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary${langInstruction}`;
- No bullet points, no meta-commentary`;
}
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
@@ -198,19 +187,10 @@ Rules:
userPrompt = isTechVariant
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else if (mode === 'translate') {
const targetLang = variant; // In translate mode, variant param holds the target language code (e.g., 'fr', 'es')
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
Rules:
- Maintain the original tone and journalistic style.
- Do NOT add any conversational filler (e.g., "Here is the translation").
- Output ONLY the translated text.
- If the text is already in ${targetLang}, return it as is.`;
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
} else {
systemPrompt = isTechVariant
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
+10 -29
View File
@@ -1,7 +1,7 @@
/**
* OpenRouter API Summarization Endpoint with Redis Caching
* Fallback when Groq is rate-limited
* Uses OpenRouter auto-routed free model
* Uses Llama 3.3 70B free model
* Free tier: 50 requests/day (20/min)
* Server-side Redis cache for cross-user deduplication
*/
@@ -14,24 +14,16 @@ export const config = {
};
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'openrouter/free';
const MODEL = 'meta-llama/llama-3.3-70b-instruct:free';
const CACHE_TTL_SECONDS = 86400; // 24 hours
const CACHE_VERSION = 'v3';
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
function getCacheKey(headlines, mode, geoContext = '', variant = 'full') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
const normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
if (mode === 'translate') {
const targetLang = normalizedVariant || normalizedLang;
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
}
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${geoHash}`;
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}`;
}
// Deduplicate similar headlines (same story from different sources)
@@ -107,7 +99,7 @@ export default async function handler(request) {
}
try {
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
const { headlines, mode = 'brief', geoContext = '', variant = 'full' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
@@ -117,7 +109,7 @@ export default async function handler(request) {
}
// Check cache first (shared with Groq endpoint)
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cacheKey = getCacheKey(headlines, mode, geoContext, variant);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[OpenRouter] Cache hit:', cacheKey);
@@ -145,9 +137,6 @@ export default async function handler(request) {
const isTechVariant = variant === 'tech';
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
if (mode === 'brief') {
if (isTechVariant) {
// Tech variant: focus on startups, AI, funding, product launches
@@ -159,7 +148,7 @@ Rules:
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
- Lead with the company/product/technology name
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
- No bullet points, no meta-commentary${langInstruction}`;
- No bullet points, no meta-commentary`;
} else {
// Full variant: geopolitical focus
systemPrompt = `${dateContext}
@@ -171,7 +160,7 @@ Rules:
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary${langInstruction}`;
- No bullet points, no meta-commentary`;
}
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
@@ -199,18 +188,10 @@ Rules:
userPrompt = isTechVariant
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else if (mode === 'translate') {
const targetLang = variant; // In translate mode, variant param holds the target language code
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
Rules:
- Maintain the original tone and journalistic style.
- Do NOT add any conversational filler.
- Output ONLY the translated text.`;
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
} else {
systemPrompt = isTechVariant
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
-15
View File
@@ -56,7 +56,6 @@ const ALLOWED_DOMAINS = [
'www.reutersagency.com',
'feeds.reuters.com',
'rsshub.app',
'asia.nikkei.com',
'www.cfr.org',
'www.csis.org',
'www.politico.com',
@@ -124,21 +123,11 @@ const ALLOWED_DOMAINS = [
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.haaretz.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
'feeds.24.com',
'feeds.capi24.com', // News24 redirect destination
// International News Sources
'www.france24.com',
'www.euronews.com',
'www.lemonde.fr',
'rss.dw.com',
'www.africanews.com',
'www.lasillavacia.com',
'www.channelnewsasia.com',
'www.thehindu.com',
// International Organizations
'news.un.org',
'www.iaea.org',
@@ -173,10 +162,6 @@ const ALLOWED_DOMAINS = [
'www.imf.org',
// Additional
'news.ycombinator.com',
// Finance variant
'seekingalpha.com',
'www.coindesk.com',
'cointelegraph.com',
];
export default async function handler(req) {
-44
View File
@@ -1,44 +0,0 @@
export const config = { runtime: 'edge' };
const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest';
export default async function handler() {
try {
const res = await fetch(RELEASES_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'WorldMonitor-Version-Check',
},
});
if (!res.ok) {
return new Response(JSON.stringify({ error: 'upstream' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
const release = await res.json();
const tag = release.tag_name ?? '';
const version = tag.replace(/^v/, '');
return new Response(JSON.stringify({
version,
tag,
url: release.html_url,
prerelease: release.prerelease ?? false,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
'Access-Control-Allow-Origin': '*',
},
});
} catch {
return new Response(JSON.stringify({ error: 'fetch_failed' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}
@@ -1,53 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"metaTitle": {
"type": "string"
},
"keywords": {
"type": "string"
},
"audience": {
"type": "string"
},
"pubDate": {
"type": "string",
"format": "date-time"
},
"modifiedDate": {
"type": "string",
"format": "date-time"
},
"author": {
"type": "string"
},
"authorUrl": {
"type": "string",
"format": "uri"
},
"authorBio": {
"type": "string"
},
"heroImage": {
"type": "string"
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"description",
"metaTitle",
"keywords",
"audience",
"pubDate"
]
}
-1
View File
@@ -1 +0,0 @@
export default new Map();
-1
View File
@@ -1 +0,0 @@
export default new Map();
-163
View File
@@ -1,163 +0,0 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
"blog": Record<string, {
id: string;
body?: string;
collection: "blog";
data: InferEntrySchema<"blog">;
rendered?: RenderedContent;
filePath?: string;
}>;
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = typeof import("../src/content.config.js");
export type LiveContentConfig = never;
}
-2
View File
@@ -1,2 +0,0 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-115
View File
@@ -1,115 +0,0 @@
package main
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
// gzip on the fly for the static SPA. The hanzoai/static image this binary
// replaced compressed responses for us; serving the Vite bundle raw (main.js
// 1.5MB, map.js 2.7MB) is the whole reason a cold load felt slow. Restoring
// gzip here cuts the JS/CSS wire size ~75%. Paired with immutable cache headers
// on hashed assets (see setCacheHeaders), each client pays the transfer once.
//
// Scope: wraps ONLY the SPA handler, never /v1/world/* — those include SSE /
// streaming endpoints that must not be buffered through a gzip.Writer.
var gzipPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
return w
},
}
// gzipStatic compresses compressible responses when the client accepts gzip.
// Range requests pass through untouched (compressing a byte-range is invalid).
func gzipStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" || !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if strings.EqualFold(strings.TrimSpace(strings.SplitN(enc, ";", 2)[0]), "gzip") {
return true
}
}
return false
}
// compressible reports whether a content type shrinks under gzip. Already-
// compressed media (png/jpg/woff2/…) is skipped so we never waste CPU or grow
// the payload.
func compressible(ct string) bool {
ct = strings.ToLower(ct)
switch {
case strings.HasPrefix(ct, "text/"),
strings.Contains(ct, "javascript"),
strings.Contains(ct, "json"),
strings.Contains(ct, "svg"),
strings.Contains(ct, "xml"),
strings.Contains(ct, "wasm"),
strings.Contains(ct, "manifest"):
return true
}
return false
}
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
compress bool
}
func (g *gzipResponseWriter) WriteHeader(status int) {
g.decide(status)
g.ResponseWriter.WriteHeader(status)
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
if !g.decided {
g.decide(http.StatusOK) // FileServer may Write without an explicit WriteHeader
}
if g.compress {
return g.gz.Write(b)
}
return g.ResponseWriter.Write(b)
}
// decide inspects the headers the wrapped handler set (Content-Type is already
// populated by http.ServeContent at this point) and commits to compress-or-not
// exactly once.
func (g *gzipResponseWriter) decide(status int) {
if g.decided {
return
}
g.decided = true
h := g.Header()
if status == http.StatusOK && h.Get("Content-Encoding") == "" && compressible(h.Get("Content-Type")) {
g.compress = true
h.Del("Content-Length") // length changes after compression
h.Set("Content-Encoding", "gzip")
h.Add("Vary", "Accept-Encoding")
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
}
func (g *gzipResponseWriter) close() {
if g.gz != nil {
_ = g.gz.Close()
gzipPool.Put(g.gz)
g.gz = nil
}
}
-278
View File
@@ -1,278 +0,0 @@
// Command world is the single process baked into the world image. It serves
// BOTH the static Vite SPA (world.hanzo.ai and every *.hanzo.app fork) and the
// same-origin /v1/world/* data + live-video backend the SPA fetches.
//
// One binary, two responsibilities kept orthogonal:
// - /v1/world/* → the Go data backend (internal/world), each endpoint a
// faithful port of the original edge function.
// - everything → static files from --root, with SPA fallback to index.html
// else for client-routed paths (never for /api or asset misses).
//
// It listens on :3000 (the container/CR port); override with --addr or PORT.
package main
import (
"context"
"errors"
"flag"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/hanzoai/world/internal/world"
)
func main() {
var (
addr = flag.String("addr", envOr("WORLD_ADDR", ":"+envOr("PORT", "3000")), "listen address")
root = flag.String("root", envOr("WORLD_STATIC_ROOT", "dist"), "static SPA root directory")
reactRoot = flag.String("react-root", envOr("WORLD_REACT_ROOT", "dist-react"), "canary React SPA root, served only to sessions that opted in via ?react")
)
flag.Parse()
// Root context cancelled on SIGINT/SIGTERM: drives the world-model ingest
// loop and triggers graceful HTTP shutdown from one signal source.
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Fetch secrets from KMS and inject them into the environment BEFORE the
// server reads any config. Fail-open: no creds / unreachable KMS logs one
// line and continues on plain env (see internal/world/kms.go).
world.LoadKMSSecrets(rootCtx)
srv := world.NewServer()
defer srv.Close() // release hanzo-kv + embedded datastore handles
srv.StartModel(rootCtx) // continuously-folded world-state engine
srv.StartDatastore(rootCtx) // shared feed warmer + lake write-behind/prune
srv.StartFund(rootCtx) // autonomous PAPER-only multi-asset fund brain
srv.StartAltAssets(rootCtx) // hourly Christie's auctions + LuxuryEstate warmer
mux := http.NewServeMux()
srv.Mount(mux) // /v1/world/* routes
// Static SPA + fallback handles everything not matched by an /api route.
// The vanilla Vite build (--root) is the default surface; the React rewrite
// (--react-root) is served ONLY to a session that opted in via ?react, sticky
// per a first-party cookie — so shipping this changes nothing until we flip
// the default. gzipStatic wraps ONLY this handler — /v1/world/* keeps its
// streaming endpoints unbuffered.
mux.Handle("/", gzipStatic(newCanaryHandler(*root, *reactRoot)))
httpSrv := &http.Server{
Addr: *addr,
Handler: logRequests(mux),
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Printf("world: serving SPA from %q and /v1/world/* on %s", *root, *addr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("world: server error: %v", err)
}
}()
<-rootCtx.Done()
log.Printf("world: shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
}
// spaHandler serves files from root and falls back to index.html for any GET
// that doesn't resolve to a real file (client-side routing). It never serves the
// SPA shell for /api paths (those are handled by the mux) and returns 404 for
// missing static assets so a broken asset URL is visible, not masked by HTML.
type spaHandler struct {
root string
indexHTML []byte
csp string
fileSrv http.Handler
}
func newSPAHandler(root, indexName string) *spaHandler {
// Honor the same CSP env the prior hanzoai/static image used, so the world
// CR needs no change on cutover; WORLD_CSP is an explicit alias.
h := &spaHandler{
root: root,
fileSrv: http.FileServer(http.Dir(root)),
csp: envOr("HANZO_STATIC_CSP", envOr("WORLD_CSP", "")),
}
if b, err := os.ReadFile(filepath.Join(root, indexName)); err == nil {
h.indexHTML = b
} else {
log.Printf("world: warning: no %s under %q: %v", indexName, root, err)
}
return h
}
// surfaceCookie pins a browser session to the vanilla ("") or React ("react")
// world surface; ?react / ?gui toggles it. Readable by the client (not HttpOnly)
// so the app can show which surface it is on.
const surfaceCookie = "world_surface"
// canaryHandler routes each request to the vanilla SPA (default) or the React
// rewrite (opt-in). A visitor opts in with ?react (or ?gui=react): that sets the
// sticky cookie and redirects to a clean URL, so every following request (HTML,
// content-hashed assets, client routes) is served consistently from the React
// root for that session. ?react=0 / ?gui=vanilla opts back out. With no cookie
// the default surface is served — so this is a true canary: nothing changes for
// anyone until we flip the default.
type canaryHandler struct {
vanilla *spaHandler
react *spaHandler // nil when no react root is present → always vanilla
}
func newCanaryHandler(root, reactRoot string) http.Handler {
c := &canaryHandler{vanilla: newSPAHandler(root, "index.html")}
if reactRoot != "" {
// Only enable the canary if the React build actually shipped in the image
// (its index loaded); otherwise fall through to vanilla, never 404.
if r := newSPAHandler(reactRoot, "index.react.html"); r.indexHTML != nil {
c.react = r
}
}
return c
}
func (c *canaryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Honor an explicit toggle on a navigation: set/clear the sticky cookie, then
// redirect to the same path without the toggle query so the address bar and
// shared links stay clean and the reload is served from the chosen root.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && hasToggle(r.URL.Query()) {
q := r.URL.Query()
want := wantsReact(q)
ck := &http.Cookie{Name: surfaceCookie, Path: "/", SameSite: http.SameSiteLaxMode}
if want {
ck.Value, ck.MaxAge = "react", 30*24*3600
} else {
ck.Value, ck.MaxAge = "", -1
}
http.SetCookie(w, ck)
q.Del("react")
q.Del("gui")
u := *r.URL
u.RawQuery = q.Encode()
http.Redirect(w, r, u.RequestURI(), http.StatusFound)
return
}
if c.react != nil && surfaceFromCookie(r) == "react" {
c.react.ServeHTTP(w, r)
return
}
c.vanilla.ServeHTTP(w, r)
}
func hasToggle(q url.Values) bool { return q.Has("react") || q.Has("gui") }
// wantsReact reads the toggle intent: ?react (bare or =1/true/on/react) or
// ?gui=react opts in; ?react=0/false/off or ?gui=<anything-else> opts out.
func wantsReact(q url.Values) bool {
if q.Has("gui") {
return strings.EqualFold(strings.TrimSpace(q.Get("gui")), "react")
}
switch strings.ToLower(strings.TrimSpace(q.Get("react"))) {
case "", "1", "true", "on", "react":
return true
default:
return false
}
}
func surfaceFromCookie(r *http.Request) string {
if ck, err := r.Cookie(surfaceCookie); err == nil {
return ck.Value
}
return ""
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clean := filepath.Clean(r.URL.Path)
// Resolve against root; reject traversal.
rel := strings.TrimPrefix(clean, "/")
full := filepath.Join(h.root, rel)
if !strings.HasPrefix(full, filepath.Clean(h.root)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if rel == "" || rel == "." {
h.serveIndex(w, r)
return
}
info, err := os.Stat(full)
switch {
case err == nil && !info.IsDir():
setCacheHeaders(w, rel)
h.fileSrv.ServeHTTP(w, r) // real file
case errors.Is(err, fs.ErrNotExist) && !hasExt(rel):
h.serveIndex(w, r) // client-routed path → SPA shell
default:
http.NotFound(w, r) // missing asset (has extension) or dir
}
}
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
if h.indexHTML == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if h.csp != "" {
w.Header().Set("Content-Security-Policy", h.csp)
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(h.indexHTML)
}
}
// setCacheHeaders makes Vite's content-hashed bundles cacheable forever while
// keeping every unhashed file (favicons, manifest, service worker) revalidated
// so SW updates and icon swaps are never stuck behind a stale cache.
func setCacheHeaders(w http.ResponseWriter, rel string) {
if strings.HasPrefix(rel, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
// hasExt reports whether the last path segment has a file extension, used to
// distinguish an asset miss (foo.js → 404) from a client route (/country/US →
// SPA shell).
func hasExt(rel string) bool {
base := filepath.Base(rel)
return strings.Contains(base, ".")
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/world/") {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("api %s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
return
}
next.ServeHTTP(w, r)
})
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
-213
View File
@@ -1,213 +0,0 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeTree lays out a minimal Vite-style dist for the SPA handler tests.
func writeTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
// A hashed JS bundle large enough that gzip clearly wins.
js := strings.Repeat("export const answer = 42;\n", 4000)
files := map[string]string{
"index.html": "<!doctype html><title>world</title>",
"assets/main-abc123.js": js,
"assets/main-abc123.css": strings.Repeat(".panel{display:flex}\n", 2000),
"favicon.ico": "\x00\x00binary",
"manifest.webmanifest": `{"name":"world"}`,
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
func TestStaticGzipAndCache(t *testing.T) {
root := writeTree(t)
srv := httptest.NewServer(gzipStatic(newSPAHandler(root, "index.html")))
defer srv.Close()
get := func(path, ae string) *http.Response {
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
req.Header.Set("Accept-Encoding", ae)
resp, err := http.DefaultTransport.RoundTrip(req) // no transparent gunzip
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
rawLen := func(root, name string) int {
b, _ := os.ReadFile(filepath.Join(root, name))
return len(b)
}
t.Run("hashed js is gzipped, immutable, and smaller on the wire", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "gzip")
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Fatalf("Cache-Control = %q, want immutable", got)
}
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", resp.Header.Get("Vary"))
}
body, _ := io.ReadAll(resp.Body)
raw := rawLen(root, "assets/main-abc123.js")
if len(body) >= raw {
t.Fatalf("gzip wire size %d not smaller than raw %d", len(body), raw)
}
// And it must actually decode back to the original bytes.
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
dec, _ := io.ReadAll(zr)
if len(dec) != raw {
t.Fatalf("decoded %d bytes, want %d", len(dec), raw)
}
})
t.Run("no gzip when client does not accept it", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "identity")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") != "" {
t.Fatalf("unexpected Content-Encoding %q", resp.Header.Get("Content-Encoding"))
}
})
t.Run("unhashed files revalidate, never immutable", func(t *testing.T) {
for _, p := range []string{"/favicon.ico", "/manifest.webmanifest"} {
resp := get(p, "gzip")
resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("%s Cache-Control = %q, want no-cache", p, cc)
}
}
})
t.Run("already-compressed media is not re-gzipped", func(t *testing.T) {
resp := get("/favicon.ico", "gzip")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") == "gzip" {
t.Fatal("favicon.ico should not be gzipped")
}
})
t.Run("index shell served with no-cache and gzip", func(t *testing.T) {
resp := get("/", "gzip")
defer resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("index Cache-Control = %q, want no-cache", cc)
}
if resp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("index should be gzipped, got %q", resp.Header.Get("Content-Encoding"))
}
})
}
// writeReactTree lays out a minimal dist-react (its index is index.react.html).
func writeReactTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.react.html"),
[]byte("<!doctype html><title>world-react</title>"), 0o644); err != nil {
t.Fatal(err)
}
return root
}
func TestCanarySurfaceSelection(t *testing.T) {
vanilla := writeTree(t)
react := writeReactTree(t)
h := newCanaryHandler(vanilla, react)
// A client that does NOT follow redirects and keeps no cookies, so each call
// is explicit about what it sends.
do := func(target string, cookie *http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodGet, target, nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Result()
}
t.Run("default is vanilla", func(t *testing.T) {
resp := do("/", nil)
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "world</title>") || strings.Contains(string(body), "world-react") {
t.Fatalf("default surface should be vanilla, got %q", body)
}
})
t.Run("?react sets the cookie and redirects to a clean URL", func(t *testing.T) {
resp := do("/?react=1&keep=me", nil)
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if strings.Contains(loc, "react") || !strings.Contains(loc, "keep=me") {
t.Fatalf("Location %q should drop the toggle and keep other query", loc)
}
var set *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == surfaceCookie {
set = c
}
}
if set == nil || set.Value != "react" || set.MaxAge <= 0 {
t.Fatalf("expected a sticky %s=react cookie, got %+v", surfaceCookie, set)
}
})
t.Run("cookie=react serves the React surface", func(t *testing.T) {
resp := do("/", &http.Cookie{Name: surfaceCookie, Value: "react"})
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "world-react") {
t.Fatalf("cookie=react should serve React index, got %q", body)
}
})
t.Run("?react=0 clears the cookie", func(t *testing.T) {
resp := do("/?react=0", &http.Cookie{Name: surfaceCookie, Value: "react"})
var cleared bool
for _, c := range resp.Cookies() {
if c.Name == surfaceCookie && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatalf("?react=0 should expire the %s cookie", surfaceCookie)
}
})
t.Run("no react root → always vanilla, even with the cookie", func(t *testing.T) {
vanillaOnly := newCanaryHandler(vanilla, "")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: surfaceCookie, Value: "react"})
rec := httptest.NewRecorder()
vanillaOnly.ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "world</title>") || strings.Contains(string(body), "world-react") {
t.Fatalf("with no react root the cookie must be ignored, got %q", body)
}
})
}
+3 -9
View File
@@ -4,20 +4,16 @@ World Monitor desktop now uses a runtime configuration schema with per-feature t
## Secret keys
The desktop vault schema supports the following 17 keys used by services and relays:
The desktop vault schema supports the following required keys used by services and relays:
- `GROQ_API_KEY`
- `OPENROUTER_API_KEY`
- `FRED_API_KEY`
- `EIA_API_KEY`
- `FINNHUB_API_KEY`
- `CLOUDFLARE_API_TOKEN`
- `ACLED_ACCESS_TOKEN`
- `URLHAUS_AUTH_KEY`
- `OTX_API_KEY`
- `ABUSEIPDB_API_KEY`
- `NASA_FIRMS_API_KEY`
- `WINGBITS_API_KEY`
- `WS_RELAY_URL`
- `VITE_OPENSKY_RELAY_URL`
- `OPENSKY_CLIENT_ID`
- `OPENSKY_CLIENT_SECRET`
@@ -45,9 +41,7 @@ Secrets are **not stored in plaintext files** by the frontend.
If required secrets are missing/disabled:
- Summarization: Groq/OpenRouter disabled, browser model fallback.
- FRED / EIA / Finnhub: economic, oil analytics, and stock data return empty state.
- FRED / EIA: economic and oil analytics return empty state.
- Cloudflare / ACLED: outages/conflicts return empty state.
- Cyber threat feeds (URLhaus, OTX, AbuseIPDB): cyber threat layer returns empty state.
- NASA FIRMS: satellite fire detection returns empty state.
- Wingbits: flight enrichment disabled, heuristic-only flight classification remains.
- AIS / OpenSky relay: live tracking features are disabled cleanly.
-134
View File
@@ -29,7 +29,6 @@ A compact **variant switcher** in the header allows seamless navigation between
The primary variant focuses on geopolitical intelligence, military tracking, and infrastructure security monitoring.
### Key Capabilities
- **Conflict Monitoring** - Active war zones, hotspots, and crisis areas with real-time escalation tracking
- **Military Intelligence** - 220+ military bases, flight tracking, naval vessel monitoring, surge detection
- **Infrastructure Security** - Undersea cables, pipelines, datacenters, internet outages
@@ -38,7 +37,6 @@ The primary variant focuses on geopolitical intelligence, military tracking, and
- **AI-Powered Analysis** - Focal point detection, country instability scoring, infrastructure cascade analysis
### Intelligence Panels
| Panel | Purpose |
|-------|---------|
| **AI Insights** | LLM-synthesized world brief with focal point detection |
@@ -49,7 +47,6 @@ The primary variant focuses on geopolitical intelligence, military tracking, and
| **Live Intelligence** | GDELT-powered topic feeds (Military, Cyber, Nuclear, Sanctions) |
### News Coverage
80+ curated sources across geopolitics, defense, energy, think tanks, and regional news (Middle East, Africa, Latin America, Asia-Pacific).
---
@@ -59,7 +56,6 @@ The primary variant focuses on geopolitical intelligence, military tracking, and
The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provides specialized layers for technology sector monitoring.
### Tech Ecosystem Layers
| Layer | Description |
|-------|-------------|
| **Tech HQs** | Headquarters of major tech companies (Big Tech, unicorns, public companies) |
@@ -69,7 +65,6 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
| **Tech Events** | Upcoming conferences and tech events with countdown timers |
### Tech Infrastructure Layers
| Layer | Description |
|-------|-------------|
| **AI Datacenters** | 111 major AI compute clusters (≥10,000 GPUs) |
@@ -77,7 +72,6 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
| **Internet Outages** | Network disruptions affecting tech operations |
### Tech News Categories
- **Startups & VC** - Funding rounds, acquisitions, startup news
- **Cybersecurity** - Security vulnerabilities, breaches, threat intelligence
- **Cloud & Infrastructure** - AWS, Azure, GCP announcements, outages
@@ -105,7 +99,6 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
## Features
### Interactive Global Map
- **Zoom & Pan** - Smooth navigation with mouse/trackpad gestures
- **Regional Focus** - 8 preset views for rapid navigation (Global, Americas, Europe, MENA, Asia, Latin America, Africa, Oceania)
- **Layer System** - Toggle visibility of 20+ data layers organized by category
@@ -118,14 +111,12 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
Dense regions with many data points use intelligent clustering to prevent visual clutter:
**How It Works**
- Markers within a pixel radius (adaptive to zoom level) merge into cluster badges
- Cluster badges show the count of grouped items
- Clicking a cluster opens a popup listing all grouped items
- Zooming in reduces cluster radius, eventually showing individual markers
**Grouping Logic**
- **Protests**: Cluster within same country only (riots sorted first, high severity prioritized)
- **Tech HQs**: Cluster within same city (Big Tech sorted before unicorns before public companies)
- **Tech Events**: Cluster within same location (sorted by date, soonest first)
@@ -201,7 +192,6 @@ These panels transform raw signals into actionable intelligence by applying scor
### News Aggregation
Multi-source RSS aggregation across categories:
- **World / Geopolitical** - BBC, Reuters, AP, Guardian, NPR, Politico, The Diplomat
- **Middle East / MENA** - Al Jazeera, BBC ME, Guardian ME, Al Arabiya, Times of Israel
- **Africa** - BBC Africa, News24, Google News aggregation (regional & Sahel coverage)
@@ -223,7 +213,6 @@ Multi-source RSS aggregation across categories:
The **📡 SOURCES** button in the header opens a global source management modal, enabling fine-grained control over which news sources appear in the dashboard.
**Capabilities:**
- **Search**: Filter the source list by name to quickly find specific outlets
- **Individual Toggle**: Click any source to enable/disable it
- **Bulk Actions**: "Select All" and "Select None" for quick adjustments
@@ -231,14 +220,12 @@ The **📡 SOURCES** button in the header opens a global source management modal
- **Persistence**: Settings are saved to localStorage and persist across sessions
**Use Cases:**
- **Noise Reduction**: Disable high-volume aggregators (Google News) to focus on primary sources
- **Regional Focus**: Enable only sources relevant to a specific geographic area
- **Source Quality**: Disable sources with poor signal-to-noise ratio
- **Bias Management**: Balance coverage by enabling/disabling sources with known editorial perspectives
**Technical Details:**
- Disabled sources are filtered at fetch time (not display time), reducing bandwidth and API calls
- Affects all news panels simultaneously—disable BBC once, it's gone everywhere
- Panels with all sources disabled show "All sources disabled" message
@@ -273,7 +260,6 @@ Embedded YouTube live streams from major news networks with channel switching:
| **Al Jazeera** | Middle East & international news |
**Core Features:**
- **Channel Switcher** - One-click switching between networks
- **Live Indicator** - Blinking dot shows stream status, click to pause/play
- **Mute Toggle** - Audio control (muted by default)
@@ -304,7 +290,6 @@ To conserve resources, the panel implements automatic idle pausing:
This prevents background tabs from consuming bandwidth while preserving user preference for manually-paused streams.
### Market Data
- **Stocks** - Major indices and tech stocks via Finnhub (Yahoo Finance backup)
- **Commodities** - Oil, gold, natural gas, copper, VIX
- **Crypto** - Bitcoin, Ethereum, Solana via CoinGecko
@@ -314,14 +299,11 @@ This prevents background tabs from consuming bandwidth while preserving user pre
- **Government Spending** - USASpending.gov: Recent federal contracts and awards
### Prediction Markets
- Polymarket integration for event probability tracking
- Correlation analysis with news events
### Search (⌘K)
Universal search across all data sources:
- News articles
- Geographic hotspots and conflicts
- Infrastructure (pipelines, cables, datacenters)
@@ -329,7 +311,6 @@ Universal search across all data sources:
- Markets and predictions
### Data Export
- CSV and JSON export of current dashboard state
- Historical playback from snapshots
@@ -349,7 +330,6 @@ The header displays an **Intelligence Findings** badge that consolidates two typ
| **Unified Alerts** | Module-generated alerts | CII spikes, geographic convergence, infrastructure cascades |
**Interaction**: Clicking the badge—or clicking an individual alert—opens a detail modal showing:
- Full alert description and context
- Component breakdown (for composite alerts)
- Affected countries or regions
@@ -398,7 +378,6 @@ The system detects 12 distinct signal types across news, markets, military, and
### How It Works
The correlation engine maintains rolling snapshots of:
- News topic frequency (by keyword extraction)
- Market price changes
- Prediction market probabilities
@@ -587,7 +566,6 @@ The dashboard visually flags sources with known state affiliations or propaganda
**Display Locations**
Propaganda risk badges appear in:
- **Cluster primary source**: Badge next to the main source name
- **Top sources list**: Small badge next to each flagged source
- **Cluster view**: Visible when expanding multi-source clusters
@@ -656,7 +634,6 @@ When a market symbol moves significantly, the system searches news clusters for
4. **Result** - "Market Move Explained" or "Silent Divergence" signal
This enables signals like:
- **Explained**: "AVGO +5.2% — Broadcom mentioned in 3 news clusters (AI chip demand)"
- **Silent**: "AVGO +5.2% — No correlated news after entity search"
@@ -700,7 +677,6 @@ similarity(A, B) = |A ∩ B| / |A B|
```
**Tokenization**:
- Headlines are lowercased and split on word boundaries
- Stop words removed: "the", "a", "an", "in", "on", "at", "to", "for", "of", "and", "or"
- Short tokens (<3 characters) filtered out
@@ -715,7 +691,6 @@ Rather than O(n²) pairwise comparison, the algorithm uses an inverted index:
4. This reduces comparisons from ~10,000 to ~500 for typical news loads
**Clustering Rules**:
- Articles with similarity ≥ 0.5 are grouped into clusters
- Clusters are sorted by source tier, then recency
- The most authoritative source becomes the "primary" headline
@@ -775,7 +750,6 @@ The entity index pre-builds five lookup maps for O(1) access:
- Example: AVGO move also searches for NVDA, INTC, AMD news
**Performance**:
- Index builds once on first access (cached singleton)
- Alias map has ~300 entries for 100+ entities
- Keyword map has ~400 entries
@@ -790,7 +764,6 @@ The system maintains rolling baselines for news volume per topic:
- **Z-score** = (current - mean) / stddev
Deviation levels:
- **Spike**: Z > 2.5 (statistically rare increase)
- **Elevated**: Z > 1.5
- **Normal**: -2 < Z < 1.5
@@ -857,7 +830,6 @@ final_score = (static_baseline × 0.30 + dynamic_score × 0.70) × proximity_boo
**Trend Detection**
The system maintains 48-point history (24 hours at 30-minute intervals) per hotspot:
- **Linear regression** calculates slope of recent scores
- **Rising**: Slope > +0.1 points per interval
- **Falling**: Slope < -0.1 points per interval
@@ -866,7 +838,6 @@ The system maintains 48-point history (24 hours at 30-minute intervals) per hots
**Signal Generation**
Escalation signals (`hotspot_escalation`) are emitted when:
1. Final score exceeds threshold (typically 60)
2. At least 2 hours since last signal for this hotspot (cooldown)
3. Trend is rising or score is critical (>80)
@@ -1027,7 +998,6 @@ The floor applies *after* the standard calculation—if the computed score excee
### Trend Detection
The CII tracks 24-hour changes to identify trajectory:
- **Rising**: Score increased by ≥5 points (escalating situation)
- **Stable**: Change within ±5 points (steady state)
- **Falling**: Score decreased by ≥5 points (de-escalation)
@@ -1043,18 +1013,15 @@ Beyond the base component scores, several contextual factors can boost a country
| **Focal Point** | 8 | AI focal point detection on country | Multi-source convergence indicator |
**Hotspot Boost Calculation**:
- Hotspot activity (0-100) scaled by 1.5× then capped at 10
- Zero boost for countries with no associated hotspot activity
**News Urgency Boost Tiers**:
- Information ≥70: +5 points
- Information ≥50: +3 points
- Information <50: +0 points
**Focal Point Boost Tiers**:
- Critical urgency: +8 points
- Elevated urgency: +4 points
- Normal urgency: +0 points
@@ -1072,7 +1039,6 @@ On dashboard startup, the CII system enters **Learning Mode**—a 15-minute cali
**Note**: Server-side pre-computation now provides immediate scores to new users—Learning Mode primarily affects client-side dynamic adjustments and alert generation rather than initial score display.
**Why 15 minutes?** Real-world testing showed that CII scores stabilize after approximately 10-20 minutes of data collection. The 15-minute window provides sufficient time for:
- Multiple refresh cycles across all data sources
- Trend detection to establish direction (rising/stable/falling)
- Cross-source correlation to normalize bias
@@ -1107,7 +1073,6 @@ This ensures users understand that early scores are provisional while preventing
### Keyword Attribution
Countries are matched to data via keyword lists:
- **Russia**: `russia`, `moscow`, `kremlin`, `putin`
- **China**: `china`, `beijing`, `xi jinping`, `prc`
- **Taiwan**: `taiwan`, `taipei`
@@ -1152,14 +1117,12 @@ convergence_score = min(100, type_score + count_boost)
### Example Scenarios
**Taiwan Strait Buildup**
- Cell: `25°N, 121°E`
- Events: Military flights (3), Naval vessels (2), Protests (1)
- Score: 75 + 12 = 87 (Critical)
- Signal: "Geographic Convergence (3 types) - military flights, naval vessels, protests"
**Middle East Flashpoint**
- Cell: `32°N, 35°E`
- Events: Military flights (5), Protests (8), Earthquake (1)
- Score: 75 + 25 = 100 (Critical)
@@ -1206,7 +1169,6 @@ When a user selects an infrastructure asset for analysis, a **breadth-first casc
### Redundancy Modeling
The system accounts for alternative routes:
- Cables with high redundancy show reduced impact
- Countries with multiple cable landings show lower vulnerability
- Alternative routes are displayed with capacity percentages
@@ -1272,7 +1234,6 @@ The system parses NGA (National Geospatial-Intelligence Agency) maritime warning
### Repair Ship Tracking
When a cableship is mentioned in warnings, the system extracts:
- **Vessel name**: CS Reliance, Cable Innovator, etc.
- **Status**: "En route" or "On station"
- **Location**: Current working area
@@ -1283,7 +1244,6 @@ This enables monitoring of ongoing repair operations before official carrier ann
### Why This Matters
Undersea cables carry 95% of intercontinental data traffic. A cable cut can:
- Cause regional internet outages
- Disrupt financial transactions
- Impact military communications
@@ -1348,7 +1308,6 @@ Priority: Critical
### Trend Detection
The system tracks the composite score over time:
- First measurement establishes baseline (shows "Stable")
- Subsequent changes of ±5 points trigger trend changes
- This prevents false "escalating" signals on initialization
@@ -1393,7 +1352,6 @@ The indicator also displays geopolitical tension scores from GDELT (Global Datab
| Russia ↔ Ukraine | Active conflict zone |
Each pair shows:
- **Current tension score** (GDELT's normalized metric)
- **7-day trend** (rising, falling, stable)
- **Percentage change** from previous period
@@ -1437,7 +1395,6 @@ Up to 3 assets per type are displayed, sorted by proximity.
### Example Context
A news cluster about "pipeline explosion in Germany" would show:
- **Pipelines**: Nord Stream (23km), Yamal-Europe (156km)
- **Cables**: TAT-14 landing (89km)
- **Bases**: Ramstein (234km)
@@ -1527,7 +1484,6 @@ The system monitors eight critical maritime chokepoints where disruptions could
### Density Analysis
Vessel positions are aggregated into a 2° grid to calculate traffic density. Each cell tracks:
- Current vessel count
- Historical baseline (30-minute rolling window)
- Change percentage from baseline
@@ -1537,7 +1493,6 @@ Density changes of ±30% trigger alerts, indicating potential congestion, divers
### Dark Ship Detection
The system monitors for AIS gaps—vessels that stop transmitting their position. An AIS gap exceeding 60 minutes in monitored regions may indicate:
- Sanctions evasion (ship-to-ship transfers)
- Illegal fishing
- Military activity
@@ -1575,13 +1530,11 @@ Browser → Railway Relay → External APIs
| `/health` | Status check | None |
**Environment Variables** (Railway):
- `AISSTREAM_API_KEY` - AIS data access
- `OPENSKY_CLIENT_ID` - OAuth2 client ID
- `OPENSKY_CLIENT_SECRET` - OAuth2 client secret
**Why Railway?**
- Residential IP ranges (not blocked like cloud providers)
- WebSocket support for persistent connections
- Global edge deployment for low latency
@@ -1661,26 +1614,22 @@ Vessels within 50km of these bases are flagged, enabling detection of unusual ac
Military aircraft are tracked via the OpenSky Network using ADS-B data. OpenSky blocks unauthenticated requests from cloud provider IPs (Vercel, Railway, AWS), so aircraft tracking requires a relay server with credentials.
**Authentication**:
- Register for a free account at [opensky-network.org](https://opensky-network.org)
- Create an API client in account settings to get `OPENSKY_CLIENT_ID` and `OPENSKY_CLIENT_SECRET`
- The relay uses **OAuth2 client credentials flow** to obtain Bearer tokens
- Tokens are cached (30-minute expiry) and automatically refreshed
**Identification Methods**:
- **Callsign matching**: Known military callsign patterns (RCH, REACH, DUKE, etc.)
- **ICAO hex ranges**: Military aircraft use assigned hex code blocks by country
- **Altitude/speed profiles**: Unusual flight characteristics
**Tracked Metrics**:
- Position history (20-point trails over 5-minute windows)
- Altitude and ground speed
- Heading and track
**Activity Detection**:
- Formations (multiple military aircraft in proximity)
- Unusual patterns (holding, reconnaissance orbits)
- Chokepoint transits
@@ -1724,7 +1673,6 @@ Aircraft are categorized by callsign pattern matching:
**Baseline Calculation**
The system maintains rolling 48-hour activity baselines per theater:
- Minimum 6 data samples required for reliable baseline
- Default baselines when data insufficient: 3 transport, 2 fighter, 1 reconnaissance
- Activity below 50% of baseline indicates stand-down
@@ -1773,7 +1721,6 @@ The system tracks 18 strategically significant geographic areas:
**Detection Logic**
For each sensitive region, the system:
1. Identifies all military aircraft within the region boundary
2. Groups aircraft by operating nation
3. Excludes "home region" operators (e.g., Russian VKS in Baltic excluded from alert)
@@ -1824,21 +1771,17 @@ When an aircraft is detected via OpenSky ADS-B, the system queries Wingbits for:
The enrichment service analyzes owner and operator fields against curated keyword lists:
**Confirmed Military** (owner/operator match):
- Government: "United States Air Force", "Department of Defense", "Royal Air Force"
- International: "NATO", "Ministry of Defence", "Bundeswehr"
**Likely Military** (operator ICAO codes):
- `AIO` (Air Mobility Command), `RRR` (Royal Air Force), `GAF` (German Air Force)
- `RCH` (REACH flights), `CNV` (Convoy flights), `DOD` (Department of Defense)
**Possible Military** (defense contractors):
- Northrop Grumman, Lockheed Martin, General Atomics, Raytheon, Boeing Defense, L3Harris
**Aircraft Type Matching**:
- Transport: C-17, C-130, C-5, KC-135, KC-46
- Reconnaissance: RC-135, U-2, RQ-4, E-3, E-8
- Combat: F-15, F-16, F-22, F-35, B-52, B-2
@@ -1891,7 +1834,6 @@ The Spaceports layer displays global launch facilities for monitoring space-rela
### Why This Matters
Space launches are geopolitically significant:
- **Military implications**: Many launches are dual-use (civilian/military)
- **Technology competition**: Launch cadence indicates space program advancement
- **Supply chain**: Satellite services affect communications, GPS, reconnaissance
@@ -1925,7 +1867,6 @@ The Minerals layer displays strategic mineral extraction sites essential for mod
### Supply Chain Risks
Critical minerals are geopolitically concentrated:
- **Cobalt**: 70% from DRC, significant artisanal mining concerns
- **Rare Earths**: 60% from China, processing nearly monopolized
- **Lithium**: Expanding production but demand outpacing supply
@@ -1954,7 +1895,6 @@ Cyber operations often correlate with geopolitical tensions. When news reports r
### Visual Indicators
APT markers appear as warning triangles (⚠) with distinct styling. Clicking a marker shows:
- **Official designation** and common aliases
- **State sponsor** and intelligence agency
- **Primary targeting sectors**
@@ -1968,7 +1908,6 @@ The Protests layer aggregates civil unrest data from two independent sources, pr
### ACLED (Armed Conflict Location & Event Data)
Academic-grade conflict data with human-verified events:
- **Coverage**: Global, 30-day rolling window
- **Event types**: Protests, riots, strikes, demonstrations
- **Metadata**: Actors involved, fatalities, detailed notes
@@ -1977,7 +1916,6 @@ Academic-grade conflict data with human-verified events:
### GDELT (Global Database of Events, Language, and Tone)
Real-time news-derived event data:
- **Coverage**: Global, 7-day rolling window
- **Event types**: Geocoded protest mentions from news
- **Volume**: Reports per location (signal strength)
@@ -1986,7 +1924,6 @@ Real-time news-derived event data:
### Multi-Source Corroboration
Events from both sources are deduplicated using a 0.5° spatial grid and date matching. When both ACLED and GDELT report events in the same area:
- Confidence is elevated to "high"
- ACLED data takes precedence (higher accuracy)
- Source list shows corroboration
@@ -2040,7 +1977,6 @@ The Flights layer tracks airport delays and ground stops at major US airports us
### Monitored Airports
The 30 largest US airports are tracked:
- Major hubs: JFK, LAX, ORD, ATL, DFW, DEN, SFO
- International gateways with high traffic volume
- Airports frequently affected by weather or congestion
@@ -2063,7 +1999,6 @@ sanitizeUrl(url) // Allows only http/https protocols
```
This applies to:
- News headlines and sources (RSS feeds)
- Search results and highlights
- Monitor keywords (user input)
@@ -2109,7 +2044,6 @@ Normal → Failure #1 → Failure #2 → OPEN (cooldown)
```
**Behavior during cooldown:**
- New requests return cached data (if available)
- UI shows "temporarily unavailable" status
- No API calls are made (prevents hammering)
@@ -2134,7 +2068,6 @@ RSS feeds use per-feed circuit breakers—one failing feed doesn't affect others
### Graceful Degradation
When a service enters cooldown:
1. Cached data continues to display (stale but available)
2. Status panel shows service health
3. Automatic recovery when cooldown expires
@@ -2172,7 +2105,6 @@ The status panel lists all data feeds with their current state:
### Per-Feed Information
Each feed entry shows:
- **Source name** - The data provider
- **Last update** - Time since last successful fetch
- **Next refresh** - Countdown to next scheduled fetch
@@ -2181,7 +2113,6 @@ Each feed entry shows:
### Why This Matters
External APIs are unreliable. The status panel helps you understand:
- **Data freshness** - Is the news feed current or stale?
- **Coverage gaps** - Which sources are currently unavailable?
- **Recovery timeline** - When will failed sources retry?
@@ -2220,7 +2151,6 @@ Each data source has calibrated freshness expectations:
### Visual Indicators
The status panel displays freshness for each source:
- **Colored dot** indicates freshness level
- **Time since update** shows exact staleness
- **Next refresh countdown** shows when data will update
@@ -2228,7 +2158,6 @@ The status panel displays freshness for each source:
### Why This Matters
Understanding data freshness is critical for decision-making:
- A "fresh" earthquake feed means recent events are displayed
- A "stale" news feed means you may be missing breaking stories
- A "critical" AIS stream means vessel positions are unreliable
@@ -2281,13 +2210,11 @@ API calls are expensive. The system only fetches data for **enabled layers**, re
### Layer-Aware Loading
When a layer is toggled OFF:
- No API calls for that data source
- No refresh interval scheduled
- WebSocket connections closed (for AIS)
When a layer is toggled ON:
- Data is fetched immediately
- Refresh interval begins
- Loading indicator shown on toggle button
@@ -2295,7 +2222,6 @@ When a layer is toggled ON:
### Unconfigured Services
Some data sources require API keys (AIS relay, Cloudflare Radar). If credentials are not configured:
- The layer toggle is hidden entirely
- No failed requests pollute the console
- Users see only functional layers
@@ -2319,7 +2245,6 @@ CPU-intensive operations run in a dedicated Web Worker to avoid blocking the mai
| DOM rendering | O(n) | ❌ Main thread |
The worker manager implements:
- **Lazy initialization**: Worker spawns on first use
- **10-second ready timeout**: Rejects if worker fails to initialize
- **30-second request timeout**: Prevents hanging on stuck operations
@@ -2330,13 +2255,11 @@ The worker manager implements:
Large lists (100+ news items) use virtualized rendering:
**Fixed-Height Mode** (VirtualList):
- Only renders items visible in viewport + 3-item overscan buffer
- Element pooling—reuses DOM nodes rather than creating new ones
- Invisible spacers maintain scroll position without rendering all items
**Variable-Height Mode** (WindowedList):
- Chunk-based rendering (10 items per chunk)
- Renders chunks on-scroll with 1-chunk buffer
- CSS containment for performance isolation
@@ -2346,7 +2269,6 @@ This reduces DOM node count from thousands to ~30, dramatically improving scroll
### Request Deduplication
Identical requests within a short window are deduplicated:
- Market quotes batch multiple symbols into single API call
- Concurrent layer toggles don't spawn duplicate fetches
- `Promise.allSettled` ensures one failing request doesn't block others
@@ -2354,7 +2276,6 @@ Identical requests within a short window are deduplicated:
### Efficient Data Updates
When refreshing data:
- **Incremental updates**: Only changed items trigger re-renders
- **Stale-while-revalidate**: Old data displays while fetch completes
- **Delta compression**: Baselines store 7-day/30-day deltas, not raw history
@@ -2368,7 +2289,6 @@ The Prediction Markets panel focuses on **geopolitically relevant** markets, fil
### Inclusion Keywords
Markets matching these topics are displayed:
- **Conflicts**: war, military, invasion, ceasefire, NATO, nuclear
- **Countries**: Russia, Ukraine, China, Taiwan, Iran, Israel, Gaza
- **Leaders**: Putin, Zelensky, Trump, Biden, Xi Jinping, Netanyahu
@@ -2378,7 +2298,6 @@ Markets matching these topics are displayed:
### Exclusion Keywords
Markets matching these are filtered out:
- **Sports**: NBA, NFL, FIFA, World Cup, championships, playoffs
- **Entertainment**: Oscars, movies, celebrities, TikTok, streaming
@@ -2472,7 +2391,6 @@ The mobile experience focuses on the most essential intelligence layers:
| **Weather** | Severe weather warnings |
Layers disabled by default on mobile (but available on desktop):
- Military bases, nuclear facilities, spaceports, minerals
- Undersea cables, pipelines, datacenters
- AIS vessels, military flights
@@ -2629,7 +2547,6 @@ User requests summary
### Lazy Loading
Models are loaded on-demand to minimize initial page load:
- Models download only when first needed
- Progress indicator shows download status
- Once cached, models load instantly from IndexedDB
@@ -2637,7 +2554,6 @@ Models are loaded on-demand to minimize initial page load:
### Worker Isolation
All ML inference runs in a dedicated Web Worker:
- Main thread remains responsive during inference
- 30-second timeout prevents hanging
- Automatic cleanup on errors
@@ -2737,18 +2653,15 @@ FOCAL POINTS (entities across news + signals):
Not all news is equally important. Headlines are scored to identify the most significant stories for the briefing:
**Score Boosters** (high weight):
- Military keywords: war, invasion, airstrike, missile, deployment, mobilization
- Violence indicators: killed, casualties, clashes, massacre, crackdown
- Civil unrest: protest, uprising, coup, riot, martial law
**Geopolitical Multipliers**:
- Flashpoint regions: Iran, Russia, China, Taiwan, Ukraine, North Korea, Gaza
- Critical actors: NATO, Pentagon, Kremlin, Hezbollah, Hamas, Wagner
**Score Reducers** (demoted):
- Business context: CEO, earnings, stock, revenue, startup, data center
- Entertainment: celebrity, movie, streaming
@@ -2783,7 +2696,6 @@ The Focal Point Detector is the intelligence synthesis layer that correlates new
### The Problem It Solves
Without synthesis, intelligence streams operate in silos:
- News feeds show 80+ sources with thousands of headlines
- Map layers display military flights, protests, outages independently
- No automated way to see that IRAN appears in news AND has military activity AND an internet outage
@@ -2842,7 +2754,6 @@ Focal points display icons indicating which signal types are active:
### Example Output
A focal point for IRAN might show:
- **Display**: "Iran [CRITICAL] ✈️📢🌐"
- **News**: 12 mentions, velocity 0.5/hour
- **Signals**: 5 military flights, 3 protests, 1 outage
@@ -2852,7 +2763,6 @@ A focal point for IRAN might show:
### Integration with CII
Focal point urgency levels feed into the Country Instability Index:
- **Critical** focal point → CII score boost for that country
- Ensures countries with multi-source convergence are properly flagged
- Prevents "silent" instability when news alone wouldn't trigger alerts
@@ -2899,7 +2809,6 @@ Near-real-time natural event detection from satellite observation:
### Multi-Source Deduplication
When both GDACS and EONET report the same event:
1. Events within 100km and 48 hours are considered duplicates
2. GDACS severity takes precedence (human-verified)
3. EONET geometry provides more precise coordinates
@@ -2908,7 +2817,6 @@ When both GDACS and EONET report the same event:
### Filtering Logic
To prevent map clutter, natural events are filtered:
- **Wildfires**: Only events < 48 hours old (older fires are either contained or well-known)
- **Earthquakes**: M4.5+ globally, lower threshold for populated areas
- **Storms**: Only named storms or those with warnings
@@ -3020,7 +2928,6 @@ Nine geographic theaters are monitored continuously, each with custom thresholds
Beyond raw counts, the system assesses whether forces in a theater constitute an **offensive strike package**—the combination of assets required for sustained combat operations.
**Strike-Capable Criteria**:
- Aerial refueling tankers (KC-135, KC-10, A330 MRTT)
- Airborne command and control (E-3 AWACS, E-7 Wedgetail)
- Combat aircraft (fighters, strike aircraft)
@@ -3176,7 +3083,6 @@ The Service Status panel tracks the operational health of external services that
### Why This Matters
External service outages can affect:
- AI summarization (Groq, OpenRouter outages)
- Deployment pipelines (Vercel, GitHub outages)
- API availability (Cloudflare, AWS outages)
@@ -3248,14 +3154,12 @@ Historical filtering is client-side—all data is fetched but filtered for displ
The map uses a hybrid rendering strategy optimized for each platform:
**Desktop (deck.gl + MapLibre GL)**:
- WebGL-accelerated layers handle thousands of markers smoothly
- MapLibre GL provides base map tiles (OpenStreetMap)
- GeoJSON, Scatterplot, Path, and Icon layers for different data types
- GPU-based clustering and picking for responsive interaction
**Mobile (D3.js + TopoJSON)**:
- SVG rendering for battery efficiency
- Reduced marker count and simplified layers
- Touch-optimized interaction with larger hit targets
@@ -3471,14 +3375,12 @@ api/ # Vercel Edge serverless proxies
## Usage
### Keyboard Shortcuts
- `⌘K` / `Ctrl+K` - Open search
- `↑↓` - Navigate search results
- `Enter` - Select result
- `Esc` - Close modals
### Map Controls
- **Scroll** - Zoom in/out
- **Drag** - Pan the map
- **Click markers** - Show detailed popup with full context
@@ -3501,14 +3403,12 @@ Infrastructure markers (nuclear facilities, economic centers, ports) display wit
This design prioritizes geographic awareness over label density—users can quickly scan for markers and then interact for context.
### Panel Management
- **Drag panels** - Reorder layout
- **Settings (⚙)** - Toggle panel visibility
### Shareable Links
The current view state is encoded in the URL, enabling:
- **Bookmarking**: Save specific views for quick access
- **Sharing**: Send colleagues a link to your exact map position and layer configuration
- **Deep linking**: Link directly to a specific region or feature
@@ -3529,11 +3429,9 @@ Values are validated and clamped to prevent invalid states.
## Data Sources
### News Feeds
Aggregates **70+ RSS feeds** from major news outlets, government sources, and specialty publications with source-tier prioritization. Categories include world news, MENA, Africa, Latin America, Asia-Pacific, energy, technology, AI/ML, finance, government releases, defense/intel, think tanks, and international crisis organizations.
### Geospatial Data
- **Hotspots**: 30+ global intelligence hotspots with keyword correlation (including Sahel, Haiti, Horn of Africa)
- **Conflicts**: 10+ active conflict zones with involved parties
- **Military Bases**: 220+ installations from US, NATO, Russia, China, and allies
@@ -3545,7 +3443,6 @@ Aggregates **70+ RSS feeds** from major news outlets, government sources, and sp
- **Ports**: 61 strategic ports (container, oil/LNG, naval, chokepoint)
### Live APIs
- **USGS**: Earthquake feed (M4.5+ global)
- **NASA EONET**: Natural events (storms, wildfires, volcanoes, floods)
- **NWS**: Severe weather alerts (US)
@@ -3563,36 +3460,30 @@ Aggregates **70+ RSS feeds** from major news outlets, government sources, and sp
This project uses data from the following sources. Please respect their terms of use.
### Aircraft Tracking
Data provided by [The OpenSky Network](https://opensky-network.org). If you use this data in publications, please cite:
> Matthias Schäfer, Martin Strohmeier, Vincent Lenders, Ivan Martinovic and Matthias Wilhelm. "Bringing Up OpenSky: A Large-scale ADS-B Sensor Network for Research". In *Proceedings of the 13th IEEE/ACM International Symposium on Information Processing in Sensor Networks (IPSN)*, pages 83-94, April 2014.
### Conflict & Protest Data
- **ACLED**: Armed Conflict Location & Event Data. Source: [ACLED](https://acleddata.com). Data must be attributed per their [Attribution Policy](https://acleddata.com/attributionpolicy/).
- **GDELT**: Global Database of Events, Language, and Tone. Source: [The GDELT Project](https://www.gdeltproject.org/).
### Financial Data
- **Stock Quotes**: Powered by [Finnhub](https://finnhub.io/) (primary), with [Yahoo Finance](https://finance.yahoo.com/) as backup for indices and commodities
- **Cryptocurrency**: Powered by [CoinGecko API](https://www.coingecko.com/en/api)
- **Economic Indicators**: Data from [FRED](https://fred.stlouisfed.org/), Federal Reserve Bank of St. Louis
### Geophysical Data
- **Earthquakes**: [U.S. Geological Survey](https://earthquake.usgs.gov/), ANSS Comprehensive Catalog
- **Natural Events**: [NASA EONET](https://eonet.gsfc.nasa.gov/) - Earth Observatory Natural Event Tracker (storms, wildfires, volcanoes, floods)
- **Weather Alerts**: [National Weather Service](https://www.weather.gov/) - Open data, free to use
### Infrastructure & Transport
- **Airport Delays**: [FAA Air Traffic Control System Command Center](https://www.fly.faa.gov/)
- **Vessel Tracking**: [AISstream](https://aisstream.io/) real-time AIS data
- **Internet Outages**: [Cloudflare Radar](https://radar.cloudflare.com/) (CC BY-NC 4.0)
### Other Sources
- **Prediction Markets**: [Polymarket](https://polymarket.com/)
## Acknowledgments
@@ -3612,7 +3503,6 @@ This project is a **proof of concept** demonstrating what's possible with public
### Data Completeness
Some data sources require paid accounts for full access:
- **ACLED**: Free tier has API restrictions; Research tier required for programmatic access
- **OpenSky Network**: Rate-limited; commercial tiers offer higher quotas
- **Satellite AIS**: Global coverage requires commercial providers (Spire, Kpler, etc.)
@@ -3622,7 +3512,6 @@ The dashboard works with free tiers but may have gaps in coverage or update freq
### AIS Coverage Bias
The Ships layer uses terrestrial AIS receivers via [AISStream.io](https://aisstream.io). This creates a **geographic bias**:
- **Strong coverage**: European waters, Atlantic, major ports
- **Weak coverage**: Middle East, open ocean, remote regions
@@ -3631,7 +3520,6 @@ Terrestrial receivers only detect vessels within ~50km of shore. Satellite AIS (
### Blocked Data Sources
Some publishers block requests from cloud providers (Vercel, Railway, AWS):
- RSS feeds from certain outlets may fail with 403 errors
- This is a common anti-bot measure, not a bug in the dashboard
- Affected feeds are automatically disabled via circuit breakers
@@ -3681,18 +3569,15 @@ See [ROADMAP.md](ROADMAP.md) for detailed planning. Recent intelligence enhancem
### Planned
**High Priority:**
- **Temporal Anomaly Detection** - Flag activity unusual for time of day/week/year (e.g., "military flights 3x normal for Tuesday")
- **Trade Route Risk Scoring** - Real-time supply chain vulnerability for major shipping routes (Asia→Europe, Middle East→Europe, etc.)
**Medium Priority:**
- **Historical Playback** - Review past dashboard states with timeline scrubbing
- **Election Calendar Integration** - Auto-boost sensitivity 30 days before major elections
- **Choropleth CII Map Layer** - Country-colored overlay showing instability scores
**Future Enhancements:**
- **Alert Webhooks** - Push critical alerts to Slack, Discord, email
- **Custom Country Watchlists** - User-defined Tier-2 country monitoring
- **Additional Data Sources** - World Bank, IMF, OFAC sanctions, UNHCR refugee data, FAO food security
@@ -3790,20 +3675,17 @@ Different data types refresh at different intervals based on volatility and API
The system implements defense-in-depth for external service failures:
**Circuit Breakers**
- Each external service has an independent circuit breaker
- After 3 consecutive failures, the circuit opens for 60 seconds
- Partial failures don't cascade to other services
- Status panel shows exact failure states
**Graceful Degradation**
- Stale cached data displays during outages (with timestamp warning)
- Failed services are automatically retried on next cycle
- Critical data (news, markets) has backup sources
**User Feedback**
- Real-time status indicators in the header
- Specific error messages in the status panel
- No silent failures—every data source state is visible
@@ -3813,19 +3695,16 @@ The system implements defense-in-depth for external service failures:
The project uses Vite for optimal production builds:
**Code Splitting**
- Web Worker code is bundled separately
- Config files (tech-geo.ts, pipelines.ts) are tree-shaken
- Lazy-loaded panels reduce initial bundle size
**Variant Builds**
- `npm run build` - Standard geopolitical dashboard
- `npm run build:tech` - Tech sector variant with different defaults
- Both share the same codebase, configured via environment variables
**Asset Optimization**
- TopoJSON geography data is pre-compressed
- Static config data is inlined at build time
- CSS is minified and autoprefixed
@@ -3833,19 +3712,16 @@ The project uses Vite for optimal production builds:
### Security Considerations
**Client-Side Security**
- All user input is sanitized via `escapeHtml()` before rendering
- URLs are validated via `sanitizeUrl()` before href assignment
- No `innerHTML` with user-controllable content
**API Security**
- Sensitive API keys are stored server-side only
- Proxy functions validate and sanitize parameters
- Geographic coordinates are clamped to valid ranges
**Privacy**
- No user accounts or cloud storage
- All preferences stored in localStorage
- No telemetry beyond basic Vercel analytics (page views only)
@@ -3882,32 +3758,27 @@ Contributions are welcome! Whether you're fixing bugs, adding features, improvin
This project follows specific patterns to maintain consistency:
**TypeScript**
- Strict type checking enabled—avoid `any` where possible
- Use interfaces for data structures, types for unions
- Prefer `const` over `let`, never use `var`
**Architecture**
- Services (`src/services/`) handle data fetching and business logic
- Components (`src/components/`) handle UI rendering
- Config (`src/config/`) contains static data and constants
- Utils (`src/utils/`) contain shared helper functions
**Security**
- Always use `escapeHtml()` when rendering user-controlled or external data
- Use `sanitizeUrl()` for any URLs from external sources
- Validate and clamp parameters in API proxy endpoints
**Performance**
- Expensive computations should run in the Web Worker
- Use virtual scrolling for lists with 50+ items
- Implement circuit breakers for external API calls
**No Comments Policy**
- Code should be self-documenting through clear naming
- Only add comments for non-obvious algorithms or workarounds
- Never commit commented-out code
@@ -3954,31 +3825,26 @@ This project follows specific patterns to maintain consistency:
### Types of Contributions
**🐛 Bug Fixes**
- Found something broken? Fix it and submit a PR
- Include steps to reproduce in the PR description
**✨ New Features**
- New data layers (with public API sources)
- UI/UX improvements
- Performance optimizations
- New signal detection algorithms
**📊 Data Sources**
- Additional RSS feeds for news aggregation
- New geospatial datasets (bases, infrastructure, etc.)
- Alternative APIs for existing data
**📝 Documentation**
- Clarify existing documentation
- Add examples and use cases
- Fix typos and improve readability
**🔒 Security**
- Report vulnerabilities via GitHub Issues (non-critical) or email (critical)
- XSS prevention improvements
- Input validation enhancements
-47
View File
@@ -1,47 +0,0 @@
# News Translation Analysis
## Current Architecture
The application fetches news via `src/services/rss.ts`.
- **Mechanism**: Direct HTTP requests (via proxy) to RSS/Atom XML feeds.
- **Processing**: `DOMParser` parses XML client-side.
- **Storage**: Items are stored in-memory in `App.ts` (`allNews`, `newsByCategory`).
## The Challenge
Legacy RSS feeds are static XML files in their original language. There is no built-in "negotiation" for language. To display French news, we must either:
1. Fetch French feeds.
2. Translate English feeds on the fly.
## Proposed Solutions
### Option 1: Localized Feed Discovery (Recommended for "Major" Support)
Instead of forcing translation, we switch the *source* based on the selected language.
- **Implementation**:
- In `src/config/feeds.ts`, change the simple URL string to an object: `url: { en: '...', fr: '...' }` or separate constant lists `FEEDS_EN`, `FEEDS_FR`.
- **Pros**: Zero latency, native content quality, no API costs.
- **Cons**: Hard to find equivalent feeds for niche topics (e.g., specific mil-tech blogs) in all languages.
- **Strategy**: Creating a curated list of international feeds for major categories (World, Politics, Finance) is the most robust & scalable approach.
### Option 2: On-Demand Client-Side Translation
Add a "Translate" button to each news card.
- **Implementation**:
- Click triggers a call to a translation API (Google/DeepL/LLM).
- Store result in a local cache (Map).
- **Pros**: Low cost (only used when needed), preserves original context.
- **Cons**: User friction (click to read).
### Option 3: Automatic Auto-Translation (Not Recommended)
Translating 500+ headlines on every load.
- **Cons**:
- **Cost**: Prohibitive for free/low-cost APIs.
- **Latency**: Massive slowdown on startup.
- **Quality**: Short headlines often translate poorly without context.
## Recommendation
**Hybrid Approach**:
1. **Primary**: Source localized feeds where possible (e.g., Le Monde for FR, Spiegel for DE). This requires a community effort to curate `feeds.json` for each locale.
2. **Fallback**: Keep English feeds for niche tech/intel sources where no alternative exists.
3. **Feature**: Add a "Summarize & Translate" button using the existing LLM worker. The prompt to the LLM (currently used for summaries) can be adjusted to "Summarize this in [Current Language]".
## Next Steps
1. Audit `src/config/feeds.ts` to structure it for multi-language support.
2. Update `rss.ts` to select the correct URL based on `i18n.language`.
+1
View File
@@ -77,6 +77,7 @@ Bundler targets are pinned in both Tauri configs and enforced by packaging scrip
- macOS: `app,dmg`
- Windows: `nsis,msi`
## Rust dependency modes (online vs restricted network)
From `src-tauri/`, the project supports two packaging paths:
-3
View File
@@ -1,7 +1,6 @@
# Tauri Validation Report
## Scope
Validated desktop build readiness for the World Monitor Tauri app by checking frontend compilation, TypeScript integrity, and Tauri/Rust build execution.
## Preflight checks before desktop validation
@@ -26,7 +25,6 @@ If any of these checks fail, treat downstream desktop build failures as environm
5. `cargo check` (from `src-tauri/`) — failed because the environment blocks downloading crates from `https://index.crates.io` (`403 CONNECT tunnel failed`).
## Assessment
- The web app portion compiles successfully.
- Full Tauri desktop validation in this run is blocked by an **external environment outage/restriction** (registry access denied with HTTP 403).
- No code/runtime defects were observed in project sources during this validation pass.
@@ -44,7 +42,6 @@ Use these labels in future reports so outcomes are actionable:
- Action: provision offline artifacts/mirror config first, enable offline override (`config.local.toml` or CLI `--config`), then rerun.
## Next action to validate desktop end-to-end
Choose one supported path:
- Online path:
-188
View File
@@ -1,188 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
/**
* React-entry CORE cutover gate (world.hanzo.ai React + @hanzo/gui surface).
*
* These specs drive the REAL React entry (index.react.html), not a harness, and prove
* the surface is cutover-ready:
* 1. shell/auth mounts HanzoAppHeader + variant tabs render.
* 2. globe island renders GlobeIsland instantiates the existing MapContainer
* (a WebGL canvas inside #mapContainer).
* 3. variant filter switching a tab swaps the visible panel set (the
* rail's per-variant filter cloud-only vs world-only).
* 4. shared panel-order a pre-seeded `panel-order` layout is honoured (the
* cross-surface persistence contract with the vanilla app).
* 5. drag reorder a real pointer drag reorders the grid and writes the
* SHARED `panel-order` key.
* 6. a panel's live fetch MarketsPanel issues its live `/v1/world/finnhub` fetch
* (mocked same-origin) and renders the returned rows.
*
* The React dev server serves the React entry at /index.react.html (/, is the vanilla
* app), so every navigation targets that path.
*/
const REACT = '/index.react.html';
// Wait for the lazy PanelRail chunk to stream in and at least one panel to mount.
async function waitForRail(page: Page): Promise<void> {
await expect(page.locator('.panels-grid [data-panel]').first()).toBeVisible({ timeout: 30000 });
}
test.describe('React entry — shell + globe', () => {
test('unified shell + variant tabs mount', async ({ page }) => {
await page.goto(REACT);
// The header view-switcher is the anchor the shell hangs off — role=tablist with
// the six canonical variant tabs (Cloud · AI · Crypto · Finance · Tech · World).
const tablist = page.getByRole('tablist', { name: 'View switcher' });
await expect(tablist).toBeVisible();
for (const label of ['Cloud', 'AI', 'Crypto', 'Finance', 'Tech', 'World']) {
await expect(tablist.getByRole('tab', { name: label, exact: true })).toBeVisible();
}
});
test('globe island renders a WebGL canvas', async ({ page }) => {
await page.goto(REACT);
// GlobeIsland mounts <div id="mapContainer"> then instantiates MapContainer, which
// creates the WebGL canvas inside it. The flagship Cloud variant OPENS on the native
// 3D globe and parks the mapbox 2D map (its canvas mounts HIDDEN behind the globe),
// so assert the canvas is ATTACHED with real render dimensions rather than visible —
// the globe engine having mounted a sized WebGL surface is the contract here.
const host = page.locator('#mapContainer');
await expect(host).toBeVisible();
const canvas = host.locator('canvas').first();
await expect(canvas).toBeAttached({ timeout: 45000 });
const size = await canvas.evaluate((c) => ({
w: (c as HTMLCanvasElement).width,
h: (c as HTMLCanvasElement).height,
}));
expect(size.w).toBeGreaterThan(0);
expect(size.h).toBeGreaterThan(0);
});
});
test.describe('React entry — variant filter', () => {
test('switching tabs swaps the visible panel set', async ({ page }) => {
// Default host variant is cloud. The live-traffic globe tile is a cloud panel and
// NOT a world panel; Country Instability (cii) is a world panel and NOT cloud.
await page.goto(REACT);
await waitForRail(page);
await expect(page.locator('[data-panel="traffic-globe"]')).toHaveCount(1);
await expect(page.locator('[data-panel="cii"]')).toHaveCount(0);
// One-switch path: click the World tab; the rail re-filters to the full set.
await page.getByRole('tab', { name: 'World', exact: true }).click();
await expect(page.locator('[data-panel="cii"]')).toHaveCount(1, { timeout: 15000 });
await expect(page.locator('[data-panel="traffic-globe"]')).toHaveCount(0);
});
});
test.describe('React entry — shared panel-order', () => {
test('a pre-seeded panel-order layout is honoured', async ({ page }) => {
// The React grid reads the SAME `panel-order` localStorage key the vanilla app
// writes. Seed a specific first panel BEFORE load, then assert the grid renders it
// first — the cross-surface persistence contract.
await page.addInitScript(() => {
// World variant so the seeded ids are all in the visible set.
window.localStorage.setItem('worldmonitor-variant', 'full');
window.localStorage.setItem(
'panel-order',
JSON.stringify(['strategic-risk', 'markets', 'cii', 'economic']),
);
});
await page.goto(REACT);
await waitForRail(page);
const firstPanel = page.locator('.panels-grid > [data-panel]').first();
await expect(firstPanel).toHaveAttribute('data-panel', 'strategic-risk', { timeout: 15000 });
});
});
test.describe('React entry — drag reorder', () => {
test('a pointer drag reorders the grid and writes the shared key', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('worldmonitor-variant', 'full');
window.localStorage.removeItem('panel-order');
});
await page.goto(REACT);
await waitForRail(page);
const grid = page.locator('.panels-grid');
const order = async (): Promise<string[]> =>
grid.evaluate((g) =>
Array.from(g.children).map((c) => (c as HTMLElement).dataset.panel ?? ''),
);
const before = await order();
expect(before.length).toBeGreaterThan(2);
// Drag panel[0] down past panel[2]'s midpoint so it commits a reorder. Drive the
// real pointer plumbing (mouse → pointer events in Chromium): press on the panel
// body, cross the 6px threshold, then step down.
const p0 = page.locator(`[data-panel="${before[0]}"]`);
const p2 = page.locator(`[data-panel="${before[2]}"]`);
const b0 = await p0.boundingBox();
const b2 = await p2.boundingBox();
expect(b0 && b2).toBeTruthy();
const startX = b0!.x + b0!.width / 2;
const startY = b0!.y + 14; // near the top (header), off any interactive control
const endY = b2!.y + b2!.height * 0.7;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Cross the drag threshold, then walk down in steps so the reflow tracks the ghost.
await page.mouse.move(startX, startY + 10);
for (let y = startY + 10; y <= endY; y += 24) {
await page.mouse.move(startX, y);
await page.waitForTimeout(30);
}
await page.mouse.move(startX, endY);
await page.mouse.up();
// The engine reordered the DOM on commit and PanelGrid persisted it to the shared
// key. Assert both: the first id moved AND `panel-order` was written.
await expect
.poll(async () => (await order())[0], { timeout: 10000 })
.not.toBe(before[0]);
const saved = await page.evaluate(() =>
JSON.parse(window.localStorage.getItem('panel-order') || '[]'),
);
expect(Array.isArray(saved)).toBe(true);
expect(saved.length).toBeGreaterThan(0);
});
});
test.describe('React entry — a panel live fetch renders', () => {
test('MarketsPanel fetches /v1/world/finnhub and renders rows', async ({ page }) => {
// MarketsPanel is a World-variant panel. Mock its same-origin proxy endpoints so
// the live fetch is deterministic + offline: finnhub echoes every requested symbol
// as a priced quote; the yahoo passthroughs return empty so nothing hits the wire.
await page.route('**/v1/world/finnhub**', async (route) => {
const symbols = (new URL(route.request().url()).searchParams.get('symbols') || '')
.split(',')
.filter(Boolean);
const quotes = symbols.map((symbol, i) => ({
symbol,
price: 100 + i,
changePercent: 1.23,
}));
await route.fulfill({ json: { quotes } });
});
await page.route('**/v1/world/yahoo-batch**', (route) => route.fulfill({ json: {} }));
await page.route('**/v1/world/yahoo-finance**', (route) => route.fulfill({ json: {} }));
await page.addInitScript(() => window.localStorage.setItem('worldmonitor-variant', 'full'));
await page.goto(REACT);
await waitForRail(page);
const markets = page.locator('[data-panel="markets"]');
await expect(markets).toHaveCount(1);
// The chassis renders the title uppercased; the live fetch leaves the loading state
// and paints priced rows ($NNN.NN) from the mocked quotes.
await expect(markets.getByText(/\$\d/).first()).toBeVisible({ timeout: 20000 });
});
});
-130
View File
@@ -1,130 +0,0 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Analyst control-surface e2e: signed-out shows the sign-in prompt (unchanged
* behaviour), and a signed-in command round-trip a stubbed AI response carrying
* a {type:"move_panel"} command flows transport dispatcher AppHost and the
* panel visibly moves, with a action-log entry in the chat. No real inference:
* the analyst + models routes are stubbed so the test is deterministic offline.
*/
const SCREENS = 'e2e/screens';
// Stub every /v1/world/* call the chat touches so the flow is deterministic.
async function stubWorldApi(page: Page): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
reply: 'Done — moved Markets to the top and switched to the light theme.',
actions: [
{ type: 'move_panel', key: 'markets', position: 'top' },
{ type: 'set_theme', theme: 'light' },
],
model: 'zen5',
tokens: 42,
}),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'zen5', label: 'Zen 5', group: 'Zen' },
{ id: 'zen5-flash', label: 'Zen 5 Flash', group: 'Zen' },
{ id: 'zen3-omni', label: 'Zen 3 Omni', group: 'Zen' },
],
default: 'zen5',
}),
});
}
// Everything else (context feeds) → empty, so collectContext degrades quietly.
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
await page.waitForSelector('[data-panel="markets"]', { timeout: 60_000 });
}
async function panelOrder(page: Page): Promise<string[]> {
return page.$$eval('#panelsGrid [data-panel]', (els) =>
els.map((e) => (e as HTMLElement).dataset.panel || ''),
);
}
test.describe('AI analyst control surface', () => {
test('signed-out: opening the dock shows the sign-in prompt (unchanged)', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
await page.click('.hzc-fab');
const prompt = page.locator('.hzc-body .hzc-signedout');
await expect(prompt).toBeVisible();
await expect(prompt.locator('.hzc-signin')).toHaveText(/sign in/i);
// No chat composer for anonymous users.
await expect(page.locator('.hzc-body .hzc-input')).toHaveCount(0);
await page.screenshot({ path: `${SCREENS}/analyst-signedout.png` });
});
test('signed-in: a stubbed command round-trip moves a panel + logs the action', async ({ page }) => {
await signIn(page);
await stubWorldApi(page);
await appReady(page);
const before = await panelOrder(page);
const beforeIdx = before.indexOf('markets');
expect(beforeIdx).toBeGreaterThan(1); // markets starts well down the grid
await page.screenshot({ path: `${SCREENS}/round-trip-before.png` });
// Open the analyst dock and confirm the model picker populated.
await page.click('.hzc-fab');
const composer = page.locator('.hzc-body .hzc-input');
await expect(composer).toBeVisible();
// The model picker is now a pill that opens a popover listbox (portaled to
// <body>), not a native <select>. Its label reflects the default (zen5)…
const modelBtn = page.locator('.hzc-body .hzc-model');
await expect(modelBtn).toBeVisible();
await expect(modelBtn.locator('.hzc-model-name')).toHaveText('Zen 5');
// …and opening it lists all three stubbed models.
await modelBtn.click();
await expect(page.locator('.hzc-model-menu .hzc-model-opt')).toHaveCount(3);
await modelBtn.click(); // close the popover
// Send a command-style message; the stub returns the move+theme commands.
await composer.fill('Move markets to the top and go light');
await page.click('.hzc-body .hzc-send');
// The prose reply renders…
await expect(page.locator('.hzc-body .hzc-row.assistant')).toContainText('moved Markets');
// …and the per-command action log shows a ✓ for the executed move.
const log = page.locator('.hzc-body .hzc-actionlog .hzc-action.ok');
await expect(log.first()).toBeVisible();
await expect(log.first()).toContainText(/moved markets/i);
// The panel VISIBLY moved: markets is now near the top of the grid.
await expect
.poll(async () => (await panelOrder(page)).indexOf('markets'), { timeout: 15_000 })
.toBeLessThan(beforeIdx);
// And the theme command took effect (whole-app visible change).
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.screenshot({ path: `${SCREENS}/round-trip-after.png` });
});
});
-147
View File
@@ -1,147 +0,0 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Full app control from the chat widget.
*
* The analyst's control surface is the app-command registry (services/app-commands.ts)
* driven through the AppHost port. Until now it could show/hide/move panels and
* touch the map, but it could NOT change the layout mode, add a topic to monitor,
* or drive the Watch Queue so "reconfigure the layout and all widgets from the
* chat" was not actually true.
*
* Each test stubs the analyst to return ONE command and asserts the app really
* changed the command is dispatched through the same path a real model reply
* takes. No mocked host, no unit-test stand-in for the DOM.
*/
const SCREENS = 'e2e/screens';
async function stubAnalyst(page: Page, actions: unknown[], reply = 'Done.'): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply, actions, model: 'zen5', tokens: 0, traces: [] }),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ models: [{ id: 'zen5', name: 'Zen 5' }], default: 'zen5' }),
});
}
// Monitors: signed-in sync endpoints. Keep them empty + accepting.
if (url.includes('/v1/world/monitors')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ monitors: [], matches: [], ok: true }),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('.panel', { timeout: 30_000 });
}
/** Send any message; the stub decides which command comes back. */
// Two AnalystChats exist (the in-grid panel and the dock) — they share the SAME
// code path, so drive the dock's explicitly rather than matching both.
async function ask(page: Page, text: string): Promise<void> {
await page.click('.hzc-fab');
const dock = page.locator('.hzc-panel');
await expect(dock).toBeVisible();
const composer = dock.locator('.hzc-input');
await expect(composer).toBeVisible();
await composer.fill(text);
await dock.locator('.hzc-send').click();
}
test.describe('analyst full app control', () => {
test('set_layout_mode switches the app into immersive', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'set_layout_mode', mode: 'immersive' }]);
await appReady(page);
await expect(page.locator('body')).not.toHaveClass(/immersive/);
await ask(page, 'go immersive');
// The app really entered immersive (body class is the mode's source of truth)…
await expect(page.locator('body')).toHaveClass(/immersive/);
// …and the dock select agrees — the AI and the UI cannot disagree about mode.
await expect(page.locator('#dockModeSelect')).toHaveValue('immersive');
await page.screenshot({ path: `${SCREENS}/analyst-immersive.png` });
});
test('add_monitor makes the analyst able to watch a new topic', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'add_monitor', keywords: 'nvidia, gpu' }]);
await appReady(page);
await ask(page, 'watch for nvidia and gpu');
// The monitor list really gained the topic (the panel renders it).
const monitors = page.locator('#monitorsList');
await expect(monitors).toContainText(/nvidia/i);
await expect(monitors).toContainText(/gpu/i);
// And it persisted through the ONE monitor path (localStorage mirror).
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('worldmonitor-monitors') || '[]'),
);
expect(JSON.stringify(stored)).toContain('nvidia');
});
test('queue_next advances the Watch Queue', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'queue_next' }]);
await appReady(page);
// Seed two items straight into the queue's own store, then let the analyst advance it.
await page.evaluate(() => {
localStorage.setItem(
'hanzo-world-watch-queue',
JSON.stringify({
items: [
{ id: 'a', kind: 'video', title: 'First talk', source: 'X', ref: 'aaaaaaaaaaa', addedAt: 1, status: 'queued' },
{ id: 'b', kind: 'video', title: 'Second talk', source: 'X', ref: 'bbbbbbbbbbb', addedAt: 2, status: 'queued' },
],
currentId: 'a',
}),
);
});
await page.reload();
await page.waitForSelector('.panel', { timeout: 30_000 });
await ask(page, 'next video');
// 'a' is finished and 'b' is now current — tracked consumption, driven by the AI.
await expect
.poll(async () =>
page.evaluate(() => {
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
return q.currentId;
}),
)
.toBe('b');
const statusOfA = await page.evaluate(() => {
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
return (q.items || []).find((i: { id: string }) => i.id === 'a')?.status;
});
expect(statusOfA).toBe('watched');
});
});
-137
View File
@@ -1,137 +0,0 @@
import { expect, test } from '@playwright/test';
// SuperAdmin fleet view — the admin-only Cloud console panels on world.hanzo.ai:
// Clusters & Nodes (DOKS nodes per cluster) and GPU Queue (gpu-jobs depth + what
// each worker serves). These prove the CLIENT gate mirrors the server one: the
// panels mount + render live data ONLY for an admin-org owner, and a non-admin
// (org-admin of their own tenant) is DENIED — the panels never mount.
//
// The server independently fail-closes 403 (handlers_cloud_admin_infra_test.go);
// here we drive the real app with the IAM userinfo + the /v1/world/cloud/* reads
// mocked to their real shapes, so the render + gating are exercised end-to-end.
const now = new Date().toISOString();
const CLUSTERS = {
available: true,
updatedAt: now,
note: 'Live DOKS + BYO clusters from visor.',
totals: { clusters: 2, nodes: 5, nodesReady: 4, gpus: 2 },
clusters: [
{
id: 'c-hanzo', name: 'hanzo-k8s', region: 'sfo3', status: 'running', kind: 'managed',
nodes: 3, nodesReady: 2, gpus: 2,
pools: [{ name: 'pool-gpu', size: 'gpu-l40', count: 2, autoScale: true, minNodes: 1, maxNodes: 4 }],
nodeList: [
{ id: 'n1', name: 'hanzo-k8s-1', status: 'active', type: 's-8vcpu-16gb', region: 'sfo3', gpu: '' },
{ id: 'n2', name: 'hanzo-k8s-2', status: 'active', type: 'gpu-l40', region: 'sfo3', gpu: 'L40S' },
{ id: 'n3', name: 'hanzo-k8s-3', status: 'provisioning', type: 'gpu-l40', region: 'sfo3', gpu: 'L40S' },
],
},
{
id: 'c-adnexus', name: 'adnexus-k8s', region: 'sfo3', status: 'running', kind: 'managed',
nodes: 2, nodesReady: 2, gpus: 0, pools: [],
nodeList: [
{ id: 'a1', name: 'adnexus-k8s-1', status: 'active', type: 's-4vcpu-8gb', region: 'sfo3', gpu: '' },
{ id: 'a2', name: 'adnexus-k8s-2', status: 'active', type: 's-4vcpu-8gb', region: 'sfo3', gpu: '' },
],
},
],
};
const QUEUE = {
available: true,
updatedAt: now,
note: 'Live GPU job queue (gpu-jobs).',
namespace: 'gpu-jobs',
depth: { pending: 1, running: 2, done: 1, failed: 1, canceled: 0 },
workers: { online: 2, total: 3 },
services: [
{ service: 'studio', pending: 1, running: 1 },
{ service: 'engine', pending: 0, running: 1 },
],
running: [
{ id: 'job-1', type: 'studio.render', service: 'studio', status: 'running', worker: 'evo', model: 'flux.1', attempt: 1, startedAt: now, closedAt: '' },
{ id: 'job-2', type: 'engine.serve', service: 'engine', status: 'running', worker: 'spark', model: 'qwen3-32b', attempt: 1, startedAt: now, closedAt: '' },
],
pending: [
{ id: 'job-3', type: 'studio.render', service: 'studio', status: 'pending', worker: '', model: 'flux.1', attempt: 0, startedAt: '', closedAt: '' },
],
recent: [
{ id: 'job-4', type: 'echo', service: 'echo', status: 'done', worker: '', model: '', attempt: 1, startedAt: '', closedAt: now },
{ id: 'job-5', type: 'studio.render', service: 'studio', status: 'failed', worker: '', model: '', attempt: 2, startedAt: '', closedAt: now },
],
};
// Seed a live (non-expired) IAM session so getToken()/isAuthenticated() resolve
// without a redirect, then let the userinfo route decide the owner (admin vs not).
async function seedSession(page: import('@playwright/test').Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000));
});
}
async function mockUserinfo(page: import('@playwright/test').Page, owner: string): Promise<void> {
await page.route('**/v1/iam/oauth/userinfo', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ sub: 'z', owner, email: 'z@hanzo.ai', name: 'Z' }) }),
);
}
async function mockFleetReads(page: import('@playwright/test').Page): Promise<void> {
await page.route('**/v1/world/cloud/clusters', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CLUSTERS) }));
await page.route('**/v1/world/cloud/queue', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(QUEUE) }));
}
test.describe('SuperAdmin fleet view', () => {
test('admin sees Clusters & Nodes + GPU Queue with live data', async ({ page }) => {
await seedSession(page);
await mockUserinfo(page, 'admin');
await mockFleetReads(page);
await page.goto('/?variant=cloud');
// Clusters panel mounts only for an admin owner, and renders the real DOKS
// clusters grouped by cluster with their node status.
const clusters = page.locator('#panelsGrid [data-panel="cloud-clusters"]');
await expect(clusters).toBeVisible({ timeout: 30000 });
await expect(clusters).toContainText('hanzo-k8s');
await expect(clusters).toContainText('adnexus-k8s');
await expect(clusters).toContainText('hanzo-k8s-2'); // a node row
await expect(clusters).toContainText('L40S'); // GPU node spec
await expect(clusters.locator('.cloud-cluster-group')).toHaveCount(2);
// Queue panel: depth + what's running, from which service, on which worker.
const queue = page.locator('#panelsGrid [data-panel="cloud-queue"]');
await expect(queue).toBeVisible();
await expect(queue).toContainText('gpu-jobs');
await expect(queue).toContainText('studio.render');
await expect(queue).toContainText('engine.serve');
await expect(queue).toContainText('spark'); // claiming worker
await expect(queue).toContainText('qwen3-32b'); // the model that worker serves
await expect(queue.locator('.cloud-queue-job').first()).toBeVisible();
await clusters.scrollIntoViewIfNeeded();
await clusters.screenshot({ path: 'e2e-shots/superadmin-clusters.png' });
await queue.scrollIntoViewIfNeeded();
await queue.screenshot({ path: 'e2e-shots/superadmin-queue.png' });
});
test('non-admin (org-admin only) is DENIED — panels never mount', async ({ page }) => {
await seedSession(page);
await mockUserinfo(page, 'acme'); // a real tenant, NOT the reserved admin org
await mockFleetReads(page); // even with data mocked, the gate must hold
await page.goto('/?variant=cloud');
// The always-mounted cloud overview confirms the grid built for this signed-in
// non-admin, so the absence of the admin panels below is a real deny, not a race.
await expect(page.locator('#panelsGrid [data-panel="cloud-overview"]')).toBeVisible({ timeout: 30000 });
await page.waitForTimeout(1500); // let any async admin-mount attempt resolve
await expect(page.locator('#panelsGrid [data-panel="cloud-clusters"]')).toHaveCount(0);
await expect(page.locator('#panelsGrid [data-panel="cloud-queue"]')).toHaveCount(0);
});
});
-155
View File
@@ -1,155 +0,0 @@
import { expect, test } from '@playwright/test';
import { clickMapControl } from './helpers/map-controls';
// Cloud mode: on world.hanzo.ai (and local dev — a hanzo brand host) the H logo is a
// toggle that reveals the [Hanzo | World | AI | Crypto | Finance | Tech] switcher,
// and the flagship `hanzo` view ships the live-traffic globe layer. This spec drives
// the real header + map on the main app, plus the TrafficGlobePanel in isolation.
const TRAFFIC = {
updatedAt: '2026-07-16T12:00:00Z',
live: true,
window: { minutes: 60, since: '2026-07-16T11:00:00Z', until: '2026-07-16T12:00:00Z' },
points: [
{ country: 'US', region: 'CA', lat: 36.12, lon: -119.68, count: 42, byService: { chat: 40, media: 2 } },
{ country: 'GB', lat: 55.38, lon: -3.44, count: 12, byService: { models: 12 } },
{ country: 'DE', lat: 51.17, lon: 10.45, count: 7, byService: { embeddings: 7 } },
],
totals: { rps_1m: 0.7, rpm_60m: 0.35, top_countries: [{ country: 'US', count: 42 }, { country: 'GB', count: 12 }, { country: 'DE', count: 7 }] },
};
test.describe('Cloud mode', () => {
test('H logo reveals the switcher; cloud view ships the live-traffic globe layer', async ({ page }) => {
// Feed the globe layer real points so its poll resolves (avoids a 404 empty state).
await page.route('**/v1/world/cloud/traffic-globe', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(TRAFFIC) }),
);
await page.goto('/?variant=cloud');
// The H logo renders as the Hanzo-mode toggle on this (hanzo brand) host.
const hLogo = page.locator('[data-hanzo-toggle]');
await expect(hLogo).toBeVisible();
// Flagship default: the switcher starts collapsed (revealed by the H, not shown
// by default), so the clean globe view leads.
const switcher = page.locator('.variant-switcher');
await expect(switcher).toBeHidden();
// Click the H → Cloud mode reveals the switcher.
await hLogo.click();
await expect(page.locator('.header.hanzo-mode')).toHaveCount(1);
await expect(switcher).toBeVisible();
await expect(hLogo).toHaveAttribute('aria-expanded', 'true');
// Exactly the [Cloud | World | AI | Crypto | Finance | Tech] tabs, Cloud first.
await expect(switcher.locator('.variant-option')).toHaveCount(6);
await expect(switcher.locator('.variant-option').first()).toHaveAttribute('data-variant', 'cloud');
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(1);
// Click the H again → collapses.
await hLogo.click();
await expect(switcher).toBeHidden();
// The globe's native traffic layer is wired into the layer-toggle system and ON
// by default in the hanzo view — i.e. the globe layer is toggleable.
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
const trafficToggle = page.locator('.layer-toggle[data-layer="traffic"]');
await expect(trafficToggle).toHaveCount(1);
await expect(trafficToggle.locator('input[type="checkbox"]')).toBeChecked();
});
test('TrafficGlobePanel renders live throughput + top origins, and an honest empty state', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
// Live payload → throughput tiles + ranked origin countries.
const live = await page.evaluate(async (payload) => {
const orig = window.fetch;
window.fetch = (async () => ({ ok: true, status: 200, json: async () => payload })) as typeof window.fetch;
const { TrafficGlobePanel } = await import('/src/components/TrafficGlobePanel.ts');
const panel = new TrafficGlobePanel();
const root = panel.getElement();
document.body.appendChild(root);
const deadline = Date.now() + 3000;
while (Date.now() < deadline && root.querySelectorAll('.traffic-row').length === 0) {
await new Promise((r) => setTimeout(r, 30));
}
const text = root.textContent ?? '';
const rows = root.querySelectorAll('.traffic-row').length;
panel.destroy(); root.remove(); window.fetch = orig;
return { text, rows };
}, TRAFFIC);
expect(live.rows).toBe(3);
expect(live.text).toContain('US');
expect(live.text).toContain('requests / sec');
expect(live.text.toLowerCase()).toContain('active regions');
// Empty payload → honest zero state, never fabricated numbers.
const empty = await page.evaluate(async () => {
const orig = window.fetch;
const EMPTY = { updatedAt: '', live: false, window: { minutes: 60, since: '', until: '' }, points: [], totals: { rps_1m: 0, rpm_60m: 0, top_countries: [] } };
window.fetch = (async () => ({ ok: true, status: 200, json: async () => EMPTY })) as typeof window.fetch;
const { TrafficGlobePanel } = await import('/src/components/TrafficGlobePanel.ts');
const panel = new TrafficGlobePanel();
const root = panel.getElement();
document.body.appendChild(root);
const deadline = Date.now() + 3000;
while (Date.now() < deadline && !(root.textContent ?? '').includes('No live traffic')) {
await new Promise((r) => setTimeout(r, 30));
}
const text = root.textContent ?? '';
panel.destroy(); root.remove(); window.fetch = orig;
return text;
});
expect(empty).toContain('No live traffic yet');
});
test('3D globe settles (no idle-spin flicker) + Cloud-accurate legend, no cables assertion', async ({ page }) => {
await page.route('**/v1/world/cloud/traffic-globe', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(TRAFFIC) }),
);
const errors: string[] = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto('/?variant=cloud');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
// Legend must MATCH what the globe plots — the Cloud data classes, not the
// geopolitical default (which would mislabel a traffic dot as "high alert").
const legend = page.locator('.deckgl-legend');
await expect(legend).toBeVisible();
const legendText = (await legend.innerText()).toLowerCase();
for (const cls of ['request origin', 'validator node', 'gpu fleet', 'cloud region', 'datacenter']) {
expect(legendText).toContain(cls);
}
for (const geo of ['high alert', 'nuclear', 'elevated']) {
expect(legendText).not.toContain(geo);
}
// Switch to the 3D globe (GlobeNative is exposed on window in dev/e2e).
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 20000 })
.toBe(true);
// Idle auto-rotate is OFF, so the globe SETTLES: the camera longitude does not
// drift and the deck render loop idles (a few data-sync renders, not ~40/s). A
// perpetually-spinning globe re-rasterizes thin vector lines every frame — that is
// the shimmer; a settled globe does not.
const settled = await page.evaluate(async () => {
const g = (window as unknown as {
__globeNative: { getCenter: () => { lon: number }; getDeck: () => { setProps: (p: unknown) => void } };
}).__globeNative;
const c0 = g.getCenter();
let renders = 0;
g.getDeck().setProps({ onAfterRender: () => { renders++; } });
await new Promise((r) => setTimeout(r, 1300));
return { lonDelta: Math.abs(g.getCenter().lon - c0.lon), renders };
});
expect(settled.lonDelta).toBeLessThan(0.001); // no idle auto-rotate drift
expect(settled.renders).toBeLessThan(12); // render loop idles (vs ~40/s spinning)
// The broken cables PathLayer must not assert (cables off in Cloud + path guarded).
expect(errors.filter((e) => /cables-layer|assertion failed/i.test(e))).toEqual([]);
});
});
-182
View File
@@ -1,182 +0,0 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Context-menu + analyst-data-tool e2e.
*
* 1. Right-click a news-shaped item (the exact data-ctx-* convention NewsPanel
* emits) the custom menu shows Open link / Copy link / Copy headline plus
* the panel baseline; Copy actually writes to the clipboard.
* 2. Right-click the live map the map menu shows Copy coordinates / Fly here /
* the 2D-3D toggle (via the MapContainer capability port).
* 3. A stubbed analyst response carrying a data-tool trace renders the collapsed
* "🔧 world_brief(...)" line before the grounded reply.
*
* No real inference or feeds: the analyst/models routes are stubbed and the news
* item is injected into a live panel so the menu code runs against production DOM.
*/
const SCREENS = 'e2e/screens';
async function stubWorldApi(page: Page, analystBody?: unknown): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
analystBody ?? { reply: '', actions: [], model: 'zen5', tokens: 0, traces: [] },
),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [{ id: 'zen5', label: 'Zen 5', group: 'Zen' }],
default: 'zen5',
}),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
}
// Read the current custom menu's item labels (buttons only, not separators/labels).
async function menuItems(page: Page): Promise<string[]> {
return page.$$eval('#panelContextMenu .panel-context-menu-item', (els) =>
els.map((e) => (e.textContent || '').trim()),
);
}
test.describe('right-click context menus', () => {
test('news item → Open link / Copy link / Copy headline (+ clipboard)', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await stubWorldApi(page);
await appReady(page);
// Add a faithful news item (exactly the data-ctx-* attributes NewsPanel
// emits) inside its own panel appended to the grid. A synthetic panel is used
// so a live panel's periodic re-render can't wipe the item mid-test, and so it
// is not under the map canvas — the menu code path is identical either way.
// The app defaults to FREE layout, where a raw injected panel (no coordinates)
// stacks at 0,0 under the full-width map and the map eats the right-click; pin
// grid mode so the injected panel flows to the grid end as a hit-testable child.
await page.evaluate(() =>
(window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'),
);
await page.waitForTimeout(80);
await page.evaluate(() => {
const grid = document.querySelector('#panelsGrid')!;
const panel = document.createElement('div');
panel.className = 'panel';
panel.dataset.panel = 'e2e-test-panel';
panel.innerHTML =
'<div class="panel-content"><div class="item" id="e2e-news-item" ' +
'data-ctx-url="https://example.com/story" data-ctx-headline="Breaking test headline" ' +
'style="padding:12px;min-height:24px;">Breaking test headline</div></div>';
grid.appendChild(panel);
});
await page.locator('#e2e-news-item').scrollIntoViewIfNeeded();
await page.locator('#e2e-news-item').click({ button: 'right' });
await expect(page.locator('#panelContextMenu')).toBeVisible();
const items = await menuItems(page);
expect(items).toEqual(expect.arrayContaining(['Open link', 'Copy link', 'Copy headline']));
// Baseline panel actions still ride below the component items.
expect(items).toEqual(expect.arrayContaining(['Hide panel', 'Reset layout', 'Full']));
await page.screenshot({ path: `${SCREENS}/ctxmenu-news.png` });
// Copy headline actually writes to the clipboard.
await page.locator('#panelContextMenu .panel-context-menu-item', { hasText: 'Copy headline' }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe('Breaking test headline');
// Menu dismisses after an action.
await expect(page.locator('#panelContextMenu')).toHaveCount(0);
});
test('map → Copy coordinates / Fly here / 2D-3D toggle', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
// The map is a first-class grid citizen in the full app; wait for its canvas.
await page.waitForSelector('.map-container canvas', { timeout: 60_000 });
// A stationary right-click on the map surface opens the map menu (a rotate
// drag would instead keep the maplibre gesture — not exercised here).
await expect
.poll(
async () => {
await page.locator('.map-container canvas').first().click({ button: 'right', position: { x: 300, y: 180 } });
if (!(await page.locator('#panelContextMenu').count())) return [];
return menuItems(page);
},
{ timeout: 30_000 },
)
.toEqual(expect.arrayContaining(['Copy coordinates', 'Fly here']));
const items = await menuItems(page);
// The 2D-3D toggle is present (label depends on current projection).
expect(items.some((l) => /Switch to (2D map|3D globe)/.test(l))).toBe(true);
await page.screenshot({ path: `${SCREENS}/ctxmenu-map.png` });
});
});
test.describe('analyst data-tool traces', () => {
test('a stubbed tool round-trip renders the 🔧 trace + grounded reply', async ({ page }) => {
await signIn(page);
await stubWorldApi(page, {
reply: 'Composite instability is steady; Sudan and Ukraine lead the movers.',
actions: [],
model: 'zen5',
tokens: 128,
traces: [
{
label: 'world_brief({"n":5})',
ok: true,
result: '{"asOf":"2026-07-09T00:00:00Z","kind":"country","top":[{"iso":"SD","instability":0.82}]}',
},
],
});
await appReady(page);
await page.click('.hzc-fab');
const composer = page.locator('.hzc-body .hzc-input');
await expect(composer).toBeVisible();
await composer.fill('What is the state of global instability?');
await page.click('.hzc-body .hzc-send');
// The tool trace renders before the reply that cites it; its summary carries
// the tool call (the redesign shows a database glyph, not the 🔧 emoji).
const trace = page.locator('.hzc-body .hzc-tool .hzc-tool-summary');
await expect(trace).toBeVisible();
await expect(trace).toContainText('world_brief(');
// The detail renders open, so the raw result body — a compact table of the
// tool's JSON — is visible inline and carries the returned instability field.
await expect(page.locator('.hzc-body .hzc-tool .hzc-table')).toContainText('instability');
// …and the grounded prose reply is shown.
await expect(page.locator('.hzc-body .hzc-row.assistant')).toContainText('instability is steady');
await page.screenshot({ path: `${SCREENS}/analyst-tool-trace.png` });
});
});
-218
View File
@@ -1,218 +0,0 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Country application-view e2e: clicking a country opens a FULLSCREEN view with
* the intel content on the left and a docked AI analyst chat on the right
* (desktop), a bottom-sheet on mobile. Escape restores the dashboard; the URL
* reflects the view (?country=FR) via pushState so browser Back closes it.
*
* The open is driven through window.__app.openCountryBriefByCode the EXACT call
* the map click handler makes (map.onCountryClicked openCountryBriefByCode)
* so the test exercises the real click code path deterministically, the same
* window.__app hook the repo's other e2e specs use.
*/
const SCREENS = 'e2e/screens';
async function stubViewApi(page: Page): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'zen5', label: 'Zen 5', group: 'Zen' },
{ id: 'zen5-flash', label: 'Zen 5 Flash', group: 'Zen' },
],
default: 'zen5',
}),
});
}
if (url.includes('/v1/world/country-intel')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
brief: 'France remains stable. Monitoring energy policy and labour actions across major cities.',
country: 'France',
code: 'FR',
cached: false,
generatedAt: new Date().toISOString(),
}),
});
}
if (url.includes('/v1/world/stock-index')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ available: true, indexName: 'CAC 40', price: '8000', weekChangePercent: '1.2' }),
});
}
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply: 'Energy policy and labour actions are the current drivers.', actions: [], model: 'zen5', tokens: 12 }),
});
}
// Seed the two risk-required feeds (rss + gdelt) so the deep-link handler's
// dataFreshness.hasSufficientData() gate flips to 'sufficient' offline.
if (url.includes('/v1/world/feeds-batch')) {
const body = route.request().postDataJSON() as { urls?: string[] } | null;
const urls = body?.urls ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
feeds: urls.map((u) => ({
url: u,
ok: true,
items: [{ title: 'Deep-link seed headline', link: 'https://example.com/news', pubDate: new Date().toISOString() }],
})),
}),
});
}
if (url.includes('/v1/world/gdelt-geo')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Paris, France', count: 60 }, geometry: { type: 'Point', coordinates: [2.3522, 48.8566] } },
{ type: 'Feature', properties: { name: 'Lyon, France', count: 40 }, geometry: { type: 'Point', coordinates: [4.8357, 45.764] } },
],
}),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
}
async function openCountry(page: Page, code = 'FR', name = 'France'): Promise<void> {
// __app is exposed before init() finishes, but countryBriefPage is constructed
// later in init() (after loadAllData) — long after #panelsGrid paints. Wait for
// the hook so the open isn't raced to a silent early-return; a real map click
// only ever fires post-boot anyway.
await page.waitForFunction(
() => Boolean((window as unknown as { __app?: { countryBriefPage?: unknown } }).__app?.countryBriefPage),
undefined,
{ timeout: 30_000 },
);
await page.evaluate(
([c, n]) => (window as unknown as { __app: { openCountryBriefByCode(c: string, n: string): Promise<void> } }).__app.openCountryBriefByCode(c, n),
[code, name],
);
await page.waitForSelector('.country-brief-overlay.active', { timeout: 30_000 });
}
test.describe('Country application view', () => {
test('desktop: fullscreen intel + docked analyst sidebar, URL + Escape', async ({ page }) => {
await signIn(page);
await stubViewApi(page);
await appReady(page);
await openCountry(page);
// Fullscreen, not the old centered card: page fills the viewport width.
const page_ = page.locator('.country-brief-page');
await expect(page_).toBeVisible();
const box = await page_.boundingBox();
const vp = page.viewportSize()!;
expect(box!.width).toBeGreaterThan(vp.width - 4); // no horizontal straitjacket
// Intel content on the left…
await expect(page.locator('.cb-main .cb-body')).toBeVisible();
// …docked analyst chat on the right (signed-in → composer, model picker present).
const sidebar = page.locator('.cb-analyst-sidebar');
await expect(sidebar).toBeVisible();
await expect(sidebar.locator('.hzc-input')).toBeVisible();
// Model picker is a pill opening a popover listbox (portaled to <body>); wait
// for the roster to paint, then open it to confirm the two stubbed models.
const modelBtn = sidebar.locator('.hzc-model');
await expect(modelBtn.locator('.hzc-model-name')).toHaveText('Zen 5');
await modelBtn.click();
await expect(page.locator('.hzc-model-menu .hzc-model-opt')).toHaveCount(2);
await modelBtn.click(); // close the popover
// Sidebar is on the right of the intel content.
const mainBox = await page.locator('.cb-main').boundingBox();
const sideBox = await sidebar.boundingBox();
expect(sideBox!.x).toBeGreaterThan(mainBox!.x);
// URL reflects the view (pushState).
expect(page.url()).toContain('country=FR');
await page.screenshot({ path: `${SCREENS}/country-view-desktop.png` });
// Escape restores the dashboard and drops ?country=.
await page.keyboard.press('Escape');
await expect(page.locator('.country-brief-overlay.active')).toHaveCount(0);
expect(page.url()).not.toContain('country=');
});
test('desktop: analyst sidebar collapses and reopens via the pill', async ({ page }) => {
await signIn(page);
await stubViewApi(page);
await appReady(page);
await openCountry(page);
const brief = page.locator('.country-brief-page');
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
await page.click('.cb-analyst-collapse');
await expect(brief).toHaveClass(/cb-analyst-collapsed/);
await expect(page.locator('.cb-analyst-fab')).toBeVisible();
await page.click('.cb-analyst-fab');
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
});
test('mobile 390px: analyst is a bottom sheet, no horizontal scroll', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signIn(page);
await stubViewApi(page);
await appReady(page);
await openCountry(page);
const brief = page.locator('.country-brief-page');
// On mobile the intel leads; the analyst starts collapsed with a reopen pill.
await expect(brief).toHaveClass(/cb-analyst-collapsed/);
await expect(page.locator('.cb-analyst-fab')).toBeVisible();
// No horizontal overflow.
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth);
expect(scrollW).toBeLessThanOrEqual(390 + 1);
await page.screenshot({ path: `${SCREENS}/country-view-mobile-closed.png` });
// Open the bottom sheet.
await page.click('.cb-analyst-fab');
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
await expect(page.locator('.cb-analyst-sidebar .hzc-input')).toBeVisible();
await page.screenshot({ path: `${SCREENS}/country-view-mobile-open.png` });
});
test('deep link: ?country=FR opens the view directly', async ({ page }) => {
await signIn(page);
await stubViewApi(page);
// The deep-link handler waits for sufficient live data before opening; let the
// real proxied feeds load (news + gdelt), then assert the view appears.
await page.goto('/?country=FR');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
await expect(page.locator('.country-brief-overlay.active')).toBeVisible({ timeout: 80_000 });
await expect(page.locator('.cb-analyst-sidebar')).toBeVisible();
expect(page.url()).toContain('country=FR');
});
});
-168
View File
@@ -1,168 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
// The signed-in dashboard-sync contract, verified end-to-end against the REAL app:
// 1. on boot, this identity's server dashboard is written into localStorage
// BEFORE the app reads it (server precedence, cross-device),
// 2. a change to a dashboard key is synced to the server (debounced PUT of the
// full snapshot),
// 3. signed out, nothing ever touches the server (localStorage only).
// IAM is faked (a non-expired token + a stubbed userinfo) and /v1/world/dashboard
// is stubbed, so no real backend or identity is needed.
const DASH = '**/v1/world/dashboard';
const HIST = '**/v1/world/history';
const LN = '[data-panel="live-news"]';
async function fakeAuth(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'faketoken');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'acme');
});
// Keep the fake session valid: a userinfo 401 would let the app drop the token,
// and the sync (correctly) stops when signed out.
await page.route('**/v1/iam/oauth/userinfo', (r) =>
r.fulfill({ json: { sub: 'u1', owner: 'acme', email: 'u@acme.test' } }),
);
// Default empty history so boot's history hydrate never hits the real backend;
// tests that assert on history override this with a later page.route (last wins).
await page.route(HIST, (r) => r.fulfill({ json: { config: {} } }));
}
test.describe('dashboard sync (real app)', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('boot hydrates localStorage from the server (server precedence)', async ({ page }) => {
await fakeAuth(page);
await page.route(DASH, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config: { 'hanzo-world-map-mode': '3d' } } });
} else {
await route.fulfill({ json: { ok: true } });
}
});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
// The server value was applied to localStorage at boot, before the app read it.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
.toBe('3d');
});
test('a dashboard change is synced to the server (debounced PUT of the snapshot)', async ({ page }) => {
await fakeAuth(page);
const puts: Array<Record<string, string>> = [];
await page.route(DASH, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config: {} } }); // empty server → first run
} else {
puts.push(route.request().postDataJSON() as Record<string, string>);
await route.fulfill({ json: { ok: true } });
}
});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await page.waitForTimeout(2000); // let the boot writes flush their debounced PUT first
// Change an observed dashboard key; a debounced PUT carries a snapshot of ALL
// dashboard keys, including our change.
await page.evaluate(() => localStorage.setItem('worldmonitor-layers', '{"quakes":true}'));
await expect
.poll(() => puts.some((p) => p && p['worldmonitor-layers'] === '{"quakes":true}'), { timeout: 8000 })
.toBe(true);
});
test('signed out: never touches the server (localStorage only)', async ({ page }) => {
let hit = false;
await page.route(DASH, async (route) => {
hit = true;
await route.fulfill({ json: { config: {} } });
});
await page.goto('/'); // no fakeAuth → anonymous
await page.waitForSelector(LN, { timeout: 45000 });
await page.evaluate(() => localStorage.setItem('worldmonitor-layers', '{"a":1}'));
await page.waitForTimeout(1200);
expect(hit).toBe(false);
});
test('history keys sync to /v1/world/history, not /dashboard', async ({ page }) => {
await fakeAuth(page);
const dashPuts: string[] = [];
const histPuts: Array<Record<string, string>> = [];
await page.route(DASH, async (route) => {
if (route.request().method() === 'GET') await route.fulfill({ json: { config: {} } });
else {
dashPuts.push(route.request().postData() || '');
await route.fulfill({ json: { ok: true } });
}
});
await page.route(HIST, async (route) => {
if (route.request().method() === 'GET') await route.fulfill({ json: { config: {} } });
else {
histPuts.push(route.request().postDataJSON() as Record<string, string>);
await route.fulfill({ json: { ok: true } });
}
});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await page.waitForTimeout(2000); // flush boot writes first
// A real user action (a recent search) is a HISTORY key → PUT to /history.
await page.evaluate(() => localStorage.setItem('worldmonitor_recent_searches', '["nvidia"]'));
await expect
.poll(() => histPuts.some((p) => p && p['worldmonitor_recent_searches'] === '["nvidia"]'), { timeout: 8000 })
.toBe(true);
// …and it was NOT mixed into a dashboard PUT (clean namespace separation).
expect(dashPuts.some((b) => b.includes('worldmonitor_recent_searches'))).toBe(false);
});
// Org-shared default precedence: the org default is hydrated as the BASE, then the
// user's own doc overlays it. One broad route serves both scopes, branching on URL
// (the `/shared` GET is the org default; the bare GET is the per-user doc).
async function routeDashboardScopes(
page: Page,
shared: Record<string, string>,
user: Record<string, string>,
): Promise<void> {
await page.route('**/v1/world/dashboard**', async (route) => {
if (route.request().method() !== 'GET') {
await route.fulfill({ json: { ok: true } });
return;
}
const isShared = route.request().url().includes('/dashboard/shared');
await route.fulfill({ json: { config: isShared ? shared : user } });
});
}
test('org default hydrates as the base when the user has no doc yet', async ({ page }) => {
await fakeAuth(page);
// Org published a default; this member has never saved their own → they get it.
await routeDashboardScopes(page, { 'hanzo-world-map-mode': '3d' }, {});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
.toBe('3d');
});
test('the per-user doc overrides the org default (user wins)', async ({ page }) => {
await fakeAuth(page);
// Org default says 3d, the user's own doc says 2d → the user's choice wins.
await routeDashboardScopes(page, { 'hanzo-world-map-mode': '3d' }, { 'hanzo-world-map-mode': '2d' });
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
.toBe('2d');
});
test('boot hydrates history from /v1/world/history (server precedence)', async ({ page }) => {
await fakeAuth(page);
await page.route(DASH, (r) => r.fulfill({ json: { config: {} } }));
await page.route(HIST, (r) => r.fulfill({ json: { config: { 'hanzo-world-watch-queue': '{"items":[7]}' } } }));
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-watch-queue')), { timeout: 8000 })
.toBe('{"items":[7]}');
});
});
-61
View File
@@ -1,61 +0,0 @@
import { expect, test } from '@playwright/test';
// Enso Live Training must reflect REAL models (the served catalog), never the opaque
// upstream "arm-N" routing mix that read as random/meaningless amounts. Both feeds are
// stubbed with real-shaped payloads; the router-stats stub deliberately includes an
// arm-N by_model map to prove it is NOT rendered anymore.
const ENSO = '.panel[data-panel="enso-training"]';
const ROUTER_STATS = {
scope: 'platform',
window: { since: '2026-07-17T00:00:00Z', until: '2026-07-18T00:00:00Z', events: 12345 },
cost: { saved_pct: 0.23, cumulative_saved_index: 98765, baseline_model: 'x', priced_events: 100 },
quality: { reward_rate: 0.5, rewarded_events: 10, engine_share: 0.62, avg_confidence: 0.8, shadow_agreement: 0.91 },
by_task: {},
by_model: { 'arm-1': 100, 'arm-2': 50, 'arm-3': 25 }, // opaque arms — must NOT render
throughput: { per_hour: [1, 2, 3, 4, 5, 6], total_window: 21 },
retrain: {
version: 'v9', trained_time: '2026-07-17T12:00:00Z', events: 100, gate_passed: true,
published: true, gate_kind: 'reward', gate_metric: 'rate', gate_value: 0.5, gate_base: 0.4, note: '',
},
};
const CLOUD_MODELS = {
updatedAt: '2026-07-18T00:00:00Z',
totalModels: 42,
zenModels: 8,
cloudRegions: 5,
cloudPlans: 3,
families: ['Zen', 'Fable'],
models: [
{ id: 'zen5', name: 'Zen 5', provider: 'hanzo', tier: 'flagship', context: 200000, inPrice: 3, outPrice: 15 },
{ id: 'zen5-flash', name: 'Zen 5 Flash', provider: 'hanzo', tier: 'fast', context: 128000, inPrice: 0.5, outPrice: 1.5 },
{ id: 'fable5', name: 'Fable 5', provider: 'hanzo', tier: 'premium', context: 400000, inPrice: 5, outPrice: 20 },
],
};
test.describe('Enso Live Training — real models only', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('renders the real served-model catalog, never opaque arm-N amounts', async ({ page }) => {
await page.route('**/v1/world/cloud/router-stats*', (r) => r.fulfill({ json: ROUTER_STATS }));
await page.route('**/v1/world/cloud/models', (r) => r.fulfill({ json: CLOUD_MODELS }));
// Enso Live Training is a Cloud-flagship panel (added only in the cloud
// variant); ?variant=cloud wins over the build-time VITE_VARIANT at runtime.
await page.goto('/?variant=cloud');
await page.waitForSelector(ENSO, { timeout: 45000 });
const panel = page.locator(ENSO);
// Real model names from the catalog appear.
await expect(panel).toContainText('Zen 5 Flash', { timeout: 15000 });
await expect(panel).toContainText('Fable 5');
await expect(panel).toContainText('models served');
// Real aggregates still render (unchanged, real telemetry).
await expect(panel).toContainText('cost saved');
// The opaque per-arm amounts are GONE — no meaningless "Enso arm N" rows.
await expect(panel).not.toContainText('Enso arm');
await expect(panel).not.toContainText('arm-1');
});
});
-94
View File
@@ -1,94 +0,0 @@
import { expect, test } from '@playwright/test';
// Mounts the real EnsoTrainingPanel against stubbed router-stats + served-model
// catalog payloads and asserts the rendered DOM. The redesign REPLACED the opaque
// "Enso arm N" routing mix with the REAL served-model catalog (real names, tiers,
// pricing) — this asserts that new surface: real model rows (never opaque arms,
// never a third-party vendor name), the cost-saved headline, the retrain gate
// verdict, and the honest "—" shadow state. Mirrors the app-level assertions in
// e2e/enso-real-models.spec.ts at the runtime-harness (unit) level.
test.describe('Enso Live Training panel', () => {
test('renders the real served-model catalog, cost-saved headline and retrain gate — no opaque arms, no vendor names', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const ROUTER_STATS = {
scope: 'platform',
window: { since: '2026-07-15T00:00:00Z', until: '2026-07-16T00:00:00Z', events: 48210 },
cost: { saved_pct: 21.5, cumulative_saved_index: 1372, baseline_model: 'arm-1', priced_events: 41000 },
quality: { reward_rate: 0.31, rewarded_events: 1200, engine_share: 0.62, avg_confidence: 0.74, shadow_agreement: null },
by_task: { chat: { events: 30000, models: { 'arm-1': 18000, 'arm-2': 12000 } } },
by_model: { 'arm-1': 30000, 'arm-2': 14000, 'arm-3': 4210 }, // opaque arms — must NOT render
throughput: { per_hour: Array.from({ length: 24 }, (_, i) => 1500 + i * 40), total_window: 48210 },
retrain: {
version: 'router-2026.07.16', trained_time: '2026-07-16T00:00:00Z', events: 48210,
gate_passed: true, published: true, gate_kind: 'holdout', gate_metric: 'reward',
gate_value: 0.312, gate_base: 0.298, note: 'shipped',
},
};
// The REAL served catalog the router trains across — Hanzo's own models only.
const CLOUD_MODELS = {
updatedAt: '2026-07-16T00:00:00Z',
totalModels: 42,
zenModels: 8,
cloudRegions: 5,
cloudPlans: 3,
families: ['Zen', 'Fable'],
models: [
{ id: 'zen5', name: 'Zen 5', provider: 'hanzo', tier: 'flagship', context: 200000, inPrice: 3, outPrice: 15 },
{ id: 'zen5-flash', name: 'Zen 5 Flash', provider: 'hanzo', tier: 'fast', context: 128000, inPrice: 0.5, outPrice: 1.5 },
{ id: 'fable5', name: 'Fable 5', provider: 'hanzo', tier: 'premium', context: 400000, inPrice: 5, outPrice: 20 },
],
};
// Stub the same-origin proxies the panel calls, by endpoint.
const origFetch = window.fetch;
window.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
const body = url.includes('/cloud/models') ? CLOUD_MODELS : ROUTER_STATS;
return { ok: true, status: 200, json: async () => body } as Response;
}) as typeof window.fetch;
const { EnsoTrainingPanel } = await import('/src/components/EnsoTrainingPanel.ts');
const panel = new EnsoTrainingPanel();
const root = panel.getElement();
document.body.appendChild(root);
// Wait for the async fetch → render to land (real model rows appear).
const deadline = Date.now() + 3000;
while (Date.now() < deadline && root.querySelectorAll('.cloud-model-row').length === 0) {
await new Promise((r) => setTimeout(r, 30));
}
const modelNames = Array.from(root.querySelectorAll('.cloud-model-name')).map(
(n) => (n.textContent ?? '').trim(),
);
const text = root.textContent ?? '';
panel.destroy();
root.remove();
window.fetch = origFetch;
return { modelNames, text };
});
// The three REAL served models render by name — never opaque "arm-N".
expect(result.modelNames.length).toBe(3);
expect(result.modelNames[0]).toContain('Zen 5');
expect(result.modelNames[1]).toContain('Zen 5 Flash');
expect(result.modelNames[2]).toContain('Fable 5');
expect(result.text).toContain('models served');
// The opaque per-arm mix is GONE.
expect(result.text).not.toContain('Enso arm');
expect(result.text).not.toContain('arm-1');
// No third-party vendor identity leaks through the served catalog.
for (const vendor of ['claude', 'anthropic', 'gpt', 'openai', 'deepseek', 'qwen', 'gemini', 'llama']) {
expect(result.text.toLowerCase()).not.toContain(vendor);
}
// Headline cost-saved + retrain gate verdict + honest empty shadow state.
expect(result.text).toContain('21.5%');
expect(result.text).toContain('passed');
expect(result.text).toContain('Shadow-vs-served agreement: —');
});
});
-136
View File
@@ -1,136 +0,0 @@
import { expect, test } from '@playwright/test';
// Native deck.gl GlobeView (GlobeNative) — the pure-deck 3D globe that replaces the
// mapbox globe behind the ?globe=native flag.
//
// This guards the structural perf + correctness contract that a headless GPU CAN
// verify:
// - the mode activates from the ?globe=native flag,
// - the whole page holds exactly ONE WebGL context (no mapbox, no overlay) — the
// single-context perf target,
// - deck's active viewport is a GlobeViewport (proves it's reprojecting onto the
// sphere, not a flat map),
// - a seeded hotspot picks at its globe-projected screen point (layers render AND
// reproject on the sphere).
type PickResult = { found: boolean; layerId: string | null };
type GlobeHarness = {
ready: boolean;
nativeEnabled: boolean;
getCanvasCount: () => number;
getViewportType: () => string | null;
getFirstHotspotLngLat: () => { lon: number; lat: number } | null;
setCamera: (lon: number, lat: number, zoom: number) => void;
stopSpin: () => void;
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
setBasemapStyle: (style: 'dark' | 'satellite' | 'terrain') => void;
getBasemapStyle: () => string;
destroy: () => void;
};
type HarnessWindow = Window & { __globeHarness?: GlobeHarness };
type Page = import('@playwright/test').Page;
const ready = async (page: Page, query: string): Promise<void> => {
await page.goto(`/tests/globe-native-harness.html${query}`);
await expect(page.locator('.globe-native-wrapper')).toBeVisible();
await expect
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__globeHarness?.ready)), {
timeout: 45000,
})
.toBe(true);
};
const pickHotspot = async (page: Page): Promise<PickResult> =>
page.evaluate(() => {
const w = (window as HarnessWindow).__globeHarness!;
const hs = w.getFirstHotspotLngLat();
if (!hs) return { found: false, layerId: null };
w.setCamera(hs.lon, hs.lat, 3);
w.stopSpin();
return w.pickAtLonLat(hs.lon, hs.lat, 12);
});
test.describe('native deck.gl GlobeView', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
test('flag activates; single WebGL context; GlobeViewport; hotspot picks on the sphere', async ({
page,
}, testInfo) => {
const pageErrors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i];
page.on('pageerror', (e) => pageErrors.push(e.message));
await ready(page, '?globe=native');
// The flag helper the app routes on reads native from ?globe=native.
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(true);
// Single WebGL context: exactly one <canvas> on the whole page (no mapbox basemap
// canvas, no deck overlay canvas — just GlobeNative's).
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.getCanvasCount()),
).toBe(1);
// Active viewport is a GlobeViewport (deck is reprojecting onto the sphere).
await expect.poll(() =>
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getViewportType()),
).toMatch(/Globe/i);
// A seeded hotspot picks at its globe-projected screen point.
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 20000 }).toBe(true);
// CTO-facing proof: dots + monochrome basemap on the sphere.
await page.evaluate(() => {
const w = (window as HarnessWindow).__globeHarness!;
const hs = w.getFirstHotspotLngLat();
if (hs) w.setCamera(hs.lon, hs.lat, 2.2);
w.stopSpin();
});
await page.waitForTimeout(600);
const shot = await page.locator('.globe-native-wrapper').screenshot();
await testInfo.attach('globe-native-dots', { body: shot, contentType: 'image/png' });
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
test('native is the default; ?globe=mapbox is the escape hatch', async ({ page }) => {
// Default (no query param) → native is on.
await ready(page, '');
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(true);
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
// Escape hatch → native off (the app would render the mapbox globe instead).
await ready(page, '?globe=mapbox');
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(false);
});
test('basemap style switches dark/satellite/terrain, stays single-context, data still picks', async ({
page,
}) => {
await ready(page, '?globe=native');
const setStyle = (s: 'dark' | 'satellite' | 'terrain') =>
page.evaluate((style) => (window as HarnessWindow).__globeHarness!.setBasemapStyle(style), s);
const getStyle = () =>
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getBasemapStyle());
for (const style of ['satellite', 'terrain', 'dark'] as const) {
await setStyle(style);
expect(await getStyle()).toBe(style);
// Draping imagery must not spawn a second WebGL context...
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
// ...and the data layers keep rendering ON TOP (still pickable on the sphere).
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 15000 }).toBe(true);
}
});
});
-152
View File
@@ -1,152 +0,0 @@
import { expect, test } from '@playwright/test';
import { clickMapControl } from './helpers/map-controls';
// P0-1: deck.gl overlays must actually RENDER on the mapbox-gl v3 globe.
//
// The regression this guards: with interleaved:true, MapboxOverlay drew deck
// layers inside mapbox's projection pass, which does NOT reproject onto the
// globe — every overlay silently vanished in 3D (2D was fine). The fix flips the
// overlay to interleaved:false (overlaid), giving deck its own canvas + its own
// _GlobeView derived from the live map projection each frame.
//
// getDeckLayerSnapshot() (used by globe.spec) reads buildLayers() output and so
// passed EVEN WITH THE BUG. This spec instead asserts the renderer mechanism and
// a real on-globe pick — signals that are only true when layers reproject:
// - the overlay is overlaid (interleaved === false) and owns its own canvas,
// - deck's active viewport is a GlobeViewport in 3D (mercator in 2D),
// - picking a seeded feature at its globe-projected screen point hits it.
// It exercises 2D→3D→2D round-trips and a live setStyle (theme swap).
type PickResult = { found: boolean; layerId: string | null };
type Harness = {
ready: boolean;
seedAllDynamicData: () => void;
setZoom: (zoom: number) => void;
setCamera: (camera: { lon: number; lat: number; zoom: number }) => void;
setLayersForSnapshot: (layers: string[]) => void;
setProjectionMode: (mode: '2d' | '3d') => void;
getProjectionType: () => string;
stopIdleSpin: () => void;
getDeckInterleaved: () => boolean | null;
getDeckCanvasCount: () => number;
getDeckViewportType: () => string | null;
getFirstHotspotLngLat: () => { lon: number; lat: number } | null;
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
};
type HarnessWindow = Window & {
__mapHarness?: Harness;
__mapboxMap?: unknown;
};
type Page = import('@playwright/test').Page;
const H = 'window.__mapHarness';
const ready = async (page: Page): Promise<void> => {
await page.goto('/tests/map-harness.html');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible();
await expect
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__mapHarness?.ready)), {
timeout: 45000,
})
.toBe(true);
// Only the hotspots layer needs to be on; it is fed by the static INTEL_HOTSPOTS
// config, so it is present regardless of any live data.
await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.seedAllDynamicData();
w.setLayersForSnapshot(['hotspots']);
w.stopIdleSpin();
});
};
const projection = (page: Page): Promise<string> =>
page.evaluate(() => (window as HarnessWindow).__mapHarness?.getProjectionType() ?? 'mercator');
const viewportType = (page: Page): Promise<string | null> =>
page.evaluate(() => (window as HarnessWindow).__mapHarness?.getDeckViewportType() ?? null);
// Centre on the seeded hotspot, freeze the spin, then pick it. Picking succeeds
// only if the layer is actually rendered AND projected at that screen location.
const pickHotspot = async (page: Page): Promise<PickResult> => {
return page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
const hs = w.getFirstHotspotLngLat();
if (!hs) return { found: false, layerId: null };
w.setCamera({ lon: hs.lon, lat: hs.lat, zoom: 3 });
w.stopIdleSpin();
return w.pickAtLonLat(hs.lon, hs.lat, 12);
});
};
const pollPickFound = async (page: Page): Promise<void> => {
await expect
.poll(async () => (await pickHotspot(page)).found, { timeout: 20000 })
.toBe(true);
};
test.describe('P0-1 deck overlay renders on the globe', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
test('overlaid mode; GlobeViewport in 3D; feature picks on the sphere; survives round-trips + setStyle', async ({
page,
}, testInfo) => {
const pageErrors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i];
page.on('pageerror', (e) => pageErrors.push(e.message));
await ready(page);
// The overlay is OVERLAID (the fix), not interleaved, and owns its own canvas.
expect(await page.evaluate(() => (window as HarnessWindow).__mapHarness!.getDeckInterleaved())).toBe(false);
expect(await page.evaluate(() => (window as HarnessWindow).__mapHarness!.getDeckCanvasCount())).toBeGreaterThanOrEqual(2);
// 2D baseline: mercator viewport + pickable dot.
expect(await projection(page)).toBe('mercator');
await expect.poll(() => viewportType(page)).toMatch(/Mercator|Web/i);
await pollPickFound(page);
// → 3D globe. Deck's active viewport must be a GlobeViewport (proves deck is
// reprojecting onto the sphere) and the dot must still pick on the globe.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect.poll(() => projection(page), { timeout: 30000 }).toBe('globe');
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
await expect.poll(() => viewportType(page), { timeout: 20000 }).toMatch(/Globe/i);
await pollPickFound(page);
// Screenshot the dots on the sphere (the CTO-facing proof).
await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
const hs = w.getFirstHotspotLngLat();
if (hs) w.setCamera({ lon: hs.lon, lat: hs.lat, zoom: 2.2 });
w.stopIdleSpin();
});
await page.waitForTimeout(600);
const shot = await page.locator('.deckgl-map-wrapper').screenshot();
await testInfo.attach('globe-3d-dots', { body: shot, contentType: 'image/png' });
// Round-trip 2D → 3D again; picks must survive each transition.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="2d"]');
await expect.poll(() => projection(page), { timeout: 20000 }).toBe('mercator');
await pollPickFound(page);
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect.poll(() => projection(page), { timeout: 20000 }).toBe('globe');
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
await pollPickFound(page);
// setStyle: a full basemap swap (theme change) clears mapbox sources but the
// overlaid deck lives outside the style, so its layers must survive.
await page.evaluate(() =>
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme: 'light' } })),
);
await page.waitForTimeout(1500); // style.load + re-applied projection/atmosphere
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
expect(await page.evaluate(() => (window as HarnessWindow).__mapHarness!.getDeckInterleaved())).toBe(false);
await pollPickFound(page);
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
});
-223
View File
@@ -1,223 +0,0 @@
import { expect, test } from '@playwright/test';
// Globe render-fix acceptance suite (world.hanzo.ai Hanzo-Cloud view).
//
// Guards the three "spazz" bugs the CTO reported and the 2D/3D parity + live-analytics
// contract:
// 1. Overlays must sit ON the globe surface with correct occlusion — a back-hemisphere
// dot/badge is HIDDEN, a front one is drawn (no floating layers above the sphere).
// 2. Terrain drapes on the globe in a single WebGL context (no second canvas, no
// striping regression — depth-write disabled on the coplanar imagery tiles).
// 3. Every cloud data layer mounts in BOTH 2D (mercator) and 3D (globe) — parity.
// Plus: the REAL live request-geo dots appear and update.
//
// Data is mocked at the same-origin /v1/world/cloud/* contracts so the run is
// deterministic and offline (production serves the real, live payloads).
type DeckLayer = { id: string; props: Record<string, unknown> };
type Rgba = [number, number, number, number];
const cloudMap = {
// Two request-origin points on OPPOSITE hemispheres so occlusion is testable:
// FRONT (lon 0) faces a camera centred at lon 0; BACK (lon 180) is behind the globe.
trafficGlobe: {
updatedAt: '2026-07-18T12:00:00Z',
live: true,
window: { minutes: 60, since: '2026-07-18T11:00:00Z', until: '2026-07-18T12:00:00Z' },
points: [
{ country: 'FR', lat: 20, lon: 0, count: 90, byService: { models: 90 } },
{ country: 'US', lat: 37.09, lon: -95.71, count: 42, byService: { models: 42 } },
{ country: 'JP', lat: 20, lon: 180, count: 30, byService: { models: 30 } },
],
totals: { rps_1m: 1.6, rpm_60m: 96, top_countries: [{ country: 'FR', count: 90 }, { country: 'US', count: 42 }, { country: 'JP', count: 30 }] },
},
traffic: {
updatedAt: '2026-07-18T12:00:00Z', demo: false,
arcs: [
{ fromLat: 20, fromLon: 0, toLat: 51.5, toLon: -0.12, weight: 1, label: 'FR → lon' },
{ fromLat: 37.09, fromLon: -95.71, toLat: 40.71, toLon: -74.0, weight: 0.6, label: 'US → nyc' },
],
},
chainNodes: {
updatedAt: '2026-07-18T12:00:00Z', positionsModeled: true,
networks: [{ id: 'lux', name: 'Lux Network', chainId: 96369, blockHeight: 1096461, peers: 3, live: true,
nodes: [{ lat: 40.71, lon: -74.0, city: 'New York', kind: 'validator' }, { lat: 37.77, lon: -122.42, city: 'San Francisco', kind: 'validator' }] }],
},
byoGpu: { updatedAt: '2026-07-18T12:00:00Z', demo: false, gpus: [] },
};
// The flagship Cloud variant now OPENS on the native 3D globe (its immersive default),
// which parks the mapbox 2D map — `.deckgl-map-wrapper` mounts hidden. These specs
// exercise the 2D→3D toggle path (2D layer parity, then go3D), so they pin the start
// to 2D with `?mode=2d` (resolveInitialMapMode: the URL wins over the variant default).
// The globe-as-default render is proven separately (direct visual + the live prod shot).
const CLOUD_2D = '/?variant=cloud&mode=2d';
async function mockCloud(page: import('@playwright/test').Page, traffic = cloudMap.trafficGlobe): Promise<void> {
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
await page.route('**/v1/world/cloud/traffic-globe', (r) => r.fulfill(json(traffic)));
await page.route('**/v1/world/cloud/traffic', (r) => r.fulfill(json(cloudMap.traffic)));
await page.route('**/v1/world/cloud/chain-nodes', (r) => r.fulfill(json(cloudMap.chainNodes)));
await page.route('**/v1/world/cloud/byo-gpu', (r) => r.fulfill(json(cloudMap.byoGpu)));
}
const layerIds = (page: import('@playwright/test').Page): Promise<string[]> =>
page.evaluate(() => {
const m = (window as unknown as { __deckMap?: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
return (m?.asGlobeSource().buildLayers() ?? []).flat(Infinity).filter(Boolean).map((l: DeckLayer) => l.id);
});
const go3D = async (page: import('@playwright/test').Page): Promise<void> => {
// Enter the 3D globe through the projection toggle's REAL click handler
// (proj-btn → setProjectionMode('3d') → activateNativeGlobe). The flagship Cloud
// view floats its control dock over the full-viewport globe canvas, so under
// headless swiftshader a synthesized mouse click's hit-test lands on the canvas,
// not the collapsed-dropdown button — dispatch the click on the element itself.
// This spec verifies the GLOBE render (occlusion/terrain/parity/live dots); the
// dock's pointer reachability is a separate controls concern, out of scope here.
await page.evaluate(() => {
const btn = document.querySelector('.deckgl-projection-toggle .proj-btn[data-mode="3d"]') as HTMLElement | null;
btn?.click();
});
await expect
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 25000 })
.toBe(true);
await page.waitForTimeout(1200); // let the first data-sync pull + push layers
};
test.describe('Globe render fixes — occlusion, terrain, 2D/3D parity, live dots', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
// The cloud data layers that must exist on the Hanzo-Cloud globe once feeds resolve.
const CLOUD_LAYERS = ['traffic', 'trafficArcs', 'chainNodes', 'datacenter-clusters-layer'];
test('3D: cloud layers mount, live dots render on the sphere, no WebGL errors', async ({ page }, testInfo) => {
const errors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i, /the layer 'background'/i, /status of 40[13]/i];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(e.message));
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// Single WebGL context for the globe (deck GlobeView, not a 2nd overlay canvas).
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-canvas').length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getViewportType: () => string | null } }).__globeNative.getViewportType())).toMatch(/Globe/i);
// Every cloud data layer is mounted.
const ids = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids).toContain(id);
// The live request-geo dots are present with the real (mocked) point count.
const trafficCount = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
return (t?.props?.data as unknown[])?.length ?? 0;
});
expect(trafficCount).toBe(cloudMap.trafficGlobe.points.length);
const shot = await page.locator('.globe-native-wrapper').screenshot();
await testInfo.attach('3d-globe-cloud-layers', { body: shot, contentType: 'image/png' });
expect(errors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
test('occlusion: a back-hemisphere dot is culled to transparent, a front dot is opaque', async ({ page }) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// Face the camera at lon 0 (the FRONT point). Read the REAL traffic layer's fill
// accessor for each point: front → opaque (alpha>0), back (lon 180) → alpha 0.
const alphas = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
m.setOcclusionCenter(0, 20);
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic') as DeckLayer;
const data = t.props.data as Array<{ lon: number }>;
const getFill = t.props.getFillColor as (d: unknown) => Rgba;
const front = getFill(data.find((d) => d.lon === 0));
const back = getFill(data.find((d) => d.lon === 180));
return { frontAlpha: front[3], backAlpha: back[3] };
});
expect(alphas.frontAlpha).toBeGreaterThan(0);
expect(alphas.backAlpha).toBe(0);
});
test('occlusion: a back-hemisphere count badge is culled (no floating badge over the globe)', async ({ page }) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// datacenter count badges are a TextLayer; a back-side badge's glyph AND pill must
// both go transparent. Probe both hemispheres against a fixed camera centre.
const badge = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
m.setOcclusionCenter(0, 20);
const b = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'datacenter-clusters-badge') as DeckLayer | undefined;
if (!b) return { skip: true };
const getColor = b.props.getColor as (d: unknown) => Rgba;
const getBg = b.props.getBackgroundColor as (d: unknown) => Rgba;
const front = { lon: 0, lat: 20, count: 5 };
const back = { lon: 180, lat: 20, count: 5 };
return { skip: false, frontText: getColor(front)[3], backText: getColor(back)[3], backBg: getBg(back)[3] };
});
if (badge.skip) test.skip(true, 'no datacenter badge in this build');
expect(badge.frontText).toBeGreaterThan(0);
expect(badge.backText).toBe(0);
expect(badge.backBg).toBe(0);
});
test('2D/3D parity: every cloud layer that mounts in 3D also mounts in 2D', async ({ page }, testInfo) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await page.waitForTimeout(1500); // 2D feeds settle
const ids2d = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids2d, `2D missing ${id}`).toContain(id);
await testInfo.attach('2d-map-cloud-layers', { body: await page.locator('.deckgl-map-wrapper').screenshot(), contentType: 'image/png' });
await go3D(page);
const ids3d = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids3d, `3D missing ${id}`).toContain(id);
await testInfo.attach('3d-globe-cloud-layers-parity', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
});
test('terrain drapes on the globe in a single context (no second canvas)', async ({ page }, testInfo) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
await page.evaluate(() => (window as unknown as { __globeNative: { setBasemapStyle: (s: string) => void } }).__globeNative.setBasemapStyle('terrain'));
await expect
.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getDeck: () => { props: { layers: DeckLayer[] } } } }).__globeNative.getDeck().props.layers.flat(Infinity).some((l: DeckLayer) => l?.id?.startsWith('globe-imagery-terrain'))), { timeout: 20000 })
.toBe(true);
// Still exactly one canvas — imagery drapes as deck tiles, not a 2nd WebGL context.
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-wrapper canvas').length)).toBe(1);
await page.waitForTimeout(2500);
await testInfo.attach('3d-terrain-globe', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
});
test('live dots update when the feed changes', async ({ page }) => {
await mockCloud(page, { ...cloudMap.trafficGlobe, points: cloudMap.trafficGlobe.points.slice(0, 1) });
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
const count = () => page.evaluate(() => {
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
return (t?.props?.data as unknown[])?.length ?? 0;
});
await expect.poll(count, { timeout: 15000 }).toBe(1);
// Swap the feed to the full 3-point payload; the DeckGLMap poll picks it up.
await mockCloud(page, cloudMap.trafficGlobe);
await expect.poll(count, { timeout: 20000 }).toBe(cloudMap.trafficGlobe.points.length);
});
});
-197
View File
@@ -1,197 +0,0 @@
import { expect, test } from '@playwright/test';
import { clickMapControl } from './helpers/map-controls';
type LayerSnapshot = { id: string; dataCount: number };
type Harness = {
ready: boolean;
seedAllDynamicData: () => void;
seedClimateAnomalies: () => void;
setZoom: (zoom: number) => void;
setProjectionMode: (mode: '2d' | '3d') => void;
getProjectionType: () => string;
getCenterLng: () => number | undefined;
isAutoRotateActive: () => boolean;
isUserInteracting: () => boolean;
autoRotateGateOpen: () => boolean;
rotateOneStep: (dtSec: number) => void;
stopIdleSpin: () => void;
getDeckLayerSnapshot: () => LayerSnapshot[];
};
type HarnessWindow = Window & { __mapHarness?: Harness };
// One representative of every deck.gl layer type in use — each must still build
// on the globe: PathLayer, GeoJsonLayer, IconLayer, ScatterplotLayer, Text.
const GLOBE_SAFE_LAYERS = [
'cables-layer', // PathLayer
'pipelines-layer', // PathLayer
'conflict-zones-layer', // GeoJsonLayer
'bases-layer', // IconLayer
'nuclear-layer', // IconLayer
'hotspots-layer', // ScatterplotLayer
'datacenters-layer', // IconLayer
'earthquakes-layer', // ScatterplotLayer
'weather-layer', // ScatterplotLayer
'military-flights-layer', // ScatterplotLayer
'ports-layer', // ScatterplotLayer
'news-locations-layer', // Text/Scatterplot
];
const waitForHarnessReady = async (
page: import('@playwright/test').Page
): Promise<void> => {
await page.goto('/tests/map-harness.html');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible();
await expect
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__mapHarness?.ready)), {
timeout: 45000,
})
.toBe(true);
};
const projectionType = (page: import('@playwright/test').Page): Promise<string> =>
page.evaluate(() => (window as HarnessWindow).__mapHarness?.getProjectionType() ?? 'mercator');
const layerIds = async (page: import('@playwright/test').Page): Promise<Set<string>> => {
const snapshot = await page.evaluate(
() => (window as HarnessWindow).__mapHarness?.getDeckLayerSnapshot() ?? []
);
return new Set(snapshot.filter((l) => l.dataCount > 0).map((l) => l.id));
};
test.describe('3D globe', () => {
test.describe.configure({ retries: 1 });
// Runs FIRST and is deliberately light: the idle-spin rAF loop is engaged then
// cancelled in the same tick, so no sustained globe repaint occurs (a
// continuously repainting software-GL globe starves page.evaluate in CI). The
// rotation math and idle/interaction gate are exercised via discrete calls —
// setCenter mutates map state synchronously, independent of GL throughput.
test.describe('idle auto-rotate', () => {
test.use({ reducedMotion: 'no-preference' });
test('engages in 3D, rotates the globe, and its gate closes on interaction / in 2D', async ({
page,
}) => {
await waitForHarnessReady(page);
const engaged = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.setZoom(1.6);
w.setProjectionMode('3d');
const active = w.isAutoRotateActive(); // rafId set synchronously
w.stopIdleSpin(); // cancel before any sustained repaint
return { active, gate: w.autoRotateGateOpen() };
});
expect(engaged.active).toBe(true); // loop engaged in 3D
expect(engaged.gate).toBe(true); // idle gate open
// One step (1s worth) rotates the globe eastward ~2 degrees (the calmer
// background drift — AUTO_ROTATE_DEG_PER_SEC was halved from 4 to 2).
const moved = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
const before = w.getCenterLng() ?? 0;
w.rotateOneStep(1);
return Math.abs((w.getCenterLng() ?? 0) - before);
});
expect(moved).toBeGreaterThan(1);
expect(moved).toBeLessThan(3);
// A real pointer interaction closes the gate (spin pauses).
await page.evaluate(() =>
document.querySelector('.mapboxgl-canvas')?.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
);
const afterInteract = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
return { interacting: w.isUserInteracting(), gate: w.autoRotateGateOpen() };
});
expect(afterInteract.interacting).toBe(true);
expect(afterInteract.gate).toBe(false);
// Flat map never auto-rotates.
const flat = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.setProjectionMode('2d');
return { active: w.isAutoRotateActive(), gate: w.autoRotateGateOpen() };
});
expect(flat.active).toBe(false);
expect(flat.gate).toBe(false);
});
});
// Runs LAST because rendering a full-layer globe under CI's software GL is the
// heaviest step. reducedMotion keeps auto-rotate off so there is no sustained
// repaint; every assertion is a pure buildLayers/getProjection state read plus
// real pill clicks. All static checks live in ONE page load to avoid repeated
// heavy globe initialisations.
test.describe('projection + layers (static)', () => {
test.use({ reducedMotion: 'reduce' });
test('2D<->3D toggle switches projection; every layer renders on the globe; heatmap is substituted; no deck errors', async ({
page,
}) => {
const pageErrors: string[] = [];
const deckAssertionErrors: string[] = [];
const ignorable = [/could not compile fragment shader/i];
page.on('pageerror', (e) => pageErrors.push(e.message));
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('deck.gl: assertion failed')) {
deckAssertionErrors.push(msg.text());
}
});
await waitForHarnessReady(page);
await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.seedAllDynamicData();
w.seedClimateAnomalies();
w.setZoom(5); // clears per-layer minZoom gates (bases/nuclear/datacenters)
});
// Flat map by default; climate uses the screen-space HeatmapLayer.
expect(await projectionType(page)).toBe('mercator');
await expect(page.locator('.deckgl-projection-toggle .proj-btn[data-mode="2d"]')).toHaveClass(/active/);
{
const ids = await layerIds(page);
expect(ids.has('climate-heatmap-layer')).toBe(true);
expect(ids.has('climate-anomaly-points-layer')).toBe(false);
}
// Click the real 3D pill in the map header.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect.poll(() => projectionType(page), { timeout: 30000 }).toBe('globe');
await expect(page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]')).toHaveClass(/active/);
// Freeze the idle spin so the static layer checks don't run against a
// continuously repainting globe (a repainting software-GL globe starves
// page.evaluate in CI). reducedMotion disables it where the emulation is
// honored; this makes it deterministic everywhere.
await page.evaluate(() => (window as HarnessWindow).__mapHarness?.stopIdleSpin());
// Every representative deck.gl layer type still builds on the globe...
await expect
.poll(async () => {
const ids = await layerIds(page);
return GLOBE_SAFE_LAYERS.filter((id) => !ids.has(id)).length;
}, { timeout: 20000 })
.toBe(0);
// ...and the screen-space climate heatmap is swapped for a globe-safe scatter.
{
const ids = await layerIds(page);
expect(ids.has('climate-anomaly-points-layer')).toBe(true);
expect(ids.has('climate-heatmap-layer')).toBe(false);
}
// Flip back to the flat map.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="2d"]');
await expect.poll(() => projectionType(page), { timeout: 20000 }).toBe('mercator');
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
expect(deckAssertionErrors).toEqual([]);
});
});
});
-18
View File
@@ -1,18 +0,0 @@
import type { Page, Locator } from '@playwright/test';
// The map's projection / basemap / time-range controls collapsed into dropdowns:
// each option button (.proj-btn / .style-btn / .time-btn) lives in a popover that a
// trigger opens. Tests that click an option must open its dropdown first. This helper
// opens the button's parent .deckgl-dd (if it's in one) and clicks the option — a
// no-op open when the button isn't inside a dropdown, so it's safe everywhere.
export async function clickMapControl(page: Page, buttonSelector: string): Promise<void> {
const btn: Locator = page.locator(buttonSelector).first();
const dd = btn.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " deckgl-dd ")][1]');
if (await dd.count()) {
const trigger = dd.locator('.dd-trigger').first();
if (!(await dd.evaluate((el) => el.classList.contains('open')).catch(() => false))) {
await trigger.click();
}
}
await btn.click();
}
-111
View File
@@ -1,111 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { clickMapControl } from './helpers/map-controls';
// Capture the CTO-facing proof screenshots for the layout/style batch. These drive
// the REAL app (not a harness) so they show the shipped chrome + layout. They are
// deliverables, not assertions — kept lenient so a flaky data feed never fails them.
const OUT = join(process.cwd(), 'e2e', 'screens');
mkdirSync(OUT, { recursive: true });
const shot = (page: Page, name: string) => page.screenshot({ path: join(OUT, name), fullPage: false });
async function boot(page: Page): Promise<void> {
await page.goto('/');
await expect(page.locator('#panelsGrid')).toBeVisible({ timeout: 60000 });
// The map + its chrome mount asynchronously; wait for the projection toggle.
await expect(page.locator('.deckgl-projection-toggle')).toBeVisible({ timeout: 60000 });
await page.waitForTimeout(1500); // let the first paint + a few panels settle
}
test.describe('layout/style batch — deliverable screenshots', () => {
test.describe.configure({ timeout: 120000 });
test('default panel sizes at 1440px', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await boot(page);
// Panels should be ≥ 2 tracks wide (comfortable), not 160px slivers.
await shot(page, 'default-sizes-1440.png');
});
test('immersive 3D background + floating panels, then video background', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await boot(page);
// Globe on.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]').catch(() => {});
await page.waitForTimeout(1200);
// Enter immersive.
await page.locator('#immersiveToggle').click();
await expect(page.locator('body')).toHaveClass(/immersive/);
await page.waitForTimeout(2000); // globe reprojects + panels restyle
await shot(page, 'immersive-3d-panels.png');
// Switch the background slot to the live video.
await page.locator('#immersiveBgSelect .ibg-btn[data-bg="video"]').click();
await expect(page.locator('body')).toHaveAttribute('data-immersive-bg', 'video');
await page.waitForTimeout(2500); // give the YouTube embed a chance to paint
await shot(page, 'immersive-video-bg.png');
// Collapse-to-edge affordance — the globe/video breathes.
await page.locator('#immersiveBgSelect .ibg-btn[data-bg="map"]').click();
await page.locator('#immersiveCollapse').click();
await expect(page.locator('body')).toHaveClass(/immersive-collapsed/);
await page.waitForTimeout(900);
await shot(page, 'immersive-collapsed.png');
});
test('basemap style switcher (dark / satellite / terrain)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await boot(page);
// The basemap control collapsed into a dropdown: assert its trigger, then open it.
await expect(page.locator('.deckgl-dd[data-dd="basemap"] .dd-trigger')).toBeVisible();
await page.locator('.deckgl-dd[data-dd="basemap"] .dd-trigger').click();
await expect(page.locator('.deckgl-style-switcher')).toBeVisible();
await shot(page, 'style-switcher-dark.png');
// Satellite + terrain need a Mapbox token (VITE_MAPBOX_TOKEN); the buttons are
// disabled without one. When a token is configured, actually switch and let the
// relief render before capturing. clickMapControl reopens the dropdown each time
// (picking an option closes it).
const sat = page.locator('.deckgl-style-switcher .style-btn[data-style="satellite"]');
if (!(await sat.isDisabled())) {
await clickMapControl(page, '.deckgl-style-switcher .style-btn[data-style="satellite"]');
await page.waitForTimeout(4000);
await shot(page, 'style-switcher-satellite.png');
await clickMapControl(page, '.deckgl-style-switcher .style-btn[data-style="terrain"]');
await page.waitForTimeout(4000);
await shot(page, 'style-switcher-terrain.png');
}
});
test('layers panel dragged + entries reordered', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await boot(page);
const panel = page.locator('.deckgl-layer-toggles');
await expect(panel).toBeVisible();
// Drag the panel by its header grip to a new spot over the map.
const grip = panel.locator('.toggle-drag-grip');
const g = (await grip.boundingBox())!;
await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2);
await page.mouse.down();
await page.mouse.move(g.x + 260, g.y + 120, { steps: 12 });
await page.mouse.up();
// Reorder: drag the first row's grip below the third row.
const rows = panel.locator('.layer-toggle');
const firstGrip = rows.nth(0).locator('.layer-reorder-grip');
const fg = (await firstGrip.boundingBox())!;
const third = (await rows.nth(2).boundingBox())!;
await page.mouse.move(fg.x + fg.width / 2, fg.y + fg.height / 2);
await page.mouse.down();
await page.mouse.move(fg.x, third.y + third.height, { steps: 14 });
await page.mouse.up();
await page.waitForTimeout(400);
await shot(page, 'layers-panel-dragged-reordered.png');
});
});
-119
View File
@@ -1,119 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('GCC investments coverage', () => {
test('focusInvestmentOnMap enables layer and recenters map', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const { focusInvestmentOnMap } = await import('/src/services/investments-focus.ts');
const calls: { layers: string[]; center: { lat: number; lon: number; zoom: number } | null } = {
layers: [],
center: null,
};
const map = {
enableLayer: (layer: string) => {
calls.layers.push(layer);
},
setCenter: (lat: number, lon: number, zoom: number) => {
calls.center = { lat, lon, zoom };
},
};
const mapLayers = { gulfInvestments: false };
focusInvestmentOnMap(
map as unknown as {
enableLayer: (layer: 'gulfInvestments') => void;
setCenter: (lat: number, lon: number, zoom: number) => void;
},
mapLayers as unknown as { gulfInvestments: boolean } & Record<string, boolean>,
24.4667,
54.3667
);
return {
layers: calls.layers,
center: calls.center,
gulfInvestmentsEnabled: mapLayers.gulfInvestments,
};
});
expect(result.layers).toEqual(['gulfInvestments']);
expect(result.center).toEqual({ lat: 24.4667, lon: 54.3667, zoom: 6 });
expect(result.gulfInvestmentsEnabled).toBe(true);
});
test('InvestmentsPanel supports search/filter/sort and row click callbacks', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const { InvestmentsPanel } = await import('/src/components/InvestmentsPanel.ts');
const { GULF_INVESTMENTS } = await import('/src/config/gulf-fdi.ts');
const clickedIds: string[] = [];
const panel = new InvestmentsPanel((inv) => {
clickedIds.push(inv.id);
});
document.body.appendChild(panel.getElement());
const root = panel.getElement();
const totalRows = root.querySelectorAll('.fdi-row').length;
const firstInvestment = GULF_INVESTMENTS[0];
const searchToken = firstInvestment?.assetName.split(/\s+/)[0]?.toLowerCase() ?? '';
const searchInput = root.querySelector<HTMLInputElement>('.fdi-search');
searchInput!.value = searchToken;
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
const searchRows = root.querySelectorAll('.fdi-row').length;
searchInput!.value = '';
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
const countrySelect = root.querySelector<HTMLSelectElement>(
'.fdi-filter[data-filter="investingCountry"]'
);
countrySelect!.value = 'SA';
countrySelect!.dispatchEvent(new Event('change', { bubbles: true }));
const saRows = root.querySelectorAll('.fdi-row').length;
const expectedSaRows = GULF_INVESTMENTS.filter((inv) => inv.investingCountry === 'SA').length;
const investmentSort = root.querySelector<HTMLElement>('.fdi-sort[data-sort="investmentUSD"]');
investmentSort!.click(); // asc
investmentSort!.click(); // desc
const firstRow = root.querySelector<HTMLElement>('.fdi-row');
const firstRowId = firstRow?.dataset.id ?? null;
const expectedTopSaId = GULF_INVESTMENTS
.filter((inv) => inv.investingCountry === 'SA')
.slice()
.sort((a, b) => (b.investmentUSD ?? -1) - (a.investmentUSD ?? -1))[0]?.id ?? null;
firstRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
panel.destroy();
root.remove();
return {
totalRows,
datasetSize: GULF_INVESTMENTS.length,
searchRows,
saRows,
expectedSaRows,
firstRowId,
expectedTopSaId,
clickedId: clickedIds[0] ?? null,
};
});
expect(result.totalRows).toBe(result.datasetSize);
expect(result.searchRows).toBeGreaterThan(0);
expect(result.searchRows).toBeLessThanOrEqual(result.totalRows);
expect(result.saRows).toBe(result.expectedSaRows);
expect(result.firstRowId).toBe(result.expectedTopSaId);
expect(result.clickedId).toBe(result.firstRowId);
});
});
+6 -113
View File
@@ -10,22 +10,11 @@ test.describe('keyword spike modal/badge flow', () => {
const trending = await import('/src/services/trending-keywords.ts');
const correlation = await import('/src/services/correlation.ts');
// The badge/modal render through i18n (t()); the app bootstrap initializes it
// before any component mounts. This isolated harness must do the same, else
// t() returns undefined and getSignalContext().actionableInsight.split() throws.
const { initI18n } = await import('/src/services/i18n.ts');
await initI18n();
const previousConfig = trending.getTrendingConfig();
const headerRight = document.createElement('div');
headerRight.className = 'header-right';
document.body.appendChild(headerRight);
// The findings badge is opt-in (default OFF): its constructor only mounts and
// renders when the user has enabled it. Simulate an opted-in session so the
// badge mounts into .header-right and its count/dropdown render.
localStorage.setItem('worldmonitor-intel-findings', 'shown');
const modal = new SignalModal();
const badge = new IntelligenceGapBadge();
badge.setOnSignalClick((signal) => modal.showSignal(signal));
@@ -38,18 +27,13 @@ test.describe('keyword spike modal/badge flow', () => {
});
const now = new Date();
// Keep the trending proper noun ("Iran") mid-sentence and capitalized: the
// significance filter (isLikelyProperNoun) only counts capitalization for
// tokens that appear past position 0, so a sentence-leading term reads as an
// ordinary word and gets suppressed. Mid-sentence, it registers as a genuine
// entity spiking across sources — no ML worker required.
const headlines = [
{ source: 'Reuters', title: 'Talks on Iran sanctions stall in Washington', link: 'https://example.com/reuters/1' },
{ source: 'AP', title: 'New Iran sanctions debate intensifies among allies', link: 'https://example.com/ap/1' },
{ source: 'BBC', title: 'Markets watch Iran sanctions with fresh concern', link: 'https://example.com/bbc/1' },
{ source: 'Reuters', title: 'Regional powers weigh Iran sanctions package', link: 'https://example.com/reuters/2' },
{ source: 'AP', title: 'Momentum grows for Iran sanctions proposal', link: 'https://example.com/ap/2' },
{ source: 'BBC', title: 'Analysts see Iran sanctions timeline shortening', link: 'https://example.com/bbc/2' },
{ source: 'Reuters', title: 'Iran sanctions pressure rises amid talks', link: 'https://example.com/reuters/1' },
{ source: 'AP', title: 'Iran sanctions debate intensifies in Washington', link: 'https://example.com/ap/1' },
{ source: 'BBC', title: 'Iran sanctions trigger fresh market concerns', link: 'https://example.com/bbc/1' },
{ source: 'Reuters', title: 'Iran sanctions package draws regional response', link: 'https://example.com/reuters/2' },
{ source: 'AP', title: 'Iran sanctions proposal gains momentum', link: 'https://example.com/ap/2' },
{ source: 'BBC', title: 'Iran sanctions timeline shortens after warnings', link: 'https://example.com/bbc/2' },
].map(item => ({
...item,
pubDate: now,
@@ -117,98 +101,7 @@ test.describe('keyword spike modal/badge flow', () => {
if (store?.previousConfig) {
trending.updateTrendingConfig(store.previousConfig);
}
localStorage.removeItem('worldmonitor-intel-findings');
delete (window as unknown as Record<string, unknown>).__keywordSpikeTest;
});
});
test('does not emit spikes from source-attribution suffixes', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const previousConfig = trending.getTrendingConfig();
try {
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 4,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'Qzxalpha ventures stabilize - WireDesk' },
{ source: 'AP', title: 'Bravotango liquidity trims - WireDesk' },
{ source: 'BBC', title: 'Cindelta refinery expands - WireDesk' },
{ source: 'Bloomberg', title: 'Dorion transit reroutes - WireDesk' },
{ source: 'WSJ', title: 'Epsiluna lending reprices - WireDesk' },
].map((item) => ({ ...item, pubDate: now }));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 40));
spikes = trending.drainTrendingSignals();
}
return {
emittedTitles: spikes.map((signal) => signal.title),
hasWireDeskSpike: spikes.some((signal) => /wiredesk/i.test(signal.title)),
};
} finally {
trending.updateTrendingConfig(previousConfig);
}
});
expect(result.hasWireDeskSpike).toBe(false);
expect(result.emittedTitles.length).toBe(0);
});
test('suppresses month-name token spikes', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const previousConfig = trending.getTrendingConfig();
try {
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 4,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'January qxavon ledger shift' },
{ source: 'AP', title: 'January brivon routing update' },
{ source: 'BBC', title: 'January caldren supply note' },
{ source: 'Bloomberg', title: 'January dernix cargo brief' },
{ source: 'WSJ', title: 'January eptara policy digest' },
].map((item) => ({ ...item, pubDate: now }));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 40));
spikes = trending.drainTrendingSignals();
}
return {
emittedTitles: spikes.map((signal) => signal.title),
hasJanuarySpike: spikes.some((signal) => /january/i.test(signal.title)),
};
} finally {
trending.updateTrendingConfig(previousConfig);
}
});
expect(result.hasJanuarySpike).toBe(false);
expect(result.emittedTitles.length).toBe(0);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+121 -157
View File
@@ -11,13 +11,13 @@ type OverlaySnapshot = {
type VisualScenarioSummary = {
id: string;
variant: 'both' | 'full' | 'tech' | 'finance';
variant: 'both' | 'full' | 'tech';
};
type HarnessWindow = Window & {
__mapHarness?: {
ready: boolean;
variant: 'full' | 'tech' | 'finance';
variant: 'full' | 'tech';
seedAllDynamicData: () => void;
setProtestsScenario: (scenario: 'alpha' | 'beta') => void;
setPulseProtestsScenario: (
@@ -40,12 +40,9 @@ type HarnessWindow = Window & {
prepareVisualScenario: (scenarioId: string) => boolean;
isVisualScenarioReady: (scenarioId: string) => boolean;
getDeckLayerSnapshot: () => LayerSnapshot[];
getLayerDataCount: (layerId: string) => number;
getLayerFirstScreenTransform: (layerId: string) => string | null;
getFirstProtestTitle: () => string | null;
getProtestClusterCount: () => number;
getOverlaySnapshot: () => OverlaySnapshot;
getCyberTooltipHtml: (indicator: string) => string;
getClusterStateSize: () => number;
};
};
@@ -117,15 +114,6 @@ const EXPECTED_TECH_DECK_LAYERS = [
'news-locations-layer',
];
const EXPECTED_FINANCE_DECK_LAYERS = [
...EXPECTED_FULL_DECK_LAYERS,
'stock-exchanges-layer',
'financial-centers-layer',
'central-banks-layer',
'commodity-hubs-layer',
'gulf-investments-layer',
];
const waitForHarnessReady = async (
page: import('@playwright/test').Page
): Promise<void> => {
@@ -141,6 +129,16 @@ const waitForHarnessReady = async (
.toBe(true);
};
const getMarkerInlineTransform = async (
page: import('@playwright/test').Page,
selector: string
): Promise<string | null> => {
return await page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null;
return el?.style.transform ?? null;
}, selector);
};
const prepareVisualScenario = async (
page: import('@playwright/test').Page,
scenarioId: string
@@ -165,8 +163,6 @@ const prepareVisualScenario = async (
};
test.describe('DeckGL map harness', () => {
test.describe.configure({ retries: 1 });
test('serves requested runtime variant for this test run', async ({ page }) => {
await waitForHarnessReady(page);
@@ -175,11 +171,7 @@ test.describe('DeckGL map harness', () => {
return w.__mapHarness?.variant ?? 'full';
});
const expectedVariant = process.env.VITE_VARIANT === 'tech'
? 'tech'
: process.env.VITE_VARIANT === 'finance'
? 'finance'
: 'full';
const expectedVariant = process.env.VITE_VARIANT === 'tech' ? 'tech' : 'full';
expect(runtimeVariant).toBe(expectedVariant);
});
@@ -230,11 +222,10 @@ test.describe('DeckGL map harness', () => {
return w.__mapHarness?.variant ?? 'full';
});
const expectedDeckLayers = variant === 'tech'
? EXPECTED_TECH_DECK_LAYERS
: variant === 'finance'
? EXPECTED_FINANCE_DECK_LAYERS
: EXPECTED_FULL_DECK_LAYERS;
const expectedDeckLayers =
variant === 'tech'
? EXPECTED_TECH_DECK_LAYERS
: EXPECTED_FULL_DECK_LAYERS;
await expect
.poll(async () => {
@@ -302,32 +293,6 @@ test.describe('DeckGL map harness', () => {
}
});
test('renders GCC investments layer when enabled in finance variant', async ({ page }) => {
await waitForHarnessReady(page);
const variant = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.variant ?? 'full';
});
test.skip(variant !== 'finance', 'Finance variant only');
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.seedAllDynamicData();
w.__mapHarness?.setLayersForSnapshot(['gulfInvestments']);
w.__mapHarness?.setCamera({ lon: 55.27, lat: 25.2, zoom: 4.2 });
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('gulf-investments-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
});
test('sanitizes cyber threat tooltip content', async ({ page }) => {
await waitForHarnessReady(page);
@@ -371,16 +336,44 @@ test.describe('DeckGL map harness', () => {
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.seedAllDynamicData();
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.forcePulseStartupElapsed();
w.__mapHarness?.setNewsPulseScenario('recent');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(true);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setNewsPulseScenario('stale');
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 10000 })
.toBe(false);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setPulseProtestsScenario('recent-gdelt-riot');
});
await page.waitForTimeout(600);
await page.waitForTimeout(800);
const gdeltPulseRunning = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
@@ -398,25 +391,8 @@ test.describe('DeckGL map harness', () => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 15000 })
}, { timeout: 10000 })
.toBe(true);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.resetPulseStartupTime();
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 12000 })
.toBe(false);
});
test('matches golden screenshots per layer and zoom', async ({ page }) => {
@@ -466,14 +442,14 @@ test.describe('DeckGL map harness', () => {
}) => {
await waitForHarnessReady(page);
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getFirstProtestTitle() ?? '';
});
}, { timeout: 15000 })
.toContain('Scenario Alpha Protest');
const protestMarker = page.locator('.protest-marker').first();
await expect(protestMarker).toBeVisible({ timeout: 15000 });
await protestMarker.click({ force: true });
await expect(page.locator('.map-popup .popup-description')).toContainText(
'Scenario Alpha Protest'
);
await page.locator('.map-popup .popup-close').click();
await page.evaluate(() => {
const w = window as HarnessWindow;
@@ -484,22 +460,19 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getProtestClusterCount() ?? 0;
return w.__mapHarness?.getClusterStateSize() ?? -1;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getFirstProtestTitle() ?? '';
});
}, { timeout: 15000 })
.toContain('Scenario Beta Protest');
await expect(protestMarker).toBeVisible({ timeout: 15000 });
await protestMarker.click({ force: true });
await expect(page.locator('.map-popup .popup-description')).toContainText(
'Scenario Beta Protest'
);
});
test('populates protest clusters on first protest cluster render', async ({
test('initializes cluster movement cache on first protest cluster render', async ({
page,
}) => {
await waitForHarnessReady(page);
@@ -515,7 +488,7 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('protest-clusters-layer') ?? 0;
return w.__mapHarness?.getOverlaySnapshot().protestMarkers ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
@@ -524,7 +497,7 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getProtestClusterCount() ?? 0;
return w.__mapHarness?.getClusterStateSize() ?? -1;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
@@ -542,19 +515,12 @@ test.describe('DeckGL map harness', () => {
w.__mapHarness?.setCamera({ lon: 0.2, lat: 15.2, zoom: 4.2 });
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('hotspots-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
const beforeTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
const markerSelector = '.hotspot';
await expect(page.locator(markerSelector).first()).toBeVisible({
timeout: 15000,
});
const beforeTransform = await getMarkerInlineTransform(page, markerSelector);
expect(beforeTransform).not.toBeNull();
await page.evaluate(() => {
@@ -569,10 +535,7 @@ test.describe('DeckGL map harness', () => {
})
);
const afterTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
});
const afterTransform = await getMarkerInlineTransform(page, markerSelector);
expect(afterTransform).not.toBeNull();
expect(afterTransform).not.toBe(beforeTransform);
});
@@ -589,41 +552,54 @@ test.describe('DeckGL map harness', () => {
w.__mapHarness?.setCamera({ lon: 0.2, lat: 15.2, zoom: 4.2 });
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('hotspots-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
const beforeTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
});
expect(beforeTransform).not.toBeNull();
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setLayersForSnapshot([]);
w.__mapHarness?.setCamera({ lon: 3.5, lat: 18.2, zoom: 4.8 });
const markerSelector = '.hotspot';
await expect(page.locator(markerSelector).first()).toBeVisible({
timeout: 15000,
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('hotspots-layer') ?? -1;
});
}, { timeout: 10000 })
.toBe(0);
const afterTransform = await page.evaluate(() => {
const result = await page.evaluate(async () => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
const marker = document.querySelector('.hotspot') as HTMLElement | null;
if (!w.__mapHarness || !marker) {
return { observed: false, styleMutations: -1, remaining: -1 };
}
let styleMutations = 0;
const observer = new MutationObserver((records) => {
for (const record of records) {
if (
record.type === 'attributes' &&
record.attributeName === 'style'
) {
styleMutations += 1;
}
}
});
observer.observe(marker, {
attributes: true,
attributeFilter: ['style'],
});
w.__mapHarness.setLayersForSnapshot([]);
w.__mapHarness.setCamera({ lon: 3.5, lat: 18.2, zoom: 4.8 });
await new Promise((resolve) => {
setTimeout(resolve, 140);
});
observer.disconnect();
return {
observed: true,
styleMutations,
remaining: document.querySelectorAll('.hotspot').length,
};
});
expect(afterTransform).toBeNull();
expect(result.observed).toBe(true);
expect(result.styleMutations).toBe(0);
expect(result.remaining).toBe(0);
});
test('reprojects protest overlay marker when panning at fixed zoom', async ({
@@ -639,19 +615,10 @@ test.describe('DeckGL map harness', () => {
await prepareVisualScenario(page, 'protests-z5');
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('protest-clusters-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
const markerSelector = '.protest-marker';
await expect(page.locator(markerSelector).first()).toBeVisible();
const beforeTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('protest-clusters-layer') ?? null;
});
const beforeTransform = await getMarkerInlineTransform(page, markerSelector);
expect(beforeTransform).not.toBeNull();
await page.evaluate(() => {
@@ -661,10 +628,7 @@ test.describe('DeckGL map harness', () => {
await page.waitForTimeout(750);
const afterTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('protest-clusters-layer') ?? null;
});
const afterTransform = await getMarkerInlineTransform(page, markerSelector);
expect(afterTransform).not.toBeNull();
expect(afterTransform).not.toBe(beforeTransform);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Some files were not shown because too many files have changed in this diff Show More