diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..da3679c84 --- /dev/null +++ b/.env.example @@ -0,0 +1,166 @@ +# ============================================ +# World Monitor — Environment Variables +# ============================================ +# Copy this file to .env.local and fill in the values you need. +# All keys are optional — the dashboard works without them, +# but the corresponding features will be disabled. +# +# cp .env.example .env.local +# +# ============================================ + + +# ------ AI Summarization (Vercel) ------ + +# Groq API (primary — 14,400 req/day on free tier) +# Get yours at: https://console.groq.com/ +GROQ_API_KEY= + +# OpenRouter API (fallback — 50 req/day on free tier) +# Get yours at: https://openrouter.ai/ +OPENROUTER_API_KEY= + + +# ------ Cross-User Cache (Vercel — Upstash Redis) ------ + +# Used to deduplicate AI calls and cache risk scores across visitors. +# Create a free Redis database at: https://upstash.com/ +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + + +# ------ Market Data (Vercel) ------ + +# Finnhub (primary stock quotes — free tier available) +# Register at: https://finnhub.io/ +FINNHUB_API_KEY= + + +# ------ Energy Data (Vercel) ------ + +# U.S. Energy Information Administration (oil prices, production, inventory) +# Register at: https://www.eia.gov/opendata/ +EIA_API_KEY= + + +# ------ Economic Data (Vercel) ------ + +# FRED (Federal Reserve Economic Data) +# Register at: https://fred.stlouisfed.org/docs/api/api_key.html +FRED_API_KEY= + + +# ------ Aircraft Tracking (Vercel) ------ + +# Wingbits aircraft enrichment (owner, operator, type) +# Contact: https://wingbits.com/ +WINGBITS_API_KEY= + + +# ------ Conflict & Protest Data (Vercel) ------ + +# ACLED (Armed Conflict Location & Event Data — free for researchers) +# Register at: https://acleddata.com/ +ACLED_ACCESS_TOKEN= + + +# ------ Internet Outages (Vercel) ------ + +# Cloudflare Radar API (requires free Cloudflare account with Radar access) +CLOUDFLARE_API_TOKEN= + + +# ------ Satellite Fire Detection (Vercel) ------ + +# NASA FIRMS (Fire Information for Resource Management System) +# Register at: https://firms.modaps.eosdis.nasa.gov/ +NASA_FIRMS_API_KEY= + + +# ------ Railway Relay (scripts/ais-relay.cjs) ------ +# The relay server handles AIS vessel tracking + OpenSky aircraft data + RSS proxy. +# It can also run the Telegram OSINT poller (stateful MTProto) when configured. +# Deploy on Railway with: node scripts/ais-relay.cjs + +# AISStream API key for live vessel positions +# Get yours at: https://aisstream.io/ +AISSTREAM_API_KEY= + +# OpenSky Network OAuth2 credentials (higher rate limits for cloud IPs) +# Register at: https://opensky-network.org/ +OPENSKY_CLIENT_ID= +OPENSKY_CLIENT_SECRET= + + +# ------ Telegram OSINT (Railway relay) ------ +# Telegram MTProto keys (free): https://my.telegram.org/apps +TELEGRAM_API_ID= +TELEGRAM_API_HASH= + +# GramJS StringSession generated locally (see: scripts/telegram/session-auth.mjs) +TELEGRAM_SESSION= + +# Which curated list bucket to ingest: full | tech | finance +TELEGRAM_CHANNEL_SET=full + +# ------ Railway Relay Connection (Vercel → Railway) ------ + +# Server-side URL (https://) — used by Vercel edge functions to reach the relay +WS_RELAY_URL= + +# Optional client-side URL (wss://) — local/dev fallback only +VITE_WS_RELAY_URL= + +# Shared secret between Vercel and Railway relay. +# Must be set to the SAME value on both platforms in production. +RELAY_SHARED_SECRET= + +# Header name used to send the relay secret (must match on both platforms) +RELAY_AUTH_HEADER=x-relay-key + +# Emergency production override to allow unauthenticated relay traffic. +# Leave unset/false in production. +ALLOW_UNAUTHENTICATED_RELAY=false + +# Rolling window size (seconds) used by relay /metrics endpoint. +RELAY_METRICS_WINDOW_SECONDS=60 + + +# ------ Public Data Sources (no keys required) ------ + +# UCDP (Uppsala Conflict Data Program) — public API, no auth +# UNHCR (UN Refugee Agency) — public API, no auth (CC BY 4.0) +# Open-Meteo — public API, no auth (processes Copernicus ERA5) +# WorldPop — public API, no auth needed + + +# ------ Site Configuration ------ + +# Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app) +VITE_VARIANT=full + +# Client-side Sentry DSN (optional). Leave empty to disable error reporting. +VITE_SENTRY_DSN= + +# PostHog product analytics (optional). Leave empty to disable analytics. +VITE_POSTHOG_KEY= +VITE_POSTHOG_HOST= + +# Map interaction mode: +# - "flat" keeps pitch/rotation disabled (2D interaction) +# - "3d" enables pitch/rotation interactions (default) +VITE_MAP_INTERACTION_MODE=3d + + +# ------ Desktop Cloud Fallback (Vercel) ------ + +# Comma-separated list of valid API keys for desktop cloud fallback. +# Generate with: openssl rand -hex 24 | sed 's/^/wm_/' +WORLDMONITOR_VALID_KEYS= + + +# ------ Registration DB (Convex) ------ + +# Convex deployment URL for email registration storage. +# Set up at: https://dashboard.convex.dev/ +CONVEX_URL= diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..6aff91256 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,88 @@ +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) + - Desktop app (Linux) + 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 + - Settings / LLMs (Ollama, Groq, OpenRouter) + - Live webcams + - 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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..fbad4f7b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..eda98ee68 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,55 @@ +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. diff --git a/.github/ISSUE_TEMPLATE/new_data_source.yml b/.github/ISSUE_TEMPLATE/new_data_source.yml new file mode 100644 index 000000000..e610b0983 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_data_source.yml @@ -0,0 +1,69 @@ +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. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..68136d4e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,36 @@ +## Summary + + + +## 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: + +## 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 + + diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 000000000..44f879a2d --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +1,402 @@ +name: 'Build Desktop App' + +on: + workflow_dispatch: + inputs: + variant: + description: 'App variant' + required: true + default: 'full' + type: choice + options: + - full + - tech + draft: + description: 'Create as draft release' + required: false + default: true + type: boolean + push: + tags: + - 'v*' + +concurrency: + group: desktop-build-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + +jobs: + build-tauri: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + 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-24.04' + args: '' + node_target: 'x86_64-unknown-linux-gnu' + label: 'Linux-x64' + timeout: 120 + - platform: 'ubuntu-24.04-arm' + args: '--target aarch64-unknown-linux-gnu' + node_target: 'aarch64-unknown-linux-gnu' + label: 'Linux-ARM64' + timeout: 120 + + runs-on: ${{ matrix.platform }} + name: Build (${{ matrix.label }}) + timeout-minutes: ${{ matrix.timeout }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Start job timer + shell: bash + run: echo "JOB_START_EPOCH=$(date +%s)" >> "$GITHUB_ENV" + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: 'npm' + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 + with: + toolchain: stable + targets: ${{ contains(matrix.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.label == 'Linux-ARM64' && 'aarch64-unknown-linux-gnu' || '') }} + + - name: Rust cache + uses: swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db + with: + 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 \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ + gstreamer1.0-gl + + - name: Install frontend dependencies + run: npm ci + + - name: Check version consistency + run: npm run version:check + + - 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') + id: apple-signing + shell: bash + run: | + if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ] && [ -n "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" ] && [ -n "${{ secrets.KEYCHAIN_PASSWORD }}" ]; then + echo "available=true" >> $GITHUB_OUTPUT + echo "Apple signing secrets detected" + else + echo "available=false" >> $GITHUB_OUTPUT + echo "No Apple signing secrets — building unsigned" + fi + + # ── macOS Code Signing (only when secrets are valid) ── + - name: Import Apple Developer Certificate + if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + CERT_SIZE=$(wc -c < certificate.p12 | tr -d ' ') + if [ "$CERT_SIZE" -lt 100 ]; then + echo "::warning::Certificate file too small ($CERT_SIZE bytes) — likely invalid. Skipping signing." + echo "SKIP_SIGNING=true" >> $GITHUB_ENV + exit 0 + fi + + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security import certificate.p12 -k build.keychain \ + -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign || { + echo "::warning::Certificate import failed — building unsigned" + echo "SKIP_SIGNING=true" >> $GITHUB_ENV + exit 0 + } + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" build.keychain + + CERT_INFO=$(security find-identity -v -p codesigning build.keychain \ + | grep "Developer ID Application" || true) + if [ -n "$CERT_INFO" ]; then + CERT_ID=$(echo "$CERT_INFO" | head -1 | awk -F'"' '{print $2}') + echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV + echo "Certificate imported: $CERT_ID" + else + echo "::warning::No Developer ID certificate found in keychain — building unsigned" + echo "SKIP_SIGNING=true" >> $GITHUB_ENV + fi + + # ── Determine variant ── + - name: Set build variant + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "BUILD_VARIANT=${{ github.event.inputs.variant }}" >> $GITHUB_ENV + else + echo "BUILD_VARIANT=full" >> $GITHUB_ENV + fi + + # ── Build with tauri-action ── + # Signed builds: only when Apple signing secrets are valid and imported + # Unsigned builds: fallback when no signing (Windows always uses this path) + + # ── Build: Full variant (signed) ── + - name: Build Tauri app (full, signed) + if: env.BUILD_VARIANT == 'full' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true' + uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_VARIANT: full + VITE_DESKTOP_RUNTIME: '1' + CONVEX_URL: ${{ secrets.CONVEX_URL }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + with: + tagName: v__VERSION__ + releaseName: 'World Monitor v__VERSION__' + releaseBody: 'See changelog below.' + releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }} + prerelease: false + args: ${{ matrix.args }} + retryAttempts: 1 + + # ── Build: Full variant (unsigned — no Apple certs) ── + - name: Build Tauri app (full, unsigned) + if: env.BUILD_VARIANT == 'full' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true') + uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_VARIANT: full + VITE_DESKTOP_RUNTIME: '1' + CONVEX_URL: ${{ secrets.CONVEX_URL }} + with: + tagName: v__VERSION__ + releaseName: 'World Monitor v__VERSION__' + releaseBody: 'See changelog below.' + releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }} + prerelease: false + args: ${{ matrix.args }} + retryAttempts: 1 + + # ── Build: Tech variant (signed) ── + - name: Build Tauri app (tech, signed) + if: env.BUILD_VARIANT == 'tech' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true' + uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_VARIANT: tech + VITE_DESKTOP_RUNTIME: '1' + CONVEX_URL: ${{ secrets.CONVEX_URL }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + with: + tagName: v__VERSION__-tech + releaseName: 'Tech Monitor v__VERSION__' + releaseBody: 'See changelog below.' + releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }} + prerelease: false + tauriScript: npx tauri + args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }} + retryAttempts: 1 + + # ── Build: Tech variant (unsigned — no Apple certs) ── + - name: Build Tauri app (tech, unsigned) + if: env.BUILD_VARIANT == 'tech' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true') + uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_VARIANT: tech + VITE_DESKTOP_RUNTIME: '1' + CONVEX_URL: ${{ secrets.CONVEX_URL }} + with: + tagName: v__VERSION__-tech + releaseName: 'Tech Monitor v__VERSION__' + releaseBody: 'See changelog below.' + releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }} + prerelease: false + tauriScript: npx tauri + args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }} + retryAttempts: 1 + + - name: 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: Smoke-test AppImage (Linux) + if: contains(matrix.platform, 'ubuntu') + shell: bash + run: | + sudo apt-get install -y xvfb imagemagick + APPIMAGE=$(find src-tauri/target -path '*/bundle/appimage/*.AppImage' | head -1) + if [ -z "$APPIMAGE" ]; then + echo "::error::No AppImage found after build" + exit 1 + fi + chmod +x "$APPIMAGE" + # Start Xvfb with known display number + Xvfb :99 -screen 0 1440x900x24 & + export DISPLAY=:99 + sleep 2 + # Launch AppImage under virtual framebuffer + "$APPIMAGE" --no-sandbox & + APP_PID=$! + # Wait for app to render + sleep 15 + # Screenshot the virtual display + import -window root screenshot.png || true + # Verify app is still running (didn't crash) + if kill -0 $APP_PID 2>/dev/null; then + echo "✅ AppImage launched successfully" + kill $APP_PID || true + else + echo "❌ AppImage crashed during startup" + exit 1 + fi + + - name: Upload smoke test screenshot + if: contains(matrix.platform, 'ubuntu') + uses: actions/upload-artifact@v4 + with: + name: linux-smoke-test-screenshot-${{ matrix.label }} + path: screenshot.png + if-no-files-found: warn + + - name: Cleanup Apple signing materials + if: always() && contains(matrix.platform, 'macos') + shell: bash + run: | + rm -f certificate.p12 + security delete-keychain build.keychain || true + + - name: Report build duration + if: always() + shell: bash + run: | + if [ -z "${JOB_START_EPOCH:-}" ]; then + echo "::warning::JOB_START_EPOCH missing; duration unavailable." + exit 0 + fi + END_EPOCH=$(date +%s) + ELAPSED=$((END_EPOCH - JOB_START_EPOCH)) + MINUTES=$((ELAPSED / 60)) + SECONDS=$((ELAPSED % 60)) + echo "Build duration for ${{ matrix.label }}: ${MINUTES}m ${SECONDS}s" + + # ── Update release notes with changelog after all builds complete ── + update-release-notes: + needs: build-tauri + if: always() && contains(needs.build-tauri.result, 'success') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + + - name: Generate and update release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + VERSION=$(jq -r .version src-tauri/tauri.conf.json) + TAG="v${VERSION}" + PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + COMMITS="Initial release" + else + COMMITS=$(git log "${PREV_TAG}..${TAG}" --oneline --no-merges | sed 's/^[a-f0-9]*//' | sed 's/^ /- /') + fi + + BODY=$(cat < target' + cache-on-failure: true + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + xwayland-run \ + xvfb \ + imagemagick \ + xdotool + + - name: Install frontend dependencies + run: npm ci + + - name: Bundle Node.js runtime + shell: bash + env: + NODE_VERSION: '22.14.0' + NODE_TARGET: 'x86_64-unknown-linux-gnu' + run: bash scripts/download-node.sh --target "$NODE_TARGET" + + - name: Build Tauri app + uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_VARIANT: full + VITE_DESKTOP_RUNTIME: '1' + CONVEX_URL: ${{ secrets.CONVEX_URL }} + with: + args: '' + retryAttempts: 1 + + - name: Smoke-test AppImage + shell: bash + run: | + APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -1) + if [ -z "$APPIMAGE" ]; then + echo "::error::No AppImage found after build" + exit 1 + fi + chmod +x "$APPIMAGE" + APPIMAGE_ABS=$(realpath "$APPIMAGE") + + # Write the inner test script (runs inside the display server) + cat > /tmp/smoke-test.sh <<'SCRIPT' + #!/bin/bash + set -x + echo "DISPLAY=$DISPLAY WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-unset}" + + GDK_BACKEND=x11 "$APPIMAGE_ABS" --no-sandbox 2>&1 | tee /tmp/app.log & + APP_PID=$! + sleep 20 + + # Screenshot via X11 + import -window root /tmp/screenshot.png 2>/dev/null || true + + # Verify app is still running + if kill -0 $APP_PID 2>/dev/null; then + echo "APP_STATUS=running" + else + echo "APP_STATUS=crashed" + echo "--- App log ---" + tail -50 /tmp/app.log || true + fi + + # Window info + xdotool search --name "" getwindowname 2>/dev/null | head -5 || true + + kill $APP_PID 2>/dev/null || true + SCRIPT + chmod +x /tmp/smoke-test.sh + + export APPIMAGE_ABS + RESULT=0 + + # --- Try 1: xwfb-run (Xwayland on headless Wayland compositor) --- + if command -v xwfb-run &>/dev/null; then + echo "=== Using xwfb-run (Xwayland + headless compositor) ===" + timeout 90 xwfb-run -- bash /tmp/smoke-test.sh 2>&1 | tee /tmp/display-server.log || RESULT=$? + else + echo "xwfb-run not found, skipping" + RESULT=1 + fi + + # --- Fallback: plain Xvfb --- + if [ $RESULT -ne 0 ] || [ ! -f /tmp/screenshot.png ]; then + echo "=== Falling back to Xvfb ===" + Xvfb :99 -screen 0 1440x900x24 & + XVFB_PID=$! + export DISPLAY=:99 + sleep 2 + bash /tmp/smoke-test.sh 2>&1 | tee /tmp/display-server.log + kill $XVFB_PID 2>/dev/null || true + fi + + # --- Copy screenshot to workspace --- + cp /tmp/screenshot.png screenshot.png 2>/dev/null || true + + # --- Check results --- + if grep -q "APP_STATUS=crashed" /tmp/display-server.log 2>/dev/null; then + echo "❌ AppImage crashed during startup" + exit 1 + fi + + if grep -q "APP_STATUS=running" /tmp/display-server.log 2>/dev/null; then + echo "✅ AppImage launched successfully" + else + echo "⚠️ Could not determine app status" + fi + + # --- Check screenshot has non-black content --- + if [ -f screenshot.png ]; then + COLORS=$(identify -verbose screenshot.png 2>/dev/null | grep "Colors:" | awk '{print $2}') + echo "Screenshot unique colors: ${COLORS:-unknown}" + if [ "${COLORS:-0}" -le 5 ]; then + echo "⚠️ Screenshot appears blank (only $COLORS colors). App may not have rendered." + else + echo "✅ Screenshot has content ($COLORS unique colors)" + fi + fi + + - name: Upload smoke test screenshot + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-smoke-test-screenshot + path: screenshot.png + if-no-files-found: warn + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-smoke-test-logs + path: | + /tmp/display-server.log + /tmp/app.log + if-no-files-found: warn diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 000000000..eea71bab5 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,23 @@ +name: Typecheck + +on: + pull_request: + paths-ignore: + - '*.md' + - '.planning/**' + - 'docs/**' + - 'e2e/**' + - 'scripts/**' + +jobs: + typecheck: + if: github.event.pull_request.head.repo.full_name == github.repository + 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 typecheck diff --git a/.gitignore b/.gitignore index 9cd681922..e0473ed68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,32 @@ node_modules/ +.idea/ dist/ .DS_Store *.log .env .env.local .playwright-mcp/ +.vercel +api/\[domain\]/v1/\[rpc\].js +api/\[\[...path\]\].js +.claude/ +.cursor/ +CLAUDE.md +.env.vercel-backup +.env.vercel-export +.agent/ +.factory/ +.windsurf/ +skills/ +ideas/ +docs/internal/ +test-results/ +src-tauri/sidecar/node/* +!src-tauri/sidecar/node/.gitkeep + +# AI planning session state +.planning/ + +# Compiled sebuf gateway bundle (built by scripts/build-sidecar-sebuf.mjs) +api/[[][[].*.js +.claudedocs/ diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..d6d093b43 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,8 @@ +echo "Running type check..." +npm run typecheck || exit 1 + +echo "Running Vite build (catches esbuild errors in server/)..." +npm run build:full || exit 1 + +echo "Running version sync check..." +npm run version:check || exit 1 diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 000000000..c9f0a11a9 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,10 @@ +{ + // 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/**", ".planning/**"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8d7d59cd3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,367 @@ +# Changelog + +All notable changes to World Monitor are documented here. + +## [2.5.10] - 2026-02-26 + +### Fixed + +- **Yahoo Finance rate-limit UX**: Show "rate limited — retrying shortly" instead of generic "Failed to load" on Markets, ETF, Commodities, and Sector panels when Yahoo returns 429 (#407) +- **Sequential Yahoo calls**: Replace `Promise.all` with staggered batching in commodity quotes, ETF flows, and macro signals to prevent 429 rate limiting (#406) +- **Sector heatmap Yahoo fallback**: Sector data now loads via Yahoo Finance when `FINNHUB_API_KEY` is missing (#406) +- **Finnhub-to-Yahoo fallback**: Market quotes route Finnhub symbols through Yahoo when API key is not configured (#407) +- **ETF early-exit on rate limit**: Skip retry loop and show rate-limit message immediately instead of waiting 60s (#407) +- **Sidecar auth resilience**: 401-retry with token refresh for stale sidecar tokens after restart; `diagFetch` auth helper for settings window diagnostics (#407) +- **Verbose toggle persistence**: Write verbose state to writable data directory instead of read-only app bundle on macOS (#407) +- **AI summary verbosity**: Tighten prompts to 2 sentences / 60 words max with `max_tokens` reduced from 150 to 100 (#404) +- **Settings modal title**: Rename from "PANELS" to "SETTINGS" across all 17 locales (#403) +- **Sentry noise filters**: CSS.escape() for news ID selectors, player.destroy guard, 11 new ignoreErrors patterns, blob: URL extension frame filter (#402) + +--- + +## [2.5.6] - 2026-02-23 + +### Added + +- **Greek (Ελληνικά) locale** — full translation of all 1,397 i18n keys (#256) +- **Nigeria RSS feeds** — 5 new sources: Premium Times, Vanguard, Channels TV, Daily Trust, ThisDay Live +- **Greek locale feeds** — Naftemporiki, in.gr, iefimerida.gr for Greek-language news coverage +- **Brasil Paralelo source** — Brazilian news with RSS feed and source tier (#260) + +### Performance + +- **AIS relay optimization** — backpressure queue with configurable watermarks, spatial indexing for chokepoint detection (O(chokepoints) vs O(chokepoints × vessels)), pre-serialized + pre-gzipped snapshot cache eliminating per-request JSON.stringify + gzip CPU (#266) + +### Fixed + +- **Vietnam flag country code** — corrected flag emoji in language selector (#245) +- **Sentry noise filters** — added patterns for SW FetchEvent, PostHog ingest; enabled SW POST method for PostHog analytics (#246) +- **Service Worker same-origin routing** — restricted SW route patterns to same-origin only, preventing cross-origin fetch interception (#247, #251) +- **Social preview bot allowlisting** — whitelisted Twitterbot, facebookexternalhit, and other crawlers on OG image assets (#251) +- **Windows CORS for Tauri** — allow `http://` origin from `tauri.localhost` for Windows desktop builds (#262) +- **Linux AppImage GLib crash** — fix GLib symbol mismatch on newer distros by bundling compatible libraries (#263) + +--- + +## [2.5.2] - 2026-02-21 + +### Fixed + +- **QuotaExceededError handling** — detect storage quota exhaustion and stop further writes to localStorage/IndexedDB instead of silently failing; shared `markStorageQuotaExceeded()` flag across persistent-cache and utility storage +- **deck.gl null.getProjection crash** — wrap `setProps()` calls in try/catch to survive map mid-teardown races in debounced/RAF callbacks +- **MapLibre "Style is not done loading"** — guard `setFilter()` in mousemove/mouseout handlers during theme switches +- **YouTube invalid video ID** — validate video ID format (`/^[\w-]{10,12}$/`) before passing to IFrame Player constructor +- **Vercel build skip on empty SHA** — guard `ignoreCommand` against unset `VERCEL_GIT_PREVIOUS_SHA` (first deploy, force deploy) which caused `git diff` to fail and cancel builds +- **Sentry noise filters** — added 7 patterns: iOS readonly property, SW FetchEvent, toLowerCase/trim/indexOf injections, QuotaExceededError + +--- + +## [2.5.1] - 2026-02-20 + +### Performance + +- **Batch FRED API requests** — frontend now sends a single request with comma-separated series IDs instead of 7 parallel edge function invocations, eliminating Vercel 25s timeouts +- **Parallel UCDP page fetches** — replaced sequential loop with Promise.all for up to 12 pages, cutting fetch time from ~96s worst-case to ~8s +- **Bot protection middleware** — blocks known social-media crawlers from hitting API routes, reducing unnecessary edge function invocations +- **Extended API cache TTLs** — country-intel 12h→24h, GDELT 2h→4h, nuclear 12h→24h; Vercel ignoreCommand skips non-code deploys + +### Fixed + +- **Partial UCDP cache poisoning** — failed page fetches no longer silently produce incomplete results cached for 6h; partial results get 10-min TTL in both Redis and memory, with `partial: true` flag propagated to CDN cache headers +- **FRED upstream error masking** — single-series failures now return 502 instead of empty 200; batch mode surfaces per-series errors and returns 502 when all fail +- **Sentry `Load failed` filter** — widened regex from `^TypeError: Load failed$` to `^TypeError: Load failed( \(.*\))?$` to catch host-suffixed variants (e.g., gamma-api.polymarket.com) +- **Tooltip XSS hardening** — replaced `rawHtml()` with `safeHtml()` allowlist sanitizer for panel info tooltips +- **UCDP country endpoint** — added missing HTTP method guards (OPTIONS/GET) +- **Middleware exact path matching** — social preview bot allowlist uses `Set.has()` instead of `startsWith()` prefix matching + +### Changed + +- FRED batch API supports up to 15 comma-separated series IDs with deduplication +- Missing FRED API key returns 200 with `X-Data-Status: skipped-no-api-key` header instead of silent empty response +- LAYER_TO_SOURCE config extracted from duplicate inline mappings into shared constant + +--- + +## [2.5.0] - 2026-02-20 + +### Highlights + +**Local LLM Support (Ollama / LM Studio)** — Run AI summarization entirely on your own hardware with zero cloud dependency. The desktop app auto-discovers models from any OpenAI-compatible local inference server (Ollama, LM Studio, llama.cpp, vLLM) and populates a selection dropdown. A 4-tier fallback chain ensures summaries always generate: Local LLM → Groq → OpenRouter → browser-side T5. Combined with the Tauri desktop app, this enables fully air-gapped intelligence analysis where no data leaves your machine. + +### Added + +- **Ollama / LM Studio integration** — local AI summarization via OpenAI-compatible `/v1/chat/completions` endpoint with automatic model discovery, embedding model filtering, and fallback to manual text input +- **4-tier summarization fallback chain** — Ollama (local) → Groq (cloud) → OpenRouter (cloud) → Transformers.js T5 (browser), each with 5-second timeout before silently advancing to the next +- **Shared summarization handler factory** — all three API tiers use identical logic for headline deduplication (Jaccard >0.6), variant-aware prompting, language-aware output, and Redis caching (`summary:v3:{mode}:{variant}:{lang}:{hash}`) +- **Settings window with 3 tabs** — dedicated **LLMs** tab (Ollama endpoint/model, Groq, OpenRouter), **API Keys** tab (12+ data source credentials), and **Debug & Logs** tab (traffic log, verbose mode, log file access). Each tab runs an independent verification pipeline +- **Consolidated keychain vault** — all desktop secrets stored as a single JSON blob in one OS keychain entry (`secrets-vault`), reducing macOS Keychain authorization prompts from 20+ to exactly 1 on app startup. One-time auto-migration from individual entries with cleanup +- **Cross-window secret synchronization** — saving credentials in the Settings window immediately syncs to the main dashboard via `localStorage` broadcast, with no app restart needed +- **API key verification pipeline** — each credential is validated against its provider's actual API endpoint. Network errors (timeouts, DNS failures) soft-pass to prevent transient failures from blocking key storage; only explicit 401/403 marks a key invalid +- **Plaintext URL inputs** — endpoint URLs (Ollama API, relay URLs, model names) display as readable text instead of masked password dots in Settings +- **5 new defense/intel RSS feeds** — Military Times, Task & Purpose, USNI News, Oryx OSINT, UK Ministry of Defence +- **Koeberg nuclear power plant** — added to the nuclear facilities map layer (the only commercial reactor in Africa, Cape Town, South Africa) +- **Privacy & Offline Architecture** documentation — README now details the three privacy levels: full cloud, desktop with cloud APIs, and air-gapped local with Ollama +- **AI Summarization Chain** documentation — README includes provider fallback flow diagram and detailed explanation of headline deduplication, variant-aware prompting, and cross-user cache deduplication + +### Changed + +- AI fallback chain now starts with Ollama (local) before cloud providers +- Feature toggles increased from 14 to 15 (added AI/Ollama) +- Desktop architecture uses consolidated vault instead of per-key keychain entries +- README expanded with ~85 lines of new content covering local LLM support, privacy architecture, summarization chain internals, and desktop readiness framework + +### Fixed + +- URL and model fields in Settings display as plaintext instead of masked password dots +- OpenAI-compatible endpoint flow hardened for Ollama/LM Studio response format differences (thinking tokens, missing `choices` array edge cases) +- Sentry null guard for `getProjection()` crash with 6 additional noise filters +- PathLayer cache cleared on layer toggle-off to prevent stale WebGL buffer rendering + +--- + +## [2.4.1] - 2026-02-19 + +### Fixed + +- **Map PathLayer cache**: Clear PathLayer on toggle-off to prevent stale WebGL buffers +- **Sentry noise**: Null guard for `getProjection()` crash and 6 additional noise filters +- **Markdown docs**: Resolve lint errors in documentation files + +--- + +## [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 + +- **Windows sidecar crash**: Strip `\\?\` UNC extended-length prefix from paths before passing to Node.js — Tauri `resource_dir()` on Windows returns UNC-prefixed paths that cause `EISDIR: lstat 'C:'` in Node.js module resolution +- **Windows sidecar CWD**: Set explicit `current_dir` on the Node.js Command to prevent bare drive-letter working directory issues from NSIS shortcut launcher +- **Sidecar package scope**: Add `package.json` with `"type": "module"` to sidecar directory, preventing Node.js from walking up the entire directory tree during ESM scope resolution + +## [2.3.3] - 2026-02-16 + +### Fixed + +- **Keychain persistence**: Enable `apple-native` (macOS) and `windows-native` (Windows) features for the `keyring` crate — v3 ships with no default platform backends, so API keys were stored in-memory only and lost on restart +- **Settings key verification**: Soft-pass network errors during API key verification so transient sidecar failures don't block saving +- **Resilient keychain reads**: Use `Promise.allSettled` in `loadDesktopSecrets` so a single key failure doesn't discard all loaded secrets +- **Settings window capabilities**: Add `"settings"` to Tauri capabilities window list for core plugin permissions +- **Input preservation**: Capture unsaved input values before DOM re-render in settings panel + +## [2.3.0] - 2026-02-15 + +### Security + +- **CORS hardening**: Tighten Vercel preview deployment regex to block origin spoofing (`worldmonitorEVIL.vercel.app`) +- **Sidecar auth bypass**: Move `/api/local-env-update` behind `LOCAL_API_TOKEN` auth check +- **Env key allowlist**: Restrict sidecar env mutations to 18 known secret keys (matching `SUPPORTED_SECRET_KEYS`) +- **postMessage validation**: Add `origin` and `source` checks on incoming messages in LiveNewsPanel +- **postMessage targetOrigin**: Replace wildcard `'*'` with specific embed origin +- **CORS enforcement**: Add `isDisallowedOrigin()` check to 25+ API endpoints that were missing it +- **Custom CORS migration**: Migrate `gdelt-geo` and `eia` from custom CORS to shared `_cors.js` module +- **New CORS coverage**: Add CORS headers + origin check to `firms-fires`, `stock-index`, `youtube/live` +- **YouTube embed origins**: Tighten `ALLOWED_ORIGINS` regex in `youtube/embed.js` +- **CSP hardening**: Remove `'unsafe-inline'` from `script-src` in both `index.html` and `tauri.conf.json` +- **iframe sandbox**: Add `sandbox="allow-scripts allow-same-origin allow-presentation"` to YouTube embed iframe +- **Meta tag validation**: Validate URL query params with regex allowlist in `parseStoryParams()` + +### Fixed + +- **Service worker stale assets**: Add `skipWaiting`, `clientsClaim`, and `cleanupOutdatedCaches` to workbox config — fixes `NS_ERROR_CORRUPTED_CONTENT` / MIME type errors when users have a cached SW serving old HTML after redeployment + +## [2.2.6] - 2026-02-14 + +### Fixed + +- Filter trending noise and fix sidecar auth +- Restore tech variant panels +- Remove Market Radar and Economic Data panels from tech variant + +### Docs + +- Add developer X/Twitter link to Support section +- Add cyber threat API keys to `.env.example` + +## [2.2.5] - 2026-02-13 + +### Security + +- Migrate all Vercel edge functions to CORS allowlist +- Restrict Railway relay CORS to allowed origins only + +### Fixed + +- Hide desktop config panel on web +- Route World Bank & Polymarket via Railway relay + +## [2.2.3] - 2026-02-12 + +### Added + +- Cyber threat intelligence map layer (Feodo Tracker, URLhaus, C2IntelFeeds, OTX, AbuseIPDB) +- Trending keyword spike detection with end-to-end flow +- Download desktop app slide-in banner for web visitors +- Country briefs in Cmd+K search + +### Changed + +- Redesign 4 panels with table layouts and scoped styles +- Redesign population exposure panel and reorder UCDP columns +- Dramatically increase cyber threat map density + +### Fixed + +- Resolve z-index conflict between pinned map and panels grid +- Cap geo enrichment at 12s timeout, prevent duplicate download banners +- Replace ipwho.is/ipapi.co with ipinfo.io/freeipapi.com for geo enrichment +- Harden trending spike processing and optimize hot paths +- Improve cyber threat tooltip/popup UX and dot visibility + +## [2.2.2] - 2026-02-10 + +### Added + +- Full-page Country Brief Page replacing modal overlay +- Download redirect API for platform-specific installers + +### Fixed + +- Normalize country name from GeoJSON to canonical TIER1 name +- Tighten headline relevance, add Top News section, compact markets +- Hide desktop config panel on web, fix irrelevant prediction markets +- Tone down climate anomalies heatmap to stop obscuring other layers +- macOS: hide window on close instead of quitting + +### Performance + +- Reduce idle CPU from pulse animation loop +- Harden regression guardrails in CI, cache, and map clustering + +## [2.2.1] - 2026-02-08 + +### Fixed + +- Consolidate variant naming and fix PWA tile caching +- Windows settings window: async command, no menu bar, no white flash +- Constrain layers menu height in DeckGLMap +- Allow Cloudflare Insights script in CSP +- macOS build failures when Apple signing secrets are missing + +## [2.2.0] - 2026-02-07 + +Initial v2.2 release with multi-variant support (World + Tech), desktop app (Tauri), and comprehensive geopolitical intelligence features. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..68f3e5731 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,119 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the +World Monitor community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Scope + +This Code of Conduct applies within all community spaces (GitHub issues, pull +requests, discussions, and any associated communication channels) and also +applies when an individual is officially representing the community in public +spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer at **[GitHub Issues](https://github.com/koala73/worldmonitor/issues)** or by contacting the +repository owner directly through GitHub. + +All complaints will be reviewed and investigated promptly and fairly. The project +team is obligated to maintain confidentiality with regard to the reporter of an +incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..debce79db --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,301 @@ +# Contributing to World Monitor + +Thank you for your interest in contributing to World Monitor! This project thrives on community contributions — whether it's code, data sources, documentation, or bug reports. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [How to Contribute](#how-to-contribute) +- [Pull Request Process](#pull-request-process) +- [AI-Assisted Development](#ai-assisted-development) +- [Coding Standards](#coding-standards) +- [Working with Sebuf (RPC Framework)](#working-with-sebuf-rpc-framework) +- [Adding Data Sources](#adding-data-sources) +- [Adding RSS Feeds](#adding-rss-feeds) +- [Reporting Bugs](#reporting-bugs) +- [Feature Requests](#feature-requests) +- [Code of Conduct](#code-of-conduct) + +## Architecture Overview + +World Monitor is a real-time OSINT dashboard built with **Vanilla TypeScript** (no UI framework), **MapLibre GL + deck.gl** for map rendering, and a custom Proto-first RPC framework called **Sebuf** for all API communication. + +### Key Technologies + +| Technology | Purpose | +|---|---| +| **TypeScript** | All code — frontend, edge functions, and handlers | +| **Vite** | Build tool and dev server | +| **Sebuf** | Proto-first HTTP RPC framework for typed API contracts | +| **Protobuf / Buf** | Service and message definitions across 17 domains | +| **MapLibre GL** | Base map rendering (tiles, globe mode, camera) | +| **deck.gl** | WebGL overlay layers (scatterplot, geojson, arcs, heatmaps) | +| **d3** | Charts, sparklines, and data visualization | +| **Vercel Edge Functions** | Serverless API gateway | +| **Tauri v2** | Desktop app (Windows, macOS, Linux) | +| **Convex** | Minimal backend (beta interest registration only) | +| **Playwright** | End-to-end and visual regression testing | + +### Variant System + +The codebase produces three app variants from the same source, each targeting a different audience: + +| Variant | Command | Focus | +|---|---|---| +| `full` | `npm run dev` | Geopolitics, military, conflicts, infrastructure | +| `tech` | `npm run dev:tech` | Startups, AI/ML, cloud, cybersecurity | +| `finance` | `npm run dev:finance` | Markets, trading, central banks, commodities | + +Variants share all code but differ in default panels, map layers, and RSS feeds. Variant configs live in `src/config/variants/`. + +### Directory Structure + +| Directory | Purpose | +|---|---| +| `src/components/` | UI components — Panel subclasses, map, modals (~50 panels) | +| `src/services/` | Data fetching modules — sebuf client wrappers, AI, signal analysis | +| `src/config/` | Static data and variant configs (feeds, geo, military, pipelines, ports) | +| `src/generated/` | Auto-generated sebuf client + server stubs (**do not edit by hand**) | +| `src/types/` | TypeScript type definitions | +| `src/locales/` | i18n JSON files (14 languages) | +| `src/workers/` | Web Workers for analysis | +| `server/` | Sebuf handler implementations for all 17 domain services | +| `api/` | Vercel Edge Functions (sebuf gateway + legacy endpoints) | +| `proto/` | Protobuf service and message definitions | +| `data/` | Static JSON datasets | +| `docs/` | Documentation + generated OpenAPI specs | +| `src-tauri/` | Tauri v2 Rust app + Node.js sidecar for desktop builds | +| `e2e/` | Playwright end-to-end tests | +| `scripts/` | Build and packaging scripts | + +## Getting Started + +1. **Fork** the repository on GitHub +2. **Clone** your fork locally: + ```bash + git clone https://github.com//worldmonitor.git + cd worldmonitor + ``` +3. **Create a branch** for your work: + ```bash + git checkout -b feature/your-feature-name + ``` + +## Development Setup + +```bash +# Install everything (buf CLI, sebuf plugins, npm deps, Playwright browsers) +make install + +# Start the development server (full variant, default) +npm run dev + +# Start other variants +npm run dev:tech +npm run dev:finance + +# Run type checking +npm run typecheck + +# Run tests +npm run test:data # Data integrity tests +npm run test:e2e # Playwright end-to-end tests + +# Production build (per variant) +npm run build # full +npm run build:tech +npm run build:finance +``` + +The dev server runs at `http://localhost:3000`. Run `make help` to see all available make targets. + +### Environment Variables (Optional) + +For full functionality, copy `.env.example` to `.env.local` and fill in the API keys you need. The app runs without any API keys — external data sources will simply be unavailable. + +See [API Dependencies](docs/DOCUMENTATION.md#api-dependencies) for the full list. + +## How to Contribute + +### Types of Contributions We Welcome + +- **Bug fixes** — found something broken? Fix it! +- **New data layers** — add new geospatial data sources to the map +- **RSS feeds** — expand our 100+ feed collection with quality sources +- **UI/UX improvements** — make the dashboard more intuitive +- **Performance optimizations** — faster loading, better caching +- **Documentation** — improve docs, add examples, fix typos +- **Accessibility** — make the dashboard usable by everyone +- **Internationalization** — help make World Monitor available in more languages +- **Tests** — add unit or integration tests + +### What We're Especially Looking For + +- New data layers (see [Adding Data Sources](#adding-data-sources)) +- Feed quality improvements and new RSS sources +- Mobile responsiveness improvements +- Performance optimizations for the map rendering pipeline +- Better anomaly detection algorithms + +## Pull Request Process + +1. **Update documentation** if your change affects the public API or user-facing behavior +2. **Run type checking** before submitting: `npm run typecheck` +3. **Test your changes** locally with at least the `full` variant, and any other variant your change affects +4. **Keep PRs focused** — one feature or fix per pull request +5. **Write a clear description** explaining what your PR does and why +6. **Link related issues** if applicable + +### PR Title Convention + +Use a descriptive title that summarizes the change: + +- `feat: add earthquake magnitude filtering to map layer` +- `fix: resolve RSS feed timeout for Al Jazeera` +- `docs: update API dependencies section` +- `perf: optimize marker clustering at low zoom levels` +- `refactor: extract threat classifier into separate module` + +### Review Process + +- All PRs require review from a maintainer before merging +- Maintainers may request changes — this is normal and collaborative +- Once approved, a maintainer will merge your PR + +## AI-Assisted Development + +We fully embrace AI-assisted development. Many of our own PRs are labeled with the LLM that helped produce them (e.g., `claude`, `codex`, `cursor`), and contributors are welcome to use any AI tools they find helpful. + +That said, **all code is held to the same quality bar regardless of how it was written**. AI-generated code will be reviewed with the same scrutiny as human-written code. Contributors are responsible for understanding and being able to explain every line they submit. Blindly pasting LLM output without review is discouraged — treat AI as a collaborator, not a replacement for your own judgement. + +## Coding Standards + +### TypeScript + +- Use TypeScript for all new code +- Avoid `any` types — use proper typing or `unknown` with type guards +- Export interfaces/types for public APIs +- Use meaningful variable and function names + +### Code Style + +- Follow the existing code style in the repository +- Use `const` by default, `let` when reassignment is needed +- Prefer functional patterns (map, filter, reduce) over imperative loops +- Keep functions focused — one responsibility per function +- Add JSDoc comments for exported functions and complex logic + +### File Organization + +- Static layer/geo data and variant configs go in `src/config/` +- Sebuf handler implementations go in `server/worldmonitor/{domain}/v1/` +- Edge function gateway and legacy endpoints go in `api/` +- UI components (panels, map, modals) go in `src/components/` +- Service modules (data fetching, client wrappers) go in `src/services/` +- Proto definitions go in `proto/worldmonitor/{domain}/v1/` + +## Working with Sebuf (RPC Framework) + +Sebuf is the project's custom Proto-first HTTP RPC framework — a lightweight alternative to gRPC-Web. All API communication between client and server uses Sebuf. + +### How It Works + +1. **Proto definitions** in `proto/worldmonitor/{domain}/v1/` define services and messages +2. **Code generation** (`make generate`) produces: + - TypeScript clients in `src/generated/client/` (e.g., `MarketServiceClient`) + - Server route factories in `src/generated/server/` (e.g., `createMarketServiceRoutes`) +3. **Handlers** in `server/worldmonitor/{domain}/v1/handler.ts` implement the service interface +4. **Gateway** in `api/[domain]/v1/[rpc].ts` registers all handlers and routes requests +5. **Clients** in `src/services/{domain}/index.ts` wrap the generated client for app use + +### Adding a New RPC Method + +1. Add the method to the `.proto` service definition +2. Run `make generate` to regenerate client/server stubs +3. Implement the handler method in the domain's `handler.ts` +4. The client stub is auto-generated — use it from `src/services/{domain}/` + +Use `make lint` to lint proto files and `make breaking` to check for breaking changes against main. + +### Proto Conventions + +- **Time fields**: Use `int64` (Unix epoch milliseconds), not `google.protobuf.Timestamp` +- **int64 encoding**: Apply `[(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]` on time fields so TypeScript receives `number` instead of `string` +- **HTTP annotations**: Every RPC method needs `option (sebuf.http.config) = { path: "...", method: POST }` + +### Proto Codegen Requirements + +Run `make install` to install everything automatically, or install individually: + +```bash +make install-buf # Install buf CLI (requires Go) +make install-plugins # Install sebuf protoc-gen plugins (requires Go) +``` + +## Adding Data Sources + +To add a new data layer to the map: + +1. **Define the data source** — identify the API or dataset you want to integrate +2. **Add the proto service** (if the data needs a backend proxy) — define messages and RPC methods in `proto/worldmonitor/{domain}/v1/` +3. **Generate stubs** — run `make generate` +4. **Implement the handler** in `server/worldmonitor/{domain}/v1/` +5. **Register the handler** in `api/[domain]/v1/[rpc].ts` and `vite.config.ts` (for local dev) +6. **Create the service module** in `src/services/{domain}/` wrapping the generated client +7. **Add the layer config** and implement the map renderer following existing layer patterns +8. **Add to layer toggles** — make it toggleable in the UI +9. **Document the source** — add it to `docs/DOCUMENTATION.md` + +For endpoints that deal with non-JSON payloads (XML feeds, binary data, HTML embeds), you can add a standalone Edge Function in `api/` instead of Sebuf. For anything returning JSON, prefer Sebuf — the typed contracts are always worth it. + +### Data Source Requirements + +- Must be freely accessible (no paid-only APIs for core functionality) +- Must have a permissive license or be public government data +- Should update at least daily for real-time relevance +- Must include geographic coordinates or be geo-locatable + +## Adding RSS Feeds + +To add new RSS feeds: + +1. Verify the feed is reliable and actively maintained +2. Assign a **source tier** (1-4) based on editorial reliability +3. Flag any **state affiliation** or **propaganda risk** +4. Categorize the feed (geopolitics, defense, energy, tech, etc.) +5. Test that the feed parses correctly through the RSS proxy + +## Reporting Bugs + +When filing a bug report, please include: + +- **Description** — clear description of the issue +- **Steps to reproduce** — how to trigger the bug +- **Expected behavior** — what should happen +- **Actual behavior** — what actually happens +- **Screenshots** — if applicable +- **Browser/OS** — your environment details +- **Console errors** — any relevant browser console output + +Use the [Bug Report issue template](https://github.com/koala73/worldmonitor/issues/new/choose) when available. + +## Feature Requests + +We welcome feature ideas! When suggesting a feature: + +- **Describe the problem** it solves +- **Propose a solution** with as much detail as possible +- **Consider alternatives** you've thought about +- **Provide context** — who would benefit from this feature? + +Use the [Feature Request issue template](https://github.com/koala73/worldmonitor/issues/new/choose) when available. + +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior through GitHub issues or by contacting the repository owner. + +--- + +Thank you for helping make World Monitor better! 🌍 diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..0b1bbc641 --- /dev/null +++ b/LICENSE @@ -0,0 +1,669 @@ +World Monitor — Real-time global intelligence dashboard +Copyright (C) 2024-2026 Elie Habib + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..9de826b99 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +.PHONY: help lint generate breaking format check clean deps install install-buf install-plugins install-npm install-playwright +.DEFAULT_GOAL := help + +# Variables +PROTO_DIR := proto +GEN_CLIENT_DIR := src/generated/client +GEN_SERVER_DIR := src/generated/server +DOCS_API_DIR := docs/api + +# Go install settings +GO_PROXY := GOPROXY=direct +GO_PRIVATE := GOPRIVATE=github.com/SebastienMelki +GO_INSTALL := $(GO_PROXY) $(GO_PRIVATE) go install + +# Required tool versions +BUF_VERSION := v1.64.0 +SEBUF_VERSION := v0.7.0 + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +install: install-buf install-plugins install-npm install-playwright deps ## Install everything (buf, sebuf plugins, npm deps, proto deps, browsers) + +install-buf: ## Install buf CLI + @if command -v buf >/dev/null 2>&1; then \ + echo "buf already installed: $$(buf --version)"; \ + else \ + echo "Installing buf..."; \ + $(GO_INSTALL) github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION); \ + echo "buf installed!"; \ + fi + +install-plugins: ## Install sebuf protoc plugins (requires Go) + @echo "Installing sebuf protoc plugins $(SEBUF_VERSION)..." + @$(GO_INSTALL) github.com/SebastienMelki/sebuf/cmd/protoc-gen-ts-client@$(SEBUF_VERSION) + @$(GO_INSTALL) github.com/SebastienMelki/sebuf/cmd/protoc-gen-ts-server@$(SEBUF_VERSION) + @$(GO_INSTALL) github.com/SebastienMelki/sebuf/cmd/protoc-gen-openapiv3@$(SEBUF_VERSION) + @echo "Plugins installed!" + +install-npm: ## Install npm dependencies + npm install + +install-playwright: ## Install Playwright browsers for e2e tests + npx playwright install chromium + +deps: ## Install/update buf proto dependencies + cd $(PROTO_DIR) && buf dep update + +lint: ## Lint protobuf files + cd $(PROTO_DIR) && buf lint + +generate: clean ## Generate code from proto definitions + @mkdir -p $(GEN_CLIENT_DIR) $(GEN_SERVER_DIR) $(DOCS_API_DIR) + cd $(PROTO_DIR) && buf generate + @echo "Code generation complete!" + +breaking: ## Check for breaking changes against main + cd $(PROTO_DIR) && buf breaking --against '.git#branch=main,subdir=proto' + +format: ## Format protobuf files + cd $(PROTO_DIR) && buf format -w + +check: lint generate ## Run all checks (lint + generate) + +clean: ## Clean generated files + @rm -rf $(GEN_CLIENT_DIR) + @rm -rf $(GEN_SERVER_DIR) + @rm -rf $(DOCS_API_DIR) + @echo "Clean complete!" diff --git a/README.md b/README.md index 62186786d..f14fdaca2 100644 --- a/README.md +++ b/README.md @@ -1,362 +1,1609 @@ # World Monitor -Real-time global intelligence dashboard aggregating news, markets, geopolitical data, and infrastructure monitoring into a unified situation awareness interface. - -![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white) -![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat&logo=vite&logoColor=white) -![D3.js](https://img.shields.io/badge/D3.js-F9A03C?style=flat&logo=d3.js&logoColor=white) - -## Features - -### Interactive Global Map -- **Zoom & Pan** - Smooth navigation with mouse/trackpad gestures -- **Multiple Views** - Global, US, and MENA region presets -- **Layer System** - Toggle visibility of different data layers -- **Time Filtering** - Filter events by time range (1h to 7d) - -### Data Layers - -| Layer | Description | -|-------|-------------| -| **Hotspots** | Intelligence hotspots with activity levels based on news correlation | -| **Conflicts** | Active conflict zones with party information | -| **Military Bases** | Global military installations | -| **Pipelines** | 88 major oil & gas pipelines worldwide | -| **Undersea Cables** | Critical internet infrastructure | -| **Nuclear Facilities** | Power plants and research reactors | -| **Gamma Irradiators** | IAEA-tracked radiation sources | -| **AI Datacenters** | Major AI compute infrastructure | -| **Earthquakes** | Live USGS seismic data | -| **Weather Alerts** | Severe weather warnings | -| **Internet Outages** | Network connectivity disruptions | -| **Sanctions** | Countries under economic sanctions | -| **Economic Centers** | Major exchanges and central banks | - -### News Aggregation - -Multi-source RSS aggregation across categories: -- **World / Geopolitical** - BBC, Reuters, AP, Guardian, NPR -- **Middle East / MENA** - Al Jazeera, BBC ME, CNN ME -- **Technology** - Hacker News, Ars Technica, The Verge, MIT Tech Review -- **AI / ML** - ArXiv, Hugging Face, VentureBeat, OpenAI -- **Finance** - CNBC, MarketWatch, Financial Times, Yahoo Finance -- **Government** - White House, State Dept, Pentagon, Treasury, Fed, SEC -- **Intel Feed** - Defense One, Breaking Defense, Bellingcat, Krebs Security -- **Think Tanks** - Foreign Policy, Brookings, CSIS, CFR -- **Layoffs Tracker** - Tech industry job cuts -- **Congress Trades** - Congressional stock trading activity - -### Market Data -- **Stocks** - Major indices and tech stocks -- **Commodities** - Oil, gold, natural gas, copper -- **Crypto** - Bitcoin, Ethereum, and top cryptocurrencies -- **Sector Heatmap** - Visual sector performance -- **Economic Indicators** - Fed data (GDP, inflation, unemployment) - -### 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) -- Nuclear facilities and irradiators -- Markets and predictions - -### Data Export -- JSON export of current dashboard state -- Historical playback from snapshots +**Real-time global intelligence dashboard** — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface. + +[![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: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Last commit](https://img.shields.io/github/last-commit/koala73/worldmonitor)](https://github.com/koala73/worldmonitor/commits/main) +[![Latest release](https://img.shields.io/github/v/release/koala73/worldmonitor?style=flat)](https://github.com/koala73/worldmonitor/releases/latest) + +

+ Web App  + Tech Variant  + Finance Variant +

+ +

+ Download Windows  + Download macOS ARM  + Download macOS Intel  + Download Linux +

+ +

+ Full Documentation  ·  + All Releases +

+ +![World Monitor Dashboard](new-world-monitor.png) --- -## Signal Intelligence +## 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 36+ toggleable data layers | +| Information overload | **AI-synthesized briefs** with focal point detection and local LLM support | +| 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 | +| Cloud-dependent AI tools | **Run AI locally** with Ollama/LM Studio — no API keys, no data leaves your machine | +| Web-only dashboards | **Native desktop app** (Tauri) for macOS, Windows, and Linux + installable PWA with offline map support | +| Flat 2D maps | **3D WebGL globe** with deck.gl rendering and 36+ toggleable data layers | +| Siloed financial data | **Finance variant** with 92 stock exchanges, 19 financial centers, 13 central banks, BIS data, WTO trade policy, and Gulf FDI tracking | +| Undocumented, fragile APIs | **Proto-first API contracts** — 20 typed services with auto-generated clients, servers, and OpenAPI docs | -The dashboard continuously analyzes data streams to detect significant patterns and anomalies. Signals appear in the header badge (⚡) with confidence scores. +--- + +## Live Demos -### Signal Types +| Variant | URL | Focus | +| ------------------- | ------------------------------------------------------------ | ------------------------------------------------ | +| **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 | +| **Happy Monitor** | [happy.worldmonitor.app](https://happy.worldmonitor.app) | Good news, positive trends, uplifting stories | -| Signal | Trigger | What It Means | -|--------|---------|---------------| -| **◉ Convergence** | 3+ source types report same story within 30 minutes | Multiple independent channels confirming the same event—higher likelihood of significance | -| **△ Triangulation** | Wire + Government + Intel sources align | The "authority triangle"—when official channels, wire services, and defense specialists all report the same thing | -| **🔥 Velocity Spike** | Topic mention rate doubles with 6+ sources/hour | A story is accelerating rapidly across the news ecosystem | -| **🔮 Prediction Leading** | Prediction market moves 5%+ with low news coverage | Markets pricing in information not yet reflected in news | -| **📊 Silent Divergence** | Market moves 2%+ with minimal related news | Unexplained price action—possible insider knowledge or algorithm-driven | +All four variants run from a single codebase — switch between them with one click via the header bar. -### How It Works +--- -The correlation engine maintains rolling snapshots of: -- News topic frequency (by keyword extraction) -- Market price changes -- Prediction market probabilities +## Key Features -Each refresh cycle compares current state to previous snapshot, applying thresholds and deduplication to avoid alert fatigue. Signals include confidence scores (60-95%) based on the strength of the pattern. +### Localization & Regional Support + +- **Multilingual UI** — Fully localized interface supporting **16 languages: English, French, Spanish, German, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, Japanese, Turkish, Thai, and Vietnamese**. Language bundles are lazy-loaded on demand — only the active language is fetched, keeping initial bundle size minimal. +- **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). Seven locales have dedicated native-language feed sets: French, Arabic, German, Spanish, Turkish (BBC Türkçe, Hurriyet, DW Turkish), Polish (TVN24, Polsat News, Rzeczpospolita), Russian (BBC Russian, Meduza, Novaya Gazeta Europe), Thai (Bangkok Post, Thai PBS), and Vietnamese (VnExpress, Tuoi Tre News). +- **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` +- **36+ 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, trade routes, and more +- **Smart clustering** — Supercluster groups markers at low zoom, expands on zoom in. Cluster thresholds adapt to zoom level +- **Progressive disclosure** — detail layers (bases, nuclear, datacenters) appear only when zoomed in; zoom-adaptive opacity fades markers from 0.2 at world view to 1.0 at street level +- **Label deconfliction** — overlapping labels (e.g., multiple BREAKING badges) are automatically suppressed by priority, highest-severity first +- **8 regional presets** — Global, Americas, Europe, MENA, Asia, Africa, Oceania, Latin America +- **Time filtering** — 1h, 6h, 24h, 48h, 7d event windows +- **URL state sharing** — map center, zoom, active layers, and time range are encoded in the URL for shareable views (`?view=mena&zoom=4&layers=conflicts,bases`) + +### AI-Powered Intelligence + +- **World Brief** — LLM-synthesized summary of top global developments with a 4-tier provider fallback chain: Ollama (local) → Groq (cloud) → OpenRouter (cloud) → browser-side T5 (Transformers.js). Each tier is attempted with a 5-second timeout before falling through to the next, so the UI is never blocked. Results are Redis-cached (24h TTL) and content-deduplicated so identical headlines across concurrent users trigger exactly one LLM call +- **Local LLM Support** — Ollama and LM Studio (any OpenAI-compatible endpoint) run AI summarization entirely on local hardware. No API keys required, no data leaves the machine. The desktop app auto-discovers available models from the local instance and populates a selection dropdown, filtering out embedding-only models. Default fallback model: `llama3.1:8b` +- **Hybrid Threat Classification** — instant keyword classifier with async LLM override for higher-confidence results +- **Focal Point Detection** — correlates entities across news, military activity, protests, outages, and markets to identify convergence +- **Country Instability Index** — real-time stability scores for every country with incoming data using weighted multi-signal blend. 23 curated tier-1 nations have tuned baseline risk profiles; all other countries receive universal scoring with sensible defaults when any event data (protests, conflicts, outages, displacement, climate anomalies) is detected +- **Trending Keyword Spike Detection** — 2-hour rolling window vs 7-day baseline flags surging terms across RSS feeds, with CVE/APT entity extraction and auto-summarization +- **Strategic Posture Assessment** — composite risk score combining all intelligence modules with trend detection +- **Country Brief Pages** — click any country for a full-page intelligence dossier with CII score ring, AI-generated analysis, top news with citation anchoring, prediction markets, 7-day event timeline, active signal chips, infrastructure exposure, and stock market index — exportable as JSON, CSV, or image + +### Real-Time Data Layers + +
+Geopolitical + +- Active conflict zones with escalation tracking (UCDP + ACLED) +- Intelligence hotspots with news correlation +- Social unrest events (dual-source: ACLED protests + GDELT geo-events, Haversine-deduplicated) +- Natural disasters from 3 sources (USGS earthquakes M4.5+, GDACS alerts, NASA EONET events) +- Sanctions regimes +- Cyber threat IOCs (C2 servers, malware hosts, phishing, malicious URLs) geo-located on the globe +- Weather alerts and severe conditions + +
+ +
+Military & Strategic + +- 220+ military bases from 9 operators +- Live military flight tracking (ADS-B) +- Naval vessel monitoring (AIS) +- Nuclear facilities & gamma irradiators +- APT cyber threat actor attribution +- Spaceports & launch facilities + +
+ +
+Infrastructure + +- Undersea cables with landing points, cable health advisories (NGA navigational warnings), and cable repair ship tracking +- Oil & gas pipelines +- AI datacenters (111 major clusters) +- 83 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) +- 19 global trade routes (container, energy, bulk) with multi-segment arcs through strategic chokepoints + +
+ +
+Market & Crypto Intelligence + +- 7-signal macro radar with composite BUY/CASH verdict +- Real-time crypto prices (BTC, ETH, SOL, XRP, and more) via CoinGecko +- BTC spot ETF flow tracker (IBIT, FBTC, GBTC, and 7 more) +- Stablecoin peg health monitor (USDT, USDC, DAI, FDUSD, USDe) +- Fear & Greed Index with 30-day history +- Bitcoin technical trend (SMA50, SMA200, VWAP, Mayer Multiple) +- JPY liquidity signal, QQQ/XLP macro regime, BTC hash rate +- Inline SVG sparklines and donut gauges for visual trends + +
+ +
+Tech Ecosystem (Tech variant) + +- Tech company HQs (Big Tech, unicorns, public) +- Startup hubs with funding data +- Cloud regions (AWS, Azure, GCP) +- Accelerators (YC, Techstars, 500) +- Upcoming tech conferences + +
+ +
+Finance & Markets (Finance variant) + +- 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) +- BIS central bank data — policy rates across major economies, real effective exchange rates (REER), and credit-to-GDP ratios sourced from the Bank for International Settlements +- 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 +- WTO trade policy intelligence — active trade restrictions, tariff trends, bilateral trade flows, and SPS/TBT barriers sourced from the World Trade Organization + +
+ +### 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 +- **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 +- **Global streaming quality control** — a user-selectable quality setting (auto, 360p, 480p, 720p) that applies to all live video streams across the dashboard. The preference persists in localStorage and propagates to active players via a `stream-quality-changed` CustomEvent — no reload required when switching quality +- **19 live webcams** — real-time YouTube streams from geopolitical hotspots across 4 regions (Middle East, Europe, Americas, Asia-Pacific). Grid view shows 4 strategic feeds simultaneously; single-feed view available. Region filtering (ALL/MIDEAST/EUROPE/AMERICAS/ASIA), idle-aware playback that pauses after 5 minutes, and Intersection Observer-based lazy loading +- **Custom keyword monitors** — user-defined keyword alerts with word-boundary matching (prevents "ai" from matching "train"), automatic color-coding from a 10-color palette, and multi-keyword support (comma-separated). Monitors search across both headline titles and descriptions and show real-time match counts +- **Entity extraction** — Auto-links countries, leaders, organizations +- **Instant flat render** — news items appear immediately as a flat list the moment feed data arrives. ML-based clustering (topic grouping, entity extraction, sentiment analysis) runs asynchronously in the background and progressively upgrades the view when ready — eliminating the 1–3 second blank delay that would occur if clustering blocked initial render. Finance variant categories fetch with 5 concurrent requests (vs 3) for ~10–15 second faster cold starts +- **Virtual scrolling** — news panels with 15+ items use a custom virtual list renderer that only creates DOM elements for visible items plus a 3-item overscan buffer. Viewport spacers simulate full-list height. Uses `requestAnimationFrame`-batched scroll handling and `ResizeObserver` for responsive adaptation. DOM elements are pooled and recycled rather than created/destroyed + +### Signal Aggregation & Anomaly Detection + +- **Multi-source signal fusion** — internet outages, military flights, naval vessels, protests, AIS disruptions, satellite fires, and keyword spikes are aggregated into a unified intelligence picture with per-country and per-region clustering +- **Temporal baseline anomaly detection** — Welford's online algorithm computes streaming mean/variance per event type, region, weekday, and month over a 90-day window. Z-score thresholds (1.5/2.0/3.0) flag deviations like "Military flights 3.2x normal for Thursday (January)" — stored in Redis via Upstash +- **Regional convergence scoring** — when multiple signal types spike in the same geographic area, the system identifies convergence zones and escalates severity + +### Story Sharing & Social Export + +- **Shareable intelligence stories** — generate country-level intelligence briefs with CII scores, threat counts, theater posture, and related prediction markets +- **Multi-platform export** — custom-formatted sharing for Twitter/X, LinkedIn, WhatsApp, Telegram, Reddit, and Facebook with platform-appropriate formatting +- **Deep links** — every story generates a unique URL (`/story?c=&t=`) with dynamic Open Graph meta tags for rich social previews +- **Canvas-based image generation** — stories render as PNG images for visual sharing, with QR codes linking back to the live dashboard +- **Dynamic Open Graph images** — the `/api/og-story` endpoint generates 1200×630px SVG cards on-the-fly for each country story. Cards display the country name, CII score gauge arc with threat-level coloring, a 0–100 score bar, and signal indicator chips (threats, military, markets, convergence). Social crawlers (Twitter, Facebook, LinkedIn, Telegram, Discord, Reddit, WhatsApp) receive these cards via `og:image` meta tags, while regular browsers get a 302 redirect to the SPA. Bot detection uses a user-agent regex for 10+ known social crawler signatures + +### Desktop Application (Tauri) + +- **Native desktop app** for macOS, Windows, and Linux — packages the full dashboard with a local Node.js sidecar that runs all 60+ 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+,) with three tabs: **LLMs** (Ollama endpoint, model selection, Groq, OpenRouter), **API Keys** (12+ data source credentials with per-key validation), and **Debug & Logs** (traffic log, verbose mode, log files). Each tab runs an independent verification pipeline — saving in the LLMs tab doesn't block API Keys validation +- **Automatic model discovery** — when you set an Ollama or LM Studio endpoint URL in the LLMs tab, the settings panel immediately queries it for available models (tries Ollama native `/api/tags` first, then OpenAI-compatible `/v1/models`) and populates a dropdown. Embedding models are filtered out. If discovery fails, a manual text input appears as fallback +- **Cross-window secret sync** — the main dashboard and settings window run in separate webviews with independent JS contexts. Saving a secret in Settings writes to the OS keychain and broadcasts a `localStorage` change event. The main window listens for this event and hot-reloads all secrets without requiring an app restart +- **Consolidated keychain vault** — all secrets are stored as a single JSON blob in one keychain entry (`secrets-vault`) rather than individual entries per key. This reduces macOS Keychain authorization prompts from 20+ to exactly 1 on each app launch. A one-time migration reads any existing individual entries, consolidates them, and cleans up the old format +- **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 +- **Auto-update checker** — polls the cloud API for new versions every 6 hours. Displays a non-intrusive update badge with direct download link and per-version dismiss. Variant-aware — a Tech Monitor desktop app links to the correct Tech Monitor release asset + +### Progressive Web App + +- **Installable** — the dashboard can be installed to the home screen on mobile or as a standalone desktop app via Chrome's install prompt. Full-screen `standalone` display mode with custom theme color +- **Offline map support** — MapTiler tiles are cached using a CacheFirst strategy (up to 500 tiles, 30-day TTL), enabling map browsing without a network connection +- **Smart caching strategies** — APIs and RSS feeds use NetworkOnly (real-time data must always be fresh), while fonts (1-year TTL), images (7-day StaleWhileRevalidate), and static assets (1-year immutable) are aggressively cached +- **Auto-updating service worker** — checks for new versions every 60 minutes. Tauri desktop builds skip service worker registration entirely (uses native APIs instead) +- **Offline fallback** — a branded fallback page with retry button is served when the network is unavailable + +### Additional Capabilities + +- Signal intelligence with "Why It Matters" context +- Infrastructure cascade analysis with proximity correlation +- Maritime & aviation tracking with surge detection +- Prediction market integration (Polymarket) with 3-tier JA3 bypass (browser-direct → Tauri native TLS → cloud proxy) +- Service status monitoring (cloud providers, AI services) +- Shareable map state via URL parameters (view, zoom, coordinates, time range, active layers) +- Data freshness monitoring across 16 data sources with explicit intelligence gap reporting +- Per-feed circuit breakers with 5-minute cooldowns to prevent cascading failures +- **Browser-side ML worker** (Transformers.js) for NER and sentiment analysis without server dependency — controllable via a "Browser Local Model" toggle in AI Flow settings. When disabled, the ML worker is never initialized, eliminating ONNX model downloads and WebGL memory allocation. The toggle propagates dynamically — enabling it mid-session initializes the worker immediately, disabling it terminates it +- **Cmd+K command palette** — fuzzy search across 20+ result types (news, countries, hotspots, markets, bases, cables, datacenters, nuclear facilities, and more), plus layer toggle commands, layer presets (e.g., `layers:military`, `layers:finance`), and instant country brief navigation for all ~250 ISO countries with flag emoji icons. Curated countries include search aliases (e.g., typing "kremlin" or "putin" finds Russia). Scoring ranks exact matches (3pts) above prefix matches (2pts) above substring matches (1pt). Recent searches are stored in localStorage (max 8 entries) +- **Historical playback** — dashboard snapshots are stored in IndexedDB. A time slider allows rewinding to any saved state, with live updates paused during playback +- **Mobile detection** — screens below 768px receive a warning modal since the dashboard is designed for multi-panel desktop use +- **UCDP conflict classification** — countries with active wars (1,000+ battle deaths/year) receive automatic CII floor scores, preventing optimistic drift. The UCDP GED API integration uses automatic version discovery (probing multiple year-based API versions in parallel), negative caching (5-minute backoff after upstream failures), discovered-version caching (1-hour TTL), and stale-on-error fallback to ensure conflict data is always available even when the upstream API is intermittently down +- **HAPI humanitarian data** — UN OCHA humanitarian access metrics and displacement flows feed into country-level instability scoring with dual-perspective (origins vs. hosts) panel +- **Idle-aware resource management** — animations pause after 2 minutes of inactivity and when the tab is hidden, preventing battery drain. Video streams are destroyed from the DOM and recreated on return +- **Country-specific stock indices** — country briefs display the primary stock market index with 1-week change (S&P 500 for US, Shanghai Composite for China, etc.) via the `/api/stock-index` endpoint +- **Climate anomaly panel** — 15 conflict-prone zones monitored for temperature/precipitation deviations against 30-day ERA5 baselines, with severity classification feeding into CII +- **Country brief export** — every brief is downloadable as structured JSON, flattened CSV, or rendered PNG image, enabling offline analysis and reporting workflows +- **Print/PDF support** — country briefs include a print button that triggers the browser's native print dialog, producing clean PDF output +- **Oil & energy analytics** — WTI/Brent crude prices, US production (Mbbl/d), and inventory levels via the EIA API with weekly trend detection +- **Population exposure estimation** — WorldPop density data estimates civilian population within event-specific radii (50–100km) for conflicts, earthquakes, floods, and wildfires +- **Trending keywords panel** — real-time display of surging terms across all RSS feeds with spike severity, source count, and AI-generated context summaries +- **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|linux-appimage}[&variant={full|tech|finance}]` redirects to the matching GitHub Release asset, with fallback to the releases page +- **Universal country coverage** — every country with incoming event data receives a live CII score automatically, not just the 23 curated tier-1 nations. Clicking any country opens a full brief with available data (news, markets, infrastructure), and non-curated countries use sensible default baselines (`DEFAULT_BASELINE_RISK = 15`) with display names resolved via `Intl.DisplayNames` +- **Feature toggles** — 15 runtime toggles (AI/Ollama, 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 +- **Ultra-wide monitor layout** — on screens 2000px+ wide, the layout automatically switches from vertical stacking to an L-shaped arrangement: the map floats left at 60% width while panels tile to the right and below it, maximizing screen real estate on ultra-wide and 4K monitors. Uses CSS `display: contents` and float-based wrapping — no JavaScript layout engine required +- **Dark/light theme** — persistent theme toggle with 20+ semantic color variable overrides. Dark theme is the default. Theme preference is stored in localStorage, applied before first paint (no flash of wrong theme), and syncs the `` for native browser chrome. A `theme-changed` custom event allows panels to react to switches +- **Panel drag-and-drop reordering** — panels can be reordered via drag-and-drop within the grid. The custom order is persisted to localStorage and restored on reload. Touch events are supported for tablet use +- **Map pin mode** — a 📌 button pins the map in a fixed position so it remains visible while scrolling through panels. Pin state is persisted to localStorage +- **Opt-in intelligence alert popups** — the Intelligence Findings badge accumulates signals and alerts silently in the background. A toggle switch in the badge's dropdown header lets users opt in to automatic popup notifications when high-priority findings arrive. The popup preference is stored in localStorage (default: off), so the dashboard never interrupts users who haven't explicitly requested it. The badge continues counting and pulsing regardless of the popup setting — clicking the badge always opens the full findings dropdown --- -## Source Intelligence +## Regression Testing -Not all sources are equal. The system implements a dual classification to prioritize authoritative information. +Map overlay behavior is validated in Playwright using the map harness (`/tests/map-harness.html`). -### Source Tiers (Authority Ranking) +- Cluster-state cache initialization guard: + - `updates protest marker click payload after data refresh` + - `initializes cluster movement cache on first protest cluster render` +- Run by variant: + - `npm run test:e2e:full -- -g "updates protest marker click payload after data refresh|initializes cluster movement cache on first protest cluster render"` + - `npm run test:e2e:tech -- -g "updates protest marker click payload after data refresh|initializes cluster movement cache on first protest cluster render"` -| Tier | Sources | Characteristics | -|------|---------|-----------------| -| **Tier 1** | Reuters, AP, AFP, Bloomberg, White House, Pentagon | Wire services and official government—fastest, most reliable | -| **Tier 2** | BBC, Guardian, NPR, Al Jazeera, CNBC, Financial Times | Major outlets—high editorial standards, some latency | -| **Tier 3** | Defense One, Bellingcat, Foreign Policy, MIT Tech Review | Domain specialists—deep expertise, narrower scope | -| **Tier 4** | Hacker News, The Verge, VentureBeat, aggregators | Useful signal but requires corroboration | +--- -When multiple sources report the same story, the **lowest tier** (most authoritative) source is displayed as the primary, with others listed as corroborating. +## How It Works -### Source Types (Categorical) +### Country Brief Pages -Sources are also categorized by function for triangulation detection: +Clicking any country on the map opens a full-page intelligence dossier — a single-screen synthesis of all intelligence modules for that country. The brief is organized into a two-column layout: -- **Wire** - News agencies (Reuters, AP, AFP, Bloomberg) -- **Gov** - Official government (White House, Pentagon, State Dept, Fed, SEC) -- **Intel** - Defense/security specialists (Defense One, Bellingcat, Krebs) -- **Mainstream** - Major news outlets (BBC, Guardian, NPR, Al Jazeera) -- **Market** - Financial press (CNBC, MarketWatch, Financial Times) -- **Tech** - Technology coverage (Hacker News, Ars Technica, MIT Tech Review) +**Left column**: ---- +- **Instability Index** — animated SVG score ring (0–100) with four component breakdown bars (Unrest, Conflict, Security, Information), severity badge, and trend indicator +- **Intelligence Brief** — AI-generated analysis (Ollama local / Groq / OpenRouter, depending on configured provider) with inline citation anchors `[1]`–`[8]` that scroll to the corresponding news source when clicked +- **Top News** — 8 most relevant headlines for the country, threat-level color-coded, with source and time-ago metadata + +**Right column**: + +- **Active Signals** — real-time chip indicators for protests, military aircraft, naval vessels, internet outages, earthquakes, displacement flows, climate stress, conflict events, and the country's stock market index (1-week change) +- **7-Day Timeline** — D3.js-rendered event chart with 4 severity-coded lanes (protest, conflict, natural, military), interactive tooltips, and responsive resizing +- **Prediction Markets** — top 3 Polymarket contracts by volume with probability bars and external links +- **Infrastructure Exposure** — pipelines, undersea cables, datacenters, military bases, nuclear facilities, and ports within a 600km radius of the country centroid, ranked by distance + +**Headline relevance filtering**: each country has an alias map (e.g., `US → ["united states", "american", "washington", "pentagon", "biden", "trump"]`). Headlines are filtered using a negative-match algorithm — if another country's alias appears earlier in the headline title than the target country's alias, the headline is excluded. This prevents cross-contamination (e.g., a headline about Venezuela mentioning "Washington sanctions" appearing in the US brief). + +**Export options**: briefs are exportable as JSON (structured data with all scores, signals, and headlines), CSV (flattened tabular format), or PNG image. A print button triggers the browser's native print dialog for PDF export. + +### Local-First Country Detection + +Map clicks resolve to countries using a local geometry service rather than relying on network reverse-geocoding (Nominatim). The system loads a GeoJSON file containing polygon boundaries for ~200 countries and builds an indexed spatial lookup: + +1. **Bounding box pre-filter** — each country's polygon(s) are wrapped in a bounding box (`[minLon, minLat, maxLon, maxLat]`). Points outside the bbox are rejected without polygon intersection testing. +2. **Ray-casting algorithm** — for points inside the bbox, a ray is cast from the point along the positive x-axis. The number of polygon edge intersections determines inside/outside status (odd = inside). Edge cases are handled: points on segment boundaries return `true`, and polygon holes are subtracted (a point inside an outer ring but also inside a hole is excluded). +3. **MultiPolygon support** — countries with non-contiguous territories (e.g., the US with Alaska and Hawaii, Indonesia with thousands of islands) use MultiPolygon geometries where each polygon is tested independently. + +This approach provides sub-millisecond country detection entirely in the browser, with no network latency. The geometry data is preloaded at app startup and cached for the session. For countries not in the GeoJSON (rare), the system falls back to hardcoded rectangular bounding boxes, and finally to network reverse-geocoding as a last resort. + +### AI Summarization Chain + +The World Brief is generated by a 4-tier provider chain that prioritizes local compute, falls back through cloud APIs, and degrades to browser-side inference as a last resort: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Summarization Request │ +│ (headlines deduplicated by Jaccard similarity > 0.6) │ +└───────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ timeout/error +│ Tier 1: Ollama / LM Studio │──────────────┐ +│ Local endpoint, no cloud │ │ +│ Auto-discovered model │ │ +└─────────────────────────────────┘ │ + ▼ + ┌─────────────────────────────┐ timeout/error + │ Tier 2: Groq │──────────────┐ + │ Llama 3.1 8B, temp 0.3 │ │ + │ Fast cloud inference │ │ + └─────────────────────────────┘ │ + ▼ + ┌─────────────────────────────┐ timeout/error + │ Tier 3: OpenRouter │──────────────┐ + │ Multi-model fallback │ │ + └─────────────────────────────┘ │ + ▼ + ┌──────────────────────────┐ + │ Tier 4: Browser T5 │ + │ Transformers.js (ONNX) │ + │ No network required │ + └──────────────────────────┘ +``` + +All three API tiers (Ollama, Groq, OpenRouter) share a common handler factory (`_summarize-handler.js`) that provides identical behavior: + +- **Headline deduplication** — before sending to any LLM, headlines are compared pairwise using word-overlap similarity. Near-duplicates (>60% overlap) are merged, reducing the prompt by 20–40% and preventing the LLM from wasting tokens on repeated stories +- **Variant-aware prompting** — the system prompt adapts to the active dashboard variant. Geopolitical summaries emphasize conflict escalation and diplomatic shifts; tech summaries focus on funding rounds and AI breakthroughs; finance summaries highlight market movements and central bank signals +- **Language-aware output** — when the UI language is non-English, the prompt instructs the LLM to generate the summary in that language +- **Redis deduplication** — summaries are cached with a composite key (`summary:v3:{mode}:{variant}:{lang}:{hash}`) so the same headlines viewed by 1,000 concurrent users trigger exactly one LLM call. Cache TTL is 24 hours +- **Graceful fallback** — if a provider returns `{fallback: true}` (missing API key or endpoint unreachable), the chain silently advances to the next tier. Progress callbacks update the UI to show which provider is being attempted + +The Ollama tier communicates via the OpenAI-compatible `/v1/chat/completions` endpoint, making it compatible with any local inference server that implements this standard (Ollama, LM Studio, llama.cpp server, vLLM, etc.). + +### Threat Classification Pipeline + +Every news item passes through a three-stage classification pipeline: + +1. **Keyword classifier** (instant, `source: 'keyword'`) — pattern-matches against ~120 threat keywords organized by severity tier (critical → high → medium → low → info) and 14 event categories (conflict, protest, disaster, diplomatic, economic, terrorism, cyber, health, environmental, military, crime, infrastructure, tech, general). Keywords use word-boundary regex matching to prevent false positives (e.g., "war" won't match "award"). Each match returns a severity level, category, and confidence score. Variant-specific keyword sets ensure the tech variant doesn't flag "sanctions" in non-geopolitical contexts. + +2. **Browser-side ML** (async, `source: 'ml'`) — Transformers.js runs NER, sentiment analysis, and topic classification directly in the browser with no server dependency. Provides a second classification opinion without any API call. + +3. **LLM classifier** (batched async, `source: 'llm'`) — headlines are collected into a batch queue and fired as parallel `classifyEvent` RPCs via the sebuf proto client. Each RPC calls the configured LLM provider (Groq Llama 3.1 8B at temperature 0, or Ollama for local inference). Results are cached in Redis (24h TTL) keyed by headline hash. When 500-series errors occur, the LLM classifier automatically pauses its queue to avoid wasting API quota, resuming after an exponential backoff delay. When the LLM result arrives, it overrides the keyword result only if its confidence is higher. + +This hybrid approach means the UI is never blocked waiting for AI — users see keyword results instantly, with ML and LLM refinements arriving within seconds and persisting for all subsequent visitors. Each classification carries its `source` tag (`keyword`, `ml`, or `llm`) so downstream consumers can weight confidence accordingly. + +### Country Instability Index (CII) + +Every country with incoming event data receives a live instability score (0–100). 23 curated tier-1 nations (US, Russia, China, Ukraine, Iran, Israel, Taiwan, North Korea, Saudi Arabia, Turkey, Poland, Germany, France, UK, India, Pakistan, Syria, Yemen, Myanmar, Venezuela, Brazil, UAE, and Japan) have individually tuned baseline risk profiles and keyword lists. All other countries that generate any signal (protests, conflicts, outages, displacement flows, climate anomalies) are scored automatically using a universal default baseline (`DEFAULT_BASELINE_RISK = 15`, `DEFAULT_EVENT_MULTIPLIER = 1.0`). The score is computed from: + +| Component | Weight | Details | +| ------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Baseline risk** | 40% | Pre-configured per country reflecting structural fragility | +| **Unrest events** | 20% | Protests scored logarithmically for democracies (routine protests don't trigger), linearly for authoritarian states (every protest is significant). Boosted for fatalities and internet outages | +| **Security activity** | 20% | Military flights (3pts) + vessels (5pts) from own forces + foreign military presence (doubled weight) | +| **Information velocity** | 20% | News mention frequency weighted by event severity multiplier, log-scaled for high-volume countries | + +Additional boosts apply for hotspot proximity, focal point urgency, and conflict-zone floors (e.g., Ukraine is pinned at ≥55, Syria at ≥50). + +### Hotspot Escalation Scoring + +Intelligence hotspots receive dynamic escalation scores blending four normalized signals (0–100): + +- **News activity** (35%) — article count and severity in the hotspot's area +- **Country instability** (25%) — CII score of the host country +- **Geo-convergence alerts** (25%) — spatial binning detects 3+ event types (protests + military + earthquakes) co-occurring within 1° lat/lon cells +- **Military activity** (15%) — vessel clusters and flight density near the hotspot + +The system blends static baseline risk (40%) with detected events (60%) and tracks trends via linear regression on 48-hour history. Signal emissions cool down for 2 hours to prevent alert fatigue. + +### Geographic Convergence Detection + +Events (protests, military flights, vessels, earthquakes) are binned into 1°×1° geographic cells within a 24-hour window. When 3+ distinct event types converge in one cell, a convergence alert fires. Scoring is based on type diversity (×25pts per unique type) plus event count bonuses (×2pts). Alerts are reverse-geocoded to human-readable names using conflict zones, waterways, and hotspot databases. + +### Strategic Theater Posture Assessment + +Nine operational theaters are continuously assessed for military posture escalation: + +| Theater | Key Trigger | +| --------------------- | ------------------------------------------- | +| Iran / Persian Gulf | Carrier groups, tanker activity, AWACS | +| Taiwan Strait | PLAAF sorties, USN carrier presence | +| Baltic / Kaliningrad | Russian Western Military District flights | +| Korean Peninsula | B-52/B-1 deployments, DPRK missile activity | +| Eastern Mediterranean | Multi-national naval exercises | +| Horn of Africa | Anti-piracy patrols, drone activity | +| South China Sea | Freedom of navigation operations | +| Arctic | Long-range aviation patrols | +| Black Sea | ISR flights, naval movements | + +Posture levels escalate from NORMAL → ELEVATED → CRITICAL based on a composite of: + +- **Aircraft count** in theater (both resident and transient) +- **Strike capability** — the presence of tankers + AWACS + fighters together indicates strike packaging, not routine training +- **Naval presence** — carrier groups and combatant formations +- **Country instability** — high CII scores for theater-adjacent countries amplify posture + +Each theater is linked to 38+ military bases, enabling automatic correlation between observed flights and known operating locations. + +### Military Surge & Foreign Presence Detection + +The system monitors five operational theaters (Middle East, Eastern Europe, Western Europe, Western Pacific, Horn of Africa) with 38+ associated military bases. It classifies vessel clusters near hotspots by activity type: + +- **Deployment** — carrier present with 5+ vessels +- **Exercise** — combatants present in formation +- **Transit** — vessels passing through + +Foreign military presence is dual-credited: the operator's country is flagged for force projection, and the host location's country is flagged for foreign military threat. AIS gaps (dark ships) are flagged as potential signal discipline indicators. + +### USNI Fleet Intelligence + +The dashboard ingests weekly U.S. Naval Institute (USNI) fleet deployment reports and merges them with live AIS vessel tracking data. Each report is parsed for carrier strike groups, amphibious ready groups, and individual combatant deployments — extracting hull numbers, vessel names, operational regions, and mission notes. + +The merge algorithm matches USNI entries against live AIS-tracked vessels by hull number and normalized name. Matched vessels receive enrichment: strike group assignment, deployment status (deployed / returning / in-port), and operational theater. Unmatched USNI entries (submarines, vessels running dark) generate synthetic positions based on the last known operational region, with coordinate scattering to prevent marker overlap. + +This dual-source approach provides a more complete operational picture than either AIS or USNI alone — AIS reveals real-time positions but misses submarines and vessels with transponders off, while USNI captures the complete order of battle but with weekly lag. + +### Aircraft Enrichment + +Military flights detected via ADS-B transponder data are enriched through the Wingbits aviation intelligence API, which provides aircraft registration, manufacturer, model, owner, and operator details. Each flight receives a military confidence classification: + +| Confidence | Criteria | +| ------------- | ---------------------------------------------------------------- | +| **Confirmed** | Operator matches a known military branch or defense contractor | +| **Likely** | Aircraft type is exclusively military (tanker, AWACS, fighter) | +| **Possible** | Government-registered aircraft in a military operating area | +| **Civilian** | No military indicators detected | + +Enrichment queries are batched (up to 50 aircraft per request) and cached with a circuit breaker pattern to avoid hammering the upstream API during high-traffic periods. The enriched metadata feeds into the Theater Posture Assessment — a KC-135 tanker paired with F-15s and an E-3 AWACS indicates strike packaging, not routine training. + +### Undersea Cable Health Monitoring + +Beyond displaying static cable routes on the map, the system actively monitors cable health by cross-referencing two live data sources: + +1. **NGA Navigational Warnings** — the U.S. National Geospatial-Intelligence Agency publishes maritime safety broadcasts that frequently mention cable repair operations. The system filters these warnings for cable-related keywords (`CABLE`, `CABLESHIP`, `SUBMARINE CABLE`, `FIBER OPTIC`, etc.) and extracts structured data: vessel names, DMS/decimal coordinates, advisory severity, and repair ETAs. Each warning is matched to the nearest cataloged undersea cable within a 5° geographic radius. + +2. **AIS Cable Ship Tracking** — dedicated cable repair vessels (CS Reliance, Île de Bréhat, Cable Innovator, etc.) are identified by name pattern matching against AIS transponder data. Ship status is classified as `enroute` (transiting to repair site) or `on-station` (actively working) based on keyword analysis of the warning text. + +Advisories are classified by severity: `fault` (cable break, cut, or damage — potential traffic rerouting) or `degraded` (repair work in progress with partial capacity). Impact descriptions are generated dynamically, linking the advisory to the specific cable and the countries it serves — enabling questions like "which cables serving South Asia are currently under repair?" + +**Health scoring algorithm** — Each cable receives a composite health score (0–100) computed from weighted signals with exponential time decay: + +``` +signal_weight = severity × (e^(-λ × age_hours)) where λ = ln(2) / 168 (7-day half-life) +health_score = max(0, 100 − Σ(signal_weights) × 100) +``` + +Signals are classified into two kinds: `operator_fault` (confirmed cable damage — severity 1.0) and `cable_advisory` (repair operations, navigational warnings — severity 0.6). Geographic matching uses cosine-latitude-corrected equirectangular approximation to find the nearest cataloged cable within 50km of each NGA warning's coordinates. Results are cached in Redis (6-hour TTL for complete results, 10 minutes for partial) with an in-memory fallback that serves stale data when Redis is unavailable — ensuring the cable health layer never shows blank data even during cache failures. + +### Infrastructure Cascade Modeling + +Beyond proximity correlation, the system models how disruptions propagate through interconnected infrastructure. A dependency graph connects undersea cables, pipelines, ports, chokepoints, and countries with weighted edges representing capacity dependencies: + +``` +Disruption Event → Affected Node → Cascade Propagation (BFS, depth ≤ 3) + │ + ┌─────────────────────┤ + ▼ ▼ + Direct Impact Indirect Impact + (e.g., cable cut) (countries served by cable) +``` + +**Impact calculation**: `strength = edge_weight × disruption_level × (1 − redundancy)` + +Strategic chokepoint modeling captures real-world dependencies: + +- **Strait of Hormuz** — 80% of Japan's oil, 70% of South Korea's, 60% of India's, 40% of China's +- **Suez Canal** — EU-Asia trade routes (Germany, Italy, UK, China) +- **Malacca Strait** — 80% of China's oil transit + +Ports are weighted by type: oil/LNG terminals (0.9 — critical), container ports (0.7), naval bases (0.4 — geopolitical but less economic). This enables questions like "if the Strait of Hormuz closes, which countries face energy shortages within 30 days?" + +### Related Assets & Proximity Correlation + +When a news event is geo-located, the system automatically identifies critical infrastructure within a 600km radius — pipelines, undersea cables, data centers, military bases, and nuclear facilities — ranked by distance. This enables instant geopolitical context: a cable cut near a strategic chokepoint, a protest near a nuclear facility, or troop movements near a data center cluster. + +### News Geo-Location + +A 74-hub strategic location database infers geography from headlines via keyword matching. Hubs span capitals, conflict zones, strategic chokepoints (Strait of Hormuz, Suez Canal, Malacca Strait), and international organizations. Confidence scoring is boosted for critical-tier hubs and active conflict zones, enabling map-driven news placement without requiring explicit location metadata from RSS feeds. + +### Entity Index & Cross-Referencing + +A structured entity registry catalogs countries, organizations, world leaders, and military entities with multiple lookup indices: + +| Index Type | Purpose | Example | +| ----------------- | --------------------- | ----------------------------------------------- | +| **ID index** | Direct entity lookup | `entity:us` → United States profile | +| **Alias index** | Name variant matching | "America", "USA", "United States" → same entity | +| **Keyword index** | Contextual detection | "Pentagon", "White House" → United States | +| **Sector index** | Domain grouping | "military", "energy", "tech" | +| **Type index** | Category filtering | "country", "organization", "leader" | + +Entity matching uses word-boundary regex to prevent false positives (e.g., "Iran" matching "Ukraine"). Confidence scores are tiered by match quality: exact name matches score 1.0, aliases 0.85–0.95, and keyword matches 0.7. When the same entity surfaces across multiple independent data sources (news, military tracking, protest feeds, market signals), the system identifies it as a focal point and escalates its prominence in the intelligence picture. + +### Temporal Baseline Anomaly Detection + +Rather than relying on static thresholds, the system learns what "normal" looks like and flags deviations. Each event type (military flights, naval vessels, protests, news velocity, AIS gaps, satellite fires) is tracked per region with separate baselines for each weekday and month — because military activity patterns differ on Tuesdays vs. weekends, and January vs. July. + +The algorithm uses **Welford's online method** for numerically stable streaming computation of mean and variance, stored in Redis with a 90-day rolling window. When a new observation arrives, its z-score is computed against the learned baseline. Thresholds: + +| Z-Score | Severity | Example | +| ------- | ------------- | ---------------------------------- | +| ≥ 1.5 | Low | Slightly elevated protest activity | +| ≥ 2.0 | Medium | Unusual naval presence | +| ≥ 3.0 | High/Critical | Military flights 3x above baseline | + +A minimum of 10 historical samples is required before anomalies are reported, preventing false positives during the learning phase. Anomalies are ingested back into the signal aggregator, where they compound with other signals for convergence detection. + +### Trending Keyword Spike Detection + +Every RSS headline is tokenized into individual terms and tracked in per-term frequency maps. A 2-hour rolling window captures current activity while a 7-day baseline (refreshed hourly) establishes what "normal" looks like for each term. A spike fires when all conditions are met: + +| Condition | Threshold | +| -------------------- | --------------------------------------------- | +| **Absolute count** | > `minSpikeCount` (5 mentions) | +| **Relative surge** | > baseline × `spikeMultiplier` (3×) | +| **Source diversity** | ≥ 2 unique RSS feed sources | +| **Cooldown** | 30 minutes since last spike for the same term | + +The tokenizer extracts CVE identifiers (`CVE-2024-xxxxx`), APT/FIN threat actor designators, and 12 compound terms for world leaders (e.g., "Xi Jinping", "Kim Jong Un") that would be lost by naive whitespace splitting. A configurable blocklist suppresses common noise terms. + +Detected spikes are auto-summarized via Groq (rate-limited to 5 summaries/hour) and emitted as `keyword_spike` signals into the correlation engine, where they compound with other signal types for convergence detection. The term registry is capped at 10,000 entries with LRU eviction to bound memory usage. All thresholds (spike multiplier, min count, cooldown, blocked terms) are configurable via the Settings panel. + +### Proto-First API Contracts + +The entire API surface is defined in Protocol Buffer (`.proto`) files using [sebuf](https://github.com/SebastienMelki/sebuf) HTTP annotations. Code generation produces TypeScript clients, server handler stubs, and OpenAPI 3.1.0 documentation from a single source of truth — eliminating request/response schema drift between frontend and backend. + +**20 service domains** cover every data vertical: + +| Domain | RPCs | +| ---------------- | ------------------------------------------------ | +| `aviation` | Airport delays (FAA, Eurocontrol) | +| `climate` | Climate anomalies | +| `conflict` | ACLED events, UCDP events, humanitarian summaries| +| `cyber` | Cyber threat IOCs | +| `displacement` | Population displacement, exposure data | +| `economic` | Energy prices, FRED series, macro signals, World Bank, BIS policy rates, exchange rates, credit-to-GDP | +| `infrastructure` | Internet outages, service statuses, temporal baselines | +| `intelligence` | Event classification, country briefs, risk scores| +| `maritime` | Vessel snapshots, navigational warnings | +| `market` | Stock indices, crypto/commodity quotes, ETF flows| +| `military` | Aircraft details, theater posture, USNI fleet | +| `news` | News items, article summarization | +| `prediction` | Prediction markets | +| `research` | arXiv papers, HackerNews, tech events | +| `seismology` | Earthquakes | +| `supply-chain` | Chokepoint disruption scores, shipping rates, critical mineral concentration | +| `trade` | WTO trade restrictions, tariff trends, trade flows, trade barriers | +| `unrest` | Protest/unrest events | +| `wildfire` | Fire detections | + +**Code generation pipeline** — a `Makefile` drives `buf generate` with three custom sebuf protoc plugins: + +1. `protoc-gen-ts-client` → typed fetch-based client classes (`src/generated/client/`) +2. `protoc-gen-ts-server` → handler interfaces and route descriptors (`src/generated/server/`) +3. `protoc-gen-openapiv3` → OpenAPI 3.1.0 specs in YAML and JSON (`docs/api/`) + +Proto definitions include `buf.validate` field constraints (e.g., latitude ∈ [−90, 90]), so request validation is generated automatically — handlers receive pre-validated data. Breaking changes are caught at CI time via `buf breaking` against the main branch. + +**Edge gateway** — a single Vercel Edge Function (`api/[domain]/v1/[rpc].ts`) imports all 20 `createServiceRoutes()` functions into a flat `Map` router. Every RPC is a POST endpoint at a static path (e.g., `POST /api/aviation/v1/list-airport-delays`), with CORS enforcement, a top-level error boundary that hides internal details on 5xx responses, and rate-limit support (`retryAfter` on 429). The same router runs locally via a Vite dev-server plugin (`sebufApiPlugin` in `vite.config.ts`) with HMR invalidation on handler changes. + +### Cyber Threat Intelligence Layer + +Six threat intelligence feeds provide indicators of compromise (IOCs) for active command-and-control servers, malware distribution hosts, phishing campaigns, malicious URLs, and ransomware operations: + +| Feed | IOC Type | Coverage | +| ---------------------------- | ------------- | ------------------------------- | +| **Feodo Tracker** (abuse.ch) | C2 servers | Botnet C&C infrastructure | +| **URLhaus** (abuse.ch) | Malware hosts | Malware distribution URLs | +| **C2IntelFeeds** | C2 servers | Community-sourced C2 indicators | +| **AlienVault OTX** | Mixed | Open threat exchange pulse IOCs | +| **AbuseIPDB** | Malicious IPs | Crowd-sourced abuse reports | +| **Ransomware.live** | Ransomware | Active ransomware group feeds | + +Each IP-based IOC is geo-enriched using ipinfo.io with freeipapi.com as fallback. Geolocation results are Redis-cached for 24 hours. Enrichment runs concurrently — 16 parallel lookups with a 12-second timeout, processing up to 250 IPs per collection run. + +IOCs are classified into four types (`c2_server`, `malware_host`, `phishing`, `malicious_url`) with four severity levels, rendered as color-coded scatter dots on the globe. The layer uses a 10-minute cache, a 14-day rolling window, and caps display at 500 IOCs to maintain rendering performance. + +### Natural Disaster Monitoring + +Three independent sources are merged into a unified disaster picture, then deduplicated on a 0.1° geographic grid: + +| Source | Coverage | Types | Update Frequency | +| -------------- | ------------------------------ | ------------------------------------------------------------- | ---------------- | +| **USGS** | Global earthquakes M4.5+ | Earthquakes | 5 minutes | +| **GDACS** | UN-coordinated disaster alerts | Earthquakes, floods, cyclones, volcanoes, wildfires, droughts | Real-time | +| **NASA EONET** | Earth observation events | 13 natural event categories (30-day open events) | Real-time | + +GDACS events carry color-coded alert levels (Red = critical, Orange = high) and are filtered to exclude low-severity Green alerts. EONET wildfires are filtered to events within 48 hours to prevent stale data. Earthquakes from EONET are excluded since USGS provides higher-quality seismological data. + +The merged output feeds into the signal aggregator for geographic convergence detection — e.g., an earthquake near a pipeline triggers an infrastructure cascade alert. + +### Dual-Source Protest Tracking + +Protest data is sourced from two independent providers to reduce single-source bias: + +1. **ACLED** (Armed Conflict Location & Event Data) — 30-day window, tokenized API with Redis caching (10-minute TTL). Covers protests, riots, strikes, and demonstrations with actor attribution and fatality counts. +2. **GDELT** (Global Database of Events, Language, and Tone) — 7-day geospatial event feed filtered to protest keywords. Events with mention count ≥5 are included; those above 30 are marked as `validated`. + +Events from both sources are **Haversine-deduplicated** on a 0.1° grid (~10km) with same-day matching. ACLED events take priority due to higher editorial confidence. Severity is classified as: + +- **High** — fatalities present or riot/clash keywords +- **Medium** — standard protest/demonstration +- **Low** — default + +Protest scoring is regime-aware: democratic countries use logarithmic scaling (routine protests don't trigger instability), while authoritarian states use linear scoring (every protest is significant). Fatalities and concurrent internet outages apply severity boosts. + +### Climate Anomaly Detection + +15 conflict-prone and disaster-prone zones are continuously monitored for temperature and precipitation anomalies using Open-Meteo ERA5 reanalysis data. A 30-day baseline is computed, and current conditions are compared against it to determine severity: + +| Severity | Temperature Deviation | Precipitation Deviation | +| ------------ | --------------------- | ------------------------- | +| **Extreme** | > 5°C above baseline | > 80mm/day above baseline | +| **Moderate** | > 3°C above baseline | > 40mm/day above baseline | +| **Normal** | Within expected range | Within expected range | + +Anomalies feed into the signal aggregator, where they amplify CII scores for affected countries (climate stress is a recognized conflict accelerant). The Climate Anomaly panel surfaces these deviations in a severity-sorted list. + +### Displacement Tracking + +Refugee and displacement data is sourced from the UN OCHA Humanitarian API (HAPI), providing population-level counts for refugees, asylum seekers, and internally displaced persons (IDPs). The Displacement panel offers two perspectives: + +- **Origins** — countries people are fleeing from, ranked by outflow volume +- **Hosts** — countries absorbing displaced populations, ranked by intake + +Crisis badges flag countries with extreme displacement: > 1 million displaced (red), > 500,000 (orange). Displacement outflow feeds into the CII as a component signal — high displacement is a lagging indicator of instability that persists even when headlines move on. + +### Population Exposure Estimation + +Active events (conflicts, earthquakes, floods, wildfires) are cross-referenced against WorldPop population density data to estimate the number of civilians within the impact zone. Event-specific radii reflect typical impact footprints: + +| Event Type | Radius | Rationale | +| --------------- | ------ | ---------------------------------------- | +| **Conflicts** | 50 km | Direct combat zone + displacement buffer | +| **Earthquakes** | 100 km | Shaking intensity propagation | +| **Floods** | 100 km | Watershed and drainage basin extent | +| **Wildfires** | 30 km | Smoke and evacuation perimeter | + +API calls to WorldPop are batched concurrently (max 10 parallel requests) to handle multiple simultaneous events without sequential bottlenecks. The Population Exposure panel displays a summary header with total affected population and a per-event breakdown table. + +### Strategic Port Infrastructure + +83 strategic ports are cataloged across six types, reflecting their role in global trade and military posture: + +| Type | Count | Examples | +| -------------- | ----- | ---------------------------------------------------- | +| **Container** | 21 | Shanghai (#1, 47M+ TEU), Singapore, Ningbo, Shenzhen | +| **Oil/LNG** | 8 | Ras Tanura (Saudi), Sabine Pass (US), Fujairah (UAE) | +| **Chokepoint** | 8 | Suez Canal, Panama Canal, Strait of Malacca | +| **Naval** | 6 | Zhanjiang, Yulin (China), Vladivostok (Russia) | +| **Mixed** | 15+ | Ports serving multiple roles (trade + military) | +| **Bulk** | 20+ | Regional commodity ports | + +Ports are ranked by throughput and weighted by strategic importance in the infrastructure cascade model: oil/LNG terminals carry 0.9 criticality, container ports 0.7, and naval bases 0.4. Port proximity appears in the Country Brief infrastructure exposure section. + +### Browser-Side ML Pipeline + +The dashboard runs a full ML pipeline in the browser via Transformers.js, with no server dependency for core intelligence. This is automatically disabled on mobile devices to conserve memory. + +| Capability | Model | Use | +| ---------------------------- | ------------------- | ------------------------------------------------- | +| **Text embeddings** | sentence-similarity | Semantic clustering of news headlines | +| **Sequence classification** | threat-classifier | Threat severity and category detection | +| **Summarization** | T5-small | Last-resort fallback when Ollama, Groq, and OpenRouter are all unavailable | +| **Named Entity Recognition** | NER pipeline | Country, organization, and leader extraction | + +**Hybrid clustering** combines fast Jaccard similarity (n-gram overlap, threshold 0.4) with ML-refined semantic similarity (cosine similarity, threshold 0.78). Jaccard runs instantly on every refresh; semantic refinement runs when the ML worker is loaded and merges clusters that are textually different but semantically identical (e.g., "NATO expands missile shield" and "Alliance deploys new air defense systems"). + +News velocity is tracked per cluster — when multiple Tier 1–2 sources converge on the same story within a short window, the cluster is flagged as a breaking alert with `sourcesPerHour` as the velocity metric. + +### Live Webcam Surveillance Grid + +19 YouTube live streams from geopolitical hotspots provide continuous visual situational awareness: + +| Region | Cities | +| ---------------- | ---------------------------------------------------------------- | +| **Middle East** | Jerusalem (Western Wall), Tehran, Tel Aviv, Mecca (Grand Mosque) | +| **Europe** | Kyiv, Odessa, Paris, St. Petersburg, London | +| **Americas** | Washington DC, New York, Los Angeles, Miami | +| **Asia-Pacific** | Taipei, Shanghai, Tokyo, Seoul, Sydney | + +The webcam panel supports two viewing modes: a 4-feed grid (default strategic selection: Jerusalem, Tehran, Kyiv, Washington DC) and a single-feed expanded view. Region tabs (ALL/MIDEAST/EUROPE/AMERICAS/ASIA) filter the available feeds. + +Resource management is aggressive — iframes are lazy-loaded via Intersection Observer (only rendered when the panel scrolls into view), paused after 5 minutes of user inactivity, and destroyed from the DOM entirely when the browser tab is hidden. On Tauri desktop, YouTube embeds route through a cloud proxy to bypass WKWebView autoplay restrictions. Each feed carries a fallback video ID in case the primary stream goes offline. + +### Desktop Auto-Update + +The desktop app checks for new versions by polling `worldmonitor.app/api/version` — once at startup (after a 5-second delay) and then every 6 hours. When a newer version is detected (semver comparison), a non-intrusive update badge appears with a direct link to the GitHub Release page. + +Update prompts are dismissable per-version — dismissing v2.5.0 won't suppress v2.6.0 notifications. The updater is variant-aware: a Tech Monitor desktop build links to the Tech Monitor release asset, not the full variant. + +The `/api/version` endpoint reads the latest GitHub Release tag and caches the result for 1 hour, so version checks don't hit the GitHub API on every request. + +### Theme System + +The dashboard supports dark and light themes with a toggle in the header bar. Dark is the default, matching the OSINT/command-center aesthetic. + +Theme state is stored in localStorage and applied via a `[data-theme="light"]` attribute on the root element. Critically, the theme is applied before any components mount — an inline script in `index.html` reads the preference and sets the attribute synchronously, preventing a flash of the wrong theme on load. + +20+ CSS custom properties are overridden in light mode to maintain contrast ratios: severity colors shift (e.g., `--semantic-high` changes from `#ff8800` to `#ea580c`), backgrounds lighten, and text inverts. Language-specific font stacks switch in `:lang()` selectors — Arabic uses Geeza Pro, Chinese uses PingFang SC. + +**Typography** — the dashboard uses a consolidated `--font-mono` CSS custom property that cascades through the entire UI: SF Mono → Monaco → Cascadia Code → Fira Code → DejaVu Sans Mono → Liberation Mono → system monospace. This single variable ensures typographic consistency across macOS (SF Mono/Monaco), Windows (Cascadia Code), and Linux (DejaVu Sans Mono/Liberation Mono). The settings window inherits the same variable, preventing font divergence between the main dashboard and configuration UI. + +A `theme-changed` CustomEvent is dispatched on toggle, allowing panels with custom rendering (charts, maps, gauges) to re-render with the new palette. + +### Privacy & Offline Architecture + +World Monitor is designed so that sensitive intelligence work can run entirely on local hardware with no data leaving the user's machine. The privacy architecture operates at three levels: + +**Level 1 — Full Cloud (Web App)** +All processing happens server-side on Vercel Edge Functions. API keys are stored in Vercel environment variables. News feeds are proxied through domain-allowlisted endpoints. AI summaries use Groq or OpenRouter. This is the default for `worldmonitor.app` — convenient but cloud-dependent. + +**Level 2 — Desktop with Cloud APIs (Tauri + Sidecar)** +The desktop app runs a local Node.js sidecar that mirrors all 60+ cloud API handlers. API keys are stored in the OS keychain (macOS Keychain / Windows Credential Manager), never in plaintext files. Requests are processed locally first; cloud is a transparent fallback for missing handlers. Credential management happens through a native settings window with per-key validation. + +**Level 3 — Air-Gapped Local (Ollama + Desktop)** +With Ollama or LM Studio configured, AI summarization runs entirely on local hardware. Combined with the desktop sidecar, the core intelligence pipeline (news aggregation, threat classification, instability scoring, AI briefings) operates with zero cloud dependency. The browser-side ML pipeline (Transformers.js) provides NER, sentiment analysis, and fallback summarization without even a local server. + +| Capability | Web | Desktop + Cloud Keys | Desktop + Ollama | +|---|:---:|:---:|:---:| +| News aggregation | Cloud proxy | Local sidecar | Local sidecar | +| AI summarization | Groq/OpenRouter | Groq/OpenRouter | Local LLM | +| Threat classification | Cloud LLM + browser ML | Cloud LLM + browser ML | Browser ML only | +| Credential storage | Server env vars | OS keychain | OS keychain | +| Map & static layers | Browser | Browser | Browser | +| Data leaves machine | Yes | Partially | No | + +The desktop readiness framework (`desktop-readiness.ts`) catalogs each feature's locality class — `fully-local` (no API required), `api-key` (degrades gracefully without keys), or `cloud-fallback` (proxy available) — enabling clear communication about what works offline. + +### Product Analytics + +World Monitor includes privacy-first product analytics via PostHog to understand usage patterns and improve the dashboard. The implementation enforces strict data safety at multiple levels: + +**Typed event allowlists** — every analytics event has a schema defining exactly which properties are permitted. Unlisted properties are silently dropped before transmission. This prevents accidental inclusion of sensitive data in analytics payloads, even if a developer passes extra fields. + +**API key stripping** — a `sanitize_properties` callback runs on every outgoing event. Any string value matching common API key prefixes (`sk-`, `gsk_`, `or-`, `Bearer `) is replaced with `[REDACTED]` before it leaves the browser. This is defense-in-depth: even if a key somehow ends up in an event payload, it never reaches the analytics backend. + +**No session recordings, no autocapture** — PostHog's session replay and automatic DOM event capture are explicitly disabled. Only explicitly instrumented events are tracked. + +**Pseudonymous identity** — each installation generates a random UUID stored in localStorage. There is no user login, no email collection, and no cross-device tracking. The UUID is purely pseudonymous — it enables session attribution without identifying individuals. + +**Ad-blocker bypass** — on the web, PostHog traffic is routed through a reverse proxy on the app's own domain (`/ingest`) rather than directly to PostHog's servers. This prevents ad blockers from silently dropping analytics requests, ensuring usage data is representative. Desktop builds use PostHog's direct endpoint since ad blockers aren't a factor in native apps. + +**Offline event queue** — the desktop app may launch without network connectivity. Events captured while offline are queued in localStorage (capped at 200 entries) and flushed to PostHog when connectivity is restored. A `window.online` listener triggers automatic flush on reconnection. + +**Super properties** — every event automatically carries platform context: variant (world/tech/finance), app version, platform (web/desktop), screen dimensions, viewport size, device pixel ratio, browser language, and desktop OS/arch. This enables segmentation without per-event instrumentation. + +30+ typed events cover core user interactions: app load timing, panel views, LLM summary generation (provider, model, cache status), API key configuration snapshots, map layer toggles, variant switches, country brief opens, theme changes, language changes, search usage, panel resizing, webcam selections, and auto-update interactions. + +Analytics is entirely opt-out by omitting the `VITE_POSTHOG_KEY` environment variable. When the key is absent, all analytics functions are no-ops with zero runtime overhead. + +### Responsive Layout System + +The dashboard adapts to four screen categories without JavaScript layout computation — all breakpoints are CSS-only: + +| Screen Width | Layout | Details | +| ---------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **< 768px** | Mobile warning | Modal recommends desktop; limited panel display with touch-optimized map popups | +| **768px–2000px** | Standard grid | Vertical stack: map on top, panels in `auto-fill` grid (`minmax(280px, 1fr)`). Panels tile in rows that adapt to available width | +| **2000px+** | Ultra-wide L-shape | Map floats left at 60% width, 65vh height. Panels wrap to the right of the map and below it using CSS `display: contents` on the grid container with `float: left` on individual panels | + +The ultra-wide layout is notable for its technique: `display: contents` dissolves the `.panels-grid` container so that individual panel elements become direct flow children of `.main-content`. Combined with `float: left` on the map, this creates natural L-shaped content wrapping — panels fill the space to the right of the map, and when they overflow past the map's height, they spread to full width. No JavaScript layout engine is involved. + +Panel heights are user-adjustable via drag handles (span-1 through span-4 grid rows), with layout state persisted to localStorage. Double-clicking a drag handle resets the panel to its default height. + +### Signal Aggregation + +All real-time data sources feed into a central signal aggregator that builds a unified geospatial intelligence picture. Signals are clustered by country and region, with each signal carrying a severity (low/medium/high), geographic coordinates, and metadata. The aggregator: -## Algorithms & Design +1. **Clusters by country** — groups signals from diverse sources (flights, vessels, protests, fires, outages, `keyword_spike`) into per-country profiles +2. **Detects regional convergence** — identifies when multiple signal types spike in the same geographic corridor (e.g., military flights + protests + satellite fires in Eastern Mediterranean) +3. **Feeds downstream analysis** — the CII, hotspot escalation, focal point detection, and AI insights modules all consume the aggregated signal picture rather than raw data -### News Clustering +### PizzINT Activity Monitor & GDELT Tension Index -Related articles are grouped using **Jaccard similarity** on tokenized headlines: +The dashboard integrates two complementary geopolitical pulse indicators: + +**PizzINT DEFCON scoring** — monitors foot traffic patterns at key military, intelligence, and government locations worldwide via the PizzINT API. Aggregate activity levels across monitored sites are converted into a 5-level DEFCON-style readout: + +| Adjusted Activity | DEFCON Level | Label | +| ----------------- | ------------ | ----------------- | +| ≥ 85% | 1 | Maximum Activity | +| 70% – 84% | 2 | High Activity | +| 50% – 69% | 3 | Elevated Activity | +| 25% – 49% | 4 | Above Normal | +| < 25% | 5 | Normal Activity | + +Activity spikes at individual locations boost the aggregate score (+10 per spike, capped at 100). Data freshness is tracked per-location — the system distinguishes between stale readings (location sensor lag) and genuine low activity. Per-location detail includes current popularity percentage, spike magnitude, and open/closed status. + +**GDELT bilateral tension pairs** — six strategic country pairs (USA↔Russia, Russia↔Ukraine, USA↔China, China↔Taiwan, USA↔Iran, USA↔Venezuela) are tracked via GDELT's GPR (Goldstein Political Relations) batch API. Each pair shows a current tension score, a percentage change from the previous data point, and a trend direction (rising/stable/falling, with ±5% thresholds). Rising bilateral tension scores that coincide with military signal spikes in the same region feed into the focal point detection algorithm. + +### Data Freshness & Intelligence Gaps + +A singleton tracker monitors 24 data sources (GDELT, RSS, AIS, military flights, earthquakes, weather, outages, ACLED, Polymarket, economic indicators, NASA FIRMS, cyber threat feeds, trending keywords, oil/energy, population exposure, BIS central bank data, WTO trade policy, and more) with status categorization: fresh (<15 min), stale (1h), very_stale (6h), no_data, error, disabled. It explicitly reports **intelligence gaps** — what analysts can't see — preventing false confidence when critical data sources are down or degraded. + +### Prediction Markets as Leading Indicators + +Polymarket geopolitical markets are queried using tag-based filters (Ukraine, Iran, China, Taiwan, etc.) with 5-minute caching. Market probability shifts are correlated with news volume: if a prediction market moves significantly before matching news arrives, this is flagged as a potential early-warning signal. + +**Cloudflare JA3 bypass** — Polymarket's API is protected by Cloudflare TLS fingerprinting (JA3) that blocks all server-side requests. The system uses a 3-tier fallback: + +| Tier | Method | When It Works | +| ----- | -------------------------- | ------------------------------------------------------- | +| **1** | Browser-direct fetch | Always (browser TLS passes Cloudflare) | +| **2** | Tauri native TLS (reqwest) | Desktop app (Rust TLS fingerprint differs from Node.js) | +| **3** | Vercel edge proxy | Rarely (edge runtime sometimes passes) | + +Once browser-direct succeeds, the system caches this state and skips fallback tiers on subsequent requests. Country-specific markets are fetched by mapping countries to Polymarket tags with name-variant matching (e.g., "Russia" matches titles containing "Russian", "Moscow", "Kremlin", "Putin"). + +Markets are filtered to exclude sports and entertainment (100+ exclusion keywords), require meaningful price divergence from 50% or volume above $50K, and are ranked by trading volume. Each variant gets different tag sets — geopolitical focus queries politics/world/ukraine/middle-east tags, while tech focus queries ai/crypto/business tags. + +### Macro Signal Analysis (Market Radar) + +The Market Radar panel computes a composite BUY/CASH verdict from 7 independent signals sourced entirely from free APIs (Yahoo Finance, mempool.space, alternative.me): + +| Signal | Computation | Bullish When | +| ------------------- | ------------------------------------- | --------------------------- | +| **Liquidity** | JPY/USD 30-day rate of change | ROC > -2% (no yen squeeze) | +| **Flow Structure** | BTC 5-day return vs QQQ 5-day return | Gap < 5% (aligned) | +| **Macro Regime** | QQQ 20-day ROC vs XLP 20-day ROC | QQQ outperforming (risk-on) | +| **Technical Trend** | BTC vs SMA50 + 30-day VWAP | Above both (bullish) | +| **Hash Rate** | Bitcoin mining hashrate 30-day change | Growing > 3% | +| **Mining Cost** | BTC price vs hashrate-implied cost | Price > $60K (profitable) | +| **Fear & Greed** | alternative.me sentiment index | Value > 50 | + +The overall verdict requires ≥57% of known signals to be bullish (BUY), otherwise CASH. Signals with unknown data are excluded from the denominator. + +**VWAP Calculation** — Volume-Weighted Average Price is computed from aligned price/volume pairs over a 30-day window. Pairs where either price or volume is null are excluded together to prevent index misalignment: ``` -similarity(A, B) = |A ∩ B| / |A ∪ B| +VWAP = Σ(price × volume) / Σ(volume) for last 30 trading days ``` -- Headlines are tokenized, lowercased, and stripped of stop words -- 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 +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 -### Velocity Analysis +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: -Each news cluster tracks publication velocity: +| Deviation | Status | Indicator | +| ----------- | ------------ | --------- | +| ≤ 0.5% | ON PEG | Green | +| 0.5% – 1.0% | SLIGHT DEPEG | Yellow | +| > 1.0% | DEPEGGED | Red | -- **Sources per hour** = article count / time span -- **Trend** = rising/stable/falling based on first-half vs second-half publication rate -- **Levels**: Normal (<3/hr), Elevated (3-6/hr), Spike (>6/hr) +The panel aggregates total stablecoin market cap, 24h volume, and an overall health status (HEALTHY / CAUTION / WARNING). The `coins` query parameter accepts a comma-separated list of CoinGecko IDs, validated against a `[a-z0-9-]+` regex to prevent injection. -### Sentiment Detection +### Oil & Energy Analytics -Headlines are scored against curated word lists: +The Oil & Energy panel tracks four key indicators from the U.S. Energy Information Administration (EIA) API: -**Negative indicators**: war, attack, killed, crisis, crash, collapse, threat, sanctions, invasion, missile, terror, assassination, recession, layoffs... +| Indicator | Series | Update Cadence | +| ----------------- | ------------------------- | -------------- | +| **WTI Crude** | Spot price ($/bbl) | Weekly | +| **Brent Crude** | Spot price ($/bbl) | Weekly | +| **US Production** | Crude oil output (Mbbl/d) | Weekly | +| **US Inventory** | Commercial crude stocks | Weekly | -**Positive indicators**: peace, deal, agreement, breakthrough, recovery, growth, ceasefire, treaty, alliance, victory... +Trend detection flags week-over-week changes exceeding ±0.5% as rising or falling, with flat readings within the threshold shown as stable. Results are cached client-side for 30 minutes. The panel provides energy market context for geopolitical analysis — price spikes often correlate with supply disruptions in monitored conflict zones and chokepoint closures. -Score determines sentiment classification: negative (<-1), neutral (-1 to +1), positive (>+1) +### BIS Central Bank Data -### Baseline Deviation (Z-Score) +The Economic panel integrates data from the Bank for International Settlements (BIS), the central bank of central banks, providing three complementary datasets: -The system maintains rolling baselines for news volume per topic: +| Dataset | Description | Use Case | +| --- | --- | --- | +| **Policy Rates** | Current central bank policy rates across major economies | Monetary policy stance comparison — tight vs. accommodative | +| **Real Effective Exchange Rates** | Trade-weighted currency indices adjusted for inflation (REER) | Currency competitiveness — rising REER = strengthening, falling = weakening | +| **Credit-to-GDP** | Total credit to the non-financial sector as percentage of GDP | Credit bubble detection — high ratios signal overleveraged economies | -- **7-day average** and **30-day average** stored in IndexedDB -- Standard deviation calculated from historical counts -- **Z-score** = (current - mean) / stddev +Data is fetched through three dedicated BIS RPCs (`GetBisPolicyRates`, `GetBisExchangeRates`, `GetBisCredit`) in the `economic/v1` proto service. Each dataset uses independent circuit breakers with 30-minute cache TTLs. The panel renders policy rates as a sorted table with spark bars, exchange rates with directional trend indicators, and credit-to-GDP as a ranked list. BIS data freshness is tracked in the intelligence gap system — staleness or failures surface as explicit warnings rather than silent gaps. -Deviation levels: -- **Spike**: Z > 2.5 (statistically rare increase) -- **Elevated**: Z > 1.5 -- **Normal**: -2 < Z < 1.5 -- **Quiet**: Z < -2 (unusually low activity) +### WTO Trade Policy Intelligence -This enables detection of anomalous activity even when absolute numbers seem normal. +The Trade Policy panel provides real-time visibility into global trade restrictions, tariffs, and barriers — critical for tracking economic warfare, sanctions impact, and supply chain disruption risk. Four data views are available: + +| Tab | Data Source | Content | +| --- | --- | --- | +| **Restrictions** | WTO trade monitoring | Active trade restrictions with imposing/affected countries, product categories, and enforcement dates | +| **Tariffs** | WTO tariff database | Tariff rate trends between country pairs (e.g., US↔China) with historical datapoints | +| **Flows** | WTO trade statistics | Bilateral trade flow volumes with year-over-year change indicators | +| **Barriers** | WTO SPS/TBT notifications | Sanitary, phytosanitary, and technical barriers to trade with status tracking | + +The `trade/v1` proto service defines four RPCs, each with its own circuit breaker (30-minute cache TTL) and `upstreamUnavailable` signaling for graceful degradation when WTO endpoints are temporarily unreachable. The panel is available on FULL and FINANCE variants. Trade policy data feeds into the data freshness tracker as `wto_trade`, with intelligence gap warnings when the WTO feed goes stale. + +### Supply Chain Disruption Intelligence + +The Supply Chain panel provides real-time visibility into global logistics risk across three complementary dimensions — strategic chokepoint health, shipping cost trends, and critical mineral concentration — enabling early detection of disruptions that cascade into economic and geopolitical consequences. + +**Chokepoints tab** — monitors 6 strategic maritime bottlenecks (Suez Canal, Strait of Malacca, Strait of Hormuz, Bab el-Mandeb, Panama Canal, Taiwan Strait) by cross-referencing live navigational warnings with AIS vessel disruption data. Each chokepoint receives a disruption score (0–100) computed from warning severity and count, mapped to color-coded status indicators (green/yellow/red). Data is cached with a 5-minute TTL for near-real-time awareness. + +**Shipping Rates tab** — tracks two Federal Reserve Economic Data (FRED) series: the Deep Sea Freight Producer Price Index (`PCU483111483111`) and the Freight Transportation Services Index (`TSIFRGHT`). Statistical spike detection flags abnormal price movements against recent history. Inline SVG sparklines render 24 months of rate history at a glance. Cached for 1 hour to reflect the weekly release cadence of underlying data. + +**Critical Minerals tab** — applies the **Herfindahl-Hirschman Index (HHI)** to 2024 global production data for minerals critical to technology and defense manufacturing — lithium, cobalt, rare earths, gallium, germanium, and others. The HHI quantifies supply concentration risk: a market dominated by a single producer scores near 10,000, while a perfectly distributed market scores near 0. Each mineral displays the top 3 producing countries with market share percentages, flagging single-country dependencies that represent strategic vulnerability (e.g., China's dominance in rare earth processing). This tab uses static production data, cached for 24 hours with no external API dependency. + +The panel is available on the FULL (World Monitor) variant and integrates with the infrastructure cascade model — when a chokepoint disruption coincides with high mineral concentration risk for affected trade routes, the combined signal feeds into convergence detection. + +### BTC ETF Flow Estimation + +Ten spot Bitcoin ETFs are tracked via Yahoo Finance's 5-day chart API (IBIT, FBTC, ARKB, BITB, GBTC, HODL, BRRR, EZBC, BTCO, BTCW). Since ETF flow data requires expensive terminal subscriptions, the system estimates flow direction from publicly available signals: + +- **Price change** — daily close vs. previous close determines direction +- **Volume ratio** — current volume / trailing average volume measures conviction +- **Flow magnitude** — `volume × price × direction × 0.1` provides a rough dollar estimate + +This is an approximation, not a substitute for official flow data, but it captures the direction and relative magnitude correctly. Results are cached for 15 minutes. --- -## Dynamic Hotspot Activity +## Tri-Variant Architecture + +A single codebase produces four specialized dashboards, each with distinct feeds, panels, map layers, and branding: + +| Aspect | World Monitor | Tech Monitor | Finance Monitor | Happy Monitor | +| --------------------- | ---------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------- | +| **Domain** | worldmonitor.app | tech.worldmonitor.app | finance.worldmonitor.app | happy.worldmonitor.app | +| **Focus** | Geopolitics, military, conflicts | AI/ML, startups, cybersecurity | Markets, trading, central banks | Good news, conservation, human progress | +| **RSS Feeds** | ~25 categories (politics, MENA, Africa, think tanks) | ~20 categories (AI, VC blogs, startups, GitHub) | ~18 categories (forex, bonds, commodities, IPOs) | 10+ positive-news sources (GNN, Positive.News, Upworthy) | +| **Panels** | 45 (strategic posture, CII, cascade, trade policy) | 31 (AI labs, unicorns, accelerators) | 31 (forex, bonds, derivatives, trade policy) | 8 (good news, breakthroughs, conservation, renewables) | +| **Unique Map Layers** | Military bases, nuclear facilities, hotspots | Tech HQs, cloud regions, startup hubs | Stock exchanges, central banks, Gulf investments | Positive events, kindness, species recovery, renewables | +| **Desktop App** | World Monitor.app / .exe / .AppImage | Tech Monitor.app / .exe / .AppImage | Finance Monitor.app / .exe / .AppImage | (web-only) | + +**Happy Monitor** is a deliberately uplifting counterpart to the geopolitical dashboard. All conflict, military, and threat overlays are disabled. The variant uses a warm nature-inspired color palette (`happy-theme.css`) and sources content from 10 dedicated positive-news RSS feeds (Good News Network, Positive.News, Reasons to be Cheerful, Optimist Daily, Upworthy, DailyGood, Good Good Good, GOOD Magazine, Sunny Skyz, The Better India). A two-pass positive classifier sorts articles into 6 categories — `science-health`, `nature-wildlife`, `humanity-kindness`, `innovation-tech`, `climate-wins`, `culture-community` — using source-name shortcuts (GNN sub-feeds are pre-classified) followed by priority-ordered keyword matching. Panels include Good News Feed with category filtering, Human Progress metrics, Live Counters, Today's Hero, Breakthroughs, 5 Good Things digest, Conservation Wins (species recovery stories), and Renewable Energy installations. + +**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 | 😊 HAPPY) navigates between deployed domains on the web, or sets `localStorage['worldmonitor-variant']` in the desktop app to switch without rebuilding. + +--- + +## Architecture Principles + +| Principle | Implementation | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Speed over perfection** | Keyword classifier is instant; LLM refines asynchronously. Users never wait. | +| **Assume failure** | Per-feed circuit breakers with 5-minute cooldowns. AI fallback chain: Ollama (local) → Groq → OpenRouter → browser-side T5. Redis cache failures degrade to in-memory fallback with stale-on-error. Negative caching (5-minute backoff after upstream failures) prevents hammering downed APIs. Every edge function returns stale cached data when upstream APIs are down. **Cache stampede prevention** — `cachedFetchJson` uses an in-flight promise map to coalesce concurrent cache misses into a single upstream fetch: the first request creates and registers a Promise, all concurrent requests for the same key await that same Promise rather than independently hitting the upstream. Rate-sensitive APIs (Yahoo Finance) use staggered sequential requests with 150ms inter-request delays to avoid 429 throttling. UCDP conflict data uses automatic version discovery (probing multiple API versions in parallel), discovered-version caching (1-hour TTL), and stale-on-error fallback. | +| **Show what you can't see** | Intelligence gap tracker explicitly reports data source outages rather than silently hiding them. | +| **Browser-first compute** | Analysis (clustering, instability scoring, surge detection) runs client-side — no backend compute dependency for core intelligence. | +| **Local-first geolocation** | Country detection uses browser-side ray-casting against GeoJSON polygons rather than network reverse-geocoding. Sub-millisecond response, zero API dependency, works offline. Network geocoding is a fallback, not the primary path. | +| **Multi-signal correlation** | No single data source is trusted alone. Focal points require convergence across news + military + markets + protests before escalating to critical. | +| **Geopolitical grounding** | Hard-coded conflict zones, baseline country risk, and strategic chokepoints prevent statistical noise from generating false alerts in low-data regions. | +| **Defense in depth** | CORS origin allowlist, domain-allowlisted RSS proxy, server-side API key isolation, token-authenticated desktop sidecar, input sanitization with output encoding, IP rate limiting on AI endpoints. | +| **Cache everything, trust nothing** | Three-tier caching (in-memory → Redis → upstream) with versioned cache keys and stale-on-error fallback. Every API response includes `X-Cache` header for debugging. CDN layer (`s-maxage`) absorbs repeated requests before they reach edge functions. | +| **Bandwidth efficiency** | Gzip compression on all relay responses (80% reduction). Content-hash static assets with 1-year immutable cache. Staggered polling intervals prevent synchronized API storms. Animations and polling pause on hidden tabs. | +| **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. | +| **Contract-first APIs** | Every API endpoint starts as a `.proto` definition with field validation, HTTP annotations, and examples. Code generation produces typed TypeScript clients and servers, eliminating schema drift. Breaking changes are caught automatically at CI time. | +| **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). | + +--- -Hotspots on the map are **not static threat levels**. Activity is calculated in real-time based on news correlation. +## Source Credibility & Feed Tiering -Each hotspot defines keywords: -```typescript -{ - id: 'dc', - name: 'DC', - keywords: ['pentagon', 'white house', 'congress', 'cia', 'nsa', ...], - agencies: ['Pentagon', 'CIA', 'NSA', 'State Dept'], -} +Every RSS feed is assigned a source tier reflecting editorial reliability: + +| Tier | Description | Examples | +| ---------- | ------------------------------------------ | ------------------------------------------- | +| **Tier 1** | Wire services, official government sources | Reuters, AP, BBC, DOD | +| **Tier 2** | Major established outlets | CNN, NYT, The Guardian, Al Jazeera | +| **Tier 3** | Specialized/niche outlets | Defense One, Breaking Defense, The War Zone | +| **Tier 4** | Aggregators and blogs | Google News, individual analyst blogs | + +Feeds also carry a **propaganda risk rating** and **state affiliation flag**. State-affiliated sources (RT, Xinhua, IRNA) are included for completeness but visually tagged so analysts can factor in editorial bias. Threat classification confidence is weighted by source tier — a Tier 1 breaking alert carries more weight than a Tier 4 blog post in the focal point detection algorithm. + +--- + +## Edge Function Architecture + +World Monitor uses 60+ Vercel Edge Functions as a lightweight API layer, split into two generations. Legacy endpoints in `api/*.js` each handle a single data source concern — proxying, caching, or transforming external APIs. The newer proto-first endpoints route through a single edge gateway (`api/[domain]/v1/[rpc].ts`) that dispatches to typed handler implementations generated from Protocol Buffer definitions (see [Proto-First API Contracts](#proto-first-api-contracts)). Both generations coexist, with new features built proto-first. 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. +- **Data Adapters** — GDELT, ACLED, OpenSky, USGS, NASA FIRMS, FRED, Yahoo Finance, CoinGecko, mempool.space, BIS, WTO, and others each have dedicated edge functions that normalize responses into consistent schemas +- **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 +- **BIS Integration** — policy rates, real effective exchange rates, and credit-to-GDP ratios from the Bank for International Settlements, cached with 30-minute TTL +- **WTO Trade Policy** — trade restrictions, tariff trends, bilateral trade flows, and SPS/TBT barriers from the World Trade Organization +- **Supply Chain Intelligence** — maritime chokepoint disruption scores (cross-referencing NGA warnings + AIS data), FRED shipping freight indices with spike detection, and critical mineral supply concentration via Herfindahl-Hirschman Index analysis + +All edge functions include circuit breaker logic and return cached stale data when upstream APIs are unavailable, ensuring the dashboard never shows blank panels. + +--- + +## Multi-Platform Architecture + +All three variants run on three platforms that work together: + +``` +┌─────────────────────────────────────┐ +│ Vercel (Edge) │ +│ 60+ edge functions · static SPA │ +│ Proto gateway (20 typed services) │ +│ CORS allowlist · Redis cache │ +│ AI pipeline · market analytics │ +│ CDN caching (s-maxage) · PWA host │ +└──────────┬─────────────┬────────────┘ + │ │ fallback + │ ▼ + │ ┌───────────────────────────────────┐ + │ │ Tauri Desktop (Rust + Node) │ + │ │ OS keychain · Token-auth sidecar │ + │ │ 60+ local API handlers · br/gzip │ + │ │ Cloud fallback · Traffic logging │ + │ └───────────────────────────────────┘ + │ + │ https:// (server-side) + │ wss:// (client-side) + ▼ +┌─────────────────────────────────────┐ +│ Railway (Relay Server) │ +│ WebSocket relay · OpenSky OAuth2 │ +│ RSS proxy for blocked domains │ +│ AIS vessel stream · gzip all resp │ +└─────────────────────────────────────┘ ``` -The system counts matching news articles in the current feed, applies velocity analysis, and assigns activity levels: +**Why two platforms?** Several upstream APIs (OpenSky Network, CNN RSS, UN News, CISA, IAEA) actively block requests from Vercel's IP ranges. The Railway relay server acts as an alternate origin, handling: -| Level | Criteria | Visual | -|-------|----------|--------| -| **Low** | <3 matches, normal velocity | Gray marker | -| **Elevated** | 3-6 matches OR elevated velocity | Yellow pulse | -| **High** | >6 matches OR spike velocity | Red pulse | +- **AIS vessel tracking** — maintains a persistent WebSocket connection to AISStream.io and multiplexes it to all connected browser clients, avoiding per-user connection limits +- **OpenSky aircraft data** — authenticates via OAuth2 client credentials flow (Vercel IPs get 403'd by OpenSky without auth tokens) +- **RSS feeds** — proxies feeds from domains that block Vercel IPs, with a separate domain allowlist for security -This creates a dynamic "heat map" of global attention based on live news flow. +The Vercel edge functions connect to Railway via `WS_RELAY_URL` (server-side, HTTPS) while browser clients connect via `VITE_WS_RELAY_URL` (client-side, WSS). This separation keeps the relay URL configurable per deployment without leaking server-side configuration to the browser. + +All Railway relay responses are gzip-compressed (zlib `gzipSync`) when the client accepts it and the payload exceeds 1KB, reducing egress by ~80% for JSON and XML responses. The desktop local sidecar now prefers Brotli (`br`) and falls back to gzip for payloads larger than 1KB, setting `Content-Encoding` and `Vary: Accept-Encoding` automatically. --- -## Custom Monitors +## Desktop Application Architecture + +The Tauri desktop app wraps the dashboard in a native window (macOS, Windows, Linux) with a local Node.js sidecar that runs all API handlers without cloud dependency: + +``` +┌─────────────────────────────────────────────────┐ +│ Tauri (Rust) │ +│ Window management · Consolidated keychain vault│ +│ Token generation · Log management · Menu bar │ +│ Polymarket native TLS bridge │ +└─────────────────────┬───────────────────────────┘ + │ spawn + env vars + ▼ +┌─────────────────────────────────────────────────┐ +│ Node.js Sidecar (dynamic port) │ +│ 60+ API handlers · Local RSS proxy │ +│ Brotli/Gzip compression · Cloud fallback │ +│ Traffic logging · Verbose debug mode │ +└─────────────────────┬───────────────────────────┘ + │ fetch (on local failure) + ▼ +┌─────────────────────────────────────────────────┐ +│ Cloud (worldmonitor.app) │ +│ Transparent fallback when local handlers fail │ +└─────────────────────────────────────────────────┘ +``` + +### Secret Management + +API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager) — never in plaintext config files. All secrets are consolidated into a single JSON vault entry in the keychain, so app startup requires exactly one OS authorization prompt regardless of how many keys are configured. + +At sidecar launch, the vault is read, parsed, 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` so handlers pick up the new value immediately. -Create personalized keyword alerts that scan all incoming news: +**Verification pipeline** — when you enter a credential in Settings, the app validates it against the actual provider API (Groq → `/openai/v1/models`, Ollama → `/api/tags`, FRED → GDP test query, NASA FIRMS → fire data fetch, etc.). Network errors (timeouts, DNS failures, unreachable hosts) are treated as soft passes — the key is saved with a "could not verify" notice rather than blocking. Only explicit 401/403 responses from the provider mark a key as invalid. This prevents transient network issues from locking users out of their own credentials. -1. Enter comma-separated keywords (e.g., "nvidia, gpu, chip shortage") -2. System assigns a unique color -3. Matching articles are highlighted in the Monitor panel -4. Matching articles in clusters inherit the monitor color +**Smart re-verification** — when saving settings, the verification pipeline skips keys that haven't been modified since their last successful verification. This prevents unnecessary round-trips to provider APIs when a user changes one key but has 15 others already configured and validated. Only newly entered or modified keys trigger verification requests. -Monitors persist across sessions via LocalStorage. +**Desktop-specific requirements** — some features require fewer credentials on desktop than on the web. For example, AIS vessel tracking on the web requires both a relay URL and an API key, but the desktop sidecar handles relay connections internally, so only the API key is needed. The settings panel adapts its required-fields display based on the detected platform. + +### Sidecar Authentication + +A unique 32-character hex token is generated per app launch using randomized hash state (`RandomState` from Rust's standard library). The token is: + +1. Injected into the sidecar as `LOCAL_API_TOKEN` +2. Retrieved by the frontend via the `get_local_api_token` Tauri command (lazy-loaded on first API request) +3. Attached as `Authorization: Bearer ` to every local request + +The `/api/service-status` health check endpoint is exempt from token validation to support monitoring tools. + +### Dynamic Port Allocation + +The sidecar defaults to port 46123 but handles `EADDRINUSE` gracefully — if the port is occupied (another World Monitor instance, or any other process), the sidecar binds to port 0 and lets the OS assign an available ephemeral port. The actual bound port is written to a port file (`sidecar.port` in the logs directory) that the Rust host polls on startup (100ms intervals, 5-second timeout). The frontend discovers the port at runtime via the `get_local_api_port` IPC command, and `getApiBaseUrl()` in `runtime.ts` is the canonical accessor — hardcoding port 46123 in frontend code is prohibited. The CSP `connect-src` directive uses `http://127.0.0.1:*` to accommodate any port. + +### Local RSS Proxy + +The sidecar includes a built-in RSS proxy handler that fetches news feeds directly from source domains, bypassing the cloud RSS proxy entirely. This means the desktop app can load all 150+ RSS feeds without any cloud dependency — the same domain allowlist used by the Vercel edge proxy is enforced locally. Combined with the local API handlers, this enables the desktop app to operate as a fully self-contained intelligence aggregation platform. + +### Sidecar Resilience + +The sidecar employs multiple resilience patterns to maintain data availability when upstream APIs degrade: + +- **Stale-on-error** — when an upstream API returns a 5xx error or times out, the sidecar serves the last successful response from its in-memory cache rather than propagating the failure. Panels display stale data with a visual "retrying" indicator rather than going blank +- **Negative caching** — after an upstream failure, the sidecar records a 5-minute negative cache entry to prevent immediately re-hitting the same broken endpoint. Subsequent requests during the cooldown receive the stale response instantly +- **Staggered requests** — APIs with strict rate limits (Yahoo Finance) use sequential request batching with 150ms inter-request delays instead of `Promise.all`. This transforms 10 concurrent requests that would trigger HTTP 429 into a staggered sequence that stays under rate limits +- **In-flight deduplication** — concurrent requests for the same resource (e.g., multiple panels polling the same endpoint) are collapsed into a single upstream fetch. The first request creates a Promise stored in an in-flight map; all concurrent requests await that single Promise +- **Panel retry indicator** — when a panel's data fetch fails and retries, the Panel base class displays a non-intrusive "Retrying..." indicator so users understand the dashboard is self-healing rather than broken + +### Cloud Fallback + +When a local API handler is missing, throws an error, or returns a 5xx status, the sidecar transparently proxies the request to the cloud deployment. Endpoints that fail are marked as `cloudPreferred` — subsequent requests skip the local handler and go directly to the cloud until the sidecar is restarted. Origin and Referer headers are stripped before proxying to maintain server-to-server parity. + +### Observability + +- **Traffic log** — a ring buffer of the last 200 requests with method, path, status, and duration (ms), accessible via `GET /api/local-traffic-log` +- **Verbose mode** — togglable via `POST /api/local-debug-toggle`, persists across sidecar restarts in `verbose-mode.json` +- **Dual log files** — `desktop.log` captures Rust-side events (startup, secret injection counts, menu actions), while `local-api.log` captures Node.js stdout/stderr +- **IPv4-forced fetch** — the sidecar patches `globalThis.fetch` to force IPv4 for all outbound requests. Government APIs (NASA FIRMS, EIA, FRED) publish AAAA DNS records but their IPv6 endpoints frequently timeout. The patch uses `node:https` with `family: 4` to bypass Happy Eyeballs and avoid cascading ETIMEDOUT failures +- **DevTools** — `Cmd+Alt+I` toggles the embedded web inspector --- -## Snapshot System +## Bandwidth Optimization + +The system minimizes egress costs through layered caching and compression across all three deployment targets: + +### Vercel CDN Headers -The dashboard captures periodic snapshots for historical analysis: +Every API edge function includes `Cache-Control` headers that enable Vercel's CDN to serve cached responses without hitting the origin: -- **Automatic capture** every refresh cycle -- **7-day retention** with automatic cleanup -- **Stored data**: news clusters, market prices, prediction values, hotspot levels -- **Playback**: Load historical snapshots to see past dashboard states +| Data Type | `s-maxage` | `stale-while-revalidate` | Rationale | +| ---------------------- | ------------ | ------------------------ | -------------------------------- | +| Classification results | 3600s (1h) | 600s (10min) | Headlines don't reclassify often | +| Country intelligence | 3600s (1h) | 600s (10min) | Briefs change slowly | +| Risk scores | 300s (5min) | 60s (1min) | Near real-time, low latency | +| Market data | 3600s (1h) | 600s (10min) | Intraday granularity sufficient | +| Fire detection | 600s (10min) | 120s (2min) | VIIRS updates every ~12 hours | +| Economic indicators | 3600s (1h) | 600s (10min) | Monthly/quarterly releases | -Baselines (7-day and 30-day averages) are stored in IndexedDB for deviation analysis. +Static assets use content-hash filenames with 1-year immutable cache headers. The service worker file (`sw.js`) is never cached (`max-age=0, must-revalidate`) to ensure update detection. + +### Brotli Pre-Compression (Build-Time) + +`vite build` now emits pre-compressed Brotli artifacts (`*.br`) for static assets larger than 1KB (JS, CSS, HTML, SVG, JSON, XML, TXT, WASM). This reduces transfer size by roughly 20–30% vs gzip-only delivery when the edge can serve Brotli directly. + +For the Hetzner Nginx origin, enable static compressed file serving so `dist/*.br` files are returned without runtime recompression: + +```nginx +gzip on; +gzip_static on; + +brotli on; +brotli_static on; +``` + +Cloudflare will negotiate Brotli automatically for compatible clients when the origin/edge has Brotli assets available. + +### Railway Relay Compression + +All relay server responses pass through `gzipSync` when the client accepts gzip and the payload exceeds 1KB. Sidecar API responses prefer Brotli and use gzip fallback with proper `Content-Encoding`/`Vary` headers for the same threshold. This applies to OpenSky aircraft JSON, RSS XML feeds, UCDP event data, AIS snapshots, and health checks — reducing wire size by approximately 50–80%. + +### In-Flight Request Deduplication + +When multiple connected clients poll simultaneously (common with the relay's multi-tenant WebSocket architecture), identical upstream requests are deduplicated at the relay level. The first request for a given resource key (e.g., an RSS feed URL or OpenSky bounding box) creates a Promise stored in an in-flight Map. All concurrent requests for the same key await that single Promise rather than stampeding the upstream API. Subsequent requests are served from cache with an `X-Cache: DEDUP` header. This prevents scenarios like 53 concurrent RSS cache misses or 5 simultaneous OpenSky requests for the same geographic region — all resolved by a single upstream fetch. + +### Frontend Polling Intervals + +Panels refresh at staggered intervals to avoid synchronized API storms: + +| Panel | Interval | Rationale | +| ---------------------------------- | ----------- | ------------------------------ | +| AIS maritime snapshot | 10s | Real-time vessel positions | +| Service status | 60s | Health check cadence | +| Market signals / ETF / Stablecoins | 180s (3min) | Market hours granularity | +| Risk scores / Theater posture | 300s (5min) | Composite scores change slowly | + +All animations and polling pause when the tab is hidden or after 2 minutes of inactivity, preventing wasted requests from background tabs. --- -## Tech Stack +## Caching Architecture + +Every external API call passes through a three-tier cache with stale-on-error fallback: + +``` +Request → [1] In-Memory Cache → [2] Redis (Upstash) → [3] Upstream API + │ + ◄──── stale data served on error ────────────────┘ +``` + +| Tier | Scope | TTL | Purpose | +| ------------------- | -------------------------- | ------------------ | --------------------------------------------- | +| **In-memory** | Per edge function instance | Varies (60s–900s) | Eliminates Redis round-trips for hot paths | +| **Redis (Upstash)** | Cross-user, cross-instance | Varies (120s–900s) | Deduplicates API calls across all visitors | +| **Upstream** | Source of truth | N/A | External API (Yahoo Finance, CoinGecko, etc.) | + +Cache keys are versioned (`opensky:v2:lamin=...`, `macro-signals:v2:default`) so schema changes don't serve stale formats. Every response includes an `X-Cache` header (`HIT`, `REDIS-HIT`, `MISS`, `REDIS-STALE`, `REDIS-ERROR-FALLBACK`) for debugging. + +**Shared caching layer** — all sebuf handler implementations share a unified Upstash Redis caching module (`_upstash-cache.js`) with a consistent API: `getCachedOrFetch(cacheKey, ttlSeconds, fetchFn)`. This eliminates per-handler caching boilerplate and ensures every RPC endpoint benefits from the three-tier strategy. Cache keys include request-varying parameters (e.g., requested symbols, country codes, bounding boxes) to prevent cache contamination across callers with different inputs. On desktop, the same module runs in the sidecar with an in-memory + persistent file backend when Redis is unavailable. + +**In-flight promise deduplication** — the `cachedFetchJson` function in `server/_shared/redis.ts` maintains an in-memory `Map` of active upstream requests. When a cache miss occurs, the first caller's fetch creates and registers a Promise in the map. All concurrent callers for the same cache key await that single Promise rather than independently hitting the upstream API. This eliminates the "thundering herd" problem where multiple edge function instances simultaneously race to refill an expired cache entry — a scenario that previously caused 50+ concurrent upstream requests during the ~15-second refill window for popular endpoints. + +The AI summarization pipeline adds content-based deduplication: headlines are hashed and checked against Redis before calling Groq, so the same breaking news viewed by 1,000 concurrent users triggers exactly one LLM call. + +--- -- **Frontend**: TypeScript, Vite -- **Visualization**: D3.js, TopoJSON -- **Data**: RSS feeds, REST APIs -- **Storage**: IndexedDB for snapshots/baselines, LocalStorage for preferences +## Security Model + +| 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`. | +| **RSS domain allowlist** | The RSS proxy only fetches from explicitly listed domains (~90+). Requests for unlisted domains are rejected with 403. | +| **Railway domain allowlist** | The Railway relay has a separate, smaller domain allowlist for feeds that need the alternate origin. | +| **API key isolation** | All API keys live server-side in Vercel environment variables. The browser never sees Groq, OpenRouter, ACLED, Finnhub, or other credentials. | +| **Input sanitization** | User-facing content passes through `escapeHtml()` (prevents XSS) and `sanitizeUrl()` (blocks `javascript:` and `data:` URIs). URLs use `escapeAttr()` for attribute context encoding. | +| **Query parameter validation** | API endpoints validate input formats (e.g., stablecoin coin IDs must match `[a-z0-9-]+`, bounding box params are numeric). | +| **IP rate limiting** | AI endpoints use Upstash Redis-backed rate limiting to prevent abuse of Groq/OpenRouter quotas. | +| **Desktop sidecar auth** | The local API sidecar requires a per-session `Bearer` token generated at launch. The token is stored in Rust state and injected into the sidecar environment — only the Tauri frontend can retrieve it via IPC. Health check endpoints are exempt. | +| **OS keychain storage** | Desktop API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager), never in plaintext files or environment variables on disk. | +| **Bot-aware social previews** | The `/api/story` endpoint detects social crawlers (10+ signatures: Twitter, Facebook, LinkedIn, Telegram, Discord, Reddit, WhatsApp, Google) and serves OG-tagged HTML with dynamic preview images. Regular browsers receive a 302 redirect to the SPA. | +| **Bot protection middleware** | Edge Middleware blocks crawlers and scrapers from all `/api/*` routes — bot user-agents and requests with short or missing `User-Agent` headers receive 403. Social preview bots are selectively allowed on `/api/story` and `/api/og-story` for Open Graph image generation. Reinforced by `robots.txt` Disallow rules on API paths. | +| **No debug endpoints** | The `api/debug-env.js` endpoint returns 404 in production — it exists only as a disabled placeholder. | +| **SSRF protection** | The desktop sidecar's RSS proxy runs two-phase URL validation: protocol allowlist (HTTP/HTTPS only), private IP rejection (all RFC-1918 ranges, link-local, multicast, IPv6-mapped v4), DNS resolution to detect rebinding attacks, and **TOCTOU-safe pinning** — the first resolved IPv4 address is locked for the actual TCP connection, preventing DNS rebinding between check and connect. | +| **IPC window hardening** | All sensitive Tauri IPC commands (keychain access, token retrieval, cache operations, Polymarket bridge) gate on `require_trusted_window()`. Only windows with labels in the `TRUSTED_WINDOWS` allowlist (`main`, `settings`, `live-channels`) can invoke these commands — injected iframes or rogue webviews receive an explicit rejection. | +| **DevTools gating** | The developer tools menu item and its `Cmd+Alt+I` keybinding only compile into the binary when the `devtools` Cargo feature is enabled. Production builds omit the feature entirely, so DevTools cannot be opened in shipped binaries regardless of UI manipulation. | -## Installation +--- + +## Error Tracking & Production Hardening + +Sentry captures unhandled exceptions and promise rejections in production, with environment-aware routing (production on `worldmonitor.app`, preview on `*.vercel.app`, disabled on localhost and Tauri desktop). + +The configuration includes 30+ `ignoreErrors` patterns that suppress noise from: + +- **Third-party WebView injections** — Twitter, Facebook, and Instagram in-app browsers inject scripts that reference undefined variables (`CONFIG`, `currentInset`) +- **Browser extensions** — Chrome/Firefox extensions that fail `importScripts` or violate CSP policies +- **WebGL context loss** — transient GPU crashes in MapLibre/deck.gl that self-recover +- **iOS Safari quirks** — IndexedDB connection drops on background tab kills, `NotAllowedError` from autoplay policies +- **Network transients** — `TypeError: Failed to fetch`, `TypeError: Load failed`, `TypeError: cancelled` +- **MapLibre internal crashes** — null-access in style layers, light, and placement that originate from the map chunk + +A custom `beforeSend` hook provides second-stage filtering: it suppresses single-character error messages (minification artifacts), `Importing a module script failed` errors from browser extensions (identified by `chrome-extension:` or `moz-extension:` in the stack trace), and MapLibre internal null-access crashes when the stack trace originates from map chunk files. + +**Chunk reload guard** — after deployments, users with stale browser tabs may encounter `vite:preloadError` events when dynamically imported chunks have new content-hash filenames. The guard listens for this event and performs a one-shot page reload, using `sessionStorage` to prevent infinite reload loops. If the reload succeeds (app initializes fully), the guard flag is cleared. This recovers gracefully from stale-asset 404s without requiring users to manually refresh. + +**Storage quota management** — when a device's localStorage or IndexedDB quota is exhausted (common on mobile Safari with its 5MB limit), a global `_storageQuotaExceeded` flag disables all further write attempts across both the persistent cache (IndexedDB + localStorage fallback) and the utility `saveToStorage()` function. The flag is set on the first `DOMException` with `name === 'QuotaExceededError'` or `code === 22`, and prevents cascading errors from repeated failed writes. Read operations continue normally — cached data remains accessible, only new writes are suppressed. + +Transactions are sampled at 10% to balance observability with cost. Release tracking (`worldmonitor@{version}`) enables regression detection across deployments. + +--- + +## Quick Start ```bash -# Clone the repository +# Clone and run git clone https://github.com/koala73/worldmonitor.git cd worldmonitor - -# Install dependencies npm install +vercel dev # Runs frontend + all 60+ API edge functions +``` + +Open [http://localhost:3000](http://localhost:3000) + +> **Note**: `vercel dev` requires the [Vercel CLI](https://vercel.com/docs/cli) (`npm i -g vercel`). If you use `npm run dev` instead, only the frontend starts — news feeds and API-dependent panels won't load. See [Self-Hosting](#self-hosting) for details. + +### Environment Variables (Optional) + +The dashboard works without any API keys — panels for unconfigured services simply won't appear. For full functionality, copy the example file and fill in the keys you need: + +```bash +cp .env.example .env.local +``` + +The `.env.example` file documents every variable with descriptions and registration links, organized by deployment target (Vercel vs Railway). Key groups: -# Start development server -npm run dev +| Group | Variables | Free Tier | +| ----------------- | -------------------------------------------------------------------------- | ------------------------------------------ | +| **AI (Local)** | `OLLAMA_API_URL`, `OLLAMA_MODEL` | Free (runs on your hardware) | +| **AI (Cloud)** | `GROQ_API_KEY`, `OPENROUTER_API_KEY` | 14,400 req/day (Groq), 50/day (OpenRouter) | +| **Cache** | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` | 10K commands/day | +| **Markets** | `FINNHUB_API_KEY`, `FRED_API_KEY`, `EIA_API_KEY` | All free tier | +| **Tracking** | `WINGBITS_API_KEY`, `AISSTREAM_API_KEY` | Free | +| **Geopolitical** | `ACLED_ACCESS_TOKEN`, `CLOUDFLARE_API_TOKEN`, `NASA_FIRMS_API_KEY` | Free for researchers | +| **Relay** | `WS_RELAY_URL`, `VITE_WS_RELAY_URL`, `OPENSKY_CLIENT_ID/SECRET` | Self-hosted | +| **UI** | `VITE_VARIANT`, `VITE_MAP_INTERACTION_MODE` (`flat` or `3d`, default `3d`) | N/A | +| **Observability** | `VITE_SENTRY_DSN` (optional, empty disables reporting) | N/A | -# Build for production -npm run build +See [`.env.example`](./.env.example) for the complete list with registration links. + +--- + +## 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. + +### Option 1: Deploy to Vercel (Recommended) + +The simplest path — Vercel runs the edge functions natively on their free tier: + +```bash +npm install -g vercel +vercel # Follow prompts to link/create project ``` -## API Dependencies +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. + +### Option 2: Local Development with Vercel CLI + +To run everything locally (frontend + edge functions): + +```bash +npm install -g vercel +cp .env.example .env.local # Add your API keys +vercel dev # Starts on http://localhost:3000 +``` -The dashboard fetches data from various public APIs: +> **Important**: Use `vercel dev` instead of `npm run dev`. The Vercel CLI emulates the edge runtime locally so all `api/` endpoints work. Plain `npm run dev` only starts Vite and the API layer won't be available. -| Service | Data | -|---------|------| -| RSS2JSON | News feed parsing | -| Alpha Vantage | Stock quotes | -| CoinGecko | Cryptocurrency prices | -| USGS | Earthquake data | -| NWS | Weather alerts | -| FRED | Economic indicators | -| Polymarket | Prediction markets | +### Option 3: Static Frontend Only -## Project Structure +If you only want the map and client-side features (no news feeds, no AI, no market data): +```bash +npm run dev # Vite dev server on http://localhost:5173 ``` -src/ -├── App.ts # Main application orchestrator -├── main.ts # Entry point -├── components/ # UI components -│ ├── Map.ts # D3 map with all layers -│ ├── MapPopup.ts # Info popups for map elements -│ ├── SearchModal.ts # Universal search (⌘K) -│ ├── SignalModal.ts # Signal intelligence display -│ ├── NewsPanel.ts # News feed display -│ ├── MarketPanel.ts # Stock/commodity display -│ ├── MonitorPanel.ts # Custom keyword monitors -│ └── ... -├── config/ # Static data & configuration -│ ├── feeds.ts # RSS feeds, source tiers, source types -│ ├── geo.ts # Hotspots, conflicts, bases, cables -│ ├── pipelines.ts # Pipeline data (88 entries) -│ ├── ai-datacenters.ts -│ ├── irradiators.ts -│ └── markets.ts -├── services/ # Data fetching & processing -│ ├── rss.ts # RSS parsing -│ ├── markets.ts # Stock/crypto APIs -│ ├── earthquakes.ts # USGS integration -│ ├── clustering.ts # Jaccard similarity clustering -│ ├── correlation.ts # Signal detection engine -│ ├── velocity.ts # Velocity & sentiment analysis -│ └── storage.ts # IndexedDB snapshots & baselines -├── styles/ # CSS -└── types/ # TypeScript definitions + +This runs the frontend without the API layer. Panels that require server-side proxying will show "No data available". The interactive map, static data layers (bases, cables, pipelines), and browser-side ML models still work. + +### Platform Notes + +| Platform | Status | Notes | +| ---------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Vercel** | Full support | Recommended deployment target | +| **Linux x86_64** | Full support | Works with `vercel dev` for local development. Desktop .AppImage available for x86_64. WebKitGTK rendering uses DMA-BUF with fallback to SHM for GPU compatibility. Font stack includes DejaVu Sans Mono and Liberation Mono for consistent rendering across distros | +| **macOS** | Works with `vercel dev` | Full local development | +| **Raspberry Pi / ARM** | Partial | `vercel dev` edge runtime emulation may not work on ARM. Use Option 1 (deploy to Vercel) or Option 3 (static frontend) instead | +| **Docker** | Planned | See [Roadmap](#roadmap) | + +### Railway Relay (Optional) + +For live AIS vessel tracking and OpenSky aircraft data, deploy the WebSocket relay on Railway: + +```bash +# On Railway, deploy with: +node scripts/ais-relay.cjs ``` -## Usage +Set `WS_RELAY_URL` (server-side, HTTPS) and `VITE_WS_RELAY_URL` (client-side, WSS) in your environment. Without the relay, AIS and OpenSky layers won't show live data, but all other features work normally. + +--- -### Keyboard Shortcuts -- `⌘K` / `Ctrl+K` - Open search -- `↑↓` - Navigate search results -- `Enter` - Select result -- `Esc` - Close modals +## Tech Stack -### Map Controls -- **Scroll** - Zoom in/out -- **Drag** - Pan the map -- **Click markers** - Show detailed popup -- **Layer toggles** - Show/hide data layers +| Category | Technologies | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **Frontend** | TypeScript, Vite, deck.gl (WebGL 3D globe), MapLibre GL, vite-plugin-pwa (service worker + manifest) | +| **Desktop** | Tauri 2 (Rust) with Node.js sidecar, OS keychain integration (keyring crate), native TLS (reqwest) | +| **AI/ML** | Ollama / LM Studio (local, OpenAI-compatible), Groq (Llama 3.1 8B), OpenRouter (fallback), Transformers.js (browser-side T5, NER, embeddings) | +| **Caching** | Redis (Upstash) — 3-tier cache with in-memory + Redis + upstream, cross-user AI deduplication. Vercel CDN (s-maxage). Service worker (Workbox) | +| **Geopolitical APIs** | OpenSky, GDELT, ACLED, UCDP, HAPI, USGS, GDACS, NASA EONET, NASA FIRMS, Polymarket, Cloudflare Radar, WorldPop | +| **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) | +| **Localization** | i18next (16 languages: en, fr, de, es, it, pl, pt, nl, sv, ru, ar, zh, ja, tr, th, vi), RTL support, lazy-loaded bundles, native-language feeds for 7 locales | +| **API Contracts** | Protocol Buffers (92 proto files, 20 services), sebuf HTTP annotations, buf CLI (lint + breaking checks), auto-generated TypeScript clients/servers + OpenAPI 3.1.0 docs | +| **Analytics** | PostHog (privacy-first, typed event schemas, pseudonymous identity, ad-blocker bypass via reverse proxy, offline queue for desktop) | +| **Deployment** | Vercel Edge Functions (60+ endpoints) + Railway (WebSocket relay) + Tauri (macOS/Windows/Linux) + 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 | -### Panel Management -- **Drag panels** - Reorder layout -- **Settings (⚙)** - Toggle panel visibility +--- -## Data Sources +--- -### News Feeds -Aggregates 40+ RSS feeds from major news outlets, government sources, and specialty publications with source-tier prioritization. +## Contributing -### Geospatial Data -- **Hotspots**: 25+ global intelligence hotspots with keyword correlation -- **Conflicts**: Active conflict zones with involved parties -- **Pipelines**: 88 operating oil/gas pipelines across all continents -- **Military Bases**: Major global installations -- **Nuclear**: Power plants, research reactors, irradiator facilities +Contributions welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed guidelines, including the Sebuf RPC framework workflow, how to add data sources and RSS feeds, and our AI-assisted development policy. The project also maintains a [Code of Conduct](./CODE_OF_CONDUCT.md) and [Security Policy](./SECURITY.md) for responsible vulnerability disclosure. -### Live APIs -- USGS earthquake feed (M4.5+ global) -- National Weather Service alerts -- Internet outage monitoring -- Cryptocurrency prices (real-time) +```bash +# 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) +npm run dev:happy # Happy variant (happy.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:happy # Build happy variant + +# Quality (also runs automatically on PRs via GitHub Actions) +npm run typecheck # TypeScript type checking (tsc --noEmit) + +# 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) + +# Generic packaging runner +npm run desktop:package -- --os macos --variant full + +# Signed packaging (same targets, requires signing env vars) +npm run desktop:package:macos:full:sign +npm run desktop:package:windows:full:sign +``` + +Desktop release details, signing hooks, variant outputs, and clean-machine validation checklist: + +- [docs/RELEASE_PACKAGING.md](./docs/RELEASE_PACKAGING.md) --- -## Design Philosophy +## Roadmap + +- [x] 60+ API edge functions for programmatic access +- [x] Tri-variant system (geopolitical + tech + finance) +- [x] Market intelligence (macro signals, ETF flows, stablecoin peg monitoring) +- [x] Railway relay for WebSocket and blocked-domain proxying +- [x] CORS origin allowlist and security hardening +- [x] Native desktop application (Tauri) with OS keychain + authenticated sidecar +- [x] Progressive Web App with offline map support and installability +- [x] Bandwidth optimization (CDN caching, gzip relay, staggered polling) +- [x] 3D WebGL globe visualization (deck.gl) +- [x] Natural disaster monitoring (USGS + GDACS + NASA EONET) +- [x] Historical playback via IndexedDB snapshots +- [x] Live YouTube stream detection with desktop embed bridge +- [x] Country brief pages with AI-generated intelligence dossiers +- [x] Local-first country detection (browser-side ray-casting, no network dependency) +- [x] Climate anomaly monitoring (15 conflict-prone zones) +- [x] Displacement tracking (UNHCR/HAPI origins & hosts) +- [x] Country brief export (JSON, CSV, PNG, PDF) +- [x] Cyber threat intelligence layer (Feodo Tracker, URLhaus, OTX, AbuseIPDB, C2IntelFeeds) +- [x] Trending keyword spike detection with baseline anomaly alerting +- [x] Oil & energy analytics (EIA: WTI, Brent, production, inventory) +- [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 +- [x] Live webcam surveillance grid (19 geopolitical hotspot streams with region filtering) +- [x] Ultra-wide monitor layout (L-shaped panel wrapping on 2000px+ screens) +- [x] Linux desktop app (.AppImage) +- [x] Dark/light theme toggle with persistent preference +- [x] Desktop auto-update checker with variant-aware release linking +- [x] Panel drag-and-drop reordering with persistent layout +- [x] Map pin mode for fixed map positioning +- [x] Virtual scrolling for news panels (DOM recycling, pooled elements) +- [x] Local LLM support (Ollama / LM Studio) with automatic model discovery and 4-tier fallback chain +- [x] Settings window with dedicated LLMs, API Keys, and Debug tabs +- [x] Consolidated keychain vault (single OS prompt on startup) +- [x] Cross-window secret synchronization (main ↔ settings) +- [x] API key verification pipeline with soft-pass on network errors +- [x] Proto-first API contracts (92 proto files, 20 service domains, auto-generated TypeScript + OpenAPI docs) +- [x] USNI Fleet Intelligence (weekly deployment reports merged with live AIS tracking) +- [x] Aircraft enrichment via Wingbits (military confidence classification) +- [x] Undersea cable health monitoring (NGA navigational warnings + AIS cable ship tracking) +- [x] Dynamic Open Graph images for social sharing (SVG card generation with CII scores) +- [x] Storage quota management (graceful degradation on exhausted localStorage/IndexedDB) +- [x] Chunk reload guard (one-shot recovery from stale-asset 404s after deployments) +- [x] PizzINT activity monitor with DEFCON-style scoring and GDELT bilateral tension tracking +- [x] Bot protection middleware (edge-level crawler blocking with social preview exceptions) +- [x] In-flight request deduplication on relay (prevents upstream API stampede from concurrent clients) +- [x] Instant flat-render news panels (ML clustering runs async, items appear immediately) +- [x] Cable health scoring algorithm (time-decay weighted signals from NGA warnings with cos-lat distance matching) +- [x] Thai and Vietnamese localization (16 total languages, 1,361 keys per locale) +- [x] Native-language RSS feeds for Turkish, Polish, Russian, Thai, and Vietnamese locales +- [x] Desktop sidecar RSS proxy (local feed fetching without cloud fallback) +- [x] Negative caching and version discovery for UCDP upstream API resilience +- [x] XRP (Ripple) added to crypto market tracking +- [x] Shared Upstash Redis caching layer across all 37 RPC handlers with parameterized cache keys +- [x] PostHog product analytics with typed event schemas, API key stripping, and ad-blocker bypass +- [x] Opt-in intelligence alert popups (default off, toggle in dropdown header) +- [x] Linux WebKitGTK DMA-BUF rendering with SHM fallback and cross-distro font stack +- [x] Consolidated `--font-mono` CSS variable for cross-platform typographic consistency +- [x] Dedup coordinate precision increased to 0.1° (~10km) for finer-grained event matching +- [x] Community guidelines (CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md) +- [x] Yahoo Finance staggered request batching to prevent 429 rate limiting +- [x] Panel base class retry indicator (`showRetrying`) for visual feedback during data refresh +- [x] Happy Monitor variant (positive news dashboard with conservation wins, renewables, and human progress metrics) +- [x] Supply Chain Disruption Intelligence (chokepoint monitoring, shipping rates, critical mineral HHI analysis) +- [x] Cache stampede prevention via `cachedFetchJson` in-flight promise deduplication across all server handlers +- [x] Dynamic sidecar port allocation with EADDRINUSE fallback and port file discovery +- [x] Universal CII scoring for all countries with event data (expanded from 23 curated to all nations) +- [x] Cmd+K command palette with ~250 country commands, layer presets, and fuzzy search scoring +- [x] SSRF protection with DNS resolution, private IP rejection, and TOCTOU-safe address pinning +- [x] IPC window hardening with trusted-window allowlist for all sensitive Tauri commands +- [x] DevTools gating via Cargo feature flag (disabled in production builds) +- [x] TypeScript CI check (GitHub Actions `tsc --noEmit` on every PR) +- [x] Trade route visualization (13 global lanes as animated deck.gl arcs with chokepoint markers) +- [x] OpenSky API optimization (4→2 merged query regions covering all military hotspots) +- [x] Global streaming video quality control (auto/360p/480p/720p with live propagation) +- [x] Ransomware.live feed added to cyber threat intelligence sources +- [x] Browser Local Model toggle properly gated (ML worker only initializes when enabled) +- [x] Linux AppImage crash fixes (WebKitGTK DMA-BUF rendering, console noise suppression) +- [x] Yahoo Finance rate-limit UX (user-facing messages instead of silent failures across all market panels) +- [x] Sidecar auth resilience (401-retry with token refresh, settings window `diagFetch` auth) +- [x] Verbose toggle persistence to writable data directory (fixes read-only app bundle on macOS) +- [x] Finnhub-to-Yahoo fallback routing when API key is missing +- [ ] Mobile-optimized views +- [ ] Push notifications for critical alerts +- [ ] Self-hosted Docker image + +See [full roadmap](./docs/DOCUMENTATION.md#roadmap). -**Information density over aesthetics.** Every pixel should convey signal. The dark interface minimizes eye strain during extended monitoring sessions. +--- -**Authority matters.** Not all sources are equal. Wire services and official government channels are prioritized over aggregators and blogs. +## Support the Project -**Correlation over accumulation.** Raw news feeds are noise. The value is in clustering related stories, detecting velocity changes, and identifying cross-source patterns. +If you find World Monitor useful: -**Local-first.** No accounts, no cloud sync. All preferences and history stored locally. The only network traffic is fetching public data. +- **Star this repo** to help others discover it +- **Share** with colleagues interested in OSINT +- **Contribute** code, data sources, or documentation +- **Report issues** to help improve the platform --- ## License -MIT +GNU Affero General Public License v3.0 (AGPL-3.0) — see [LICENSE](LICENSE) for details. + +--- ## Author -**Elie Habib** +**Elie Habib** — [GitHub](https://github.com/koala73) --- -*Built for situational awareness and open-source intelligence gathering.* +## Contributors + +Thanks to everyone who has contributed to World Monitor: + +[@SebastienMelki](https://github.com/SebastienMelki), +[@Lib-LOCALE](https://github.com/Lib-LOCALE), +[@lawyered0](https://github.com/lawyered0), +[@elzalem](https://github.com/elzalem), +[@Rau1CS](https://github.com/Rau1CS), +[@Sethispr](https://github.com/Sethispr), +[@InlitX](https://github.com/InlitX), +[@Ahmadhamdan47](https://github.com/Ahmadhamdan47), +[@K35P](https://github.com/K35P), +[@Niboshi-Wasabi](https://github.com/Niboshi-Wasabi), +[@pedroddomingues](https://github.com/pedroddomingues), +[@haosenwang1018](https://github.com/haosenwang1018), +[@aa5064](https://github.com/aa5064), +[@cwnicoletti](https://github.com/cwnicoletti), +[@facusturla](https://github.com/facusturla), +[@toasterbook88](https://github.com/toasterbook88) + +--- + +## Security Acknowledgments + +We thank the following researchers for responsibly disclosing security issues: + +- **Cody Richard** — Disclosed three security findings covering IPC command exposure via DevTools in production builds, renderer-to-sidecar trust boundary analysis, and the global fetch patch credential injection architecture (2025) + +If you discover a vulnerability, please see our [Security Policy](./SECURITY.md) for responsible disclosure guidelines. + +--- + +

+ worldmonitor.app  ·  + tech.worldmonitor.app  ·  + finance.worldmonitor.app +

+ +## Star History + + + + + Star History Chart + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..cff890e27 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,112 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| main | :white_check_mark: | + +Only the latest version on the `main` branch is actively maintained and receives security updates. + +## Reporting a Vulnerability + +**Please do NOT report security vulnerabilities through public GitHub issues.** + +If you discover a security vulnerability in World Monitor, please report it responsibly: + +1. **GitHub Private Vulnerability Reporting**: Use [GitHub's private vulnerability reporting](https://github.com/koala73/worldmonitor/security/advisories/new) to submit your report directly through the repository. + +2. **Direct Contact**: Alternatively, reach out to the repository owner [@koala73](https://github.com/koala73) directly through GitHub. + +### What to Include + +- A description of the vulnerability and its potential impact +- Steps to reproduce the issue +- Affected components (edge functions, client-side code, data layers, etc.) +- Any potential fixes or mitigations you've identified + +### Response Timeline + +- **Acknowledgment**: Within 48 hours of your report +- **Initial Assessment**: Within 1 week +- **Fix/Patch**: Depending on severity, critical issues will be prioritized + +### What to Expect + +- You will receive an acknowledgment of your report +- We will work with you to understand and validate the issue +- We will keep you informed of progress toward a fix +- Credit will be given to reporters in the fix commit (unless you prefer anonymity) + +## Security Considerations + +World Monitor is a client-side intelligence dashboard that aggregates publicly available data. Here are the key security areas: + +### API Keys & Secrets + +- **Web deployment**: API keys are stored server-side in Vercel Edge Functions +- **Desktop runtime**: API keys are stored in the OS keychain (macOS Keychain / Windows Credential Manager) via a consolidated vault entry, never on disk in plaintext +- No API keys should ever be committed to the repository +- Environment variables (`.env.local`) are gitignored +- The RSS proxy uses domain allowlisting to prevent SSRF + +### Edge Functions & Sebuf Handlers + +- All 17 domain APIs are served through Sebuf (a Proto-first RPC framework) via Vercel Edge Functions +- Edge functions and handlers should validate/sanitize all input +- CORS headers are configured per-function +- Rate limiting and circuit breakers protect against abuse + +### Client-Side Security + +- No sensitive data is stored in localStorage or sessionStorage +- External content (RSS feeds, news) is sanitized before rendering +- Map data layers use trusted, vetted data sources +- Content Security Policy restricts script-src to `'self'` (no unsafe-inline/eval) + +### Desktop Runtime Security (Tauri) + +- **IPC origin validation**: Sensitive Tauri commands (secrets, cache, token) are gated to trusted windows only; external-origin windows (e.g., YouTube login) are blocked +- **DevTools**: Disabled in production builds; gated behind an opt-in Cargo feature for development +- **Sidecar authentication**: A per-session CSPRNG token (`LOCAL_API_TOKEN`) authenticates all renderer-to-sidecar requests, preventing other local processes from accessing the API +- **Capability isolation**: The YouTube login window runs under a restricted capability with no access to secret or cache IPC commands +- **Fetch patch trust boundary**: The global fetch interceptor injects the sidecar token with a 5-minute TTL; the renderer is the intended client — if renderer integrity is compromised, Tauri IPC provides strictly more access than the fetch patch + +### Data Sources + +- World Monitor aggregates publicly available OSINT data +- No classified or restricted data sources are used +- State-affiliated sources are flagged with propaganda risk ratings +- All data is consumed read-only — the platform does not modify upstream sources + +## Scope + +The following are **in scope** for security reports: + +- Vulnerabilities in the World Monitor codebase +- Edge function security issues (SSRF, injection, auth bypass) +- XSS or content injection through RSS feeds or external data +- API key exposure or secret leakage +- Tauri IPC command privilege escalation or capability bypass +- Sidecar authentication bypass or token leakage +- Dependency vulnerabilities with a viable attack vector + +The following are **out of scope**: + +- Vulnerabilities in third-party services we consume (report to the upstream provider) +- Social engineering attacks +- Denial of service attacks +- Issues in forked copies of the repository +- Security issues in user-provided environment configurations + +## Best Practices for Contributors + +- Never commit API keys, tokens, or secrets +- Use environment variables for all sensitive configuration +- Sanitize external input in edge functions +- Keep dependencies updated — run `npm audit` regularly +- Follow the principle of least privilege for API access + +--- + +Thank you for helping keep World Monitor and its users safe! 🔒 diff --git a/api-cache.json b/api-cache.json new file mode 100644 index 000000000..ed1ab5233 --- /dev/null +++ b/api-cache.json @@ -0,0 +1 @@ +{"theater-posture:stale:v4":{"value":{"postures":[{"theaterId":"iran-theater","theaterName":"Iran Theater","shortName":"IRAN","targetNation":"Iran","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":2,"totalAircraft":2,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":2},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"2 other","headline":"Normal activity - Iran Theater","centerLat":31,"centerLon":47.5,"bounds":{"north":42,"south":20,"east":65,"west":30}},{"theaterId":"taiwan-theater","theaterName":"Taiwan Strait","shortName":"TAIWAN","targetNation":"Taiwan","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Taiwan Strait","centerLat":24,"centerLon":122.5,"bounds":{"north":30,"south":18,"east":130,"west":115}},{"theaterId":"baltic-theater","theaterName":"Baltic Theater","shortName":"BALTIC","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Baltic Theater","centerLat":58.5,"centerLon":21,"bounds":{"north":65,"south":52,"east":32,"west":10}},{"theaterId":"blacksea-theater","theaterName":"Black Sea","shortName":"BLACK SEA","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Black Sea","centerLat":44,"centerLon":34,"bounds":{"north":48,"south":40,"east":42,"west":26}},{"theaterId":"korea-theater","theaterName":"Korean Peninsula","shortName":"KOREA","targetNation":"North Korea","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Korean Peninsula","centerLat":38,"centerLon":128,"bounds":{"north":43,"south":33,"east":132,"west":124}},{"theaterId":"south-china-sea","theaterName":"South China Sea","shortName":"SCS","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":4,"totalAircraft":4,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":4},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"4 other","headline":"Normal activity - South China Sea","centerLat":15,"centerLon":113,"bounds":{"north":25,"south":5,"east":121,"west":105}},{"theaterId":"east-med-theater","theaterName":"Eastern Mediterranean","shortName":"E.MED","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Eastern Mediterranean","centerLat":35,"centerLon":31,"bounds":{"north":37,"south":33,"east":37,"west":25}},{"theaterId":"israel-gaza-theater","theaterName":"Israel/Gaza","shortName":"GAZA","targetNation":"Gaza","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Israel/Gaza","centerLat":31,"centerLon":34.5,"bounds":{"north":33,"south":29,"east":36,"west":33}},{"theaterId":"yemen-redsea-theater","theaterName":"Yemen/Red Sea","shortName":"RED SEA","targetNation":"Yemen","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Yemen/Red Sea","centerLat":16.5,"centerLon":43,"bounds":{"north":22,"south":11,"east":54,"west":32}}],"totalFlights":538,"timestamp":"2026-02-19T20:11:12.832Z","cached":false,"source":"opensky"},"expiresAt":1771618272832},"theater-posture:backup:v4":{"value":{"postures":[{"theaterId":"iran-theater","theaterName":"Iran Theater","shortName":"IRAN","targetNation":"Iran","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":2,"totalAircraft":2,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":2},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"2 other","headline":"Normal activity - Iran Theater","centerLat":31,"centerLon":47.5,"bounds":{"north":42,"south":20,"east":65,"west":30}},{"theaterId":"taiwan-theater","theaterName":"Taiwan Strait","shortName":"TAIWAN","targetNation":"Taiwan","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Taiwan Strait","centerLat":24,"centerLon":122.5,"bounds":{"north":30,"south":18,"east":130,"west":115}},{"theaterId":"baltic-theater","theaterName":"Baltic Theater","shortName":"BALTIC","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Baltic Theater","centerLat":58.5,"centerLon":21,"bounds":{"north":65,"south":52,"east":32,"west":10}},{"theaterId":"blacksea-theater","theaterName":"Black Sea","shortName":"BLACK SEA","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Black Sea","centerLat":44,"centerLon":34,"bounds":{"north":48,"south":40,"east":42,"west":26}},{"theaterId":"korea-theater","theaterName":"Korean Peninsula","shortName":"KOREA","targetNation":"North Korea","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Korean Peninsula","centerLat":38,"centerLon":128,"bounds":{"north":43,"south":33,"east":132,"west":124}},{"theaterId":"south-china-sea","theaterName":"South China Sea","shortName":"SCS","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":4,"totalAircraft":4,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":4},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"4 other","headline":"Normal activity - South China Sea","centerLat":15,"centerLon":113,"bounds":{"north":25,"south":5,"east":121,"west":105}},{"theaterId":"east-med-theater","theaterName":"Eastern Mediterranean","shortName":"E.MED","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Eastern Mediterranean","centerLat":35,"centerLon":31,"bounds":{"north":37,"south":33,"east":37,"west":25}},{"theaterId":"israel-gaza-theater","theaterName":"Israel/Gaza","shortName":"GAZA","targetNation":"Gaza","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Israel/Gaza","centerLat":31,"centerLon":34.5,"bounds":{"north":33,"south":29,"east":36,"west":33}},{"theaterId":"yemen-redsea-theater","theaterName":"Yemen/Red Sea","shortName":"RED SEA","targetNation":"Yemen","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Yemen/Red Sea","centerLat":16.5,"centerLon":43,"bounds":{"north":22,"south":11,"east":54,"west":32}}],"totalFlights":538,"timestamp":"2026-02-19T20:11:12.832Z","cached":false,"source":"opensky"},"expiresAt":1772136672832},"baseline:military_flights:global:4:2":{"value":{"mean":0,"m2":0,"sampleCount":2,"lastUpdated":"2026-02-19T20:08:35.349Z"},"expiresAt":1779307715349},"baseline:vessels:global:4:2":{"value":{"mean":0,"m2":0,"sampleCount":2,"lastUpdated":"2026-02-19T20:08:35.349Z"},"expiresAt":1779307715349},"hapi:conflict-events:v2":{"value":{"success":true,"count":1,"countries":[{"iso3":"AFG","locationName":"Afghanistan","month":"2026-02-01T00:00:00","eventsTotal":0,"eventsPoliticalViolence":0,"eventsCivilianTargeting":0,"eventsDemonstrations":0,"fatalitiesTotalPoliticalViolence":0,"fatalitiesTotalCivilianTargeting":0}],"cached_at":"2026-02-19T20:01:20.424Z"},"expiresAt":1771552880425},"summary:v3:brief:full:en:10a2oqe:g1rq24u":{"value":{"summary":"President Donald Trump warned Iran that “bad things” will happen if a nuclear deal isn’t reached as a US aircraft carrier approaches the Middle East. Trump also stated the world has ten days to see if an agreement with Iran can be made, adding further pressure on negotiations.","model":"gemma2:latest","timestamp":1771531292941},"expiresAt":1771617692941},"summary:v3:brief:full:en:1idysne:g12sk09":{"value":{"summary":"Putin is increasingly turning to Africa for soldiers as Russian battlefield losses mount, according to multiple reports. This comes as the US considers lifting some anti-Russian sanctions due to lucrative projects, a proposition put forward by Putin's envoy.","model":"gemma2:latest","timestamp":1771531307616},"expiresAt":1771617707616},"baseline:news:global:4:2":{"value":{"mean":295,"m2":0,"sampleCount":2,"lastUpdated":"2026-02-19T20:08:47.959Z"},"expiresAt":1779307727959},"summary:v3:brief:full:en:15s2wma:g1iehlv":{"value":{"summary":"Iran's regime faces increasing pressure as President Donald Trump warns of \"bad things\" if a nuclear deal isn't reached within ten days, while a US aircraft carrier approaches the Middle East. Trump's ultimatum coincides with the US withdrawing troops from Syria amid escalating tensions with Iran.","model":"gemma2:latest","timestamp":1771531329216},"expiresAt":1771617729216},"summary:v3:brief:full:en:uo7yqh:g1bu384":{"value":{"summary":"At a meeting of Trump's Board of Peace in Washington D.C., President Donald Trump announced billions of dollars in aid for Gaza, while Palestinian officials in Gaza denounced the initiative as further entrenching Israeli occupation. The announcement comes amidst ongoing humanitarian crisis in Gaza, with reports indicating worsening conditions for residents.","model":"gemma2:latest","timestamp":1771531350348},"expiresAt":1771617750348},"summary:v3:brief:full:en:rnciuc:g1jkpe1":{"value":{"summary":"At the Board of Peace meeting in Jerusalem, Donald Trump announced billions of dollars in aid for Gaza, a move met with mixed reactions. While some Palestinians view the aid as insufficient and a tool to further Israeli occupation, Hamas is reasserting control in Gaza despite heavy losses during its recent conflict with Israel.","model":"gemma2:latest","timestamp":1771531388192},"expiresAt":1771617788192},"summary:v3:brief:full:en:11skhu5:guhriii":{"value":{"summary":"Trump appears to be setting a 10-day deadline for Iran, as the US deploys a vast military force in the Middle East. This escalation comes amid reports of a power struggle within Iran following mass protests and the death of Abu Shabab, a key Hamas leader.","model":"gemma2:latest","timestamp":1771531570725},"expiresAt":1771617970725},"summary:v3:brief:full:en:19vfxdl:guhriii":{"value":{"summary":"Iran's regime faces internal power struggles following nationwide protests and the death of Abu Shabab, a key Hamas leader. Simultaneously, President Trump has issued a potential 10-day ultimatum to Iran while deploying a significant US military force to the Middle East, raising tensions in the region.","model":"gemma2:latest","timestamp":1771531825190},"expiresAt":1771618225190},"theater-posture:v4":{"value":{"postures":[{"theaterId":"iran-theater","theaterName":"Iran Theater","shortName":"IRAN","targetNation":"Iran","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":2,"totalAircraft":2,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":2},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"2 other","headline":"Normal activity - Iran Theater","centerLat":31,"centerLon":47.5,"bounds":{"north":42,"south":20,"east":65,"west":30}},{"theaterId":"taiwan-theater","theaterName":"Taiwan Strait","shortName":"TAIWAN","targetNation":"Taiwan","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Taiwan Strait","centerLat":24,"centerLon":122.5,"bounds":{"north":30,"south":18,"east":130,"west":115}},{"theaterId":"baltic-theater","theaterName":"Baltic Theater","shortName":"BALTIC","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Baltic Theater","centerLat":58.5,"centerLon":21,"bounds":{"north":65,"south":52,"east":32,"west":10}},{"theaterId":"blacksea-theater","theaterName":"Black Sea","shortName":"BLACK SEA","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Black Sea","centerLat":44,"centerLon":34,"bounds":{"north":48,"south":40,"east":42,"west":26}},{"theaterId":"korea-theater","theaterName":"Korean Peninsula","shortName":"KOREA","targetNation":"North Korea","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Korean Peninsula","centerLat":38,"centerLon":128,"bounds":{"north":43,"south":33,"east":132,"west":124}},{"theaterId":"south-china-sea","theaterName":"South China Sea","shortName":"SCS","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":4,"totalAircraft":4,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":4},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"4 other","headline":"Normal activity - South China Sea","centerLat":15,"centerLon":113,"bounds":{"north":25,"south":5,"east":121,"west":105}},{"theaterId":"east-med-theater","theaterName":"Eastern Mediterranean","shortName":"E.MED","targetNation":null,"fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":3,"totalAircraft":3,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{"unknown":3},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"3 other","headline":"Normal activity - Eastern Mediterranean","centerLat":35,"centerLon":31,"bounds":{"north":37,"south":33,"east":37,"west":25}},{"theaterId":"israel-gaza-theater","theaterName":"Israel/Gaza","shortName":"GAZA","targetNation":"Gaza","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Israel/Gaza","centerLat":31,"centerLon":34.5,"bounds":{"north":33,"south":29,"east":36,"west":33}},{"theaterId":"yemen-redsea-theater","theaterName":"Yemen/Red Sea","shortName":"RED SEA","targetNation":"Yemen","fighters":0,"tankers":0,"awacs":0,"reconnaissance":0,"transport":0,"bombers":0,"drones":0,"unknown":0,"totalAircraft":0,"destroyers":0,"frigates":0,"carriers":0,"submarines":0,"patrol":0,"auxiliaryVessels":0,"totalVessels":0,"byOperator":{},"postureLevel":"normal","strikeCapable":false,"trend":"stable","changePercent":0,"summary":"No military aircraft","headline":"Normal activity - Yemen/Red Sea","centerLat":16.5,"centerLon":43,"bounds":{"north":22,"south":11,"east":54,"west":32}}],"totalFlights":538,"timestamp":"2026-02-19T20:11:12.832Z","cached":false,"source":"opensky"},"expiresAt":1771532172832}} \ No newline at end of file diff --git a/api/[domain]/v1/[rpc].ts b/api/[domain]/v1/[rpc].ts new file mode 100644 index 000000000..197fd4ae2 --- /dev/null +++ b/api/[domain]/v1/[rpc].ts @@ -0,0 +1,150 @@ +/** + * Vercel edge function for sebuf RPC routes. + * + * Matches /api/{domain}/v1/{rpc} via Vercel dynamic segment routing. + * CORS headers are applied to every response (200, 204, 403, 404). + */ + +export const config = { runtime: 'edge' }; + +import { createRouter } from '../../../server/router'; +import { getCorsHeaders, isDisallowedOrigin } from '../../../server/cors'; +// @ts-expect-error — JS module, no declaration file +import { validateApiKey } from '../../_api-key.js'; +import { mapErrorToResponse } from '../../../server/error-mapper'; +import { createSeismologyServiceRoutes } from '../../../src/generated/server/worldmonitor/seismology/v1/service_server'; +import { seismologyHandler } from '../../../server/worldmonitor/seismology/v1/handler'; +import { createWildfireServiceRoutes } from '../../../src/generated/server/worldmonitor/wildfire/v1/service_server'; +import { wildfireHandler } from '../../../server/worldmonitor/wildfire/v1/handler'; +import { createClimateServiceRoutes } from '../../../src/generated/server/worldmonitor/climate/v1/service_server'; +import { climateHandler } from '../../../server/worldmonitor/climate/v1/handler'; +import { createPredictionServiceRoutes } from '../../../src/generated/server/worldmonitor/prediction/v1/service_server'; +import { predictionHandler } from '../../../server/worldmonitor/prediction/v1/handler'; +import { createDisplacementServiceRoutes } from '../../../src/generated/server/worldmonitor/displacement/v1/service_server'; +import { displacementHandler } from '../../../server/worldmonitor/displacement/v1/handler'; +import { createAviationServiceRoutes } from '../../../src/generated/server/worldmonitor/aviation/v1/service_server'; +import { aviationHandler } from '../../../server/worldmonitor/aviation/v1/handler'; +import { createResearchServiceRoutes } from '../../../src/generated/server/worldmonitor/research/v1/service_server'; +import { researchHandler } from '../../../server/worldmonitor/research/v1/handler'; +import { createUnrestServiceRoutes } from '../../../src/generated/server/worldmonitor/unrest/v1/service_server'; +import { unrestHandler } from '../../../server/worldmonitor/unrest/v1/handler'; +import { createConflictServiceRoutes } from '../../../src/generated/server/worldmonitor/conflict/v1/service_server'; +import { conflictHandler } from '../../../server/worldmonitor/conflict/v1/handler'; +import { createMaritimeServiceRoutes } from '../../../src/generated/server/worldmonitor/maritime/v1/service_server'; +import { maritimeHandler } from '../../../server/worldmonitor/maritime/v1/handler'; +import { createCyberServiceRoutes } from '../../../src/generated/server/worldmonitor/cyber/v1/service_server'; +import { cyberHandler } from '../../../server/worldmonitor/cyber/v1/handler'; +import { createEconomicServiceRoutes } from '../../../src/generated/server/worldmonitor/economic/v1/service_server'; +import { economicHandler } from '../../../server/worldmonitor/economic/v1/handler'; +import { createInfrastructureServiceRoutes } from '../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; +import { infrastructureHandler } from '../../../server/worldmonitor/infrastructure/v1/handler'; +import { createMarketServiceRoutes } from '../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { marketHandler } from '../../../server/worldmonitor/market/v1/handler'; +import { createNewsServiceRoutes } from '../../../src/generated/server/worldmonitor/news/v1/service_server'; +import { newsHandler } from '../../../server/worldmonitor/news/v1/handler'; +import { createIntelligenceServiceRoutes } from '../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; +import { intelligenceHandler } from '../../../server/worldmonitor/intelligence/v1/handler'; +import { createMilitaryServiceRoutes } from '../../../src/generated/server/worldmonitor/military/v1/service_server'; +import { militaryHandler } from '../../../server/worldmonitor/military/v1/handler'; +import { createPositiveEventsServiceRoutes } from '../../../src/generated/server/worldmonitor/positive_events/v1/service_server'; +import { positiveEventsHandler } from '../../../server/worldmonitor/positive-events/v1/handler'; +import { createGivingServiceRoutes } from '../../../src/generated/server/worldmonitor/giving/v1/service_server'; +import { givingHandler } from '../../../server/worldmonitor/giving/v1/handler'; +import { createTradeServiceRoutes } from '../../../src/generated/server/worldmonitor/trade/v1/service_server'; +import { tradeHandler } from '../../../server/worldmonitor/trade/v1/handler'; +import { createSupplyChainServiceRoutes } from '../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; +import { supplyChainHandler } from '../../../server/worldmonitor/supply-chain/v1/handler'; + +import type { ServerOptions } from '../../../src/generated/server/worldmonitor/seismology/v1/service_server'; + +const serverOptions: ServerOptions = { onError: mapErrorToResponse }; + +const allRoutes = [ + ...createSeismologyServiceRoutes(seismologyHandler, serverOptions), + ...createWildfireServiceRoutes(wildfireHandler, serverOptions), + ...createClimateServiceRoutes(climateHandler, serverOptions), + ...createPredictionServiceRoutes(predictionHandler, serverOptions), + ...createDisplacementServiceRoutes(displacementHandler, serverOptions), + ...createAviationServiceRoutes(aviationHandler, serverOptions), + ...createResearchServiceRoutes(researchHandler, serverOptions), + ...createUnrestServiceRoutes(unrestHandler, serverOptions), + ...createConflictServiceRoutes(conflictHandler, serverOptions), + ...createMaritimeServiceRoutes(maritimeHandler, serverOptions), + ...createCyberServiceRoutes(cyberHandler, serverOptions), + ...createEconomicServiceRoutes(economicHandler, serverOptions), + ...createInfrastructureServiceRoutes(infrastructureHandler, serverOptions), + ...createMarketServiceRoutes(marketHandler, serverOptions), + ...createNewsServiceRoutes(newsHandler, serverOptions), + ...createIntelligenceServiceRoutes(intelligenceHandler, serverOptions), + ...createMilitaryServiceRoutes(militaryHandler, serverOptions), + ...createPositiveEventsServiceRoutes(positiveEventsHandler, serverOptions), + ...createGivingServiceRoutes(givingHandler, serverOptions), + ...createTradeServiceRoutes(tradeHandler, serverOptions), + ...createSupplyChainServiceRoutes(supplyChainHandler, serverOptions), +]; + +const router = createRouter(allRoutes); + +export default async function handler(request: Request): Promise { + // Origin check first — skip CORS headers for disallowed origins (M-2 fix) + if (isDisallowedOrigin(request)) { + return new Response(JSON.stringify({ error: 'Origin not allowed' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let corsHeaders: Record; + try { + corsHeaders = getCorsHeaders(request); + } catch { + corsHeaders = { 'Access-Control-Allow-Origin': '*' }; + } + + // OPTIONS preflight + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + // API key validation (origin-aware) + const keyCheck = validateApiKey(request); + if (keyCheck.required && !keyCheck.valid) { + return new Response(JSON.stringify({ error: keyCheck.error }), { + status: 401, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Route matching + const matchedHandler = router.match(request); + if (!matchedHandler) { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + // Execute handler with top-level error boundary (H-1 fix) + let response: Response; + try { + response = await matchedHandler(request); + } catch (err) { + console.error('[gateway] Unhandled handler error:', err); + response = new Response(JSON.stringify({ message: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Merge CORS headers into response + const mergedHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(corsHeaders)) { + mergedHeaders.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: mergedHeaders, + }); +} diff --git a/api/_api-key.js b/api/_api-key.js new file mode 100644 index 000000000..d7974bca7 --- /dev/null +++ b/api/_api-key.js @@ -0,0 +1,30 @@ +const DESKTOP_ORIGIN_PATTERNS = [ + /^https?:\/\/tauri\.localhost(:\d+)?$/, + /^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i, + /^tauri:\/\/localhost$/, + /^asset:\/\/localhost$/, +]; + +function isDesktopOrigin(origin) { + return Boolean(origin) && DESKTOP_ORIGIN_PATTERNS.some(p => p.test(origin)); +} + +export function validateApiKey(req) { + const key = req.headers.get('X-WorldMonitor-Key'); + const origin = req.headers.get('Origin') || ''; + + if (isDesktopOrigin(origin)) { + if (!key) return { valid: false, required: true, error: 'API key required for desktop access' }; + const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean); + if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' }; + return { valid: true, required: true }; + } + + if (key) { + const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean); + if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' }; + return { valid: true, required: true }; + } + + return { valid: false, required: false }; +} diff --git a/api/_cors.js b/api/_cors.js new file mode 100644 index 000000000..77d0637e9 --- /dev/null +++ b/api/_cors.js @@ -0,0 +1,32 @@ +const ALLOWED_ORIGIN_PATTERNS = [ + /^https:\/\/(.*\.)?worldmonitor\.app$/, + /^https:\/\/worldmonitor-[a-z0-9-]+-elie-[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, + /^tauri:\/\/localhost$/, + /^asset:\/\/localhost$/, +]; + +function isAllowedOrigin(origin) { + return Boolean(origin) && ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin)); +} + +export function getCorsHeaders(req, methods = 'GET, OPTIONS') { + const origin = req.headers.get('origin') || ''; + const allowOrigin = isAllowedOrigin(origin) ? origin : 'https://worldmonitor.app'; + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Methods': methods, + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key', + 'Access-Control-Max-Age': '86400', + 'Vary': 'Origin', + }; +} + +export function isDisallowedOrigin(req) { + const origin = req.headers.get('origin'); + if (!origin) return false; + return !isAllowedOrigin(origin); +} diff --git a/api/_cors.test.mjs b/api/_cors.test.mjs new file mode 100644 index 000000000..61f85c1ad --- /dev/null +++ b/api/_cors.test.mjs @@ -0,0 +1,40 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; + +function makeRequest(origin) { + const headers = new Headers(); + if (origin !== null) { + headers.set('origin', origin); + } + return new Request('https://worldmonitor.app/api/test', { headers }); +} + +test('allows desktop Tauri origins', () => { + const origins = [ + 'https://tauri.localhost', + 'https://abc123.tauri.localhost', + 'tauri://localhost', + 'asset://localhost', + 'http://127.0.0.1:46123', + ]; + + for (const origin of origins) { + const req = makeRequest(origin); + assert.equal(isDisallowedOrigin(req), false, `origin should be allowed: ${origin}`); + const cors = getCorsHeaders(req); + assert.equal(cors['Access-Control-Allow-Origin'], origin); + } +}); + +test('rejects unrelated external origins', () => { + const req = makeRequest('https://evil.example.com'); + assert.equal(isDisallowedOrigin(req), true); + const cors = getCorsHeaders(req); + assert.equal(cors['Access-Control-Allow-Origin'], 'https://worldmonitor.app'); +}); + +test('requests without origin remain allowed', () => { + const req = makeRequest(null); + assert.equal(isDisallowedOrigin(req), false); +}); diff --git a/api/ais-snapshot.js b/api/ais-snapshot.js new file mode 100644 index 000000000..cf035be66 --- /dev/null +++ b/api/ais-snapshot.js @@ -0,0 +1,88 @@ +import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; + +export const config = { runtime: 'edge' }; + +function getRelayBaseUrl() { + const relayUrl = process.env.WS_RELAY_URL; + if (!relayUrl) return null; + return relayUrl.replace('wss://', 'https://').replace('ws://', 'http://').replace(/\/$/, ''); +} + +function getRelayHeaders(baseHeaders = {}) { + const headers = { ...baseHeaders }; + const relaySecret = process.env.RELAY_SHARED_SECRET || ''; + if (relaySecret) { + const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase(); + headers[relayHeader] = relaySecret; + headers.Authorization = `Bearer ${relaySecret}`; + } + return headers; +} + +async function fetchWithTimeout(url, options, timeoutMs = 15000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +export default async function handler(req) { + const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); + + if (isDisallowedOrigin(req)) { + return new Response(JSON.stringify({ error: 'Origin not allowed' }), { + status: 403, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }); + } + if (req.method !== 'GET') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + const relayBaseUrl = getRelayBaseUrl(); + if (!relayBaseUrl) { + return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), { + status: 503, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } + + try { + const requestUrl = new URL(req.url); + const relayUrl = `${relayBaseUrl}/ais/snapshot${requestUrl.search || ''}`; + const response = await fetchWithTimeout(relayUrl, { + headers: getRelayHeaders({ Accept: 'application/json' }), + }, 12000); + + const body = await response.text(); + const headers = { + 'Content-Type': response.headers.get('content-type') || 'application/json', + 'Cache-Control': response.headers.get('cache-control') || 'no-cache', + ...corsHeaders, + }; + + return new Response(body, { + status: response.status, + headers, + }); + } catch (error) { + const isTimeout = error?.name === 'AbortError'; + return new Response(JSON.stringify({ + error: isTimeout ? 'Relay timeout' : 'Relay request failed', + details: error?.message || String(error), + }), { + status: isTimeout ? 504 : 502, + headers: { 'Content-Type': 'application/json', ...corsHeaders }, + }); + } +} diff --git a/api/data/city-coords.ts b/api/data/city-coords.ts new file mode 100644 index 000000000..b338c033f --- /dev/null +++ b/api/data/city-coords.ts @@ -0,0 +1,405 @@ +/** + * Comprehensive city geocoding database (500+ cities worldwide). + * Extracted from the legacy api/tech-events.js endpoint. + */ + +export interface CityCoord { + lat: number; + lng: number; + country: string; + virtual?: boolean; +} + +export const CITY_COORDS: Record = { + // North America - USA + 'san francisco': { lat: 37.7749, lng: -122.4194, country: 'USA' }, + 'san jose': { lat: 37.3382, lng: -121.8863, country: 'USA' }, + 'palo alto': { lat: 37.4419, lng: -122.1430, country: 'USA' }, + 'mountain view': { lat: 37.3861, lng: -122.0839, country: 'USA' }, + 'menlo park': { lat: 37.4530, lng: -122.1817, country: 'USA' }, + 'cupertino': { lat: 37.3230, lng: -122.0322, country: 'USA' }, + 'sunnyvale': { lat: 37.3688, lng: -122.0363, country: 'USA' }, + 'santa clara': { lat: 37.3541, lng: -121.9552, country: 'USA' }, + 'redwood city': { lat: 37.4852, lng: -122.2364, country: 'USA' }, + 'oakland': { lat: 37.8044, lng: -122.2712, country: 'USA' }, + 'berkeley': { lat: 37.8716, lng: -122.2727, country: 'USA' }, + 'los angeles': { lat: 34.0522, lng: -118.2437, country: 'USA' }, + 'santa monica': { lat: 34.0195, lng: -118.4912, country: 'USA' }, + 'pasadena': { lat: 34.1478, lng: -118.1445, country: 'USA' }, + 'irvine': { lat: 33.6846, lng: -117.8265, country: 'USA' }, + 'san diego': { lat: 32.7157, lng: -117.1611, country: 'USA' }, + 'seattle': { lat: 47.6062, lng: -122.3321, country: 'USA' }, + 'bellevue': { lat: 47.6101, lng: -122.2015, country: 'USA' }, + 'redmond': { lat: 47.6740, lng: -122.1215, country: 'USA' }, + 'portland': { lat: 45.5155, lng: -122.6789, country: 'USA' }, + 'new york': { lat: 40.7128, lng: -74.0060, country: 'USA' }, + 'nyc': { lat: 40.7128, lng: -74.0060, country: 'USA' }, + 'manhattan': { lat: 40.7831, lng: -73.9712, country: 'USA' }, + 'brooklyn': { lat: 40.6782, lng: -73.9442, country: 'USA' }, + 'boston': { lat: 42.3601, lng: -71.0589, country: 'USA' }, + 'cambridge': { lat: 42.3736, lng: -71.1097, country: 'USA' }, + 'chicago': { lat: 41.8781, lng: -87.6298, country: 'USA' }, + 'austin': { lat: 30.2672, lng: -97.7431, country: 'USA' }, + 'austin, tx': { lat: 30.2672, lng: -97.7431, country: 'USA' }, + 'dallas': { lat: 32.7767, lng: -96.7970, country: 'USA' }, + 'houston': { lat: 29.7604, lng: -95.3698, country: 'USA' }, + 'denver': { lat: 39.7392, lng: -104.9903, country: 'USA' }, + 'boulder': { lat: 40.0150, lng: -105.2705, country: 'USA' }, + 'phoenix': { lat: 33.4484, lng: -112.0740, country: 'USA' }, + 'scottsdale': { lat: 33.4942, lng: -111.9261, country: 'USA' }, + 'miami': { lat: 25.7617, lng: -80.1918, country: 'USA' }, + 'orlando': { lat: 28.5383, lng: -81.3792, country: 'USA' }, + 'tampa': { lat: 27.9506, lng: -82.4572, country: 'USA' }, + 'atlanta': { lat: 33.7490, lng: -84.3880, country: 'USA' }, + 'washington': { lat: 38.9072, lng: -77.0369, country: 'USA' }, + 'washington dc': { lat: 38.9072, lng: -77.0369, country: 'USA' }, + 'washington, dc': { lat: 38.9072, lng: -77.0369, country: 'USA' }, + 'dc': { lat: 38.9072, lng: -77.0369, country: 'USA' }, + 'reston': { lat: 38.9586, lng: -77.3570, country: 'USA' }, + 'philadelphia': { lat: 39.9526, lng: -75.1652, country: 'USA' }, + 'pittsburgh': { lat: 40.4406, lng: -79.9959, country: 'USA' }, + 'detroit': { lat: 42.3314, lng: -83.0458, country: 'USA' }, + 'ann arbor': { lat: 42.2808, lng: -83.7430, country: 'USA' }, + 'minneapolis': { lat: 44.9778, lng: -93.2650, country: 'USA' }, + 'salt lake city': { lat: 40.7608, lng: -111.8910, country: 'USA' }, + 'las vegas': { lat: 36.1699, lng: -115.1398, country: 'USA' }, + 'raleigh': { lat: 35.7796, lng: -78.6382, country: 'USA' }, + 'durham': { lat: 35.9940, lng: -78.8986, country: 'USA' }, + 'chapel hill': { lat: 35.9132, lng: -79.0558, country: 'USA' }, + 'charlotte': { lat: 35.2271, lng: -80.8431, country: 'USA' }, + 'nashville': { lat: 36.1627, lng: -86.7816, country: 'USA' }, + 'indianapolis': { lat: 39.7684, lng: -86.1581, country: 'USA' }, + 'columbus': { lat: 39.9612, lng: -82.9988, country: 'USA' }, + 'cleveland': { lat: 41.4993, lng: -81.6944, country: 'USA' }, + 'cincinnati': { lat: 39.1031, lng: -84.5120, country: 'USA' }, + 'st. louis': { lat: 38.6270, lng: -90.1994, country: 'USA' }, + 'kansas city': { lat: 39.0997, lng: -94.5786, country: 'USA' }, + 'omaha': { lat: 41.2565, lng: -95.9345, country: 'USA' }, + 'milwaukee': { lat: 43.0389, lng: -87.9065, country: 'USA' }, + 'new orleans': { lat: 29.9511, lng: -90.0715, country: 'USA' }, + 'san antonio': { lat: 29.4241, lng: -98.4936, country: 'USA' }, + 'albuquerque': { lat: 35.0844, lng: -106.6504, country: 'USA' }, + 'tucson': { lat: 32.2226, lng: -110.9747, country: 'USA' }, + 'honolulu': { lat: 21.3069, lng: -157.8583, country: 'USA' }, + 'anchorage': { lat: 61.2181, lng: -149.9003, country: 'USA' }, + + // North America - Canada + 'toronto': { lat: 43.6532, lng: -79.3832, country: 'Canada' }, + 'vancouver': { lat: 49.2827, lng: -123.1207, country: 'Canada' }, + 'montreal': { lat: 45.5017, lng: -73.5673, country: 'Canada' }, + 'ottawa': { lat: 45.4215, lng: -75.6972, country: 'Canada' }, + 'calgary': { lat: 51.0447, lng: -114.0719, country: 'Canada' }, + 'edmonton': { lat: 53.5461, lng: -113.4938, country: 'Canada' }, + 'winnipeg': { lat: 49.8951, lng: -97.1384, country: 'Canada' }, + 'quebec city': { lat: 46.8139, lng: -71.2080, country: 'Canada' }, + 'waterloo': { lat: 43.4643, lng: -80.5204, country: 'Canada' }, + 'victoria': { lat: 48.4284, lng: -123.3656, country: 'Canada' }, + 'halifax': { lat: 44.6488, lng: -63.5752, country: 'Canada' }, + + // Mexico & Central America + 'mexico city': { lat: 19.4326, lng: -99.1332, country: 'Mexico' }, + 'guadalajara': { lat: 20.6597, lng: -103.3496, country: 'Mexico' }, + 'monterrey': { lat: 25.6866, lng: -100.3161, country: 'Mexico' }, + 'tijuana': { lat: 32.5149, lng: -117.0382, country: 'Mexico' }, + 'cancun': { lat: 21.1619, lng: -86.8515, country: 'Mexico' }, + 'panama city': { lat: 8.9824, lng: -79.5199, country: 'Panama' }, + 'san jose cr': { lat: 9.9281, lng: -84.0907, country: 'Costa Rica' }, + + // South America + 'sao paulo': { lat: -23.5505, lng: -46.6333, country: 'Brazil' }, + 'são paulo': { lat: -23.5505, lng: -46.6333, country: 'Brazil' }, + 'rio de janeiro': { lat: -22.9068, lng: -43.1729, country: 'Brazil' }, + 'brasilia': { lat: -15.7975, lng: -47.8919, country: 'Brazil' }, + 'belo horizonte': { lat: -19.9167, lng: -43.9345, country: 'Brazil' }, + 'porto alegre': { lat: -30.0346, lng: -51.2177, country: 'Brazil' }, + 'buenos aires': { lat: -34.6037, lng: -58.3816, country: 'Argentina' }, + 'santiago': { lat: -33.4489, lng: -70.6693, country: 'Chile' }, + 'bogota': { lat: 4.7110, lng: -74.0721, country: 'Colombia' }, + 'bogot\u00e1': { lat: 4.7110, lng: -74.0721, country: 'Colombia' }, + 'medellin': { lat: 6.2476, lng: -75.5658, country: 'Colombia' }, + 'medell\u00edn': { lat: 6.2476, lng: -75.5658, country: 'Colombia' }, + 'lima': { lat: -12.0464, lng: -77.0428, country: 'Peru' }, + 'caracas': { lat: 10.4806, lng: -66.9036, country: 'Venezuela' }, + 'montevideo': { lat: -34.9011, lng: -56.1645, country: 'Uruguay' }, + 'quito': { lat: -0.1807, lng: -78.4678, country: 'Ecuador' }, + + // Europe - UK & Ireland + 'london': { lat: 51.5074, lng: -0.1278, country: 'UK' }, + 'cambridge uk': { lat: 52.2053, lng: 0.1218, country: 'UK' }, + 'oxford': { lat: 51.7520, lng: -1.2577, country: 'UK' }, + 'manchester': { lat: 53.4808, lng: -2.2426, country: 'UK' }, + 'birmingham': { lat: 52.4862, lng: -1.8904, country: 'UK' }, + 'edinburgh': { lat: 55.9533, lng: -3.1883, country: 'UK' }, + 'glasgow': { lat: 55.8642, lng: -4.2518, country: 'UK' }, + 'bristol': { lat: 51.4545, lng: -2.5879, country: 'UK' }, + 'leeds': { lat: 53.8008, lng: -1.5491, country: 'UK' }, + 'liverpool': { lat: 53.4084, lng: -2.9916, country: 'UK' }, + 'belfast': { lat: 54.5973, lng: -5.9301, country: 'UK' }, + 'cardiff': { lat: 51.4816, lng: -3.1791, country: 'UK' }, + 'dublin': { lat: 53.3498, lng: -6.2603, country: 'Ireland' }, + 'cork': { lat: 51.8985, lng: -8.4756, country: 'Ireland' }, + 'galway': { lat: 53.2707, lng: -9.0568, country: 'Ireland' }, + + // Europe - Western + 'paris': { lat: 48.8566, lng: 2.3522, country: 'France' }, + 'lyon': { lat: 45.7640, lng: 4.8357, country: 'France' }, + 'marseille': { lat: 43.2965, lng: 5.3698, country: 'France' }, + 'toulouse': { lat: 43.6047, lng: 1.4442, country: 'France' }, + 'nice': { lat: 43.7102, lng: 7.2620, country: 'France' }, + 'bordeaux': { lat: 44.8378, lng: -0.5792, country: 'France' }, + 'strasbourg': { lat: 48.5734, lng: 7.7521, country: 'France' }, + 'nantes': { lat: 47.2184, lng: -1.5536, country: 'France' }, + 'cannes': { lat: 43.5528, lng: 7.0174, country: 'France' }, + 'monaco': { lat: 43.7384, lng: 7.4246, country: 'Monaco' }, + 'berlin': { lat: 52.5200, lng: 13.4050, country: 'Germany' }, + 'munich': { lat: 48.1351, lng: 11.5820, country: 'Germany' }, + 'm\u00fcnchen': { lat: 48.1351, lng: 11.5820, country: 'Germany' }, + 'frankfurt': { lat: 50.1109, lng: 8.6821, country: 'Germany' }, + 'hamburg': { lat: 53.5511, lng: 9.9937, country: 'Germany' }, + 'cologne': { lat: 50.9375, lng: 6.9603, country: 'Germany' }, + 'k\u00f6ln': { lat: 50.9375, lng: 6.9603, country: 'Germany' }, + 'd\u00fcsseldorf': { lat: 51.2277, lng: 6.7735, country: 'Germany' }, + 'dusseldorf': { lat: 51.2277, lng: 6.7735, country: 'Germany' }, + 'stuttgart': { lat: 48.7758, lng: 9.1829, country: 'Germany' }, + 'hanover': { lat: 52.3759, lng: 9.7320, country: 'Germany' }, + 'hannover': { lat: 52.3759, lng: 9.7320, country: 'Germany' }, + 'dresden': { lat: 51.0504, lng: 13.7373, country: 'Germany' }, + 'leipzig': { lat: 51.3397, lng: 12.3731, country: 'Germany' }, + 'nuremberg': { lat: 49.4521, lng: 11.0767, country: 'Germany' }, + 'amsterdam': { lat: 52.3676, lng: 4.9041, country: 'Netherlands' }, + 'rotterdam': { lat: 51.9225, lng: 4.4792, country: 'Netherlands' }, + 'the hague': { lat: 52.0705, lng: 4.3007, country: 'Netherlands' }, + 'eindhoven': { lat: 51.4416, lng: 5.4697, country: 'Netherlands' }, + 'utrecht': { lat: 52.0907, lng: 5.1214, country: 'Netherlands' }, + 'brussels': { lat: 50.8503, lng: 4.3517, country: 'Belgium' }, + 'antwerp': { lat: 51.2194, lng: 4.4025, country: 'Belgium' }, + 'ghent': { lat: 51.0543, lng: 3.7174, country: 'Belgium' }, + 'luxembourg': { lat: 49.6116, lng: 6.1319, country: 'Luxembourg' }, + 'zurich': { lat: 47.3769, lng: 8.5417, country: 'Switzerland' }, + 'z\u00fcrich': { lat: 47.3769, lng: 8.5417, country: 'Switzerland' }, + 'geneva': { lat: 46.2044, lng: 6.1432, country: 'Switzerland' }, + 'gen\u00e8ve': { lat: 46.2044, lng: 6.1432, country: 'Switzerland' }, + 'basel': { lat: 47.5596, lng: 7.5886, country: 'Switzerland' }, + 'bern': { lat: 46.9480, lng: 7.4474, country: 'Switzerland' }, + 'lausanne': { lat: 46.5197, lng: 6.6323, country: 'Switzerland' }, + 'davos': { lat: 46.8027, lng: 9.8360, country: 'Switzerland' }, + 'vienna': { lat: 48.2082, lng: 16.3738, country: 'Austria' }, + 'wien': { lat: 48.2082, lng: 16.3738, country: 'Austria' }, + 'salzburg': { lat: 47.8095, lng: 13.0550, country: 'Austria' }, + 'graz': { lat: 47.0707, lng: 15.4395, country: 'Austria' }, + 'innsbruck': { lat: 47.2692, lng: 11.4041, country: 'Austria' }, + + // Europe - Southern + 'barcelona': { lat: 41.3851, lng: 2.1734, country: 'Spain' }, + 'madrid': { lat: 40.4168, lng: -3.7038, country: 'Spain' }, + 'valencia': { lat: 39.4699, lng: -0.3763, country: 'Spain' }, + 'seville': { lat: 37.3891, lng: -5.9845, country: 'Spain' }, + 'sevilla': { lat: 37.3891, lng: -5.9845, country: 'Spain' }, + 'malaga': { lat: 36.7213, lng: -4.4214, country: 'Spain' }, + 'm\u00e1laga': { lat: 36.7213, lng: -4.4214, country: 'Spain' }, + 'bilbao': { lat: 43.2630, lng: -2.9350, country: 'Spain' }, + 'lisbon': { lat: 38.7223, lng: -9.1393, country: 'Portugal' }, + 'lisboa': { lat: 38.7223, lng: -9.1393, country: 'Portugal' }, + 'porto': { lat: 41.1579, lng: -8.6291, country: 'Portugal' }, + 'rome': { lat: 41.9028, lng: 12.4964, country: 'Italy' }, + 'roma': { lat: 41.9028, lng: 12.4964, country: 'Italy' }, + 'milan': { lat: 45.4642, lng: 9.1900, country: 'Italy' }, + 'milano': { lat: 45.4642, lng: 9.1900, country: 'Italy' }, + 'florence': { lat: 43.7696, lng: 11.2558, country: 'Italy' }, + 'firenze': { lat: 43.7696, lng: 11.2558, country: 'Italy' }, + 'venice': { lat: 45.4408, lng: 12.3155, country: 'Italy' }, + 'venezia': { lat: 45.4408, lng: 12.3155, country: 'Italy' }, + 'turin': { lat: 45.0703, lng: 7.6869, country: 'Italy' }, + 'torino': { lat: 45.0703, lng: 7.6869, country: 'Italy' }, + 'naples': { lat: 40.8518, lng: 14.2681, country: 'Italy' }, + 'napoli': { lat: 40.8518, lng: 14.2681, country: 'Italy' }, + 'bologna': { lat: 44.4949, lng: 11.3426, country: 'Italy' }, + 'athens': { lat: 37.9838, lng: 23.7275, country: 'Greece' }, + 'thessaloniki': { lat: 40.6401, lng: 22.9444, country: 'Greece' }, + 'malta': { lat: 35.8989, lng: 14.5146, country: 'Malta' }, + 'valletta': { lat: 35.8989, lng: 14.5146, country: 'Malta' }, + + // Europe - Northern + 'stockholm': { lat: 59.3293, lng: 18.0686, country: 'Sweden' }, + 'gothenburg': { lat: 57.7089, lng: 11.9746, country: 'Sweden' }, + 'g\u00f6teborg': { lat: 57.7089, lng: 11.9746, country: 'Sweden' }, + 'malm\u00f6': { lat: 55.6050, lng: 13.0038, country: 'Sweden' }, + 'malmo': { lat: 55.6050, lng: 13.0038, country: 'Sweden' }, + 'copenhagen': { lat: 55.6761, lng: 12.5683, country: 'Denmark' }, + 'k\u00f8benhavn': { lat: 55.6761, lng: 12.5683, country: 'Denmark' }, + 'aarhus': { lat: 56.1629, lng: 10.2039, country: 'Denmark' }, + 'oslo': { lat: 59.9139, lng: 10.7522, country: 'Norway' }, + 'bergen': { lat: 60.3913, lng: 5.3221, country: 'Norway' }, + 'helsinki': { lat: 60.1699, lng: 24.9384, country: 'Finland' }, + 'espoo': { lat: 60.2055, lng: 24.6559, country: 'Finland' }, + 'tampere': { lat: 61.4978, lng: 23.7610, country: 'Finland' }, + 'reykjavik': { lat: 64.1466, lng: -21.9426, country: 'Iceland' }, + + // Europe - Eastern + 'warsaw': { lat: 52.2297, lng: 21.0122, country: 'Poland' }, + 'warszawa': { lat: 52.2297, lng: 21.0122, country: 'Poland' }, + 'krakow': { lat: 50.0647, lng: 19.9450, country: 'Poland' }, + 'krak\u00f3w': { lat: 50.0647, lng: 19.9450, country: 'Poland' }, + 'wroclaw': { lat: 51.1079, lng: 17.0385, country: 'Poland' }, + 'wroc\u0142aw': { lat: 51.1079, lng: 17.0385, country: 'Poland' }, + 'gdansk': { lat: 54.3520, lng: 18.6466, country: 'Poland' }, + 'prague': { lat: 50.0755, lng: 14.4378, country: 'Czech Republic' }, + 'praha': { lat: 50.0755, lng: 14.4378, country: 'Czech Republic' }, + 'brno': { lat: 49.1951, lng: 16.6068, country: 'Czech Republic' }, + 'budapest': { lat: 47.4979, lng: 19.0402, country: 'Hungary' }, + 'bucharest': { lat: 44.4268, lng: 26.1025, country: 'Romania' }, + 'bucure\u0219ti': { lat: 44.4268, lng: 26.1025, country: 'Romania' }, + 'cluj-napoca': { lat: 46.7712, lng: 23.6236, country: 'Romania' }, + 'sofia': { lat: 42.6977, lng: 23.3219, country: 'Bulgaria' }, + 'belgrade': { lat: 44.7866, lng: 20.4489, country: 'Serbia' }, + 'beograd': { lat: 44.7866, lng: 20.4489, country: 'Serbia' }, + 'zagreb': { lat: 45.8150, lng: 15.9819, country: 'Croatia' }, + 'ljubljana': { lat: 46.0569, lng: 14.5058, country: 'Slovenia' }, + 'bratislava': { lat: 48.1486, lng: 17.1077, country: 'Slovakia' }, + 'tallinn': { lat: 59.4370, lng: 24.7536, country: 'Estonia' }, + 'riga': { lat: 56.9496, lng: 24.1052, country: 'Latvia' }, + 'vilnius': { lat: 54.6872, lng: 25.2797, country: 'Lithuania' }, + 'kyiv': { lat: 50.4501, lng: 30.5234, country: 'Ukraine' }, + 'kiev': { lat: 50.4501, lng: 30.5234, country: 'Ukraine' }, + 'lviv': { lat: 49.8397, lng: 24.0297, country: 'Ukraine' }, + 'minsk': { lat: 53.9045, lng: 27.5615, country: 'Belarus' }, + 'moscow': { lat: 55.7558, lng: 37.6173, country: 'Russia' }, + 'st. petersburg': { lat: 59.9311, lng: 30.3609, country: 'Russia' }, + 'saint petersburg': { lat: 59.9311, lng: 30.3609, country: 'Russia' }, + + // Middle East + 'dubai': { lat: 25.2048, lng: 55.2708, country: 'UAE' }, + 'abu dhabi': { lat: 24.4539, lng: 54.3773, country: 'UAE' }, + 'doha': { lat: 25.2854, lng: 51.5310, country: 'Qatar' }, + 'riyadh': { lat: 24.7136, lng: 46.6753, country: 'Saudi Arabia' }, + 'jeddah': { lat: 21.4858, lng: 39.1925, country: 'Saudi Arabia' }, + 'neom': { lat: 28.0000, lng: 35.0000, country: 'Saudi Arabia' }, + 'tel aviv': { lat: 32.0853, lng: 34.7818, country: 'Israel' }, + 'jerusalem': { lat: 31.7683, lng: 35.2137, country: 'Israel' }, + 'haifa': { lat: 32.7940, lng: 34.9896, country: 'Israel' }, + 'amman': { lat: 31.9454, lng: 35.9284, country: 'Jordan' }, + 'beirut': { lat: 33.8938, lng: 35.5018, country: 'Lebanon' }, + 'istanbul': { lat: 41.0082, lng: 28.9784, country: 'Turkey' }, + 'ankara': { lat: 39.9334, lng: 32.8597, country: 'Turkey' }, + 'izmir': { lat: 38.4237, lng: 27.1428, country: 'Turkey' }, + 'tehran': { lat: 35.6892, lng: 51.3890, country: 'Iran' }, + 'cairo': { lat: 30.0444, lng: 31.2357, country: 'Egypt' }, + 'muscat': { lat: 23.5880, lng: 58.3829, country: 'Oman' }, + 'manama': { lat: 26.2285, lng: 50.5860, country: 'Bahrain' }, + 'kuwait city': { lat: 29.3759, lng: 47.9774, country: 'Kuwait' }, + + // Asia - East + 'tokyo': { lat: 35.6762, lng: 139.6503, country: 'Japan' }, + 'osaka': { lat: 34.6937, lng: 135.5023, country: 'Japan' }, + 'kyoto': { lat: 35.0116, lng: 135.7681, country: 'Japan' }, + 'yokohama': { lat: 35.4437, lng: 139.6380, country: 'Japan' }, + 'nagoya': { lat: 35.1815, lng: 136.9066, country: 'Japan' }, + 'fukuoka': { lat: 33.5904, lng: 130.4017, country: 'Japan' }, + 'sapporo': { lat: 43.0618, lng: 141.3545, country: 'Japan' }, + 'kobe': { lat: 34.6901, lng: 135.1956, country: 'Japan' }, + 'seoul': { lat: 37.5665, lng: 126.9780, country: 'South Korea' }, + 'busan': { lat: 35.1796, lng: 129.0756, country: 'South Korea' }, + 'incheon': { lat: 37.4563, lng: 126.7052, country: 'South Korea' }, + 'beijing': { lat: 39.9042, lng: 116.4074, country: 'China' }, + 'shanghai': { lat: 31.2304, lng: 121.4737, country: 'China' }, + 'shenzhen': { lat: 22.5431, lng: 114.0579, country: 'China' }, + 'guangzhou': { lat: 23.1291, lng: 113.2644, country: 'China' }, + 'hong kong': { lat: 22.3193, lng: 114.1694, country: 'Hong Kong' }, + 'hangzhou': { lat: 30.2741, lng: 120.1551, country: 'China' }, + 'chengdu': { lat: 30.5728, lng: 104.0668, country: 'China' }, + 'xian': { lat: 34.3416, lng: 108.9398, country: 'China' }, + "xi'an": { lat: 34.3416, lng: 108.9398, country: 'China' }, + 'nanjing': { lat: 32.0603, lng: 118.7969, country: 'China' }, + 'wuhan': { lat: 30.5928, lng: 114.3055, country: 'China' }, + 'tianjin': { lat: 39.3434, lng: 117.3616, country: 'China' }, + 'suzhou': { lat: 31.2990, lng: 120.5853, country: 'China' }, + 'taipei': { lat: 25.0330, lng: 121.5654, country: 'Taiwan' }, + 'kaohsiung': { lat: 22.6273, lng: 120.3014, country: 'Taiwan' }, + 'macau': { lat: 22.1987, lng: 113.5439, country: 'Macau' }, + 'macao': { lat: 22.1987, lng: 113.5439, country: 'Macau' }, + + // Asia - Southeast + 'singapore': { lat: 1.3521, lng: 103.8198, country: 'Singapore' }, + 'kuala lumpur': { lat: 3.1390, lng: 101.6869, country: 'Malaysia' }, + 'penang': { lat: 5.4141, lng: 100.3288, country: 'Malaysia' }, + 'jakarta': { lat: -6.2088, lng: 106.8456, country: 'Indonesia' }, + 'bali': { lat: -8.3405, lng: 115.0920, country: 'Indonesia' }, + 'denpasar': { lat: -8.6705, lng: 115.2126, country: 'Indonesia' }, + 'bandung': { lat: -6.9175, lng: 107.6191, country: 'Indonesia' }, + 'surabaya': { lat: -7.2575, lng: 112.7521, country: 'Indonesia' }, + 'bangkok': { lat: 13.7563, lng: 100.5018, country: 'Thailand' }, + 'chiang mai': { lat: 18.7883, lng: 98.9853, country: 'Thailand' }, + 'phuket': { lat: 7.8804, lng: 98.3923, country: 'Thailand' }, + 'ho chi minh city': { lat: 10.8231, lng: 106.6297, country: 'Vietnam' }, + 'saigon': { lat: 10.8231, lng: 106.6297, country: 'Vietnam' }, + 'hanoi': { lat: 21.0278, lng: 105.8342, country: 'Vietnam' }, + 'da nang': { lat: 16.0544, lng: 108.2022, country: 'Vietnam' }, + 'manila': { lat: 14.5995, lng: 120.9842, country: 'Philippines' }, + 'cebu': { lat: 10.3157, lng: 123.8854, country: 'Philippines' }, + 'phnom penh': { lat: 11.5564, lng: 104.9282, country: 'Cambodia' }, + 'yangon': { lat: 16.8661, lng: 96.1951, country: 'Myanmar' }, + + // Asia - South + 'mumbai': { lat: 19.0760, lng: 72.8777, country: 'India' }, + 'bombay': { lat: 19.0760, lng: 72.8777, country: 'India' }, + 'delhi': { lat: 28.7041, lng: 77.1025, country: 'India' }, + 'new delhi': { lat: 28.6139, lng: 77.2090, country: 'India' }, + 'bangalore': { lat: 12.9716, lng: 77.5946, country: 'India' }, + 'bengaluru': { lat: 12.9716, lng: 77.5946, country: 'India' }, + 'hyderabad': { lat: 17.3850, lng: 78.4867, country: 'India' }, + 'chennai': { lat: 13.0827, lng: 80.2707, country: 'India' }, + 'madras': { lat: 13.0827, lng: 80.2707, country: 'India' }, + 'pune': { lat: 18.5204, lng: 73.8567, country: 'India' }, + 'kolkata': { lat: 22.5726, lng: 88.3639, country: 'India' }, + 'calcutta': { lat: 22.5726, lng: 88.3639, country: 'India' }, + 'ahmedabad': { lat: 23.0225, lng: 72.5714, country: 'India' }, + 'jaipur': { lat: 26.9124, lng: 75.7873, country: 'India' }, + 'gurgaon': { lat: 28.4595, lng: 77.0266, country: 'India' }, + 'gurugram': { lat: 28.4595, lng: 77.0266, country: 'India' }, + 'noida': { lat: 28.5355, lng: 77.3910, country: 'India' }, + 'kochi': { lat: 9.9312, lng: 76.2673, country: 'India' }, + 'goa': { lat: 15.2993, lng: 74.1240, country: 'India' }, + 'karachi': { lat: 24.8607, lng: 67.0011, country: 'Pakistan' }, + 'lahore': { lat: 31.5497, lng: 74.3436, country: 'Pakistan' }, + 'islamabad': { lat: 33.6844, lng: 73.0479, country: 'Pakistan' }, + 'dhaka': { lat: 23.8103, lng: 90.4125, country: 'Bangladesh' }, + 'colombo': { lat: 6.9271, lng: 79.8612, country: 'Sri Lanka' }, + 'kathmandu': { lat: 27.7172, lng: 85.3240, country: 'Nepal' }, + + // Africa + 'cape town': { lat: -33.9249, lng: 18.4241, country: 'South Africa' }, + 'johannesburg': { lat: -26.2041, lng: 28.0473, country: 'South Africa' }, + 'pretoria': { lat: -25.7479, lng: 28.2293, country: 'South Africa' }, + 'durban': { lat: -29.8587, lng: 31.0218, country: 'South Africa' }, + 'lagos': { lat: 6.5244, lng: 3.3792, country: 'Nigeria' }, + 'abuja': { lat: 9.0765, lng: 7.3986, country: 'Nigeria' }, + 'nairobi': { lat: -1.2921, lng: 36.8219, country: 'Kenya' }, + 'accra': { lat: 5.6037, lng: -0.1870, country: 'Ghana' }, + 'casablanca': { lat: 33.5731, lng: -7.5898, country: 'Morocco' }, + 'marrakech': { lat: 31.6295, lng: -7.9811, country: 'Morocco' }, + 'tunis': { lat: 36.8065, lng: 10.1815, country: 'Tunisia' }, + 'algiers': { lat: 36.7538, lng: 3.0588, country: 'Algeria' }, + 'addis ababa': { lat: 8.9806, lng: 38.7578, country: 'Ethiopia' }, + 'dar es salaam': { lat: -6.7924, lng: 39.2083, country: 'Tanzania' }, + 'kampala': { lat: 0.3476, lng: 32.5825, country: 'Uganda' }, + 'kigali': { lat: -1.9403, lng: 29.8739, country: 'Rwanda' }, + 'mauritius': { lat: -20.3484, lng: 57.5522, country: 'Mauritius' }, + 'port louis': { lat: -20.1609, lng: 57.5012, country: 'Mauritius' }, + + // Oceania + 'sydney': { lat: -33.8688, lng: 151.2093, country: 'Australia' }, + 'melbourne': { lat: -37.8136, lng: 144.9631, country: 'Australia' }, + 'brisbane': { lat: -27.4698, lng: 153.0251, country: 'Australia' }, + 'perth': { lat: -31.9505, lng: 115.8605, country: 'Australia' }, + 'adelaide': { lat: -34.9285, lng: 138.6007, country: 'Australia' }, + 'canberra': { lat: -35.2809, lng: 149.1300, country: 'Australia' }, + 'gold coast': { lat: -28.0167, lng: 153.4000, country: 'Australia' }, + 'auckland': { lat: -36.8509, lng: 174.7645, country: 'New Zealand' }, + 'wellington': { lat: -41.2865, lng: 174.7762, country: 'New Zealand' }, + 'christchurch': { lat: -43.5321, lng: 172.6362, country: 'New Zealand' }, + + // Online/Virtual + 'online': { lat: 0, lng: 0, country: 'Virtual', virtual: true }, + 'virtual': { lat: 0, lng: 0, country: 'Virtual', virtual: true }, + 'hybrid': { lat: 0, lng: 0, country: 'Virtual', virtual: true }, +}; diff --git a/api/download.js b/api/download.js new file mode 100644 index 000000000..de84f276d --- /dev/null +++ b/api/download.js @@ -0,0 +1,83 @@ +// Non-sebuf: returns XML/HTML, stays as standalone Vercel function +export const config = { runtime: 'edge' }; + +const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest'; +const RELEASES_PAGE = 'https://github.com/koala73/worldmonitor/releases/latest'; + +const PLATFORM_PATTERNS = { + 'windows-exe': (name) => name.endsWith('_x64-setup.exe'), + 'windows-msi': (name) => name.endsWith('_x64_en-US.msi'), + 'macos-arm64': (name) => name.endsWith('_aarch64.dmg'), + 'macos-x64': (name) => name.endsWith('_x64.dmg') && !name.includes('setup'), + 'linux-appimage': (name) => name.endsWith('_amd64.AppImage'), + 'linux-appimage-arm64': (name) => name.endsWith('_aarch64.AppImage'), +}; + +const VARIANT_IDENTIFIERS = { + full: ['worldmonitor'], + world: ['worldmonitor'], + tech: ['techmonitor'], + finance: ['financemonitor'], +}; + +function canonicalAssetName(name) { + return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +function findAssetForVariant(assets, variant, platformMatcher) { + const identifiers = VARIANT_IDENTIFIERS[variant] ?? null; + if (!identifiers) return null; + + return assets.find((asset) => { + const assetName = String(asset?.name || ''); + const normalizedAssetName = canonicalAssetName(assetName); + const hasVariantIdentifier = identifiers.some((identifier) => + normalizedAssetName.includes(identifier) + ); + return hasVariantIdentifier && platformMatcher(assetName); + }) ?? null; +} + +export default async function handler(req) { + const url = new URL(req.url); + const platform = url.searchParams.get('platform'); + const variant = (url.searchParams.get('variant') || '').toLowerCase(); + + if (!platform || !PLATFORM_PATTERNS[platform]) { + return Response.redirect(RELEASES_PAGE, 302); + } + + try { + const res = await fetch(RELEASES_URL, { + headers: { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'WorldMonitor-Download-Redirect', + }, + }); + + if (!res.ok) { + return Response.redirect(RELEASES_PAGE, 302); + } + + const release = await res.json(); + const matcher = PLATFORM_PATTERNS[platform]; + const assets = Array.isArray(release.assets) ? release.assets : []; + const asset = variant + ? findAssetForVariant(assets, variant, matcher) + : assets.find((a) => matcher(String(a?.name || ''))); + + if (!asset) { + return Response.redirect(RELEASES_PAGE, 302); + } + + return new Response(null, { + status: 302, + headers: { + 'Location': asset.browser_download_url, + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60', + }, + }); + } catch { + return Response.redirect(RELEASES_PAGE, 302); + } +} diff --git a/api/eia/[[...path]].js b/api/eia/[[...path]].js new file mode 100644 index 000000000..5cc0962b0 --- /dev/null +++ b/api/eia/[[...path]].js @@ -0,0 +1,117 @@ +// EIA (Energy Information Administration) API proxy +// Keeps API key server-side +import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js'; +export const config = { runtime: 'edge' }; + +export default async function handler(req) { + const cors = getCorsHeaders(req); + if (isDisallowedOrigin(req)) { + return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors }); + } + + // Only allow GET and OPTIONS methods + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { + status: 405, + headers: cors, + }); + } + + const url = new URL(req.url); + const path = url.pathname.replace('/api/eia', ''); + + const apiKey = process.env.EIA_API_KEY; + + if (!apiKey) { + return Response.json({ + configured: false, + skipped: true, + reason: 'EIA_API_KEY not configured', + }, { + status: 200, + headers: cors, + }); + } + + // Health check + if (path === '/health' || path === '') { + return Response.json({ configured: true }, { + headers: cors, + }); + } + + // Petroleum data endpoint + if (path === '/petroleum') { + try { + const series = { + wti: 'PET.RWTC.W', + brent: 'PET.RBRTE.W', + production: 'PET.WCRFPUS2.W', + inventory: 'PET.WCESTUS1.W', + }; + + const results = {}; + + // Fetch all series in parallel + const fetchPromises = Object.entries(series).map(async ([key, seriesId]) => { + try { + const response = await fetch( + `https://api.eia.gov/v2/seriesid/${seriesId}?api_key=${apiKey}&num=2`, + { headers: { 'Accept': 'application/json' } } + ); + + if (!response.ok) return null; + + const data = await response.json(); + const values = data?.response?.data || []; + + if (values.length >= 1) { + return { + key, + data: { + current: values[0]?.value, + previous: values[1]?.value || values[0]?.value, + date: values[0]?.period, + unit: values[0]?.unit, + } + }; + } + } catch (e) { + console.error(`[EIA] Failed to fetch ${key}:`, e.message); + } + return null; + }); + + const fetchResults = await Promise.all(fetchPromises); + + for (const result of fetchResults) { + if (result) { + results[result.key] = result.data; + } + } + + return Response.json(results, { + headers: { + ...cors, + 'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', + }, + }); + } catch (error) { + console.error('[EIA] Fetch error:', error); + return Response.json({ + error: 'Failed to fetch EIA data', + }, { + status: 500, + headers: cors, + }); + } + } + + return Response.json({ error: 'Not found' }, { + status: 404, + headers: cors, + }); +} diff --git a/api/fwdstart.js b/api/fwdstart.js new file mode 100644 index 000000000..2d890c562 --- /dev/null +++ b/api/fwdstart.js @@ -0,0 +1,110 @@ +// Non-sebuf: returns XML/HTML, stays as standalone Vercel function +import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +export const config = { runtime: 'edge' }; + +// Scrape FwdStart newsletter archive and return as RSS +export default async function handler(req) { + const cors = getCorsHeaders(req); + if (isDisallowedOrigin(req)) { + return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors }); + } + try { + const response = await fetch('https://www.fwdstart.me/archive', { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Accept': 'text/html,application/xhtml+xml', + }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const html = await response.text(); + const items = []; + const seenUrls = new Set(); + + // Split by embla__slide to get each post block + const slideBlocks = html.split('embla__slide'); + + for (const block of slideBlocks) { + // Extract URL + const urlMatch = block.match(/href="(\/p\/[^"]+)"/); + if (!urlMatch) continue; + + const url = `https://www.fwdstart.me${urlMatch[1]}`; + if (seenUrls.has(url)) continue; + seenUrls.add(url); + + // Extract title from alt attribute + const altMatch = block.match(/alt="([^"]+)"/); + const title = altMatch ? altMatch[1] : ''; + if (!title || title.length < 5) continue; + + // Extract date - look for "Mon DD, YYYY" pattern + const dateMatch = block.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})/i); + let pubDate = new Date(); + if (dateMatch) { + const dateStr = `${dateMatch[1]} ${dateMatch[2]}, ${dateMatch[3]}`; + const parsed = new Date(dateStr); + if (!isNaN(parsed.getTime())) { + pubDate = parsed; + } + } + + // Extract subtitle/description if available + let description = ''; + const subtitleMatch = block.match(/line-clamp-3[^>]*>.*?]*>([^<]{20,})<\/span>/s); + if (subtitleMatch) { + description = subtitleMatch[1].trim(); + } + + items.push({ title, link: url, date: pubDate.toISOString(), description }); + } + + // Build RSS XML + const rssItems = items.slice(0, 30).map(item => ` + + <![CDATA[${item.title}]]> + ${item.link} + ${item.link} + ${new Date(item.date).toUTCString()} + + FwdStart Newsletter + `).join(''); + + const rss = ` + + + FwdStart Newsletter + https://www.fwdstart.me + Forward-thinking startup and VC news from MENA and beyond + en-us + ${new Date().toUTCString()} + + ${rssItems} + +`; + + return new Response(rss, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + ...cors, + 'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', + }, + }); + } catch (error) { + console.error('FwdStart scraper error:', error); + return new Response(JSON.stringify({ + error: 'Failed to fetch FwdStart archive', + details: error.message + }), { + status: 502, + headers: { + 'Content-Type': 'application/json', + ...cors, + }, + }); + } +} diff --git a/api/loaders-xml-wms-regression.test.mjs b/api/loaders-xml-wms-regression.test.mjs new file mode 100644 index 000000000..dc7d484f5 --- /dev/null +++ b/api/loaders-xml-wms-regression.test.mjs @@ -0,0 +1,123 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import { XMLLoader } from '@loaders.gl/xml'; +import { WMSCapabilitiesLoader, WMSErrorLoader, _WMSFeatureInfoLoader } from '@loaders.gl/wms'; + +const WMS_CAPABILITIES_XML = ` + + + WMS + Test Service + + alerts + world + + + + + + image/png + image/jpeg + + + + application/vnd.ogc.se_xml + + + Root Layer + EPSG:4326 + + -180 + 180 + -90 + 90 + + + alerts + Alerts + + + 2024-01-01/2024-12-31/P1D + + + + +`; + +test('XMLLoader keeps namespace stripping + array paths stable', () => { + const xml = 'okyo'; + const parsed = XMLLoader.parseTextSync(xml, { + xml: { + removeNSPrefix: true, + arrayPaths: ['root.Child'], + }, + }); + + assert.deepEqual(parsed, { + root: { + Child: [ + { value: 'ok', attr: 'x' }, + { value: 'yo', attr: 'y' }, + ], + }, + }); +}); + +test('WMSCapabilitiesLoader parses core typed fields from XML capabilities', () => { + const parsed = WMSCapabilitiesLoader.parseTextSync(WMS_CAPABILITIES_XML); + + assert.equal(parsed.version, '1.3.0'); + assert.equal(parsed.name, 'WMS'); + assert.deepEqual(parsed.requests.GetMap.mimeTypes, ['image/png', 'image/jpeg']); + + assert.equal(parsed.layers.length, 1); + const rootLayer = parsed.layers[0]; + assert.deepEqual(rootLayer.geographicBoundingBox, [[-180, -90], [180, 90]]); + + const alertsLayer = rootLayer.layers[0]; + assert.equal(alertsLayer.name, 'alerts'); + assert.equal(alertsLayer.queryable, true); + assert.deepEqual(alertsLayer.boundingBoxes[0], { + crs: 'EPSG:4326', + boundingBox: [[-10, -20], [30, 40]], + }); + assert.deepEqual(alertsLayer.dimensions[0], { + name: 'time', + units: 'ISO8601', + extent: '2024-01-01/2024-12-31/P1D', + defaultValue: '2024-01-01', + nearestValue: true, + }); +}); + +test('WMSErrorLoader extracts namespaced error text and honors throw options', () => { + const namespacedErrorXml = + 'Bad layer'; + + const defaultMessage = WMSErrorLoader.parseTextSync(namespacedErrorXml); + assert.equal(defaultMessage, 'WMS Service error: Bad layer'); + + const minimalMessage = WMSErrorLoader.parseTextSync(namespacedErrorXml, { + wms: { minimalErrors: true }, + }); + assert.equal(minimalMessage, 'Bad layer'); + + assert.throws( + () => WMSErrorLoader.parseTextSync(namespacedErrorXml, { wms: { throwOnError: true } }), + /WMS Service error: Bad layer/ + ); +}); + +test('WMS feature info parsing remains stable for single and repeated FIELDS nodes', () => { + const singleFieldsXml = ''; + const manyFieldsXml = ''; + + const single = _WMSFeatureInfoLoader.parseTextSync(singleFieldsXml); + const many = _WMSFeatureInfoLoader.parseTextSync(manyFieldsXml); + + assert.equal(single.features.length, 1); + assert.deepEqual(single.features[0]?.attributes, { id: '1', label: 'one' }); + assert.equal(many.features.length, 2); + assert.equal(many.features[0]?.attributes?.id, '1'); + assert.equal(many.features[1]?.attributes?.id, '2'); +}); diff --git a/api/og-story.js b/api/og-story.js new file mode 100644 index 000000000..5b1f114e6 --- /dev/null +++ b/api/og-story.js @@ -0,0 +1,230 @@ +// Non-sebuf: returns XML/HTML, stays as standalone Vercel function +/** + * Dynamic OG Image Generator for Story Sharing + * Returns an SVG image (1200x630) — rich intelligence card for social previews. + */ + +const COUNTRY_NAMES = { + UA: 'Ukraine', RU: 'Russia', CN: 'China', US: 'United States', + IR: 'Iran', IL: 'Israel', TW: 'Taiwan', KP: 'North Korea', + SA: 'Saudi Arabia', TR: 'Turkey', PL: 'Poland', DE: 'Germany', + FR: 'France', GB: 'United Kingdom', IN: 'India', PK: 'Pakistan', + SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela', +}; + +const LEVEL_COLORS = { + critical: '#ef4444', high: '#f97316', elevated: '#eab308', + normal: '#22c55e', low: '#3b82f6', +}; + +const LEVEL_LABELS = { + critical: 'CRITICAL INSTABILITY', + high: 'HIGH INSTABILITY', + elevated: 'ELEVATED INSTABILITY', + normal: 'STABLE', + low: 'LOW RISK', +}; + +function normalizeLevel(rawLevel) { + const level = String(rawLevel || '').toLowerCase(); + return Object.prototype.hasOwnProperty.call(LEVEL_COLORS, level) ? level : 'normal'; +} + +export default function handler(req, res) { + const url = new URL(req.url, `https://${req.headers.host}`); + const countryCode = (url.searchParams.get('c') || '').toUpperCase(); + const type = url.searchParams.get('t') || 'ciianalysis'; + const score = url.searchParams.get('s'); + const level = normalizeLevel(url.searchParams.get('l')); + + const countryName = COUNTRY_NAMES[countryCode] || countryCode || 'Global'; + const levelColor = LEVEL_COLORS[level] || '#eab308'; + const levelLabel = LEVEL_LABELS[level] || 'MONITORING'; + const parsedScore = score ? Number.parseInt(score, 10) : Number.NaN; + const scoreNum = Number.isFinite(parsedScore) + ? Math.max(0, Math.min(100, parsedScore)) + : null; + const dateStr = new Date().toISOString().slice(0, 10); + + // Score arc (semicircle gauge) + const arcRadius = 90; + const arcCx = 960; + const arcCy = 340; + const scoreAngle = scoreNum !== null ? (scoreNum / 100) * Math.PI : 0; + const arcEndX = arcCx - arcRadius * Math.cos(scoreAngle); + const arcEndY = arcCy - arcRadius * Math.sin(scoreAngle); + const largeArc = scoreNum > 50 ? 1 : 0; + + const svg = ` + + + + + + + + + + + + + + + + + + + + + + + ${Array.from({length: 30}, (_, i) => ``).join('\n ')} + ${Array.from({length: 16}, (_, i) => ``).join('\n ')} + + + + WORLDMONITOR + + + + ${levelLabel} + + + ${dateStr} + + + + + + ${escapeXml(countryName.toUpperCase())} + + + + ${escapeXml(countryCode)} + + + INTELLIGENCE BRIEF + + ${scoreNum !== null ? ` + + + ${scoreNum} + /100 + INSTABILITY INDEX + + + + + + + + + + 0 + 25 + 50 + 75 + 100 + + + + + + ${scoreNum > 0 ? `` : ''} + + ${scoreNum} + /100 + + + + ${level.toUpperCase()} + + + + + + Threat Classification + + + Military Posture + + + Prediction Markets + + + Signal Convergence + + + Active Signals + + ` : ` + + Real-time intelligence analysis + + + + + + Instability Index + 20 countries monitored + + + Military Tracking + Live flights & vessels + + + Prediction Markets + Polymarket integration + + + Signal Convergence + Multi-source correlation + `} + + + + + + + + W + + WORLDMONITOR + Real-time global intelligence monitoring + + + + VIEW FULL BRIEF → + + + worldmonitor.app · ${dateStr} · Free & open source +`; + + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600'); + res.status(200).send(svg); +} + +function escapeXml(str) { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/api/og-story.test.mjs b/api/og-story.test.mjs new file mode 100644 index 000000000..d398d529e --- /dev/null +++ b/api/og-story.test.mjs @@ -0,0 +1,48 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import handler from './og-story.js'; + +function renderOgStory(query = '') { + const req = { + url: `https://worldmonitor.app/api/og-story${query ? `?${query}` : ''}`, + headers: { host: 'worldmonitor.app' }, + }; + + let statusCode = 0; + let body = ''; + const headers = {}; + + const res = { + setHeader(name, value) { + headers[String(name).toLowerCase()] = String(value); + }, + status(code) { + statusCode = code; + return this; + }, + send(payload) { + body = String(payload); + }, + }; + + handler(req, res); + return { statusCode, body, headers }; +} + +test('normalizes unsupported level values to prevent SVG script injection', () => { + const injectedLevel = encodeURIComponent(''); + const response = renderOgStory(`c=US&s=50&l=${injectedLevel}`); + + assert.equal(response.statusCode, 200); + assert.equal(/ + +`; + + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'public, s-maxage=900, stale-while-revalidate=300', + }, + }); +} diff --git a/api/youtube/embed.test.mjs b/api/youtube/embed.test.mjs new file mode 100644 index 000000000..ecd790f2d --- /dev/null +++ b/api/youtube/embed.test.mjs @@ -0,0 +1,50 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import handler from './embed.js'; + +function makeRequest(query = '') { + return new Request(`https://worldmonitor.app/api/youtube/embed${query}`); +} + +test('rejects missing or invalid video ids', async () => { + const missing = await handler(makeRequest()); + assert.equal(missing.status, 400); + + const invalid = await handler(makeRequest('?videoId=bad')); + assert.equal(invalid.status, 400); +}); + +test('returns embeddable html for valid video id', async () => { + const response = await handler(makeRequest('?videoId=iEpJwprxDdk&autoplay=0&mute=1')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type')?.includes('text/html'), true); + + const html = await response.text(); + assert.equal(html.includes("videoId:'iEpJwprxDdk'"), true); + assert.equal(html.includes("host:'https://www.youtube.com'"), true); + assert.equal(html.includes('autoplay:0'), true); + assert.equal(html.includes('mute:1'), true); + assert.equal(html.includes('origin:"https://worldmonitor.app"'), true); + assert.equal(html.includes('postMessage'), true); +}); + +test('accepts custom origin parameter', async () => { + const response = await handler(makeRequest('?videoId=iEpJwprxDdk&origin=http://127.0.0.1:46123')); + const html = await response.text(); + assert.equal(html.includes('origin:"http://127.0.0.1:46123"'), true); +}); + +test('uses dedicated parentOrigin for iframe postMessage target', async () => { + const response = await handler(makeRequest('?videoId=iEpJwprxDdk&origin=https://worldmonitor.app&parentOrigin=https://tauri.localhost')); + const html = await response.text(); + assert.match(html, /playerVars:\{[^}]*origin:"https:\/\/worldmonitor\.app"/); + assert.match(html, /parentOrigin="https:\/\/tauri\.localhost"/); + assert.match(html, /if\(allowedOrigin!==['"]\*['"]&&e\.origin!==allowedOrigin\)return/); +}); + +test('does not accept wildcard parentOrigin query parameter', async () => { + const response = await handler(makeRequest('?videoId=iEpJwprxDdk&origin=https://worldmonitor.app&parentOrigin=*')); + const html = await response.text(); + assert.equal(html.includes('parentOrigin="*"'), false); + assert.match(html, /parentOrigin="https:\/\/worldmonitor\.app"/); +}); diff --git a/api/youtube/live.js b/api/youtube/live.js new file mode 100644 index 000000000..97902421d --- /dev/null +++ b/api/youtube/live.js @@ -0,0 +1,109 @@ +// YouTube Live Stream Detection API +// Uses YouTube's oembed endpoint to check for live streams + +import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js'; + +export const config = { + runtime: 'edge', +}; + +export default async function handler(request) { + const cors = getCorsHeaders(request); + if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors }); + if (isDisallowedOrigin(request)) { + return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors }); + } + const url = new URL(request.url); + const channel = url.searchParams.get('channel'); + const videoIdParam = url.searchParams.get('videoId'); + + // Video ID lookup: resolve author name via oembed + if (videoIdParam && /^[A-Za-z0-9_-]{11}$/.test(videoIdParam)) { + try { + const oembedRes = await fetch( + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoIdParam}&format=json`, + { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } }, + ); + if (oembedRes.ok) { + const data = await oembedRes.json(); + return new Response(JSON.stringify({ channelName: data.author_name || null, title: data.title || null, videoId: videoIdParam }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600' }, + }); + } + } catch {} + return new Response(JSON.stringify({ channelName: null, title: null, videoId: videoIdParam }), { + status: 200, + headers: { ...cors, 'Content-Type': 'application/json' }, + }); + } + + if (!channel) { + return new Response(JSON.stringify({ error: 'Missing channel parameter' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + // Try to fetch the channel's live page + const channelHandle = channel.startsWith('@') ? channel : `@${channel}`; + const liveUrl = `https://www.youtube.com/${channelHandle}/live`; + + const response = await fetch(liveUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + redirect: 'follow', + }); + + if (!response.ok) { + return new Response(JSON.stringify({ videoId: null, channelExists: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const html = await response.text(); + + // Channel exists if the page contains canonical channel metadata + const channelExists = html.includes('"channelId"') || html.includes('og:url'); + + // Extract channel name from page metadata (prefer channel name over video title) + let channelName = null; + const ownerMatch = html.match(/"ownerChannelName"\s*:\s*"([^"]+)"/); + if (ownerMatch) { + channelName = ownerMatch[1]; + } else { + const authorMatch = html.match(/"author"\s*:\s*"([^"]+)"/); + if (authorMatch) channelName = authorMatch[1]; + } + + // Scope both fields to the same videoDetails block so we don't + // combine a videoId from one object with isLive from another. + let videoId = null; + const detailsIdx = html.indexOf('"videoDetails"'); + if (detailsIdx !== -1) { + const block = html.substring(detailsIdx, detailsIdx + 5000); + const vidMatch = block.match(/"videoId":"([a-zA-Z0-9_-]{11})"/); + const liveMatch = block.match(/"isLive"\s*:\s*true/); + if (vidMatch && liveMatch) { + videoId = vidMatch[1]; + } + } + + return new Response(JSON.stringify({ videoId, isLive: videoId !== null, channelExists, channelName }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60', + }, + }); + } catch (error) { + console.error('YouTube live check error:', error); + return new Response(JSON.stringify({ videoId: null, error: error.message }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/convex/registerInterest.ts b/convex/registerInterest.ts new file mode 100644 index 000000000..2367f7e20 --- /dev/null +++ b/convex/registerInterest.ts @@ -0,0 +1,32 @@ +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const register = mutation({ + args: { + email: v.string(), + source: v.optional(v.string()), + appVersion: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const normalizedEmail = args.email.trim().toLowerCase(); + + const existing = await ctx.db + .query("registrations") + .withIndex("by_normalized_email", (q) => q.eq("normalizedEmail", normalizedEmail)) + .first(); + + if (existing) { + return { status: "already_registered" as const }; + } + + await ctx.db.insert("registrations", { + email: args.email.trim(), + normalizedEmail, + registeredAt: Date.now(), + source: args.source ?? "unknown", + appVersion: args.appVersion ?? "unknown", + }); + + return { status: "registered" as const }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 000000000..66a738ec3 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,12 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + registrations: defineTable({ + email: v.string(), + normalizedEmail: v.string(), + registeredAt: v.number(), + source: v.optional(v.string()), + appVersion: v.optional(v.string()), + }).index("by_normalized_email", ["normalizedEmail"]), +}); diff --git a/convex/tsconfig.json b/convex/tsconfig.json new file mode 100644 index 000000000..217b4c2d8 --- /dev/null +++ b/convex/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ES2021"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "outDir": "./_generated" + }, + "include": ["./**/*.ts"], + "exclude": ["./_generated"] +} diff --git a/data/telegram-channels.json b/data/telegram-channels.json new file mode 100644 index 000000000..2ade21ab9 --- /dev/null +++ b/data/telegram-channels.json @@ -0,0 +1,146 @@ +{ + "version": 1, + "updatedAt": "2026-02-23T12:19:41Z", + "note": "Product-managed curated list. Not user-configurable.", + "channels": { + "full": [ + { + "handle": "VahidOnline", + "label": "Vahid Online", + "topic": "politics", + "tier": 1, + "enabled": true, + "region": "iran", + "maxMessages": 20 + }, + { + "handle": "BNONews", + "label": "BNO News", + "topic": "breaking", + "tier": 2, + "enabled": true, + "region": "global", + "maxMessages": 25 + }, + { + "handle": "LiveUAMap", + "label": "LiveUAMap", + "topic": "breaking", + "tier": 2, + "enabled": true, + "region": "global", + "maxMessages": 25 + }, + { + "handle": "ClashReport", + "label": "Clash Report", + "topic": "conflict", + "tier": 2, + "enabled": true, + "region": "global", + "maxMessages": 30 + }, + { + "handle": "OSINTdefender", + "label": "OSINTdefender", + "topic": "conflict", + "tier": 2, + "enabled": true, + "region": "global", + "maxMessages": 25 + }, + { + "handle": "AuroraIntel", + "label": "Aurora Intel", + "topic": "conflict", + "tier": 2, + "enabled": true, + "region": "global", + "maxMessages": 20 + }, + { + "handle": "GeopoliticalCenter", + "label": "GeopoliticalCenter", + "topic": "geopolitics", + "tier": 3, + "enabled": true, + "region": "global", + "maxMessages": 20 + }, + { + "handle": "Osintlatestnews", + "label": "OSIntOps News", + "topic": "osint", + "tier": 3, + "enabled": true, + "region": "global", + "maxMessages": 20 + }, + { + "handle": "air_alert_ua", + "label": "Повітряна Тривога", + "topic": "alerts", + "tier": 2, + "enabled": true, + "region": "ukraine", + "maxMessages": 20 + }, + { + "handle": "kpszsu", + "label": "Air Force of the Armed Forces of Ukraine", + "topic": "alerts", + "tier": 2, + "enabled": true, + "region": "ukraine", + "maxMessages": 20 + }, + { + "handle": "war_monitor", + "label": "monitor", + "topic": "alerts", + "tier": 3, + "enabled": true, + "region": "ukraine", + "maxMessages": 20 + }, + { + "handle": "DeepStateUA", + "label": "DeepState", + "topic": "conflict", + "tier": 2, + "enabled": true, + "region": "ukraine", + "maxMessages": 20 + }, + { + "handle": "bellingcat", + "label": "Bellingcat", + "topic": "osint", + "tier": 3, + "enabled": true, + "region": "global", + "maxMessages": 10 + }, + { + "handle": "nexta_live", + "label": "NEXTA Live", + "topic": "breaking", + "tier": 3, + "enabled": true, + "region": "europe", + "maxMessages": 15 + }, + { + "handle": "nexta_tv", + "label": "NEXTA", + "topic": "politics", + "tier": 3, + "enabled": true, + "region": "europe", + "maxMessages": 15 + } + ], + "tech": [], + "finance": [] + } +} diff --git a/deploy/nginx/brotli-api-proxy.conf b/deploy/nginx/brotli-api-proxy.conf new file mode 100644 index 000000000..026efccd5 --- /dev/null +++ b/deploy/nginx/brotli-api-proxy.conf @@ -0,0 +1,34 @@ +# Nginx API proxy compression baseline for WorldMonitor. +# Requires ngx_brotli (or Nginx Plus Brotli module) to be installed. + +# Prefer Brotli for HTTPS clients and keep gzip as fallback. +brotli on; +brotli_comp_level 5; +brotli_min_length 1024; +brotli_types application/json application/javascript text/css text/plain application/xml text/xml; + +gzip on; +gzip_comp_level 5; +gzip_min_length 1024; +gzip_vary on; +gzip_proxied any; +gzip_types application/json application/javascript text/css text/plain application/xml text/xml; + +server { + listen 443 ssl; + server_name api.worldmonitor.local; + + location /api/ { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + + # Preserve upstream compression behavior and pass through client preferences. + proxy_set_header Accept-Encoding $http_accept_encoding; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # If upstream sends pre-compressed content, do not decompress. + gunzip off; + } +} diff --git a/docs/ADDING_ENDPOINTS.md b/docs/ADDING_ENDPOINTS.md new file mode 100644 index 000000000..89b86dfdb --- /dev/null +++ b/docs/ADDING_ENDPOINTS.md @@ -0,0 +1,461 @@ +# Adding API Endpoints + +All JSON API endpoints in WorldMonitor **must** use sebuf. Do not create standalone `api/*.js` files — the legacy pattern is deprecated and being removed. + +This guide walks through adding a new RPC to an existing service and adding an entirely new service. + +> **Important:** After modifying any `.proto` file, you **must** run `make generate` before building or pushing. The generated TypeScript files in `src/generated/` are checked into the repo and must stay in sync with the proto definitions. CI does not run generation yet — this is your responsibility until we add it to the pipeline (see [#200](https://github.com/koala73/worldmonitor/issues/200)). + +## Prerequisites + +You need **Go 1.21+** and **Node.js 18+** installed. Everything else is installed automatically: + +```bash +make install # one-time: installs buf, sebuf plugins, npm deps, proto deps +``` + +This installs: + +- **buf** — proto linting, dependency management, and code generation orchestrator +- **protoc-gen-ts-client** — generates TypeScript client classes (from [sebuf](https://github.com/SebastienMelki/sebuf)) +- **protoc-gen-ts-server** — generates TypeScript server handler interfaces (from sebuf) +- **protoc-gen-openapiv3** — generates OpenAPI v3 specs (from sebuf) +- **npm dependencies** — all Node.js packages + +Run code generation from the repo root: + +```bash +make generate # regenerate all TypeScript + OpenAPI from protos +``` + +This produces three outputs per service: + +- `src/generated/client/{domain}/v1/service_client.ts` — typed fetch client for the frontend +- `src/generated/server/{domain}/v1/service_server.ts` — handler interface + route factory for the backend +- `docs/api/{Domain}Service.openapi.yaml` + `.json` — OpenAPI v3 documentation + +## Adding an RPC to an existing service + +Example: adding `GetEarthquakeDetails` to `SeismologyService`. + +### 1. Define the request/response messages + +Create `proto/worldmonitor/seismology/v1/get_earthquake_details.proto`: + +```protobuf +syntax = "proto3"; +package worldmonitor.seismology.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/seismology/v1/earthquake.proto"; + +// GetEarthquakeDetailsRequest specifies which earthquake to retrieve. +message GetEarthquakeDetailsRequest { + // USGS event identifier (e.g., "us7000abcd"). + string earthquake_id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 100 + ]; +} + +// GetEarthquakeDetailsResponse contains the full earthquake record. +message GetEarthquakeDetailsResponse { + // The earthquake matching the requested ID. + Earthquake earthquake = 1; +} +``` + +### 2. Add the RPC to the service definition + +Edit `proto/worldmonitor/seismology/v1/service.proto`: + +```protobuf +import "worldmonitor/seismology/v1/get_earthquake_details.proto"; + +service SeismologyService { + // ... existing RPCs ... + + // GetEarthquakeDetails retrieves a single earthquake by its USGS event ID. + rpc GetEarthquakeDetails(GetEarthquakeDetailsRequest) returns (GetEarthquakeDetailsResponse) { + option (sebuf.http.config) = {path: "/get-earthquake-details"}; + } +} +``` + +### 3. Lint and generate + +```bash +make check # lint + generate in one step +``` + +At this point, `npx tsc --noEmit` will **fail** because the handler doesn't implement the new method yet. This is by design — the compiler enforces the contract. + +### 4. Implement the handler + +Create `server/worldmonitor/seismology/v1/get-earthquake-details.ts`: + +```typescript +import type { + SeismologyServiceHandler, + ServerContext, + GetEarthquakeDetailsRequest, + GetEarthquakeDetailsResponse, +} from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; + +export const getEarthquakeDetails: SeismologyServiceHandler['getEarthquakeDetails'] = async ( + _ctx: ServerContext, + req: GetEarthquakeDetailsRequest, +): Promise => { + const response = await fetch( + `https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/${req.earthquakeId}.geojson`, + ); + if (!response.ok) { + throw new Error(`USGS API error: ${response.status}`); + } + const f: any = await response.json(); + return { + earthquake: { + id: f.id, + place: f.properties.place || '', + magnitude: f.properties.mag ?? 0, + depthKm: f.geometry.coordinates[2] ?? 0, + location: { + latitude: f.geometry.coordinates[1], + longitude: f.geometry.coordinates[0], + }, + occurredAt: f.properties.time, + sourceUrl: f.properties.url || '', + }, + }; +}; +``` + +### 5. Wire it into the handler re-export + +Edit `server/worldmonitor/seismology/v1/handler.ts`: + +```typescript +import type { SeismologyServiceHandler } from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; + +import { listEarthquakes } from './list-earthquakes'; +import { getEarthquakeDetails } from './get-earthquake-details'; + +export const seismologyHandler: SeismologyServiceHandler = { + listEarthquakes, + getEarthquakeDetails, +}; +``` + +### 6. Verify + +```bash +npx tsc --noEmit # should pass with zero errors +``` + +The route is already live. `createSeismologyServiceRoutes()` picks up the new RPC automatically — no changes needed to `api/[[...path]].ts` or `vite.config.ts`. + +### 7. Check the generated docs + +Open `docs/api/SeismologyService.openapi.yaml` — the new endpoint should appear with all validation constraints from your proto annotations. + +## Adding a new service + +Example: adding a `SanctionsService`. + +### 1. Create the proto directory + +``` +proto/worldmonitor/sanctions/v1/ +``` + +### 2. Define entity messages + +Create `proto/worldmonitor/sanctions/v1/sanctions_entry.proto`: + +```protobuf +syntax = "proto3"; +package worldmonitor.sanctions.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// SanctionsEntry represents a single entity on a sanctions list. +message SanctionsEntry { + // Unique identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Name of the sanctioned entity or individual. + string name = 2; + // Issuing authority (e.g., "OFAC", "EU", "UN"). + string authority = 3; + // ISO 3166-1 alpha-2 country code of the target. + string country_code = 4; + // Date the sanction was imposed, as Unix epoch milliseconds. + int64 imposed_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} +``` + +### 3. Define request/response messages + +Create `proto/worldmonitor/sanctions/v1/list_sanctions.proto`: + +```protobuf +syntax = "proto3"; +package worldmonitor.sanctions.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/sanctions/v1/sanctions_entry.proto"; + +// ListSanctionsRequest specifies filters for sanctions data. +message ListSanctionsRequest { + // Filter by issuing authority (e.g., "OFAC"). Empty returns all. + string authority = 1; + // Filter by country code. + string country_code = 2 [(buf.validate.field).string.max_len = 2]; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 3; +} + +// ListSanctionsResponse contains the matching sanctions entries. +message ListSanctionsResponse { + // The list of sanctions entries. + repeated SanctionsEntry entries = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} +``` + +### 4. Define the service + +Create `proto/worldmonitor/sanctions/v1/service.proto`: + +```protobuf +syntax = "proto3"; +package worldmonitor.sanctions.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/sanctions/v1/list_sanctions.proto"; + +// SanctionsService provides APIs for international sanctions monitoring. +service SanctionsService { + option (sebuf.http.service_config) = {base_path: "/api/sanctions/v1"}; + + // ListSanctions retrieves sanctions entries matching the given filters. + rpc ListSanctions(ListSanctionsRequest) returns (ListSanctionsResponse) { + option (sebuf.http.config) = {path: "/list-sanctions"}; + } +} +``` + +### 5. Generate + +```bash +make check # lint + generate in one step +``` + +### 6. Implement the handler + +Create the handler directory and files: + +``` +server/worldmonitor/sanctions/v1/ +├── handler.ts # thin re-export +└── list-sanctions.ts # RPC implementation +``` + +`server/worldmonitor/sanctions/v1/list-sanctions.ts`: +```typescript +import type { + SanctionsServiceHandler, + ServerContext, + ListSanctionsRequest, + ListSanctionsResponse, +} from '../../../../src/generated/server/worldmonitor/sanctions/v1/service_server'; + +export const listSanctions: SanctionsServiceHandler['listSanctions'] = async ( + _ctx: ServerContext, + req: ListSanctionsRequest, +): Promise => { + // Your implementation here — fetch from upstream API, transform to proto shape + return { entries: [], pagination: undefined }; +}; +``` + +`server/worldmonitor/sanctions/v1/handler.ts`: +```typescript +import type { SanctionsServiceHandler } from '../../../../src/generated/server/worldmonitor/sanctions/v1/service_server'; + +import { listSanctions } from './list-sanctions'; + +export const sanctionsHandler: SanctionsServiceHandler = { + listSanctions, +}; +``` + +### 7. Register the service in the gateway + +Edit `api/[[...path]].js` — add the import and mount the routes: + +```typescript +import { createSanctionsServiceRoutes } from '../src/generated/server/worldmonitor/sanctions/v1/service_server'; +import { sanctionsHandler } from './server/worldmonitor/sanctions/v1/handler'; + +const allRoutes = [ + // ... existing routes ... + ...createSanctionsServiceRoutes(sanctionsHandler, serverOptions), +]; +``` + +### 8. Register in the Vite dev server + +Edit `vite.config.ts` — add the lazy import and route mount inside the `sebufApiPlugin()` function. Follow the existing pattern (search for any other service to see the exact locations). + +### 9. Create the frontend service wrapper + +Create `src/services/sanctions.ts`: + +```typescript +import { + SanctionsServiceClient, + type SanctionsEntry, + type ListSanctionsResponse, +} from '@/generated/client/worldmonitor/sanctions/v1/service_client'; +import { createCircuitBreaker } from '@/utils'; + +export type { SanctionsEntry }; + +const client = new SanctionsServiceClient('', { fetch: fetch.bind(globalThis) }); +const breaker = createCircuitBreaker({ name: 'Sanctions' }); + +const emptyFallback: ListSanctionsResponse = { entries: [] }; + +export async function fetchSanctions(authority?: string): Promise { + const response = await breaker.execute(async () => { + return client.listSanctions({ authority: authority ?? '', countryCode: '', pagination: undefined }); + }, emptyFallback); + return response.entries; +} +``` + +### 10. Verify + +```bash +npx tsc --noEmit # zero errors +``` + +## Proto conventions + +These conventions are enforced across the codebase. Follow them for consistency. + +### File naming + +- One file per message type: `earthquake.proto`, `sanctions_entry.proto` +- One file per RPC pair: `list_earthquakes.proto`, `get_earthquake_details.proto` +- Service definition: `service.proto` +- Use `snake_case` for file names and field names + +### Time fields + +Always use `int64` with Unix epoch milliseconds. Never use `google.protobuf.Timestamp`. + +Always add the `INT64_ENCODING_NUMBER` annotation so TypeScript gets `number` instead of `string`: + +```protobuf +int64 occurred_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +``` + +### Validation annotations + +Import `buf/validate/validate.proto` and annotate fields at the proto level. These constraints flow through to the generated OpenAPI spec automatically. + +Common patterns: + +```protobuf +// Required string with length bounds +string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 100 +]; + +// Numeric range (e.g., score 0-100) +double risk_score = 2 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 +]; + +// Non-negative value +double min_magnitude = 3 [(buf.validate.field).double.gte = 0]; + +// Coordinate bounds (prefer using core.v1.GeoCoordinates instead) +double latitude = 1 [ + (buf.validate.field).double.gte = -90, + (buf.validate.field).double.lte = 90 +]; +``` + +### Shared core types + +Reuse these instead of redefining: + +| Type | Import | Use for | +|------|--------|---------| +| `GeoCoordinates` | `worldmonitor/core/v1/geo.proto` | Any lat/lon location (has built-in -90/90 and -180/180 bounds) | +| `BoundingBox` | `worldmonitor/core/v1/geo.proto` | Spatial filtering | +| `TimeRange` | `worldmonitor/core/v1/time.proto` | Time-based filtering (has `INT64_ENCODING_NUMBER`) | +| `PaginationRequest` | `worldmonitor/core/v1/pagination.proto` | Request pagination (has page_size 1-100 constraint) | +| `PaginationResponse` | `worldmonitor/core/v1/pagination.proto` | Response pagination metadata | + +### Comments + +buf lint enforces comments on all messages, fields, services, RPCs, and enum values. Every proto element must have a `//` comment. This is not optional — `buf lint` will fail without them. + +### Route paths + +- Service base path: `/api/{domain}/v1` +- RPC path: `/{verb}-{noun}` in kebab-case (e.g., `/list-earthquakes`, `/get-vessel-snapshot`) + +### Handler typing + +Always type the handler function against the generated interface using indexed access: + +```typescript +export const listSanctions: SanctionsServiceHandler['listSanctions'] = async ( + _ctx: ServerContext, + req: ListSanctionsRequest, +): Promise => { + // ... +}; +``` + +This ensures the compiler catches any mismatch between your implementation and the proto contract. + +### Client construction + +Always pass `{ fetch: fetch.bind(globalThis) }` when creating clients: + +```typescript +const client = new SanctionsServiceClient('', { fetch: fetch.bind(globalThis) }); +``` + +The empty string base URL works because both Vite dev server and Vercel serve the API on the same origin. The `fetch.bind(globalThis)` is required for Tauri compatibility. + +## Generated documentation + +Every time you run `make generate`, OpenAPI v3 specs are generated for each service: + +- `docs/api/{Domain}Service.openapi.yaml` — human-readable YAML +- `docs/api/{Domain}Service.openapi.json` — machine-readable JSON + +These specs include: + +- All endpoints with request/response schemas +- Validation constraints from `buf.validate` annotations (min/max, required fields, ranges) +- Field descriptions from proto comments +- Error response schemas (400 validation errors, 500 server errors) + +You do not need to write or maintain OpenAPI specs by hand. They are generated artifacts. If you need to change the API documentation, change the proto and regenerate. diff --git a/docs/API_KEY_DEPLOYMENT.md b/docs/API_KEY_DEPLOYMENT.md new file mode 100644 index 000000000..5260bf9a0 --- /dev/null +++ b/docs/API_KEY_DEPLOYMENT.md @@ -0,0 +1,140 @@ +# API Key Gating & Registration — Deployment Guide + +## Overview + +Desktop cloud fallback is gated on a `WORLDMONITOR_API_KEY`. Without a valid key, the desktop app operates local-only (sidecar). A registration form collects emails via Convex DB for future key distribution. + +## Architecture + +``` +Desktop App Cloud (Vercel) +┌──────────────────┐ ┌──────────────────────┐ +│ fetch('/api/...')│ │ api/[domain]/v1/[rpc]│ +│ │ │ │ │ │ +│ ┌──────▼───────┐ │ │ ┌──────▼───────┐ │ +│ │ sidecar try │ │ │ │ validateApiKey│ │ +│ │ (local-first)│ │ │ │ (origin-aware)│ │ +│ └──────┬───────┘ │ │ └──────┬───────┘ │ +│ fail │ │ │ 401 if invalid │ +│ ┌──────▼───────┐ │ fallback │ │ +│ │ WM key check │─┼──────────────►│ ┌──────────────┐ │ +│ │ (gate) │ │ +header │ │ route handler │ │ +│ └──────────────┘ │ │ └──────────────┘ │ +└──────────────────┘ └──────────────────────┘ +``` + +## Required Environment Variables + +### Vercel + +| Variable | Description | Example | +|----------|-------------|---------| +| `WORLDMONITOR_VALID_KEYS` | Comma-separated list of valid API keys | `wm_abc123def456,wm_xyz789` | +| `CONVEX_URL` | Convex deployment URL (from `npx convex deploy`) | `https://xyz-123.convex.cloud` | + +### Generating API keys + +Keys must be at least 16 characters (validated client-side). Recommended format: + +```bash +# Generate a key +openssl rand -hex 24 | sed 's/^/wm_/' +# Example output: wm_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6 +``` + +Add to `WORLDMONITOR_VALID_KEYS` in Vercel dashboard (comma-separated, no spaces). + +## Convex Setup + +### First-time deployment + +```bash +# 1. Install (already in package.json) +npm install + +# 2. Login to Convex +npx convex login + +# 3. Initialize project (creates .env.local with CONVEX_URL) +npx convex init + +# 4. Deploy schema and functions +npx convex deploy + +# 5. Copy the deployment URL to Vercel env vars +# The URL is printed by `npx convex deploy` and saved in .env.local +``` + +### Verify Convex deployment + +```bash +# Typecheck Convex functions +npx convex dev --typecheck + +# Open Convex dashboard to see registrations +npx convex dashboard +``` + +### Schema + +The `registrations` table stores: + +| Field | Type | Description | +|-------|------|-------------| +| `email` | string | Original email (for display) | +| `normalizedEmail` | string | Lowercased email (for dedup) | +| `registeredAt` | number | Unix timestamp | +| `source` | string? | Where the registration came from | +| `appVersion` | string? | Desktop app version | + +Indexed by `normalizedEmail` for duplicate detection. + +## Security Model + +### Client-side (desktop app) + +- `installRuntimeFetchPatch()` checks `WORLDMONITOR_API_KEY` before allowing cloud fallback +- Key must be present AND valid (min 16 chars) +- `secretsReady` promise ensures secrets are loaded before first fetch (2s timeout) +- Fail-closed: any error in key check blocks cloud fallback + +### Server-side (Vercel edge) + +- `api/_api-key.js` validates `X-WorldMonitor-Key` header on sebuf routes +- **Origin-aware**: desktop origins (`tauri.localhost`, `tauri://`, `asset://`) require a key +- Web origins (`worldmonitor.app`) pass through without a key +- Non-desktop origin with key header: key is still validated +- Invalid key returns `401 { error: "Invalid API key" }` + +### CORS + +`X-WorldMonitor-Key` is allowed in both `server/cors.ts` and `api/_cors.js`. + +## Verification Checklist + +After deployment: + +- [ ] Set `WORLDMONITOR_VALID_KEYS` in Vercel +- [ ] Set `CONVEX_URL` in Vercel +- [ ] Run `npx convex deploy` to push schema +- [ ] Desktop without key: cloud fallback blocked (console shows `cloud fallback blocked`) +- [ ] Desktop with invalid key: sebuf requests get `401` +- [ ] Desktop with valid key: cloud fallback works as before +- [ ] Web access: no key required, works normally +- [ ] Registration form: submit email, check Convex dashboard +- [ ] Duplicate email: shows "already registered" +- [ ] Existing settings tabs (LLMs, API Keys, Debug) unchanged + +## Files Reference + +| File | Role | +|------|------| +| `src/services/runtime.ts` | Client-side key gate + header attachment | +| `src/services/runtime-config.ts` | `WORLDMONITOR_API_KEY` type, validation, `secretsReady` | +| `api/_api-key.js` | Server-side key validation (origin-aware) | +| `api/[domain]/v1/[rpc].ts` | Sebuf gateway — calls `validateApiKey` | +| `api/register-interest.js` | Registration endpoint → Convex | +| `server/cors.ts` / `api/_cors.js` | CORS headers with `X-WorldMonitor-Key` | +| `src/components/WorldMonitorTab.ts` | Settings UI for key + registration | +| `convex/schema.ts` | Convex DB schema | +| `convex/registerInterest.ts` | Convex mutation | diff --git a/docs/COMMUNITY-PROMOTION-GUIDE.md b/docs/COMMUNITY-PROMOTION-GUIDE.md new file mode 100644 index 000000000..bbad85c47 --- /dev/null +++ b/docs/COMMUNITY-PROMOTION-GUIDE.md @@ -0,0 +1,184 @@ +# World Monitor — Community Promotion Guide + +Thank you for helping spread the word about World Monitor! This guide provides talking points, must-see features, and visual suggestions to help you create compelling content for your audience. + +--- + +## What is World Monitor? + +**One-line pitch**: A free, open-source, real-time global intelligence dashboard — like Bloomberg Terminal meets OSINT, for everyone. + +**Longer description**: World Monitor aggregates 150+ news feeds, military tracking, financial markets, conflict data, protest monitoring, satellite imagery, and AI-powered analysis into a single unified dashboard with an interactive globe. Available as a web app, desktop app (macOS/Windows/Linux), and installable PWA. + +--- + +## Key URLs + +| Link | Description | +|------|-------------| +| [worldmonitor.app](https://worldmonitor.app) | Main dashboard — geopolitics, military, conflicts | +| [tech.worldmonitor.app](https://tech.worldmonitor.app) | Tech variant — startups, AI/ML, cybersecurity | +| [finance.worldmonitor.app](https://finance.worldmonitor.app) | Finance variant — markets, exchanges, central banks | +| [GitHub](https://github.com/koala73/worldmonitor) | Source code (AGPL-3.0) | + +--- + +## Must-See Features (Top 10) + +### 1. Interactive Globe with 35+ Data Layers + +The centerpiece. A WebGL-accelerated globe (deck.gl) with toggleable layers for conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fires, protests, cyber threats, and more. Zoom in and the detail layers progressively reveal. + +**Show**: Toggle different layers on/off. Zoom into a conflict region. Show the layer panel. + +### 2. AI-Powered World Brief + +One-click AI summary of the top global developments. Three-tier LLM provider chain: local Ollama/LM Studio (fully private, offline), Groq (fast cloud), or OpenRouter (fallback). Redis caching for instant responses on repeat queries. + +**Show**: The summary card at the top of the news panel. + +### 3. Country Intelligence Dossiers + +Click any country on the map for a full-page intelligence brief: instability score ring, AI-generated analysis, top headlines, prediction markets, 7-day event timeline, active signal chips, infrastructure exposure, and stock market data. + +**Show**: Click a country (e.g., Japan, Ukraine, or Iran) → full dossier page. + +### 4. 14 Languages Support + +Full UI in 14 languages including Japanese. Regional news feeds auto-adapt — Japanese users see NHK World, Nikkei Asia, and Japan-relevant sources. Language bundles are lazy-loaded for fast performance. + +**Show**: Switch language to Japanese in the settings. Note how feeds change. + +### 5. Live Military Tracking + +Real-time ADS-B military flight tracking and AIS naval vessel monitoring. Strategic Posture panel shows theater-level risk assessment across 9 global regions (Baltic, Black Sea, South China Sea, Eastern Mediterranean, etc.). + +**Show**: Enable the Military layer. Show the Strategic Posture panel. + +### 6. Three Variant Dashboards + +One codebase, three specialized views — switch between World (geopolitics), Tech (startups/AI), and Finance (markets/exchanges) with one click in the header bar. + +**Show**: Click the variant switcher (🌍 WORLD | 💻 TECH | 📈 FINANCE). + +### 7. Market & Crypto Intelligence + +7-signal macro radar with composite BUY/CASH verdict, BTC spot ETF flow tracker, stablecoin peg monitor, Fear & Greed Index, and Bitcoin technical indicators. Sparkline charts and donut gauges for visual trends. + +**Show**: Scroll to the crypto/market panels. Point out the sparklines. + +### 8. Live Video & Webcam Feeds + +8 live news streams (Bloomberg, Al Jazeera, Sky News, etc.) + 19 live webcams from geopolitical hotspots across 4 regions. Idle-aware — auto-pauses after 5 minutes of inactivity. + +**Show**: Open the video panel or webcam panel. + +### 9. Desktop Application (Free) + +Native app for macOS, Windows, and Linux via Tauri. API keys stored in OS keychain (not plaintext). Local Node.js sidecar runs all 60+ API handlers offline-capable. Run local LLMs for fully private, offline AI summaries. + +**Show**: The download buttons on the site, or the desktop app running natively. + +### 10. Story Sharing & Social Export + +Generate intelligence briefs for any country and share to Twitter/X, LinkedIn, WhatsApp, Telegram, Reddit. Includes canvas-rendered PNG images with QR codes linking back to the live dashboard. + +**Show**: Generate a story for a country → share dialog with platform options. + +### 11. Local LLM Support (Ollama / LM Studio) + +Run AI summarization entirely on your own hardware — no API keys, no cloud, no data leaving your machine. The desktop app auto-discovers models from Ollama or LM Studio, with a three-tier fallback chain: local → Groq → OpenRouter. Settings are split into dedicated LLMs and API Keys tabs for easy configuration. + +**Show**: Open Settings → LLMs tab → Ollama model dropdown auto-populated → generate a summary with the local model. + +--- + +## Visual Content Suggestions + +### Screenshots Worth Taking + +1. **Full dashboard overview** — globe in center, panels on sides, news feed visible +2. **Country dossier page** — click Japan or a hotspot country, show the full brief +3. **Layer toggle demo** — before/after with conflicts + military bases enabled +4. **Finance variant** — stock exchanges, financial centers, market panels +5. **Japanese UI** — show the language switcher and Japanese interface +6. **Webcam grid** — 4 live feeds from different regions +7. **Strategic Posture** — theater risk levels panel +8. **Settings LLMs tab** — Ollama model dropdown with local models discovered + +### Video/GIF Ideas + +1. **30-second tour**: Open site → rotate globe → toggle layers → click country → show brief +2. **Language switch**: English → Japanese, show how feeds adapt +3. **Layer stacking**: Start empty → add conflicts → military → cyber → fires → wow +4. **Variant switching**: World → Tech → Finance in quick succession + +--- + +## Talking Points for Posts + +### For General Audience + +- "An open-source Bloomberg Terminal for everyone — free, no login required" +- "150+ news sources, military tracking, AI analysis — all in one dashboard" +- "Run AI summaries locally with Ollama — your data never leaves your machine" +- "Available in Japanese with NHK and Nikkei feeds built in" +- "Native desktop app for macOS/Windows/Linux, completely free" + +### For Tech Audience + +- "Built with TypeScript, Vite, deck.gl, MapLibre GL, Tauri" +- "35+ WebGL data layers running at 60fps" +- "ONNX Runtime Web for browser-based ML inference (sentiment, NER, summarization)" +- "Local LLM support — plug in Ollama or LM Studio, zero cloud dependency" +- "Open source under AGPL-3.0 — contribute on GitHub" + +### For Finance/OSINT Audience + +- "7-signal crypto macro radar with BUY/CASH composite verdict" +- "92 global stock exchanges mapped with market caps and trading hours" +- "Country Instability Index tracking 22 nations in real-time" +- "Prediction market integration for geopolitical forecasting" +- "Air-gapped AI analysis — run Ollama locally for sensitive intelligence work" + +### For Japanese Audience Specifically + +- 日本語完全対応 — UI、ニュースフィード、AI要約すべて日本語で利用可能 +- NHK World、日経アジアなど日本向けニュースソース内蔵 +- 無料・オープンソース — アカウント登録不要 +- macOS/Windows/Linux対応のデスクトップアプリあり + +--- + +## Recent Major Features (Changelog Highlights) + +| Version | Feature | +|---------|---------| +| v2.5.1 | Batch FRED fetching, parallel UCDP, partial cache TTL, bot middleware | +| v2.5.0 | Ollama/LM Studio local LLM support, settings split into LLMs + API Keys tabs, keychain vault consolidation | +| v2.4.1 | Ultra-wide layout (panels wrap around map on 2000px+ screens) | +| v2.4.0 | Live webcams from 19 geopolitical hotspots, 4 regions | +| v2.3.9 | Full i18n: 14 languages including Japanese, Arabic (RTL), Chinese | +| v2.3.8 | Finance variant with 92 exchanges, Gulf FDI investments | +| v2.3.7 | Light/dark theme system, UCDP/UNHCR/Climate panels | +| v2.3.6 | Desktop app with Tauri, OS keychain, auto-updates | +| v2.3.0 | Country Intelligence dossiers, story sharing | + +--- + +## Branding Notes + +- **Name**: "World Monitor" (two words, capitalized) +- **Tagline**: "Real-time global intelligence dashboard" +- **License**: AGPL-3.0 (free and open source) +- **Creator**: Credit "World Monitor by Elie Habib" or link to the GitHub repo +- **Variants**: You can mention all three (World/Tech/Finance) or focus on the main one +- **No login required**: Anyone can use the web app immediately — no signup, no paywall + +--- + +## Thank You + +We genuinely appreciate community members helping grow World Monitor's reach. Feel free to interpret these guidelines creatively — there's no strict template. The most compelling content comes from showing what YOU find most interesting or useful about the tool. + +If you have questions or want specific screenshots/assets, open a Discussion on the GitHub repo or reach out directly. diff --git a/docs/DESKTOP_CONFIGURATION.md b/docs/DESKTOP_CONFIGURATION.md new file mode 100644 index 000000000..29507ba09 --- /dev/null +++ b/docs/DESKTOP_CONFIGURATION.md @@ -0,0 +1,61 @@ +# Desktop Runtime Configuration Schema + +World Monitor desktop now uses a runtime configuration schema with per-feature toggles and secret-backed credentials. + +## Secret keys + +The desktop vault schema (Rust `SUPPORTED_SECRET_KEYS`) supports the following 22 keys: + +- `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_WS_RELAY_URL` +- `VITE_OPENSKY_RELAY_URL` +- `OPENSKY_CLIENT_ID` +- `OPENSKY_CLIENT_SECRET` +- `AISSTREAM_API_KEY` +- `OLLAMA_API_URL` +- `OLLAMA_MODEL` +- `WORLDMONITOR_API_KEY` — gates cloud fallback access (min 16 chars) +- `WTO_API_KEY` + +Note: `UC_DP_KEY` exists in the TypeScript `RuntimeSecretKey` union but is not in the desktop Rust keychain or sidecar. + +## Feature schema + +Each feature includes: + +- `id`: stable feature identifier. +- `requiredSecrets`: list of keys that must be present and valid. +- `enabled`: user-toggle state from runtime settings panel. +- `available`: computed (`enabled && requiredSecrets valid`). +- `fallback`: user-facing degraded behavior description. + +## Desktop secret storage + +Desktop builds persist secrets in OS credential storage through Tauri command bindings backed by Rust `keyring` entries (`world-monitor` service namespace). + +Secrets are **not stored in plaintext files** by the frontend. + +## Degradation behavior + +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. +- 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. +- WorldMonitor API key: cloud fallback is blocked; desktop operates local-only. diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md new file mode 100644 index 000000000..497c8c2a6 --- /dev/null +++ b/docs/DOCUMENTATION.md @@ -0,0 +1,4044 @@ +# World Monitor v2 + +AI-powered real-time global intelligence dashboard aggregating news, markets, geopolitical data, and infrastructure monitoring into a unified situation awareness interface. + +🌐 **[Live Demo: worldmonitor.app](https://worldmonitor.app)** | 💻 **[Tech Variant: tech.worldmonitor.app](https://tech.worldmonitor.app)** + +![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white) +![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat&logo=vite&logoColor=white) +![D3.js](https://img.shields.io/badge/D3.js-F9A03C?style=flat&logo=d3.js&logoColor=white) +![Version](https://img.shields.io/badge/version-2.5.1-blue) + +![World Monitor Dashboard](../new-world-monitor.png) + +## Platform Variants + +World Monitor runs two specialized variants from a single codebase, each optimized for different monitoring needs: + +| Variant | URL | Focus | +|---------|-----|-------| +| **🌍 World Monitor** | [worldmonitor.app](https://worldmonitor.app) | Geopolitical intelligence, military tracking, conflict monitoring, infrastructure security | +| **💻 Tech Monitor** | [tech.worldmonitor.app](https://tech.worldmonitor.app) | Technology sector intelligence, AI/startup ecosystems, cloud infrastructure, tech events | + +A compact **variant switcher** in the header allows seamless navigation between variants while preserving your map position and panel configuration. + +--- + +## World Monitor (Geopolitical) + +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 +- **Economic Intelligence** - FRED indicators, oil analytics, government spending, sanctions tracking +- **Natural Disasters** - Earthquakes, severe weather, NASA EONET events (wildfires, volcanoes, floods) +- **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 | +| **AI Strategic Posture** | Theater-level military force aggregation with strike capability assessment | +| **Country Instability Index** | Real-time stability scores for 20 monitored countries | +| **Strategic Risk Overview** | Composite risk score combining all intelligence modules | +| **Infrastructure Cascade** | Dependency analysis for cables, pipelines, and chokepoints | +| **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). + +--- + +## Tech Monitor + +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) | +| **Startup Hubs** | Major startup ecosystems with ecosystem tier, funding data, and notable companies | +| **Cloud Regions** | AWS, Azure, GCP data center regions with zone counts | +| **Accelerators** | Y Combinator, Techstars, 500 Startups, and regional accelerator locations | +| **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) | +| **Undersea Cables** | Submarine fiber routes critical for cloud connectivity | +| **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 +- **Hardware & Chips** - Semiconductors, AI accelerators, manufacturing +- **Developer & Open Source** - Languages, frameworks, open source projects +- **Tech Policy** - Regulation, antitrust, digital governance + +### Regional Tech HQ Coverage + +| Region | Notable Companies | +|--------|------------------| +| **Silicon Valley** | Apple, Google, Meta, Nvidia, Intel, Cisco, Oracle, VMware | +| **Seattle** | Microsoft, Amazon, Tableau, Expedia | +| **New York** | Bloomberg, MongoDB, Datadog, Squarespace | +| **London** | Revolut, Deliveroo, Darktrace, Monzo | +| **Tel Aviv** | Wix, Check Point, Monday.com, Fiverr | +| **Dubai/MENA** | Careem, Noon, Anghami, Property Finder, Kitopi | +| **Riyadh** | Tabby, Presight.ai, Ninja, XPANCEO | +| **Singapore** | Grab, Razer, Sea Limited | +| **Berlin** | Zalando, Delivery Hero, N26, Celonis | +| **Tokyo** | Sony, Toyota, SoftBank, Rakuten | + +--- + +## 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 +- **Time Filtering** - Filter events by time range (1h, 6h, 24h, 48h, 7d) +- **Pinnable Map** - Pin the map to the top while scrolling through panels, or let it scroll with the page +- **Smart Marker Clustering** - Nearby markers group at low zoom, expand on zoom in + +### Marker Clustering + +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) + +This prevents issues like Dubai and Riyadh companies appearing merged at global zoom, while still providing clean visualization at continental scales. + +### Data Layers + +Layers are organized into logical groups for efficient monitoring: + +**Geopolitical** +| Layer | Description | +|-------|-------------| +| **Conflicts** | Active conflict zones with involved parties and status | +| **Hotspots** | Intelligence hotspots with activity levels based on news correlation | +| **Sanctions** | Countries under economic sanctions regimes | +| **Protests** | Live social unrest events from ACLED and GDELT | + +**Military & Strategic** +| Layer | Description | +|-------|-------------| +| **Military Bases** | 220+ global military installations from 9 operators | +| **Nuclear Facilities** | Power plants, weapons labs, enrichment sites | +| **Gamma Irradiators** | IAEA-tracked Category 1-3 radiation sources | +| **APT Groups** | State-sponsored cyber threat actors with geographic attribution | +| **Spaceports** | 12 major launch facilities (NASA, SpaceX, Roscosmos, CNSA, ESA, ISRO, JAXA) | +| **Critical Minerals** | Strategic mineral deposits (lithium, cobalt, rare earths) with operator info | + +**Infrastructure** +| Layer | Description | +|-------|-------------| +| **Undersea Cables** | 55 major submarine cable routes worldwide | +| **Pipelines** | 88 operating oil & gas pipelines across all continents | +| **Internet Outages** | Network disruptions via Cloudflare Radar | +| **AI Datacenters** | 111 major AI compute clusters (≥10,000 GPUs) | + +**Transport** +| Layer | Description | +|-------|-------------| +| **Ships (AIS)** | Live vessel tracking via AIS with chokepoint monitoring and 61 strategic ports* | +| **Delays** | FAA airport delay status and ground stops | + +*\*AIS data via [AISStream.io](https://aisstream.io) uses terrestrial receivers with stronger coverage in European/Atlantic waters. Middle East, Asia, and open ocean coverage is limited. Satellite AIS providers (Spire, Kpler) offer global coverage but require commercial licenses.* + +**Natural Events** +| Layer | Description | +|-------|-------------| +| **Natural** | USGS earthquakes (M4.5+) + NASA EONET events (storms, wildfires, volcanoes, floods) | +| **Weather** | NWS severe weather warnings | + +**Economic & Labels** +| Layer | Description | +|-------|-------------| +| **Economic** | Tabbed economic panel with FRED indicators, EIA oil analytics, and USASpending.gov government contracts | +| **Countries** | Country boundary labels | +| **Waterways** | Strategic waterways and chokepoints | + +### Intelligence Panels + +Beyond raw data feeds, the dashboard provides synthesized intelligence panels: + +| Panel | Purpose | +|-------|---------| +| **AI Strategic Posture** | Theater-level military aggregation with strike capability analysis | +| **Strategic Risk Overview** | Composite risk score combining all intelligence modules | +| **Country Instability Index** | Real-time stability scores for 20 monitored countries | +| **Infrastructure Cascade** | Dependency analysis for cables, pipelines, and chokepoints | +| **Live Intelligence** | GDELT-powered topic feeds (Military, Cyber, Nuclear, Sanctions) | +| **Intel Feed** | Curated defense and security news sources | + +These panels transform raw signals into actionable intelligence by applying scoring algorithms, trend detection, and cross-source correlation. + +### 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) +- **Latin America** - BBC Latin America, Guardian Americas, Google News aggregation +- **Asia-Pacific** - BBC Asia, South China Morning Post, Google News aggregation +- **Energy & Resources** - Google News aggregation (oil/gas, nuclear, mining, Reuters Energy) +- **Technology** - Hacker News, Ars Technica, The Verge, MIT Tech Review +- **AI / ML** - ArXiv, VentureBeat AI, The Verge AI, MIT Tech Review +- **Finance** - CNBC, MarketWatch, Financial Times, Yahoo Finance +- **Government** - White House, State Dept, Pentagon, Treasury, Fed, SEC, UN News, CISA +- **Intel Feed** - Defense One, Breaking Defense, Bellingcat, Krebs Security, Janes +- **Think Tanks** - Foreign Policy, Atlantic Council, Foreign Affairs, CSIS, RAND, Brookings, Carnegie +- **Crisis Watch** - International Crisis Group, IAEA, WHO, UNHCR +- **Regional Sources** - Xinhua, TASS, Kyiv Independent, Moscow Times +- **Layoffs Tracker** - Tech industry job cuts + +### Source Filtering + +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 +- **Counter Display**: Shows "45/77 enabled" to indicate current selection +- **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 +- Changes take effect on the next refresh cycle + +### Regional Intelligence Panels + +Dedicated panels provide focused coverage for strategically significant regions: + +| Panel | Coverage | Key Topics | +|-------|----------|------------| +| **Middle East** | MENA region | Israel-Gaza, Iran, Gulf states, Red Sea | +| **Africa** | Sub-Saharan Africa | Sahel instability, coups, insurgencies, resources | +| **Latin America** | Central & South America | Venezuela, drug trafficking, regional politics | +| **Asia-Pacific** | East & Southeast Asia | China-Taiwan, Korean peninsula, ASEAN | +| **Energy & Resources** | Global | Oil markets, nuclear, mining, energy security | + +Each panel aggregates region-specific sources to provide concentrated situational awareness for that theater. This enables focused monitoring when global events warrant attention to a particular region. + +### Live News Streams + +Embedded YouTube live streams from major news networks with channel switching: + +| Channel | Coverage | +|---------|----------| +| **Bloomberg** | Business & financial news | +| **Sky News** | UK & international news | +| **Euronews** | European perspective | +| **DW News** | German international broadcaster | +| **France 24** | French global news | +| **Al Arabiya** | Middle East news (Arabic perspective) | +| **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) +- **Double-Width Panel** - Larger video player for better viewing + +**Performance Optimizations:** + +The live stream panel uses the **YouTube IFrame Player API** rather than raw iframe embedding. This provides several advantages: + +| Feature | Benefit | +|---------|---------| +| **Persistent player** | No iframe reload on mute/play/channel change | +| **API control** | Direct `playVideo()`, `pauseVideo()`, `mute()` calls | +| **Reduced bandwidth** | Same stream continues across state changes | +| **Faster switching** | Channel changes via `loadVideoById()` | + +**Idle Detection:** + +To conserve resources, the panel implements automatic idle pausing: + +| Trigger | Action | +|---------|--------| +| **Tab hidden** | Stream pauses (via Visibility API) | +| **5 min idle** | Stream pauses (no mouse/keyboard activity) | +| **User returns** | Stream resumes automatically | +| **Manual pause** | User intent tracked separately | + +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 +- **Sector Heatmap** - Visual sector performance (11 SPDR sectors) +- **Economic Indicators** - Fed data via FRED (assets, rates, yields) +- **Oil Analytics** - EIA data: WTI/Brent prices, US production, US inventory with weekly changes +- **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) +- Nuclear facilities and irradiators +- Markets and predictions + +### Data Export + +- CSV and JSON export of current dashboard state +- Historical playback from snapshots + +--- + +## Signal Intelligence + +The dashboard continuously analyzes data streams to detect significant patterns and anomalies. Signals appear in the header badge (⚡) with confidence scores. + +### Intelligence Findings Badge + +The header displays an **Intelligence Findings** badge that consolidates two types of alerts: + +| Alert Type | Source | Examples | +|------------|--------|----------| +| **Correlation Signals** | Cross-source pattern detection | Velocity spikes, market divergence, prediction leading | +| **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 +- Confidence score and priority level +- Timestamp and trending direction + +This provides a unified command center for all intelligence findings, whether generated by correlation analysis or module-specific threshold detection. + +### Signal Types + +The system detects 12 distinct signal types across news, markets, military, and infrastructure domains: + +**News & Source Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **◉ Convergence** | 3+ source types report same story within 30 minutes | Multiple independent channels confirming the same event—higher likelihood of significance | +| **△ Triangulation** | Wire + Government + Intel sources align | The "authority triangle"—when official channels, wire services, and defense specialists all report the same thing | +| **🔥 Velocity Spike** | Topic mention rate doubles with 6+ sources/hour | A story is accelerating rapidly across the news ecosystem | + +**Market Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **🔮 Prediction Leading** | Prediction market moves 5%+ with low news coverage | Markets pricing in information not yet reflected in news | +| **📰 News Leads Markets** | High news velocity without corresponding market move | Breaking news not yet priced in—potential mispricing | +| **✓ Market Move Explained** | Market moves 2%+ with correlated news coverage | Price action has identifiable news catalyst—entity correlation found related stories | +| **📊 Silent Divergence** | Market moves 2%+ with no correlated news after entity search | Unexplained price action after exhaustive search—possible insider knowledge or algorithm-driven | +| **📈 Sector Cascade** | Multiple related sectors moving in same direction | Market reaction cascading through correlated industries | + +**Infrastructure & Energy Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **🛢 Flow Drop** | Pipeline flow disruption keywords detected | Physical commodity supply constraint—may precede price spike | +| **🔁 Flow-Price Divergence** | Pipeline disruption news without corresponding oil price move | Energy supply disruption not yet priced in—potential information edge | + +**Geopolitical & Military Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **🌍 Geographic Convergence** | 3+ event types in same 1°×1° grid cell | Multiple independent data streams converging on same location—heightened regional activity | +| **🔺 Hotspot Escalation** | Multi-component score exceeds threshold with rising trend | Hotspot showing corroborated escalation across news, CII, convergence, and military data | +| **✈ Military Surge** | Transport/fighter activity 2× baseline in theater | Unusual military airlift concentration—potential deployment or crisis response | + +### How It Works + +The correlation engine maintains rolling snapshots of: + +- News topic frequency (by keyword extraction) +- Market price changes +- Prediction market probabilities + +Each refresh cycle compares current state to previous snapshot, applying thresholds and deduplication to avoid alert fatigue. Signals include confidence scores (60-95%) based on the strength of the pattern. + +### Entity-Aware Correlation + +The signal engine uses a **knowledge base of 100+ entities** to intelligently correlate market movements with news coverage. Rather than simple keyword matching, the system understands that "AVGO" (the ticker) relates to "Broadcom" (the company), "AI chips" (the sector), and entities like "Nvidia" (a competitor). + +#### Entity Knowledge Base + +Each entity in the registry contains: + +| Field | Purpose | Example | +|-------|---------|---------| +| **ID** | Canonical identifier | `broadcom` | +| **Name** | Display name | `Broadcom Inc.` | +| **Type** | Category | `company`, `commodity`, `crypto`, `country`, `person` | +| **Aliases** | Alternative names | `AVGO`, `Broadcom`, `Broadcom Inc` | +| **Keywords** | Related topics | `AI chips`, `semiconductors`, `VMware` | +| **Sector** | Industry classification | `semiconductors` | +| **Related** | Linked entities | `nvidia`, `intel`, `amd` | + +#### Entity Types + +| Type | Count | Examples | +|------|-------|----------| +| **Companies** | 50+ | Nvidia, Apple, Tesla, Broadcom, Boeing, Lockheed Martin, TSMC, Rheinmetall | +| **Indices** | 5+ | S&P 500, Dow Jones, NASDAQ | +| **Sectors** | 10+ | Technology (XLK), Finance (XLF), Energy (XLE), Healthcare (XLV), Semiconductors (SMH) | +| **Commodities** | 10+ | Oil (WTI), Gold, Natural Gas, Copper, Silver, VIX | +| **Crypto** | 3 | Bitcoin, Ethereum, Solana | +| **Countries** | 15+ | China, Russia, Iran, Israel, Ukraine, Taiwan, Saudi Arabia, UAE, Qatar, Turkey, Egypt | + +#### How Entity Matching Works + +When a market moves significantly (≥2%), the system: + +1. **Looks up the ticker** in the entity registry (e.g., `AVGO` → `broadcom`) +2. **Gathers all identifiers**: aliases, keywords, sector peers, related entities +3. **Scans all news clusters** for matches against any identifier +4. **Scores confidence** based on match type: + - Alias match (exact name): 95% + - Keyword match (topic): 70% + - Related entity match: 60% + +If correlated news is found → **"Market Move Explained"** signal with the news headline. +If no correlation after exhaustive search → **"Silent Divergence"** signal. + +#### Example: Broadcom +2.5% + +``` +1. Ticker AVGO detected with +2.5% move +2. Entity lookup: broadcom +3. Search terms: ["Broadcom", "AVGO", "AI chips", "semiconductors", "VMware", "nvidia", "intel", "amd"] +4. News scan finds: "Broadcom AI Revenue Beats Estimates" +5. Result: "✓ Market Move Explained: Broadcom AI Revenue Beats Estimates" +``` + +Without this system, the same move would generate a generic "Silent Divergence: AVGO +2.5%" signal. + +#### Sector Coverage + +The entity registry spans strategically significant sectors: + +| Sector | Examples | Keywords Tracked | +|--------|----------|------------------| +| **Technology** | Apple, Microsoft, Nvidia, Google, Meta, TSMC | AI, cloud, chips, datacenter, streaming | +| **Defense & Aerospace** | Lockheed Martin, Raytheon, Northrop Grumman, Boeing, Rheinmetall, Airbus | F-35, missiles, drones, tanks, defense contracts | +| **Semiconductors** | ASML, Samsung, AMD, Intel, Broadcom | Lithography, EUV, foundry, fab, wafer | +| **Critical Minerals** | Albemarle, SQM, MP Materials, Freeport-McMoRan | Lithium, rare earth, cobalt, copper | +| **Finance** | JPMorgan, Berkshire Hathaway, Visa, Mastercard | Banking, credit, investment, interest rates | +| **Healthcare** | Eli Lilly, Novo Nordisk, UnitedHealth, J&J | Pharma, drugs, GLP-1, obesity, diabetes | +| **Energy** | Exxon, Chevron, ConocoPhillips | Oil, gas, drilling, refinery, LNG | +| **Consumer** | Tesla, Walmart, Costco, Home Depot | EV, retail, grocery, housing | + +This broad coverage enables correlation detection across diverse geopolitical and market events. + +### Entity Registry Architecture + +The entity registry is a knowledge base of 600+ entities with rich metadata for intelligent correlation: + +```typescript +{ + id: 'NVDA', // Unique identifier + name: 'Nvidia', // Display name + type: 'company', // company | country | index | commodity | currency + sector: 'semiconductors', + searchTerms: ['Nvidia', 'NVDA', 'Jensen Huang', 'H100', 'CUDA'], + aliases: ['nvidia', 'nvda'], + competitors: ['AMD', 'INTC'], + related: ['AVGO', 'TSM', 'ASML'], // Related entities + country: 'US', // Headquarters/origin +} +``` + +**Entity Types**: + +| Type | Count | Use Case | +|------|-------|----------| +| `company` | 100+ | Market-news correlation, sector analysis | +| `country` | 200+ | Focal point detection, CII scoring | +| `index` | 20+ | Market overview, regional tracking | +| `commodity` | 15+ | Energy and mineral correlation | +| `currency` | 10+ | FX market tracking | + +**Lookup Indexes**: + +The registry provides multiple lookup paths for fast entity resolution: + +| Index | Query Example | Use Case | +|-------|---------------|----------| +| `byId` | `'NVDA'` → Nvidia entity | Direct lookup from ticker | +| `byAlias` | `'nvidia'` → Nvidia entity | Case-insensitive name match | +| `byKeyword` | `'AI chips'` → [Nvidia, AMD, Intel] | News keyword extraction | +| `bySector` | `'semiconductors'` → all chip companies | Sector cascade analysis | +| `byCountry` | `'US'` → all US entities | Country-level aggregation | + +### Signal Deduplication + +To prevent alert fatigue, signals use **type-specific TTL (time-to-live)** values for deduplication: + +| Signal Type | TTL | Rationale | +|-------------|-----|-----------| +| **Silent Divergence** | 6 hours | Market moves persist; don't re-alert on same stock | +| **Flow-Price Divergence** | 6 hours | Energy events unfold slowly | +| **Explained Market Move** | 6 hours | Same correlation shouldn't repeat | +| **Prediction Leading** | 2 hours | Prediction markets update more frequently | +| **Other signals** | 30 minutes | Default for fast-moving events | + +Market signals use **symbol-only keys** (e.g., `silent_divergence:AVGO`) rather than including the price change. This means a stock moving +2.5% then +3.0% won't trigger duplicate alerts—the first alert covers the story. + +--- + +## Source Intelligence + +Not all sources are equal. The system implements a dual classification to prioritize authoritative information. + +### Source Tiers (Authority Ranking) + +| Tier | Sources | Characteristics | +|------|---------|-----------------| +| **Tier 1** | Reuters, AP, AFP, Bloomberg, White House, Pentagon | Wire services and official government—fastest, most reliable | +| **Tier 2** | BBC, Guardian, NPR, Al Jazeera, CNBC, Financial Times | Major outlets—high editorial standards, some latency | +| **Tier 3** | Defense One, Bellingcat, Foreign Policy, MIT Tech Review | Domain specialists—deep expertise, narrower scope | +| **Tier 4** | Hacker News, The Verge, VentureBeat, aggregators | Useful signal but requires corroboration | + +When multiple sources report the same story, the **lowest tier** (most authoritative) source is displayed as the primary, with others listed as corroborating. + +### Source Types (Categorical) + +Sources are also categorized by function for triangulation detection: + +- **Wire** - News agencies (Reuters, AP, AFP, Bloomberg) +- **Gov** - Official government (White House, Pentagon, State Dept, Fed, SEC) +- **Intel** - Defense/security specialists (Defense One, Bellingcat, Krebs) +- **Mainstream** - Major news outlets (BBC, Guardian, NPR, Al Jazeera) +- **Market** - Financial press (CNBC, MarketWatch, Financial Times) +- **Tech** - Technology coverage (Hacker News, Ars Technica, MIT Tech Review) + +### Propaganda Risk Indicators + +The dashboard visually flags sources with known state affiliations or propaganda risk, enabling users to appropriately weight information from these outlets. + +**Risk Levels** + +| Level | Visual | Meaning | +|-------|--------|---------| +| **High** | ⚠ State Media (red) | Direct state control or ownership | +| **Medium** | ! Caution (orange) | Significant state influence or funding | +| **Low** | (none) | Independent editorial control | + +**Flagged Sources** + +| Source | Risk Level | State Affiliation | Notes | +|--------|------------|-------------------|-------| +| **Xinhua** | High | China (CCP) | Official news agency of PRC | +| **TASS** | High | Russia | State-owned news agency | +| **RT** | High | Russia | Registered foreign agent in US | +| **CGTN** | High | China (CCP) | China Global Television Network | +| **PressTV** | High | Iran | IRIB subsidiary | +| **Al Jazeera** | Medium | Qatar | Qatari government funded | +| **TRT World** | Medium | Turkey | Turkish state broadcaster | + +**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 + +**Why Include State Media?** + +State-controlled outlets are included rather than filtered because: + +1. **Signal Value**: What state media reports (and omits) reveals government priorities +2. **Rapid Response**: State media often breaks domestic news faster than international outlets +3. **Narrative Analysis**: Understanding how events are framed by different governments +4. **Completeness**: Excluding them creates blind spots in coverage + +The badges ensure users can **contextualize** state media reports rather than unknowingly treating them as independent journalism. + +--- + +## Entity Extraction System + +The dashboard extracts **named entities** (companies, countries, leaders, organizations) from news headlines to enable news-to-market correlation and entity-based filtering. + +### How It Works + +Headlines are scanned against a curated entity index containing: + +| Entity Type | Examples | Purpose | +|-------------|----------|---------| +| **Companies** | Apple, Tesla, NVIDIA, Boeing | Market symbol correlation | +| **Countries** | Russia, China, Iran, Ukraine | Geopolitical attribution | +| **Leaders** | Putin, Xi Jinping, Khamenei | Political event tracking | +| **Organizations** | NATO, OPEC, Fed, SEC | Institutional news filtering | +| **Commodities** | Oil, Gold, Bitcoin | Commodity news correlation | + +### Entity Matching + +Each entity has multiple match patterns for comprehensive detection: + +``` +Entity: NVIDIA (NVDA) + Aliases: nvidia, nvda, jensen huang + Keywords: gpu, h100, a100, cuda, ai chip + Match Types: + - Name match: "NVIDIA announces..." → 95% confidence + - Alias match: "Jensen Huang says..." → 90% confidence + - Keyword match: "H100 shortage..." → 70% confidence +``` + +### Confidence Scoring + +Entity extraction produces confidence scores based on match quality: + +| Match Type | Confidence | Example | +|------------|------------|---------| +| **Direct name** | 95% | "Apple reports earnings" | +| **Alias** | 90% | "Tim Cook announces..." | +| **Keyword** | 70% | "iPhone sales decline" | +| **Related cluster** | 63% | Secondary headline mention (90% × 0.7) | + +### Market Correlation + +When a market symbol moves significantly, the system searches news clusters for related entities: + +1. **Symbol lookup** - Find entity by market symbol (e.g., `AAPL` → Apple) +2. **News search** - Find clusters mentioning the entity or related entities +3. **Confidence ranking** - Sort by extraction confidence +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" + +--- + +## Signal Context ("Why It Matters") + +Every signal includes contextual information explaining its analytical significance: + +### Context Fields + +| Field | Purpose | Example | +|-------|---------|---------| +| **Why It Matters** | Analytical significance | "Markets pricing in information before news" | +| **Actionable Insight** | What to do next | "Monitor for breaking news in 1-6 hours" | +| **Confidence Note** | Signal reliability caveats | "Higher confidence if multiple markets align" | + +### Signal-Specific Context + +| Signal | Why It Matters | +|--------|---------------| +| **Prediction Leading** | Prediction markets often price in information before it becomes news—traders may have early access to developments | +| **Silent Divergence** | Market moving without identifiable catalyst—possible insider knowledge, algorithmic trading, or unreported development | +| **Velocity Spike** | Story accelerating across multiple sources—indicates growing significance and potential for market/policy impact | +| **Triangulation** | The "authority triangle" (wire + government + intel) aligned—gold standard for breaking news confirmation | +| **Flow-Price Divergence** | Supply disruption not yet reflected in prices—potential information edge or markets have better information | +| **Hotspot Escalation** | Geopolitical hotspot showing escalation across news, instability, convergence, and military presence | + +This contextual layer transforms raw alerts into **actionable intelligence** by explaining the analytical reasoning behind each signal. + +--- + +## Algorithms & Design + +### News Clustering + +Related articles are grouped using **Jaccard similarity** on tokenized headlines: + +``` +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 +- Result cached per headline for performance + +**Inverted Index Optimization**: +Rather than O(n²) pairwise comparison, the algorithm uses an inverted index: + +1. Build token → article indices map +2. For each article, find candidate matches via shared tokens +3. Only compute Jaccard for candidates with token overlap +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 +- Clusters maintain full item list for multi-source attribution + +### Velocity Analysis + +Each news cluster tracks publication velocity: + +- **Sources per hour** = article count / time span +- **Trend** = rising/stable/falling based on first-half vs second-half publication rate +- **Levels**: Normal (<3/hr), Elevated (3-6/hr), Spike (>6/hr) + +### Sentiment Detection + +Headlines are scored against curated word lists: + +**Negative indicators**: war, attack, killed, crisis, crash, collapse, threat, sanctions, invasion, missile, terror, assassination, recession, layoffs... + +**Positive indicators**: peace, deal, agreement, breakthrough, recovery, growth, ceasefire, treaty, alliance, victory... + +Score determines sentiment classification: negative (<-1), neutral (-1 to +1), positive (>+1) + +### Entity Extraction + +News headlines are scanned against the entity knowledge base using **word-boundary regex matching**: + +``` +regex = /\b{escaped_alias}\b/gi +``` + +**Index Structure**: +The entity index pre-builds five lookup maps for O(1) access: + +| Map | Key | Value | Purpose | +|-----|-----|-------|---------| +| `byId` | Entity ID | Full entity record | Direct lookup | +| `byAlias` | Lowercase alias | Entity ID | Name matching | +| `byKeyword` | Lowercase keyword | Set of entity IDs | Topic matching | +| `bySector` | Sector name | Set of entity IDs | Sector queries | +| `byType` | Entity type | Set of entity IDs | Type filtering | + +**Matching Algorithm**: + +1. **Alias matching** (highest confidence): + - Iterate all aliases (minimum 3 characters to avoid false positives) + - Word-boundary regex prevents partial matches ("AI" won't match "RAID") + - First alias match for each entity stops further searching (deduplication) + +2. **Keyword matching** (medium confidence): + - Simple substring check (faster than regex) + - Multiple entities may match same keyword + - Lower confidence (70%) than alias matches (95%) + +3. **Related entity expansion**: + - If entity has `related` field, those entities are also checked + - 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 +- Full news scan: O(aliases × clusters) ≈ 300 × 50 = 15,000 comparisons + +### Baseline Deviation (Z-Score) + +The system maintains rolling baselines for news volume per topic: + +- **7-day average** and **30-day average** stored in IndexedDB +- Standard deviation calculated from historical counts +- **Z-score** = (current - mean) / stddev + +Deviation levels: + +- **Spike**: Z > 2.5 (statistically rare increase) +- **Elevated**: Z > 1.5 +- **Normal**: -2 < Z < 1.5 +- **Quiet**: Z < -2 (unusually low activity) + +This enables detection of anomalous activity even when absolute numbers seem normal. + +--- + +## Dynamic Hotspot Activity + +Hotspots on the map are **not static threat levels**. Activity is calculated in real-time based on news correlation. + +Each hotspot defines keywords: +```typescript +{ + id: 'dc', + name: 'DC', + keywords: ['pentagon', 'white house', 'congress', 'cia', 'nsa', ...], + agencies: ['Pentagon', 'CIA', 'NSA', 'State Dept'], +} +``` + +The system counts matching news articles in the current feed, applies velocity analysis, and assigns activity levels: + +| Level | Criteria | Visual | +|-------|----------|--------| +| **Low** | <3 matches, normal velocity | Gray marker | +| **Elevated** | 3-6 matches OR elevated velocity | Yellow pulse | +| **High** | >6 matches OR spike velocity | Red pulse | + +This creates a dynamic "heat map" of global attention based on live news flow. + +### Hotspot Escalation Signals + +Beyond visual activity levels, the system generates **escalation signals** when hotspots show significant changes across multiple dimensions. This multi-component approach reduces false positives by requiring corroboration from independent data streams. + +**Escalation Components** + +Each hotspot's escalation score blends four weighted components: + +| Component | Weight | Data Source | What It Measures | +|-----------|--------|-------------|------------------| +| **News Activity** | 35% | RSS feeds | Matching news count, breaking flags, velocity | +| **CII Contribution** | 25% | Country Instability Index | Instability score of associated country | +| **Geographic Convergence** | 25% | Multi-source events | Event type diversity in geographic cell | +| **Military Activity** | 15% | OpenSky/AIS | Flights and vessels within 200km | + +**Score Calculation** + +``` +static_baseline = hotspot.baselineRisk // 1-5 per hotspot +dynamic_score = ( + news_component × 0.35 + + cii_component × 0.25 + + geo_component × 0.25 + + military_component × 0.15 +) +proximity_boost = hotspot_proximity_multiplier // 1.0-2.0 + +final_score = (static_baseline × 0.30 + dynamic_score × 0.70) × proximity_boost +``` + +**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 +- **Stable**: Slope within ±0.1 + +**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) + +**Signal Context** + +| Field | Content | +|-------|---------| +| **Why It Matters** | "Geopolitical hotspot showing significant escalation based on news activity, country instability, geographic convergence, and military presence" | +| **Actionable Insight** | "Increase monitoring priority; assess downstream impacts on infrastructure, markets, and regional stability" | +| **Confidence Note** | "Weighted by multiple data sources—news (35%), CII (25%), geo-convergence (25%), military (15%)" | + +This multi-signal approach means a hotspot escalation signal represents **corroborated evidence** across independent data streams—not just a spike in news mentions. + +--- + +## Regional Focus Navigation + +The FOCUS selector in the header provides instant navigation to strategic regions. Each preset is calibrated to center on the region's geographic area with an appropriate zoom level. + +### Available Regions + +| Region | Coverage | Primary Use Cases | +|--------|----------|-------------------| +| **Global** | Full world view | Overview, cross-regional comparison | +| **Americas** | North America focus | US monitoring, NORAD activity | +| **Europe** | EU + UK + Scandinavia + Western Russia | NATO activity, energy infrastructure | +| **MENA** | Middle East + North Africa | Conflict zones, oil infrastructure | +| **Asia** | East Asia + Southeast Asia | China-Taiwan, Korean peninsula | +| **Latin America** | Central + South America | Regional instability, drug trafficking | +| **Africa** | Sub-Saharan Africa | Conflict zones, resource extraction | +| **Oceania** | Australia + Pacific | Indo-Pacific activity | + +### Quick Navigation + +The FOCUS dropdown enables rapid context switching: + +1. **Breaking news** - Jump to the affected region +2. **Regional briefing** - Cycle through regions for situational awareness +3. **Crisis monitoring** - Lock onto a specific theater + +Regional views are encoded in shareable URLs, enabling direct links to specific geographic contexts. + +--- + +## Map Pinning + +By default, the map scrolls with the page, allowing you to scroll down to view panels below. The **pin button** (📌) in the map header toggles sticky behavior: + +| State | Behavior | +|-------|----------| +| **Unpinned** (default) | Map scrolls with page; scroll down to see panels | +| **Pinned** | Map stays fixed at top; panels scroll beneath | + +### When to Pin + +- **Active monitoring** - Keep the map visible while reading news panels +- **Cross-referencing** - Compare map markers with panel data +- **Presentation** - Show the map while discussing panel content + +### When to Unpin + +- **Panel focus** - Read through panels without map taking screen space +- **Mobile** - Pin is disabled on mobile for better space utilization +- **Research** - Focus on data panels without geographic distraction + +Pin state persists across sessions via localStorage. + +--- + +## Country Instability Index (CII) + +The dashboard maintains a **real-time instability score** for 20 strategically significant countries. Rather than relying on static risk ratings, the CII dynamically reflects current conditions based on multiple input streams. + +### Monitored Countries (Tier 1) + +| Region | Countries | +|--------|-----------| +| **Americas** | United States, Venezuela | +| **Europe** | Germany, France, United Kingdom, Poland | +| **Eastern Europe** | Russia, Ukraine | +| **Middle East** | Iran, Israel, Saudi Arabia, Turkey, Syria, Yemen | +| **Asia-Pacific** | China, Taiwan, North Korea, India, Pakistan, Myanmar | + +### Three Component Scores + +Each country's CII is computed from three weighted components: + +| Component | Weight | Data Sources | What It Measures | +|-----------|--------|--------------|------------------| +| **Unrest** | 40% | ACLED protests, GDELT events | Civil unrest intensity, fatalities, event severity | +| **Security** | 30% | Military flights, naval vessels | Unusual military activity patterns | +| **Information** | 30% | News velocity, alert clusters | Media attention intensity and acceleration | + +### Scoring Algorithm + +``` +Unrest Score: + base = min(50, protest_count × 8) + fatality_boost = min(30, total_fatalities × 5) + severity_boost = min(20, high_severity_count × 10) + unrest = min(100, base + fatality_boost + severity_boost) + +Security Score: + flight_score = min(50, military_flights × 3) + vessel_score = min(30, naval_vessels × 5) + security = min(100, flight_score + vessel_score) + +Information Score: + base = min(40, news_count × 5) + velocity_boost = min(40, avg_velocity × 10) + alert_boost = 20 if any_alert else 0 + information = min(100, base + velocity_boost + alert_boost) + +Final CII = round(unrest × 0.4 + security × 0.3 + information × 0.3) +``` + +### Scoring Bias Prevention + +Raw news volume creates a natural bias—English-language media generates far more coverage of the US, UK, and Western Europe than conflict zones. Without correction, stable democracies would consistently score higher than actual crisis regions. + +**Log Scaling for High-Volume Countries** + +Countries with high media coverage receive logarithmic dampening on their unrest and information scores: + +``` +if (newsVolume > threshold): + dampingFactor = 1 / (1 + log10(newsVolume / threshold)) + score = rawScore × dampingFactor +``` + +This ensures the US receiving 50 news mentions about routine political activity doesn't outscore Ukraine with 10 mentions about active combat. + +**Conflict Zone Floor Scores** + +Active conflict zones have minimum score floors that prevent them from appearing stable during data gaps or low-coverage periods: + +| Country | Floor | Rationale | +|---------|-------|-----------| +| Ukraine | 55 | Active war with Russia | +| Syria | 50 | Ongoing civil war | +| Yemen | 50 | Ongoing civil war | +| Myanmar | 45 | Military coup, civil conflict | +| Israel | 45 | Active Gaza conflict | + +The floor applies *after* the standard calculation—if the computed score exceeds the floor, the computed score is used. This prevents false "all clear" signals while preserving sensitivity to actual escalations. + +### Instability Levels + +| Level | Score Range | Visual | Meaning | +|-------|-------------|--------|---------| +| **Critical** | 81-100 | Red | Active crisis or major escalation | +| **High** | 66-80 | Orange | Significant instability requiring close monitoring | +| **Elevated** | 51-65 | Yellow | Above-normal activity patterns | +| **Normal** | 31-50 | Gray | Baseline geopolitical activity | +| **Low** | 0-30 | Green | Unusually quiet period | + +### 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) + +### Contextual Score Boosts + +Beyond the base component scores, several contextual factors can boost a country's CII score (up to a combined maximum of 23 additional points): + +| Boost Type | Max Points | Condition | Purpose | +|------------|------------|-----------|---------| +| **Hotspot Activity** | 10 | Events near defined hotspots | Captures localized escalation | +| **News Urgency** | 5 | Information component ≥50 | High media attention indicator | +| **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 + +These boosts are designed to elevate scores only when corroborating evidence exists—a country must have both high base scores AND contextual signals to reach extreme levels. + +### Server-Side Pre-Computation + +To eliminate the "cold start" problem where new users would see blank data during the Learning Mode warmup, CII scores are **pre-computed server-side** via the `/api/risk-scores` endpoint. See the [Server-Side Risk Score API](#server-side-risk-score-api) section for details. + +### Learning Mode (15-Minute Warmup) + +On dashboard startup, the CII system enters **Learning Mode**—a 15-minute calibration period where scores are calculated but alerts are suppressed. This prevents the flood of false-positive alerts that would otherwise occur as the system establishes baseline values. + +**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 + +**Visual Indicators** + +During Learning Mode, the dashboard provides clear visual feedback: + +| Location | Indicator | +|----------|-----------| +| **CII Panel** | Yellow banner with progress bar and countdown timer | +| **Strategic Risk Overview** | "Learning Mode - Xm until reliable" status | +| **Score Display** | Scores shown at 60% opacity (dimmed) | + +**Behavior** + +``` +Minutes 0-15: Learning Mode Active + - CII scores calculated and displayed (dimmed) + - Trend detection active (stores baseline) + - All CII-related alerts suppressed + - Progress bar fills as time elapses + +After 15 minutes: Learning Complete + - Full opacity scores + - Alert generation enabled (threshold ≥10 point change) + - "All data sources active" status shown +``` + +This ensures users understand that early scores are provisional while preventing alert fatigue during the calibration period. + +### Keyword Attribution + +Countries are matched to data via keyword lists: + +- **Russia**: `russia`, `moscow`, `kremlin`, `putin` +- **China**: `china`, `beijing`, `xi jinping`, `prc` +- **Taiwan**: `taiwan`, `taipei` + +This enables attribution of news and events to specific countries even when formal country codes aren't present in the source data. + +--- + +## Geographic Convergence Detection + +One of the most valuable intelligence signals is when **multiple independent data streams converge on the same geographic area**. This often precedes significant events. + +### How It Works + +The system maintains a real-time grid of geographic cells (1° × 1° resolution). Each cell tracks four event types: + +| Event Type | Source | Detection Method | +|------------|--------|-----------------| +| **Protests** | ACLED/GDELT | Direct geolocation | +| **Military Flights** | OpenSky | ADS-B position | +| **Naval Vessels** | AIS stream | Ship position | +| **Earthquakes** | USGS | Epicenter location | + +When **3 or more different event types** occur within the same cell during a 24-hour window, a **convergence alert** is generated. + +### Convergence Scoring + +``` +type_score = event_types × 25 # Max 100 (4 types) +count_boost = min(25, total_events × 2) +convergence_score = min(100, type_score + count_boost) +``` + +### Alert Thresholds + +| Types Converging | Score Range | Alert Level | +|-----------------|-------------|-------------| +| **4 types** | 80-100 | Critical | +| **3 types** | 60-80 | High | +| **3 types** (low count) | 40-60 | Medium | + +### 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) +- Signal: Multiple activity streams converging on region + +### Why This Matters + +Individual data points are often noise. But when **protests break out, military assets reposition, and seismic monitors detect anomalies** in the same location simultaneously, it warrants attention—regardless of whether any single source is reporting a crisis. + +--- + +## Infrastructure Cascade Analysis + +Critical infrastructure is interdependent. A cable cut doesn't just affect connectivity—it creates cascading effects across dependent countries and systems. The cascade analysis system visualizes these dependencies. + +### Dependency Graph + +The system builds a graph of **279 infrastructure nodes** and **280 dependency edges**: + +| Node Type | Count | Examples | +|-----------|-------|----------| +| **Undersea Cables** | 18 | MAREA, FLAG Europe-Asia, SEA-ME-WE 6 | +| **Pipelines** | 88 | Nord Stream, Trans-Siberian, Keystone | +| **Ports** | 61 | Singapore, Rotterdam, Shenzhen | +| **Chokepoints** | 8 | Suez, Hormuz, Malacca | +| **Countries** | 105 | End nodes representing national impact | + +### Cascade Calculation + +When a user selects an infrastructure asset for analysis, a **breadth-first cascade** propagates through the graph: + +``` +1. Start at source node (e.g., "cable:marea") +2. For each dependent node: + impact = edge_strength × disruption_level × (1 - redundancy) +3. Categorize impact: + - Critical: impact > 0.8 + - High: impact > 0.5 + - Medium: impact > 0.2 + - Low: impact ≤ 0.2 +4. Recurse to depth 3 (prevent infinite loops) +``` + +### 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 + +### Example Analysis + +**MAREA Cable Disruption**: +``` +Source: MAREA (US ↔ Spain, 200 Tbps) +Countries Affected: 4 +- Spain: Medium (redundancy via other Atlantic cables) +- Portugal: Low (secondary landing) +- France: Low (alternative routes via UK) +- US: Low (high redundancy) +Alternative Routes: TAT-14 (35%), Hibernia (22%), AEConnect (18%) +``` + +**FLAG Europe-Asia Disruption**: +``` +Source: FLAG Europe-Asia (UK ↔ Japan) +Countries Affected: 7 +- India: Medium (major capacity share) +- UAE, Saudi Arabia: Medium (limited alternatives) +- UK, Japan: Low (high redundancy) +Alternative Routes: SEA-ME-WE 6 (11%), 2Africa (8%), Falcon (8%) +``` + +### Use Cases + +- **Pre-positioning**: Understand which countries are most vulnerable to specific infrastructure failures +- **Risk Assessment**: Evaluate supply chain exposure to chokepoint disruptions +- **Incident Response**: Quickly identify downstream effects of reported cable cuts or pipeline damage + +--- + +## Undersea Cable Activity Monitoring + +The dashboard monitors real-time cable operations and advisories from official maritime warning systems, providing early warning of potential connectivity disruptions. + +### Data Sources + +| Source | Coverage | Data Type | +|--------|----------|-----------| +| **NGA Warnings** | Global | NAVAREA maritime warnings | +| **Cable Operators** | Route-specific | Maintenance advisories | + +### How It Works + +The system parses NGA (National Geospatial-Intelligence Agency) maritime warnings for cable-related activity: + +1. **Keyword filtering**: Warnings containing "CABLE", "CABLESHIP", "SUBMARINE CABLE", "FIBER OPTIC" are extracted +2. **Coordinate parsing**: DMS and decimal coordinates are extracted from warning text +3. **Cable matching**: Coordinates are matched to nearest cable routes within 5° radius +4. **Severity classification**: Keywords like "FAULT", "BREAK", "DAMAGE" indicate faults; others indicate maintenance + +### Alert Types + +| Type | Trigger | Map Display | +|------|---------|-------------| +| **Cable Advisory** | Any cable-related NAVAREA warning | ⚠ Yellow marker at location | +| **Repair Ship** | Cableship name detected in warning | 🚢 Ship icon with status | + +### 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 +- **Associated cable**: Nearest cable route + +This enables monitoring of ongoing repair operations before official carrier announcements. + +### 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 +- Create economic cascading effects + +Early visibility into cable operations—even maintenance windows—provides advance warning for contingency planning. + +--- + +## Strategic Risk Overview + +The Strategic Risk Overview provides a **composite dashboard** that synthesizes all intelligence modules into a single risk assessment. + +### Composite Score (0-100) + +The strategic risk score combines three components: + +| Component | Weight | Calculation | +|-----------|--------|-------------| +| **Convergence** | 40% | `min(100, convergence_zones × 20)` | +| **CII Deviation** | 35% | `min(100, avg_deviation × 2)` | +| **Infrastructure** | 25% | `min(100, incidents × 25)` | + +### Risk Levels + +| Score | Level | Trend Icon | Meaning | +|-------|-------|------------|---------| +| 70-100 | **Critical** | 📈 Escalating | Multiple converging crises | +| 50-69 | **Elevated** | ➡️ Stable | Heightened global tension | +| 30-49 | **Moderate** | ➡️ Stable | Normal fluctuation | +| 0-29 | **Low** | 📉 De-escalating | Unusually quiet period | + +### Unified Alert System + +Alerts from all modules are merged using **temporal and spatial deduplication**: + +- **Time window**: Alerts within 2 hours may be merged +- **Distance threshold**: Alerts within 200km may be merged +- **Same country**: Alerts affecting the same country may be merged + +When alerts merge, they become **composite alerts** that show the full picture: + +``` +Type: Composite Alert +Title: Convergence + CII + Infrastructure: Ukraine +Components: + - Geographic Convergence: 4 event types in Kyiv region + - CII Spike: Ukraine +15 points (Critical) + - Infrastructure: Black Sea cables at risk +Priority: Critical +``` + +### Alert Priority + +| Priority | Criteria | +|----------|----------| +| **Critical** | CII critical level, convergence score ≥80, cascade critical impact | +| **High** | CII high level, convergence score ≥60, cascade affecting ≥5 countries | +| **Medium** | CII change ≥10 points, convergence score ≥40 | +| **Low** | Minor changes and low-impact events | + +### 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 + +--- + +## Pentagon Pizza Index (PizzINT) + +The dashboard integrates real-time foot traffic data from strategic locations near government and military facilities. This "Pizza Index" concept—tracking late-night activity spikes at restaurants near the Pentagon, Langley, and other facilities—provides an unconventional indicator of crisis activity. + +### How It Works + +The system aggregates percentage-of-usual metrics from monitored locations: + +1. **Locations**: Fast food, pizza shops, and convenience stores near Pentagon, CIA, NSA, State Dept, and other facilities +2. **Aggregation**: Activity percentages are averaged, capped at 100% +3. **Spike Detection**: Locations exceeding their baseline are flagged + +### DEFCON-Style Alerting + +Aggregate activity maps to a 5-level readiness scale: + +| Level | Threshold | Label | Meaning | +|-------|-----------|-------|---------| +| **DEFCON 1** | ≥90% | COCKED PISTOL | Maximum readiness; crisis response active | +| **DEFCON 2** | ≥75% | FAST PACE | High activity; significant event underway | +| **DEFCON 3** | ≥50% | ROUND HOUSE | Elevated; above-normal operations | +| **DEFCON 4** | ≥25% | DOUBLE TAKE | Increased vigilance | +| **DEFCON 5** | <25% | FADE OUT | Normal peacetime operations | + +### GDELT Tension Pairs + +The indicator also displays geopolitical tension scores from GDELT (Global Database of Events, Language, and Tone): + +| Pair | Monitored Relationship | +|------|----------------------| +| USA ↔ Russia | Primary nuclear peer adversary | +| USA ↔ China | Economic and military competition | +| USA ↔ Iran | Middle East regional tensions | +| Israel ↔ Iran | Direct conflict potential | +| China ↔ Taiwan | Cross-strait relations | +| 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 + +This provides context for the activity levels—a spike at Pentagon locations during a rising China-Taiwan tension score carries different weight than during a quiet period. + +--- + +## Related Assets + +News clusters are automatically enriched with nearby critical infrastructure. When a story mentions a geographic region, the system identifies relevant assets within 600km, providing immediate operational context. + +### Asset Types + +| Type | Source | Examples | +|------|--------|----------| +| **Pipelines** | 88 global routes | Nord Stream, Keystone, Trans-Siberian | +| **Undersea Cables** | 55 major cables | TAT-14, SEA-ME-WE, Pacific Crossing | +| **AI Datacenters** | 111 clusters (≥10k GPUs) | Azure East US, GCP Council Bluffs | +| **Military Bases** | 220+ installations | Ramstein, Diego Garcia, Guam | +| **Nuclear Facilities** | 100+ sites | Power plants, weapons labs, enrichment | + +### Location Inference + +The system infers the geographic focus of news stories through: + +1. **Keyword matching**: Headlines are scanned against hotspot keyword lists (e.g., "Taiwan" → Taiwan Strait hotspot) +2. **Confidence scoring**: Multiple keyword matches increase location confidence +3. **Fallback to conflicts**: If no hotspot matches, active conflict zones are checked + +### Distance Calculation + +Assets are ranked by Haversine distance from the inferred location: + +``` +d = 2r × arcsin(√(sin²(Δφ/2) + cos(φ₁) × cos(φ₂) × sin²(Δλ/2))) +``` + +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) + +Clicking an asset zooms the map to its location and displays detailed information. + +--- + +## Custom Monitors + +Create personalized keyword alerts that scan all incoming news: + +1. Enter comma-separated keywords (e.g., "nvidia, gpu, chip shortage") +2. System assigns a unique color +3. Matching articles are highlighted in the Monitor panel +4. Matching articles in clusters inherit the monitor color + +Monitors persist across sessions via LocalStorage. + +--- + +## Activity Tracking + +The dashboard highlights newly-arrived items so you can quickly identify what changed since your last look. + +### Visual Indicators + +| Indicator | Duration | Purpose | +|-----------|----------|---------| +| **NEW tag** | 2 minutes | Badge on items that just appeared | +| **Glow highlight** | 30 seconds | Subtle animation drawing attention | +| **Panel badge** | Until viewed | Count of new items in collapsed panels | + +### Automatic "Seen" Detection + +The system uses IntersectionObserver to detect when panels become visible: + +- When a panel is >50% visible for >500ms, items are marked as "seen" +- Scrolling through a panel marks visible items progressively +- Switching panels resets the "new" state appropriately + +### Panel-Specific Tracking + +Each panel maintains independent activity state: + +- **News**: New clusters since last view +- **Markets**: Price changes exceeding thresholds +- **Predictions**: Probability shifts >5% +- **Natural Events**: New earthquakes and EONET events + +This enables focused monitoring—you can collapse panels you've reviewed and see at a glance which have new activity. + +--- + +## Snapshot System + +The dashboard captures periodic snapshots for historical analysis: + +- **Automatic capture** every refresh cycle +- **7-day retention** with automatic cleanup +- **Stored data**: news clusters, market prices, prediction values, hotspot levels +- **Playback**: Load historical snapshots to see past dashboard states + +Baselines (7-day and 30-day averages) are stored in IndexedDB for deviation analysis. + +--- + +## Maritime Intelligence + +The Ships layer provides real-time vessel tracking and maritime domain awareness through AIS (Automatic Identification System) data. + +### Chokepoint Monitoring + +The system monitors eight critical maritime chokepoints where disruptions could impact global trade: + +| Chokepoint | Strategic Importance | +|------------|---------------------| +| **Strait of Hormuz** | 20% of global oil transits; Iran control | +| **Suez Canal** | Europe-Asia shipping; single point of failure | +| **Strait of Malacca** | Primary Asia-Pacific oil route | +| **Bab el-Mandeb** | Red Sea access; Yemen/Houthi activity | +| **Panama Canal** | Americas east-west transit | +| **Taiwan Strait** | Semiconductor supply chain; PLA activity | +| **South China Sea** | Contested waters; island disputes | +| **Black Sea** | Ukraine grain exports; Russian naval activity | + +### 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 + +Density changes of ±30% trigger alerts, indicating potential congestion, diversions, or blockades. + +### 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 +- Equipment failure + +Vessels reappearing after gaps are flagged for the duration of the session. + +### WebSocket Architecture + +AIS data flows through a WebSocket relay for real-time updates without polling: + +``` +AISStream → WebSocket Relay → Browser + (ws://relay) +``` + +The connection automatically reconnects on disconnection with a 30-second backoff. When the Ships layer is disabled, the WebSocket disconnects to conserve resources. + +### Railway Relay Architecture + +Some APIs block requests from cloud providers (Vercel, AWS, Cloudflare Workers). A Railway relay server provides authenticated access: + +``` +Browser → Railway Relay → External APIs + (Node.js) (AIS, OpenSky, RSS) +``` + +**Relay Functions**: + +| Endpoint | Purpose | Authentication | +|----------|---------|----------------| +| `/` (WebSocket) | AIS vessel stream | AISStream API key | +| `/opensky` | Military aircraft | OAuth2 Bearer token | +| `/rss` | Blocked RSS feeds | None (user-agent spoofing) | +| `/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 +- Free tier sufficient for moderate traffic + +The relay is stateless—it simply authenticates and proxies requests. All caching and processing happens client-side or in Vercel Edge Functions. + +--- + +## Military Tracking + +The Military layer provides specialized tracking of military vessels and aircraft, identifying assets by their transponder characteristics and monitoring activity patterns. + +### Military Vessel Identification + +Vessels are identified as military through multiple methods: + +**MMSI Analysis**: Maritime Mobile Service Identity numbers encode the vessel's flag state. The system maintains a mapping of 150+ country codes to identify naval vessels: + +| MID Range | Country | Notes | +|-----------|---------|-------| +| 338-339 | USA | US Navy, Coast Guard | +| 273 | Russia | Russian Navy | +| 412-414 | China | PLAN vessels | +| 232-235 | UK | Royal Navy | +| 226-228 | France | Marine Nationale | + +**Known Vessel Database**: A curated database of 50+ named vessels enables positive identification when AIS transmits vessel names: + +| Category | Tracked Vessels | +|----------|-----------------| +| **US Carriers** | All 11 Nimitz/Ford-class (CVN-68 through CVN-78) | +| **UK Carriers** | HMS Queen Elizabeth (R08), HMS Prince of Wales (R09) | +| **Chinese Carriers** | Liaoning (16), Shandong (17), Fujian (18) | +| **Russian Carrier** | Admiral Kuznetsov | +| **Notable Destroyers** | USS Zumwalt (DDG-1000), HMS Defender (D36), HMS Duncan (D37) | +| **Research/Intel** | USNS Victorious (T-AGOS-19), USNS Impeccable (T-AGOS-23), Yuan Wang | + +**Vessel Classification Algorithm**: + +1. Check vessel name against known database (hull numbers and ship names) +2. Fall back to AIS ship type code if name match fails +3. Apply MMSI pattern matching for country/operator identification +4. For naval-prefix vessels (USS, HMS, HMCS, HMAS, INS, JS, ROKS, TCG), infer military status + +**Callsign Patterns**: Known military callsign prefixes (NAVY, GUARD, etc.) provide secondary identification. + +### Naval Chokepoint Monitoring + +The system monitors 12 critical maritime chokepoints with configurable detection radii: + +| Chokepoint | Strategic Significance | +|------------|----------------------| +| Strait of Hormuz | Persian Gulf access, oil transit | +| Suez Canal | Mediterranean-Red Sea link | +| Strait of Malacca | Pacific-Indian Ocean route | +| Taiwan Strait | Cross-strait tensions | +| Bosphorus | Black Sea access | +| GIUK Gap | North Atlantic submarine route | + +When military vessels enter these zones, proximity alerts are generated. + +### Naval Base Proximity + +Activity near 12 major naval installations is tracked: + +- **Norfolk** (USA) - Atlantic Fleet headquarters +- **Pearl Harbor** (USA) - Pacific Fleet base +- **Sevastopol** (Russia) - Black Sea Fleet +- **Qingdao** (China) - North Sea Fleet +- **Yokosuka** (Japan) - US 7th Fleet + +Vessels within 50km of these bases are flagged, enabling detection of unusual activity patterns. + +### Aircraft Tracking (OpenSky) + +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 + +### Vessel Position History + +The system maintains position trails for tracked vessels: + +- **30-point history** per MMSI +- **10-minute cleanup interval** for stale data +- **Trail visualization** on map for recent movement + +This enables detection of loitering, circling, or other anomalous behavior patterns. + +### Military Surge Detection + +The system continuously monitors military aircraft activity to detect **surge events**—significant increases above normal operational baselines that may indicate mobilization, exercises, or crisis response. + +**Theater Classification** + +Military activity is analyzed across five geographic theaters: + +| Theater | Coverage | Key Areas | +|---------|----------|-----------| +| **Middle East** | Persian Gulf, Levant, Arabian Peninsula | US CENTCOM activity, Iranian airspace | +| **Eastern Europe** | Ukraine, Baltics, Black Sea | NATO-Russia border activity | +| **Western Europe** | Central Europe, North Sea | NATO exercises, air policing | +| **Pacific** | East Asia, Southeast Asia | Taiwan Strait, Korean Peninsula | +| **Horn of Africa** | Red Sea, East Africa | Counter-piracy, Houthi activity | + +**Aircraft Classification** + +Aircraft are categorized by callsign pattern matching: + +| Type | Callsign Patterns | Significance | +|------|-------------------|--------------| +| **Transport** | RCH, REACH, MOOSE, HERKY, EVAC, DUSTOFF | Airlift operations, troop movement | +| **Fighter** | VIPER, EAGLE, RAPTOR, STRIKE | Combat air patrol, interception | +| **Reconnaissance** | SIGNT, COBRA, RIVET, JSTARS | Intelligence gathering | + +**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 + +**Surge Detection Algorithm** + +``` +surge_ratio = current_count / baseline +surge_triggered = ( + ratio ≥ 2.0 AND + transport ≥ 5 AND + fighters ≥ 4 +) +``` + +**Surge Signal Output** + +When a surge is detected, the system generates a `military_surge` signal: + +| Field | Content | +|-------|---------| +| **Location** | Theater centroid coordinates | +| **Message** | "Military Transport Surge in [Theater]: [X] aircraft (baseline: [Y])" | +| **Details** | Aircraft types, nearby bases (150km radius), top callsigns | +| **Confidence** | Based on surge ratio (0.6–0.9) | + +### Foreign Military Presence Detection + +Beyond surge detection, the system monitors for **foreign military aircraft in sensitive regions**—situations where aircraft from one nation appear in geopolitically significant areas outside their normal operating range. + +**Sensitive Regions** + +The system tracks 18 strategically significant geographic areas: + +| Region | Sensitivity | Monitored For | +|--------|-------------|---------------| +| **Taiwan Strait** | Critical | PLAAF activity, US transits | +| **Persian Gulf** | Critical | Iranian, US, Gulf state activity | +| **Baltic Sea** | High | Russian activity near NATO | +| **Black Sea** | High | NATO reconnaissance, Russian activity | +| **South China Sea** | High | PLAAF patrols, US FONOPs | +| **Korean Peninsula** | High | DPRK activity, US-ROK exercises | +| **Eastern Mediterranean** | Medium | Russian naval aviation, NATO | +| **Arctic** | Medium | Russian bomber patrols | + +**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) +4. Applies concentration thresholds (typically 2-3 aircraft per operator) + +**Critical Combinations** + +Certain operator-region combinations trigger **critical severity** alerts: + +| Operator | Region | Rationale | +|----------|--------|-----------| +| PLAAF | Taiwan Strait | Potential invasion rehearsal | +| Russian VKS | Arctic | Nuclear bomber patrols | +| USAF | Persian Gulf | Potential strike package | + +**Signal Output** + +Foreign presence detection generates a `foreign_military_presence` signal: + +| Field | Content | +|-------|---------| +| **Title** | "Foreign Military Presence: [Region]" | +| **Details** | "[Operator] aircraft detected: [count] [types]" | +| **Severity** | Critical/High/Medium based on combination | +| **Confidence** | 0.7–0.95 based on aircraft count and type diversity | + +--- + +## Aircraft Enrichment + +Military aircraft tracking is enhanced with **Wingbits** enrichment data, providing detailed aircraft information that goes beyond basic transponder data. + +### What Wingbits Provides + +When an aircraft is detected via OpenSky ADS-B, the system queries Wingbits for: + +| Field | Description | Use Case | +|-------|-------------|----------| +| **Registration** | Aircraft tail number (e.g., N12345) | Unique identification | +| **Owner** | Legal owner of the aircraft | Military branch detection | +| **Operator** | Operating entity | Distinguish military vs. contractor | +| **Manufacturer** | Boeing, Lockheed Martin, etc. | Aircraft type classification | +| **Model** | Specific aircraft model | Capability assessment | +| **Built Year** | Year of manufacture | Fleet age analysis | + +### Military Classification Algorithm + +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 +- European: Eurofighter, Typhoon, Rafale, Tornado, Gripen + +### Confidence Levels + +Each enriched aircraft receives a confidence classification: + +| Level | Criteria | Display | +|-------|----------|---------| +| **Confirmed** | Direct military owner/operator match | Green badge | +| **Likely** | Military ICAO code or aircraft type | Yellow badge | +| **Possible** | Defense contractor ownership | Gray badge | +| **Civilian** | No military indicators | No badge | + +### Caching Strategy + +Aircraft details rarely change, so aggressive caching reduces API load: + +- **Server-side**: HTTP Cache-Control headers (24-hour max-age) +- **Client-side**: 1-hour local cache per aircraft +- **Batch optimization**: Up to 20 aircraft per API call + +This means an aircraft's details are fetched at most once per day, regardless of how many times it appears on the map. + +--- + +## Space Launch Infrastructure + +The Spaceports layer displays global launch facilities for monitoring space-related activity and supply chain implications. + +### Tracked Launch Sites + +| Site | Country | Operator | Activity Level | +|------|---------|----------|----------------| +| **Kennedy Space Center** | USA | NASA/Space Force | High | +| **Vandenberg SFB** | USA | US Space Force | Medium | +| **Starbase** | USA | SpaceX | High | +| **Baikonur Cosmodrome** | Kazakhstan | Roscosmos | Medium | +| **Plesetsk Cosmodrome** | Russia | Roscosmos/Military | Medium | +| **Vostochny Cosmodrome** | Russia | Roscosmos | Low | +| **Jiuquan SLC** | China | CNSA | High | +| **Xichang SLC** | China | CNSA | High | +| **Wenchang SLC** | China | CNSA | Medium | +| **Guiana Space Centre** | France | ESA/CNES | Medium | +| **Satish Dhawan SC** | India | ISRO | Medium | +| **Tanegashima SC** | Japan | JAXA | Low | + +### 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 +- **Incident correlation**: News about space debris, failed launches, or policy changes + +--- + +## Critical Mineral Deposits + +The Minerals layer displays strategic mineral extraction sites essential for modern technology and defense supply chains. + +### Tracked Resources + +| Mineral | Strategic Importance | Major Producers | +|---------|---------------------|-----------------| +| **Lithium** | EV batteries, energy storage | Australia, Chile, China | +| **Cobalt** | Battery cathodes, superalloys | DRC (60%+ global), Australia | +| **Rare Earths** | Magnets, electronics, defense | China (60%+ global), Australia, USA | + +### Key Sites + +| Site | Mineral | Country | Significance | +|------|---------|---------|--------------| +| Greenbushes | Lithium | Australia | World's largest hard-rock lithium mine | +| Salar de Atacama | Lithium | Chile | Largest brine lithium source | +| Mutanda | Cobalt | DRC | World's largest cobalt mine | +| Tenke Fungurume | Cobalt | DRC | Major Chinese-owned cobalt source | +| Bayan Obo | Rare Earths | China | 45% of global REE production | +| Mountain Pass | Rare Earths | USA | Only active US rare earth mine | + +### 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 + +News about these regions or mining companies can signal supply disruptions affecting technology and defense sectors. + +--- + +## Cyber Threat Actors (APT Groups) + +The map displays geographic attribution markers for major state-sponsored Advanced Persistent Threat (APT) groups. These markers show the approximate operational centers of known threat actors. + +### Tracked Groups + +| Group | Aliases | Sponsor | Notable Activity | +|-------|---------|---------|-----------------| +| **APT28/29** | Fancy Bear, Cozy Bear | Russia (GRU/FSB) | Election interference, government espionage | +| **APT41** | Double Dragon | China (MSS) | Supply chain attacks, intellectual property theft | +| **Lazarus** | Hidden Cobra | North Korea (RGB) | Financial theft, cryptocurrency heists | +| **APT33/35** | Elfin, Charming Kitten | Iran (IRGC) | Critical infrastructure, aerospace targeting | + +### Why This Matters + +Cyber operations often correlate with geopolitical tensions. When news reports reference Russian cyber activity during a Ukraine escalation, or Iranian hacking during Middle East tensions, these markers provide geographic context for the threat landscape. + +### 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** + +--- + +## Social Unrest Tracking + +The Protests layer aggregates civil unrest data from two independent sources, providing corroboration and global coverage. + +### 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 +- **Confidence**: High (human-curated) + +### 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) +- **Confidence**: Medium (algorithmic extraction) + +### 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 + +### Severity Classification + +| Severity | Criteria | +|----------|----------| +| **High** | Fatalities reported, riots, or clashes | +| **Medium** | Large demonstrations, strikes | +| **Low** | Smaller protests, localized events | + +Events near intelligence hotspots are cross-referenced to provide geopolitical context. + +### Map Display Filtering + +To reduce visual clutter and focus attention on significant events, the map displays only **high-severity protests and riots**: + +| Displayed | Event Type | Visual | +|-----------|------------|--------| +| ✅ Yes | Riot | Bright red marker | +| ✅ Yes | High-severity protest | Red marker | +| ❌ No | Medium/low-severity protest | Not shown on map | + +Lower-severity events are still tracked for CII scoring and data exports—they simply don't create map markers. This filtering prevents dense urban areas (which naturally generate more low-severity demonstrations) from overwhelming the map display. + +--- + +## Aviation Monitoring + +The Flights layer tracks airport delays and ground stops at major US airports using FAA NASSTATUS data. + +### Delay Types + +| Type | Description | +|------|-------------| +| **Ground Stop** | No departures permitted; severe disruption | +| **Ground Delay** | Departures held; arrival rate limiting | +| **Arrival Delay** | Inbound traffic backed up | +| **Departure Delay** | Outbound traffic delayed | + +### Severity Thresholds + +| Severity | Average Delay | Visual | +|----------|--------------|--------| +| **Severe** | ≥60 minutes | Red | +| **Major** | 45-59 minutes | Orange | +| **Moderate** | 25-44 minutes | Yellow | +| **Minor** | 15-24 minutes | Gray | + +### 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 + +Ground stops are particularly significant—they indicate severe disruption (weather, security, or infrastructure failure) and can cascade across the network. + +--- + +## Security & Input Validation + +The dashboard handles untrusted data from dozens of external sources. Defense-in-depth measures prevent injection attacks and API abuse. + +### XSS Prevention + +All user-visible content is sanitized before DOM insertion: + +```typescript +escapeHtml(str) // Encodes & < > " ' as HTML entities +sanitizeUrl(url) // Allows only http/https protocols +``` + +This applies to: + +- News headlines and sources (RSS feeds) +- Search results and highlights +- Monitor keywords (user input) +- Map popup content +- Tension pair labels + +The `` highlighting in search escapes text *before* wrapping matches, preventing injection via crafted search queries. + +### Proxy Endpoint Validation + +Serverless proxy functions validate and clamp all parameters: + +| Endpoint | Validation | +|----------|------------| +| `/api/yahoo-finance` | Symbol format `[A-Za-z0-9.^=-]`, max 20 chars | +| `/api/coingecko` | Coin IDs alphanumeric+hyphen, max 20 IDs | +| `/api/polymarket` | Order field allowlist, limit clamped 1-100 | + +This prevents upstream API abuse and rate limit exhaustion from malformed requests. + +### Content Security + +- URLs are validated via `URL()` constructor—only `http:` and `https:` protocols are permitted +- External links use `rel="noopener"` to prevent reverse tabnapping +- No inline scripts or `eval()`—all code is bundled at build time + +--- + +## Fault Tolerance + +External APIs are unreliable. Rate limits, outages, and network errors are inevitable. The system implements **circuit breaker** patterns to maintain availability. + +### Circuit Breaker Pattern + +Each external service is wrapped in a circuit breaker that tracks failures: + +``` +Normal → Failure #1 → Failure #2 → OPEN (cooldown) + ↓ + 5 minutes pass + ↓ + CLOSED +``` + +**Behavior during cooldown:** + +- New requests return cached data (if available) +- UI shows "temporarily unavailable" status +- No API calls are made (prevents hammering) + +### Protected Services + +| Service | Cooldown | Cache TTL | +|---------|----------|-----------| +| Yahoo Finance | 5 min | 10 min | +| Polymarket | 5 min | 10 min | +| USGS Earthquakes | 5 min | 10 min | +| NWS Weather | 5 min | 10 min | +| FRED Economic | 5 min | 10 min | +| Cloudflare Radar | 5 min | 10 min | +| ACLED | 5 min | 10 min | +| GDELT | 5 min | 10 min | +| FAA Status | 5 min | 5 min | +| RSS Feeds | 5 min per feed | 10 min | + +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 +4. No user intervention required + +--- + +## System Health Monitoring + +The status panel (accessed via the health indicator in the header) provides real-time visibility into data source status and system health. + +### Health Indicator + +The header displays a system health badge: + +| State | Visual | Meaning | +|-------|--------|---------| +| **Healthy** | Green dot | All data sources operational | +| **Degraded** | Yellow dot | Some sources in cooldown | +| **Unhealthy** | Red dot | Multiple sources failing | + +Click the indicator to expand the full status panel. + +### Data Source Status + +The status panel lists all data feeds with their current state: + +| Status | Icon | Description | +|--------|------|-------------| +| **Active** | ● Green | Fetching data normally | +| **Cooldown** | ● Yellow | Temporarily paused (circuit breaker) | +| **Disabled** | ○ Gray | Layer not enabled | +| **Error** | ● Red | Persistent failure | + +### 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 +- **Cooldown remaining** - Time until circuit breaker resets (if in cooldown) + +### 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? + +This transparency enables informed interpretation of the dashboard data. + +--- + +## Data Freshness Tracking + +Beyond simple "online/offline" status, the system tracks fine-grained freshness for each data source to indicate data reliability and staleness. + +### Freshness Levels + +| Status | Color | Criteria | Meaning | +|--------|-------|----------|---------| +| **Fresh** | Green | Updated within expected interval | Data is current | +| **Aging** | Yellow | 1-2× expected interval elapsed | Data may be slightly stale | +| **Stale** | Orange | 2-4× expected interval elapsed | Data is outdated | +| **Critical** | Red | >4× expected interval elapsed | Data unreliable | +| **Disabled** | Gray | Layer toggled off | Not fetching | + +### Source-Specific Thresholds + +Each data source has calibrated freshness expectations: + +| Source | Expected Interval | "Fresh" Threshold | +|--------|------------------|-------------------| +| News feeds | 5 minutes | <10 minutes | +| Stock quotes | 1 minute | <5 minutes | +| Earthquakes | 5 minutes | <15 minutes | +| Weather | 10 minutes | <30 minutes | +| Flight delays | 10 minutes | <20 minutes | +| AIS vessels | Real-time | <1 minute | + +### 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 + +### 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 + +This visibility enables appropriate confidence calibration when interpreting the dashboard. + +### Core vs. Optional Sources + +Data sources are classified by their importance to risk assessment: + +| Classification | Sources | Impact | +|----------------|---------|--------| +| **Core** | GDELT, RSS feeds | Required for meaningful risk scores | +| **Optional** | ACLED, Military, AIS, Weather, Economic | Enhance but not required | + +The Strategic Risk Overview panel adapts its display based on core source availability: + +| Status | Display Mode | Behavior | +|--------|--------------|----------| +| **Sufficient** | Full data view | All metrics shown with confidence | +| **Limited** | Limited data view | Shows "Limited Data" warning banner | +| **Insufficient** | Insufficient data view | "Insufficient Data" message, no risk score | + +### Freshness-Aware Risk Assessment + +The composite risk score is adjusted based on data freshness: + +``` +If core sources fresh: + → Full confidence in risk score + → "All data sources active" indicator + +If core sources stale: + → Display warning: "Limited Data - [active sources]" + → Score shown but flagged as potentially unreliable + +If core sources unavailable: + → "Insufficient data for risk assessment" + → No score displayed +``` + +This prevents false "all clear" signals when the system actually lacks data to make that determination. + +--- + +## Conditional Data Loading + +API calls are expensive. The system only fetches data for **enabled layers**, reducing unnecessary network traffic and rate limit consumption. + +### 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 + +### 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 + +This prevents confusion when deploying without full API access. + +--- + +## Performance Optimizations + +The dashboard processes thousands of data points in real-time. Several techniques keep the UI responsive even with heavy data loads. + +### Web Worker for Analysis + +CPU-intensive operations run in a dedicated Web Worker to avoid blocking the main thread: + +| Operation | Complexity | Worker? | +|-----------|------------|---------| +| News clustering (Jaccard) | O(n²) | ✅ Yes | +| Correlation detection | O(n × m) | ✅ Yes | +| 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 +- **Automatic cleanup**: Terminates worker on fatal errors + +### Virtual Scrolling + +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 + +This reduces DOM node count from thousands to ~30, dramatically improving scroll performance. + +### 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 + +### 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 + +--- + +## Prediction Market Filtering + +The Prediction Markets panel focuses on **geopolitically relevant** markets, filtering out sports and entertainment. + +### 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 +- **Economics**: Fed, interest rate, inflation, recession, tariffs, sanctions +- **Global**: UN, EU, treaties, summits, coups, refugees + +### Exclusion Keywords + +Markets matching these are filtered out: + +- **Sports**: NBA, NFL, FIFA, World Cup, championships, playoffs +- **Entertainment**: Oscars, movies, celebrities, TikTok, streaming + +This ensures the panel shows markets like "Will Russia withdraw from Ukraine?" rather than "Will the Lakers win the championship?" + +--- + +## Panel Management + +The dashboard organizes data into **draggable, collapsible panels** that persist user preferences across sessions. + +### Drag-to-Reorder + +Panels can be reorganized by dragging: + +1. Grab the panel header (grip icon appears on hover) +2. Drag to desired position +3. Drop to reorder +4. New order saves automatically to LocalStorage + +This enables personalized layouts—put your most-watched panels at the top. + +### Panel Visibility + +Toggle panels on/off via the Settings menu (⚙): + +- **Hidden panels**: Don't render, don't fetch data +- **Visible panels**: Full functionality +- **Collapsed panels**: Header only, data still refreshes + +Hiding a panel is different from disabling a layer—the panel itself doesn't appear in the interface. + +### Default Panel Order + +Panels are organized by intelligence priority: + +| Priority | Panels | Purpose | +|----------|--------|---------| +| **Critical** | Strategic Risk, Live Intel | Immediate situational awareness | +| **Primary** | News, CII, Markets | Core monitoring data | +| **Supporting** | Predictions, Economic, Monitor | Supplementary analysis | +| **Reference** | Live News Video | Background context | + +### Persistence + +Panel state survives browser restarts: + +- **LocalStorage**: Panel order, visibility, collapsed state +- **Automatic save**: Changes persist immediately +- **Per-device**: Settings are browser-specific (not synced) + +--- + +## Mobile Experience + +The dashboard is optimized for mobile devices with a streamlined interface that prioritizes usability on smaller screens. + +### First-Time Mobile Welcome + +When accessing the dashboard on a mobile device for the first time, a welcome modal explains the mobile-optimized experience: + +- **Simplified view notice** - Informs users they're seeing a curated mobile version +- **Navigation tip** - Explains regional view buttons and marker interaction +- **"Don't show again" option** - Checkbox to skip on future visits (persisted to localStorage) + +### Mobile-First Design + +On screens narrower than 768px or touch devices: + +- **Compact map** - Reduced height (40vh) to show more panels +- **Single-column layout** - Panels stack vertically for easy scrolling +- **Hidden map labels** - All marker labels are hidden to reduce visual clutter +- **Fixed layer set** - Layer toggle buttons are hidden; a curated set of layers is enabled by default +- **Simplified controls** - Map resize handle and pin button are hidden +- **Touch-optimized markers** - Expanded touch targets (44px) for easy tapping +- **Hidden DEFCON indicator** - Pentagon Pizza Index hidden to reduce header clutter +- **Hidden FOCUS selector** - Regional focus buttons hidden (use preset views instead) +- **Compact header** - Social link shows X logo instead of username text + +### Mobile Default Layers + +The mobile experience focuses on the most essential intelligence layers: + +| Layer | Purpose | +|-------|---------| +| **Conflicts** | Active conflict zones | +| **Hotspots** | Intelligence hotspots with activity levels | +| **Sanctions** | Countries under economic sanctions | +| **Outages** | Network disruptions | +| **Natural** | Earthquakes, storms, wildfires | +| **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 +- Protests, economic centers + +This curated set provides situational awareness without overwhelming the interface or consuming excessive data/battery. + +### Touch Gestures + +Map navigation supports: + +- **Pinch zoom** - Two-finger zoom in/out +- **Drag pan** - Single-finger map movement +- **Tap markers** - Show popup (replaces hover) +- **Double-tap** - Quick zoom + +### Performance Considerations + +Mobile optimizations reduce resource consumption: + +| Optimization | Benefit | +|--------------|---------| +| Fewer layers | Reduced API calls, lower battery usage | +| No labels | Faster rendering, cleaner interface | +| Hidden controls | More screen space for content | +| Simplified header | Reduced visual processing | + +### Desktop Experience + +On larger screens, the full feature set is available: + +- Multi-column responsive panel grid +- All layer toggles accessible +- Map labels visible at appropriate zoom levels +- Resizable map section +- Pinnable map (keeps map visible while scrolling panels) +- Full DEFCON indicator with tension pairs +- FOCUS regional selector for rapid navigation + +--- + +## Energy Flow Detection + +The correlation engine detects signals related to energy infrastructure and commodity markets. + +### Pipeline Keywords + +The system monitors news for pipeline-related events: + +**Infrastructure terms**: pipeline, pipeline explosion, pipeline leak, pipeline attack, pipeline sabotage, pipeline disruption, nord stream, keystone, druzhba + +**Flow indicators**: gas flow, oil flow, supply disruption, transit halt, capacity reduction + +### Flow Drop Signals + +When news mentions flow disruptions, two signal types may trigger: + +| Signal | Criteria | Meaning | +|--------|----------|---------| +| **Flow Drop** | Pipeline keywords + disruption terms | Potential supply interruption | +| **Flow-Price Divergence** | Flow drop news + oil price stable (< $1.50 move) | Markets not yet pricing in disruption | + +### Why This Matters + +Energy supply disruptions create cascading effects: + +1. **Immediate**: Spot price volatility +2. **Short-term**: Industrial production impacts +3. **Long-term**: Geopolitical leverage shifts + +Early detection of flow drops—especially when markets haven't reacted—provides an information edge. + +--- + +## Signal Aggregator + +The Signal Aggregator is the central nervous system that collects, groups, and summarizes intelligence signals from all data sources. + +### What It Aggregates + +| Signal Type | Source | Frequency | +|-------------|--------|-----------| +| `military_flight` | OpenSky ADS-B | Real-time | +| `military_vessel` | AIS WebSocket | Real-time | +| `protest` | ACLED + GDELT | Hourly | +| `internet_outage` | Cloudflare Radar | 5 min | +| `ais_disruption` | AIS analysis | Real-time | + +### Country-Level Grouping + +All signals are grouped by country code, creating a unified view: + +```typescript +{ + country: 'UA', // Ukraine + countryName: 'Ukraine', + totalCount: 15, + highSeverityCount: 3, + signalTypes: Set(['military_flight', 'protest', 'internet_outage']), + signals: [/* all signals for this country */] +} +``` + +### Regional Convergence Detection + +The aggregator identifies geographic convergence—when multiple signal types cluster in the same region: + +| Convergence Level | Criteria | Alert Priority | +|-------------------|----------|----------------| +| **Critical** | 4+ signal types within 200km | Immediate | +| **High** | 3 signal types within 200km | High | +| **Medium** | 2 signal types within 200km | Normal | + +### Summary Output + +The aggregator provides a real-time summary for dashboards and AI context: + +``` +[SIGNAL SUMMARY] +Top Countries: Ukraine (15 signals), Iran (12), Taiwan (8) +Convergence Zones: Baltic Sea (military_flight + military_vessel), + Tehran (protest + internet_outage) +Active Signal Types: 5 of 5 +Total Signals: 47 +``` + +--- + +## Browser-Based Machine Learning + +For offline resilience and reduced API costs, the system includes browser-based ML capabilities using ONNX Runtime Web. + +### Available Models + +| Model | Task | Size | Use Case | +|-------|------|------|----------| +| **T5-small** | Text summarization | ~60MB | Offline briefing generation | +| **DistilBERT** | Sentiment analysis | ~67MB | News tone classification | + +### Fallback Strategy + +Browser ML serves as the final fallback when cloud APIs are unavailable: + +``` +User requests summary + ↓ +1. Try Groq API (fast, free tier) + ↓ (rate limited or error) +2. Try OpenRouter API (fallback provider) + ↓ (unavailable) +3. Use Browser T5 (offline, always available) +``` + +### 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 + +### 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 + +### Limitations + +Browser ML has constraints compared to cloud models: + +| Aspect | Cloud (Llama 3.3) | Browser (T5) | +|--------|-------------------|--------------| +| Context window | 128K tokens | 512 tokens | +| Output quality | High | Moderate | +| Inference speed | 2-3 seconds | 5-10 seconds | +| Offline support | No | Yes | + +Browser summarization is intentionally limited to 6 headlines × 80 characters to stay within model constraints. + +--- + +## Cross-Module Integration + +Intelligence modules don't operate in isolation. Data flows between systems to enable composite analysis. + +### Data Flow Architecture + +``` +News Feeds → Clustering → Velocity Analysis → Hotspot Correlation + ↓ ↓ + Topic Extraction CII Information Score + ↓ ↓ + Keyword Monitors Strategic Risk Overview + ↑ +Military Flights → Near-Hotspot Detection ──────────┤ + ↑ +AIS Vessels → Chokepoint Monitoring ────────────────┤ + ↑ +ACLED/GDELT → Protest Events ───────────────────────┤ + ↓ + CII Unrest Score +``` + +### Module Dependencies + +| Consumer Module | Data Source | Integration | +|----------------|-------------|-------------| +| **CII Unrest Score** | ACLED, GDELT protests | Event count, fatalities | +| **CII Security Score** | Military flights, vessels | Activity near hotspots | +| **CII Information Score** | News clusters | Velocity, keyword matches | +| **Strategic Risk** | CII, Convergence, Cascade | Composite scoring | +| **Related Assets** | News location inference | Pipeline/cable proximity | +| **Geographic Convergence** | All geo-located events | Multi-type clustering | + +### Alert Propagation + +When a threshold is crossed: + +1. **Source module** generates alert (e.g., CII spike) +2. **Alert merges** with related alerts (same country/region) +3. **Strategic Risk** receives composite alert +4. **UI updates** header badge and panel indicators + +This ensures a single escalation (e.g., Ukraine military flights + protests + news spike) surfaces as one coherent signal rather than three separate alerts. + +--- + +## AI Insights Panel + +The Insights Panel provides AI-powered analysis of the current news landscape, transforming raw headlines into actionable intelligence briefings. + +### World Brief Generation + +Every 2 minutes (with rate limiting), the system generates a concise situation brief using a multi-provider fallback chain: + +| Priority | Provider | Model | Latency | Use Case | +|----------|----------|-------|---------|----------| +| 1 | Groq | Llama 3.3 70B | ~2s | Primary provider (fast inference) | +| 2 | OpenRouter | Llama 3.3 70B | ~3s | Fallback when Groq rate-limited | +| 3 | Browser | T5 (ONNX) | ~5s | Offline fallback (local ML) | + +**Caching Strategy**: Redis server-side caching prevents redundant API calls. When the same headline set has been summarized recently, the cached result is returned immediately. + +### Focal Point Detection + +The AI receives enriched context about **focal points**—entities that appear in both news coverage AND map signals. This enables intelligence-grade analysis: + +``` +[INTELLIGENCE SYNTHESIS] +FOCAL POINTS (entities across news + signals): +- IRAN [CRITICAL]: 12 news mentions + 5 map signals (military_flight, protest, internet_outage) + KEY: "Iran protests continue..." | SIGNALS: military activity, outage detected +- TAIWAN [ELEVATED]: 8 news mentions + 3 map signals (military_vessel, military_flight) + KEY: "Taiwan tensions rise..." | SIGNALS: naval vessels detected +``` + +### Headline Scoring Algorithm + +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 + +This ensures military conflicts and humanitarian crises surface above routine business news. + +### Sentiment Analysis + +Headlines are analyzed for overall sentiment distribution: + +| Sentiment | Detection Method | Display | +|-----------|------------------|---------| +| **Negative** | Crisis, conflict, death keywords | Red percentage | +| **Positive** | Agreement, growth, peace keywords | Green percentage | +| **Neutral** | Neither detected | Gray percentage | + +The overall sentiment balance provides a quick read on whether the news cycle is trending toward escalation or de-escalation. + +### Velocity Detection + +Fast-moving stories are flagged when the same topic appears in multiple recent headlines: + +- Headlines are grouped by shared keywords and entities +- Topics with 3+ mentions in 6 hours are marked as "high velocity" +- Displayed separately to highlight developing situations + +--- + +## Focal Point Detector + +The Focal Point Detector is the intelligence synthesis layer that correlates news entities with map signals to identify "main characters" driving current events. + +### 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 + +### How It Works + +1. **Entity Extraction**: Extract countries, companies, and organizations from all news clusters using the entity registry (600+ entities with aliases) + +2. **Signal Aggregation**: Collect all map signals (military flights, protests, outages, vessels) and group by country + +3. **Cross-Reference**: Match news entities with signal countries + +4. **Score & Rank**: Calculate focal scores based on correlation strength + +### Focal Point Scoring + +``` +FocalScore = NewsScore + SignalScore + CorrelationBonus + +NewsScore (0-40): + base = min(20, mentionCount × 4) + velocity = min(10, newsVelocity × 2) + confidence = avgConfidence × 10 + +SignalScore (0-40): + types = signalTypes.count × 10 + count = min(15, signalCount × 3) + severity = highSeverityCount × 5 + +CorrelationBonus (0-20): + +10 if entity appears in BOTH news AND signals + +5 if news keywords match signal types (e.g., "military" + military_flight) + +5 if related entities also have signals +``` + +### Urgency Classification + +| Urgency | Criteria | Visual | +|---------|----------|--------| +| **Critical** | Score > 70 OR 3+ signal types | Red badge | +| **Elevated** | Score > 50 OR 2+ signal types | Orange badge | +| **Watch** | Default | Yellow badge | + +### Signal Type Icons + +Focal points display icons indicating which signal types are active: + +| Icon | Signal Type | Meaning | +|------|-------------|---------| +| ✈️ | military_flight | Military aircraft detected nearby | +| ⚓ | military_vessel | Naval vessels in waters | +| 📢 | protest | Civil unrest events | +| 🌐 | internet_outage | Network disruption | +| 🚢 | ais_disruption | Shipping anomaly | + +### 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 +- **Narrative**: "12 news mentions | 5 military flights, 3 protests, 1 internet outage | 'Iran protests continue amid...'" +- **Correlation Evidence**: "Iran appears in both news (12) and map signals (9)" + +### 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 + +--- + +## Natural Disaster Tracking + +The Natural layer combines two authoritative sources for comprehensive disaster monitoring. + +### GDACS (Global Disaster Alert and Coordination System) + +UN-backed disaster alert system providing official severity assessments: + +| Event Type | Code | Icon | Sources | +|------------|------|------|---------| +| Earthquake | EQ | 🔴 | USGS, EMSC | +| Flood | FL | 🌊 | Satellite imagery | +| Tropical Cyclone | TC | 🌀 | NOAA, JMA | +| Volcano | VO | 🌋 | Smithsonian GVP | +| Wildfire | WF | 🔥 | MODIS, VIIRS | +| Drought | DR | ☀️ | Multiple sources | + +**Alert Levels**: +| Level | Color | Meaning | +|-------|-------|---------| +| **Red** | Critical | Significant humanitarian impact expected | +| **Orange** | Alert | Moderate impact, monitoring required | +| **Green** | Advisory | Minor event, localized impact | + +### NASA EONET (Earth Observatory Natural Event Tracker) + +Near-real-time natural event detection from satellite observation: + +| Category | Detection Method | Typical Delay | +|----------|------------------|---------------| +| Severe Storms | GOES/Himawari imagery | Minutes | +| Wildfires | MODIS thermal anomalies | 4-6 hours | +| Volcanoes | Thermal + SO2 emissions | Hours | +| Floods | SAR imagery + gauges | Hours to days | +| Sea/Lake Ice | Passive microwave | Daily | +| Dust/Haze | Aerosol optical depth | Hours | + +### 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 +4. Combined entry shows both source attributions + +### 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 + +--- + +## Military Surge Detection + +The system detects unusual concentrations of military activity using two complementary algorithms. + +### Baseline-Based Surge Detection + +Surges are detected by comparing current aircraft counts to historical baselines within defined military theaters: + +| Parameter | Value | Purpose | +|-----------|-------|---------| +| Surge threshold | 2.0× baseline | Minimum multiplier to trigger alert | +| Baseline window | 48 hours | Historical data used for comparison | +| Minimum samples | 6 observations | Required data points for valid baseline | + +**Aircraft Categories Tracked**: + +| Category | Examples | Minimum Count | +|----------|----------|---------------| +| Transport/Airlift | C-17, C-130, KC-135, REACH flights | 5 aircraft | +| Fighter | F-15, F-16, F-22, Typhoon | 4 aircraft | +| Reconnaissance | RC-135, E-3 AWACS, U-2 | 3 aircraft | + +### Surge Severity + +| Severity | Criteria | Meaning | +|----------|----------|---------| +| **Critical** | 4× baseline or higher | Major deployment | +| **High** | 3× baseline | Significant increase | +| **Medium** | 2× baseline | Elevated activity | + +### Military Theaters + +Surge detection groups activity into strategic theaters: + +| Theater | Center | Key Bases | +|---------|--------|-----------| +| Middle East | Persian Gulf | Al Udeid, Al Dhafra, Incirlik | +| Eastern Europe | Poland | Ramstein, Spangdahlem, Łask | +| Pacific | Guam/Japan | Andersen, Kadena, Yokota | +| Horn of Africa | Djibouti | Camp Lemonnier | + +### Foreign Presence Detection + +A separate system monitors for military operators outside their normal operating areas: + +| Operator | Home Regions | Alert When Found In | +|----------|--------------|---------------------| +| USAF/USN | Alaska ADIZ | Persian Gulf, Taiwan Strait | +| Russian VKS | Kaliningrad, Arctic, Black Sea | Baltic Region, Alaska ADIZ | +| PLAAF/PLAN | Taiwan Strait, South China Sea | (alerts when increased) | +| Israeli IAF | Eastern Med | Iran border region | + +**Example alert**: +``` +FOREIGN MILITARY PRESENCE: Persian Gulf +USAF: 3 aircraft detected (KC-135, RC-135W, E-3) +Severity: HIGH - Operator outside normal home regions +``` + +### News Correlation + +Both surge and foreign presence alerts query the Focal Point Detector for context: + +1. Identify countries involved (aircraft operators, region countries) +2. Check focal points for those countries +3. If news correlation exists, attach headlines and evidence + +**Example with correlation**: +``` +MILITARY AIRLIFT SURGE: Middle East Theater +Current: 8 transport aircraft (2.5× baseline) +Aircraft: C-17 (3), KC-135 (3), C-130J (2) + +NEWS CORRELATION: +Iran: "Iran protests continue amid military..." +→ Iran appears in both news (12) and map signals (9) +``` + +--- + +## Strategic Posture Analysis + +The AI Strategic Posture panel aggregates military aircraft and naval vessels across defined theaters, providing at-a-glance situational awareness of global force concentrations. + +### Strategic Theaters + +Nine geographic theaters are monitored continuously, each with custom thresholds based on typical peacetime activity levels: + +| Theater | Bounds | Elevated Threshold | Critical Threshold | +|---------|--------|--------------------|--------------------| +| **Iran Theater** | Persian Gulf, Iraq, Syria (20°N–42°N, 30°E–65°E) | 50 aircraft | 100 aircraft | +| **Taiwan Strait** | Taiwan, East China Sea (18°N–30°N, 115°E–130°E) | 30 aircraft | 60 aircraft | +| **Korean Peninsula** | North/South Korea (33°N–43°N, 124°E–132°E) | 20 aircraft | 50 aircraft | +| **Baltic Theater** | Baltics, Poland, Scandinavia (52°N–65°N, 10°E–32°E) | 20 aircraft | 40 aircraft | +| **Black Sea** | Ukraine, Turkey, Romania (40°N–48°N, 26°E–42°E) | 15 aircraft | 30 aircraft | +| **South China Sea** | Philippines, Vietnam (5°N–25°N, 105°E–121°E) | 25 aircraft | 50 aircraft | +| **Eastern Mediterranean** | Syria, Cyprus, Lebanon (33°N–37°N, 25°E–37°E) | 15 aircraft | 30 aircraft | +| **Israel/Gaza** | Israel, Gaza Strip (29°N–33°N, 33°E–36°E) | 10 aircraft | 25 aircraft | +| **Yemen/Red Sea** | Bab el-Mandeb, Houthi areas (11°N–22°N, 32°E–54°E) | 15 aircraft | 30 aircraft | + +### Strike Capability Assessment + +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) + +Each theater has custom thresholds reflecting realistic strike package sizes: + +| Theater | Min Tankers | Min AWACS | Min Fighters | +|---------|-------------|-----------|--------------| +| Iran Theater | 10 | 2 | 30 | +| Taiwan Strait | 5 | 1 | 20 | +| Korean Peninsula | 4 | 1 | 15 | +| Baltic/Black Sea | 3-4 | 1 | 10-15 | +| Israel/Gaza | 2 | 1 | 8 | + +When all three criteria are met, the theater is flagged as **STRIKE CAPABLE**, indicating forces sufficient for sustained offensive operations. + +### Naval Vessel Integration + +The panel augments aircraft data with real-time naval vessel positions from AIS tracking. Vessels are classified into categories: + +| Category | Examples | Strategic Significance | +|----------|----------|------------------------| +| **Carriers** | CVN, CV, LHD | Power projection, air superiority | +| **Destroyers** | DDG, DDH | Air defense, cruise missile strike | +| **Frigates** | FFG, FF | Multi-role escort, ASW | +| **Submarines** | SSN, SSK, SSBN | Deterrence, ISR, strike | +| **Patrol** | PC, PG | Coastal defense | +| **Auxiliary** | T-AO, AOR | Fleet support, logistics | + +**Data Accumulation Note**: AIS vessel data arrives via WebSocket stream and accumulates gradually. The panel automatically re-checks vessel counts at 30, 60, 90, and 120 seconds after initial load to capture late-arriving data. + +### Posture Levels + +| Level | Indicator | Criteria | Meaning | +|-------|-----------|----------|---------| +| **Normal** | 🟢 NORM | Below elevated threshold | Routine peacetime activity | +| **Elevated** | 🟡 ELEV | At or above elevated threshold | Increased activity, possible exercises | +| **Critical** | 🔴 CRIT | At or above critical threshold | Major deployment, potential crisis | + +**Elevated + Strike Capable** is treated as a higher alert state than regular elevated status. + +### Trend Detection + +Activity trends are computed from rolling historical data: + +- **Increasing** (↗): Current activity >10% higher than previous period +- **Stable** (→): Activity within ±10% of previous period +- **Decreasing** (↘): Current activity >10% lower than previous period + +### Server-Side Caching + +Theater posture computations run on edge servers with Redis caching: + +| Cache Type | TTL | Purpose | +|------------|-----|---------| +| **Active cache** | 5 minutes | Matches OpenSky refresh rate | +| **Stale cache** | 1 hour | Fallback when upstream APIs fail | + +This ensures consistent data across all users and minimizes redundant API calls to OpenSky Network. + +--- + +## Server-Side Risk Score API + +Strategic risk and Country Instability Index (CII) scores are pre-computed server-side rather than calculated in the browser. This eliminates the "cold start" problem where new users would see no data while the system accumulated enough information to generate scores. + +### How It Works + +The `/api/risk-scores` edge function: + +1. Fetches recent protest/riot data from ACLED (7-day window) +2. Computes CII scores for 20 Tier 1 countries +3. Derives strategic risk from weighted top-5 CII scores +4. Caches results in Redis (10-minute TTL) + +### CII Score Calculation + +Each country's score combines: + +**Baseline Risk** (0–50 points): Static geopolitical risk based on historical instability, ongoing conflicts, and authoritarian governance. + +| Country | Baseline | Rationale | +|---------|----------|-----------| +| Syria, Ukraine, Yemen | 50 | Active conflict zones | +| Myanmar, Venezuela, North Korea | 40-45 | Civil unrest, authoritarian | +| Iran, Israel, Pakistan | 35-45 | Regional tensions | +| Saudi Arabia, Turkey, India | 20-25 | Moderate instability | +| Germany, UK, US | 5-10 | Stable democracies | + +**Unrest Component** (0–50 points): Recent protest and riot activity, weighted by event significance multiplier. + +**Information Component** (0–25 points): News coverage intensity (proxy for international attention). + +**Security Component** (0–25 points): Baseline plus riot contribution. + +### Event Significance Multipliers + +Events in some countries carry more global significance than others: + +| Multiplier | Countries | Rationale | +|------------|-----------|-----------| +| 3.0× | North Korea | Any visible unrest is highly unusual | +| 2.0-2.5× | China, Russia, Iran, Saudi Arabia | Authoritarian states suppress protests | +| 1.5-1.8× | Taiwan, Pakistan, Myanmar, Venezuela | Regional flashpoints | +| 0.5-0.8× | US, UK, France, Germany | Protests are routine in democracies | + +### Strategic Risk Derivation + +The composite strategic risk score is computed as a weighted average of the top 5 CII scores: + +``` +Weights: [1.0, 0.85, 0.70, 0.55, 0.40] (total: 3.5) +Strategic Risk = (Σ CII[i] × weight[i]) / 3.5 × 0.7 + 15 +``` + +The top countries contribute most heavily, with diminishing influence for lower-ranked countries. + +### Fallback Behavior + +When ACLED data is unavailable (API errors, rate limits, expired auth): + +1. **Stale cache** (1-hour TTL): Return recent scores with `stale: true` flag +2. **Baseline fallback**: Return scores using only static baseline values with `baseline: true` flag + +This ensures the dashboard always displays meaningful data even during upstream outages. + +--- + +## Service Status Monitoring + +The Service Status panel tracks the operational health of external services that WorldMonitor users may depend on. + +### Monitored Services + +| Service | Status Endpoint | Parser | +|---------|-----------------|--------| +| Anthropic (Claude) | status.claude.com | Statuspage.io | +| OpenAI | status.openai.com | Statuspage.io | +| Vercel | vercel-status.com | Statuspage.io | +| Cloudflare | cloudflarestatus.com | Statuspage.io | +| AWS | health.aws.amazon.com | Custom | +| GitHub | githubstatus.com | Statuspage.io | + +### Status Levels + +| Status | Color | Meaning | +|--------|-------|---------| +| **Operational** | Green | All systems functioning normally | +| **Degraded** | Yellow | Partial outage or performance issues | +| **Partial Outage** | Orange | Some components unavailable | +| **Major Outage** | Red | Significant service disruption | + +### Why This Matters + +External service outages can affect: + +- AI summarization (Groq, OpenRouter outages) +- Deployment pipelines (Vercel, GitHub outages) +- API availability (Cloudflare, AWS outages) + +Monitoring these services provides context when dashboard features behave unexpectedly. + +--- + +## Refresh Intervals + +Different data sources update at different frequencies based on volatility and API constraints. + +### Polling Schedule + +| Data Type | Interval | Rationale | +|-----------|----------|-----------| +| **News feeds** | 5 min | Balance freshness vs. rate limits | +| **Stock quotes** | 1 min | Market hours require near-real-time | +| **Crypto prices** | 1 min | 24/7 markets, high volatility | +| **Predictions** | 5 min | Probabilities shift slowly | +| **Earthquakes** | 5 min | USGS updates every 5 min | +| **Weather alerts** | 10 min | NWS alert frequency | +| **Flight delays** | 10 min | FAA status update cadence | +| **Internet outages** | 60 min | BGP events are rare | +| **Economic data** | 30 min | FRED data rarely changes intraday | +| **Military tracking** | 5 min | Activity patterns need timely updates | +| **PizzINT** | 10 min | Foot traffic changes slowly | + +### Real-Time Streams + +AIS vessel tracking uses WebSocket for true real-time: + +- **Connection**: Persistent WebSocket to Railway relay +- **Messages**: Position updates as vessels transmit +- **Reconnection**: Automatic with exponential backoff (5s → 10s → 20s) + +### User Control + +Time range selector affects displayed data, not fetch frequency: + +| Selection | Effect | +|-----------|--------| +| **1 hour** | Show only events from last 60 minutes | +| **6 hours** | Show events from last 6 hours | +| **24 hours** | Show events from last day | +| **7 days** | Show all recent events | + +Historical filtering is client-side—all data is fetched but filtered for display. + +--- + +## Tech Stack + +| Layer | Technology | Purpose | +|-------|------------|---------| +| **Language** | TypeScript 5.x | Type safety across 60+ source files | +| **Build** | Vite | Fast HMR, optimized production builds | +| **Map (Desktop)** | deck.gl + MapLibre GL | WebGL-accelerated rendering for large datasets | +| **Map (Mobile)** | D3.js + TopoJSON | SVG fallback for battery efficiency | +| **Concurrency** | Web Workers | Off-main-thread clustering and correlation | +| **AI/ML** | ONNX Runtime Web | Browser-based inference for offline summarization | +| **Networking** | WebSocket + REST | Real-time AIS stream, HTTP for other APIs | +| **Storage** | IndexedDB | Snapshots, baselines (megabytes of state) | +| **Preferences** | LocalStorage | User settings, monitors, panel order | +| **Deployment** | Vercel Edge | Serverless proxies with global distribution | + +### Map Rendering Architecture + +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 +- Automatic fallback when WebGL unavailable + +### Key Libraries + +- **deck.gl**: High-performance WebGL visualization layers +- **MapLibre GL**: Open-source map rendering engine +- **D3.js**: SVG map rendering, zoom behavior (mobile fallback) +- **TopoJSON**: Efficient geographic data encoding +- **ONNX Runtime**: Browser-based ML inference +- **Custom HTML escaping**: XSS prevention (DOMPurify pattern) + +### No External UI Frameworks + +The entire UI is hand-crafted DOM manipulation—no React, Vue, or Angular. This keeps the bundle small (~250KB gzipped) and provides fine-grained control over rendering performance. + +### Build-Time Configuration + +Vite injects configuration values at build time, enabling features like automatic version syncing: + +| Variable | Source | Purpose | +|----------|--------|---------| +| `__APP_VERSION__` | `package.json` version field | Header displays current version | + +This ensures the displayed version always matches the published package—no manual synchronization required. + +```typescript +// vite.config.ts +define: { + __APP_VERSION__: JSON.stringify(pkg.version), +} + +// App.ts +const header = `World Monitor v${__APP_VERSION__}`; +``` + +--- + +## Installation + +**Requirements:** Go 1.21+ and Node.js 18+. + +```bash +# Clone the repository +git clone https://github.com/koala73/worldmonitor.git +cd worldmonitor + +# Install everything (buf, sebuf plugins, npm deps, proto deps) +make install + +# Start development server +npm run dev + +# Build for production +npm run build +``` + +If you modify any `.proto` files, regenerate before building or pushing: + +```bash +make generate # regenerate TypeScript clients, servers, and OpenAPI docs +``` + +See [ADDING_ENDPOINTS.md](ADDING_ENDPOINTS.md) for the full proto workflow. + +## API Dependencies + +The dashboard fetches data from various public APIs and data sources: + +| Service | Data | Auth Required | +|---------|------|---------------| +| RSS2JSON | News feed parsing | No | +| Finnhub | Stock quotes (primary) | Yes (free) | +| Yahoo Finance | Stock indices & commodities (backup) | No | +| CoinGecko | Cryptocurrency prices | No | +| USGS | Earthquake data | No | +| NASA EONET | Natural events (storms, fires, volcanoes, floods) | No | +| NWS | Weather alerts | No | +| FRED | Economic indicators (Fed data) | No | +| EIA | Oil analytics (prices, production, inventory) | Yes (free) | +| USASpending.gov | Federal government contracts & awards | No | +| Polymarket | Prediction markets | No | +| ACLED | Armed conflict & protest data | Yes (free) | +| GDELT Geo | News-derived event geolocation + tensions | No | +| GDELT Doc | Topic-based intelligence feeds (cyber, military, nuclear) | No | +| FAA NASSTATUS | Airport delay status | No | +| Cloudflare Radar | Internet outage data | Yes (free) | +| AISStream | Live vessel positions | Yes (relay) | +| OpenSky Network | Military aircraft tracking | Yes (free) | +| Wingbits | Aircraft enrichment (owner, operator) | Yes (free) | +| PizzINT | Pentagon-area activity metrics | No | + +### Optional API Keys + +Some features require API credentials. Without them, the corresponding layer is hidden: + +| Variable | Service | How to Get | +|----------|---------|------------| +| `FINNHUB_API_KEY` | Stock quotes (primary) | Free registration at [finnhub.io](https://finnhub.io/) | +| `EIA_API_KEY` | Oil analytics | Free registration at [eia.gov/opendata](https://www.eia.gov/opendata/) | +| `VITE_WS_RELAY_URL` | AIS vessel tracking | Deploy AIS relay or use hosted service | +| `VITE_OPENSKY_RELAY_URL` | Military aircraft | Deploy relay with OpenSky credentials | +| `OPENSKY_CLIENT_ID` | OpenSky auth (relay) | Free registration at [opensky-network.org](https://opensky-network.org) | +| `OPENSKY_CLIENT_SECRET` | OpenSky auth (relay) | API key from OpenSky account settings | +| `CLOUDFLARE_API_TOKEN` | Internet outages | Free Cloudflare account with Radar access | +| `ACLED_ACCESS_TOKEN` | Protest data (server-side) | Free registration at acleddata.com | +| `WINGBITS_API_KEY` | Aircraft enrichment | Contact [Wingbits](https://wingbits.com) for API access | + +The dashboard functions fully without these keys—affected layers simply don't appear. Core functionality (news, markets, earthquakes, weather) requires no configuration. + +## Project Structure + +``` +src/ +├── App.ts # Main application orchestrator +├── main.ts # Entry point +├── components/ +│ ├── DeckGLMap.ts # WebGL map with deck.gl + MapLibre (desktop) +│ ├── Map.ts # D3.js SVG map (mobile fallback) +│ ├── MapContainer.ts # Map wrapper with platform detection +│ ├── MapPopup.ts # Contextual info popups +│ ├── SearchModal.ts # Universal search (⌘K) +│ ├── SignalModal.ts # Signal intelligence display with focal points +│ ├── PizzIntIndicator.ts # Pentagon Pizza Index display +│ ├── VirtualList.ts # Virtual/windowed scrolling +│ ├── InsightsPanel.ts # AI briefings + focal point display +│ ├── EconomicPanel.ts # FRED economic indicators +│ ├── GdeltIntelPanel.ts # Topic-based intelligence (cyber, military, etc.) +│ ├── LiveNewsPanel.ts # YouTube live news streams with channel switching +│ ├── NewsPanel.ts # News feed with clustering +│ ├── MarketPanel.ts # Stock/commodity display +│ ├── MonitorPanel.ts # Custom keyword monitors +│ ├── CIIPanel.ts # Country Instability Index display +│ ├── CascadePanel.ts # Infrastructure cascade analysis +│ ├── StrategicRiskPanel.ts # Strategic risk overview dashboard +│ ├── StrategicPosturePanel.ts # AI strategic posture with theater analysis +│ ├── ServiceStatusPanel.ts # External service health monitoring +│ └── ... +├── config/ +│ ├── feeds.ts # 70+ RSS feeds, source tiers, regional sources +│ ├── geo.ts # 30+ hotspots, conflicts, 55 cables, waterways, spaceports, minerals +│ ├── pipelines.ts # 88 oil & gas pipelines +│ ├── ports.ts # 61 strategic ports worldwide +│ ├── bases-expanded.ts # 220+ military bases +│ ├── ai-datacenters.ts # 313 AI clusters (filtered to 111) +│ ├── airports.ts # 30 monitored US airports +│ ├── irradiators.ts # IAEA gamma irradiator sites +│ ├── nuclear-facilities.ts # Global nuclear infrastructure +│ ├── markets.ts # Stock symbols, sectors +│ ├── entities.ts # 100+ entity definitions (companies, indices, commodities, countries) +│ └── panels.ts # Panel configs, layer defaults, mobile optimizations +├── services/ +│ ├── ais.ts # WebSocket vessel tracking with density analysis +│ ├── military-vessels.ts # Naval vessel identification and tracking +│ ├── military-flights.ts # Aircraft tracking via OpenSky relay +│ ├── military-surge.ts # Surge detection with news correlation +│ ├── cached-theater-posture.ts # Theater posture API client with caching +│ ├── wingbits.ts # Aircraft enrichment (owner, operator, type) +│ ├── pizzint.ts # Pentagon Pizza Index + GDELT tensions +│ ├── protests.ts # ACLED + GDELT integration +│ ├── gdelt-intel.ts # GDELT Doc API topic intelligence +│ ├── gdacs.ts # UN GDACS disaster alerts +│ ├── eonet.ts # NASA EONET natural events + GDACS merge +│ ├── flights.ts # FAA delay parsing +│ ├── outages.ts # Cloudflare Radar integration +│ ├── rss.ts # RSS parsing with circuit breakers +│ ├── markets.ts # Finnhub, Yahoo Finance, CoinGecko +│ ├── earthquakes.ts # USGS integration +│ ├── weather.ts # NWS alerts +│ ├── fred.ts # Federal Reserve data +│ ├── oil-analytics.ts # EIA oil prices, production, inventory +│ ├── usa-spending.ts # USASpending.gov contracts & awards +│ ├── polymarket.ts # Prediction markets (filtered) +│ ├── clustering.ts # Jaccard similarity clustering +│ ├── correlation.ts # Signal detection engine +│ ├── velocity.ts # Velocity & sentiment analysis +│ ├── related-assets.ts # Infrastructure near news events +│ ├── activity-tracker.ts # New item detection & highlighting +│ ├── analysis-worker.ts # Web Worker manager +│ ├── ml-worker.ts # Browser ML inference (ONNX) +│ ├── summarization.ts # AI briefings with fallback chain +│ ├── parallel-analysis.ts # Concurrent headline analysis +│ ├── storage.ts # IndexedDB snapshots & baselines +│ ├── data-freshness.ts # Real-time data staleness tracking +│ ├── signal-aggregator.ts # Central signal collection & grouping +│ ├── focal-point-detector.ts # Intelligence synthesis layer +│ ├── entity-index.ts # Entity lookup maps (by alias, keyword, sector) +│ ├── entity-extraction.ts # News-to-entity matching for market correlation +│ ├── country-instability.ts # CII scoring algorithm +│ ├── geo-convergence.ts # Geographic convergence detection +│ ├── infrastructure-cascade.ts # Dependency graph and cascade analysis +│ └── cross-module-integration.ts # Unified alerts and strategic risk +├── workers/ +│ └── analysis.worker.ts # Off-thread clustering & correlation +├── utils/ +│ ├── circuit-breaker.ts # Fault tolerance pattern +│ ├── sanitize.ts # XSS prevention (escapeHtml, sanitizeUrl) +│ ├── urlState.ts # Shareable link encoding/decoding +│ └── analysis-constants.ts # Shared thresholds for worker sync +├── styles/ +└── types/ +api/ # Vercel Edge serverless proxies +├── cloudflare-outages.js # Proxies Cloudflare Radar +├── coingecko.js # Crypto prices with validation +├── eia/[[...path]].js # EIA petroleum data (oil prices, production) +├── faa-status.js # FAA ground stops/delays +├── finnhub.js # Stock quotes (batch, primary) +├── fred-data.js # Federal Reserve economic data +├── gdelt-doc.js # GDELT Doc API (topic intelligence) +├── gdelt-geo.js # GDELT Geo API (event geolocation) +├── polymarket.js # Prediction markets with validation +├── yahoo-finance.js # Stock indices/commodities (backup) +├── opensky-relay.js # Military aircraft tracking +├── wingbits.js # Aircraft enrichment proxy +├── risk-scores.js # Pre-computed CII and strategic risk (Redis cached) +├── theater-posture.js # Theater-level force aggregation (Redis cached) +├── groq-summarize.js # AI summarization with Groq API +└── openrouter-summarize.js # AI summarization fallback via OpenRouter +``` + +## 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 +- **Hover markers** - Show tooltip with summary information +- **Layer toggles** - Show/hide data layers + +### Map Marker Design + +Infrastructure markers (nuclear facilities, economic centers, ports) display without labels to reduce visual clutter. Full information is available through interaction: + +| Layer | Label Behavior | Interaction | +|-------|---------------|-------------| +| Nuclear facilities | Hidden | Click for popover with details | +| Economic centers | Hidden | Click for popover with details | +| Protests | Hidden | Hover for tooltip, click for details | +| Military bases | Hidden | Click for popover with base info | +| Hotspots | Visible | Color-coded activity levels | +| Conflicts | Visible | Status and involved parties | + +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 + +**Encoded Parameters**: +| Parameter | Description | +|-----------|-------------| +| `lat`, `lon` | Map center coordinates | +| `zoom` | Zoom level (1-10) | +| `time` | Active time filter (1h, 6h, 24h, 7d) | +| `view` | Preset view (global, us, mena) | +| `layers` | Comma-separated enabled layer IDs | + +Example: `?lat=38.9&lon=-77&zoom=6&layers=bases,conflicts,hotspots` + +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 +- **Pipelines**: 88 operating oil/gas pipelines across all continents +- **Undersea Cables**: 55 major submarine cable routes +- **Nuclear**: 100+ power plants, weapons labs, enrichment facilities +- **AI Infrastructure**: 111 major compute clusters (≥10k GPUs) +- **Strategic Waterways**: 8 critical chokepoints +- **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) +- **FAA**: Airport delays and ground stops +- **Cloudflare Radar**: Internet outage detection +- **AIS**: Real-time vessel positions +- **ACLED/GDELT**: Protest and unrest events +- **Yahoo Finance**: Stock quotes and indices +- **CoinGecko**: Cryptocurrency prices +- **FRED**: Federal Reserve economic data +- **Polymarket**: Prediction market odds + +## Data Attribution + +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 + +Original dashboard concept inspired by Reggie James ([@HipCityReg](https://x.com/HipCityReg/status/2009003048044220622)) - with thanks for the vision of a comprehensive situation awareness tool + +Special thanks to **Yanal at [Wingbits](https://wingbits.com)** for providing API access for aircraft enrichment data, enabling military aircraft classification and ownership tracking + +Thanks to **[@fai9al](https://github.com/fai9al)** for the inspiration and original PR that led to the Tech Monitor variant + +--- + +## Limitations & Caveats + +This project is a **proof of concept** demonstrating what's possible with publicly available data. While functional, there are important limitations: + +### 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.) + +The dashboard works with free tiers but may have gaps in coverage or update frequency. + +### 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 + +Terrestrial receivers only detect vessels within ~50km of shore. Satellite AIS (commercial) provides true global coverage but is not included in this free implementation. + +### 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 + +The system degrades gracefully—blocked sources are skipped while others continue functioning. + +--- + +## Roadmap + +See [ROADMAP.md](../.planning/ROADMAP.md) for detailed planning. Recent intelligence enhancements: + +### Completed + +- ✅ **Focal Point Detection** - Intelligence synthesis correlating news entities with map signals +- ✅ **AI-Powered Briefings** - Groq/OpenRouter/Browser ML fallback chain for summarization +- ✅ **Military Surge Detection** - Alerts when multiple operators converge on regions +- ✅ **News-Signal Correlation** - Surge alerts include related focal point context +- ✅ **GDACS Integration** - UN disaster alert system for earthquakes, floods, cyclones, volcanoes +- ✅ **WebGL Map (deck.gl)** - High-performance rendering for desktop users +- ✅ **Browser ML Fallback** - ONNX Runtime for offline summarization capability +- ✅ **Multi-Signal Geographic Convergence** - Alerts when 3+ data types converge on same region within 24h +- ✅ **Country Instability Index (CII)** - Real-time composite risk score for 20 Tier-1 countries +- ✅ **Infrastructure Cascade Visualization** - Dependency graph showing downstream effects of disruptions +- ✅ **Strategic Risk Overview** - Unified alert system with cross-module correlation and deduplication +- ✅ **GDELT Topic Intelligence** - Categorized feeds for military, cyber, nuclear, and sanctions topics +- ✅ **OpenSky Authentication** - OAuth2 credentials for military aircraft tracking via relay +- ✅ **Human-Readable Locations** - Convergence alerts show place names instead of coordinates +- ✅ **Data Freshness Tracking** - Status panel shows enabled/disabled state for all feeds +- ✅ **CII Scoring Bias Prevention** - Log scaling and conflict zone floors prevent news volume bias +- ✅ **Alert Warmup Period** - Suppresses false positives on dashboard startup +- ✅ **Significant Protest Filtering** - Map shows only riots and high-severity protests +- ✅ **Intelligence Findings Detail Modal** - Click any alert for full context and component breakdown +- ✅ **Build-Time Version Sync** - Header version auto-syncs with package.json +- ✅ **Tech Monitor Variant** - Dedicated technology sector dashboard with startup ecosystems, cloud regions, and tech events +- ✅ **Smart Marker Clustering** - Geographic grouping of nearby markers with click-to-expand popups +- ✅ **Variant Switcher UI** - Compact orbital navigation between World Monitor and Tech Monitor +- ✅ **CII Learning Mode** - 15-minute calibration period with visual progress indicator +- ✅ **Regional Tech Coverage** - Verified tech HQ data for MENA, Europe, Asia-Pacific hubs +- ✅ **Service Status Panel** - External service health monitoring (AI providers, cloud platforms) +- ✅ **AI Strategic Posture Panel** - Theater-level force aggregation with strike capability assessment +- ✅ **Server-Side Risk Score API** - Pre-computed CII and strategic risk scores with Redis caching +- ✅ **Naval Vessel Classification** - Known vessel database with hull number matching and AIS type inference +- ✅ **Strike Capability Detection** - Assessment of offensive force packages (tankers + AWACS + fighters) +- ✅ **Theater Posture Thresholds** - Custom elevated/critical thresholds for each strategic theater + +### 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 +- **Think Tank Feeds** - RUSI, Chatham House, ECFR, CFR, Wilson Center, CNAS, Arms Control Association + +The full [ROADMAP.md](../.planning/ROADMAP.md) documents implementation details, API endpoints, and 30+ free data sources for future integration. + +--- + +## Design Philosophy + +**Information density over aesthetics.** Every pixel should convey signal. The dark interface minimizes eye strain during extended monitoring sessions. Panels are collapsible, draggable, and hideable—customize to show only what matters. + +**Authority matters.** Not all sources are equal. Wire services and official government channels are prioritized over aggregators and blogs. When multiple sources report the same story, the most authoritative source is displayed as primary. + +**Correlation over accumulation.** Raw news feeds are noise. The value is in clustering related stories, detecting velocity changes, and identifying cross-source patterns. A single "Broadcom +2.5% explained by AI chip news" signal is more valuable than showing both data points separately. + +**Signal, not noise.** Deduplication is aggressive. The same market move doesn't generate repeated alerts. Signals include confidence scores so you can prioritize attention. Alert fatigue is the enemy of situational awareness. + +**Knowledge-first matching.** Simple keyword matching produces false positives. The entity knowledge base understands that AVGO is Broadcom, that Broadcom competes with Nvidia, and that both are in semiconductors. This semantic layer transforms naive string matching into intelligent correlation. + +**Fail gracefully.** External APIs are unreliable. Circuit breakers prevent cascading failures. Cached data displays during outages. The status panel shows exactly what's working and what isn't—no silent failures. + +**Local-first.** No accounts, no cloud sync. All preferences and history stored locally. The only network traffic is fetching public data. Your monitoring configuration is yours alone. + +**Compute where it matters.** CPU-intensive operations (clustering, correlation) run in Web Workers to keep the UI responsive. The main thread handles only rendering and user interaction. + +--- + +## System Architecture + +### Data Flow Overview + +``` + ┌─────────────────────────────────┐ + │ External Data Sources │ + │ RSS Feeds, APIs, WebSockets │ + └─────────────┬───────────────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ RSS Parser │ │ API Client │ │ WebSocket Hub │ + │ (News Feeds) │ │ (USGS, FAA...) │ │ (AIS, Markets) │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └──────────────────────┼──────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Circuit Breakers │ + │ (Rate Limiting, Retry Logic) │ + └─────────────┬───────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ Data Freshness │ │ Search Index │ │ Web Worker │ + │ Tracker │ │ (Searchables) │ │ (Clustering) │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ App State │ + │ (Map, Panels, Intelligence) │ + └─────────────┬───────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Rendering Pipeline │ + │ D3.js Map + React-like Panels │ + └─────────────────────────────────┘ +``` + +### Update Cycles + +Different data types refresh at different intervals based on volatility and API limits: + +| Data Type | Refresh Interval | Rationale | +|-----------|------------------|-----------| +| **News Feeds** | 3 minutes | Balance between freshness and API politeness | +| **Market Data** | 60 seconds | Real-time awareness with rate limit constraints | +| **Military Tracking** | 30 seconds | High-priority for situational awareness | +| **Weather Alerts** | 5 minutes | NWS update frequency | +| **Earthquakes** | 5 minutes | USGS update cadence | +| **Internet Outages** | 5 minutes | Cloudflare Radar update frequency | +| **AIS Vessels** | Real-time | WebSocket streaming | + +### Error Handling Strategy + +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 + +### Build-Time Optimization + +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 + +### 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) + +--- + +## Contributing + +Contributions are welcome! Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your help makes this project better. + +### Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR_USERNAME/worldmonitor.git + cd worldmonitor + ``` +3. **Install dependencies**: + ```bash + npm install + ``` +4. **Create a feature branch**: + ```bash + git checkout -b feature/your-feature-name + ``` +5. **Start the development server**: + ```bash + npm run dev + ``` + +### Code Style & Conventions + +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 + +### Submitting a Pull Request + +1. **Ensure your code builds**: + ```bash + npm run build + ``` + +2. **Test your changes** manually in the browser + +3. **Write a clear commit message**: + ``` + Add earthquake magnitude filtering to map layer + + - Adds slider control to filter by minimum magnitude + - Persists preference to localStorage + - Updates URL state for shareable links + ``` + +4. **Push to your fork**: + ```bash + git push origin feature/your-feature-name + ``` + +5. **Open a Pull Request** with: + - A clear title describing the change + - Description of what the PR does and why + - Screenshots for UI changes + - Any breaking changes or migration notes + +### What Makes a Good PR + +| Do | Don't | +|----|-------| +| Focus on one feature or fix | Bundle unrelated changes | +| Follow existing code patterns | Introduce new frameworks without discussion | +| Keep changes minimal and targeted | Refactor surrounding code unnecessarily | +| Update README if adding features | Add features without documentation | +| Test edge cases | Assume happy path only | + +### 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 + +### Review Process + +1. **Automated checks** run on PR submission +2. **Maintainer review** within a few days +3. **Feedback addressed** through commits to the same branch +4. **Merge** once approved + +PRs that don't follow the code style or introduce security issues will be asked to revise. + +### Development Tips + +**Adding or Modifying API Endpoints** + +All JSON API endpoints **must** use sebuf. Do not create standalone `api/*.js` files — the legacy pattern is deprecated. + +See **[docs/ADDING_ENDPOINTS.md](ADDING_ENDPOINTS.md)** for the complete guide covering: + +- Adding an RPC to an existing service +- Adding an entirely new service +- Proto conventions (validation, time fields, shared types) +- Generated OpenAPI documentation + +**Adding a New Data Layer** + +1. Define the proto contract and generate code (see [ADDING_ENDPOINTS.md](ADDING_ENDPOINTS.md)) +2. Implement the handler in `server/worldmonitor/{domain}/v1/` +3. Create the frontend service wrapper in `src/services/` +4. Add layer toggle in `src/components/Map.ts` +5. Add rendering logic for map markers/overlays + +**Debugging** + +- Browser DevTools → Network tab for API issues +- Console logs prefixed with `[ServiceName]` for easy filtering +- Circuit breaker status visible in browser console + +--- + +## License + +MIT + +## Author + +**Elie Habib** + +--- + +*Built for situational awareness and open-source intelligence gathering.* diff --git a/docs/Docs_To_Review/API_REFERENCE.md b/docs/Docs_To_Review/API_REFERENCE.md new file mode 100644 index 000000000..b65a1aa47 --- /dev/null +++ b/docs/Docs_To_Review/API_REFERENCE.md @@ -0,0 +1,2390 @@ +# World Monitor — API Reference + +> Comprehensive reference for all Vercel Edge Function endpoints powering the World Monitor intelligence dashboard. + +**Base URL**: All endpoints are relative to `/api/` (e.g., `https://worldmonitor.app/api/earthquakes`). + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Overview](#overview) +- [Shared Middleware](#shared-middleware) + - [`_cors.js`](#_corsjs) + - [`_cache-telemetry.js`](#_cache-telemetryjs) + - [`_ip-rate-limit.js`](#_ip-rate-limitjs) + - [`_upstash-cache.js`](#_upstash-cachejs) +- [Endpoints by Domain](#endpoints-by-domain) + - [Geopolitical](#geopolitical) + - [Markets & Finance](#markets--finance) + - [Military & Security](#military--security) + - [Natural Events](#natural-events) + - [AI / ML](#ai--ml) + - [Infrastructure](#infrastructure) + - [Humanitarian](#humanitarian) + - [Content](#content) + - [Meta](#meta) + - [Risk & Baseline](#risk--baseline) + - [Proxy / Passthrough Subdirectories](#proxy--passthrough-subdirectories) +- [Error Handling](#error-handling) +- [Rate Limiting](#rate-limiting) +- [Caching Architecture](#caching-architecture) + +--- + +## Quick Reference + +| Method | Path | Auth | Cache TTL | Rate Limit | Domain | +|--------|------|------|-----------|------------|--------| +| `GET` | `/api/acled` | `ACLED_ACCESS_TOKEN` + `ACLED_EMAIL` | 600 s | 10 req/min | Geopolitical | +| `GET` | `/api/acled-conflict` | `ACLED_ACCESS_TOKEN` + `ACLED_EMAIL` | 600 s | 10 req/min | Geopolitical | +| `GET` | `/api/ucdp` | None | 86 400 s (24 h) | — | Geopolitical | +| `GET` | `/api/ucdp-events` | None | 21 600 s (6 h) | 15 req/min | Geopolitical | +| `GET` | `/api/gdelt-doc` | None | CDN 300 s | — | Geopolitical | +| `GET` | `/api/gdelt-geo` | None | CDN 300 s | — | Geopolitical | +| `GET` | `/api/nga-warnings` | None | CDN 3 600 s | — | Geopolitical | +| `POST` | `/api/country-intel` | `GROQ_API_KEY` | 7 200 s (2 h) | — | Geopolitical | +| `GET` | `/api/finnhub` | `FINNHUB_API_KEY` | CDN 60 s | — | Markets | +| `GET` | `/api/yahoo-finance` | None | CDN 60 s | — | Markets | +| `GET` | `/api/coingecko` | None | 120 s | — | Markets | +| `GET` | `/api/stablecoin-markets` | None | In-mem 120 s | — | Markets | +| `GET` | `/api/etf-flows` | `FINNHUB_API_KEY` | In-mem 900 s | — | Markets | +| `GET` | `/api/stock-index` | None | 3 600 s (1 h) | — | Markets | +| `GET` | `/api/fred-data` | `FRED_API_KEY` | 3 600 s | — | Markets | +| `GET` | `/api/macro-signals` | `FRED_API_KEY`, `FINNHUB_API_KEY` | In-mem 300 s | — | Markets | +| `GET` | `/api/polymarket` | None | 300 s | — | Markets | +| `GET` | `/api/opensky` | None | CDN 15 s | — | Military | +| `GET` | `/api/ais-snapshot` | `WS_RELAY_URL` | 3-tier 4–8 s | — | Military | +| `GET` | `/api/theater-posture` | None | 3-tier 5 min–7 d | — | Military | +| `GET` | `/api/cyber-threats` | `ABUSEIPDB_API_KEY` (opt.) | 600 s | 20 req/min | Military | +| `GET` | `/api/earthquakes` | None | CDN 300 s | — | Natural Events | +| `GET` | `/api/firms-fires` | `NASA_FIRMS_API_KEY` | 600 s | — | Natural Events | +| `GET` | `/api/climate-anomalies` | None | 21 600 s (6 h) | 15 req/min | Natural Events | +| `POST` | `/api/classify-batch` | `GROQ_API_KEY` | 86 400 s (24 h) | — | AI / ML | +| `GET` | `/api/classify-event` | `GROQ_API_KEY` | 86 400 s (24 h) | — | AI / ML | +| `POST` | `/api/groq-summarize` | `GROQ_API_KEY` | 3 600 s | — | AI / ML | +| `POST` | `/api/openrouter-summarize` | `OPENROUTER_API_KEY` | 3 600 s | — | AI / ML | +| `GET` | `/api/cloudflare-outages` | `CLOUDFLARE_API_TOKEN` | 600 s | — | Infrastructure | +| `GET` | `/api/service-status` | None | In-mem 60 s | — | Infrastructure | +| `GET` | `/api/faa-status` | None | CDN 300 s | — | Infrastructure | +| `GET` | `/api/unhcr-population` | None | 86 400 s (24 h) | 20 req/min | Humanitarian | +| `GET` | `/api/hapi` | `HDX_APP_IDENTIFIER` (opt.) | 21 600 s (6 h) | — | Humanitarian | +| `GET` | `/api/worldpop-exposure` | None | 604 800 s (7 d) | 30 req/min | Humanitarian | +| `GET` | `/api/worldbank` | None | 86 400 s (24 h) | — | Humanitarian | +| `GET` | `/api/rss-proxy` | None | CDN 300 s | — | Content | +| `GET` | `/api/hackernews` | None | CDN 300 s | — | Content | +| `GET` | `/api/github-trending` | `GITHUB_TOKEN` (opt.) | 3 600 s | — | Content | +| `GET` | `/api/tech-events` | None | 21 600 s (6 h) | — | Content | +| `GET` | `/api/arxiv` | None | CDN 3 600 s | — | Content | +| `GET` | `/api/version` | `GITHUB_TOKEN` (opt.) | CDN 600 s | — | Meta | +| `GET` | `/api/cache-telemetry` | None | `no-store` | — | Meta | +| `GET` | `/api/debug-env` | None | — | — | Meta | +| `GET` | `/api/download` | None | — | — | Meta | +| `GET` | `/api/og-story` | None | — | — | Meta | +| `GET` | `/api/story` | None | — | — | Meta | +| `GET` | `/api/risk-scores` | None | 600 s | — | Risk | +| `GET/POST` | `/api/temporal-baseline` | None | 7 776 000 s (90 d) | — | Risk | +| `GET` | `/api/eia/*` | `EIA_API_KEY` | CDN 3 600 s | — | Proxy | +| `GET` | `/api/pizzint/*` | None | CDN 120 s | — | Proxy | +| `GET` | `/api/wingbits/*` | `WINGBITS_API_KEY` | CDN 15–300 s | — | Proxy | +| `GET` | `/api/youtube/*` | None | — | — | Proxy | + +--- + +## Overview + +World Monitor exposes **60+ serverless endpoints** deployed as **Vercel Edge Functions** (unless noted otherwise). Every endpoint: + +1. Applies **CORS middleware** — only whitelisted origins may call the API. +2. Optionally applies **IP-based rate limiting** via a sliding-window algorithm. +3. Leverages a **multi-tier caching** strategy: CDN edge (`Cache-Control` + `s-maxage`), Upstash Redis, and in-memory Maps. +4. Returns **JSON** with consistent error envelopes (see [Error Handling](#error-handling)). + +### Common Response Headers + +``` +Access-Control-Allow-Origin: +Access-Control-Allow-Methods: GET, OPTIONS +Access-Control-Allow-Headers: Content-Type, Authorization +Cache-Control: public, s-maxage=, stale-while-revalidate= +Content-Type: application/json; charset=utf-8 +``` + +### Common Patterns + +- **OPTIONS pre-flight**: Every endpoint responds to `OPTIONS` with 204 + CORS headers. +- **Graceful degradation**: When credentials are missing, most endpoints return `{ success: true, data: [] }` or `{ unavailable: true }` instead of erroring. +- **Query hashing**: Composite cache keys use `hashString()` from `_upstash-cache.js` to generate deterministic hashes of query parameters. + +--- + +## Shared Middleware + +All middleware modules live in the `api/` directory, prefixed with `_` to prevent Vercel from deploying them as standalone routes. + +--- + +### `_cors.js` + +Cross-origin request gating applied to every endpoint. + +#### Allowed Origin Patterns + +Eight regex patterns control access: + +| # | Pattern | Matches | +|---|---------|---------| +| 1 | `worldmonitor\.app$` | `https://worldmonitor.app` | +| 2 | `\.worldmonitor\.app$` | `https://*.worldmonitor.app` | +| 3 | `\.vercel\.app$` | Vercel preview deploys | +| 4 | `localhost(:\d+)?$` | `http://localhost:*` | +| 5 | `127\.0\.0\.1(:\d+)?$` | IPv4 loopback | +| 6 | `\[::1\](:\d+)?$` | IPv6 loopback | +| 7 | `tauri://localhost` | Tauri desktop shell | +| 8 | `https://tauri\.localhost` | Tauri (alternative scheme) | + +#### Exports + +```typescript +/** Returns CORS headers object for the given request and allowed methods. */ +function getCorsHeaders( + req: Request, + methods?: string // default "GET, OPTIONS" +): Record; + +/** Returns true if the request origin is NOT on the allowlist. */ +function isDisallowedOrigin(req: Request): boolean; +``` + +#### Behaviour + +- `getCorsHeaders` reflects the request `Origin` back in `Access-Control-Allow-Origin` if it matches any pattern; otherwise the header is omitted. +- `isDisallowedOrigin` returns `true` for origins matching none of the 8 patterns. Endpoints can use this to short-circuit with 403. + +--- + +### `_cache-telemetry.js` + +Per-instance, in-memory cache telemetry recorder used to track HIT/MISS/STALE ratios per endpoint. + +#### Exports + +```typescript +/** Record a cache outcome for a named endpoint. */ +function recordCacheTelemetry( + endpoint: string, + outcome: "HIT" | "MISS" | "STALE" +): void; + +/** Return the current telemetry snapshot. */ +function getCacheTelemetrySnapshot(): Record; +``` + +#### Configuration + +| Constant | Default | Description | +|----------|---------|-------------| +| `MAX_ENDPOINTS` | `128` | Max distinct endpoint keys tracked before oldest is evicted | +| `LOG_EVERY` | `50` | Console-log telemetry summary every N recordings | + +--- + +### `_ip-rate-limit.js` + +Sliding-window IP rate limiter with LRU cleanup, used by endpoints that call expensive or quota-limited upstream APIs. + +#### Factory + +```typescript +function createIpRateLimiter(opts?: { + limit?: number; // default 60 + windowMs?: number; // default 60_000 (1 min) + maxEntries?: number; // default 10_000 + cleanupIntervalMs?: number; // default 300_000 (5 min) +}): { + check(ip: string): { allowed: boolean; retryAfter?: number }; + size(): number; +}; +``` + +#### Defaults + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `limit` | `60` | Max requests per window | +| `windowMs` | `60 000` | Sliding window duration (ms) | +| `maxEntries` | `10 000` | Max tracked IPs before LRU eviction | +| `cleanupIntervalMs` | `300 000` | Interval for stale-entry cleanup (ms) | + +#### Behaviour + +- When `check(ip)` returns `{ allowed: false }`, the endpoint responds with **429 Too Many Requests** and the `Retry-After` header set to the number of seconds until the window resets. +- LRU eviction ensures memory stays bounded on long-lived Edge instances. + +--- + +### `_upstash-cache.js` + +Dual-mode distributed cache — Upstash Redis in production, in-memory `Map` with disk persistence in sidecar/local mode. + +#### Mode Selection + +| Env Var | Mode | Backend | +|---------|------|---------| +| `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` set | **Cloud** | Upstash Redis REST API | +| `SIDECAR=true` | **Sidecar** | In-memory `Map` + disk persist to `./data/upstash-cache.json` | + +#### Exports + +```typescript +/** Retrieve cached JSON by key. Returns null on miss. */ +async function getCachedJson(key: string): Promise; + +/** Store JSON with a TTL in seconds. */ +async function setCachedJson(key: string, value: unknown, ttlSeconds: number): Promise; + +/** Batch-get multiple keys. Returns array in same order (null for misses). */ +async function mget(...keys: string[]): Promise<(T | null)[]>; + +/** Deterministic hash for building cache keys. */ +function hashString(str: string): string; +``` + +#### Sidecar Mode Details + +| Constant | Value | Description | +|----------|-------|-------------| +| `MAX_PERSIST_ENTRIES` | `5 000` | Max entries persisted to disk | +| Persist path | `./data/upstash-cache.json` | Location of disk snapshot | + +In sidecar mode, entries are evicted LRU-style when the Map exceeds `MAX_PERSIST_ENTRIES`. +The disk snapshot is read on cold start and written periodically. + +--- + +## Endpoints by Domain + +--- + +### Geopolitical + +Eight endpoints covering armed conflict, protest tracking, news intelligence, and maritime warnings. + +--- + +#### `GET /api/acled` + +ACLED protest and political violence events. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `country` | `string` | — | ISO country name filter (optional) | +| `limit` | `number` | `500` | Max events to return | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `ACLED_ACCESS_TOKEN` | Yes | `https://api.acleddata.com/acled/read` | +| `ACLED_EMAIL` | Yes | — | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| CDN | `s-maxage=600` | — | +| Upstash | 600 s | `acled:{query_hash}` | + +**Rate Limit**: 10 req/min via `createIpRateLimiter` + +**Response** + +```typescript +interface AcledResponse { + success: true; + data: AcledEvent[]; +} + +interface AcledEvent { + event_id_cnty: string; + event_date: string; // "YYYY-MM-DD" + event_type: string; + sub_event_type: string; + actor1: string; + country: string; + latitude: number; + longitude: number; + fatalities: number; + notes: string; +} +``` + +**Error Responses** + +| Status | Condition | +|--------|-----------| +| `429` | IP rate limit exceeded | +| `500` | Upstream ACLED API failure | +| `503` | Missing credentials — returns `{ success: true, data: [] }` gracefully | + +--- + +#### `GET /api/acled-conflict` + +ACLED conflict-specific events: battles, violence against civilians, explosions/remote violence. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `country` | `string` | — | ISO country name filter (optional) | +| `limit` | `number` | `500` | Max events to return | +| `days` | `number` | `30` | Lookback window in days | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `ACLED_ACCESS_TOKEN` | Yes | `https://api.acleddata.com/acled/read` (with `event_type` filter) | +| `ACLED_EMAIL` | Yes | — | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| CDN | `s-maxage=600` | — | +| Upstash | 600 s | `acled-conflict:{query_hash}` | + +**Rate Limit**: 10 req/min + +**Response**: Same shape as [`/api/acled`](#get-apiacled). + +--- + +#### `GET /api/ucdp` + +UCDP conflict catalog (paginated). + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `page` | `number` | `1` | Page number | +| `pagesize` | `number` | `100` | Items per page | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://ucdpapi.pcr.uu.se/api/` | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 86 400 s (24 h) | `ucdp:{page}:{pagesize}` | + +**Response** + +```typescript +interface UcdpCatalogResponse { + Result: UcdpConflict[]; + TotalCount: number; + NextPageUrl: string | null; + PreviousPageUrl: string | null; +} +``` + +--- + +#### `GET /api/ucdp-events` + +UCDP georeferenced events with automatic version discovery. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `pagesize` | `number` | `1000` | Items per page | +| `page` | `number` | `1` | Page number | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://ucdpapi.pcr.uu.se/api/gedevents/` | + +The endpoint auto-discovers the current-year version of the UCDP GED dataset. + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 21 600 s (6 h) | `ucdp-events:{version}:{page}:{pagesize}` | + +**Rate Limit**: 15 req/min + +**Response** + +```typescript +interface UcdpEventsResponse { + Result: UcdpGeoEvent[]; +} + +interface UcdpGeoEvent { + id: number; + type_of_violence: number; // 1=state, 2=non-state, 3=one-sided + country: string; + latitude: number; + longitude: number; + date_start: string; + date_end: string; + deaths_a: number; + deaths_b: number; + deaths_civilians: number; + best: number; // best estimate of total fatalities +} +``` + +--- + +#### `GET /api/gdelt-doc` + +GDELT article search. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `query` | `string` | **(required)** | Search query | +| `mode` | `string` | `"ArtList"` | GDELT query mode | +| `maxrecords` | `number` | `75` | Max articles | +| `timespan` | `string` | `"2d"` | Lookback window | +| `format` | `string` | `"json"` | Response format | +| `sourcelang` | `string` | — | Language filter (optional) | +| `domain` | `string` | — | Domain filter (optional) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://api.gdeltproject.org/api/v2/doc/doc` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=300` | +| Upstash | — (not cached) | + +**Response**: Passthrough JSON from GDELT API. + +**Error Responses** + +| Status | Condition | +|--------|-----------| +| `400` | Missing `query` parameter | +| `502` | Upstream GDELT failure | + +--- + +#### `GET /api/gdelt-geo` + +GDELT geographic data. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `query` | `string` | **(required)** | Search query | +| `format` | `string` | — | `GeoJSON`, `JSON`, or `CSV` | +| `timespan` | `string` | — | Lookback window | +| `mode` | `string` | — | GDELT geo mode | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://api.gdeltproject.org/api/v2/geo/geo` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=300` | + +**Validation**: Strict input validation on all parameters; malformed values are rejected. + +**Response**: GeoJSON `FeatureCollection` (when `format=GeoJSON`) or raw JSON/CSV passthrough. + +--- + +#### `GET /api/nga-warnings` + +NGA maritime warnings. + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://msi.gs.mil/api/publications/broadcast-warn` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=3600` | + +**Response**: Pure passthrough proxy — whatever the NGA API returns is forwarded as-is. + +--- + +#### `POST /api/country-intel` + +AI-generated country intelligence brief via Groq LLM. + +**Request Body** (max 50 KB) + +```typescript +interface CountryIntelRequest { + country: string; // required — country name or ISO code + context?: object; // optional additional context for the LLM +} +``` + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `GROQ_API_KEY` | Yes | Groq API (llama3 model) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 7 200 s (2 h) | `country-intel:{country_hash}` | + +**Response** + +```typescript +interface CountryIntelResponse { + brief: string; // markdown-formatted intelligence brief +} +``` + +**Error Responses** + +| Status | Condition | +|--------|-----------| +| `400` | Missing `country` in request body | +| `413` | Payload exceeds 50 KB | +| `500` | LLM processing failure | + +--- + +### Markets & Finance + +Nine endpoints covering equities, crypto, macro signals, and prediction markets. + +--- + +#### `GET /api/finnhub` + +Batch stock quotes (max 20 symbols per request). + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `symbols` | `string` | **(required)** | Comma-separated ticker symbols (max 20) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `FINNHUB_API_KEY` | Yes | `https://finnhub.io/api/v1/quote` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=60` | + +**Response** + +```typescript +interface FinnhubResponse { + [symbol: string]: { + c: number; // current price + d: number; // change (delta) + dp: number; // percent change (delta %) + h: number; // high of the day + l: number; // low of the day + o: number; // open price + pc: number; // previous close + t: number; // timestamp (unix) + }; +} +``` + +**Error Responses** + +| Status | Condition | +|--------|-----------| +| `400` | Missing `symbols` or more than 20 symbols | +| `502` | Upstream Finnhub failure | + +--- + +#### `GET /api/yahoo-finance` + +Single-symbol chart data from Yahoo Finance. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `symbol` | `string` | **(required)** | Ticker symbol | +| `range` | `string` | `"1d"` | Time range (1d, 5d, 1mo, 3mo, 6mo, 1y, 5y, max) | +| `interval` | `string` | `"5m"` | Data interval (1m, 5m, 15m, 1d, 1wk, 1mo) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://query1.finance.yahoo.com/v8/finance/chart/` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=60` | + +**Response**: Passthrough chart data with OHLCV (open, high, low, close, volume) arrays. + +--- + +#### `GET /api/coingecko` + +Cryptocurrency prices and market data from CoinGecko (free tier). + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `vs_currency` | `string` | `"usd"` | Fiat currency for prices | +| `ids` | `string` | — | Comma-separated CoinGecko coin IDs (optional) | +| `per_page` | `number` | `50` | Results per page | +| `sparkline` | `boolean` | `true` | Include 7-day sparkline data | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://api.coingecko.com/api/v3/coins/markets` | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 120 s | `coingecko:{hash}` | + +**Response** + +```typescript +type CoinGeckoResponse = CoinGeckoMarket[]; + +interface CoinGeckoMarket { + id: string; + symbol: string; + name: string; + current_price: number; + market_cap: number; + total_volume: number; + price_change_24h: number; + price_change_percentage_24h: number; + sparkline_in_7d?: { price: number[] }; + // ...additional CoinGecko fields +} +``` + +--- + +#### `GET /api/stablecoin-markets` + +Stablecoin health monitoring and depeg detection. + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | CoinGecko (specific stablecoin IDs: `tether`, `usd-coin`, `dai`, `frax`, `trueusd`, etc.) | + +**Caching** + +| Layer | TTL | +|-------|-----| +| In-memory | 120 s | + +**Response** + +```typescript +interface StablecoinMarketsResponse { + coins: StablecoinData[]; + timestamp: number; + unavailable?: boolean; // true when upstream is unreachable +} + +interface StablecoinData { + id: string; + symbol: string; + name: string; + current_price: number; + peg_deviation: number; // deviation from $1.00 + price_change_24h: number; + high_24h: number; + low_24h: number; + market_cap: number; +} +``` + +--- + +#### `GET /api/etf-flows` + +Bitcoin spot ETF flow estimation across 10 major ETFs. + +**Tracked ETFs**: `IBIT`, `FBTC`, `GBTC`, `ARKB`, `BITB`, `HODL`, `BRRR`, `EZBC`, `BTCW`, `BTCO` + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `FINNHUB_API_KEY` | Yes | Finnhub (volume-based flow estimation) | + +**Caching** + +| Layer | TTL | +|-------|-----| +| In-memory | 900 s (15 min) | + +**Response** + +```typescript +interface ETFFlowsResponse { + etfs: ETFFlow[]; + totalNetFlow: number; + timestamp: number; + unavailable?: boolean; +} + +interface ETFFlow { + symbol: string; + name: string; + volume: number; + estimatedFlow: number; + price: number; +} +``` + +--- + +#### `GET /api/stock-index` + +Country-level stock indices for 42 countries. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `symbols` | `string` | All 42 | Comma-separated index symbols (optional) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | Yahoo Finance (batch) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 3 600 s (1 h) | `stock-index:{hash}` | + +**Response** + +```typescript +interface StockIndexResponse { + indices: StockIndex[]; + timestamp: number; +} + +interface StockIndex { + symbol: string; + name: string; + country: string; + price: number; + change: number; + changePercent: number; +} +``` + +--- + +#### `GET /api/fred-data` + +FRED (Federal Reserve Economic Data) series observations. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `series_id` | `string` | **(required)** | FRED series ID (e.g., `DGS10`, `UNRATE`) | +| `limit` | `number` | `10` | Max observations | +| `sort_order` | `string` | `"desc"` | Sort order (`asc` or `desc`) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `FRED_API_KEY` | Yes | `https://api.stlouisfed.org/fred/series/observations` | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 3 600 s (1 h) | `fred:{series_id}:{limit}` | + +**Response** + +```typescript +interface FredDataResponse { + observations: FredObservation[]; +} + +interface FredObservation { + date: string; // "YYYY-MM-DD" + value: string; // numeric string +} +``` + +--- + +#### `GET /api/macro-signals` + +Six-source macro signal aggregation producing a BUY/CASH investment verdict. + +**Auth & External API** + +| Env Var | Required | Upstream URLs | +|---------|----------|---------------| +| `FRED_API_KEY` | Yes | FRED API | +| `FINNHUB_API_KEY` | Yes (partial) | Finnhub | +| — | — | CoinGecko, alternative.me (Fear & Greed), blockchain.info | + +**Caching** + +| Layer | TTL | +|-------|-----| +| In-memory | 300 s (5 min) | + +**Response** + +```typescript +interface MacroSignalsResponse { + verdict: "BUY" | "CASH"; + confidence: number; // 0–1 + signals: MacroSignal[]; + timestamp: number; + unavailable?: boolean; +} + +interface MacroSignal { + name: string; + // One of: liquidity, flowStructure, macroRegime, + // technicalTrend, hashRate, miningCost, fearGreed + value: number; + signal: "BUY" | "CASH" | "NEUTRAL"; + weight: number; + source: string; +} +``` + +--- + +#### `GET /api/polymarket` + +Prediction markets from Polymarket's Gamma API. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `limit` | `number` | `20` | Max markets to return | +| `active` | `boolean` | `true` | Only return active markets | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://gamma-api.polymarket.com/markets` | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| CDN | `s-maxage=300` | — | +| Upstash | 300 s | Polymarket cache key | + +**Response** + +```typescript +type PolymarketResponse = PredictionMarket[]; + +interface PredictionMarket { + id: string; + question: string; + outcomePrices: string[]; // array of price strings + volume: number; + endDate: string; + active: boolean; +} +``` + +--- + +### Military & Security + +Four endpoints covering aviation tracking, vessel tracking, theater readiness, and cyber threat intelligence. + +--- + +#### `GET /api/opensky` + +Real-time aircraft flight states within a geographic bounding box. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `lamin` | `number` | **(required)** | Latitude min (south) | +| `lomin` | `number` | **(required)** | Longitude min (west) | +| `lamax` | `number` | **(required)** | Latitude max (north) | +| `lomax` | `number` | **(required)** | Longitude max (east) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://opensky-network.org/api/states/all` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=15` | + +**Response** + +```typescript +interface OpenSkyResponse { + time: number; + states: FlightState[][]; +} + +// FlightState tuple indices: +// [0] icao24 string – ICAO 24-bit address +// [1] callsign string – callsign (trimmed) +// [2] origin_country string +// [3] time_position number – unix timestamp +// [4] last_contact number – unix timestamp +// [5] longitude number +// [6] latitude number +// [7] baro_altitude number – barometric altitude (m) +// [8] on_ground boolean +// [9] velocity number – m/s +// [10] true_track number – heading (degrees) +// [11] vertical_rate number – m/s +// [12] sensors number[] +// [13] geo_altitude number – geometric altitude (m) +// [14] squawk string +// [15] spi boolean – special purpose indicator +// [16] position_source number – 0=ADS-B, 1=ASTERIX, 2=MLAT +``` + +--- + +#### `GET /api/ais-snapshot` + +AIS vessel data from custom WebSocket relay. + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| `WS_RELAY_URL` | Yes | Custom WebSocket relay (configurable) | + +**Caching** — 3-tier + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=8` | +| Upstash | 8 s | +| In-memory | 4 s | + +**Response** + +```typescript +interface AISSnapshotResponse { + vessels: AISVessel[]; + timestamp: number; + count: number; +} + +interface AISVessel { + mmsi: number; + name?: string; + lat: number; + lon: number; + cog: number; // course over ground + sog: number; // speed over ground (knots) + heading?: number; + shipType?: number; + destination?: string; + timestamp: number; +} +``` + +--- + +#### `GET /api/theater-posture` + +Nine-theater military posture analysis combining aviation and Wingbits data. + +**Auth & External API** + +| Env Var | Required | Upstream URLs | +|---------|----------|---------------| +| — | — | OpenSky Network, Wingbits API | + +**Caching** — 3-tier Upstash with graduated TTLs + +| Key | TTL | Purpose | +|-----|-----|---------| +| `theater-posture:fresh` | 300 s (5 min) | Hot data | +| `theater-posture:warm` | 86 400 s (24 h) | Warm fallback | +| `theater-posture:cold` | 604 800 s (7 d) | Cold fallback | + +No per-request rate limit — the endpoint is aggressively cached. + +**Response** + +```typescript +interface TheaterPostureResponse { + theaters: TheaterPosture[]; + globalReadiness: number; // 0–1 composite score + timestamp: number; +} + +interface TheaterPosture { + region: string; // e.g., "EUCOM", "INDOPACOM" + alertLevel: string; // "LOW" | "ELEVATED" | "HIGH" | "CRITICAL" + flightActivity: number; + assessment: string; // human-readable assessment +} +``` + +--- + +#### `GET /api/cyber-threats` + +Five-source cyber threat intelligence aggregation with geolocation hydration. + +**Auth & External API** + +| Env Var | Required | Upstream URLs | +|---------|----------|---------------| +| `ABUSEIPDB_API_KEY` | Optional | AbuseIPDB | +| — | — | Feodo Tracker, URLhaus, C2IntelFeeds, AlienVault OTX | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 600 s (10 min) | `cyber-threats:v2` | + +**Rate Limit**: 20 req/min + +**Response** + +```typescript +interface CyberThreatsResponse { + threats: CyberThreat[]; + sources: string[]; + timestamp: number; +} + +interface CyberThreat { + ip: string; + type: string; // "botnet" | "c2" | "malware" | "scanner" | ... + source: string; // originating feed + country: string; + latitude: number; + longitude: number; + confidence: number; // 0–100 + tags: string[]; +} +``` + +--- + +### Natural Events + +Three endpoints for seismology, active fires, and climate anomaly data. + +--- + +#### `GET /api/earthquakes` + +USGS earthquakes M4.5+ for the past week (GeoJSON). + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=300` | + +**Response**: Standard USGS GeoJSON `FeatureCollection`. + +```typescript +interface EarthquakeFeature { + type: "Feature"; + properties: { + mag: number; + place: string; + time: number; // unix ms + url: string; + tsunami: number; // 0 or 1 + type: string; + }; + geometry: { + type: "Point"; + coordinates: [number, number, number]; // [lon, lat, depth_km] + }; +} +``` + +--- + +#### `GET /api/firms-fires` + +NASA FIRMS satellite fire detections across 9 global regions. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `region` | `string` | All 9 regions | Specific region filter (optional) | +| `days` | `number` | `1` | Lookback in days | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `NASA_FIRMS_API_KEY` | Yes | `https://firms.modaps.eosdis.nasa.gov/api/area/csv/` | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 600 s | Per-region key | + +**Response** + +```typescript +interface FIRMSResponse { + fires: FIRMSFire[]; + count: number; + regions: string[]; +} + +interface FIRMSFire { + latitude: number; + longitude: number; + brightness: number; + frp: number; // fire radiative power (MW) + confidence: string; // "low" | "nominal" | "high" + acq_date: string; // "YYYY-MM-DD" + acq_time: string; // "HHMM" UTC + satellite: string; // "MODIS" | "VIIRS" | ... +} +``` + +--- + +#### `GET /api/climate-anomalies` + +Temperature and precipitation anomalies for 15 global climate zones. + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | NOAA Climate Monitoring | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 21 600 s (6 h) | `climate-anomalies:v1` | + +**Rate Limit**: 15 req/min + +**Response** + +```typescript +interface ClimateAnomaliesResponse { + zones: ClimateZone[]; + globalAnomaly: number; + timestamp: number; +} + +interface ClimateZone { + name: string; + tempAnomaly: number; // °C above/below baseline + precipAnomaly: number; // mm above/below baseline + severity: string; // "normal" | "moderate" | "severe" | "extreme" +} +``` + +--- + +### AI / ML + +Four endpoints for LLM-powered classification and summarization. + +--- + +#### `POST /api/classify-batch` + +Batch threat/event classification (max 20 items per request). + +**Request Body** + +```typescript +interface ClassifyBatchRequest { + items: ClassifyItem[]; // max 20 +} + +interface ClassifyItem { + title: string; + description?: string; +} +``` + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| `GROQ_API_KEY` | Yes | Groq (llama3) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 86 400 s (24 h) | Per-item hash | + +**Response** + +```typescript +interface ClassifyBatchResponse { + results: Classification[]; +} + +interface Classification { + category: string; + severity: string; // "low" | "medium" | "high" | "critical" + confidence: number; // 0–1 + tags: string[]; +} +``` + +--- + +#### `GET /api/classify-event` + +Single event classification. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `title` | `string` | **(required)** | Event title | +| `description` | `string` | — | Event description (optional) | + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| `GROQ_API_KEY` | Yes | Groq (llama3) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 86 400 s (24 h) | `classify:{hash}` | + +**Response** + +```typescript +interface ClassifyEventResponse { + category: string; + severity: string; + confidence: number; + tags: string[]; +} +``` + +--- + +#### `POST /api/groq-summarize` + +News article summarization via Groq LLM (primary). + +**Request Body** (max 50 KB) + +```typescript +interface SummarizeRequest { + text: string; // article text to summarize + type?: string; // content type hint + panelId?: string; // originating panel identifier +} +``` + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| `GROQ_API_KEY` | Yes | Groq API | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 3 600 s (1 h) | `summary:{hash}` (shared with `openrouter-summarize`) | + +> **Note**: The cache key is shared with `/api/openrouter-summarize` — a summary cached by one endpoint is served by the other. + +**Response** + +```typescript +interface SummarizeResponse { + summary: string; +} +``` + +--- + +#### `POST /api/openrouter-summarize` + +Fallback summarization via free-tier OpenRouter models. + +**Request Body**: Same as [`/api/groq-summarize`](#post-apigroq-summarize) (max 50 KB). + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| `OPENROUTER_API_KEY` | Yes | OpenRouter (free-tier models) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 3 600 s (1 h) | `summary:{hash}` (shared with `groq-summarize`) | + +**Response**: Same as [`/api/groq-summarize`](#post-apigroq-summarize). + +--- + +### Infrastructure + +Three endpoints for monitoring internet outages, service health, and airspace status. + +--- + +#### `GET /api/cloudflare-outages` + +Cloudflare Radar internet outage annotations. + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `CLOUDFLARE_API_TOKEN` | Yes | `https://api.cloudflare.com/client/v4/radar/annotations/outages` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=600` | +| Upstash | 600 s | + +**Response** + +```typescript +interface CloudflareOutagesResponse { + outages: CloudflareOutage[]; + count: number; +} + +interface CloudflareOutage { + id: string; + name: string; + scope: string; + asns: number[]; + locations: string[]; + startDate: string; + endDate?: string; + eventType: string; +} +``` + +--- + +#### `GET /api/service-status` + +Aggregated operational status of 33 major internet services. + +**Auth & External API** + +| Env Var | Required | Upstream URLs | +|---------|----------|---------------| +| — | — | 33 public status pages (`*.statuspage.io`, `status.*`) | + +**Monitored Services** (selection): GitHub, Cloudflare, AWS, Stripe, Vercel, Datadog, PagerDuty, Twilio, Heroku, Atlassian, npm, PyPI, and more. + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=60` | +| In-memory | 60 s | + +**Response** + +```typescript +interface ServiceStatusResponse { + services: ServiceStatus[]; +} + +interface ServiceStatus { + name: string; + status: "operational" | "degraded" | "major" | "critical"; + url: string; + indicator: string; +} +``` + +--- + +#### `GET /api/faa-status` + +FAA National Airspace System status. + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://soa.smext.faa.gov/asws/api/airport/status` (XML) | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=300` | + +**Response**: XML-to-JSON passthrough of FAA airport status data. + +--- + +### Humanitarian + +Four endpoints covering refugee displacement, conflict events, population exposure, and development indicators. + +--- + +#### `GET /api/unhcr-population` + +UNHCR displacement data (paginated). + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `year` | `number` | — | Filter by year (optional) | +| `limit` | `number` | `100` | Results per page | +| `page` | `number` | `1` | Page number | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://api.unhcr.org/population/v1/` | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 86 400 s (24 h) | `unhcr:{hash}` | + +**Rate Limit**: 20 req/min + +**Response** + +```typescript +interface UNHCRResponse { + items: DisplacementRecord[]; + pagination: { + page: number; + pages: number; + total: number; + }; +} + +interface DisplacementRecord { + year: number; + country_of_origin: string; + country_of_asylum: string; + refugees: number; + asylum_seekers: number; + internally_displaced: number; + stateless: number; +} +``` + +--- + +#### `GET /api/hapi` + +HDX HAPI (Humanitarian API) conflict events. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `limit` | `number` | `1000` | Max events to return | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `HDX_APP_IDENTIFIER` | Optional | `https://hapi.humdata.org/api/v2/coordination/conflict-event` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| Upstash | 21 600 s (6 h) | + +**Response** + +```typescript +interface HapiResponse { + data: HapiConflictEvent[]; +} + +interface HapiConflictEvent { + event_type: string; + admin1: string; + admin2: string; + location_name: string; + country: string; + date: string; + fatalities: number; + latitude: number; + longitude: number; +} +``` + +--- + +#### `GET /api/worldpop-exposure` + +Population exposure analysis for conflict zones, earthquakes, floods, and fires. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `country` | `string` | **(required)** | ISO 3166-1 alpha-3 code | +| `mode` | `string` | **(required)** | Analysis mode: `conflict`, `earthquake`, `flood`, or `fire` | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | WorldPop (raster data) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 604 800 s (7 d) | `worldpop:{country}:{mode}` | + +**Rate Limit**: 30 req/min + +**Response** + +```typescript +interface PopulationExposure { + country: string; + mode: "conflict" | "earthquake" | "flood" | "fire"; + exposedPopulation: number; + totalPopulation: number; + percentage: number; // 0–100 +} +``` + +--- + +#### `GET /api/worldbank` + +World Bank development indicators for 47 countries. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `indicator` | `string` | **(required)** | World Bank indicator code (e.g., `NY.GDP.MKTP.CD`) | +| `country` | `string` | All 47 | ISO country code filter (optional) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://api.worldbank.org/v2/` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| Upstash | 86 400 s (24 h) | + +**Response** + +```typescript +interface WorldBankResponse { + data: WorldBankIndicator[]; +} + +interface WorldBankIndicator { + country: { id: string; value: string }; + date: string; + value: number | null; + indicator: { id: string; value: string }; +} +``` + +--- + +### Content + +Five endpoints for news feeds, trending repositories, tech events, and research papers. + +--- + +#### `GET /api/rss-proxy` + +RSS/Atom feed proxy with a domain allowlist (~150 domains). + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `url` | `string` | **(required)** | Full RSS/Atom feed URL | + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| — | — | The URL provided (if domain is on allowlist) | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=300` | + +**Security** + +- ~150 allowed domains hardcoded in the endpoint +- Requests to non-allowed domains are rejected with **403** +- 12-second fetch timeout + +**Response**: Raw RSS/Atom XML passthrough with appropriate `Content-Type`. + +**Error Responses** + +| Status | Condition | +|--------|-----------| +| `400` | Missing `url` parameter | +| `403` | Domain not on allowlist | +| `504` | Upstream fetch timeout (> 12 s) | + +--- + +#### `GET /api/hackernews` + +Hacker News stories feed. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `type` | `string` | `"top"` | Feed type: `top`, `new`, `best`, `ask`, `show`, `job` | +| `limit` | `number` | `30` | Max stories to return | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://hacker-news.firebaseio.com/v0/` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=300` | + +**Response** + +```typescript +type HackerNewsResponse = HNStory[]; + +interface HNStory { + id: number; + title: string; + url?: string; + score: number; + by: string; + time: number; // unix timestamp + descendants: number; // comment count +} +``` + +--- + +#### `GET /api/github-trending` + +GitHub trending repositories. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `since` | `string` | `"daily"` | Time window: `daily`, `weekly`, `monthly` | +| `language` | `string` | — | Programming language filter (optional) | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `GITHUB_TOKEN` | Optional (higher rate limits) | GitHub API v3 (`search/repositories`) + HTML scrape fallback | + +**Caching** + +| Layer | TTL | +|-------|-----| +| Upstash | 3 600 s (1 h) | + +**Response** + +```typescript +type GitHubTrendingResponse = TrendingRepo[]; + +interface TrendingRepo { + name: string; + owner: string; + description: string; + stars: number; + forks: number; + language: string | null; + todayStars: number; + url: string; +} +``` + +--- + +#### `GET /api/tech-events` + +Tech conferences and events with automated geocoding. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `days` | `number` | `90` | Forward-looking window in days | +| `limit` | `number` | `50` | Max events to return | + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| — | — | Scraped from tech event sources | + +**Caching** + +| Layer | TTL | +|-------|-----| +| Upstash | 21 600 s (6 h) | + +**Response** + +```typescript +type TechEventsResponse = TechEvent[]; + +interface TechEvent { + name: string; + date: string; + location: string; + lat: number; + lng: number; + category: string; + url: string; +} +``` + +> **Note**: The endpoint includes a 500+ city geocoding lookup table for resolving event locations to coordinates. + +--- + +#### `GET /api/arxiv` + +ArXiv research paper search (XML passthrough). + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `search_query` | `string` | **(required)** | ArXiv query string | +| `max_results` | `number` | `10` | Max papers to return | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://export.arxiv.org/api/query` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=3600` | + +**Response**: XML Atom feed passthrough (Content-Type: `application/xml`). + +--- + +### Meta + +Six utility endpoints for version checking, telemetry, downloads, and social sharing. + +--- + +#### `GET /api/version` + +Latest GitHub release version info. + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| `GITHUB_TOKEN` | Optional | GitHub Releases API | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=600` | + +**Response** + +```typescript +interface VersionResponse { + version: string; // e.g., "1.4.2" + url: string; // release page URL + published_at: string; // ISO 8601 date +} +``` + +--- + +#### `GET /api/cache-telemetry` + +In-memory cache telemetry snapshot (diagnostic). + +**Caching**: `Cache-Control: no-store` — never cached. + +**Response**: Output of `getCacheTelemetrySnapshot()` — per-endpoint hit/miss/stale counts and hit rates. See [`_cache-telemetry.js`](#_cache-telemetryjs) for the schema. + +--- + +#### `GET /api/debug-env` + +Dead endpoint — always returns 404. + +**Response** + +```json +{ "error": "Not available" } +``` + +Status: **404** + +--- + +#### `GET /api/download` + +Platform-specific desktop installer redirect. + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `platform` | `string` | **(required)** | Target platform: `macos-arm64`, `macos-x64`, `windows`, `linux` | + +**Response**: **302 redirect** to the corresponding GitHub release asset URL. + +--- + +#### `GET /api/og-story` + +SVG social preview card generator. + +> **Runtime**: Node.js (NOT Edge Function) + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `title` | `string` | — | Card title | +| `subtitle` | `string` | — | Card subtitle | +| `variant` | `string` | — | Visual variant (optional) | + +**Response**: SVG image (`Content-Type: image/svg+xml`). + +--- + +#### `GET /api/story` + +OG meta page for social sharing — serves HTML with Open Graph tags to crawlers and redirects real users. + +> **Runtime**: Node.js (NOT Edge Function) + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `id` | `string` | **(required)** | Story ID | + +**Response**: + +- **Bot user-agents**: HTML page with `` tags +- **Real browsers**: **302 redirect** to the dashboard with the story pre-selected + +--- + +### Risk & Baseline + +Two endpoints for pre-computed risk scores and temporal statistical baselines. + +--- + +#### `GET /api/risk-scores` + +Pre-computed Country Instability Index (CII) for 20 Tier-1 countries. + +**Auth & External API** + +| Env Var | Required | Upstream | +|---------|----------|----------| +| — | — | Pre-computed (no external calls at request time) | + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 600 s (10 min) | `risk-scores:v1` | + +**Response** + +```typescript +interface RiskScoresResponse { + scores: RiskScore[]; + timestamp: number; +} + +interface RiskScore { + country: string; + countryCode: string; + cii: number; // Country Instability Index (0–100) + components: { + conflict: number; + economic: number; + governance: number; + social: number; + }; + trend: "improving" | "stable" | "deteriorating"; +} +``` + +--- + +#### `GET /api/temporal-baseline` / `POST /api/temporal-baseline` + +Welford's online algorithm for maintaining streaming temporal baselines. + +##### `GET` — Read Baseline + +**Query Parameters** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `key` | `string` | **(required)** | Baseline key | + +**Response** + +```typescript +interface BaselineReadResponse { + mean: number; + variance: number; + stddev: number; + count: number; + lastUpdated: string; // ISO 8601 +} +``` + +##### `POST` — Update Baseline + +**Request Body** + +```typescript +interface BaselineUpdateRequest { + key: string; + value: number; +} +``` + +**Response** + +```typescript +interface BaselineUpdateResponse { + updated: true; + stats: { + mean: number; + variance: number; + stddev: number; + count: number; + }; +} +``` + +**Caching** + +| Layer | TTL | Key | +|-------|-----|-----| +| Upstash | 7 776 000 s (90 d) | `baseline:{key}` | + +--- + +### Proxy / Passthrough Subdirectories + +Four catch-all proxy groups that forward requests to external APIs, injecting credentials where needed. + +--- + +#### `GET /api/eia/*` + +EIA (Energy Information Administration) energy data proxy. + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `EIA_API_KEY` | Yes | `https://api.eia.gov/v2/` — path suffix is appended | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=3600` | + +**Behaviour**: The API key is injected server-side; the request path after `/api/eia/` is forwarded to the EIA API verbatim. + +--- + +#### `GET /api/pizzint/*` + +Proxy to pizzint.watch intelligence APIs. + +**Endpoints** + +| Path | Purpose | +|------|---------| +| `/api/pizzint/dashboard-data` | Dashboard data | +| `/api/pizzint/gdelt/batch` | GDELT batch queries | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| — | — | `https://pizzint.watch/` | + +**Caching** + +| Layer | TTL | +|-------|-----| +| CDN | `s-maxage=120` | + +--- + +#### `GET /api/wingbits/*` + +Wingbits aircraft tracking data proxy. + +**Endpoints** + +| Path | Cache TTL | Purpose | +|------|-----------|---------| +| `/api/wingbits/flights` | `s-maxage=15` | Live flight positions | +| `/api/wingbits/details` | `s-maxage=300` | Aircraft details | +| `/api/wingbits/batch` | `s-maxage=15` | Batch flight queries | + +**Auth & External API** + +| Env Var | Required | Upstream URL | +|---------|----------|--------------| +| `WINGBITS_API_KEY` | Yes | `https://data.wingbits.com/` | + +--- + +#### `GET /api/youtube/*` + +YouTube integration endpoints. + +**Endpoints** + +| Path | Purpose | Response | +|------|---------|----------| +| `/api/youtube/embed` | HTML embed player page | HTML document | +| `/api/youtube/live` | Channel live video scraper | `{ videoId: string }` | + +**Auth**: None — uses public YouTube pages. + +--- + +## Error Handling + +All endpoints return errors in a consistent JSON envelope: + +```typescript +interface ErrorResponse { + error: string; // human-readable message + status?: number; // HTTP status code (sometimes included) + details?: string; // additional context (dev-mode only) +} +``` + +### Standard HTTP Status Codes + +| Status | Meaning | Common Trigger | +|--------|---------|----------------| +| `400` | Bad Request | Missing or invalid required parameters | +| `403` | Forbidden | CORS origin not on allowlist | +| `404` | Not Found | Invalid endpoint path | +| `405` | Method Not Allowed | Wrong HTTP method (e.g., POST to a GET-only endpoint) | +| `413` | Payload Too Large | Request body exceeds size limit (e.g., 50 KB for LLM endpoints) | +| `429` | Too Many Requests | IP rate limit exceeded — includes `Retry-After` header | +| `500` | Internal Server Error | Unhandled exception or upstream processing failure | +| `502` | Bad Gateway | Upstream API returned an error | +| `503` | Service Unavailable | Missing credentials; some endpoints degrade gracefully | +| `504` | Gateway Timeout | Upstream API did not respond within timeout | + +### Graceful Degradation + +Many endpoints are designed to **never hard-fail** on credential or upstream issues: + +- Missing `ACLED_*` credentials → `{ success: true, data: [] }` +- Unreachable upstream → `{ unavailable: true, ... }` with stale cached data when available +- The 3-tier cache (fresh → warm → cold) ensures `theater-posture` almost always returns data + +--- + +## Rate Limiting + +### Global Defaults + +The default rate limiter configuration (from `_ip-rate-limit.js`): + +| Parameter | Value | +|-----------|-------| +| Requests per window | 60 | +| Window duration | 60 seconds | +| Max tracked IPs | 10 000 | +| Cleanup interval | 5 minutes | + +### Per-Endpoint Overrides + +| Endpoint | Limit | Window | +|----------|-------|--------| +| `/api/acled` | 10 req | 1 min | +| `/api/acled-conflict` | 10 req | 1 min | +| `/api/ucdp-events` | 15 req | 1 min | +| `/api/climate-anomalies` | 15 req | 1 min | +| `/api/cyber-threats` | 20 req | 1 min | +| `/api/unhcr-population` | 20 req | 1 min | +| `/api/worldpop-exposure` | 30 req | 1 min | + +### Rate Limit Response + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 42 +Content-Type: application/json + +{ + "error": "Rate limit exceeded", + "retryAfter": 42 +} +``` + +--- + +## Caching Architecture + +World Monitor uses a **multi-tier caching strategy** to minimize upstream API calls, reduce latency, and stay within third-party rate limits. + +### Tier Overview + +``` +┌──────────────────────────────────────────────────┐ +│ Client │ +└──────────────────┬───────────────────────────────┘ + │ +┌──────────────────▼───────────────────────────────┐ +│ Tier 1: Vercel CDN Edge Cache │ +│ ───────────────────────────────── │ +│ • Cache-Control: s-maxage= │ +│ • stale-while-revalidate= │ +│ • Fastest — zero origin hit on cache hit │ +│ • TTLs: 15 s (real-time) to 3600 s (static) │ +└──────────────────┬───────────────────────────────┘ + │ (CDN MISS) +┌──────────────────▼───────────────────────────────┐ +│ Tier 2: Upstash Redis │ +│ ───────────────────────────────── │ +│ • Distributed, shared across all Edge instances │ +│ • Key-value with per-key TTL │ +│ • Used for expensive/quota-limited upstreams │ +│ • TTLs: 120 s (crypto) to 7 776 000 s (90 d) │ +│ • Sidecar mode: in-memory Map + disk persist │ +└──────────────────┬───────────────────────────────┘ + │ (Upstash MISS) +┌──────────────────▼───────────────────────────────┐ +│ Tier 3: In-Memory Map (per-instance) │ +│ ───────────────────────────────── │ +│ • Process-local, fastest read path │ +│ • Used as write-through for hot endpoints │ +│ • Bounded by MAX_PERSIST_ENTRIES (5000) │ +│ • Survives within a single Edge invocation │ +└──────────────────┬───────────────────────────────┘ + │ (All MISS) +┌──────────────────▼───────────────────────────────┐ +│ Origin: External API │ +│ ───────────────────────────────── │ +│ • Actual upstream fetch │ +│ • Result written back to all applicable tiers │ +└──────────────────────────────────────────────────┘ +``` + +### Cache Key Conventions + +| Pattern | Example | Used By | +|---------|---------|---------| +| `{endpoint}:{hash}` | `acled:a1b2c3d4` | Most endpoints | +| `{endpoint}:{param}:{param}` | `ucdp:1:100` | Simple paginators | +| `{endpoint}:v{N}` | `cyber-threats:v2` | Versioned caches | +| `{endpoint}:{tier}` | `theater-posture:fresh` | Multi-tier graduated | +| `summary:{hash}` | `summary:e5f6g7h8` | Shared across groq + openrouter | +| `baseline:{key}` | `baseline:earthquake-rate` | Temporal baselines | + +### Cache Telemetry + +The `_cache-telemetry.js` module records HIT/MISS/STALE per endpoint, accessible via `GET /api/cache-telemetry`. This provides per-instance visibility into cache performance: + +```json +{ + "acled": { "hit": 142, "miss": 8, "stale": 3, "total": 153, "hitRate": 0.928 }, + "earthquakes": { "hit": 891, "miss": 12, "stale": 0, "total": 903, "hitRate": 0.987 } +} +``` + +### TTL Reference Table + +| TTL | Duration | Endpoints | +|-----|----------|-----------| +| 8–15 s | Real-time | `ais-snapshot`, `opensky`, `wingbits/flights` | +| 60 s | 1 min | `finnhub`, `yahoo-finance`, `service-status` | +| 120 s | 2 min | `coingecko`, `stablecoin-markets`, `pizzint/*` | +| 300 s | 5 min | `gdelt-doc`, `gdelt-geo`, `rss-proxy`, `hackernews`, `polymarket`, `theater-posture:fresh` | +| 600 s | 10 min | `acled`, `acled-conflict`, `firms-fires`, `cyber-threats`, `cloudflare-outages`, `risk-scores`, `version` | +| 900 s | 15 min | `etf-flows` | +| 3 600 s | 1 h | `nga-warnings`, `stock-index`, `fred-data`, `groq-summarize`, `openrouter-summarize`, `github-trending`, `arxiv`, `eia/*` | +| 7 200 s | 2 h | `country-intel` | +| 21 600 s | 6 h | `ucdp-events`, `climate-anomalies`, `hapi`, `tech-events` | +| 86 400 s | 24 h | `ucdp`, `unhcr-population`, `worldbank`, `classify-batch`, `classify-event`, `theater-posture:warm` | +| 604 800 s | 7 d | `worldpop-exposure`, `theater-posture:cold` | +| 7 776 000 s | 90 d | `temporal-baseline` | + +--- + +## Environment Variables Reference + +Complete list of environment variables used across all endpoints: + +| Variable | Required By | Description | +|----------|-------------|-------------| +| `ACLED_ACCESS_TOKEN` | `acled`, `acled-conflict` | ACLED API access token | +| `ACLED_EMAIL` | `acled`, `acled-conflict` | ACLED registered email | +| `ABUSEIPDB_API_KEY` | `cyber-threats` (optional) | AbuseIPDB API key | +| `CLOUDFLARE_API_TOKEN` | `cloudflare-outages` | Cloudflare Radar API token | +| `EIA_API_KEY` | `eia/*` | EIA energy data API key | +| `FINNHUB_API_KEY` | `finnhub`, `etf-flows`, `macro-signals` | Finnhub stock data API key | +| `FRED_API_KEY` | `fred-data`, `macro-signals` | FRED economic data API key | +| `GITHUB_TOKEN` | `version`, `github-trending` (optional) | GitHub API token for higher rate limits | +| `GROQ_API_KEY` | `classify-batch`, `classify-event`, `groq-summarize`, `country-intel` | Groq LLM API key | +| `HDX_APP_IDENTIFIER` | `hapi` (optional) | HDX HAPI app identifier | +| `NASA_FIRMS_API_KEY` | `firms-fires` | NASA FIRMS fire data API key | +| `OPENROUTER_API_KEY` | `openrouter-summarize` | OpenRouter API key | +| `UPSTASH_REDIS_REST_URL` | `_upstash-cache` (cloud mode) | Upstash Redis REST endpoint | +| `UPSTASH_REDIS_REST_TOKEN` | `_upstash-cache` (cloud mode) | Upstash Redis REST token | +| `WINGBITS_API_KEY` | `wingbits/*` | Wingbits aircraft data API key | +| `WS_RELAY_URL` | `ais-snapshot` | AIS WebSocket relay URL | +| `SIDECAR` | `_upstash-cache` (sidecar mode) | Set to `"true"` for local disk-backed cache | diff --git a/docs/Docs_To_Review/ARCHITECTURE.md b/docs/Docs_To_Review/ARCHITECTURE.md new file mode 100644 index 000000000..5e140f225 --- /dev/null +++ b/docs/Docs_To_Review/ARCHITECTURE.md @@ -0,0 +1,937 @@ +# Architecture + +World Monitor is an AI-powered real-time global intelligence dashboard built as a TypeScript single-page application. It aggregates 30+ external data sources — covering geopolitics, military activity, financial markets, cyber threats, climate events, and more — into a unified operational picture rendered through an interactive 3D globe and a grid of specialised panels. + +This document covers the full system architecture: deployment topology, variant configuration, data pipelines, signal intelligence, map rendering, caching, desktop packaging, machine-learning inference, and error handling. + +--- + +## Table of Contents + +1. [High-Level System Diagram](#1-high-level-system-diagram) +2. [Variant Architecture](#2-variant-architecture) +3. [Data Flow: RSS Ingestion to Display](#3-data-flow-rss-ingestion-to-display) +4. [Signal Intelligence Pipeline](#4-signal-intelligence-pipeline) +5. [Map Rendering Pipeline](#5-map-rendering-pipeline) +6. [Caching Architecture](#6-caching-architecture) +7. [Desktop Architecture](#7-desktop-architecture) +8. [ML Pipeline](#8-ml-pipeline) +9. [Error Handling Hierarchy](#9-error-handling-hierarchy) + +--- + +## 1. High-Level System Diagram + +The system follows a classic edge-compute pattern: a static SPA served from a CDN communicates with serverless API endpoints that proxy, normalise, and cache upstream data. + +```mermaid +graph TD + subgraph Browser + SPA["TypeScript SPA
(Vite 6, class-based)"] + SW["Service Worker
(Workbox)"] + IDB["IndexedDB
(snapshots & baselines)"] + MLW["ML Web Worker
(ONNX / Transformers.js)"] + SPA --> SW + SPA --> IDB + SPA --> MLW + end + + subgraph Vercel["Vercel Edge Functions"] + API["60+ API Endpoints
(api/ directory, plain JS)"] + end + + subgraph External["External APIs (30+)"] + RSS["RSS Feeds"] + ACLED["ACLED"] + UCDP["UCDP"] + GDELT["GDELT"] + OpenSky["OpenSky"] + Finnhub["Finnhub"] + Yahoo["Yahoo Finance"] + FRED["FRED"] + CoinGecko["CoinGecko"] + Polymarket["Polymarket"] + FIRMS["NASA FIRMS"] + GROQ["Groq / OpenRouter"] + Others["+ 20 more"] + end + + subgraph Cache["Upstash Redis"] + Redis["Server-side
API Response Cache"] + end + + subgraph Desktop["Tauri Desktop Shell"] + Tauri["Tauri 2 (Rust)"] + Sidecar["Node.js Sidecar
127.0.0.1:46123"] + Tauri --> Sidecar + end + + SPA <-->|"fetch()"| API + SPA <-->|"Tauri IPC"| Tauri + SPA <-->|"fetch()"| Sidecar + API <--> Redis + API <--> RSS + API <--> ACLED + API <--> UCDP + API <--> GDELT + API <--> OpenSky + API <--> Finnhub + API <--> Yahoo + API <--> FRED + API <--> CoinGecko + API <--> Polymarket + API <--> FIRMS + API <--> GROQ + API <--> Others +``` + +### Component Summary + +| Layer | Technology | Role | +|---|---|---| +| **SPA** | TypeScript, Vite 6, no framework | UI rendering via class-based components extending a `Panel` base class. 44 panels in the full variant. | +| **Vercel Edge Functions** | Plain JS (60+ files in api/) | Proxy, normalise, and cache upstream API calls. Each file exports a default Vercel handler. | +| **External APIs** | 30+ heterogeneous sources | RSS feeds, conflict databases (ACLED, UCDP), geospatial (GDELT, NASA FIRMS, OpenSky), markets (Finnhub, Yahoo Finance, CoinGecko), LLMs (Groq, OpenRouter), and more. | +| **Upstash Redis** | Redis REST API | Server-side response cache with TTL-based expiry. Falls back to in-memory Map in sidecar mode. | +| **Service Worker** | Workbox | Offline support, runtime caching strategies, background sync. | +| **IndexedDB** | `worldmonitor_db` | Client-side storage for playback snapshots and temporal baseline data. | +| **Tauri Shell** | Tauri 2 (Rust) + Node.js sidecar | Desktop packaging. Sidecar runs a local API server; Rust layer provides OS keychain, window management, and IPC. | +| **ML Worker** | Web Worker + ONNX Runtime / Transformers.js | In-browser inference for embeddings, sentiment, summarisation, and NER. | + +--- + +## 2. Variant Architecture + +World Monitor ships as three product variants from a single codebase. Each variant surfaces a different subset of panels, map layers, and data sources. + +| Variant | Domain | Focus | +|---|---|---| +| `full` | worldmonitor.app | Geopolitics, military, OSINT, conflicts, markets | +| `tech` | tech.worldmonitor.app | AI/ML, startups, cybersecurity, developer tools | +| `finance` | finance.worldmonitor.app | Markets, trading, central banks, macro indicators | + +### Variant Resolution + +The active variant is resolved at startup in src/config/variant.ts via a strict priority chain: + +``` +localStorage('worldmonitor-variant') → import.meta.env.VITE_VARIANT → default 'full' +``` + +The exported constant `SITE_VARIANT` is computed once as an IIFE: + +```typescript +export const SITE_VARIANT: string = (() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('worldmonitor-variant'); + if (stored === 'tech' || stored === 'full' || stored === 'finance') return stored; + } + return import.meta.env.VITE_VARIANT || 'full'; +})(); +``` + +The `localStorage` override enables runtime variant switching on the settings page without a rebuild. The `VITE_VARIANT` env var is set at deploy time (one Vercel project per subdomain). + +### Configuration Tree-Shaking + +```mermaid +graph TD + subgraph ConfigTree["src/config/variants/"] + Base["base.ts
VariantConfig interface
API_URLS, REFRESH_INTERVALS
STORAGE_KEYS, MONITOR_COLORS"] + Full["full.ts
VARIANT_CONFIG"] + Tech["tech.ts
VARIANT_CONFIG"] + Finance["finance.ts
VARIANT_CONFIG"] + Base --> Full + Base --> Tech + Base --> Finance + end + + subgraph Panels["src/config/panels.ts"] + FP["FULL_PANELS (44)"] + FM["FULL_MAP_LAYERS (35+)"] + FMM["FULL_MOBILE_MAP_LAYERS"] + TP["TECH_PANELS"] + TM["TECH_MAP_LAYERS"] + FiP["FINANCE_PANELS"] + FiM["FINANCE_MAP_LAYERS"] + end + + Variant["SITE_VARIANT"] --> Switch{"Ternary switch"} + Switch -->|"full"| FP + Switch -->|"tech"| TP + Switch -->|"finance"| FiP + + Switch --> DefaultPanels["DEFAULT_PANELS"] + Switch --> DefaultLayers["DEFAULT_MAP_LAYERS"] + Switch --> MobileLayers["MOBILE_DEFAULT_MAP_LAYERS"] +``` + +The `VariantConfig` interface in src/config/variants/base.ts defines the shape: + +```typescript +interface VariantConfig { + name: string; + description: string; + panels: Record; + mapLayers: MapLayers; + mobileMapLayers: MapLayers; +} +``` + +Each variant file (full.ts, tech.ts, finance.ts) exports a `VARIANT_CONFIG` conforming to this interface. The shared base re-exports common constants: `API_URLS`, `REFRESH_INTERVALS`, `STORAGE_KEYS`, `MONITOR_COLORS`, `SECTORS`, `COMMODITIES`, `MARKET_SYMBOLS`, `UNDERSEA_CABLES`, and `AI_DATA_CENTERS`. + +At build time, Vite's tree-shaking eliminates the unused variant configs. If `VITE_VARIANT=tech`, the full and finance panel definitions are dead-code-eliminated from the production bundle. + +At runtime, src/config/panels.ts selects the active config via ternary expressions: + +```typescript +export const DEFAULT_PANELS = SITE_VARIANT === 'tech' + ? TECH_PANELS + : SITE_VARIANT === 'finance' + ? FINANCE_PANELS + : FULL_PANELS; +``` + +The same pattern applies to `DEFAULT_MAP_LAYERS` and `MOBILE_DEFAULT_MAP_LAYERS`. + +### Panel and Layer Counts + +| Variant | Panels | Desktop Map Layers | Mobile Map Layers | +|---|---|---|---| +| `full` | 44 | 35+ | Reduced subset | +| `tech` | ~20 | Tech-focused layers (cloud regions, startup hubs, accelerators) | Minimal | +| `finance` | ~18 | Finance-focused layers (stock exchanges, financial centres, central banks) | Minimal | + +The `MapLayers` interface contains 35+ boolean toggle keys including: `conflicts`, `bases`, `cables`, `pipelines`, `hotspots`, `ais`, `nuclear`, `irradiators`, `sanctions`, `weather`, `economic`, `waterways`, `outages`, `cyberThreats`, `datacenters`, `protests`, `flights`, `military`, `natural`, `spaceports`, `minerals`, `fires`, `ucdpEvents`, `displacement`, `climate`, `startupHubs`, `cloudRegions`, `accelerators`, `techHQs`, `techEvents`, `stockExchanges`, `financialCenters`, `centralBanks`, `commodityHubs`, and `gulfInvestments`. + +--- + +## 3. Data Flow: RSS Ingestion to Display + +The core intelligence pipeline transforms raw RSS feeds into clustered, classified, and scored events displayed across panels. This pipeline runs entirely in the browser. + +```mermaid +sequenceDiagram + participant RSS as RSS Sources + participant Proxy as /api/rss-proxy + participant Cache as Upstash Redis + participant SPA as Browser SPA + participant Cluster as clustering.ts + participant ML as ML Worker + participant Threat as threat-classifier.ts + participant Entity as entity-extraction.ts + participant Panel as Panel Components + + SPA->>Proxy: fetch(feedUrl) + Proxy->>Cache: getCachedJson(key) + alt Cache hit + Cache-->>Proxy: cached response + else Cache miss + Proxy->>RSS: GET feed XML/JSON + RSS-->>Proxy: raw feed data + Proxy->>Cache: setCachedJson(key, data, ttl) + end + Proxy-->>SPA: NewsItem[] + + SPA->>Cluster: clusterNews(items) + Note over Cluster: Jaccard similarity
on title token sets + + alt ML Worker available + SPA->>Cluster: clusterNewsHybrid(items) + Cluster->>ML: embed(clusterTexts) + ML-->>Cluster: embeddings[][] + Cluster->>Cluster: mergeSemanticallySimilarClusters() + end + + Cluster-->>SPA: ClusteredEvent[] + SPA->>Threat: classifyCluster(event) + Threat-->>SPA: ThreatClassification + SPA->>Entity: extractEntitiesFromCluster(event) + Entity-->>SPA: NewsEntityContext + SPA->>Panel: render(scoredEvents) +``` + +### Pipeline Stages + +**Stage 1 — RSS Fetch** (src/services/rss.ts) + +The `fetchFeed()` function calls the `/api/rss-proxy` endpoint, which fetches and parses upstream RSS/Atom feeds on the server side. Responses are cached in Upstash Redis (or the sidecar in-memory cache). On the client, a per-feed in-memory cache (`feedCache` Map) prevents redundant network requests within the refresh interval, and a persistent cache layer (via src/services/persistent-cache.ts) provides resilience across page reloads and desktop restarts. + +The `fetchAllFeeds()` function orchestrates concurrent fetching across all enabled feeds with configurable `onBatch` callbacks for progressive rendering. + +**Stage 2 — Clustering** (src/services/clustering.ts) + +Two clustering strategies are available: + +- `clusterNews(items)` — fast Jaccard similarity over title token sets via `clusterNewsCore()`. Groups headlines with high textual overlap into `ClusteredEvent[]`. This is the default path when ML is unavailable. +- `clusterNewsHybrid(items)` — first runs Jaccard clustering, then refines results using semantic embeddings from the ML Worker. `mergeSemanticallySimilarClusters()` reduces fragmentation by joining clusters whose embedding centroids exceed the `semanticClusterThreshold` (default 0.75). Requires at least `minClustersForML` (5) initial clusters to activate. + +**Stage 3 — Classification** (src/services/threat-classifier.ts) + +Each clustered event receives a `ThreatClassification` with a `ThreatLevel` (`critical | high | medium | low | info`). The classifier uses keyword pattern matching and source-tier weighting. Threat levels map to CSS variables (`--threat-critical`, `--threat-high`, etc.) for consistent colour coding across panels. + +**Stage 4 — Entity Extraction** (src/services/entity-extraction.ts + src/services/entity-index.ts) + +The `extractEntitiesFromTitle()` function matches text against a pre-built entity index. The `extractEntitiesFromCluster()` function aggregates entities across all items in a cluster to produce a `NewsEntityContext` containing primary and related entities. + +The entity index (src/services/entity-index.ts) is a multi-index structure with five `Map` lookups: + +| Index | Type | Purpose | +|---|---|---| +| `byId` | `Map` | Canonical lookup by entity ID | +| `byAlias` | `Map` | Alias-to-ID resolution (case-insensitive) | +| `byKeyword` | `Map>` | Keyword-to-entity-IDs for text matching | +| `bySector` | `Map>` | Sector-based grouping | +| `byType` | `Map>` | Entity type grouping (person, org, country, etc.) | + +**Stage 5 — Display** + +Classified and entity-enriched events are distributed to panels. The `Panel` base class provides a consistent rendering contract. Each panel subclass (LiveNewsPanel, IntelligencePanel, etc.) decides how to filter, sort, and present events relevant to its domain. + +--- + +## 4. Signal Intelligence Pipeline + +The signal aggregator fuses heterogeneous geospatial data sources into a unified intelligence picture with country-level clustering and regional convergence detection. + +```mermaid +graph TD + subgraph Sources["Data Sources"] + IO["Internet Outages"] + MF["Military Flights
(OpenSky)"] + MV["Military Vessels
(AIS)"] + PR["Protests
(ACLED)"] + AD["AIS Disruptions"] + SF["Satellite Fires
(NASA FIRMS)"] + TA["Temporal Anomalies
(Baseline Deviations)"] + end + + subgraph Aggregator["SignalAggregator (src/services/signal-aggregator.ts)"] + Extract["Signal Extraction
normalise to GeoSignal"] + Geo["Geo-Spatial Correlation
country code lookup"] + Country["Country Clustering
CountrySignalCluster"] + Regional["Regional Convergence
REGION_DEFINITIONS (6 regions)"] + Score["Convergence Scoring
multi-signal co-occurrence"] + Summary["SignalSummary
AI context generation"] + end + + IO --> Extract + MF --> Extract + MV --> Extract + PR --> Extract + AD --> Extract + SF --> Extract + TA --> Extract + + Extract --> Geo + Geo --> Country + Country --> Regional + Regional --> Score + Score --> Summary + + Summary --> Insights["AI Insights Panel"] + Summary --> MapVis["Map Visualisation"] + Summary --> SignalModal["Signal Modal"] +``` + +### Type Hierarchy + +The pipeline defined in src/services/signal-aggregator.ts operates on a layered type system: + +``` +SignalType (enum-like union) + ├── internet_outage + ├── military_flight + ├── military_vessel + ├── protest + ├── ais_disruption + ├── satellite_fire + └── temporal_anomaly + +GeoSignal (individual signal) + ├── type: SignalType + ├── country: string (ISO 3166-1 alpha-2) + ├── countryName: string + ├── lat / lon: number + ├── severity: 'low' | 'medium' | 'high' + ├── title: string + └── timestamp: Date + +CountrySignalCluster (per-country aggregation) + ├── country / countryName + ├── signals: GeoSignal[] + ├── signalTypes: Set + ├── totalCount / highSeverityCount + └── convergenceScore: number + +RegionalConvergence (cross-country pattern) + ├── region: string + ├── countries: string[] + ├── signalTypes: SignalType[] + ├── totalSignals: number + └── description: string + +SignalSummary (final output) + ├── timestamp: Date + ├── totalSignals: number + ├── byType: Record + ├── convergenceZones: RegionalConvergence[] + ├── topCountries: CountrySignalCluster[] + └── aiContext: string +``` + +### Region Definitions + +The `REGION_DEFINITIONS` constant maps six monitored regions to their constituent country codes: + +| Region | Name | Countries | +|---|---|---| +| `middle_east` | Middle East | IR, IL, SA, AE, IQ, SY, YE, JO, LB, KW, QA, OM, BH | +| `east_asia` | East Asia | CN, TW, JP, KR, KP, HK, MN | +| `south_asia` | South Asia | IN, PK, BD, AF, NP, LK, MM | +| `europe_east` | Eastern Europe | UA, RU, BY, PL, RO, MD, HU, CZ, SK, BG | +| `africa_north` | North Africa | EG, LY, DZ, TN, MA, SD, SS | +| `africa_sahel` | Sahel Region | ML, NE, BF, TD, NG, CM, CF | + +### Convergence Scoring + +The `convergenceScore` on each `CountrySignalCluster` quantifies multi-signal co-occurrence. A high score indicates that multiple independent signal types are present in the same country within the 24-hour analysis window (`WINDOW_MS`). This score drives the AI Insights panel prioritisation and the signal modal display. + +The `SignalAggregator` class maintains a rolling window of signals and a `WeakMap`-based source tracking for temporal anomaly provenance. Individual `ingest*()` methods (e.g., `ingestInternetOutages()`, `ingestMilitaryFlights()`) clear stale signals by type before inserting fresh data, ensuring the aggregation always reflects the latest state. + +--- + +## 5. Map Rendering Pipeline + +The map system combines a 2D vector tile base map (MapLibre GL JS) with a 3D WebGL overlay (deck.gl) for globe rendering, supporting 35+ toggleable data layers. + +```mermaid +graph TD + subgraph MapStack["Map Rendering Stack"] + Container["MapContainer.ts
Layout & resize management"] + BaseMap["Map.ts
MapLibre GL JS
Vector tiles, region controls"] + DeckGL["DeckGLMap.ts
deck.gl WebGL overlay
3D globe & data layers"] + Popup["MapPopup.ts
Feature interaction"] + end + + subgraph LayerConfig["Layer Configuration"] + Defaults["FULL_MAP_LAYERS
(35+ boolean toggles)"] + UserPref["localStorage overrides
(worldmonitor-layers)"] + URLState["URL state overrides"] + Variant["Variant-specific defaults"] + end + + subgraph DataLayers["Data Layers (toggleable)"] + Geo["Geopolitical:
conflicts, bases, nuclear,
sanctions, waterways"] + Military["Military:
flights, military, ais"] + Infra["Infrastructure:
cables, pipelines,
datacenters, spaceports"] + Environmental["Environmental:
weather, fires, climate,
natural, minerals"] + Threat["Threat:
outages, cyberThreats,
protests, hotspots"] + Data["Data Sources:
ucdpEvents, displacement"] + TechLayers["Tech:
startupHubs, cloudRegions,
accelerators, techHQs"] + FinanceLayers["Finance:
stockExchanges,
financialCenters,
centralBanks"] + end + + Defaults --> Merge["Layer Merge Logic"] + UserPref --> Merge + URLState --> Merge + Variant --> Merge + Merge --> ActiveLayers["Active MapLayers"] + + ActiveLayers --> DeckGL + Container --> BaseMap + Container --> DeckGL + BaseMap --> Popup + DeckGL --> Popup + + Geo --> DeckGL + Military --> DeckGL + Infra --> DeckGL + Environmental --> DeckGL + Threat --> DeckGL + Data --> DeckGL + TechLayers --> DeckGL + FinanceLayers --> DeckGL +``` + +### Layer Toggle Resolution + +Map layers follow a three-tier override system: + +1. **Variant defaults** — `FULL_MAP_LAYERS`, `TECH_MAP_LAYERS`, or `FINANCE_MAP_LAYERS` define the base layer state for each variant. The full variant enables `conflicts`, `bases`, `hotspots`, `nuclear`, `sanctions`, `weather`, `economic`, `waterways`, `outages`, and `military` by default. + +2. **User localStorage** — Stored under the key `worldmonitor-layers`. Users toggle layers in the map controls UI, and their preferences persist across sessions. + +3. **URL state** — Query parameters can override individual layers for shareable links and embeds. + +The merge logic applies overrides in this order, meaning URL state has the highest priority. + +### Mobile Adaptation + +Mobile devices receive a reduced layer set via `MOBILE_DEFAULT_MAP_LAYERS` (variant-specific). This disables heavier layers (bases, nuclear, cables, pipelines, spaceports, minerals) that would degrade performance on constrained devices while retaining the most operationally relevant overlays (conflicts, hotspots, sanctions, weather). + +### Rendering Pipeline + +The rendering stack works in two layers: + +- **MapLibre GL JS** (src/components/Map.ts) provides the base map with vector tiles, region-specific map controls, and the 2D rendering context. It handles camera management, style loading, and base interaction events. + +- **deck.gl** (src/components/DeckGLMap.ts) overlays a WebGL context for 3D globe rendering and data-driven layers. Each toggleable layer maps to a deck.gl layer instance (ScatterplotLayer, IconLayer, ArcLayer, etc.) that is conditionally created based on the active `MapLayers` state. + +The **MapPopup** component (src/components/MapPopup.ts) provides a unified popup system for feature interaction across both rendering layers, displaying contextual information when users click or hover over map features. + +--- + +## 6. Caching Architecture + +World Monitor employs a five-tier caching strategy to minimise API costs, reduce latency, and enable offline operation. + +```mermaid +graph TD + subgraph Tier1["Tier 1: Upstash Redis (Server)"] + Redis["api/_upstash-cache.js
getCachedJson() / setCachedJson()
TTL-based expiry"] + end + + subgraph Tier1b["Tier 1b: Sidecar In-Memory Cache"] + MemCache["In-memory Map
+ disk persistence (api-cache.json)
Max 5000 entries"] + end + + subgraph Tier2["Tier 2: Vercel CDN"] + CDN["s-maxage headers
stale-while-revalidate
Edge caching"] + end + + subgraph Tier3["Tier 3: Service Worker"] + Workbox["Workbox Runtime Caching
Offline support
Cache-first / network-first strategies"] + end + + subgraph Tier4["Tier 4: IndexedDB (Client)"] + IDB["worldmonitor_db"] + Baselines["baselines store
(keyPath: 'key')"] + Snapshots["snapshots store
(keyPath: 'timestamp'
index: 'by_time')"] + IDB --> Baselines + IDB --> Snapshots + end + + subgraph Tier5["Tier 5: Persistent Cache"] + PC["persistent-cache.ts
CacheEnvelope<T>"] + TauriInvoke["Tauri invoke
(OS filesystem)"] + LSFallback["localStorage fallback
prefix: worldmonitor-persistent-cache:"] + PC --> TauriInvoke + PC --> LSFallback + end + + Browser["Browser SPA"] --> Workbox + Workbox --> CDN + CDN --> Redis + Redis --> ExternalAPI["External APIs"] + + Browser --> IDB + Browser --> PC + + Sidecar["Desktop Sidecar"] --> MemCache + MemCache --> ExternalAPI +``` + +### Tier 1: Upstash Redis (Server-Side) + +The api/_upstash-cache.js module wraps all API fetch operations with Redis GET/SET. Every API endpoint calls `getCachedJson(key)` before hitting upstream. On cache miss, the upstream response is stored with `setCachedJson(key, value, ttlSeconds)`. The module lazily initialises the Redis client from `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables. + +A `hashString()` utility produces compact cache keys from request parameters using a DJB2 hash. + +### Tier 1b: Sidecar In-Memory Cache + +When running in desktop/sidecar mode (`LOCAL_API_MODE=sidecar`), Redis is bypassed entirely. An in-memory `Map` stores cache entries with expiry timestamps. Entries persist to disk as `api-cache.json` via debounced writes (2-second delay). A periodic cleanup interval (60 seconds) evicts expired entries. The maximum persisted entry count is capped at `MAX_PERSIST_ENTRIES` (default 5000). + +The disk persistence uses atomic writes: data is written to a `.tmp` file first, then renamed to the final path. A `persistInFlight` flag with `persistQueued` prevents concurrent writes. + +### Tier 2: Vercel CDN + +API responses include `Cache-Control` headers with `s-maxage` and `stale-while-revalidate` directives. This enables Vercel's CDN edge nodes to serve cached responses without invoking the serverless function, reducing cold starts and upstream API calls. + +### Tier 3: Service Worker (Workbox) + +The Service Worker (configured via Workbox) provides runtime caching with strategy selection per route: + +- **Cache-first** for static assets and infrequently changing data +- **Network-first** for real-time feeds and market data +- **Stale-while-revalidate** for semi-static resources + +The offline fallback page (public/offline.html) is served when the network is unavailable and no cached response exists. + +### Tier 4: IndexedDB + +The `worldmonitor_db` IndexedDB database contains two object stores: + +| Store | keyPath | Index | Purpose | +|---|---|---|---| +| `baselines` | `key` | — | Stores baseline values for temporal deviation tracking. The signal aggregator compares current values against baselines to detect anomalies. | +| `snapshots` | `timestamp` | `by_time` | Stores periodic system state snapshots for the playback control feature, enabling users to replay historical states. | + +### Tier 5: Persistent Cache + +The src/services/persistent-cache.ts module provides a cross-platform persistent storage abstraction. Data is wrapped in a `CacheEnvelope`: + +```typescript +type CacheEnvelope = { + key: string; + updatedAt: number; + data: T; +}; +``` + +On desktop, `getPersistentCache()` and `setPersistentCache()` attempt Tauri IPC invocations (`read_cache_entry` / `write_cache_entry`) first, which store data on the OS filesystem via the Rust backend. If the Tauri call fails (or in web mode), the module falls back to `localStorage` with the prefix `worldmonitor-persistent-cache:`. + +--- + +## 7. Desktop Architecture + +The desktop application uses Tauri 2 (Rust) as a native shell around the web SPA, with a Node.js sidecar process providing a local API server. + +```mermaid +graph TD + subgraph TauriApp["Tauri 2 Desktop Application"] + subgraph Rust["Rust Backend (src-tauri/)"] + TauriCore["tauri.conf.json
(+ variant overrides)"] + BuildRS["build.rs"] + Cargo["Cargo.toml"] + Commands["IPC Commands
(read_cache_entry,
write_cache_entry, etc.)"] + Keychain["OS Keychain
(18 RuntimeSecretKeys)"] + end + + subgraph SidecarProc["Node.js Sidecar"] + LocalAPI["Local API Server
http://127.0.0.1:46123"] + MemCache["In-memory Map
+ api-cache.json"] + LocalAPI --> MemCache + end + + subgraph WebView["WebView (SPA)"] + Runtime["runtime.ts
detectDesktopRuntime()"] + Bridge["tauri-bridge.ts
Typed IPC wrapper"] + Config["runtime-config.ts
Feature toggles & secrets"] + PCache["persistent-cache.ts"] + end + end + + Runtime -->|"isDesktopRuntime()"| Bridge + Bridge -->|"invokeTauri()"| Commands + Config -->|"readSecret()"| Keychain + PCache -->|"read/write_cache_entry"| Commands + WebView -->|"fetch() via patch"| LocalAPI +``` + +### Runtime Detection + +The src/services/runtime.ts module detects the desktop environment through multiple signals: + +```typescript +function detectDesktopRuntime(probe: RuntimeProbe): boolean { + // Checks: window.__TAURI__, user agent, location host (127.0.0.1) +} +``` + +When desktop mode is detected, `getApiBaseUrl()` returns `http://127.0.0.1:46123` instead of relative paths, routing all API calls through the local sidecar. A global `fetch()` monkey-patch (applied once via `__wmFetchPatched` guard) rewrites API URLs to point at the sidecar. + +### Tauri Configuration + +The src-tauri/ directory contains: + +| File | Purpose | +|---|---| +| tauri.conf.json | Base Tauri configuration (window size, CSP, bundle settings) | +| tauri.tech.conf.json | Tech variant overrides (app name, window title, icons) | +| tauri.finance.conf.json | Finance variant overrides | +| build.rs | Rust build script for Tauri codegen | +| Cargo.toml | Rust dependencies | +| sidecar/ | Node.js sidecar source (local API server) | +| capabilities/ | Tauri capability definitions (permissions) | +| icons/ | Application icons for each platform | + +### Tauri Bridge + +The src/services/tauri-bridge.ts module provides a typed TypeScript wrapper around Tauri's IPC invoke mechanism. It exposes functions like `invokeTauri(command, args)` that handle serialisation and error mapping. + +### Runtime Configuration + +The src/services/runtime-config.ts module manages two concerns: + +**1. Runtime Secrets** — 18 `RuntimeSecretKey` values representing API keys and credentials: + +`GROQ_API_KEY`, `OPENROUTER_API_KEY`, `FRED_API_KEY`, `EIA_API_KEY`, `CLOUDFLARE_API_TOKEN`, `ACLED_ACCESS_TOKEN`, `URLHAUS_AUTH_KEY`, `OTX_API_KEY`, `ABUSEIPDB_API_KEY`, `WINGBITS_API_KEY`, `WS_RELAY_URL`, `VITE_OPENSKY_RELAY_URL`, `OPENSKY_CLIENT_ID`, `OPENSKY_CLIENT_SECRET`, `AISSTREAM_API_KEY`, `FINNHUB_API_KEY`, `NASA_FIRMS_API_KEY`, `UC_DP_KEY`. + +On desktop, secrets are read from the OS keychain via Tauri IPC. In web mode, they fall back to environment variables. A `validateSecret()` function provides format validation with user-facing hints. + +**2. Feature Toggles** — 14 `RuntimeFeatureId` values stored in localStorage under the key `worldmonitor-runtime-feature-toggles`: + +`aiGroq`, `aiOpenRouter`, `economicFred`, `energyEia`, `internetOutages`, `acledConflicts`, `abuseChThreatIntel`, `alienvaultOtxThreatIntel`, `abuseIpdbThreatIntel`, `wingbitsEnrichment`, `aisRelay`, `openskyRelay`, `finnhubMarkets`, `nasaFirms`. + +Each `RuntimeFeatureDefinition` declares its required secrets (and optionally desktop-specific overrides via `desktopRequiredSecrets`), along with a `fallback` description explaining behaviour when the feature is unavailable. The `isFeatureAvailable()` function checks both the toggle state and secret availability. + +The settings page listens for `storage` events on the toggles key, enabling cross-tab synchronisation. + +--- + +## 8. ML Pipeline + +World Monitor runs machine-learning inference directly in the browser using ONNX Runtime Web via Transformers.js, with API-based fallbacks for constrained devices. + +```mermaid +graph TD + subgraph Capabilities["Capability Detection"] + Detect["ml-capabilities.ts
detectMLCapabilities()"] + WebGPU["WebGPU check"] + WebGL["WebGL check"] + SIMD["SIMD check"] + Threads["SharedArrayBuffer check"] + Memory["Device memory estimation"] + Detect --> WebGPU + Detect --> WebGL + Detect --> SIMD + Detect --> Threads + Detect --> Memory + end + + subgraph Config["Model Configuration (ml-config.ts)"] + Models["MODEL_CONFIGS"] + Embed["embeddings
all-MiniLM-L6-v2
23 MB"] + Sentiment["sentiment
DistilBERT-SST2
65 MB"] + Summarize["summarization
Flan-T5-base
250 MB"] + SumSmall["summarization-beta
Flan-T5-small
60 MB"] + NER["ner
BERT-NER
65 MB"] + Models --> Embed + Models --> Sentiment + Models --> Summarize + Models --> SumSmall + Models --> NER + end + + subgraph WorkerPipeline["ML Worker Pipeline"] + Manager["MLWorkerManager
(ml-worker.ts)"] + Worker["ml.worker.ts
(Web Worker)"] + ONNX["ONNX Runtime Web
(@xenova/transformers)"] + Manager -->|"postMessage"| Worker + Worker --> ONNX + end + + subgraph Fallback["Fallback Chain"] + Groq["Groq API
(cloud LLM)"] + OpenRouter["OpenRouter API
(cloud LLM)"] + BrowserML["Browser Transformers.js
(offline capable)"] + Groq -->|"unavailable"| OpenRouter + OpenRouter -->|"unavailable"| BrowserML + end + + subgraph Results["Worker Message Types"] + EmbedR["embed-result"] + SumR["summarize-result"] + SentR["sentiment-result"] + EntR["entities-result"] + ClusterR["cluster-semantic-result"] + end + + Detect -->|"isSupported"| Manager + Config --> Worker + Manager --> Results +``` + +### Capability Detection + +The src/services/ml-capabilities.ts module probes the browser environment before loading any models: + +```typescript +interface MLCapabilities { + isSupported: boolean; + isDesktop: boolean; + hasWebGL: boolean; + hasWebGPU: boolean; + hasSIMD: boolean; + hasThreads: boolean; + estimatedMemoryMB: number; + recommendedExecutionProvider: 'webgpu' | 'webgl' | 'wasm'; + recommendedThreads: number; +} +``` + +ML is only enabled on desktop-class devices (`!isMobileDevice()`) with at least WebGL support and an estimated 100+ MB of available memory. The `recommendedExecutionProvider` selects the optimal ONNX backend: WebGPU (fastest, if available), WebGL, or WASM fallback. + +### Model Configuration + +The src/config/ml-config.ts module defines five model configurations: + +| Model ID | HuggingFace Model | Size | Task | Required | +|---|---|---|---|---| +| `embeddings` | Xenova/all-MiniLM-L6-v2 | 23 MB | feature-extraction | Yes | +| `sentiment` | Xenova/distilbert-base-uncased-finetuned-sst-2-english | 65 MB | text-classification | No | +| `summarization` | Xenova/flan-t5-base | 250 MB | text2text-generation | No | +| `summarization-beta` | Xenova/flan-t5-small | 60 MB | text2text-generation | No | +| `ner` | Xenova/bert-base-NER | 65 MB | token-classification | No | + +Only the embeddings model is marked as `required` — it powers semantic clustering. Other models are loaded on-demand based on feature flags (`ML_FEATURE_FLAGS`) and available memory budget (`ML_THRESHOLDS.memoryBudgetMB`, default 200 MB). + +### ML Thresholds + +```typescript +const ML_THRESHOLDS = { + semanticClusterThreshold: 0.75, // cosine similarity for merging clusters + minClustersForML: 5, // minimum clusters before ML refinement + maxTextsPerBatch: 20, // batch size for embedding requests + modelLoadTimeoutMs: 600_000, // 10 min model download/compile timeout + inferenceTimeoutMs: 120_000, // 2 min per inference call + memoryBudgetMB: 200, // max memory for all loaded models +}; +``` + +### Worker Architecture + +The `MLWorkerManager` class (src/services/ml-worker.ts) manages the lifecycle of a dedicated Web Worker (src/workers/ml.worker.ts). Communication uses a request-response pattern over `postMessage`: + +1. **Initialisation** — `init()` calls `detectMLCapabilities()`, creates the worker if supported, and waits for a `worker-ready` message (10-second timeout). + +2. **Request dispatch** — Each method (`embed()`, `summarize()`, `sentiment()`, `entities()`, `clusterSemantic()`) generates a unique request ID, posts a message to the worker, and returns a `Promise` that resolves when the worker posts back a matching result message. + +3. **Timeout handling** — Each pending request has an independent timeout. If the worker fails to respond within `inferenceTimeoutMs`, the promise rejects and the request is cleaned up. + +4. **Model lifecycle** — Models are loaded lazily on first use. The worker emits `model-progress` events during download, enabling progress UI. `model-loaded` and `model-unloaded` events track the loaded model set. + +### Worker Result Message Types + +| Message Type | Payload | Used By | +|---|---|---| +| `embed-result` | `embeddings: number[][]` | Semantic clustering | +| `summarize-result` | `summaries: string[]` | AI Insights panel | +| `sentiment-result` | `results: SentimentResult[]` | Threat classification augmentation | +| `entities-result` | `entities: NEREntity[][]` | Entity extraction (ML-backed) | +| `cluster-semantic-result` | `clusters: number[][]` | Cluster merging | + +### Fallback Chain + +When browser-based ML is not available (mobile devices, constrained hardware, or feature disabled), the system falls back to cloud-based LLM APIs: + +1. **Groq API** — Primary cloud fallback. Used for summarisation and classification via /api/groq-summarize. +2. **OpenRouter API** — Secondary cloud fallback via /api/openrouter-summarize. +3. **Browser Transformers.js** — Tertiary fallback for offline operation. Even without API access, the embeddings model enables basic semantic clustering. + +The fallback is not automatic at the ML worker level; each consumer service chooses its preferred provider and handles degradation independently. + +--- + +## 9. Error Handling Hierarchy + +World Monitor uses a circuit-breaker pattern to manage transient failures across its many data sources, preventing cascade failures and providing graceful degradation. + +```mermaid +stateDiagram-v2 + [*] --> Closed: Initial state + + Closed --> Closed: fetch() success → recordSuccess() + Closed --> HalfOpen: fetch() failure
(failures < MAX_FAILURES) + HalfOpen --> Open: fetch() failure
(failures >= MAX_FAILURES) + Open --> Recovery: COOLDOWN_MS elapsed + Recovery --> Closed: retry success → reset + Recovery --> Open: retry failure → extend cooldown + + state Closed { + [*] --> Live + Live: mode = 'live' + Live: Serve fresh data + } + + state HalfOpen { + [*] --> Degraded + Degraded: failures > 0 + Degraded: Still attempting fetches + } + + state Open { + [*] --> CircuitOpen + CircuitOpen: mode = 'cached' or 'unavailable' + CircuitOpen: Serve cached data if available + CircuitOpen: Skip fetch until cooldown expires + } + + state Recovery { + [*] --> Retry + Retry: Single probe request + Retry: On success → reset to Closed + } +``` + +### Circuit Breaker Implementation + +The `CircuitBreaker` class in src/utils/circuit-breaker.ts implements per-feed failure tracking with automatic cooldowns: + +```typescript +interface CircuitState { + failures: number; + cooldownUntil: number; + lastError?: string; +} + +type BreakerDataMode = 'live' | 'cached' | 'unavailable'; +``` + +**Constants:** + +| Constant | Default | Purpose | +|---|---|---| +| `DEFAULT_MAX_FAILURES` | 2 | Consecutive failures before opening the circuit | +| `DEFAULT_COOLDOWN_MS` | 5 min (300,000 ms) | How long to wait before retrying | +| `DEFAULT_CACHE_TTL_MS` | 10 min (600,000 ms) | How long cached data remains valid | + +### Lifecycle + +1. **Closed (Live)** — Normal operation. Each successful `fetch()` calls `recordSuccess()`, resetting the failure counter. + +2. **Failure Tracking** — On fetch failure, the failure counter increments. The `lastError` is recorded for diagnostics. + +3. **Open (Circuit Tripped)** — When `failures >= maxFailures`, the circuit opens. `cooldownUntil` is set to `Date.now() + cooldownMs`. While open: + - `isOnCooldown()` returns `true` + - No fetch attempts are made + - `getCached()` serves the last successful response if within `cacheTtlMs` + - If no cached data exists, the data mode is `'unavailable'` + +4. **Recovery (Cooldown Expired)** — After the cooldown period, `isOnCooldown()` returns `false` and resets the state. The next fetch attempt acts as a probe: + - On success → circuit fully resets to closed + - On failure → circuit re-opens with a fresh cooldown + +### Data State Reporting + +Each breaker tracks a `BreakerDataState` for UI display: + +```typescript +interface BreakerDataState { + mode: BreakerDataMode; // 'live' | 'cached' | 'unavailable' + timestamp: number | null; + offline: boolean; +} +``` + +Panels use this state to display freshness indicators — e.g., showing a "cached" badge with the last successful timestamp, or an "unavailable" state with the `lastError` message. + +### Desktop Offline Mode + +The `isDesktopOfflineMode()` helper detects when the Tauri desktop app loses network connectivity (`navigator.onLine === false`). In this mode, all circuit breakers immediately fall back to cached data without attempting network requests, preserving the user experience during temporary disconnections. + +### Global Breaker Registry + +A module-level `Map>` maintains all active breakers. Utility functions provide system-wide observability: + +| Function | Purpose | +|---|---| +| `createCircuitBreaker(options)` | Create and register a new breaker | +| `getCircuitBreakerStatus()` | Returns status of all breakers (for diagnostics) | +| `isCircuitBreakerOnCooldown(name)` | Check if a specific breaker is in cooldown | +| `getCircuitBreakerCooldownInfo(name)` | Get cooldown state and remaining seconds | +| `removeCircuitBreaker(name)` | Deregister a breaker | + +### Degradation Hierarchy + +The overall error handling follows a predictable degradation path: + +``` +Live data (fresh fetch) + └── on failure → Stale cache (within cacheTtlMs) + └── expired cache → 'unavailable' state in UI + └── desktop offline → immediate cache fallback +``` + +Each panel independently manages its breaker, so a failure in one data source (e.g., OpenSky API downtime) does not affect other panels. The AI Insights panel aggregates breaker states to provide a system-wide health summary. diff --git a/docs/Docs_To_Review/COMPONENTS.md b/docs/Docs_To_Review/COMPONENTS.md new file mode 100644 index 000000000..357e9c30e --- /dev/null +++ b/docs/Docs_To_Review/COMPONENTS.md @@ -0,0 +1,1358 @@ +# Component Documentation — World Monitor + +> Auto-generated reference for all UI components in `src/components/`. +> Last updated: 2026-02-19 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Panel Base Class](#panel-base-class) +3. [Map System](#map-system) + - [DeckGLMap (WebGL / 3D)](#deckglmap) + - [MapComponent (D3 / SVG)](#mapcomponent) + - [MapContainer (Adapter)](#mapcontainer) + - [MapPopup (Popup Builder)](#mappopup) +4. [Virtual Scrolling](#virtual-scrolling) + - [VirtualList](#virtuallist) + - [WindowedList\](#windowedlistt) +5. [Search](#search) + - [SearchModal](#searchmodal) +6. [Domain Panels](#domain-panels) + - [Intelligence & Analysis](#intelligence--analysis-panels) + - [News & Content](#news--content-panels) + - [Markets & Finance](#markets--finance-panels) + - [Military & Security](#military--security-panels) + - [Natural Events & Humanitarian](#natural-events--humanitarian-panels) + - [Infrastructure & Tech](#infrastructure--tech-panels) + - [Platform](#platform-panels) +7. [Modals & Widgets](#modals--widgets) +8. [Variant Visibility Matrix](#variant-visibility-matrix) +9. [Component Interaction Diagram](#component-interaction-diagram) + +--- + +## Overview + +World Monitor's UI is built entirely with **vanilla TypeScript** — no React, Vue, or +Angular. Every component is a plain ES class that owns its own DOM subtree and +communicates through method calls, callbacks, and a handful of +`document`/`window`-level custom events. + +### Design principles + +| Principle | Detail | +|---|---| +| **No framework** | Components create and manage DOM imperatively. | +| **Class-based** | Each component is a standalone class (or, rarely, a set of exported functions). | +| **Panel inheritance** | Most dashboard tiles extend the shared `Panel` base class. | +| **Variant-aware** | Components check `SITE_VARIANT` (`world` / `tech` / `finance`) to show/hide themselves or swap data sources. | +| **Theme-aware** | Components listen for `theme-changed` events and adapt colours accordingly. | +| **Desktop-aware** | Tauri bridge detection via `isDesktopRuntime()` unlocks native features. | + +### Component count + +| Category | Count | +|---|---| +| Panel subclasses | ~35 | +| Map components | 4 (DeckGLMap, MapComponent, MapContainer, MapPopup) | +| Virtual scrolling | 2 (VirtualList, WindowedList) | +| Search | 1 (SearchModal) | +| Modals & widgets | ~9 | +| **Total** | **~51** | + +All public exports are re-exported from `src/components/index.ts` (barrel file, +40+ symbols). + +--- + +## Panel Base Class + +**File:** `src/components/Panel.ts` (420 lines) + +`Panel` is the shared superclass for every dashboard tile. It owns the chrome — +header, collapse/expand, resize handle, loading/error states, count badge, +"NEW" badge, data-quality badge, and tooltip — so that subclasses only need to +fill in `panel-content`. + +### Constructor + +```ts +interface PanelOptions { + id: string; // unique DOM id, also used as localStorage key + title: string; // human-readable header text + showCount?: boolean; // if true, renders a count badge in the header + className?: string; // extra CSS class on the root element + trackActivity?: boolean; // enable activity tracking + infoTooltip?: string; // markdown/HTML shown on hover of the ℹ icon +} + +const panel = new Panel(options); +``` + +### Lifecycle & Methods + +#### Public + +| Method | Signature | Description | +|---|---|---| +| `getElement()` | `(): HTMLElement` | Returns the root `div.panel`. | +| `showLoading()` | `(): void` | Replaces content with a spinner. | +| `showError()` | `(msg: string): void` | Shows a red error banner inside content. | +| `showConfigError()` | `(): void` | Shows a config-missing message (desktop). | +| `setCount()` | `(n: number): void` | Updates the header count badge. | +| `setErrorState()` | `(isError: boolean): void` | Toggles `.panel-error` class. | +| `setContent()` | `(html: string \| HTMLElement): void` | Replaces panel-content innerHTML or child. | +| `show()` | `(): void` | Removes `display:none`. | +| `hide()` | `(): void` | Sets `display:none`. | +| `toggle()` | `(): void` | Toggles between show/hide. | +| `setNewBadge()` | `(): void` | Adds the "NEW" pill next to the title. | +| `clearNewBadge()` | `(): void` | Removes the "NEW" pill. | +| `getId()` | `(): string` | Returns the panel id. | +| `resetHeight()` | `(): void` | Clears any user-set height override. | +| `destroy()` | `(): void` | Removes element, cleans up listeners. | + +#### Protected + +| Method | Signature | Description | +|---|---|---| +| `setDataBadge(state, detail?)` | `(state: 'live'\|'cached'\|'unavailable', detail?: string): void` | Shows a coloured dot in the header indicating data freshness. | +| `clearDataBadge()` | `(): void` | Hides the data badge. | + +### DOM Structure + +``` +div.panel#${id} ← root + ├─ div.panel-header + │ ├─ div.panel-header-left + │ │ ├─ span.panel-title ← title text + │ │ ├─ span.panel-info-wrapper ← ℹ icon + tooltip + │ │ └─ span.panel-new-badge ← "NEW" pill (conditional) + │ ├─ span.panel-data-badge ← live/cached/unavailable dot + │ └─ span.panel-count ← count number (conditional) + ├─ div.panel-content ← subclass fills this + └─ div.panel-resize-handle ← drag handle for vertical resize +``` + +### CSS Classes + +| Class | Applied to | Meaning | +|---|---|---| +| `.panel` | root | Base panel styling. | +| `.panel-collapsed` | root | Content hidden, header only. | +| `.panel-error` | root | Red border / error state. | +| `.panel-resizing` | root | While user drags resize handle. | +| `.panel-header` | header wrapper | Flex row. | +| `.panel-content` | content area | Overflow auto, flex-grow 1. | +| `.panel-resize-handle` | resize bar | Cursor `ns-resize`. | + +### Events + +| Event / Listener | Target | Direction | +|---|---|---| +| `click` (document) | tooltip close | Consumed | +| `mousemove` / `mouseup` | resize drag | Consumed | +| `touchstart` / `touchmove` / `touchend` | mobile resize | Consumed | +| `dragstart` | blocked during resize | Consumed | + +### Persistence + +Panel **span sizes** (user-resized heights) are stored in +`localStorage['worldmonitor-panel-spans']` as a JSON map of `{ [id]: height }`. + +### Services + +- `escapeHtml` from `sanitize` — XSS-safe title rendering. +- `isDesktopRuntime()` / `invokeTauri()` — Tauri bridge for settings sync. +- `t()` — i18n translation of UI strings. + +### Variant Logic + +None — `Panel` is a pure base class and renders identically in all variants. + +--- + +## Map System + +World Monitor ships two independent map renderers and an adapter that picks the +right one at runtime. + +### DeckGLMap + +**File:** `src/components/DeckGLMap.ts` (3 853 lines) + +The primary, high-performance map used on desktop and WebGL-capable browsers. + +#### Constructor + +```ts +interface DeckMapState { + zoom: number; + pan: [number, number]; + view: DeckMapView; + layers: MapLayers; + timeRange: TimeRange; +} + +type DeckMapView = + | 'global' | 'america' | 'mena' | 'eu' + | 'asia' | 'latam' | 'africa' | 'oceania'; + +const map = new DeckGLMap(container, initialState); +``` + +#### Exported Types + +| Type | Description | +|---|---| +| `TimeRange` | `{ start: number; end: number }` epoch-ms window. | +| `DeckMapView` | 8 named camera presets. | +| `CountryClickPayload` | `{ iso: string; name: string; lngLat: [number, number] }` | +| `MapInteractionMode` | `'flat' \| '3d'` — controlled by `MAP_INTERACTION_MODE` env. | + +#### Rendering Stack + +Built on **MapLibre GL JS** (`maplibregl.Map`) with a **deck.gl** overlay +(`MapboxOverlay` from `@deck.gl/mapbox`). The following deck.gl layer types +are used: + +- `GeoJsonLayer` — country polygons, cables, pipelines, waterways +- `ScatterplotLayer` — point events (earthquakes, fires, outages, bases) +- `PathLayer` — flight tracks, vessel tracks +- `IconLayer` — infrastructure icons, cluster markers +- `TextLayer` — labels +- `ArcLayer` — origin/destination arcs (trade, displacement) +- `HeatmapLayer` — density surfaces (protests, fires) +- `Supercluster` — client-side clustering of dense point data + +#### Static Infrastructure Data + +The map embeds or lazily loads over 20 static datasets: + +| Dataset | Constant / Source | +|---|---| +| Intelligence hotspots | `INTEL_HOTSPOTS` | +| Conflict zones | `CONFLICT_ZONES` | +| Military bases | `MILITARY_BASES` | +| Undersea cables | `UNDERSEA_CABLES` | +| Nuclear facilities | `NUCLEAR_FACILITIES` | +| Gamma irradiators | `GAMMA_IRRADIATORS` | +| Pipelines | `PIPELINES` | +| Strategic waterways | `STRATEGIC_WATERWAYS` | +| Economic centers | `ECONOMIC_CENTERS` | +| AI data centers | `AI_DATA_CENTERS` | +| Startup hubs | `STARTUP_HUBS` | +| Accelerators | `ACCELERATORS` | +| Tech HQs | `TECH_HQS` | +| Cloud regions | `CLOUD_REGIONS` | +| Ports | `PORTS` | +| Spaceports | `SPACEPORTS` | +| APT groups | `APT_GROUPS` | +| Critical minerals | `CRITICAL_MINERALS` | +| Stock exchanges | `STOCK_EXCHANGES` | +| Financial centers | `FINANCIAL_CENTERS` | +| Central banks | `CENTRAL_BANKS` | +| Commodity hubs | `COMMODITY_HUBS` | +| Gulf investments | `GULF_INVESTMENTS` | + +#### Services + +| Service | Purpose | +|---|---| +| `hotspot-escalation` | Escalation scoring for hotspot markers. | +| `country-instability` | CII heat overlay. | +| `geo-convergence` | Convergence ring rendering. | +| `country-geometry` | GeoJSON boundaries. | +| `MapPopup` | Generates popup HTML for clicked features. | + +#### Variant & Theme Logic + +- **`SITE_VARIANT`** determines which static infrastructure layers are + loaded (e.g. `tech` loads `AI_DATA_CENTERS`, `STARTUP_HUBS`, + `CLOUD_REGIONS`; `finance` loads `STOCK_EXCHANGES`, `FINANCIAL_CENTERS`). +- **`MAP_INTERACTION_MODE`** env var toggles flat (2-D pitch-locked) vs 3-D + (free pitch/bearing). +- **Theme**: basemap switches between CARTO Dark Matter and CARTO Positron + via `getOverlayColors()` which provides a colour palette per theme. + +--- + +### MapComponent + +**File:** `src/components/Map.ts` (3 500 lines) + +Fallback **D3 + SVG** map used on mobile and devices without WebGL. + +#### Constructor + +```ts +interface MapState { + zoom: number; + pan: [number, number]; + view: MapView; + layers: MapLayers; + timeRange: TimeRange; +} + +const map = new MapComponent(container, initialState); +``` + +#### DOM Structure + +``` +div.map-wrapper#mapWrapper + ├─ svg.map-svg#mapSvg ← D3 projected countries & overlays + ├─ canvas.map-cluster-canvas#mapClusterCanvas ← Canvas 2-D for clusters + └─ div#mapOverlays ← Popup container +``` + +#### Key Data Overlays + +| Overlay | Source | +|---|---| +| Hotspots | `hotspot-escalation` | +| Earthquakes | USGS feed | +| Weather alerts | `weather` service | +| Outages | Cloudflare / service-status | +| AIS disruptions | AIS feed | +| Cable advisories | Undersea cable advisories | +| Protests | ACLED | +| Military flights | OpenSky | +| Military vessels | AIS snapshot | +| Natural events | EONET | +| FIRMS fires | NASA FIRMS | +| Tech events | Tech events API | +| Tech activities | GitHub trending, HackerNews | +| Geo activities | Geo-convergence | +| News | Geolocated news clusters | + +#### Events Consumed + +| Event | Source | Effect | +|---|---|---| +| `theme-changed` | `window` | Re-renders all layers with new colour palette. | + +#### Variant Logic + +Imports `SITE_VARIANT` and adjusts loaded data layers accordingly. Runs a +health-check interval every **30 seconds**. + +--- + +### MapContainer + +**File:** `src/components/MapContainer.ts` (553 lines) + +Adapter / façade that selects the appropriate map renderer at startup. + +#### Constructor + +```ts +const mapContainer = new MapContainer(container, initialState); +``` + +#### Selection Logic + +``` +if (isMobileDevice() || !hasWebGLSupport()) + → MapComponent (D3/SVG) +else + → DeckGLMap (WebGL/deck.gl) +``` + +The container element receives the CSS class `deckgl-mode` or `svg-mode` +accordingly. + +#### Delegated API + +All calls are forwarded transparently to the underlying renderer: + +| Method | Description | +|---|---| +| `render()` | Full re-render. | +| `setView(view)` | Switch named camera preset. | +| `setZoom(z)` | Set zoom level. | +| `setCenter(lng, lat)` | Pan to coordinates. | +| `setTimeRange(range)` | Filter displayed events by time. | +| `setLayers(layers)` | Toggle layer visibility. | +| `getState()` | Return current map state. | +| `setEarthquakes(data)` | Push earthquake data. | +| `setWeatherAlerts(data)` | Push weather alerts. | +| `setOutages(data)` | Push outage data. | +| … | (and many more domain-specific setters) | + +--- + +### MapPopup + +**File:** `src/components/MapPopup.ts` (2 400+ lines) + +**Not a class** — a collection of builder functions that generate popup HTML +for every feature type the map can display. + +#### PopupType Union + +Over 40 discriminated popup types grouped into categories: + +| Category | Types (examples) | +|---|---| +| Conflict | `conflict-event`, `protest`, `acled-event`, `hotspot`, `conflict-zone` | +| Military | `military-base`, `military-flight`, `military-vessel`, `spaceport` | +| Natural | `earthquake`, `weather-alert`, `natural-event`, `firms-fire` | +| Infrastructure | `cable`, `pipeline`, `nuclear`, `port`, `waterway`, `gamma-irradiator` | +| Tech | `datacenter`, `tech-hq`, `cloud-region`, `startup-hub`, `accelerator`, `tech-event` | +| Finance | `stock-exchange`, `financial-center`, `central-bank`, `commodity-hub`, `gulf-investment` | +| Intelligence | `apt-group`, `critical-mineral`, `news-cluster`, `convergence` | + +Each popup type has a dedicated data interface and a builder function that +returns sanitized HTML. + +--- + +## Virtual Scrolling + +**File:** `src/components/VirtualList.ts` + +Two complementary strategies for rendering large lists without creating +thousands of DOM nodes. + +### VirtualList + +Fixed-height, DOM-recycling virtual scroller. + +#### Constructor + +```ts +interface VirtualListOptions { + itemHeight: number; // px height per row + overscan?: number; // extra rows above/below viewport (default 3) + container: HTMLElement; // parent element + renderItem: (index: number, el: HTMLElement) => void; + onRecycle?: (el: HTMLElement) => void; +} + +const vl = new VirtualList(options); +``` + +#### Methods + +| Method | Signature | Description | +|---|---|---| +| `setItemCount()` | `(n: number): void` | Total number of items. | +| `refresh()` | `(): void` | Re-render visible items from current scroll position. | +| `scrollToIndex()` | `(i: number): void` | Programmatic scroll. | +| `getViewport()` | `(): { start: number; end: number }` | Currently visible range. | +| `destroy()` | `(): void` | Cleanup. | + +#### DOM Structure + +``` +div.virtual-viewport + └─ div.virtual-content ← total height = itemHeight × itemCount + ├─ (spacer top) + ├─ div.virtual-item ← pooled, reused via renderItem() + ├─ div.virtual-item + ├─ ... + └─ (spacer bottom) +``` + +--- + +### WindowedList\ + +Variable-height, chunk-based windowed scroller. Used by `NewsPanel`. + +#### Constructor + +```ts +interface WindowedListOptions { + container: HTMLElement; + chunkSize?: number; // items per chunk (default 10) + bufferChunks?: number; // chunks to keep rendered above/below viewport +} + +const wl = new WindowedList(options, renderItem, onRendered?); +``` + +#### Methods + +| Method | Description | +|---|---| +| `setItems(items: T[])` | Replace item array, re-chunk, re-render. | +| `refresh()` | Force re-render of visible chunks. | +| `destroy()` | Cleanup, remove intersection observers. | + +#### DOM Structure + +``` +div.windowed-list + ├─ div.windowed-chunk ← IntersectionObserver placeholder + ├─ div.windowed-chunk + └─ ... +``` + +--- + +## Search + +### SearchModal + +**File:** `src/components/SearchModal.ts` (377 lines) + +Global search overlay accessible via `Ctrl+K` / `Cmd+K`. + +#### Constructor + +```ts +const search = new SearchModal(container, { + placeholder?: string, // input placeholder text + hint?: string, // footer hint text +}); +``` + +#### Methods + +| Method | Signature | Description | +|---|---|---| +| `registerSource(type, items)` | `(type: SearchResultType, items: SearchItem[]): void` | Add or replace a searchable dataset. | +| `setOnSelect(callback)` | `(cb: (result: SearchResult) => void): void` | Selection handler. | +| `open()` | `(): void` | Show modal, focus input. | +| `close()` | `(): void` | Hide modal. | +| `isOpen()` | `(): boolean` | Visibility check. | + +#### Search Result Types + +20+ discriminated types: + +``` +country | news | hotspot | market | prediction | conflict | base | +pipeline | cable | datacenter | earthquake | outage | nuclear | +techhq | exchange | financial-center | central-bank | commodity-hub | +gulf-investment | apt-group | critical-mineral | ... +``` + +#### Scoring + +| Match kind | Score | +|---|---| +| Prefix match | 2 | +| Substring match | 1 | + +Results are further sorted by a **priority tier**: + +``` +news > prediction > market > earthquake > outage > +conflict > hotspot > country > infrastructure > tech +``` + +#### Persistence + +Recent selections stored in `localStorage['worldmonitor_recent_searches']` +(most recent 10). + +#### DOM Structure + +``` +div.search-overlay + └─ div.search-modal + ├─ div.search-header + │ ├─ svg (search icon) + │ ├─ input[type=text] + │ └─ kbd (Esc) + ├─ div.search-results ← rendered matches + └─ div.search-footer ← hint text +``` + +--- + +## Domain Panels + +All domain panels extend `Panel` (§2) and fill `.panel-content` with +domain-specific markup. + +### Intelligence & Analysis Panels + +#### InsightsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/InsightsPanel.ts` | +| **Panel ID** | `insights` | +| **Purpose** | AI-generated analytical insights aggregated from multiple ML pipelines. | +| **Constructor** | `new InsightsPanel()` — no extra args. | +| **Key methods** | `setMilitaryFlights(flights)` | +| **Services** | `mlWorker`, `generateSummary`, `parallelAnalysis`, `signalAggregator`, `focalPointDetector`, `ingestNewsForCII`, `getTheaterPostureSummaries` | +| **Variant** | All | +| **Notes** | Hidden on mobile via CSS media query. | + +#### CIIPanel (Country Instability Index) + +| Field | Detail | +|---|---| +| **File** | `src/components/CIIPanel.ts` (150 lines) | +| **Panel ID** | `cii` | +| **Purpose** | Ranks countries by a composite instability score (U/C/S/I sub-scores). | +| **Key methods** | `setShareStoryHandler()`, `refresh(forceLocal?)`, `getScores()` | +| **DOM** | `.cii-list` → `.cii-country` each with header (emoji flag, name, score, trend arrow, share button), colour bar, sub-score row (U/C/S/I). | +| **Services** | `calculateCII()`, `getCSSColor` | +| **Variant** | `full` only | + +#### GdeltIntelPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/GdeltIntelPanel.ts` | +| **Panel ID** | `gdelt-intel` | +| **Purpose** | Multi-topic intelligence digest from GDELT data. | +| **Key methods** | `refresh()`, `refreshAll()` | +| **Services** | `getIntelTopics`, `fetchTopicIntelligence` | +| **Variant** | `full` only | +| **Notes** | Tab-based UI, per-topic 5-minute cache. | + +#### StrategicRiskPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/StrategicRiskPanel.ts` | +| **Panel ID** | `strategic-risk` | +| **Purpose** | Composite strategic risk overview with convergence detection. | +| **Key methods** | `refresh()` | +| **Services** | `calculateStrategicRiskOverview`, `getRecentAlerts`, `detectConvergence`, `dataFreshness`, `getLearningProgress`, `fetchCachedRiskScores` | +| **Data badge** | `live` / `cached` / `unavailable` | +| **Variant** | `full` only | + +#### StrategicPosturePanel + +| Field | Detail | +|---|---| +| **File** | `src/components/StrategicPosturePanel.ts` | +| **Panel ID** | `strategic-posture` | +| **Purpose** | Theater-level military posture assessment. | +| **Services** | `fetchCachedTheaterPosture`, `fetchMilitaryVessels`, `recalcPostureWithVessels` | +| **Variant** | `full` only | +| **Notes** | Multi-stage loading, 5-minute refresh interval. | + +#### CascadePanel + +| Field | Detail | +|---|---| +| **File** | `src/components/CascadePanel.ts` | +| **Panel ID** | `cascade` | +| **Purpose** | Infrastructure cascade / dependency-graph analysis. | +| **Services** | `buildDependencyGraph`, `calculateCascade`, `getGraphStats`, `clearGraphCache` (from `infrastructure-cascade` service) | +| **Variant** | `full` only | + +#### MonitorPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/MonitorPanel.ts` (172 lines) | +| **Panel ID** | `monitors` | +| **Purpose** | User-defined keyword monitors that match against news. | +| **Key methods** | `removeMonitor(id)`, `renderResults(news)` | +| **DOM** | Input + add button, `#monitorsList` (`.monitor-tag` pills), `#monitorsResults` | +| **Matching** | Word-boundary regex, max 10 results per keyword. | +| **Variant** | All | + +--- + +### News & Content Panels + +#### NewsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/NewsPanel.ts` (593 lines) | +| **Constructor** | `new NewsPanel(id: string, title: string)` | +| **Purpose** | Clustered news feed with velocity scoring and AI summaries. | +| **Scrolling** | `WindowedList` with `chunkSize: 8`. | +| **Services** | `analysisWorker`, `enrichWithVelocityML`, `getClusterAssetContext`, `activityTracker`, `generateSummary`, `translateText`, `getSourcePropagandaRisk` / `getSourceTier` / `getSourceType` | +| **DOM extras** | Deviation indicator, summary container, summarize button. | +| **Variant** | `SITE_VARIANT` used in summary cache key. | + +#### LiveNewsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/LiveNewsPanel.ts` (703 lines) | +| **Panel ID** | `live-news` | +| **Purpose** | Embedded YouTube live-stream player with channel switching. | +| **DOM** | YouTube IFrame player, channel switcher bar, mute/live buttons. | +| **Services** | `fetchLiveVideoId`, `isDesktopRuntime`, `getRemoteApiBaseUrl` | +| **Variant channels** | `tech` → `TECH_LIVE_CHANNELS` (Bloomberg, Yahoo Finance, CNBC, NASA TV). `world` / `full` → `FULL_LIVE_CHANNELS` (Bloomberg, Sky, Euronews, DW, CNBC, France24, Al Arabiya, Al Jazeera). | +| **Notes** | Idle pause after 5 minutes of inactivity. | + +#### PredictionPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/PredictionPanel.ts` (62 lines) | +| **Panel ID** | `polymarket` | +| **Purpose** | Polymarket prediction market odds display. | +| **Key methods** | `renderPredictions(data)` | +| **DOM** | `.prediction-item` with question text, volume, yes/no percentage bars. | +| **Variant** | All | + +#### LiveWebcamsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/LiveWebcamsPanel.ts` | +| **Panel ID** | `live-webcams` | +| **Purpose** | Grid of live YouTube webcam feeds from global locations. | +| **Data** | Hardcoded `WEBCAM_FEEDS` (YouTube channels). | +| **Variant** | All | +| **Notes** | Region filters, grid/single view toggle, idle pause. | + +--- + +### Markets & Finance Panels + +#### MarketPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/MarketPanel.ts` (152 lines, shared file) | +| **Panel ID** | `markets` | +| **Purpose** | Stock index overview with sparkline charts. | +| **DOM** | `.market-item` with inline sparkline ``. | +| **Helper** | `miniSparkline()` — generates a tiny inline SVG polyline. | + +#### HeatmapPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/MarketPanel.ts` (shared file) | +| **Panel ID** | `heatmap` | +| **Purpose** | Market heatmap grid (sector / index performance). | +| **DOM** | `.heatmap` grid of colour-coded cells. | + +#### CommoditiesPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/MarketPanel.ts` (shared file) | +| **Panel ID** | `commodities` | +| **Purpose** | Commodity price overview. | +| **DOM** | `.commodities-grid` of price cells. | + +#### CryptoPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/MarketPanel.ts` (shared file) | +| **Panel ID** | `crypto` | +| **Purpose** | Top crypto assets with sparklines. | +| **DOM** | `.market-item` with inline sparkline ``. | + +#### ETFFlowsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/ETFFlowsPanel.ts` | +| **Panel ID** | `etf-flows` | +| **Purpose** | ETF fund flow data. | +| **Services** | `fetch('/api/etf-flows')` | +| **Refresh** | Auto, every 3 minutes. | +| **Variant** | `finance` / `full` / `tech` | + +#### MacroSignalsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/MacroSignalsPanel.ts` | +| **Panel ID** | `macro-signals` | +| **Purpose** | Macro economic signal aggregator with BUY/CASH verdict. | +| **Services** | `fetch('/api/macro-signals')` | +| **DOM** | Sparklines, donut gauge, verdict label. | +| **Refresh** | Auto, every 3 minutes. | +| **Variant** | `finance` / `full` / `tech` | + +#### StablecoinPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/StablecoinPanel.ts` | +| **Panel ID** | `stablecoins` | +| **Purpose** | Stablecoin peg deviation monitor. | +| **Services** | `fetch('/api/stablecoin-markets')` | +| **DOM** | Peg status badges (pegged / depegging / depegged). | +| **Refresh** | Auto, every 3 minutes. | +| **Variant** | `finance` / `full` / `tech` | + +#### InvestmentsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/InvestmentsPanel.ts` | +| **Panel ID** | `gcc-investments` | +| **Constructor** | `new InvestmentsPanel(onInvestmentClick?)` | +| **Purpose** | GCC / Gulf sovereign investment tracker. | +| **Services** | `GULF_INVESTMENTS` config data. | +| **Variant** | `finance` | +| **Notes** | Multi-filter, sortable columns. | + +#### EconomicPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/EconomicPanel.ts` | +| **Panel ID** | `economic` | +| **Purpose** | Three-tab economic dashboard: indicators (FRED), oil (EIA), spending (USASpending). | +| **Key methods** | `update(data)`, `updateOil(data)`, `updateSpending(data)` | +| **Variant** | All | + +--- + +### Military & Security Panels + +#### UcdpEventsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/UcdpEventsPanel.ts` | +| **Panel ID** | `ucdp-events` | +| **Purpose** | Uppsala Conflict Data Program — armed conflict event log. | +| **Key methods** | `setEventClickHandler()`, `setEvents()`, `getEvents()` | +| **DOM** | Three tabs: state-based / non-state / one-sided. Max 50 rows per tab. | +| **Variant** | `full` only | + +#### PlaybackControl + +| Field | Detail | +|---|---| +| **File** | `src/components/PlaybackControl.ts` (178 lines) | +| **Purpose** | Time-travel slider for historical snapshot playback. | +| **DOM** | `div.playback-control` with toggle, range slider, action buttons (⏮ ◀ LIVE ▶ ⏭). | +| **Key methods** | `getElement()`, `onSnapshotChange` callback. | +| **Services** | `getSnapshotTimestamps()`, `getSnapshotAt()` | +| **Notes** | Adds `.playback-mode` class to `` when active. | + +--- + +### Natural Events & Humanitarian Panels + +#### ClimateAnomalyPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/ClimateAnomalyPanel.ts` | +| **Panel ID** | `climate` | +| **Purpose** | Climate anomaly zones with severity indicators. | +| **Key methods** | `setZoneClickHandler(handler)`, `setAnomalies(anomalies)` | +| **Services** | `getSeverityIcon`, `formatDelta` from climate service. | + +#### SatelliteFiresPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/SatelliteFiresPanel.ts` | +| **Panel ID** | `satellite-fires` | +| **Purpose** | NASA FIRMS satellite fire detection statistics. | +| **Key methods** | `update(stats, totalCount)` | +| **Variant** | `full` only | + +#### PopulationExposurePanel + +| Field | Detail | +|---|---| +| **File** | `src/components/PopulationExposurePanel.ts` | +| **Panel ID** | `population-exposure` | +| **Purpose** | Population within impact radius of active events. | +| **Key methods** | `setExposures()` | +| **DOM** | Event type icons (conflict / earthquake / flood / fire). Highlighted row at 1 M+. | + +#### DisplacementPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/DisplacementPanel.ts` | +| **Panel ID** | `displacement` | +| **Purpose** | UNHCR displacement data — refugee origin / hosting. | +| **Key methods** | `setCountryClickHandler()`, `setData(data)` | +| **Services** | `formatPopulation` from UNHCR service. | +| **DOM** | Two tabs: origins / hosts. | + +--- + +### Infrastructure & Tech Panels + +#### TechEventsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/TechEventsPanel.ts` | +| **Constructor** | `new TechEventsPanel(id)` | +| **Purpose** | Tech conferences, earnings calls, product launches. | +| **Services** | `fetch('/api/tech-events')` | +| **DOM** | View mode switcher: upcoming / conferences / earnings / all. | +| **Variant** | `tech` | + +#### TechHubsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/TechHubsPanel.ts` | +| **Panel ID** | `tech-hubs` | +| **Purpose** | Top-10 tech hub activity ranking. | +| **Key methods** | `setOnHubClick()`, `setActivities()` | +| **Variant** | `tech` | + +#### TechReadinessPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/TechReadinessPanel.ts` | +| **Panel ID** | `tech-readiness` | +| **Purpose** | Country technology readiness rankings (World Bank data). | +| **Key methods** | `refresh()` | +| **Services** | `getTechReadinessRankings` from `worldbank` service. | +| **Refresh** | Every 6 hours. Top 25 countries. | +| **Variant** | `tech` | + +#### GeoHubsPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/GeoHubsPanel.ts` | +| **Panel ID** | `geo-hubs` | +| **Purpose** | Geopolitical hub activity monitor. | +| **Key methods** | `setOnHubClick()`, `setActivities()` | +| **Services** | `GeoHubActivity` type. | +| **Variant** | `full` only | + +#### RegulationPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/RegulationPanel.ts` | +| **Panel ID** | `regulation` | +| **Purpose** | AI / tech regulation tracker by country and timeline. | +| **DOM** | Four view modes: timeline / deadlines / regulations / countries. | +| **Services** | `AI_REGULATIONS`, `COUNTRY_REGULATION_PROFILES` | +| **Variant** | `tech` | + +#### ServiceStatusPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/ServiceStatusPanel.ts` | +| **Panel ID** | `service-status` | +| **Purpose** | External service health / availability monitor. | +| **Refresh** | Auto, every 60 seconds. | +| **DOM** | Category filter chips, status rows. | +| **Variant** | `tech` primarily | +| **Notes** | Desktop readiness checks via Tauri bridge. | + +--- + +### Platform Panels + +#### StatusPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/StatusPanel.ts` (251 lines) | +| **Panel ID** | `status` | +| **Purpose** | Internal feed & API health status dashboard. | +| **Key methods** | `updateFeed(name, status)`, `updateApi(name, status)`, `setFeedDisabled(name)` | +| **DOM** | `div.status-panel-container` with toggle button, sections: `feeds-list`, `apis-list`, `storage-info`. | +| **Variant data** | `tech` → `TECH_FEEDS` / `TECH_APIS`. `world` / `full` → `WORLD_FEEDS` / `WORLD_APIS`. | + +#### RuntimeConfigPanel + +| Field | Detail | +|---|---| +| **File** | `src/components/RuntimeConfigPanel.ts` | +| **Panel ID** | `runtime-config` | +| **Purpose** | Runtime configuration / API-key management panel. | +| **Key methods** | `commitPendingSecrets()`, `hasPendingChanges()`, `verifyPendingSecrets()` | +| **Variant** | All | +| **Notes** | Desktop vs web mode adapts form fields. Tauri bridge for secure secrets storage. | + +#### LanguageSelector + +| Field | Detail | +|---|---| +| **File** | `src/components/LanguageSelector.ts` | +| **Purpose** | UI language switcher dropdown. | +| **Constructor** | `new LanguageSelector()` | +| **Key methods** | `getElement()` | +| **Services** | `ENABLED_LANGUAGES`, `changeLanguage`, `getCurrentLanguage` | +| **Variant** | All | + +--- + +## Modals & Widgets + +Standalone components that are not panels — overlays, banners, badges, and +small UI affordances. + +### SignalModal + +| Field | Detail | +|---|---| +| **File** | `src/components/SignalModal.ts` (326 lines) | +| **Purpose** | Full-screen modal for correlation signals and unified alerts. | +| **DOM** | `div.signal-modal-overlay` → header, scrollable content, footer (audio toggle + dismiss). | +| **Methods** | `show(signals)`, `showSignal(signal)`, `showAlert(alert)`, `setLocationClickHandler()`, `hide()` | +| **Audio** | Inline base64-encoded WAV notification sound. | +| **Types** | `CorrelationSignal`, `UnifiedAlert` | + +### CountryIntelModal + +| Field | Detail | +|---|---| +| **File** | `src/components/CountryIntelModal.ts` | +| **Purpose** | AI-generated country intelligence briefing modal. | +| **Services** | `getCSSColor`, `country-instability` types. | + +### CountryBriefPage + +| Field | Detail | +|---|---| +| **File** | `src/components/CountryBriefPage.ts` | +| **Purpose** | Full-page overlay showing a comprehensive country intelligence brief with nearby infrastructure analysis. | +| **Services** | `getNearbyInfrastructure`, `haversineDistanceKm`, `exportCountryBriefJSON`, `exportCountryBriefCSV` | + +### CountryTimeline + +| Field | Detail | +|---|---| +| **File** | `src/components/CountryTimeline.ts` | +| **Constructor** | `new CountryTimeline(container)` | +| **Purpose** | D3 SVG timeline of country events across conflict / protest / natural / military lanes. | +| **Methods** | `render(events)` | + +### StoryModal + +| Field | Detail | +|---|---| +| **File** | `src/components/StoryModal.ts` | +| **Purpose** | Shareable story card generator and viewer. | +| **Exported functions** | `openStoryModal(data)`, `closeStoryModal()` | +| **Share targets** | WhatsApp, X (Twitter), LinkedIn, clipboard copy. | +| **Notes** | Generates PNG image for sharing via canvas rendering. | + +### MobileWarningModal + +| Field | Detail | +|---|---| +| **File** | `src/components/MobileWarningModal.ts` | +| **Purpose** | First-visit warning on mobile devices about limited functionality. | +| **Static** | `MobileWarningModal.shouldShow()` | +| **Notes** | Dismissible, remembers via `localStorage`. | + +### DownloadBanner + +| Field | Detail | +|---|---| +| **File** | `src/components/DownloadBanner.ts` | +| **Purpose** | Prompts web users to download the desktop app. | +| **Exported** | `maybeShowDownloadBanner()` | +| **Platform detection** | `macos-arm64`, `macos-x64`, `windows`, `linux` | + +### CommunityWidget + +| Field | Detail | +|---|---| +| **File** | `src/components/CommunityWidget.ts` | +| **Purpose** | Small floating widget linking to GitHub Discussions. | +| **Exported** | `mountCommunityWidget()` | +| **Notes** | Dismissible via `localStorage`. | + +### PizzIntIndicator + +| Field | Detail | +|---|---| +| **File** | `src/components/PizzIntIndicator.ts` | +| **Purpose** | DEFCON-style 1–5 threat indicator with expandable detail panel. | +| **DOM** | Toggle button + expandable panel. | +| **Notes** | Links to `pizzint.watch`. | + +### IntelligenceGapBadge (IntelligenceFindingsBadge) + +| Field | Detail | +|---|---| +| **File** | `src/components/IntelligenceGapBadge.ts` | +| **Purpose** | Header badge + dropdown showing intelligence findings and gaps. | +| **Key methods** | `setOnSignalClick()`, `setOnAlertClick()` | +| **Notes** | Audio notification for new findings. `full` variant only. | + +### VerificationChecklist + +| Field | Detail | +|---|---| +| **File** | `src/components/VerificationChecklist.ts` | +| **Purpose** | Bellingcat-style open-source verification checklist (8 checks). | +| **Framework** | **Preact** component (`extends Component`) — the only Preact component in the codebase. | +| **Notes** | Verdict scoring based on completed checks. | + +--- + +## Variant Visibility Matrix + +| Component | `world` / `full` | `tech` | `finance` | +|---|:---:|:---:|:---:| +| Panel (base) | ✅ | ✅ | ✅ | +| DeckGLMap | ✅ | ✅ | ✅ | +| MapComponent | ✅ | ✅ | ✅ | +| MapContainer | ✅ | ✅ | ✅ | +| MapPopup | ✅ | ✅ | ✅ | +| VirtualList / WindowedList | ✅ | ✅ | ✅ | +| SearchModal | ✅ | ✅ | ✅ | +| NewsPanel | ✅ | ✅ | ✅ | +| LiveNewsPanel | ✅ | ✅ | — | +| PredictionPanel | ✅ | ✅ | ✅ | +| LiveWebcamsPanel | ✅ | ✅ | ✅ | +| MarketPanel | ✅ | ✅ | ✅ | +| HeatmapPanel | ✅ | ✅ | ✅ | +| CommoditiesPanel | ✅ | ✅ | ✅ | +| CryptoPanel | ✅ | ✅ | ✅ | +| ETFFlowsPanel | ✅ | ✅ | ✅ | +| MacroSignalsPanel | ✅ | ✅ | ✅ | +| StablecoinPanel | ✅ | ✅ | ✅ | +| InvestmentsPanel | — | — | ✅ | +| EconomicPanel | ✅ | ✅ | ✅ | +| InsightsPanel | ✅ | ✅ | ✅ | +| CIIPanel | ✅ | — | — | +| GdeltIntelPanel | ✅ | — | — | +| StrategicRiskPanel | ✅ | — | — | +| StrategicPosturePanel | ✅ | — | — | +| CascadePanel | ✅ | — | — | +| MonitorPanel | ✅ | ✅ | ✅ | +| UcdpEventsPanel | ✅ | — | — | +| ClimateAnomalyPanel | ✅ | ✅ | — | +| SatelliteFiresPanel | ✅ | — | — | +| PopulationExposurePanel | ✅ | ✅ | — | +| DisplacementPanel | ✅ | — | — | +| TechEventsPanel | — | ✅ | — | +| TechHubsPanel | — | ✅ | — | +| TechReadinessPanel | — | ✅ | — | +| GeoHubsPanel | ✅ | — | — | +| RegulationPanel | — | ✅ | — | +| ServiceStatusPanel | — | ✅ | — | +| StatusPanel | ✅ | ✅ | ✅ | +| RuntimeConfigPanel | ✅ | ✅ | ✅ | +| LanguageSelector | ✅ | ✅ | ✅ | +| PlaybackControl | ✅ | ✅ | ✅ | +| SignalModal | ✅ | ✅ | ✅ | +| CountryIntelModal | ✅ | ✅ | — | +| CountryBriefPage | ✅ | ✅ | — | +| CountryTimeline | ✅ | ✅ | — | +| StoryModal | ✅ | ✅ | ✅ | +| MobileWarningModal | ✅ | ✅ | ✅ | +| DownloadBanner | ✅ | ✅ | ✅ | +| CommunityWidget | ✅ | ✅ | ✅ | +| PizzIntIndicator | ✅ | — | — | +| IntelligenceFindingsBadge | ✅ | — | — | +| VerificationChecklist | ✅ | ✅ | — | + +--- + +## Component Interaction Diagram + +```mermaid +graph TD + subgraph App["App.ts (Orchestrator)"] + APP[App] + end + + subgraph Maps["Map System"] + MC[MapContainer] + DGL[DeckGLMap] + SVG[MapComponent] + MP[MapPopup] + MC -->|desktop/WebGL| DGL + MC -->|mobile/fallback| SVG + DGL --> MP + SVG --> MP + end + + subgraph Intelligence["Intelligence & Analysis"] + INS[InsightsPanel] + CII[CIIPanel] + GDL[GdeltIntelPanel] + SRP[StrategicRiskPanel] + SPP[StrategicPosturePanel] + CSC[CascadePanel] + MON[MonitorPanel] + end + + subgraph News["News & Content"] + NP[NewsPanel] + LNP[LiveNewsPanel] + PP[PredictionPanel] + LWP[LiveWebcamsPanel] + end + + subgraph Markets["Markets & Finance"] + MKT[MarketPanel] + HMP[HeatmapPanel] + CMD[CommoditiesPanel] + CRY[CryptoPanel] + ETF[ETFFlowsPanel] + MAC[MacroSignalsPanel] + STB[StablecoinPanel] + INV[InvestmentsPanel] + ECO[EconomicPanel] + end + + subgraph Military["Military & Security"] + UCDP[UcdpEventsPanel] + PBC[PlaybackControl] + end + + subgraph NatHum["Natural & Humanitarian"] + CLP[ClimateAnomalyPanel] + SFP[SatelliteFiresPanel] + PEP[PopulationExposurePanel] + DSP[DisplacementPanel] + end + + subgraph Tech["Infrastructure & Tech"] + TEP[TechEventsPanel] + THP[TechHubsPanel] + TRP[TechReadinessPanel] + GHP[GeoHubsPanel] + REG[RegulationPanel] + SSP[ServiceStatusPanel] + end + + subgraph Platform["Platform"] + STP[StatusPanel] + RCP[RuntimeConfigPanel] + LNG[LanguageSelector] + end + + subgraph Modals["Modals & Widgets"] + SIG[SignalModal] + CIM[CountryIntelModal] + CBP[CountryBriefPage] + CTL[CountryTimeline] + STM[StoryModal] + IGB[IntelligenceFindingsBadge] + PIZ[PizzIntIndicator] + end + + subgraph Services["Service Layer"] + HES[hotspot-escalation] + CIS[country-instability] + GCS[geo-convergence] + MLW[mlWorker] + ACT[activityTracker] + SAS[signalAggregator] + end + + subgraph Search["Search"] + SM[SearchModal] + end + + APP --> MC + APP --> SM + APP --> Intelligence + APP --> News + APP --> Markets + APP --> Military + APP --> NatHum + APP --> Tech + APP --> Platform + APP --> Modals + + DGL --> HES + DGL --> CIS + DGL --> GCS + SVG --> HES + SVG --> CIS + SVG --> GCS + + INS --> MLW + INS --> SAS + CII --> CIS + SRP --> GCS + + NP --> MLW + NP --> ACT + + SM -.->|registers sources| APP + SIG -.->|triggered by| SAS + IGB -.->|triggered by| SAS +``` + +### Reading the Diagram + +- **Solid arrows** (`→`) represent ownership or direct method calls. +- **Dashed arrows** (`-.->`) represent event-driven or callback-based + connections. +- `App.ts` is the top-level orchestrator that instantiates all panels, + wires callbacks, and drives the refresh cycle. +- The **Service Layer** is shared — multiple panels and map components call + the same services. +- `MapContainer` is the single map entry point; it delegates to + `DeckGLMap` or `MapComponent` based on runtime capability detection. + +--- + +## Appendix: Barrel Exports + +`src/components/index.ts` re-exports 40+ symbols: + +```ts +// Panels +export { Panel } from './Panel'; +export { NewsPanel } from './NewsPanel'; +export { LiveNewsPanel } from './LiveNewsPanel'; +export { MarketPanel, HeatmapPanel, CommoditiesPanel, CryptoPanel } from './MarketPanel'; +export { CIIPanel } from './CIIPanel'; +export { MonitorPanel } from './MonitorPanel'; +export { PredictionPanel } from './PredictionPanel'; +export { StatusPanel } from './StatusPanel'; +export { InsightsPanel } from './InsightsPanel'; +export { CascadePanel } from './CascadePanel'; +export { ClimateAnomalyPanel } from './ClimateAnomalyPanel'; +export { EconomicPanel } from './EconomicPanel'; +export { ETFFlowsPanel } from './ETFFlowsPanel'; +export { GdeltIntelPanel } from './GdeltIntelPanel'; +export { GeoHubsPanel } from './GeoHubsPanel'; +export { MacroSignalsPanel } from './MacroSignalsPanel'; +export { StablecoinPanel } from './StablecoinPanel'; +export { InvestmentsPanel } from './InvestmentsPanel'; +export { TechEventsPanel } from './TechEventsPanel'; +export { TechHubsPanel } from './TechHubsPanel'; +export { TechReadinessPanel } from './TechReadinessPanel'; +export { UcdpEventsPanel } from './UcdpEventsPanel'; +export { DisplacementPanel } from './DisplacementPanel'; +export { SatelliteFiresPanel } from './SatelliteFiresPanel'; +export { PopulationExposurePanel } from './PopulationExposurePanel'; +export { StrategicPosturePanel } from './StrategicPosturePanel'; +export { StrategicRiskPanel } from './StrategicRiskPanel'; +export { RegulationPanel } from './RegulationPanel'; +export { ServiceStatusPanel } from './ServiceStatusPanel'; +export { RuntimeConfigPanel } from './RuntimeConfigPanel'; +export { LiveWebcamsPanel } from './LiveWebcamsPanel'; + +// Map system +export { DeckGLMap } from './DeckGLMap'; +export { MapComponent } from './Map'; +export { MapContainer } from './MapContainer'; + +// Scrolling +export { VirtualList, WindowedList } from './VirtualList'; + +// Search +export { SearchModal } from './SearchModal'; + +// Modals & widgets +export { SignalModal } from './SignalModal'; +export { CountryIntelModal } from './CountryIntelModal'; +export { CountryBriefPage } from './CountryBriefPage'; +export { CountryTimeline } from './CountryTimeline'; +export { PlaybackControl } from './PlaybackControl'; +export { LanguageSelector } from './LanguageSelector'; +export { VerificationChecklist } from './VerificationChecklist'; +// ... and standalone functions +``` + +> **Note:** The barrel file is the canonical list. If a component is not +> exported here it is either internal or mounted directly by `App.ts`. diff --git a/docs/Docs_To_Review/DATA_MODEL.md b/docs/Docs_To_Review/DATA_MODEL.md new file mode 100644 index 000000000..ab8e9d6de --- /dev/null +++ b/docs/Docs_To_Review/DATA_MODEL.md @@ -0,0 +1,1894 @@ +# Data Model Reference + +Comprehensive data model documentation for **World Monitor** — an AI-powered real-time global intelligence dashboard. This reference covers all TypeScript interfaces, data structures, and their relationships across the system. + +> **Source of truth:** [`src/types/index.ts`](../src/types/index.ts) (1,297 lines, 60+ interfaces) + +--- + +## Table of Contents + +1. [Core News & Events](#1-core-news--events) +2. [Geopolitical & Military](#2-geopolitical--military) +3. [Cyber & Security](#3-cyber--security) +4. [Humanitarian & Climate](#4-humanitarian--climate) +5. [Infrastructure](#5-infrastructure) +6. [Natural Events](#6-natural-events) +7. [Markets & Finance](#7-markets--finance) +8. [Tech Variant Types](#8-tech-variant-types) +9. [Panel & Map Configuration](#9-panel--map-configuration) +10. [Application State](#10-application-state) +11. [Focal Points](#11-focal-points) +12. [Social Unrest](#12-social-unrest) +13. [Entity Model](#13-entity-model) +14. [News Item Lifecycle](#14-news-item-lifecycle) +15. [Signal Model](#15-signal-model) +16. [Map Data Models](#16-map-data-models) +17. [Panel State Model](#17-panel-state-model) +18. [Variant Configuration](#18-variant-configuration) +19. [Risk Scoring Models](#19-risk-scoring-models) +20. [Cache & Storage Schemas](#20-cache--storage-schemas) + +--- + +## 1. Core News & Events + +The news pipeline ingests RSS feeds, parses individual items, clusters them into events, and scores each event for severity and velocity. + +### Feed + +RSS feed configuration. Each feed defines a source to poll, with optional metadata for filtering and propaganda risk assessment. + +```typescript +interface Feed { + name: string; + url: string | Record; + type?: string; + region?: string; + propagandaRisk?: PropagandaRisk; // 'low' | 'medium' | 'high' + stateAffiliated?: string; // e.g. "Russia", "China", "Iran" + lang?: string; // ISO 2-letter language code +} +``` + +### NewsItem + +A single parsed news article from an RSS feed. The minimal unit of intelligence in the pipeline. + +```typescript +interface NewsItem { + source: string; + title: string; + link: string; + pubDate: Date; + isAlert: boolean; + monitorColor?: string; + tier?: number; + threat?: ThreatClassification; + lat?: number; + lon?: number; + locationName?: string; + lang?: string; +} +``` + +### ClusteredEvent + +Multiple `NewsItem`s merged into a single event via Jaccard or hybrid semantic clustering. This is the primary unit displayed in news panels. + +```typescript +interface ClusteredEvent { + id: string; + primaryTitle: string; + primarySource: string; + primaryLink: string; + sourceCount: number; + topSources: Array<{ name: string; tier: number; url: string }>; + allItems: NewsItem[]; + firstSeen: Date; + lastUpdated: Date; + isAlert: boolean; + monitorColor?: string; + velocity?: VelocityMetrics; + threat?: ThreatClassification; + lat?: number; + lon?: number; + lang?: string; +} +``` + +### VelocityMetrics + +Measures how quickly a story is spreading across sources. Used to detect breaking news and surging stories. + +```typescript +type VelocityLevel = 'normal' | 'elevated' | 'spike'; +type SentimentType = 'negative' | 'neutral' | 'positive'; + +interface VelocityMetrics { + sourcesPerHour: number; + level: VelocityLevel; + trend: 'rising' | 'stable' | 'falling'; + sentiment: SentimentType; + sentimentScore: number; +} +``` + +### RelatedAsset + +Links a news event to a nearby physical asset (pipeline, cable, datacenter, military base, nuclear facility). + +```typescript +type AssetType = 'pipeline' | 'cable' | 'datacenter' | 'base' | 'nuclear'; + +interface RelatedAsset { + id: string; + name: string; + type: AssetType; + distanceKm: number; +} + +interface RelatedAssetContext { + origin: { label: string; lat: number; lon: number }; + types: AssetType[]; + assets: RelatedAsset[]; +} +``` + +--- + +## 2. Geopolitical & Military + +### Hotspot + +A monitored geopolitical hotspot from the geo configuration. Includes static metadata and dynamic escalation tracking. + +```typescript +type EscalationTrend = 'escalating' | 'stable' | 'de-escalating'; + +interface Hotspot { + id: string; + name: string; + lat: number; + lon: number; + keywords: string[]; + subtext?: string; + location?: string; // e.g. "Sahel Region, West Africa" + agencies?: string[]; + level?: 'low' | 'elevated' | 'high'; + description?: string; + status?: string; + escalationScore?: 1 | 2 | 3 | 4 | 5; + escalationTrend?: EscalationTrend; + escalationIndicators?: string[]; + history?: HistoricalContext; + whyItMatters?: string; +} + +interface HistoricalContext { + lastMajorEvent?: string; + lastMajorEventDate?: string; + precedentCount?: number; + precedentDescription?: string; + cyclicalRisk?: string; +} +``` + +### DynamicEscalationScore + +Real-time escalation assessment combining static baselines with live signal data. Maintained in `src/services/hotspot-escalation.ts`. + +```typescript +interface DynamicEscalationScore { + hotspotId: string; + staticBaseline: number; + dynamicScore: number; + combinedScore: number; + trend: EscalationTrend; + components: { + newsActivity: number; // weight: 0.35 + ciiContribution: number; // weight: 0.25 + geoConvergence: number; // weight: 0.25 + militaryActivity: number; // weight: 0.15 + }; + history: Array<{ timestamp: number; score: number }>; + lastUpdated: Date; +} +``` + +### ConflictZone + +Active conflict zone with polygon boundaries and contextual metadata. + +```typescript +interface ConflictZone { + id: string; + name: string; + coords: [number, number][]; + center: [number, number]; + intensity?: 'high' | 'medium' | 'low'; + parties?: string[]; + casualties?: string; + displaced?: string; + keywords?: string[]; + startDate?: string; + location?: string; + description?: string; + keyDevelopments?: string[]; +} +``` + +### UCDP Geo Events + +Georeferenced events from the Uppsala Conflict Data Program. + +```typescript +type UcdpEventType = 'state-based' | 'non-state' | 'one-sided'; + +interface UcdpGeoEvent { + id: string; + date_start: string; + date_end: string; + latitude: number; + longitude: number; + country: string; + side_a: string; + side_b: string; + deaths_best: number; + deaths_low: number; + deaths_high: number; + type_of_violence: UcdpEventType; + source_original: string; +} +``` + +### Military Bases + +Foreign military installations and bases worldwide. + +```typescript +type MilitaryBaseType = + | 'us-nato' | 'china' | 'russia' | 'uk' | 'france' + | 'india' | 'italy' | 'uae' | 'turkey' | 'japan' | 'other'; + +interface MilitaryBase { + id: string; + name: string; + lat: number; + lon: number; + type: MilitaryBaseType; + description?: string; + country?: string; // Host country + arm?: string; // Armed forces branch + status?: 'active' | 'planned' | 'controversial' | 'closed'; + source?: string; +} +``` + +### Military Flights + +Tracked military aircraft from ADS-B/OpenSky with classification metadata. + +```typescript +type MilitaryAircraftType = + | 'fighter' | 'bomber' | 'transport' | 'tanker' | 'awacs' + | 'reconnaissance' | 'helicopter' | 'drone' | 'patrol' + | 'special_ops' | 'vip' | 'unknown'; + +type MilitaryOperator = + | 'usaf' | 'usn' | 'usmc' | 'usa' | 'raf' | 'rn' + | 'faf' | 'gaf' | 'plaaf' | 'plan' | 'vks' | 'iaf' + | 'nato' | 'other'; + +interface MilitaryFlight { + id: string; + callsign: string; + hexCode: string; + registration?: string; + aircraftType: MilitaryAircraftType; + aircraftModel?: string; + operator: MilitaryOperator; + operatorCountry: string; + lat: number; + lon: number; + altitude: number; + heading: number; + speed: number; + verticalRate?: number; + onGround: boolean; + squawk?: string; + origin?: string; + destination?: string; + lastSeen: Date; + firstSeen?: Date; + track?: [number, number][]; + confidence: 'high' | 'medium' | 'low'; + isInteresting?: boolean; + note?: string; + enriched?: { + manufacturer?: string; + owner?: string; + operatorName?: string; + typeCode?: string; + builtYear?: string; + confirmedMilitary?: boolean; + militaryBranch?: string; + }; +} + +interface MilitaryFlightCluster { + id: string; + name: string; + lat: number; + lon: number; + flightCount: number; + flights: MilitaryFlight[]; + dominantOperator?: MilitaryOperator; + activityType?: 'exercise' | 'patrol' | 'transport' | 'unknown'; +} +``` + +### Military Vessels + +Naval vessel tracking from AIS data with type classification. + +```typescript +type MilitaryVesselType = + | 'carrier' | 'destroyer' | 'frigate' | 'submarine' + | 'amphibious' | 'patrol' | 'auxiliary' | 'research' + | 'icebreaker' | 'special' | 'unknown'; + +interface MilitaryVessel { + id: string; + mmsi: string; + name: string; + vesselType: MilitaryVesselType; + aisShipType?: string; + hullNumber?: string; + operator: MilitaryOperator | 'other'; + operatorCountry: string; + lat: number; + lon: number; + heading: number; + speed: number; + course?: number; + destination?: string; + lastAisUpdate: Date; + aisGapMinutes?: number; + isDark?: boolean; + nearChokepoint?: string; + nearBase?: string; + track?: [number, number][]; + confidence: 'high' | 'medium' | 'low'; + isInteresting?: boolean; + note?: string; +} + +interface MilitaryVesselCluster { + id: string; + name: string; + lat: number; + lon: number; + vesselCount: number; + vessels: MilitaryVessel[]; + region?: string; + activityType?: 'exercise' | 'deployment' | 'transit' | 'unknown'; +} +``` + +### Military Activity Summary + +Aggregated view of all tracked military assets. + +```typescript +interface MilitaryActivitySummary { + flights: MilitaryFlight[]; + vessels: MilitaryVessel[]; + flightClusters: MilitaryFlightCluster[]; + vesselClusters: MilitaryVesselCluster[]; + activeOperations: number; + lastUpdate: Date; +} +``` + +### Strategic Waterways & AIS + +```typescript +interface StrategicWaterway { + id: string; + name: string; + lat: number; + lon: number; + description?: string; +} + +type AisDisruptionType = 'gap_spike' | 'chokepoint_congestion'; + +interface AisDisruptionEvent { + id: string; + name: string; + type: AisDisruptionType; + lat: number; + lon: number; + severity: 'low' | 'elevated' | 'high'; + changePct: number; + windowHours: number; + darkShips?: number; + vesselCount?: number; + region?: string; + description: string; +} + +interface AisDensityZone { + id: string; + name: string; + lat: number; + lon: number; + intensity: number; + deltaPct: number; + shipsPerDay?: number; + note?: string; +} +``` + +### GDELT Tension & PizzINT + +```typescript +interface GdeltTensionPair { + id: string; + countries: [string, string]; + label: string; + score: number; + trend: 'rising' | 'stable' | 'falling'; + changePercent: number; + region: string; +} + +// Pentagon Pizza Index (novelty OSINT indicator) +type PizzIntDefconLevel = 1 | 2 | 3 | 4 | 5; +type PizzIntDataFreshness = 'fresh' | 'stale'; + +interface PizzIntStatus { + defconLevel: PizzIntDefconLevel; + defconLabel: string; + aggregateActivity: number; + activeSpikes: number; + locationsMonitored: number; + locationsOpen: number; + lastUpdate: Date; + dataFreshness: PizzIntDataFreshness; + locations: PizzIntLocation[]; +} +``` + +--- + +## 3. Cyber & Security + +### CyberThreat + +Cyber threat indicators sourced from threat intelligence feeds (Feodo, URLhaus, C2Intel, OTX, AbuseIPDB). + +```typescript +type CyberThreatType = 'c2_server' | 'malware_host' | 'phishing' | 'malicious_url'; +type CyberThreatSource = 'feodo' | 'urlhaus' | 'c2intel' | 'otx' | 'abuseipdb'; +type CyberThreatSeverity = 'low' | 'medium' | 'high' | 'critical'; +type CyberThreatIndicatorType = 'ip' | 'domain' | 'url'; + +interface CyberThreat { + id: string; + type: CyberThreatType; + source: CyberThreatSource; + indicator: string; + indicatorType: CyberThreatIndicatorType; + lat: number; + lon: number; + country?: string; + severity: CyberThreatSeverity; + malwareFamily?: string; + tags: string[]; + firstSeen?: string; + lastSeen?: string; +} +``` + +### APT Groups + +Known Advanced Persistent Threat group profiles, mapped to their attributed state sponsors. + +```typescript +interface APTGroup { + id: string; + name: string; + aka: string; + sponsor: string; + lat: number; + lon: number; +} +``` + +--- + +## 4. Humanitarian & Climate + +### UNHCR Displacement + +Three-tier model: global summary → country-level displacement → individual flow corridors. + +```typescript +interface DisplacementFlow { + originCode: string; + originName: string; + asylumCode: string; + asylumName: string; + refugees: number; + originLat?: number; + originLon?: number; + asylumLat?: number; + asylumLon?: number; +} + +interface CountryDisplacement { + code: string; + name: string; + // Origin-country displacement outflow + refugees: number; + asylumSeekers: number; + idps: number; + stateless: number; + totalDisplaced: number; + // Host-country intake + hostRefugees: number; + hostAsylumSeekers: number; + hostTotal: number; + lat?: number; + lon?: number; +} + +interface UnhcrSummary { + year: number; + globalTotals: { + refugees: number; + asylumSeekers: number; + idps: number; + stateless: number; + total: number; + }; + countries: CountryDisplacement[]; + topFlows: DisplacementFlow[]; +} +``` + +### Climate Anomalies + +Derived from Open-Meteo / ERA5 reanalysis data. + +```typescript +type AnomalySeverity = 'normal' | 'moderate' | 'extreme'; + +interface ClimateAnomaly { + zone: string; + lat: number; + lon: number; + tempDelta: number; + precipDelta: number; + severity: AnomalySeverity; + type: 'warm' | 'cold' | 'wet' | 'dry' | 'mixed'; + period: string; +} +``` + +### Population Exposure + +WorldPop-derived population density for impact assessment. + +```typescript +interface CountryPopulation { + code: string; + name: string; + population: number; + densityPerKm2: number; +} + +interface PopulationExposure { + eventId: string; + eventName: string; + eventType: string; + lat: number; + lon: number; + exposedPopulation: number; + exposureRadiusKm: number; +} +``` + +--- + +## 5. Infrastructure + +### Undersea Cables + +Submarine cable routes with landing points and country-level capacity data. + +```typescript +interface CableLandingPoint { + country: string; // ISO code + countryName: string; + city?: string; + lat: number; + lon: number; +} + +interface CountryCapacity { + country: string; // ISO code + capacityShare: number; // 0–1 + isRedundant: boolean; +} + +interface UnderseaCable { + id: string; + name: string; + points: [number, number][]; + major?: boolean; + landingPoints?: CableLandingPoint[]; + countriesServed?: CountryCapacity[]; + capacityTbps?: number; + rfsYear?: number; + owners?: string[]; +} + +interface CableAdvisory { + id: string; + cableId: string; + title: string; + severity: 'fault' | 'degraded'; + description: string; + reported: Date; + lat: number; + lon: number; + impact: string; + repairEta?: string; +} + +interface RepairShip { + id: string; + name: string; + cableId: string; + status: 'enroute' | 'on-station'; + lat: number; + lon: number; + eta: string; + operator?: string; + note?: string; +} +``` + +### Pipelines + +Oil and gas pipelines with terminal endpoints and capacity data. + +```typescript +type PipelineType = 'oil' | 'gas' | 'products'; +type PipelineStatus = 'operating' | 'construction'; + +interface PipelineTerminal { + country: string; + name?: string; + portId?: string; + lat?: number; + lon?: number; +} + +interface Pipeline { + id: string; + name: string; + type: PipelineType; + status: PipelineStatus; + points: [number, number][]; + capacity?: string; + length?: string; + operator?: string; + countries?: string[]; + origin?: PipelineTerminal; + destination?: PipelineTerminal; + transitCountries?: string[]; + capacityMbpd?: number; + capacityBcmY?: number; + alternatives?: string[]; +} +``` + +### Internet Outages + +Real-time internet connectivity disruptions. + +```typescript +interface InternetOutage { + id: string; + title: string; + link: string; + description: string; + pubDate: Date; + country: string; + region?: string; + lat: number; + lon: number; + severity: 'partial' | 'major' | 'total'; + categories: string[]; + cause?: string; + outageType?: string; + endDate?: Date; +} +``` + +### Infrastructure Cascade Analysis + +Graph-based dependency model for simulating cascading failures across infrastructure networks. + +```typescript +type InfrastructureNodeType = 'cable' | 'pipeline' | 'port' | 'chokepoint' | 'country' | 'route'; + +interface InfrastructureNode { + id: string; + type: InfrastructureNodeType; + name: string; + coordinates?: [number, number]; + metadata?: Record; +} + +type DependencyType = + | 'serves' | 'terminates_at' | 'transits_through' | 'lands_at' + | 'depends_on' | 'shares_risk' | 'alternative_to' + | 'trade_route' | 'controls_access' | 'trade_dependency'; + +interface DependencyEdge { + from: string; + to: string; + type: DependencyType; + strength: number; // 0–1 criticality + redundancy?: number; // 0–1 replaceability + metadata?: { + capacityShare?: number; + alternativeRoutes?: number; + estimatedImpact?: string; + portType?: string; + relationship?: string; + }; +} + +type CascadeImpactLevel = 'critical' | 'high' | 'medium' | 'low'; + +interface CascadeAffectedNode { + node: InfrastructureNode; + impactLevel: CascadeImpactLevel; + pathLength: number; + dependencyChain: string[]; + redundancyAvailable: boolean; + estimatedRecovery?: string; +} + +interface CascadeResult { + source: InfrastructureNode; + affectedNodes: CascadeAffectedNode[]; + countriesAffected: CascadeCountryImpact[]; + economicImpact?: { + dailyTradeLoss?: number; + affectedThroughput?: number; + }; + redundancies?: Array<{ + id: string; + name: string; + capacityShare: number; + }>; +} +``` + +### Nuclear Facilities + +```typescript +type NuclearFacilityType = + | 'plant' | 'enrichment' | 'reprocessing' | 'weapons' + | 'ssbn' | 'test-site' | 'icbm' | 'research'; + +interface NuclearFacility { + id: string; + name: string; + lat: number; + lon: number; + type: NuclearFacilityType; + status: 'active' | 'contested' | 'inactive' | 'decommissioned' | 'construction'; + operator?: string; +} + +interface GammaIrradiator { + id: string; + city: string; + country: string; + lat: number; + lon: number; + organization?: string; +} +``` + +--- + +## 6. Natural Events + +### Earthquakes + +USGS earthquake data. + +```typescript +interface Earthquake { + id: string; + place: string; + magnitude: number; + lat: number; + lon: number; + depth: number; + time: Date; + url: string; +} +``` + +### NASA EONET Natural Events + +```typescript +type NaturalEventCategory = + | 'severeStorms' | 'wildfires' | 'volcanoes' | 'earthquakes' + | 'floods' | 'landslides' | 'drought' | 'dustHaze' + | 'snow' | 'tempExtremes' | 'seaLakeIce' | 'waterColor' | 'manmade'; + +interface NaturalEvent { + id: string; + title: string; + description?: string; + category: NaturalEventCategory; + categoryTitle: string; + lat: number; + lon: number; + date: Date; + magnitude?: number; + magnitudeUnit?: string; + sourceUrl?: string; + sourceName?: string; + closed: boolean; +} +``` + +--- + +## 7. Markets & Finance + +### MarketData + +Equities, indices, and commodities pricing. + +```typescript +interface MarketData { + symbol: string; + name: string; + display: string; + price: number | null; + change: number | null; + sparkline?: number[]; +} + +interface CryptoData { + name: string; + symbol: string; + price: number; + change: number; + sparkline?: number[]; +} + +interface Sector { + symbol: string; + name: string; +} + +interface Commodity { + symbol: string; + name: string; + display: string; +} +``` + +### Prediction Markets + +Polymarket-sourced prediction contract data. + +```typescript +interface PredictionMarket { + title: string; + yesPrice: number; + volume?: number; + url?: string; +} +``` + +### Gulf FDI Investments + +Tracks Saudi and UAE foreign direct investment in global infrastructure. + +```typescript +type GulfInvestorCountry = 'SA' | 'UAE'; + +type GulfInvestmentSector = + | 'ports' | 'pipelines' | 'energy' | 'datacenters' | 'airports' + | 'railways' | 'telecoms' | 'water' | 'logistics' | 'mining' + | 'real-estate' | 'manufacturing'; + +type GulfInvestmentStatus = + | 'operational' | 'under-construction' | 'announced' + | 'rumoured' | 'cancelled' | 'divested'; + +type GulfInvestingEntity = + | 'DP World' | 'AD Ports' | 'Mubadala' | 'ADIA' | 'ADNOC' + | 'Masdar' | 'PIF' | 'Saudi Aramco' | 'ACWA Power' | 'STC' + | 'Mawani' | 'NEOM' | 'Emirates Global Aluminium' | 'Other'; + +interface GulfInvestment { + id: string; + investingEntity: GulfInvestingEntity; + investingCountry: GulfInvestorCountry; + targetCountry: string; + targetCountryIso: string; + sector: GulfInvestmentSector; + assetType: string; + assetName: string; + lat: number; + lon: number; + investmentUSD?: number; + stakePercent?: number; + status: GulfInvestmentStatus; + yearAnnounced?: number; + yearOperational?: number; + description: string; + sourceUrl?: string; + tags?: string[]; +} +``` + +### Economic Centers + +```typescript +type EconomicCenterType = 'exchange' | 'central-bank' | 'financial-hub'; + +interface EconomicCenter { + id: string; + name: string; + type: EconomicCenterType; + lat: number; + lon: number; + country: string; + marketHours?: { open: string; close: string; timezone: string }; + description?: string; +} +``` + +--- + +## 8. Tech Variant Types + +Specialized types for the Tech variant dashboard. + +### AI Data Centers + +```typescript +interface AIDataCenter { + id: string; + name: string; + owner: string; + country: string; + lat: number; + lon: number; + status: 'existing' | 'planned' | 'decommissioned'; + chipType: string; + chipCount: number; + powerMW?: number; + h100Equivalent?: number; + sector?: string; + note?: string; +} +``` + +### AI Regulation + +```typescript +type RegulationType = 'comprehensive' | 'sectoral' | 'voluntary' | 'proposed'; +type ComplianceStatus = 'active' | 'proposed' | 'draft' | 'superseded'; +type RegulationStance = 'strict' | 'moderate' | 'permissive' | 'undefined'; + +interface AIRegulation { + id: string; + name: string; + shortName: string; + country: string; + region?: string; + type: RegulationType; + status: ComplianceStatus; + announcedDate: string; + effectiveDate?: string; + complianceDeadline?: string; + scope: string[]; + keyProvisions: string[]; + penalties?: string; + link?: string; + description?: string; +} + +interface RegulatoryAction { + id: string; + date: string; + country: string; + title: string; + type: 'law-passed' | 'executive-order' | 'guideline' | 'enforcement' | 'consultation'; + regulationId?: string; + description: string; + impact: 'high' | 'medium' | 'low'; + source?: string; +} +``` + +### Tech Companies & AI Research Labs + +```typescript +interface TechCompany { + id: string; + name: string; + lat: number; + lon: number; + country: string; + city?: string; + sector?: string; + officeType?: 'headquarters' | 'regional' | 'engineering' | 'research' | 'campus' | 'major office'; + employees?: number; + foundedYear?: number; + keyProducts?: string[]; + valuation?: number; + stockSymbol?: string; + description?: string; +} + +interface AIResearchLab { + id: string; + name: string; + lat: number; + lon: number; + country: string; + city?: string; + type: 'corporate' | 'academic' | 'government' | 'nonprofit' | 'industry' | 'research institute'; + parent?: string; + focusAreas?: string[]; + description?: string; + foundedYear?: number; + notableWork?: string[]; + publications?: number; + faculty?: number; +} + +interface StartupEcosystem { + id: string; + name: string; + lat: number; + lon: number; + country: string; + city: string; + ecosystemTier?: 'tier1' | 'tier2' | 'tier3' | 'emerging'; + totalFunding2024?: number; + activeStartups?: number; + unicorns?: number; + topSectors?: string[]; + majorVCs?: string[]; + notableStartups?: string[]; + avgSeedRound?: number; + avgSeriesA?: number; + description?: string; +} +``` + +--- + +## 9. Panel & Map Configuration + +### PanelConfig + +Per-panel toggle and priority for the variant system. + +```typescript +interface PanelConfig { + name: string; + enabled: boolean; + priority?: number; +} +``` + +### MapLayers + +35+ boolean layer toggles that control which data overlays appear on the map. + +```typescript +interface MapLayers { + // Geopolitical + conflicts: boolean; + bases: boolean; + hotspots: boolean; + military: boolean; + sanctions: boolean; + + // Infrastructure + cables: boolean; + pipelines: boolean; + nuclear: boolean; + irradiators: boolean; + datacenters: boolean; + waterways: boolean; + spaceports: boolean; + minerals: boolean; + + // Security + cyberThreats: boolean; + outages: boolean; + + // Environmental + weather: boolean; + fires: boolean; + natural: boolean; + climate: boolean; + + // Tracking + ais: boolean; + flights: boolean; + protests: boolean; + + // Economic + economic: boolean; + gulfInvestments: boolean; + stockExchanges: boolean; + financialCenters: boolean; + centralBanks: boolean; + commodityHubs: boolean; + + // Data sources + ucdpEvents: boolean; + displacement: boolean; + + // Tech variant + startupHubs: boolean; + cloudRegions: boolean; + accelerators: boolean; + techHQs: boolean; + techEvents: boolean; +} +``` + +### Monitor + +User-defined keyword monitors that highlight matching news items. + +```typescript +interface Monitor { + id: string; + keywords: string[]; + color: string; + name?: string; + lat?: number; + lon?: number; +} +``` + +--- + +## 10. Application State + +Top-level application state holding all active data and UI configuration. + +```typescript +interface AppState { + currentView: 'global' | 'us'; + mapZoom: number; + mapPan: { x: number; y: number }; + mapLayers: MapLayers; + panels: Record; + monitors: Monitor[]; + allNews: NewsItem[]; + isLoading: boolean; +} +``` + +--- + +## 11. Focal Points + +Intelligence synthesis layer that detects entities (countries, companies) with converging news and signal activity. + +```typescript +type FocalPointUrgency = 'watch' | 'elevated' | 'critical'; + +interface HeadlineWithUrl { + title: string; + url: string; +} + +interface EntityMention { + entityId: string; + entityType: 'country' | 'company' | 'index' | 'commodity' | 'crypto' | 'sector'; + displayName: string; + mentionCount: number; + avgConfidence: number; + clusterIds: string[]; + topHeadlines: HeadlineWithUrl[]; +} + +interface FocalPoint { + id: string; + entityId: string; + entityType: 'country' | 'company' | 'index' | 'commodity' | 'crypto' | 'sector'; + displayName: string; + + // News dimension + newsMentions: number; + newsVelocity: number; + topHeadlines: HeadlineWithUrl[]; + + // Signal dimension + signalTypes: string[]; + signalCount: number; + highSeverityCount: number; + signalDescriptions: string[]; + + // Scoring + focalScore: number; + urgency: FocalPointUrgency; + + // AI context + narrative: string; + correlationEvidence: string[]; +} + +interface FocalPointSummary { + timestamp: Date; + focalPoints: FocalPoint[]; + aiContext: string; + topCountries: FocalPoint[]; + topCompanies: FocalPoint[]; +} +``` + +--- + +## 12. Social Unrest + +### SocialUnrestEvent + +Individual protest, riot, or civil unrest event sourced from ACLED, GDELT, or RSS feeds. + +```typescript +type ProtestSeverity = 'low' | 'medium' | 'high'; +type ProtestSource = 'acled' | 'gdelt' | 'rss'; +type ProtestEventType = 'protest' | 'riot' | 'strike' | 'demonstration' | 'civil_unrest'; + +interface SocialUnrestEvent { + id: string; + title: string; + summary?: string; + eventType: ProtestEventType; + city?: string; + country: string; + region?: string; + lat: number; + lon: number; + time: Date; + severity: ProtestSeverity; + fatalities?: number; + sources: string[]; + sourceType: ProtestSource; + tags?: string[]; + actors?: string[]; + relatedHotspots?: string[]; + confidence: 'high' | 'medium' | 'low'; + validated: boolean; + imageUrl?: string; + sentiment?: 'angry' | 'peaceful' | 'mixed'; +} +``` + +### ProtestCluster + +Geographically grouped protest events. + +```typescript +interface ProtestCluster { + id: string; + country: string; + region?: string; + eventCount: number; + events: SocialUnrestEvent[]; + severity: ProtestSeverity; + startDate: Date; + endDate: Date; + primaryCause?: string; +} +``` + +### Map Cluster Types + +Compact cluster representations used for map rendering (protest, tech HQ, datacenter, tech event). + +```typescript +interface MapProtestCluster { + id: string; + lat: number; + lon: number; + count: number; + items: SocialUnrestEvent[]; + country: string; + maxSeverity: 'low' | 'medium' | 'high'; + hasRiot: boolean; + totalFatalities: number; + riotCount?: number; + highSeverityCount?: number; + verifiedCount?: number; + sampled?: boolean; +} + +interface MapDatacenterCluster { + id: string; + lat: number; + lon: number; + count: number; + items: AIDataCenter[]; + region: string; + country: string; + totalChips: number; + totalPowerMW: number; + majorityExisting: boolean; + sampled?: boolean; +} +``` + +--- + +## 13. Entity Model + +**Source:** [`src/config/entities.ts`](../src/config/entities.ts) (636 lines) + +The entity system provides a registry of 600+ real-world entities (companies, indices, commodities, countries, crypto assets) used for news-to-asset linking. + +### Core Types + +```typescript +type EntityType = 'company' | 'index' | 'commodity' | 'crypto' | 'sector' | 'country'; + +interface EntityEntry { + id: string; // Ticker symbol or code (e.g., "AAPL", "^GSPC", "BTC") + type: EntityType; + name: string; // Display name (e.g., "Apple Inc.") + aliases: string[]; // All recognized names/abbreviations + keywords: string[]; // Contextual keywords for matching + sector?: string; // Sector classification (e.g., "Technology") + related?: string[]; // Entity IDs of related entities +} +``` + +### EntityIndex + +Multi-index lookup structure built from `ENTITY_REGISTRY`. Defined in [`src/services/entity-index.ts`](../src/services/entity-index.ts). + +```typescript +interface EntityIndex { + byId: Map; // Direct lookup by entity ID + byAlias: Map; // alias → entity ID (lowercased) + byKeyword: Map>; // keyword → set of entity IDs + bySector: Map>; // sector → set of entity IDs + byType: Map>; // entity type → set of entity IDs +} +``` + +**Lookup functions:** + +| Function | Signature | Description | +|----------|-----------|-------------| +| `buildEntityIndex()` | `(entities: EntityEntry[]) → EntityIndex` | Build all index maps | +| `getEntityIndex()` | `() → EntityIndex` | Singleton accessor (lazy build) | +| `lookupEntityByAlias()` | `(alias: string) → EntityEntry \| undefined` | Find entity by any alias | +| `lookupEntitiesByKeyword()` | `(keyword: string) → EntityEntry[]` | Find all entities matching keyword | +| `lookupEntitiesBySector()` | `(sector: string) → EntityEntry[]` | Find all entities in a sector | +| `findRelatedEntities()` | `(entityId: string) → EntityEntry[]` | Get related entities | +| `findEntitiesInText()` | `(text: string) → EntityMatch[]` | NLP-style entity extraction from text | + +### EntityMatch + +Result of text-based entity extraction. + +```typescript +interface EntityMatch { + entityId: string; + matchedText: string; + matchType: 'alias' | 'keyword' | 'name'; + confidence: number; + position: number; +} +``` + +--- + +## 14. News Item Lifecycle + +```mermaid +flowchart LR + A[RSS Feed] --> B[Parsed NewsItem] + B --> C{Clustering} + C -->|Jaccard only| D[clusterNews] + C -->|Jaccard + semantic| E[clusterNewsHybrid] + D --> F[ClusteredEvent] + E --> F + F --> G[Threat Classification] + G --> H[Entity Extraction] + H --> I[Severity & Velocity Scoring] + I --> J[Panel Display] +``` + +**Stages:** + +1. **Ingest** — RSS feeds polled at `REFRESH_INTERVALS.feeds` (5 min). Raw XML parsed into `NewsItem` objects. +2. **Cluster** — Duplicate/related stories merged via two strategies: + - `clusterNews()` — Jaccard similarity on tokenized titles. Fast, no ML dependency. + - `clusterNewsHybrid()` — Jaccard + ML-based semantic similarity via Web Worker embedding model (cosine similarity). Produces higher-quality merges. +3. **Classify** — Threat classifier assigns `ThreatClassification` with category and severity level. +4. **Entity extraction** — `findEntitiesInText()` matches aliases/keywords from `ENTITY_REGISTRY` against titles. +5. **Score** — `VelocityMetrics` computed (sources/hour, acceleration). Sentiment scored. +6. **Display** — Final `ClusteredEvent` rendered in news panels with velocity badges, threat indicators, and entity links. + +--- + +## 15. Signal Model + +**Source:** [`src/services/signal-aggregator.ts`](../src/services/signal-aggregator.ts) (495 lines) + +The signal aggregator collects all map-layer signals and correlates them by country/region to feed the AI Insights engine. + +### Signal Types + +```typescript +type SignalType = + | 'internet_outage' + | 'military_flight' + | 'military_vessel' + | 'protest' + | 'ais_disruption' + | 'satellite_fire' // NASA FIRMS thermal anomalies + | 'temporal_anomaly'; // Baseline deviation alerts +``` + +### Signal Pipeline + +```mermaid +flowchart TD + S1[Internet Outages] --> GS[GeoSignal] + S2[Military Flights] --> GS + S3[Military Vessels] --> GS + S4[Protests] --> GS + S5[AIS Disruptions] --> GS + S6[Satellite Fires] --> GS + S7[Temporal Anomalies] --> GS + GS --> CSC[CountrySignalCluster] + CSC --> RC[RegionalConvergence] + RC --> SS[SignalSummary] + SS --> AI[AI Insights Context] +``` + +### Core Signal Types + +```typescript +interface GeoSignal { + type: SignalType; + country: string; + countryName: string; + lat: number; + lon: number; + severity: 'low' | 'medium' | 'high'; + title: string; + timestamp: Date; +} + +interface CountrySignalCluster { + country: string; + countryName: string; + signals: GeoSignal[]; + signalTypes: Set; + totalCount: number; + highSeverityCount: number; + convergenceScore: number; +} + +interface RegionalConvergence { + region: string; + countries: string[]; + signalTypes: SignalType[]; + totalSignals: number; + description: string; +} + +interface SignalSummary { + timestamp: Date; + totalSignals: number; + byType: Record; + convergenceZones: RegionalConvergence[]; + topCountries: CountrySignalCluster[]; + aiContext: string; // Pre-formatted text for LLM prompts +} +``` + +### Region Definitions + +Six monitored regions with their constituent country codes: + +| Region | Countries | +|--------|-----------| +| Middle East | IR, IL, SA, AE, IQ, SY, YE, JO, LB, KW, QA, OM, BH | +| East Asia | CN, TW, JP, KR, KP, HK, MN | +| South Asia | IN, PK, BD, AF, NP, LK, MM | +| Eastern Europe | UA, RU, BY, PL, RO, MD, HU, CZ, SK, BG | +| North Africa | EG, LY, DZ, TN, MA, SD, SS | +| Sahel | ML, NE, BF, TD, NG, CM, CF | + +--- + +## 16. Map Data Models + +The map renders 35+ toggleable layers. Each layer is controlled by a boolean in `MapLayers`. + +### Layer Keys + +Defined in [`src/utils/urlState.ts`](../src/utils/urlState.ts), the `LAYER_KEYS` array lists all URL-serializable layer identifiers: + +```typescript +const LAYER_KEYS: (keyof MapLayers)[] = [ + 'conflicts', 'bases', 'cables', 'pipelines', 'hotspots', 'ais', + 'nuclear', 'irradiators', 'sanctions', 'weather', 'economic', + 'waterways', 'outages', 'cyberThreats', 'datacenters', 'protests', + 'flights', 'military', 'natural', 'spaceports', 'minerals', 'fires', + 'ucdpEvents', 'displacement', 'climate', + 'startupHubs', 'cloudRegions', 'accelerators', 'techHQs', 'techEvents', +]; +``` + +### Layer → Data Type Mapping + +| Layer | Data Interface | Source | +|-------|----------------|--------| +| `conflicts` | `ConflictZone` | Static config + UCDP | +| `bases` | `MilitaryBase` | Static config | +| `cables` | `UnderseaCable` | Static config | +| `pipelines` | `Pipeline` | Static config | +| `hotspots` | `Hotspot` | Static config + dynamic escalation | +| `ais` | `AisDisruptionEvent`, `AisDensityZone` | API | +| `nuclear` | `NuclearFacility` | Static config | +| `flights` | `MilitaryFlight` | OpenSky / Wingbits | +| `military` | `MilitaryVessel` | AIS data | +| `protests` | `MapProtestCluster` | ACLED / GDELT | +| `fires` | FIRMS data | NASA FIRMS API | +| `cyberThreats` | `CyberThreat` | Multi-source threat feeds | +| `outages` | `InternetOutage` | Cloudflare / IODA | +| `datacenters` | `MapDatacenterCluster` | Static config | +| `ucdpEvents` | `UcdpGeoEvent` | UCDP API | +| `displacement` | `CountryDisplacement` | UNHCR API | +| `climate` | `ClimateAnomaly` | Open-Meteo / ERA5 | +| `natural` | `NaturalEvent` | NASA EONET | +| `economic` | `EconomicCenter` | Static config | +| `gulfInvestments` | `GulfInvestment` | Static config | + +--- + +## 17. Panel State Model + +**Source:** [`src/components/Panel.ts`](../src/components/Panel.ts) + +### PanelOptions + +Constructor options for creating a panel widget. + +```typescript +interface PanelOptions { + id: string; + title: string; + showCount?: boolean; + className?: string; + trackActivity?: boolean; + infoTooltip?: string; +} +``` + +### Panel Persistence + +Panels persist their size and ordering in `localStorage`: + +| Key | Constant | Value Schema | +|-----|----------|--------------| +| `worldmonitor-panel-spans` | `PANEL_SPANS_KEY` | `Record` — panel ID → grid span (1–4) | +| `panel-order` | `PANEL_ORDER_KEY` | `string[]` — ordered panel IDs | + +### Span/Resize Logic + +`heightToSpan(height: number) → number` converts a pixel height to a grid span: + +| Pixel Height | Grid Span | +|-------------|-----------| +| < 250px | 1 | +| 250–349px | 2 | +| 350–499px | 3 | +| ≥ 500px | 4 | + +Functions: `loadPanelSpans()` reads from localStorage, `savePanelSpan(panelId, span)` writes back. + +--- + +## 18. Variant Configuration + +**Source:** [`src/config/variants/base.ts`](../src/config/variants/base.ts) + +The variant system supports multiple dashboard configurations (full, tech, finance) via an override chain. + +### VariantConfig + +```typescript +interface VariantConfig { + name: string; + description: string; + panels: Record; + mapLayers: MapLayers; + mobileMapLayers: MapLayers; +} +``` + +### Shared Constants + +```typescript +const API_URLS = { + finnhub: (symbols: string[]) => `/api/finnhub?symbols=...`, + yahooFinance: (symbol: string) => `/api/yahoo-finance?symbol=...`, + coingecko: '/api/coingecko?...', + polymarket: '/api/polymarket?...', + earthquakes: '/api/earthquakes', + arxiv: (category, maxResults) => `/api/arxiv?...`, + githubTrending: (language, since) => `/api/github-trending?...`, + hackernews: (type, limit) => `/api/hackernews?...`, +}; + +const REFRESH_INTERVALS = { + feeds: 5 * 60 * 1000, // 5 min + markets: 2 * 60 * 1000, // 2 min + crypto: 2 * 60 * 1000, // 2 min + predictions: 5 * 60 * 1000, // 5 min + ais: 10 * 60 * 1000, // 10 min + arxiv: 60 * 60 * 1000, // 1 hr + githubTrending: 30 * 60 * 1000, // 30 min + hackernews: 5 * 60 * 1000, // 5 min +}; + +const STORAGE_KEYS = { + panels: 'worldmonitor-panels', + monitors: 'worldmonitor-monitors', + mapLayers: 'worldmonitor-layers', + disabledFeeds: 'worldmonitor-disabled-feeds', +} as const; +``` + +### Override Chain + +``` +base.ts (defaults) → full.ts / tech.ts / finance.ts (overrides) +``` + +Each variant file imports from `base.ts` and selectively overrides `panels`, `mapLayers`, and `mobileMapLayers` to tailor the dashboard for its domain. + +--- + +## 19. Risk Scoring Models + +### Country Instability Index (CII) + +**Source:** [`src/services/country-instability.ts`](../src/services/country-instability.ts) (703 lines) + +Computes a real-time instability score per country from four components. + +```typescript +interface CountryScore { + code: string; + name: string; + score: number; + level: 'low' | 'normal' | 'elevated' | 'high' | 'critical'; + trend: 'rising' | 'stable' | 'falling'; + change24h: number; + components: ComponentScores; + lastUpdated: Date; +} + +interface ComponentScores { + unrest: number; // Protests, riots, civil unrest + conflict: number; // Armed conflict, UCDP events + security: number; // Military activity, internet outages + information: number; // News volume, velocity +} +``` + +**Learning mode:** 15-minute warmup period during which scores are unreliable. Bypassed when cached scores are available from the backend (`setHasCachedScores(true)`). + +**Input data per country:** `SocialUnrestEvent[]`, `ConflictEvent[]`, `UcdpConflictStatus`, `HapiConflictSummary`, `MilitaryFlight[]`, `MilitaryVessel[]`, `ClusteredEvent[]`, `InternetOutage[]`, displacement outflow, climate stress. + +### Cached Risk Scores + +**Source:** [`src/services/cached-risk-scores.ts`](../src/services/cached-risk-scores.ts) + +Pre-computed scores fetched from the backend to eliminate the learning mode delay. + +```typescript +interface CachedCIIScore { + code: string; + name: string; + score: number; + level: 'low' | 'normal' | 'elevated' | 'high' | 'critical'; + trend: 'rising' | 'stable' | 'falling'; + change24h: number; + components: ComponentScores; + lastUpdated: string; +} + +interface CachedStrategicRisk { + score: number; + level: string; + trend: string; + lastUpdated: string; + contributors: Array<{ + country: string; + code: string; + score: number; + level: string; + }>; +} + +interface CachedRiskScores { + cii: CachedCIIScore[]; + strategicRisk: CachedStrategicRisk; + protestCount: number; + computedAt: string; + cached: boolean; +} +``` + +### Hotspot Escalation + +**Source:** [`src/services/hotspot-escalation.ts`](../src/services/hotspot-escalation.ts) (349 lines) + +Combines a static baseline with four dynamic components to produce a real-time escalation score per hotspot. + +**Component weights:** + +| Component | Weight | Description | +|-----------|--------|-------------| +| `newsActivity` | 0.35 | Keyword matches, breaking news, velocity | +| `ciiContribution` | 0.25 | Country instability score for hotspot's country | +| `geoConvergence` | 0.25 | Nearby geospatial signal density | +| `militaryActivity` | 0.15 | Military flights/vessels within radius | + +**Constraints:** + +- 24-hour history window, maximum 48 history points +- 2-hour signal cooldown between updates per hotspot +- Static baseline from `Hotspot.escalationScore` (default: 3) + +--- + +## 20. Cache & Storage Schemas + +### Upstash Redis (Server-side) + +**Source:** [`api/_upstash-cache.js`](../api/_upstash-cache.js) + +- TTL-based expiration per endpoint +- In sidecar mode: in-memory `Map` with max 5,000 entries, disk-persisted to `api-cache.json` + +### IndexedDB (Client-side) + +**Source:** [`src/services/storage.ts`](../src/services/storage.ts) (230 lines) + +Database: `worldmonitor_db`, version 1. + +#### Store: `baselines` + +Rolling window statistics for temporal anomaly detection. + +```typescript +// keyPath: 'key' +interface BaselineEntry { + key: string; + counts: number[]; // Historical count values + timestamps: number[]; // Corresponding timestamps (ms) + avg7d: number; // 7-day rolling average + avg30d: number; // 30-day rolling average + lastUpdated: number; // Timestamp (ms) +} +``` + +**Deviation calculation:** + +```typescript +function calculateDeviation(current: number, baseline: BaselineEntry): { + zScore: number; + percentChange: number; + level: 'normal' | 'elevated' | 'spike' | 'quiet'; +} +``` + +| z-score | Level | +|---------|-------| +| > 2.5 | `spike` | +| > 1.5 | `elevated` | +| < −2.0 | `quiet` | +| otherwise | `normal` | + +#### Store: `snapshots` + +Periodic dashboard state captures for historical comparison. + +```typescript +// keyPath: 'timestamp', index: 'by_time' +interface DashboardSnapshot { + timestamp: number; + events: unknown[]; + marketPrices: Record; + predictions: Array<{ title: string; yesPrice: number }>; + hotspotLevels: Record; +} +``` + +Retention: 7 days. Cleaned via `cleanOldSnapshots()`. + +### Persistent Cache (Client-side) + +**Source:** [`src/services/persistent-cache.ts`](../src/services/persistent-cache.ts) + +Cross-runtime cache with Tauri invoke → localStorage fallback. + +```typescript +type CacheEnvelope = { + key: string; + updatedAt: number; + data: T; +}; +``` + +- Key prefix: `worldmonitor-persistent-cache:` +- Desktop runtime: Tauri `read_cache_entry` / `write_cache_entry` commands +- Web runtime: `localStorage` fallback +- Helper: `describeFreshness(updatedAt)` → `"just now" | "5m ago" | "2h ago" | "1d ago"` + +--- + +## Relationship Overview + +```mermaid +erDiagram + Feed ||--o{ NewsItem : "parsed into" + NewsItem }o--|| ClusteredEvent : "clustered into" + ClusteredEvent ||--o| VelocityMetrics : "scored with" + ClusteredEvent ||--o| ThreatClassification : "classified as" + ClusteredEvent }o--o{ EntityEntry : "mentions" + + Hotspot ||--|| DynamicEscalationScore : "scored by" + DynamicEscalationScore }o--|| CountryScore : "uses CII from" + + GeoSignal }o--|| CountrySignalCluster : "grouped into" + CountrySignalCluster }o--|| RegionalConvergence : "aggregated into" + RegionalConvergence }o--|| SignalSummary : "summarized in" + + FocalPoint }o--|| EntityEntry : "references" + FocalPoint }o--|| SignalSummary : "correlated with" + + InfrastructureNode }o--o{ DependencyEdge : "connected by" + DependencyEdge }o--|| CascadeResult : "produces" + + CountryScore }o--|| CachedRiskScores : "cached in" + BaselineEntry }o--|| DashboardSnapshot : "compared against" +``` diff --git a/docs/Docs_To_Review/DESKTOP_CONFIGURATION.md b/docs/Docs_To_Review/DESKTOP_CONFIGURATION.md new file mode 100644 index 000000000..658a199f9 --- /dev/null +++ b/docs/Docs_To_Review/DESKTOP_CONFIGURATION.md @@ -0,0 +1,53 @@ +# Desktop Runtime Configuration Schema + +World Monitor desktop now uses a runtime configuration schema with per-feature toggles and secret-backed credentials. + +## Secret keys + +The desktop vault schema supports the following 17 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` +- `VITE_OPENSKY_RELAY_URL` +- `OPENSKY_CLIENT_ID` +- `OPENSKY_CLIENT_SECRET` +- `AISSTREAM_API_KEY` +- `VITE_WS_RELAY_URL` + +## Feature schema + +Each feature includes: + +- `id`: stable feature identifier. +- `requiredSecrets`: list of keys that must be present and valid. +- `enabled`: user-toggle state from runtime settings panel. +- `available`: computed (`enabled && requiredSecrets valid`). +- `fallback`: user-facing degraded behavior description. + +## Desktop secret storage + +Desktop builds persist secrets in OS credential storage through Tauri command bindings backed by Rust `keyring` entries (`world-monitor` service namespace). + +Secrets are **not stored in plaintext files** by the frontend. + +## Degradation behavior + +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. +- 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. diff --git a/docs/Docs_To_Review/DOCUMENTATION.md b/docs/Docs_To_Review/DOCUMENTATION.md new file mode 100644 index 000000000..04e6aca32 --- /dev/null +++ b/docs/Docs_To_Review/DOCUMENTATION.md @@ -0,0 +1,4030 @@ +# World Monitor v2 + +AI-powered real-time global intelligence dashboard aggregating news, markets, geopolitical data, and infrastructure monitoring into a unified situation awareness interface. + +🌐 **[Live Demo: worldmonitor.app](https://worldmonitor.app)** | 💻 **[Tech Variant: tech.worldmonitor.app](https://tech.worldmonitor.app)** + +![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white) +![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat&logo=vite&logoColor=white) +![D3.js](https://img.shields.io/badge/D3.js-F9A03C?style=flat&logo=d3.js&logoColor=white) +![Version](https://img.shields.io/badge/version-2.1.4-blue) + +![World Monitor Dashboard](../new-world-monitor.png) + +## Platform Variants + +World Monitor runs two specialized variants from a single codebase, each optimized for different monitoring needs: + +| Variant | URL | Focus | +|---------|-----|-------| +| **🌍 World Monitor** | [worldmonitor.app](https://worldmonitor.app) | Geopolitical intelligence, military tracking, conflict monitoring, infrastructure security | +| **💻 Tech Monitor** | [tech.worldmonitor.app](https://tech.worldmonitor.app) | Technology sector intelligence, AI/startup ecosystems, cloud infrastructure, tech events | + +A compact **variant switcher** in the header allows seamless navigation between variants while preserving your map position and panel configuration. + +--- + +## World Monitor (Geopolitical) + +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 +- **Economic Intelligence** - FRED indicators, oil analytics, government spending, sanctions tracking +- **Natural Disasters** - Earthquakes, severe weather, NASA EONET events (wildfires, volcanoes, floods) +- **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 | +| **AI Strategic Posture** | Theater-level military force aggregation with strike capability assessment | +| **Country Instability Index** | Real-time stability scores for 20 monitored countries | +| **Strategic Risk Overview** | Composite risk score combining all intelligence modules | +| **Infrastructure Cascade** | Dependency analysis for cables, pipelines, and chokepoints | +| **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). + +--- + +## Tech Monitor + +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) | +| **Startup Hubs** | Major startup ecosystems with ecosystem tier, funding data, and notable companies | +| **Cloud Regions** | AWS, Azure, GCP data center regions with zone counts | +| **Accelerators** | Y Combinator, Techstars, 500 Startups, and regional accelerator locations | +| **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) | +| **Undersea Cables** | Submarine fiber routes critical for cloud connectivity | +| **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 +- **Hardware & Chips** - Semiconductors, AI accelerators, manufacturing +- **Developer & Open Source** - Languages, frameworks, open source projects +- **Tech Policy** - Regulation, antitrust, digital governance + +### Regional Tech HQ Coverage + +| Region | Notable Companies | +|--------|------------------| +| **Silicon Valley** | Apple, Google, Meta, Nvidia, Intel, Cisco, Oracle, VMware | +| **Seattle** | Microsoft, Amazon, Tableau, Expedia | +| **New York** | Bloomberg, MongoDB, Datadog, Squarespace | +| **London** | Revolut, Deliveroo, Darktrace, Monzo | +| **Tel Aviv** | Wix, Check Point, Monday.com, Fiverr | +| **Dubai/MENA** | Careem, Noon, Anghami, Property Finder, Kitopi | +| **Riyadh** | Tabby, Presight.ai, Ninja, XPANCEO | +| **Singapore** | Grab, Razer, Sea Limited | +| **Berlin** | Zalando, Delivery Hero, N26, Celonis | +| **Tokyo** | Sony, Toyota, SoftBank, Rakuten | + +--- + +## 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 +- **Time Filtering** - Filter events by time range (1h, 6h, 24h, 48h, 7d) +- **Pinnable Map** - Pin the map to the top while scrolling through panels, or let it scroll with the page +- **Smart Marker Clustering** - Nearby markers group at low zoom, expand on zoom in + +### Marker Clustering + +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) + +This prevents issues like Dubai and Riyadh companies appearing merged at global zoom, while still providing clean visualization at continental scales. + +### Data Layers + +Layers are organized into logical groups for efficient monitoring: + +**Geopolitical** +| Layer | Description | +|-------|-------------| +| **Conflicts** | Active conflict zones with involved parties and status | +| **Hotspots** | Intelligence hotspots with activity levels based on news correlation | +| **Sanctions** | Countries under economic sanctions regimes | +| **Protests** | Live social unrest events from ACLED and GDELT | + +**Military & Strategic** +| Layer | Description | +|-------|-------------| +| **Military Bases** | 220+ global military installations from 9 operators | +| **Nuclear Facilities** | Power plants, weapons labs, enrichment sites | +| **Gamma Irradiators** | IAEA-tracked Category 1-3 radiation sources | +| **APT Groups** | State-sponsored cyber threat actors with geographic attribution | +| **Spaceports** | 12 major launch facilities (NASA, SpaceX, Roscosmos, CNSA, ESA, ISRO, JAXA) | +| **Critical Minerals** | Strategic mineral deposits (lithium, cobalt, rare earths) with operator info | + +**Infrastructure** +| Layer | Description | +|-------|-------------| +| **Undersea Cables** | 55 major submarine cable routes worldwide | +| **Pipelines** | 88 operating oil & gas pipelines across all continents | +| **Internet Outages** | Network disruptions via Cloudflare Radar | +| **AI Datacenters** | 111 major AI compute clusters (≥10,000 GPUs) | + +**Transport** +| Layer | Description | +|-------|-------------| +| **Ships (AIS)** | Live vessel tracking via AIS with chokepoint monitoring and 61 strategic ports* | +| **Delays** | FAA airport delay status and ground stops | + +*\*AIS data via [AISStream.io](https://aisstream.io) uses terrestrial receivers with stronger coverage in European/Atlantic waters. Middle East, Asia, and open ocean coverage is limited. Satellite AIS providers (Spire, Kpler) offer global coverage but require commercial licenses.* + +**Natural Events** +| Layer | Description | +|-------|-------------| +| **Natural** | USGS earthquakes (M4.5+) + NASA EONET events (storms, wildfires, volcanoes, floods) | +| **Weather** | NWS severe weather warnings | + +**Economic & Labels** +| Layer | Description | +|-------|-------------| +| **Economic** | Tabbed economic panel with FRED indicators, EIA oil analytics, and USASpending.gov government contracts | +| **Countries** | Country boundary labels | +| **Waterways** | Strategic waterways and chokepoints | + +### Intelligence Panels + +Beyond raw data feeds, the dashboard provides synthesized intelligence panels: + +| Panel | Purpose | +|-------|---------| +| **AI Strategic Posture** | Theater-level military aggregation with strike capability analysis | +| **Strategic Risk Overview** | Composite risk score combining all intelligence modules | +| **Country Instability Index** | Real-time stability scores for 20 monitored countries | +| **Infrastructure Cascade** | Dependency analysis for cables, pipelines, and chokepoints | +| **Live Intelligence** | GDELT-powered topic feeds (Military, Cyber, Nuclear, Sanctions) | +| **Intel Feed** | Curated defense and security news sources | + +These panels transform raw signals into actionable intelligence by applying scoring algorithms, trend detection, and cross-source correlation. + +### 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) +- **Latin America** - BBC Latin America, Guardian Americas, Google News aggregation +- **Asia-Pacific** - BBC Asia, South China Morning Post, Google News aggregation +- **Energy & Resources** - Google News aggregation (oil/gas, nuclear, mining, Reuters Energy) +- **Technology** - Hacker News, Ars Technica, The Verge, MIT Tech Review +- **AI / ML** - ArXiv, VentureBeat AI, The Verge AI, MIT Tech Review +- **Finance** - CNBC, MarketWatch, Financial Times, Yahoo Finance +- **Government** - White House, State Dept, Pentagon, Treasury, Fed, SEC, UN News, CISA +- **Intel Feed** - Defense One, Breaking Defense, Bellingcat, Krebs Security, Janes +- **Think Tanks** - Foreign Policy, Atlantic Council, Foreign Affairs, CSIS, RAND, Brookings, Carnegie +- **Crisis Watch** - International Crisis Group, IAEA, WHO, UNHCR +- **Regional Sources** - Xinhua, TASS, Kyiv Independent, Moscow Times +- **Layoffs Tracker** - Tech industry job cuts + +### Source Filtering + +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 +- **Counter Display**: Shows "45/77 enabled" to indicate current selection +- **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 +- Changes take effect on the next refresh cycle + +### Regional Intelligence Panels + +Dedicated panels provide focused coverage for strategically significant regions: + +| Panel | Coverage | Key Topics | +|-------|----------|------------| +| **Middle East** | MENA region | Israel-Gaza, Iran, Gulf states, Red Sea | +| **Africa** | Sub-Saharan Africa | Sahel instability, coups, insurgencies, resources | +| **Latin America** | Central & South America | Venezuela, drug trafficking, regional politics | +| **Asia-Pacific** | East & Southeast Asia | China-Taiwan, Korean peninsula, ASEAN | +| **Energy & Resources** | Global | Oil markets, nuclear, mining, energy security | + +Each panel aggregates region-specific sources to provide concentrated situational awareness for that theater. This enables focused monitoring when global events warrant attention to a particular region. + +### Live News Streams + +Embedded YouTube live streams from major news networks with channel switching: + +| Channel | Coverage | +|---------|----------| +| **Bloomberg** | Business & financial news | +| **Sky News** | UK & international news | +| **Euronews** | European perspective | +| **DW News** | German international broadcaster | +| **France 24** | French global news | +| **Al Arabiya** | Middle East news (Arabic perspective) | +| **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) +- **Double-Width Panel** - Larger video player for better viewing + +**Performance Optimizations:** + +The live stream panel uses the **YouTube IFrame Player API** rather than raw iframe embedding. This provides several advantages: + +| Feature | Benefit | +|---------|---------| +| **Persistent player** | No iframe reload on mute/play/channel change | +| **API control** | Direct `playVideo()`, `pauseVideo()`, `mute()` calls | +| **Reduced bandwidth** | Same stream continues across state changes | +| **Faster switching** | Channel changes via `loadVideoById()` | + +**Idle Detection:** + +To conserve resources, the panel implements automatic idle pausing: + +| Trigger | Action | +|---------|--------| +| **Tab hidden** | Stream pauses (via Visibility API) | +| **5 min idle** | Stream pauses (no mouse/keyboard activity) | +| **User returns** | Stream resumes automatically | +| **Manual pause** | User intent tracked separately | + +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 +- **Sector Heatmap** - Visual sector performance (11 SPDR sectors) +- **Economic Indicators** - Fed data via FRED (assets, rates, yields) +- **Oil Analytics** - EIA data: WTI/Brent prices, US production, US inventory with weekly changes +- **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) +- Nuclear facilities and irradiators +- Markets and predictions + +### Data Export + +- CSV and JSON export of current dashboard state +- Historical playback from snapshots + +--- + +## Signal Intelligence + +The dashboard continuously analyzes data streams to detect significant patterns and anomalies. Signals appear in the header badge (⚡) with confidence scores. + +### Intelligence Findings Badge + +The header displays an **Intelligence Findings** badge that consolidates two types of alerts: + +| Alert Type | Source | Examples | +|------------|--------|----------| +| **Correlation Signals** | Cross-source pattern detection | Velocity spikes, market divergence, prediction leading | +| **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 +- Confidence score and priority level +- Timestamp and trending direction + +This provides a unified command center for all intelligence findings, whether generated by correlation analysis or module-specific threshold detection. + +### Signal Types + +The system detects 12 distinct signal types across news, markets, military, and infrastructure domains: + +**News & Source Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **◉ Convergence** | 3+ source types report same story within 30 minutes | Multiple independent channels confirming the same event—higher likelihood of significance | +| **△ Triangulation** | Wire + Government + Intel sources align | The "authority triangle"—when official channels, wire services, and defense specialists all report the same thing | +| **🔥 Velocity Spike** | Topic mention rate doubles with 6+ sources/hour | A story is accelerating rapidly across the news ecosystem | + +**Market Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **🔮 Prediction Leading** | Prediction market moves 5%+ with low news coverage | Markets pricing in information not yet reflected in news | +| **📰 News Leads Markets** | High news velocity without corresponding market move | Breaking news not yet priced in—potential mispricing | +| **✓ Market Move Explained** | Market moves 2%+ with correlated news coverage | Price action has identifiable news catalyst—entity correlation found related stories | +| **📊 Silent Divergence** | Market moves 2%+ with no correlated news after entity search | Unexplained price action after exhaustive search—possible insider knowledge or algorithm-driven | +| **📈 Sector Cascade** | Multiple related sectors moving in same direction | Market reaction cascading through correlated industries | + +**Infrastructure & Energy Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **🛢 Flow Drop** | Pipeline flow disruption keywords detected | Physical commodity supply constraint—may precede price spike | +| **🔁 Flow-Price Divergence** | Pipeline disruption news without corresponding oil price move | Energy supply disruption not yet priced in—potential information edge | + +**Geopolitical & Military Signals** + +| Signal | Trigger | What It Means | +|--------|---------|---------------| +| **🌍 Geographic Convergence** | 3+ event types in same 1°×1° grid cell | Multiple independent data streams converging on same location—heightened regional activity | +| **🔺 Hotspot Escalation** | Multi-component score exceeds threshold with rising trend | Hotspot showing corroborated escalation across news, CII, convergence, and military data | +| **✈ Military Surge** | Transport/fighter activity 2× baseline in theater | Unusual military airlift concentration—potential deployment or crisis response | + +### How It Works + +The correlation engine maintains rolling snapshots of: + +- News topic frequency (by keyword extraction) +- Market price changes +- Prediction market probabilities + +Each refresh cycle compares current state to previous snapshot, applying thresholds and deduplication to avoid alert fatigue. Signals include confidence scores (60-95%) based on the strength of the pattern. + +### Entity-Aware Correlation + +The signal engine uses a **knowledge base of 100+ entities** to intelligently correlate market movements with news coverage. Rather than simple keyword matching, the system understands that "AVGO" (the ticker) relates to "Broadcom" (the company), "AI chips" (the sector), and entities like "Nvidia" (a competitor). + +#### Entity Knowledge Base + +Each entity in the registry contains: + +| Field | Purpose | Example | +|-------|---------|---------| +| **ID** | Canonical identifier | `broadcom` | +| **Name** | Display name | `Broadcom Inc.` | +| **Type** | Category | `company`, `commodity`, `crypto`, `country`, `person` | +| **Aliases** | Alternative names | `AVGO`, `Broadcom`, `Broadcom Inc` | +| **Keywords** | Related topics | `AI chips`, `semiconductors`, `VMware` | +| **Sector** | Industry classification | `semiconductors` | +| **Related** | Linked entities | `nvidia`, `intel`, `amd` | + +#### Entity Types + +| Type | Count | Examples | +|------|-------|----------| +| **Companies** | 50+ | Nvidia, Apple, Tesla, Broadcom, Boeing, Lockheed Martin, TSMC, Rheinmetall | +| **Indices** | 5+ | S&P 500, Dow Jones, NASDAQ | +| **Sectors** | 10+ | Technology (XLK), Finance (XLF), Energy (XLE), Healthcare (XLV), Semiconductors (SMH) | +| **Commodities** | 10+ | Oil (WTI), Gold, Natural Gas, Copper, Silver, VIX | +| **Crypto** | 3 | Bitcoin, Ethereum, Solana | +| **Countries** | 15+ | China, Russia, Iran, Israel, Ukraine, Taiwan, Saudi Arabia, UAE, Qatar, Turkey, Egypt | + +#### How Entity Matching Works + +When a market moves significantly (≥2%), the system: + +1. **Looks up the ticker** in the entity registry (e.g., `AVGO` → `broadcom`) +2. **Gathers all identifiers**: aliases, keywords, sector peers, related entities +3. **Scans all news clusters** for matches against any identifier +4. **Scores confidence** based on match type: + - Alias match (exact name): 95% + - Keyword match (topic): 70% + - Related entity match: 60% + +If correlated news is found → **"Market Move Explained"** signal with the news headline. +If no correlation after exhaustive search → **"Silent Divergence"** signal. + +#### Example: Broadcom +2.5% + +``` +1. Ticker AVGO detected with +2.5% move +2. Entity lookup: broadcom +3. Search terms: ["Broadcom", "AVGO", "AI chips", "semiconductors", "VMware", "nvidia", "intel", "amd"] +4. News scan finds: "Broadcom AI Revenue Beats Estimates" +5. Result: "✓ Market Move Explained: Broadcom AI Revenue Beats Estimates" +``` + +Without this system, the same move would generate a generic "Silent Divergence: AVGO +2.5%" signal. + +#### Sector Coverage + +The entity registry spans strategically significant sectors: + +| Sector | Examples | Keywords Tracked | +|--------|----------|------------------| +| **Technology** | Apple, Microsoft, Nvidia, Google, Meta, TSMC | AI, cloud, chips, datacenter, streaming | +| **Defense & Aerospace** | Lockheed Martin, Raytheon, Northrop Grumman, Boeing, Rheinmetall, Airbus | F-35, missiles, drones, tanks, defense contracts | +| **Semiconductors** | ASML, Samsung, AMD, Intel, Broadcom | Lithography, EUV, foundry, fab, wafer | +| **Critical Minerals** | Albemarle, SQM, MP Materials, Freeport-McMoRan | Lithium, rare earth, cobalt, copper | +| **Finance** | JPMorgan, Berkshire Hathaway, Visa, Mastercard | Banking, credit, investment, interest rates | +| **Healthcare** | Eli Lilly, Novo Nordisk, UnitedHealth, J&J | Pharma, drugs, GLP-1, obesity, diabetes | +| **Energy** | Exxon, Chevron, ConocoPhillips | Oil, gas, drilling, refinery, LNG | +| **Consumer** | Tesla, Walmart, Costco, Home Depot | EV, retail, grocery, housing | + +This broad coverage enables correlation detection across diverse geopolitical and market events. + +### Entity Registry Architecture + +The entity registry is a knowledge base of 600+ entities with rich metadata for intelligent correlation: + +```typescript +{ + id: 'NVDA', // Unique identifier + name: 'Nvidia', // Display name + type: 'company', // company | country | index | commodity | currency + sector: 'semiconductors', + searchTerms: ['Nvidia', 'NVDA', 'Jensen Huang', 'H100', 'CUDA'], + aliases: ['nvidia', 'nvda'], + competitors: ['AMD', 'INTC'], + related: ['AVGO', 'TSM', 'ASML'], // Related entities + country: 'US', // Headquarters/origin +} +``` + +**Entity Types**: + +| Type | Count | Use Case | +|------|-------|----------| +| `company` | 100+ | Market-news correlation, sector analysis | +| `country` | 200+ | Focal point detection, CII scoring | +| `index` | 20+ | Market overview, regional tracking | +| `commodity` | 15+ | Energy and mineral correlation | +| `currency` | 10+ | FX market tracking | + +**Lookup Indexes**: + +The registry provides multiple lookup paths for fast entity resolution: + +| Index | Query Example | Use Case | +|-------|---------------|----------| +| `byId` | `'NVDA'` → Nvidia entity | Direct lookup from ticker | +| `byAlias` | `'nvidia'` → Nvidia entity | Case-insensitive name match | +| `byKeyword` | `'AI chips'` → [Nvidia, AMD, Intel] | News keyword extraction | +| `bySector` | `'semiconductors'` → all chip companies | Sector cascade analysis | +| `byCountry` | `'US'` → all US entities | Country-level aggregation | + +### Signal Deduplication + +To prevent alert fatigue, signals use **type-specific TTL (time-to-live)** values for deduplication: + +| Signal Type | TTL | Rationale | +|-------------|-----|-----------| +| **Silent Divergence** | 6 hours | Market moves persist; don't re-alert on same stock | +| **Flow-Price Divergence** | 6 hours | Energy events unfold slowly | +| **Explained Market Move** | 6 hours | Same correlation shouldn't repeat | +| **Prediction Leading** | 2 hours | Prediction markets update more frequently | +| **Other signals** | 30 minutes | Default for fast-moving events | + +Market signals use **symbol-only keys** (e.g., `silent_divergence:AVGO`) rather than including the price change. This means a stock moving +2.5% then +3.0% won't trigger duplicate alerts—the first alert covers the story. + +--- + +## Source Intelligence + +Not all sources are equal. The system implements a dual classification to prioritize authoritative information. + +### Source Tiers (Authority Ranking) + +| Tier | Sources | Characteristics | +|------|---------|-----------------| +| **Tier 1** | Reuters, AP, AFP, Bloomberg, White House, Pentagon | Wire services and official government—fastest, most reliable | +| **Tier 2** | BBC, Guardian, NPR, Al Jazeera, CNBC, Financial Times | Major outlets—high editorial standards, some latency | +| **Tier 3** | Defense One, Bellingcat, Foreign Policy, MIT Tech Review | Domain specialists—deep expertise, narrower scope | +| **Tier 4** | Hacker News, The Verge, VentureBeat, aggregators | Useful signal but requires corroboration | + +When multiple sources report the same story, the **lowest tier** (most authoritative) source is displayed as the primary, with others listed as corroborating. + +### Source Types (Categorical) + +Sources are also categorized by function for triangulation detection: + +- **Wire** - News agencies (Reuters, AP, AFP, Bloomberg) +- **Gov** - Official government (White House, Pentagon, State Dept, Fed, SEC) +- **Intel** - Defense/security specialists (Defense One, Bellingcat, Krebs) +- **Mainstream** - Major news outlets (BBC, Guardian, NPR, Al Jazeera) +- **Market** - Financial press (CNBC, MarketWatch, Financial Times) +- **Tech** - Technology coverage (Hacker News, Ars Technica, MIT Tech Review) + +### Propaganda Risk Indicators + +The dashboard visually flags sources with known state affiliations or propaganda risk, enabling users to appropriately weight information from these outlets. + +**Risk Levels** + +| Level | Visual | Meaning | +|-------|--------|---------| +| **High** | ⚠ State Media (red) | Direct state control or ownership | +| **Medium** | ! Caution (orange) | Significant state influence or funding | +| **Low** | (none) | Independent editorial control | + +**Flagged Sources** + +| Source | Risk Level | State Affiliation | Notes | +|--------|------------|-------------------|-------| +| **Xinhua** | High | China (CCP) | Official news agency of PRC | +| **TASS** | High | Russia | State-owned news agency | +| **RT** | High | Russia | Registered foreign agent in US | +| **CGTN** | High | China (CCP) | China Global Television Network | +| **PressTV** | High | Iran | IRIB subsidiary | +| **Al Jazeera** | Medium | Qatar | Qatari government funded | +| **TRT World** | Medium | Turkey | Turkish state broadcaster | + +**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 + +**Why Include State Media?** + +State-controlled outlets are included rather than filtered because: + +1. **Signal Value**: What state media reports (and omits) reveals government priorities +2. **Rapid Response**: State media often breaks domestic news faster than international outlets +3. **Narrative Analysis**: Understanding how events are framed by different governments +4. **Completeness**: Excluding them creates blind spots in coverage + +The badges ensure users can **contextualize** state media reports rather than unknowingly treating them as independent journalism. + +--- + +## Entity Extraction System + +The dashboard extracts **named entities** (companies, countries, leaders, organizations) from news headlines to enable news-to-market correlation and entity-based filtering. + +### How It Works + +Headlines are scanned against a curated entity index containing: + +| Entity Type | Examples | Purpose | +|-------------|----------|---------| +| **Companies** | Apple, Tesla, NVIDIA, Boeing | Market symbol correlation | +| **Countries** | Russia, China, Iran, Ukraine | Geopolitical attribution | +| **Leaders** | Putin, Xi Jinping, Khamenei | Political event tracking | +| **Organizations** | NATO, OPEC, Fed, SEC | Institutional news filtering | +| **Commodities** | Oil, Gold, Bitcoin | Commodity news correlation | + +### Entity Matching + +Each entity has multiple match patterns for comprehensive detection: + +``` +Entity: NVIDIA (NVDA) + Aliases: nvidia, nvda, jensen huang + Keywords: gpu, h100, a100, cuda, ai chip + Match Types: + - Name match: "NVIDIA announces..." → 95% confidence + - Alias match: "Jensen Huang says..." → 90% confidence + - Keyword match: "H100 shortage..." → 70% confidence +``` + +### Confidence Scoring + +Entity extraction produces confidence scores based on match quality: + +| Match Type | Confidence | Example | +|------------|------------|---------| +| **Direct name** | 95% | "Apple reports earnings" | +| **Alias** | 90% | "Tim Cook announces..." | +| **Keyword** | 70% | "iPhone sales decline" | +| **Related cluster** | 63% | Secondary headline mention (90% × 0.7) | + +### Market Correlation + +When a market symbol moves significantly, the system searches news clusters for related entities: + +1. **Symbol lookup** - Find entity by market symbol (e.g., `AAPL` → Apple) +2. **News search** - Find clusters mentioning the entity or related entities +3. **Confidence ranking** - Sort by extraction confidence +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" + +--- + +## Signal Context ("Why It Matters") + +Every signal includes contextual information explaining its analytical significance: + +### Context Fields + +| Field | Purpose | Example | +|-------|---------|---------| +| **Why It Matters** | Analytical significance | "Markets pricing in information before news" | +| **Actionable Insight** | What to do next | "Monitor for breaking news in 1-6 hours" | +| **Confidence Note** | Signal reliability caveats | "Higher confidence if multiple markets align" | + +### Signal-Specific Context + +| Signal | Why It Matters | +|--------|---------------| +| **Prediction Leading** | Prediction markets often price in information before it becomes news—traders may have early access to developments | +| **Silent Divergence** | Market moving without identifiable catalyst—possible insider knowledge, algorithmic trading, or unreported development | +| **Velocity Spike** | Story accelerating across multiple sources—indicates growing significance and potential for market/policy impact | +| **Triangulation** | The "authority triangle" (wire + government + intel) aligned—gold standard for breaking news confirmation | +| **Flow-Price Divergence** | Supply disruption not yet reflected in prices—potential information edge or markets have better information | +| **Hotspot Escalation** | Geopolitical hotspot showing escalation across news, instability, convergence, and military presence | + +This contextual layer transforms raw alerts into **actionable intelligence** by explaining the analytical reasoning behind each signal. + +--- + +## Algorithms & Design + +### News Clustering + +Related articles are grouped using **Jaccard similarity** on tokenized headlines: + +``` +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 +- Result cached per headline for performance + +**Inverted Index Optimization**: +Rather than O(n²) pairwise comparison, the algorithm uses an inverted index: + +1. Build token → article indices map +2. For each article, find candidate matches via shared tokens +3. Only compute Jaccard for candidates with token overlap +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 +- Clusters maintain full item list for multi-source attribution + +### Velocity Analysis + +Each news cluster tracks publication velocity: + +- **Sources per hour** = article count / time span +- **Trend** = rising/stable/falling based on first-half vs second-half publication rate +- **Levels**: Normal (<3/hr), Elevated (3-6/hr), Spike (>6/hr) + +### Sentiment Detection + +Headlines are scored against curated word lists: + +**Negative indicators**: war, attack, killed, crisis, crash, collapse, threat, sanctions, invasion, missile, terror, assassination, recession, layoffs... + +**Positive indicators**: peace, deal, agreement, breakthrough, recovery, growth, ceasefire, treaty, alliance, victory... + +Score determines sentiment classification: negative (<-1), neutral (-1 to +1), positive (>+1) + +### Entity Extraction + +News headlines are scanned against the entity knowledge base using **word-boundary regex matching**: + +``` +regex = /\b{escaped_alias}\b/gi +``` + +**Index Structure**: +The entity index pre-builds five lookup maps for O(1) access: + +| Map | Key | Value | Purpose | +|-----|-----|-------|---------| +| `byId` | Entity ID | Full entity record | Direct lookup | +| `byAlias` | Lowercase alias | Entity ID | Name matching | +| `byKeyword` | Lowercase keyword | Set of entity IDs | Topic matching | +| `bySector` | Sector name | Set of entity IDs | Sector queries | +| `byType` | Entity type | Set of entity IDs | Type filtering | + +**Matching Algorithm**: + +1. **Alias matching** (highest confidence): + - Iterate all aliases (minimum 3 characters to avoid false positives) + - Word-boundary regex prevents partial matches ("AI" won't match "RAID") + - First alias match for each entity stops further searching (deduplication) + +2. **Keyword matching** (medium confidence): + - Simple substring check (faster than regex) + - Multiple entities may match same keyword + - Lower confidence (70%) than alias matches (95%) + +3. **Related entity expansion**: + - If entity has `related` field, those entities are also checked + - 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 +- Full news scan: O(aliases × clusters) ≈ 300 × 50 = 15,000 comparisons + +### Baseline Deviation (Z-Score) + +The system maintains rolling baselines for news volume per topic: + +- **7-day average** and **30-day average** stored in IndexedDB +- Standard deviation calculated from historical counts +- **Z-score** = (current - mean) / stddev + +Deviation levels: + +- **Spike**: Z > 2.5 (statistically rare increase) +- **Elevated**: Z > 1.5 +- **Normal**: -2 < Z < 1.5 +- **Quiet**: Z < -2 (unusually low activity) + +This enables detection of anomalous activity even when absolute numbers seem normal. + +--- + +## Dynamic Hotspot Activity + +Hotspots on the map are **not static threat levels**. Activity is calculated in real-time based on news correlation. + +Each hotspot defines keywords: +```typescript +{ + id: 'dc', + name: 'DC', + keywords: ['pentagon', 'white house', 'congress', 'cia', 'nsa', ...], + agencies: ['Pentagon', 'CIA', 'NSA', 'State Dept'], +} +``` + +The system counts matching news articles in the current feed, applies velocity analysis, and assigns activity levels: + +| Level | Criteria | Visual | +|-------|----------|--------| +| **Low** | <3 matches, normal velocity | Gray marker | +| **Elevated** | 3-6 matches OR elevated velocity | Yellow pulse | +| **High** | >6 matches OR spike velocity | Red pulse | + +This creates a dynamic "heat map" of global attention based on live news flow. + +### Hotspot Escalation Signals + +Beyond visual activity levels, the system generates **escalation signals** when hotspots show significant changes across multiple dimensions. This multi-component approach reduces false positives by requiring corroboration from independent data streams. + +**Escalation Components** + +Each hotspot's escalation score blends four weighted components: + +| Component | Weight | Data Source | What It Measures | +|-----------|--------|-------------|------------------| +| **News Activity** | 35% | RSS feeds | Matching news count, breaking flags, velocity | +| **CII Contribution** | 25% | Country Instability Index | Instability score of associated country | +| **Geographic Convergence** | 25% | Multi-source events | Event type diversity in geographic cell | +| **Military Activity** | 15% | OpenSky/AIS | Flights and vessels within 200km | + +**Score Calculation** + +``` +static_baseline = hotspot.baselineRisk // 1-5 per hotspot +dynamic_score = ( + news_component × 0.35 + + cii_component × 0.25 + + geo_component × 0.25 + + military_component × 0.15 +) +proximity_boost = hotspot_proximity_multiplier // 1.0-2.0 + +final_score = (static_baseline × 0.30 + dynamic_score × 0.70) × proximity_boost +``` + +**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 +- **Stable**: Slope within ±0.1 + +**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) + +**Signal Context** + +| Field | Content | +|-------|---------| +| **Why It Matters** | "Geopolitical hotspot showing significant escalation based on news activity, country instability, geographic convergence, and military presence" | +| **Actionable Insight** | "Increase monitoring priority; assess downstream impacts on infrastructure, markets, and regional stability" | +| **Confidence Note** | "Weighted by multiple data sources—news (35%), CII (25%), geo-convergence (25%), military (15%)" | + +This multi-signal approach means a hotspot escalation signal represents **corroborated evidence** across independent data streams—not just a spike in news mentions. + +--- + +## Regional Focus Navigation + +The FOCUS selector in the header provides instant navigation to strategic regions. Each preset is calibrated to center on the region's geographic area with an appropriate zoom level. + +### Available Regions + +| Region | Coverage | Primary Use Cases | +|--------|----------|-------------------| +| **Global** | Full world view | Overview, cross-regional comparison | +| **Americas** | North America focus | US monitoring, NORAD activity | +| **Europe** | EU + UK + Scandinavia + Western Russia | NATO activity, energy infrastructure | +| **MENA** | Middle East + North Africa | Conflict zones, oil infrastructure | +| **Asia** | East Asia + Southeast Asia | China-Taiwan, Korean peninsula | +| **Latin America** | Central + South America | Regional instability, drug trafficking | +| **Africa** | Sub-Saharan Africa | Conflict zones, resource extraction | +| **Oceania** | Australia + Pacific | Indo-Pacific activity | + +### Quick Navigation + +The FOCUS dropdown enables rapid context switching: + +1. **Breaking news** - Jump to the affected region +2. **Regional briefing** - Cycle through regions for situational awareness +3. **Crisis monitoring** - Lock onto a specific theater + +Regional views are encoded in shareable URLs, enabling direct links to specific geographic contexts. + +--- + +## Map Pinning + +By default, the map scrolls with the page, allowing you to scroll down to view panels below. The **pin button** (📌) in the map header toggles sticky behavior: + +| State | Behavior | +|-------|----------| +| **Unpinned** (default) | Map scrolls with page; scroll down to see panels | +| **Pinned** | Map stays fixed at top; panels scroll beneath | + +### When to Pin + +- **Active monitoring** - Keep the map visible while reading news panels +- **Cross-referencing** - Compare map markers with panel data +- **Presentation** - Show the map while discussing panel content + +### When to Unpin + +- **Panel focus** - Read through panels without map taking screen space +- **Mobile** - Pin is disabled on mobile for better space utilization +- **Research** - Focus on data panels without geographic distraction + +Pin state persists across sessions via localStorage. + +--- + +## Country Instability Index (CII) + +The dashboard maintains a **real-time instability score** for 20 strategically significant countries. Rather than relying on static risk ratings, the CII dynamically reflects current conditions based on multiple input streams. + +### Monitored Countries (Tier 1) + +| Region | Countries | +|--------|-----------| +| **Americas** | United States, Venezuela | +| **Europe** | Germany, France, United Kingdom, Poland | +| **Eastern Europe** | Russia, Ukraine | +| **Middle East** | Iran, Israel, Saudi Arabia, Turkey, Syria, Yemen | +| **Asia-Pacific** | China, Taiwan, North Korea, India, Pakistan, Myanmar | + +### Three Component Scores + +Each country's CII is computed from three weighted components: + +| Component | Weight | Data Sources | What It Measures | +|-----------|--------|--------------|------------------| +| **Unrest** | 40% | ACLED protests, GDELT events | Civil unrest intensity, fatalities, event severity | +| **Security** | 30% | Military flights, naval vessels | Unusual military activity patterns | +| **Information** | 30% | News velocity, alert clusters | Media attention intensity and acceleration | + +### Scoring Algorithm + +``` +Unrest Score: + base = min(50, protest_count × 8) + fatality_boost = min(30, total_fatalities × 5) + severity_boost = min(20, high_severity_count × 10) + unrest = min(100, base + fatality_boost + severity_boost) + +Security Score: + flight_score = min(50, military_flights × 3) + vessel_score = min(30, naval_vessels × 5) + security = min(100, flight_score + vessel_score) + +Information Score: + base = min(40, news_count × 5) + velocity_boost = min(40, avg_velocity × 10) + alert_boost = 20 if any_alert else 0 + information = min(100, base + velocity_boost + alert_boost) + +Final CII = round(unrest × 0.4 + security × 0.3 + information × 0.3) +``` + +### Scoring Bias Prevention + +Raw news volume creates a natural bias—English-language media generates far more coverage of the US, UK, and Western Europe than conflict zones. Without correction, stable democracies would consistently score higher than actual crisis regions. + +**Log Scaling for High-Volume Countries** + +Countries with high media coverage receive logarithmic dampening on their unrest and information scores: + +``` +if (newsVolume > threshold): + dampingFactor = 1 / (1 + log10(newsVolume / threshold)) + score = rawScore × dampingFactor +``` + +This ensures the US receiving 50 news mentions about routine political activity doesn't outscore Ukraine with 10 mentions about active combat. + +**Conflict Zone Floor Scores** + +Active conflict zones have minimum score floors that prevent them from appearing stable during data gaps or low-coverage periods: + +| Country | Floor | Rationale | +|---------|-------|-----------| +| Ukraine | 55 | Active war with Russia | +| Syria | 50 | Ongoing civil war | +| Yemen | 50 | Ongoing civil war | +| Myanmar | 45 | Military coup, civil conflict | +| Israel | 45 | Active Gaza conflict | + +The floor applies *after* the standard calculation—if the computed score exceeds the floor, the computed score is used. This prevents false "all clear" signals while preserving sensitivity to actual escalations. + +### Instability Levels + +| Level | Score Range | Visual | Meaning | +|-------|-------------|--------|---------| +| **Critical** | 81-100 | Red | Active crisis or major escalation | +| **High** | 66-80 | Orange | Significant instability requiring close monitoring | +| **Elevated** | 51-65 | Yellow | Above-normal activity patterns | +| **Normal** | 31-50 | Gray | Baseline geopolitical activity | +| **Low** | 0-30 | Green | Unusually quiet period | + +### 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) + +### Contextual Score Boosts + +Beyond the base component scores, several contextual factors can boost a country's CII score (up to a combined maximum of 23 additional points): + +| Boost Type | Max Points | Condition | Purpose | +|------------|------------|-----------|---------| +| **Hotspot Activity** | 10 | Events near defined hotspots | Captures localized escalation | +| **News Urgency** | 5 | Information component ≥50 | High media attention indicator | +| **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 + +These boosts are designed to elevate scores only when corroborating evidence exists—a country must have both high base scores AND contextual signals to reach extreme levels. + +### Server-Side Pre-Computation + +To eliminate the "cold start" problem where new users would see blank data during the Learning Mode warmup, CII scores are **pre-computed server-side** via the `/api/risk-scores` endpoint. See the [Server-Side Risk Score API](#server-side-risk-score-api) section for details. + +### Learning Mode (15-Minute Warmup) + +On dashboard startup, the CII system enters **Learning Mode**—a 15-minute calibration period where scores are calculated but alerts are suppressed. This prevents the flood of false-positive alerts that would otherwise occur as the system establishes baseline values. + +**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 + +**Visual Indicators** + +During Learning Mode, the dashboard provides clear visual feedback: + +| Location | Indicator | +|----------|-----------| +| **CII Panel** | Yellow banner with progress bar and countdown timer | +| **Strategic Risk Overview** | "Learning Mode - Xm until reliable" status | +| **Score Display** | Scores shown at 60% opacity (dimmed) | + +**Behavior** + +``` +Minutes 0-15: Learning Mode Active + - CII scores calculated and displayed (dimmed) + - Trend detection active (stores baseline) + - All CII-related alerts suppressed + - Progress bar fills as time elapses + +After 15 minutes: Learning Complete + - Full opacity scores + - Alert generation enabled (threshold ≥10 point change) + - "All data sources active" status shown +``` + +This ensures users understand that early scores are provisional while preventing alert fatigue during the calibration period. + +### Keyword Attribution + +Countries are matched to data via keyword lists: + +- **Russia**: `russia`, `moscow`, `kremlin`, `putin` +- **China**: `china`, `beijing`, `xi jinping`, `prc` +- **Taiwan**: `taiwan`, `taipei` + +This enables attribution of news and events to specific countries even when formal country codes aren't present in the source data. + +--- + +## Geographic Convergence Detection + +One of the most valuable intelligence signals is when **multiple independent data streams converge on the same geographic area**. This often precedes significant events. + +### How It Works + +The system maintains a real-time grid of geographic cells (1° × 1° resolution). Each cell tracks four event types: + +| Event Type | Source | Detection Method | +|------------|--------|-----------------| +| **Protests** | ACLED/GDELT | Direct geolocation | +| **Military Flights** | OpenSky | ADS-B position | +| **Naval Vessels** | AIS stream | Ship position | +| **Earthquakes** | USGS | Epicenter location | + +When **3 or more different event types** occur within the same cell during a 24-hour window, a **convergence alert** is generated. + +### Convergence Scoring + +``` +type_score = event_types × 25 # Max 100 (4 types) +count_boost = min(25, total_events × 2) +convergence_score = min(100, type_score + count_boost) +``` + +### Alert Thresholds + +| Types Converging | Score Range | Alert Level | +|-----------------|-------------|-------------| +| **4 types** | 80-100 | Critical | +| **3 types** | 60-80 | High | +| **3 types** (low count) | 40-60 | Medium | + +### 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) +- Signal: Multiple activity streams converging on region + +### Why This Matters + +Individual data points are often noise. But when **protests break out, military assets reposition, and seismic monitors detect anomalies** in the same location simultaneously, it warrants attention—regardless of whether any single source is reporting a crisis. + +--- + +## Infrastructure Cascade Analysis + +Critical infrastructure is interdependent. A cable cut doesn't just affect connectivity—it creates cascading effects across dependent countries and systems. The cascade analysis system visualizes these dependencies. + +### Dependency Graph + +The system builds a graph of **279 infrastructure nodes** and **280 dependency edges**: + +| Node Type | Count | Examples | +|-----------|-------|----------| +| **Undersea Cables** | 18 | MAREA, FLAG Europe-Asia, SEA-ME-WE 6 | +| **Pipelines** | 88 | Nord Stream, Trans-Siberian, Keystone | +| **Ports** | 61 | Singapore, Rotterdam, Shenzhen | +| **Chokepoints** | 8 | Suez, Hormuz, Malacca | +| **Countries** | 105 | End nodes representing national impact | + +### Cascade Calculation + +When a user selects an infrastructure asset for analysis, a **breadth-first cascade** propagates through the graph: + +``` +1. Start at source node (e.g., "cable:marea") +2. For each dependent node: + impact = edge_strength × disruption_level × (1 - redundancy) +3. Categorize impact: + - Critical: impact > 0.8 + - High: impact > 0.5 + - Medium: impact > 0.2 + - Low: impact ≤ 0.2 +4. Recurse to depth 3 (prevent infinite loops) +``` + +### 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 + +### Example Analysis + +**MAREA Cable Disruption**: +``` +Source: MAREA (US ↔ Spain, 200 Tbps) +Countries Affected: 4 +- Spain: Medium (redundancy via other Atlantic cables) +- Portugal: Low (secondary landing) +- France: Low (alternative routes via UK) +- US: Low (high redundancy) +Alternative Routes: TAT-14 (35%), Hibernia (22%), AEConnect (18%) +``` + +**FLAG Europe-Asia Disruption**: +``` +Source: FLAG Europe-Asia (UK ↔ Japan) +Countries Affected: 7 +- India: Medium (major capacity share) +- UAE, Saudi Arabia: Medium (limited alternatives) +- UK, Japan: Low (high redundancy) +Alternative Routes: SEA-ME-WE 6 (11%), 2Africa (8%), Falcon (8%) +``` + +### Use Cases + +- **Pre-positioning**: Understand which countries are most vulnerable to specific infrastructure failures +- **Risk Assessment**: Evaluate supply chain exposure to chokepoint disruptions +- **Incident Response**: Quickly identify downstream effects of reported cable cuts or pipeline damage + +--- + +## Undersea Cable Activity Monitoring + +The dashboard monitors real-time cable operations and advisories from official maritime warning systems, providing early warning of potential connectivity disruptions. + +### Data Sources + +| Source | Coverage | Data Type | +|--------|----------|-----------| +| **NGA Warnings** | Global | NAVAREA maritime warnings | +| **Cable Operators** | Route-specific | Maintenance advisories | + +### How It Works + +The system parses NGA (National Geospatial-Intelligence Agency) maritime warnings for cable-related activity: + +1. **Keyword filtering**: Warnings containing "CABLE", "CABLESHIP", "SUBMARINE CABLE", "FIBER OPTIC" are extracted +2. **Coordinate parsing**: DMS and decimal coordinates are extracted from warning text +3. **Cable matching**: Coordinates are matched to nearest cable routes within 5° radius +4. **Severity classification**: Keywords like "FAULT", "BREAK", "DAMAGE" indicate faults; others indicate maintenance + +### Alert Types + +| Type | Trigger | Map Display | +|------|---------|-------------| +| **Cable Advisory** | Any cable-related NAVAREA warning | ⚠ Yellow marker at location | +| **Repair Ship** | Cableship name detected in warning | 🚢 Ship icon with status | + +### 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 +- **Associated cable**: Nearest cable route + +This enables monitoring of ongoing repair operations before official carrier announcements. + +### 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 +- Create economic cascading effects + +Early visibility into cable operations—even maintenance windows—provides advance warning for contingency planning. + +--- + +## Strategic Risk Overview + +The Strategic Risk Overview provides a **composite dashboard** that synthesizes all intelligence modules into a single risk assessment. + +### Composite Score (0-100) + +The strategic risk score combines three components: + +| Component | Weight | Calculation | +|-----------|--------|-------------| +| **Convergence** | 40% | `min(100, convergence_zones × 20)` | +| **CII Deviation** | 35% | `min(100, avg_deviation × 2)` | +| **Infrastructure** | 25% | `min(100, incidents × 25)` | + +### Risk Levels + +| Score | Level | Trend Icon | Meaning | +|-------|-------|------------|---------| +| 70-100 | **Critical** | 📈 Escalating | Multiple converging crises | +| 50-69 | **Elevated** | ➡️ Stable | Heightened global tension | +| 30-49 | **Moderate** | ➡️ Stable | Normal fluctuation | +| 0-29 | **Low** | 📉 De-escalating | Unusually quiet period | + +### Unified Alert System + +Alerts from all modules are merged using **temporal and spatial deduplication**: + +- **Time window**: Alerts within 2 hours may be merged +- **Distance threshold**: Alerts within 200km may be merged +- **Same country**: Alerts affecting the same country may be merged + +When alerts merge, they become **composite alerts** that show the full picture: + +``` +Type: Composite Alert +Title: Convergence + CII + Infrastructure: Ukraine +Components: + - Geographic Convergence: 4 event types in Kyiv region + - CII Spike: Ukraine +15 points (Critical) + - Infrastructure: Black Sea cables at risk +Priority: Critical +``` + +### Alert Priority + +| Priority | Criteria | +|----------|----------| +| **Critical** | CII critical level, convergence score ≥80, cascade critical impact | +| **High** | CII high level, convergence score ≥60, cascade affecting ≥5 countries | +| **Medium** | CII change ≥10 points, convergence score ≥40 | +| **Low** | Minor changes and low-impact events | + +### 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 + +--- + +## Pentagon Pizza Index (PizzINT) + +The dashboard integrates real-time foot traffic data from strategic locations near government and military facilities. This "Pizza Index" concept—tracking late-night activity spikes at restaurants near the Pentagon, Langley, and other facilities—provides an unconventional indicator of crisis activity. + +### How It Works + +The system aggregates percentage-of-usual metrics from monitored locations: + +1. **Locations**: Fast food, pizza shops, and convenience stores near Pentagon, CIA, NSA, State Dept, and other facilities +2. **Aggregation**: Activity percentages are averaged, capped at 100% +3. **Spike Detection**: Locations exceeding their baseline are flagged + +### DEFCON-Style Alerting + +Aggregate activity maps to a 5-level readiness scale: + +| Level | Threshold | Label | Meaning | +|-------|-----------|-------|---------| +| **DEFCON 1** | ≥90% | COCKED PISTOL | Maximum readiness; crisis response active | +| **DEFCON 2** | ≥75% | FAST PACE | High activity; significant event underway | +| **DEFCON 3** | ≥50% | ROUND HOUSE | Elevated; above-normal operations | +| **DEFCON 4** | ≥25% | DOUBLE TAKE | Increased vigilance | +| **DEFCON 5** | <25% | FADE OUT | Normal peacetime operations | + +### GDELT Tension Pairs + +The indicator also displays geopolitical tension scores from GDELT (Global Database of Events, Language, and Tone): + +| Pair | Monitored Relationship | +|------|----------------------| +| USA ↔ Russia | Primary nuclear peer adversary | +| USA ↔ China | Economic and military competition | +| USA ↔ Iran | Middle East regional tensions | +| Israel ↔ Iran | Direct conflict potential | +| China ↔ Taiwan | Cross-strait relations | +| 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 + +This provides context for the activity levels—a spike at Pentagon locations during a rising China-Taiwan tension score carries different weight than during a quiet period. + +--- + +## Related Assets + +News clusters are automatically enriched with nearby critical infrastructure. When a story mentions a geographic region, the system identifies relevant assets within 600km, providing immediate operational context. + +### Asset Types + +| Type | Source | Examples | +|------|--------|----------| +| **Pipelines** | 88 global routes | Nord Stream, Keystone, Trans-Siberian | +| **Undersea Cables** | 55 major cables | TAT-14, SEA-ME-WE, Pacific Crossing | +| **AI Datacenters** | 111 clusters (≥10k GPUs) | Azure East US, GCP Council Bluffs | +| **Military Bases** | 220+ installations | Ramstein, Diego Garcia, Guam | +| **Nuclear Facilities** | 100+ sites | Power plants, weapons labs, enrichment | + +### Location Inference + +The system infers the geographic focus of news stories through: + +1. **Keyword matching**: Headlines are scanned against hotspot keyword lists (e.g., "Taiwan" → Taiwan Strait hotspot) +2. **Confidence scoring**: Multiple keyword matches increase location confidence +3. **Fallback to conflicts**: If no hotspot matches, active conflict zones are checked + +### Distance Calculation + +Assets are ranked by Haversine distance from the inferred location: + +``` +d = 2r × arcsin(√(sin²(Δφ/2) + cos(φ₁) × cos(φ₂) × sin²(Δλ/2))) +``` + +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) + +Clicking an asset zooms the map to its location and displays detailed information. + +--- + +## Custom Monitors + +Create personalized keyword alerts that scan all incoming news: + +1. Enter comma-separated keywords (e.g., "nvidia, gpu, chip shortage") +2. System assigns a unique color +3. Matching articles are highlighted in the Monitor panel +4. Matching articles in clusters inherit the monitor color + +Monitors persist across sessions via LocalStorage. + +--- + +## Activity Tracking + +The dashboard highlights newly-arrived items so you can quickly identify what changed since your last look. + +### Visual Indicators + +| Indicator | Duration | Purpose | +|-----------|----------|---------| +| **NEW tag** | 2 minutes | Badge on items that just appeared | +| **Glow highlight** | 30 seconds | Subtle animation drawing attention | +| **Panel badge** | Until viewed | Count of new items in collapsed panels | + +### Automatic "Seen" Detection + +The system uses IntersectionObserver to detect when panels become visible: + +- When a panel is >50% visible for >500ms, items are marked as "seen" +- Scrolling through a panel marks visible items progressively +- Switching panels resets the "new" state appropriately + +### Panel-Specific Tracking + +Each panel maintains independent activity state: + +- **News**: New clusters since last view +- **Markets**: Price changes exceeding thresholds +- **Predictions**: Probability shifts >5% +- **Natural Events**: New earthquakes and EONET events + +This enables focused monitoring—you can collapse panels you've reviewed and see at a glance which have new activity. + +--- + +## Snapshot System + +The dashboard captures periodic snapshots for historical analysis: + +- **Automatic capture** every refresh cycle +- **7-day retention** with automatic cleanup +- **Stored data**: news clusters, market prices, prediction values, hotspot levels +- **Playback**: Load historical snapshots to see past dashboard states + +Baselines (7-day and 30-day averages) are stored in IndexedDB for deviation analysis. + +--- + +## Maritime Intelligence + +The Ships layer provides real-time vessel tracking and maritime domain awareness through AIS (Automatic Identification System) data. + +### Chokepoint Monitoring + +The system monitors eight critical maritime chokepoints where disruptions could impact global trade: + +| Chokepoint | Strategic Importance | +|------------|---------------------| +| **Strait of Hormuz** | 20% of global oil transits; Iran control | +| **Suez Canal** | Europe-Asia shipping; single point of failure | +| **Strait of Malacca** | Primary Asia-Pacific oil route | +| **Bab el-Mandeb** | Red Sea access; Yemen/Houthi activity | +| **Panama Canal** | Americas east-west transit | +| **Taiwan Strait** | Semiconductor supply chain; PLA activity | +| **South China Sea** | Contested waters; island disputes | +| **Black Sea** | Ukraine grain exports; Russian naval activity | + +### 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 + +Density changes of ±30% trigger alerts, indicating potential congestion, diversions, or blockades. + +### 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 +- Equipment failure + +Vessels reappearing after gaps are flagged for the duration of the session. + +### WebSocket Architecture + +AIS data flows through a WebSocket relay for real-time updates without polling: + +``` +AISStream → WebSocket Relay → Browser + (ws://relay) +``` + +The connection automatically reconnects on disconnection with a 30-second backoff. When the Ships layer is disabled, the WebSocket disconnects to conserve resources. + +### Railway Relay Architecture + +Some APIs block requests from cloud providers (Vercel, AWS, Cloudflare Workers). A Railway relay server provides authenticated access: + +``` +Browser → Railway Relay → External APIs + (Node.js) (AIS, OpenSky, RSS) +``` + +**Relay Functions**: + +| Endpoint | Purpose | Authentication | +|----------|---------|----------------| +| `/` (WebSocket) | AIS vessel stream | AISStream API key | +| `/opensky` | Military aircraft | OAuth2 Bearer token | +| `/rss` | Blocked RSS feeds | None (user-agent spoofing) | +| `/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 +- Free tier sufficient for moderate traffic + +The relay is stateless—it simply authenticates and proxies requests. All caching and processing happens client-side or in Vercel Edge Functions. + +--- + +## Military Tracking + +The Military layer provides specialized tracking of military vessels and aircraft, identifying assets by their transponder characteristics and monitoring activity patterns. + +### Military Vessel Identification + +Vessels are identified as military through multiple methods: + +**MMSI Analysis**: Maritime Mobile Service Identity numbers encode the vessel's flag state. The system maintains a mapping of 150+ country codes to identify naval vessels: + +| MID Range | Country | Notes | +|-----------|---------|-------| +| 338-339 | USA | US Navy, Coast Guard | +| 273 | Russia | Russian Navy | +| 412-414 | China | PLAN vessels | +| 232-235 | UK | Royal Navy | +| 226-228 | France | Marine Nationale | + +**Known Vessel Database**: A curated database of 50+ named vessels enables positive identification when AIS transmits vessel names: + +| Category | Tracked Vessels | +|----------|-----------------| +| **US Carriers** | All 11 Nimitz/Ford-class (CVN-68 through CVN-78) | +| **UK Carriers** | HMS Queen Elizabeth (R08), HMS Prince of Wales (R09) | +| **Chinese Carriers** | Liaoning (16), Shandong (17), Fujian (18) | +| **Russian Carrier** | Admiral Kuznetsov | +| **Notable Destroyers** | USS Zumwalt (DDG-1000), HMS Defender (D36), HMS Duncan (D37) | +| **Research/Intel** | USNS Victorious (T-AGOS-19), USNS Impeccable (T-AGOS-23), Yuan Wang | + +**Vessel Classification Algorithm**: + +1. Check vessel name against known database (hull numbers and ship names) +2. Fall back to AIS ship type code if name match fails +3. Apply MMSI pattern matching for country/operator identification +4. For naval-prefix vessels (USS, HMS, HMCS, HMAS, INS, JS, ROKS, TCG), infer military status + +**Callsign Patterns**: Known military callsign prefixes (NAVY, GUARD, etc.) provide secondary identification. + +### Naval Chokepoint Monitoring + +The system monitors 12 critical maritime chokepoints with configurable detection radii: + +| Chokepoint | Strategic Significance | +|------------|----------------------| +| Strait of Hormuz | Persian Gulf access, oil transit | +| Suez Canal | Mediterranean-Red Sea link | +| Strait of Malacca | Pacific-Indian Ocean route | +| Taiwan Strait | Cross-strait tensions | +| Bosphorus | Black Sea access | +| GIUK Gap | North Atlantic submarine route | + +When military vessels enter these zones, proximity alerts are generated. + +### Naval Base Proximity + +Activity near 12 major naval installations is tracked: + +- **Norfolk** (USA) - Atlantic Fleet headquarters +- **Pearl Harbor** (USA) - Pacific Fleet base +- **Sevastopol** (Russia) - Black Sea Fleet +- **Qingdao** (China) - North Sea Fleet +- **Yokosuka** (Japan) - US 7th Fleet + +Vessels within 50km of these bases are flagged, enabling detection of unusual activity patterns. + +### Aircraft Tracking (OpenSky) + +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 + +### Vessel Position History + +The system maintains position trails for tracked vessels: + +- **30-point history** per MMSI +- **10-minute cleanup interval** for stale data +- **Trail visualization** on map for recent movement + +This enables detection of loitering, circling, or other anomalous behavior patterns. + +### Military Surge Detection + +The system continuously monitors military aircraft activity to detect **surge events**—significant increases above normal operational baselines that may indicate mobilization, exercises, or crisis response. + +**Theater Classification** + +Military activity is analyzed across five geographic theaters: + +| Theater | Coverage | Key Areas | +|---------|----------|-----------| +| **Middle East** | Persian Gulf, Levant, Arabian Peninsula | US CENTCOM activity, Iranian airspace | +| **Eastern Europe** | Ukraine, Baltics, Black Sea | NATO-Russia border activity | +| **Western Europe** | Central Europe, North Sea | NATO exercises, air policing | +| **Pacific** | East Asia, Southeast Asia | Taiwan Strait, Korean Peninsula | +| **Horn of Africa** | Red Sea, East Africa | Counter-piracy, Houthi activity | + +**Aircraft Classification** + +Aircraft are categorized by callsign pattern matching: + +| Type | Callsign Patterns | Significance | +|------|-------------------|--------------| +| **Transport** | RCH, REACH, MOOSE, HERKY, EVAC, DUSTOFF | Airlift operations, troop movement | +| **Fighter** | VIPER, EAGLE, RAPTOR, STRIKE | Combat air patrol, interception | +| **Reconnaissance** | SIGNT, COBRA, RIVET, JSTARS | Intelligence gathering | + +**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 + +**Surge Detection Algorithm** + +``` +surge_ratio = current_count / baseline +surge_triggered = ( + ratio ≥ 2.0 AND + transport ≥ 5 AND + fighters ≥ 4 +) +``` + +**Surge Signal Output** + +When a surge is detected, the system generates a `military_surge` signal: + +| Field | Content | +|-------|---------| +| **Location** | Theater centroid coordinates | +| **Message** | "Military Transport Surge in [Theater]: [X] aircraft (baseline: [Y])" | +| **Details** | Aircraft types, nearby bases (150km radius), top callsigns | +| **Confidence** | Based on surge ratio (0.6–0.9) | + +### Foreign Military Presence Detection + +Beyond surge detection, the system monitors for **foreign military aircraft in sensitive regions**—situations where aircraft from one nation appear in geopolitically significant areas outside their normal operating range. + +**Sensitive Regions** + +The system tracks 18 strategically significant geographic areas: + +| Region | Sensitivity | Monitored For | +|--------|-------------|---------------| +| **Taiwan Strait** | Critical | PLAAF activity, US transits | +| **Persian Gulf** | Critical | Iranian, US, Gulf state activity | +| **Baltic Sea** | High | Russian activity near NATO | +| **Black Sea** | High | NATO reconnaissance, Russian activity | +| **South China Sea** | High | PLAAF patrols, US FONOPs | +| **Korean Peninsula** | High | DPRK activity, US-ROK exercises | +| **Eastern Mediterranean** | Medium | Russian naval aviation, NATO | +| **Arctic** | Medium | Russian bomber patrols | + +**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) +4. Applies concentration thresholds (typically 2-3 aircraft per operator) + +**Critical Combinations** + +Certain operator-region combinations trigger **critical severity** alerts: + +| Operator | Region | Rationale | +|----------|--------|-----------| +| PLAAF | Taiwan Strait | Potential invasion rehearsal | +| Russian VKS | Arctic | Nuclear bomber patrols | +| USAF | Persian Gulf | Potential strike package | + +**Signal Output** + +Foreign presence detection generates a `foreign_military_presence` signal: + +| Field | Content | +|-------|---------| +| **Title** | "Foreign Military Presence: [Region]" | +| **Details** | "[Operator] aircraft detected: [count] [types]" | +| **Severity** | Critical/High/Medium based on combination | +| **Confidence** | 0.7–0.95 based on aircraft count and type diversity | + +--- + +## Aircraft Enrichment + +Military aircraft tracking is enhanced with **Wingbits** enrichment data, providing detailed aircraft information that goes beyond basic transponder data. + +### What Wingbits Provides + +When an aircraft is detected via OpenSky ADS-B, the system queries Wingbits for: + +| Field | Description | Use Case | +|-------|-------------|----------| +| **Registration** | Aircraft tail number (e.g., N12345) | Unique identification | +| **Owner** | Legal owner of the aircraft | Military branch detection | +| **Operator** | Operating entity | Distinguish military vs. contractor | +| **Manufacturer** | Boeing, Lockheed Martin, etc. | Aircraft type classification | +| **Model** | Specific aircraft model | Capability assessment | +| **Built Year** | Year of manufacture | Fleet age analysis | + +### Military Classification Algorithm + +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 +- European: Eurofighter, Typhoon, Rafale, Tornado, Gripen + +### Confidence Levels + +Each enriched aircraft receives a confidence classification: + +| Level | Criteria | Display | +|-------|----------|---------| +| **Confirmed** | Direct military owner/operator match | Green badge | +| **Likely** | Military ICAO code or aircraft type | Yellow badge | +| **Possible** | Defense contractor ownership | Gray badge | +| **Civilian** | No military indicators | No badge | + +### Caching Strategy + +Aircraft details rarely change, so aggressive caching reduces API load: + +- **Server-side**: HTTP Cache-Control headers (24-hour max-age) +- **Client-side**: 1-hour local cache per aircraft +- **Batch optimization**: Up to 20 aircraft per API call + +This means an aircraft's details are fetched at most once per day, regardless of how many times it appears on the map. + +--- + +## Space Launch Infrastructure + +The Spaceports layer displays global launch facilities for monitoring space-related activity and supply chain implications. + +### Tracked Launch Sites + +| Site | Country | Operator | Activity Level | +|------|---------|----------|----------------| +| **Kennedy Space Center** | USA | NASA/Space Force | High | +| **Vandenberg SFB** | USA | US Space Force | Medium | +| **Starbase** | USA | SpaceX | High | +| **Baikonur Cosmodrome** | Kazakhstan | Roscosmos | Medium | +| **Plesetsk Cosmodrome** | Russia | Roscosmos/Military | Medium | +| **Vostochny Cosmodrome** | Russia | Roscosmos | Low | +| **Jiuquan SLC** | China | CNSA | High | +| **Xichang SLC** | China | CNSA | High | +| **Wenchang SLC** | China | CNSA | Medium | +| **Guiana Space Centre** | France | ESA/CNES | Medium | +| **Satish Dhawan SC** | India | ISRO | Medium | +| **Tanegashima SC** | Japan | JAXA | Low | + +### 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 +- **Incident correlation**: News about space debris, failed launches, or policy changes + +--- + +## Critical Mineral Deposits + +The Minerals layer displays strategic mineral extraction sites essential for modern technology and defense supply chains. + +### Tracked Resources + +| Mineral | Strategic Importance | Major Producers | +|---------|---------------------|-----------------| +| **Lithium** | EV batteries, energy storage | Australia, Chile, China | +| **Cobalt** | Battery cathodes, superalloys | DRC (60%+ global), Australia | +| **Rare Earths** | Magnets, electronics, defense | China (60%+ global), Australia, USA | + +### Key Sites + +| Site | Mineral | Country | Significance | +|------|---------|---------|--------------| +| Greenbushes | Lithium | Australia | World's largest hard-rock lithium mine | +| Salar de Atacama | Lithium | Chile | Largest brine lithium source | +| Mutanda | Cobalt | DRC | World's largest cobalt mine | +| Tenke Fungurume | Cobalt | DRC | Major Chinese-owned cobalt source | +| Bayan Obo | Rare Earths | China | 45% of global REE production | +| Mountain Pass | Rare Earths | USA | Only active US rare earth mine | + +### 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 + +News about these regions or mining companies can signal supply disruptions affecting technology and defense sectors. + +--- + +## Cyber Threat Actors (APT Groups) + +The map displays geographic attribution markers for major state-sponsored Advanced Persistent Threat (APT) groups. These markers show the approximate operational centers of known threat actors. + +### Tracked Groups + +| Group | Aliases | Sponsor | Notable Activity | +|-------|---------|---------|-----------------| +| **APT28/29** | Fancy Bear, Cozy Bear | Russia (GRU/FSB) | Election interference, government espionage | +| **APT41** | Double Dragon | China (MSS) | Supply chain attacks, intellectual property theft | +| **Lazarus** | Hidden Cobra | North Korea (RGB) | Financial theft, cryptocurrency heists | +| **APT33/35** | Elfin, Charming Kitten | Iran (IRGC) | Critical infrastructure, aerospace targeting | + +### Why This Matters + +Cyber operations often correlate with geopolitical tensions. When news reports reference Russian cyber activity during a Ukraine escalation, or Iranian hacking during Middle East tensions, these markers provide geographic context for the threat landscape. + +### 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** + +--- + +## Social Unrest Tracking + +The Protests layer aggregates civil unrest data from two independent sources, providing corroboration and global coverage. + +### 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 +- **Confidence**: High (human-curated) + +### 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) +- **Confidence**: Medium (algorithmic extraction) + +### 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 + +### Severity Classification + +| Severity | Criteria | +|----------|----------| +| **High** | Fatalities reported, riots, or clashes | +| **Medium** | Large demonstrations, strikes | +| **Low** | Smaller protests, localized events | + +Events near intelligence hotspots are cross-referenced to provide geopolitical context. + +### Map Display Filtering + +To reduce visual clutter and focus attention on significant events, the map displays only **high-severity protests and riots**: + +| Displayed | Event Type | Visual | +|-----------|------------|--------| +| ✅ Yes | Riot | Bright red marker | +| ✅ Yes | High-severity protest | Red marker | +| ❌ No | Medium/low-severity protest | Not shown on map | + +Lower-severity events are still tracked for CII scoring and data exports—they simply don't create map markers. This filtering prevents dense urban areas (which naturally generate more low-severity demonstrations) from overwhelming the map display. + +--- + +## Aviation Monitoring + +The Flights layer tracks airport delays and ground stops at major US airports using FAA NASSTATUS data. + +### Delay Types + +| Type | Description | +|------|-------------| +| **Ground Stop** | No departures permitted; severe disruption | +| **Ground Delay** | Departures held; arrival rate limiting | +| **Arrival Delay** | Inbound traffic backed up | +| **Departure Delay** | Outbound traffic delayed | + +### Severity Thresholds + +| Severity | Average Delay | Visual | +|----------|--------------|--------| +| **Severe** | ≥60 minutes | Red | +| **Major** | 45-59 minutes | Orange | +| **Moderate** | 25-44 minutes | Yellow | +| **Minor** | 15-24 minutes | Gray | + +### 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 + +Ground stops are particularly significant—they indicate severe disruption (weather, security, or infrastructure failure) and can cascade across the network. + +--- + +## Security & Input Validation + +The dashboard handles untrusted data from dozens of external sources. Defense-in-depth measures prevent injection attacks and API abuse. + +### XSS Prevention + +All user-visible content is sanitized before DOM insertion: + +```typescript +escapeHtml(str) // Encodes & < > " ' as HTML entities +sanitizeUrl(url) // Allows only http/https protocols +``` + +This applies to: + +- News headlines and sources (RSS feeds) +- Search results and highlights +- Monitor keywords (user input) +- Map popup content +- Tension pair labels + +The `` highlighting in search escapes text *before* wrapping matches, preventing injection via crafted search queries. + +### Proxy Endpoint Validation + +Serverless proxy functions validate and clamp all parameters: + +| Endpoint | Validation | +|----------|------------| +| `/api/yahoo-finance` | Symbol format `[A-Za-z0-9.^=-]`, max 20 chars | +| `/api/coingecko` | Coin IDs alphanumeric+hyphen, max 20 IDs | +| `/api/polymarket` | Order field allowlist, limit clamped 1-100 | + +This prevents upstream API abuse and rate limit exhaustion from malformed requests. + +### Content Security + +- URLs are validated via `URL()` constructor—only `http:` and `https:` protocols are permitted +- External links use `rel="noopener"` to prevent reverse tabnapping +- No inline scripts or `eval()`—all code is bundled at build time + +--- + +## Fault Tolerance + +External APIs are unreliable. Rate limits, outages, and network errors are inevitable. The system implements **circuit breaker** patterns to maintain availability. + +### Circuit Breaker Pattern + +Each external service is wrapped in a circuit breaker that tracks failures: + +``` +Normal → Failure #1 → Failure #2 → OPEN (cooldown) + ↓ + 5 minutes pass + ↓ + CLOSED +``` + +**Behavior during cooldown:** + +- New requests return cached data (if available) +- UI shows "temporarily unavailable" status +- No API calls are made (prevents hammering) + +### Protected Services + +| Service | Cooldown | Cache TTL | +|---------|----------|-----------| +| Yahoo Finance | 5 min | 10 min | +| Polymarket | 5 min | 10 min | +| USGS Earthquakes | 5 min | 10 min | +| NWS Weather | 5 min | 10 min | +| FRED Economic | 5 min | 10 min | +| Cloudflare Radar | 5 min | 10 min | +| ACLED | 5 min | 10 min | +| GDELT | 5 min | 10 min | +| FAA Status | 5 min | 5 min | +| RSS Feeds | 5 min per feed | 10 min | + +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 +4. No user intervention required + +--- + +## System Health Monitoring + +The status panel (accessed via the health indicator in the header) provides real-time visibility into data source status and system health. + +### Health Indicator + +The header displays a system health badge: + +| State | Visual | Meaning | +|-------|--------|---------| +| **Healthy** | Green dot | All data sources operational | +| **Degraded** | Yellow dot | Some sources in cooldown | +| **Unhealthy** | Red dot | Multiple sources failing | + +Click the indicator to expand the full status panel. + +### Data Source Status + +The status panel lists all data feeds with their current state: + +| Status | Icon | Description | +|--------|------|-------------| +| **Active** | ● Green | Fetching data normally | +| **Cooldown** | ● Yellow | Temporarily paused (circuit breaker) | +| **Disabled** | ○ Gray | Layer not enabled | +| **Error** | ● Red | Persistent failure | + +### 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 +- **Cooldown remaining** - Time until circuit breaker resets (if in cooldown) + +### 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? + +This transparency enables informed interpretation of the dashboard data. + +--- + +## Data Freshness Tracking + +Beyond simple "online/offline" status, the system tracks fine-grained freshness for each data source to indicate data reliability and staleness. + +### Freshness Levels + +| Status | Color | Criteria | Meaning | +|--------|-------|----------|---------| +| **Fresh** | Green | Updated within expected interval | Data is current | +| **Aging** | Yellow | 1-2× expected interval elapsed | Data may be slightly stale | +| **Stale** | Orange | 2-4× expected interval elapsed | Data is outdated | +| **Critical** | Red | >4× expected interval elapsed | Data unreliable | +| **Disabled** | Gray | Layer toggled off | Not fetching | + +### Source-Specific Thresholds + +Each data source has calibrated freshness expectations: + +| Source | Expected Interval | "Fresh" Threshold | +|--------|------------------|-------------------| +| News feeds | 5 minutes | <10 minutes | +| Stock quotes | 1 minute | <5 minutes | +| Earthquakes | 5 minutes | <15 minutes | +| Weather | 10 minutes | <30 minutes | +| Flight delays | 10 minutes | <20 minutes | +| AIS vessels | Real-time | <1 minute | + +### 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 + +### 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 + +This visibility enables appropriate confidence calibration when interpreting the dashboard. + +### Core vs. Optional Sources + +Data sources are classified by their importance to risk assessment: + +| Classification | Sources | Impact | +|----------------|---------|--------| +| **Core** | GDELT, RSS feeds | Required for meaningful risk scores | +| **Optional** | ACLED, Military, AIS, Weather, Economic | Enhance but not required | + +The Strategic Risk Overview panel adapts its display based on core source availability: + +| Status | Display Mode | Behavior | +|--------|--------------|----------| +| **Sufficient** | Full data view | All metrics shown with confidence | +| **Limited** | Limited data view | Shows "Limited Data" warning banner | +| **Insufficient** | Insufficient data view | "Insufficient Data" message, no risk score | + +### Freshness-Aware Risk Assessment + +The composite risk score is adjusted based on data freshness: + +``` +If core sources fresh: + → Full confidence in risk score + → "All data sources active" indicator + +If core sources stale: + → Display warning: "Limited Data - [active sources]" + → Score shown but flagged as potentially unreliable + +If core sources unavailable: + → "Insufficient data for risk assessment" + → No score displayed +``` + +This prevents false "all clear" signals when the system actually lacks data to make that determination. + +--- + +## Conditional Data Loading + +API calls are expensive. The system only fetches data for **enabled layers**, reducing unnecessary network traffic and rate limit consumption. + +### 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 + +### 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 + +This prevents confusion when deploying without full API access. + +--- + +## Performance Optimizations + +The dashboard processes thousands of data points in real-time. Several techniques keep the UI responsive even with heavy data loads. + +### Web Worker for Analysis + +CPU-intensive operations run in a dedicated Web Worker to avoid blocking the main thread: + +| Operation | Complexity | Worker? | +|-----------|------------|---------| +| News clustering (Jaccard) | O(n²) | ✅ Yes | +| Correlation detection | O(n × m) | ✅ Yes | +| 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 +- **Automatic cleanup**: Terminates worker on fatal errors + +### Virtual Scrolling + +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 + +This reduces DOM node count from thousands to ~30, dramatically improving scroll performance. + +### 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 + +### 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 + +--- + +## Prediction Market Filtering + +The Prediction Markets panel focuses on **geopolitically relevant** markets, filtering out sports and entertainment. + +### 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 +- **Economics**: Fed, interest rate, inflation, recession, tariffs, sanctions +- **Global**: UN, EU, treaties, summits, coups, refugees + +### Exclusion Keywords + +Markets matching these are filtered out: + +- **Sports**: NBA, NFL, FIFA, World Cup, championships, playoffs +- **Entertainment**: Oscars, movies, celebrities, TikTok, streaming + +This ensures the panel shows markets like "Will Russia withdraw from Ukraine?" rather than "Will the Lakers win the championship?" + +--- + +## Panel Management + +The dashboard organizes data into **draggable, collapsible panels** that persist user preferences across sessions. + +### Drag-to-Reorder + +Panels can be reorganized by dragging: + +1. Grab the panel header (grip icon appears on hover) +2. Drag to desired position +3. Drop to reorder +4. New order saves automatically to LocalStorage + +This enables personalized layouts—put your most-watched panels at the top. + +### Panel Visibility + +Toggle panels on/off via the Settings menu (⚙): + +- **Hidden panels**: Don't render, don't fetch data +- **Visible panels**: Full functionality +- **Collapsed panels**: Header only, data still refreshes + +Hiding a panel is different from disabling a layer—the panel itself doesn't appear in the interface. + +### Default Panel Order + +Panels are organized by intelligence priority: + +| Priority | Panels | Purpose | +|----------|--------|---------| +| **Critical** | Strategic Risk, Live Intel | Immediate situational awareness | +| **Primary** | News, CII, Markets | Core monitoring data | +| **Supporting** | Predictions, Economic, Monitor | Supplementary analysis | +| **Reference** | Live News Video | Background context | + +### Persistence + +Panel state survives browser restarts: + +- **LocalStorage**: Panel order, visibility, collapsed state +- **Automatic save**: Changes persist immediately +- **Per-device**: Settings are browser-specific (not synced) + +--- + +## Mobile Experience + +The dashboard is optimized for mobile devices with a streamlined interface that prioritizes usability on smaller screens. + +### First-Time Mobile Welcome + +When accessing the dashboard on a mobile device for the first time, a welcome modal explains the mobile-optimized experience: + +- **Simplified view notice** - Informs users they're seeing a curated mobile version +- **Navigation tip** - Explains regional view buttons and marker interaction +- **"Don't show again" option** - Checkbox to skip on future visits (persisted to localStorage) + +### Mobile-First Design + +On screens narrower than 768px or touch devices: + +- **Compact map** - Reduced height (40vh) to show more panels +- **Single-column layout** - Panels stack vertically for easy scrolling +- **Hidden map labels** - All marker labels are hidden to reduce visual clutter +- **Fixed layer set** - Layer toggle buttons are hidden; a curated set of layers is enabled by default +- **Simplified controls** - Map resize handle and pin button are hidden +- **Touch-optimized markers** - Expanded touch targets (44px) for easy tapping +- **Hidden DEFCON indicator** - Pentagon Pizza Index hidden to reduce header clutter +- **Hidden FOCUS selector** - Regional focus buttons hidden (use preset views instead) +- **Compact header** - Social link shows X logo instead of username text + +### Mobile Default Layers + +The mobile experience focuses on the most essential intelligence layers: + +| Layer | Purpose | +|-------|---------| +| **Conflicts** | Active conflict zones | +| **Hotspots** | Intelligence hotspots with activity levels | +| **Sanctions** | Countries under economic sanctions | +| **Outages** | Network disruptions | +| **Natural** | Earthquakes, storms, wildfires | +| **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 +- Protests, economic centers + +This curated set provides situational awareness without overwhelming the interface or consuming excessive data/battery. + +### Touch Gestures + +Map navigation supports: + +- **Pinch zoom** - Two-finger zoom in/out +- **Drag pan** - Single-finger map movement +- **Tap markers** - Show popup (replaces hover) +- **Double-tap** - Quick zoom + +### Performance Considerations + +Mobile optimizations reduce resource consumption: + +| Optimization | Benefit | +|--------------|---------| +| Fewer layers | Reduced API calls, lower battery usage | +| No labels | Faster rendering, cleaner interface | +| Hidden controls | More screen space for content | +| Simplified header | Reduced visual processing | + +### Desktop Experience + +On larger screens, the full feature set is available: + +- Multi-column responsive panel grid +- All layer toggles accessible +- Map labels visible at appropriate zoom levels +- Resizable map section +- Pinnable map (keeps map visible while scrolling panels) +- Full DEFCON indicator with tension pairs +- FOCUS regional selector for rapid navigation + +--- + +## Energy Flow Detection + +The correlation engine detects signals related to energy infrastructure and commodity markets. + +### Pipeline Keywords + +The system monitors news for pipeline-related events: + +**Infrastructure terms**: pipeline, pipeline explosion, pipeline leak, pipeline attack, pipeline sabotage, pipeline disruption, nord stream, keystone, druzhba + +**Flow indicators**: gas flow, oil flow, supply disruption, transit halt, capacity reduction + +### Flow Drop Signals + +When news mentions flow disruptions, two signal types may trigger: + +| Signal | Criteria | Meaning | +|--------|----------|---------| +| **Flow Drop** | Pipeline keywords + disruption terms | Potential supply interruption | +| **Flow-Price Divergence** | Flow drop news + oil price stable (< $1.50 move) | Markets not yet pricing in disruption | + +### Why This Matters + +Energy supply disruptions create cascading effects: + +1. **Immediate**: Spot price volatility +2. **Short-term**: Industrial production impacts +3. **Long-term**: Geopolitical leverage shifts + +Early detection of flow drops—especially when markets haven't reacted—provides an information edge. + +--- + +## Signal Aggregator + +The Signal Aggregator is the central nervous system that collects, groups, and summarizes intelligence signals from all data sources. + +### What It Aggregates + +| Signal Type | Source | Frequency | +|-------------|--------|-----------| +| `military_flight` | OpenSky ADS-B | Real-time | +| `military_vessel` | AIS WebSocket | Real-time | +| `protest` | ACLED + GDELT | Hourly | +| `internet_outage` | Cloudflare Radar | 5 min | +| `ais_disruption` | AIS analysis | Real-time | + +### Country-Level Grouping + +All signals are grouped by country code, creating a unified view: + +```typescript +{ + country: 'UA', // Ukraine + countryName: 'Ukraine', + totalCount: 15, + highSeverityCount: 3, + signalTypes: Set(['military_flight', 'protest', 'internet_outage']), + signals: [/* all signals for this country */] +} +``` + +### Regional Convergence Detection + +The aggregator identifies geographic convergence—when multiple signal types cluster in the same region: + +| Convergence Level | Criteria | Alert Priority | +|-------------------|----------|----------------| +| **Critical** | 4+ signal types within 200km | Immediate | +| **High** | 3 signal types within 200km | High | +| **Medium** | 2 signal types within 200km | Normal | + +### Summary Output + +The aggregator provides a real-time summary for dashboards and AI context: + +``` +[SIGNAL SUMMARY] +Top Countries: Ukraine (15 signals), Iran (12), Taiwan (8) +Convergence Zones: Baltic Sea (military_flight + military_vessel), + Tehran (protest + internet_outage) +Active Signal Types: 5 of 5 +Total Signals: 47 +``` + +--- + +## Browser-Based Machine Learning + +For offline resilience and reduced API costs, the system includes browser-based ML capabilities using ONNX Runtime Web. + +### Available Models + +| Model | Task | Size | Use Case | +|-------|------|------|----------| +| **T5-small** | Text summarization | ~60MB | Offline briefing generation | +| **DistilBERT** | Sentiment analysis | ~67MB | News tone classification | + +### Fallback Strategy + +Browser ML serves as the final fallback when cloud APIs are unavailable: + +``` +User requests summary + ↓ +1. Try Groq API (fast, free tier) + ↓ (rate limited or error) +2. Try OpenRouter API (fallback provider) + ↓ (unavailable) +3. Use Browser T5 (offline, always available) +``` + +### 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 + +### 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 + +### Limitations + +Browser ML has constraints compared to cloud models: + +| Aspect | Cloud (Llama 3.3) | Browser (T5) | +|--------|-------------------|--------------| +| Context window | 128K tokens | 512 tokens | +| Output quality | High | Moderate | +| Inference speed | 2-3 seconds | 5-10 seconds | +| Offline support | No | Yes | + +Browser summarization is intentionally limited to 6 headlines × 80 characters to stay within model constraints. + +--- + +## Cross-Module Integration + +Intelligence modules don't operate in isolation. Data flows between systems to enable composite analysis. + +### Data Flow Architecture + +``` +News Feeds → Clustering → Velocity Analysis → Hotspot Correlation + ↓ ↓ + Topic Extraction CII Information Score + ↓ ↓ + Keyword Monitors Strategic Risk Overview + ↑ +Military Flights → Near-Hotspot Detection ──────────┤ + ↑ +AIS Vessels → Chokepoint Monitoring ────────────────┤ + ↑ +ACLED/GDELT → Protest Events ───────────────────────┤ + ↓ + CII Unrest Score +``` + +### Module Dependencies + +| Consumer Module | Data Source | Integration | +|----------------|-------------|-------------| +| **CII Unrest Score** | ACLED, GDELT protests | Event count, fatalities | +| **CII Security Score** | Military flights, vessels | Activity near hotspots | +| **CII Information Score** | News clusters | Velocity, keyword matches | +| **Strategic Risk** | CII, Convergence, Cascade | Composite scoring | +| **Related Assets** | News location inference | Pipeline/cable proximity | +| **Geographic Convergence** | All geo-located events | Multi-type clustering | + +### Alert Propagation + +When a threshold is crossed: + +1. **Source module** generates alert (e.g., CII spike) +2. **Alert merges** with related alerts (same country/region) +3. **Strategic Risk** receives composite alert +4. **UI updates** header badge and panel indicators + +This ensures a single escalation (e.g., Ukraine military flights + protests + news spike) surfaces as one coherent signal rather than three separate alerts. + +--- + +## AI Insights Panel + +The Insights Panel provides AI-powered analysis of the current news landscape, transforming raw headlines into actionable intelligence briefings. + +### World Brief Generation + +Every 2 minutes (with rate limiting), the system generates a concise situation brief using a multi-provider fallback chain: + +| Priority | Provider | Model | Latency | Use Case | +|----------|----------|-------|---------|----------| +| 1 | Groq | Llama 3.3 70B | ~2s | Primary provider (fast inference) | +| 2 | OpenRouter | Llama 3.3 70B | ~3s | Fallback when Groq rate-limited | +| 3 | Browser | T5 (ONNX) | ~5s | Offline fallback (local ML) | + +**Caching Strategy**: Redis server-side caching prevents redundant API calls. When the same headline set has been summarized recently, the cached result is returned immediately. + +### Focal Point Detection + +The AI receives enriched context about **focal points**—entities that appear in both news coverage AND map signals. This enables intelligence-grade analysis: + +``` +[INTELLIGENCE SYNTHESIS] +FOCAL POINTS (entities across news + signals): +- IRAN [CRITICAL]: 12 news mentions + 5 map signals (military_flight, protest, internet_outage) + KEY: "Iran protests continue..." | SIGNALS: military activity, outage detected +- TAIWAN [ELEVATED]: 8 news mentions + 3 map signals (military_vessel, military_flight) + KEY: "Taiwan tensions rise..." | SIGNALS: naval vessels detected +``` + +### Headline Scoring Algorithm + +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 + +This ensures military conflicts and humanitarian crises surface above routine business news. + +### Sentiment Analysis + +Headlines are analyzed for overall sentiment distribution: + +| Sentiment | Detection Method | Display | +|-----------|------------------|---------| +| **Negative** | Crisis, conflict, death keywords | Red percentage | +| **Positive** | Agreement, growth, peace keywords | Green percentage | +| **Neutral** | Neither detected | Gray percentage | + +The overall sentiment balance provides a quick read on whether the news cycle is trending toward escalation or de-escalation. + +### Velocity Detection + +Fast-moving stories are flagged when the same topic appears in multiple recent headlines: + +- Headlines are grouped by shared keywords and entities +- Topics with 3+ mentions in 6 hours are marked as "high velocity" +- Displayed separately to highlight developing situations + +--- + +## Focal Point Detector + +The Focal Point Detector is the intelligence synthesis layer that correlates news entities with map signals to identify "main characters" driving current events. + +### 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 + +### How It Works + +1. **Entity Extraction**: Extract countries, companies, and organizations from all news clusters using the entity registry (600+ entities with aliases) + +2. **Signal Aggregation**: Collect all map signals (military flights, protests, outages, vessels) and group by country + +3. **Cross-Reference**: Match news entities with signal countries + +4. **Score & Rank**: Calculate focal scores based on correlation strength + +### Focal Point Scoring + +``` +FocalScore = NewsScore + SignalScore + CorrelationBonus + +NewsScore (0-40): + base = min(20, mentionCount × 4) + velocity = min(10, newsVelocity × 2) + confidence = avgConfidence × 10 + +SignalScore (0-40): + types = signalTypes.count × 10 + count = min(15, signalCount × 3) + severity = highSeverityCount × 5 + +CorrelationBonus (0-20): + +10 if entity appears in BOTH news AND signals + +5 if news keywords match signal types (e.g., "military" + military_flight) + +5 if related entities also have signals +``` + +### Urgency Classification + +| Urgency | Criteria | Visual | +|---------|----------|--------| +| **Critical** | Score > 70 OR 3+ signal types | Red badge | +| **Elevated** | Score > 50 OR 2+ signal types | Orange badge | +| **Watch** | Default | Yellow badge | + +### Signal Type Icons + +Focal points display icons indicating which signal types are active: + +| Icon | Signal Type | Meaning | +|------|-------------|---------| +| ✈️ | military_flight | Military aircraft detected nearby | +| ⚓ | military_vessel | Naval vessels in waters | +| 📢 | protest | Civil unrest events | +| 🌐 | internet_outage | Network disruption | +| 🚢 | ais_disruption | Shipping anomaly | + +### 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 +- **Narrative**: "12 news mentions | 5 military flights, 3 protests, 1 internet outage | 'Iran protests continue amid...'" +- **Correlation Evidence**: "Iran appears in both news (12) and map signals (9)" + +### 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 + +--- + +## Natural Disaster Tracking + +The Natural layer combines two authoritative sources for comprehensive disaster monitoring. + +### GDACS (Global Disaster Alert and Coordination System) + +UN-backed disaster alert system providing official severity assessments: + +| Event Type | Code | Icon | Sources | +|------------|------|------|---------| +| Earthquake | EQ | 🔴 | USGS, EMSC | +| Flood | FL | 🌊 | Satellite imagery | +| Tropical Cyclone | TC | 🌀 | NOAA, JMA | +| Volcano | VO | 🌋 | Smithsonian GVP | +| Wildfire | WF | 🔥 | MODIS, VIIRS | +| Drought | DR | ☀️ | Multiple sources | + +**Alert Levels**: +| Level | Color | Meaning | +|-------|-------|---------| +| **Red** | Critical | Significant humanitarian impact expected | +| **Orange** | Alert | Moderate impact, monitoring required | +| **Green** | Advisory | Minor event, localized impact | + +### NASA EONET (Earth Observatory Natural Event Tracker) + +Near-real-time natural event detection from satellite observation: + +| Category | Detection Method | Typical Delay | +|----------|------------------|---------------| +| Severe Storms | GOES/Himawari imagery | Minutes | +| Wildfires | MODIS thermal anomalies | 4-6 hours | +| Volcanoes | Thermal + SO2 emissions | Hours | +| Floods | SAR imagery + gauges | Hours to days | +| Sea/Lake Ice | Passive microwave | Daily | +| Dust/Haze | Aerosol optical depth | Hours | + +### 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 +4. Combined entry shows both source attributions + +### 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 + +--- + +## Military Surge Detection + +The system detects unusual concentrations of military activity using two complementary algorithms. + +### Baseline-Based Surge Detection + +Surges are detected by comparing current aircraft counts to historical baselines within defined military theaters: + +| Parameter | Value | Purpose | +|-----------|-------|---------| +| Surge threshold | 2.0× baseline | Minimum multiplier to trigger alert | +| Baseline window | 48 hours | Historical data used for comparison | +| Minimum samples | 6 observations | Required data points for valid baseline | + +**Aircraft Categories Tracked**: + +| Category | Examples | Minimum Count | +|----------|----------|---------------| +| Transport/Airlift | C-17, C-130, KC-135, REACH flights | 5 aircraft | +| Fighter | F-15, F-16, F-22, Typhoon | 4 aircraft | +| Reconnaissance | RC-135, E-3 AWACS, U-2 | 3 aircraft | + +### Surge Severity + +| Severity | Criteria | Meaning | +|----------|----------|---------| +| **Critical** | 4× baseline or higher | Major deployment | +| **High** | 3× baseline | Significant increase | +| **Medium** | 2× baseline | Elevated activity | + +### Military Theaters + +Surge detection groups activity into strategic theaters: + +| Theater | Center | Key Bases | +|---------|--------|-----------| +| Middle East | Persian Gulf | Al Udeid, Al Dhafra, Incirlik | +| Eastern Europe | Poland | Ramstein, Spangdahlem, Łask | +| Pacific | Guam/Japan | Andersen, Kadena, Yokota | +| Horn of Africa | Djibouti | Camp Lemonnier | + +### Foreign Presence Detection + +A separate system monitors for military operators outside their normal operating areas: + +| Operator | Home Regions | Alert When Found In | +|----------|--------------|---------------------| +| USAF/USN | Alaska ADIZ | Persian Gulf, Taiwan Strait | +| Russian VKS | Kaliningrad, Arctic, Black Sea | Baltic Region, Alaska ADIZ | +| PLAAF/PLAN | Taiwan Strait, South China Sea | (alerts when increased) | +| Israeli IAF | Eastern Med | Iran border region | + +**Example alert**: +``` +FOREIGN MILITARY PRESENCE: Persian Gulf +USAF: 3 aircraft detected (KC-135, RC-135W, E-3) +Severity: HIGH - Operator outside normal home regions +``` + +### News Correlation + +Both surge and foreign presence alerts query the Focal Point Detector for context: + +1. Identify countries involved (aircraft operators, region countries) +2. Check focal points for those countries +3. If news correlation exists, attach headlines and evidence + +**Example with correlation**: +``` +MILITARY AIRLIFT SURGE: Middle East Theater +Current: 8 transport aircraft (2.5× baseline) +Aircraft: C-17 (3), KC-135 (3), C-130J (2) + +NEWS CORRELATION: +Iran: "Iran protests continue amid military..." +→ Iran appears in both news (12) and map signals (9) +``` + +--- + +## Strategic Posture Analysis + +The AI Strategic Posture panel aggregates military aircraft and naval vessels across defined theaters, providing at-a-glance situational awareness of global force concentrations. + +### Strategic Theaters + +Nine geographic theaters are monitored continuously, each with custom thresholds based on typical peacetime activity levels: + +| Theater | Bounds | Elevated Threshold | Critical Threshold | +|---------|--------|--------------------|--------------------| +| **Iran Theater** | Persian Gulf, Iraq, Syria (20°N–42°N, 30°E–65°E) | 50 aircraft | 100 aircraft | +| **Taiwan Strait** | Taiwan, East China Sea (18°N–30°N, 115°E–130°E) | 30 aircraft | 60 aircraft | +| **Korean Peninsula** | North/South Korea (33°N–43°N, 124°E–132°E) | 20 aircraft | 50 aircraft | +| **Baltic Theater** | Baltics, Poland, Scandinavia (52°N–65°N, 10°E–32°E) | 20 aircraft | 40 aircraft | +| **Black Sea** | Ukraine, Turkey, Romania (40°N–48°N, 26°E–42°E) | 15 aircraft | 30 aircraft | +| **South China Sea** | Philippines, Vietnam (5°N–25°N, 105°E–121°E) | 25 aircraft | 50 aircraft | +| **Eastern Mediterranean** | Syria, Cyprus, Lebanon (33°N–37°N, 25°E–37°E) | 15 aircraft | 30 aircraft | +| **Israel/Gaza** | Israel, Gaza Strip (29°N–33°N, 33°E–36°E) | 10 aircraft | 25 aircraft | +| **Yemen/Red Sea** | Bab el-Mandeb, Houthi areas (11°N–22°N, 32°E–54°E) | 15 aircraft | 30 aircraft | + +### Strike Capability Assessment + +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) + +Each theater has custom thresholds reflecting realistic strike package sizes: + +| Theater | Min Tankers | Min AWACS | Min Fighters | +|---------|-------------|-----------|--------------| +| Iran Theater | 10 | 2 | 30 | +| Taiwan Strait | 5 | 1 | 20 | +| Korean Peninsula | 4 | 1 | 15 | +| Baltic/Black Sea | 3-4 | 1 | 10-15 | +| Israel/Gaza | 2 | 1 | 8 | + +When all three criteria are met, the theater is flagged as **STRIKE CAPABLE**, indicating forces sufficient for sustained offensive operations. + +### Naval Vessel Integration + +The panel augments aircraft data with real-time naval vessel positions from AIS tracking. Vessels are classified into categories: + +| Category | Examples | Strategic Significance | +|----------|----------|------------------------| +| **Carriers** | CVN, CV, LHD | Power projection, air superiority | +| **Destroyers** | DDG, DDH | Air defense, cruise missile strike | +| **Frigates** | FFG, FF | Multi-role escort, ASW | +| **Submarines** | SSN, SSK, SSBN | Deterrence, ISR, strike | +| **Patrol** | PC, PG | Coastal defense | +| **Auxiliary** | T-AO, AOR | Fleet support, logistics | + +**Data Accumulation Note**: AIS vessel data arrives via WebSocket stream and accumulates gradually. The panel automatically re-checks vessel counts at 30, 60, 90, and 120 seconds after initial load to capture late-arriving data. + +### Posture Levels + +| Level | Indicator | Criteria | Meaning | +|-------|-----------|----------|---------| +| **Normal** | 🟢 NORM | Below elevated threshold | Routine peacetime activity | +| **Elevated** | 🟡 ELEV | At or above elevated threshold | Increased activity, possible exercises | +| **Critical** | 🔴 CRIT | At or above critical threshold | Major deployment, potential crisis | + +**Elevated + Strike Capable** is treated as a higher alert state than regular elevated status. + +### Trend Detection + +Activity trends are computed from rolling historical data: + +- **Increasing** (↗): Current activity >10% higher than previous period +- **Stable** (→): Activity within ±10% of previous period +- **Decreasing** (↘): Current activity >10% lower than previous period + +### Server-Side Caching + +Theater posture computations run on edge servers with Redis caching: + +| Cache Type | TTL | Purpose | +|------------|-----|---------| +| **Active cache** | 5 minutes | Matches OpenSky refresh rate | +| **Stale cache** | 1 hour | Fallback when upstream APIs fail | + +This ensures consistent data across all users and minimizes redundant API calls to OpenSky Network. + +--- + +## Server-Side Risk Score API + +Strategic risk and Country Instability Index (CII) scores are pre-computed server-side rather than calculated in the browser. This eliminates the "cold start" problem where new users would see no data while the system accumulated enough information to generate scores. + +### How It Works + +The `/api/risk-scores` edge function: + +1. Fetches recent protest/riot data from ACLED (7-day window) +2. Computes CII scores for 20 Tier 1 countries +3. Derives strategic risk from weighted top-5 CII scores +4. Caches results in Redis (10-minute TTL) + +### CII Score Calculation + +Each country's score combines: + +**Baseline Risk** (0–50 points): Static geopolitical risk based on historical instability, ongoing conflicts, and authoritarian governance. + +| Country | Baseline | Rationale | +|---------|----------|-----------| +| Syria, Ukraine, Yemen | 50 | Active conflict zones | +| Myanmar, Venezuela, North Korea | 40-45 | Civil unrest, authoritarian | +| Iran, Israel, Pakistan | 35-45 | Regional tensions | +| Saudi Arabia, Turkey, India | 20-25 | Moderate instability | +| Germany, UK, US | 5-10 | Stable democracies | + +**Unrest Component** (0–50 points): Recent protest and riot activity, weighted by event significance multiplier. + +**Information Component** (0–25 points): News coverage intensity (proxy for international attention). + +**Security Component** (0–25 points): Baseline plus riot contribution. + +### Event Significance Multipliers + +Events in some countries carry more global significance than others: + +| Multiplier | Countries | Rationale | +|------------|-----------|-----------| +| 3.0× | North Korea | Any visible unrest is highly unusual | +| 2.0-2.5× | China, Russia, Iran, Saudi Arabia | Authoritarian states suppress protests | +| 1.5-1.8× | Taiwan, Pakistan, Myanmar, Venezuela | Regional flashpoints | +| 0.5-0.8× | US, UK, France, Germany | Protests are routine in democracies | + +### Strategic Risk Derivation + +The composite strategic risk score is computed as a weighted average of the top 5 CII scores: + +``` +Weights: [1.0, 0.85, 0.70, 0.55, 0.40] (total: 3.5) +Strategic Risk = (Σ CII[i] × weight[i]) / 3.5 × 0.7 + 15 +``` + +The top countries contribute most heavily, with diminishing influence for lower-ranked countries. + +### Fallback Behavior + +When ACLED data is unavailable (API errors, rate limits, expired auth): + +1. **Stale cache** (1-hour TTL): Return recent scores with `stale: true` flag +2. **Baseline fallback**: Return scores using only static baseline values with `baseline: true` flag + +This ensures the dashboard always displays meaningful data even during upstream outages. + +--- + +## Service Status Monitoring + +The Service Status panel tracks the operational health of external services that WorldMonitor users may depend on. + +### Monitored Services + +| Service | Status Endpoint | Parser | +|---------|-----------------|--------| +| Anthropic (Claude) | status.claude.com | Statuspage.io | +| OpenAI | status.openai.com | Statuspage.io | +| Vercel | vercel-status.com | Statuspage.io | +| Cloudflare | cloudflarestatus.com | Statuspage.io | +| AWS | health.aws.amazon.com | Custom | +| GitHub | githubstatus.com | Statuspage.io | + +### Status Levels + +| Status | Color | Meaning | +|--------|-------|---------| +| **Operational** | Green | All systems functioning normally | +| **Degraded** | Yellow | Partial outage or performance issues | +| **Partial Outage** | Orange | Some components unavailable | +| **Major Outage** | Red | Significant service disruption | + +### Why This Matters + +External service outages can affect: + +- AI summarization (Groq, OpenRouter outages) +- Deployment pipelines (Vercel, GitHub outages) +- API availability (Cloudflare, AWS outages) + +Monitoring these services provides context when dashboard features behave unexpectedly. + +--- + +## Refresh Intervals + +Different data sources update at different frequencies based on volatility and API constraints. + +### Polling Schedule + +| Data Type | Interval | Rationale | +|-----------|----------|-----------| +| **News feeds** | 5 min | Balance freshness vs. rate limits | +| **Stock quotes** | 1 min | Market hours require near-real-time | +| **Crypto prices** | 1 min | 24/7 markets, high volatility | +| **Predictions** | 5 min | Probabilities shift slowly | +| **Earthquakes** | 5 min | USGS updates every 5 min | +| **Weather alerts** | 10 min | NWS alert frequency | +| **Flight delays** | 10 min | FAA status update cadence | +| **Internet outages** | 60 min | BGP events are rare | +| **Economic data** | 30 min | FRED data rarely changes intraday | +| **Military tracking** | 5 min | Activity patterns need timely updates | +| **PizzINT** | 10 min | Foot traffic changes slowly | + +### Real-Time Streams + +AIS vessel tracking uses WebSocket for true real-time: + +- **Connection**: Persistent WebSocket to Railway relay +- **Messages**: Position updates as vessels transmit +- **Reconnection**: Automatic with exponential backoff (5s → 10s → 20s) + +### User Control + +Time range selector affects displayed data, not fetch frequency: + +| Selection | Effect | +|-----------|--------| +| **1 hour** | Show only events from last 60 minutes | +| **6 hours** | Show events from last 6 hours | +| **24 hours** | Show events from last day | +| **7 days** | Show all recent events | + +Historical filtering is client-side—all data is fetched but filtered for display. + +--- + +## Tech Stack + +| Layer | Technology | Purpose | +|-------|------------|---------| +| **Language** | TypeScript 5.x | Type safety across 60+ source files | +| **Build** | Vite | Fast HMR, optimized production builds | +| **Map (Desktop)** | deck.gl + MapLibre GL | WebGL-accelerated rendering for large datasets | +| **Map (Mobile)** | D3.js + TopoJSON | SVG fallback for battery efficiency | +| **Concurrency** | Web Workers | Off-main-thread clustering and correlation | +| **AI/ML** | ONNX Runtime Web | Browser-based inference for offline summarization | +| **Networking** | WebSocket + REST | Real-time AIS stream, HTTP for other APIs | +| **Storage** | IndexedDB | Snapshots, baselines (megabytes of state) | +| **Preferences** | LocalStorage | User settings, monitors, panel order | +| **Deployment** | Vercel Edge | Serverless proxies with global distribution | + +### Map Rendering Architecture + +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 +- Automatic fallback when WebGL unavailable + +### Key Libraries + +- **deck.gl**: High-performance WebGL visualization layers +- **MapLibre GL**: Open-source map rendering engine +- **D3.js**: SVG map rendering, zoom behavior (mobile fallback) +- **TopoJSON**: Efficient geographic data encoding +- **ONNX Runtime**: Browser-based ML inference +- **Custom HTML escaping**: XSS prevention (DOMPurify pattern) + +### No External UI Frameworks + +The entire UI is hand-crafted DOM manipulation—no React, Vue, or Angular. This keeps the bundle small (~250KB gzipped) and provides fine-grained control over rendering performance. + +### Build-Time Configuration + +Vite injects configuration values at build time, enabling features like automatic version syncing: + +| Variable | Source | Purpose | +|----------|--------|---------| +| `__APP_VERSION__` | `package.json` version field | Header displays current version | + +This ensures the displayed version always matches the published package—no manual synchronization required. + +```typescript +// vite.config.ts +define: { + __APP_VERSION__: JSON.stringify(pkg.version), +} + +// App.ts +const header = `World Monitor v${__APP_VERSION__}`; +``` + +--- + +## Installation + +```bash +# Clone the repository +git clone https://github.com/koala73/worldmonitor.git +cd worldmonitor + +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build +``` + +## API Dependencies + +The dashboard fetches data from various public APIs and data sources: + +| Service | Data | Auth Required | +|---------|------|---------------| +| RSS2JSON | News feed parsing | No | +| Finnhub | Stock quotes (primary) | Yes (free) | +| Yahoo Finance | Stock indices & commodities (backup) | No | +| CoinGecko | Cryptocurrency prices | No | +| USGS | Earthquake data | No | +| NASA EONET | Natural events (storms, fires, volcanoes, floods) | No | +| NWS | Weather alerts | No | +| FRED | Economic indicators (Fed data) | No | +| EIA | Oil analytics (prices, production, inventory) | Yes (free) | +| USASpending.gov | Federal government contracts & awards | No | +| Polymarket | Prediction markets | No | +| ACLED | Armed conflict & protest data | Yes (free) | +| GDELT Geo | News-derived event geolocation + tensions | No | +| GDELT Doc | Topic-based intelligence feeds (cyber, military, nuclear) | No | +| FAA NASSTATUS | Airport delay status | No | +| Cloudflare Radar | Internet outage data | Yes (free) | +| AISStream | Live vessel positions | Yes (relay) | +| OpenSky Network | Military aircraft tracking | Yes (free) | +| Wingbits | Aircraft enrichment (owner, operator) | Yes (free) | +| PizzINT | Pentagon-area activity metrics | No | + +### Optional API Keys + +Some features require API credentials. Without them, the corresponding layer is hidden: + +| Variable | Service | How to Get | +|----------|---------|------------| +| `FINNHUB_API_KEY` | Stock quotes (primary) | Free registration at [finnhub.io](https://finnhub.io/) | +| `EIA_API_KEY` | Oil analytics | Free registration at [eia.gov/opendata](https://www.eia.gov/opendata/) | +| `VITE_WS_RELAY_URL` | AIS vessel tracking | Deploy AIS relay or use hosted service | +| `VITE_OPENSKY_RELAY_URL` | Military aircraft | Deploy relay with OpenSky credentials | +| `OPENSKY_CLIENT_ID` | OpenSky auth (relay) | Free registration at [opensky-network.org](https://opensky-network.org) | +| `OPENSKY_CLIENT_SECRET` | OpenSky auth (relay) | API key from OpenSky account settings | +| `CLOUDFLARE_API_TOKEN` | Internet outages | Free Cloudflare account with Radar access | +| `ACLED_ACCESS_TOKEN` | Protest data (server-side) | Free registration at acleddata.com | +| `WINGBITS_API_KEY` | Aircraft enrichment | Contact [Wingbits](https://wingbits.com) for API access | + +The dashboard functions fully without these keys—affected layers simply don't appear. Core functionality (news, markets, earthquakes, weather) requires no configuration. + +## Project Structure + +``` +src/ +├── App.ts # Main application orchestrator +├── main.ts # Entry point +├── components/ +│ ├── DeckGLMap.ts # WebGL map with deck.gl + MapLibre (desktop) +│ ├── Map.ts # D3.js SVG map (mobile fallback) +│ ├── MapContainer.ts # Map wrapper with platform detection +│ ├── MapPopup.ts # Contextual info popups +│ ├── SearchModal.ts # Universal search (⌘K) +│ ├── SignalModal.ts # Signal intelligence display with focal points +│ ├── PizzIntIndicator.ts # Pentagon Pizza Index display +│ ├── VirtualList.ts # Virtual/windowed scrolling +│ ├── InsightsPanel.ts # AI briefings + focal point display +│ ├── EconomicPanel.ts # FRED economic indicators +│ ├── GdeltIntelPanel.ts # Topic-based intelligence (cyber, military, etc.) +│ ├── LiveNewsPanel.ts # YouTube live news streams with channel switching +│ ├── NewsPanel.ts # News feed with clustering +│ ├── MarketPanel.ts # Stock/commodity display +│ ├── MonitorPanel.ts # Custom keyword monitors +│ ├── CIIPanel.ts # Country Instability Index display +│ ├── CascadePanel.ts # Infrastructure cascade analysis +│ ├── StrategicRiskPanel.ts # Strategic risk overview dashboard +│ ├── StrategicPosturePanel.ts # AI strategic posture with theater analysis +│ ├── ServiceStatusPanel.ts # External service health monitoring +│ └── ... +├── config/ +│ ├── feeds.ts # 70+ RSS feeds, source tiers, regional sources +│ ├── geo.ts # 30+ hotspots, conflicts, 55 cables, waterways, spaceports, minerals +│ ├── pipelines.ts # 88 oil & gas pipelines +│ ├── ports.ts # 61 strategic ports worldwide +│ ├── bases-expanded.ts # 220+ military bases +│ ├── ai-datacenters.ts # 313 AI clusters (filtered to 111) +│ ├── airports.ts # 30 monitored US airports +│ ├── irradiators.ts # IAEA gamma irradiator sites +│ ├── nuclear-facilities.ts # Global nuclear infrastructure +│ ├── markets.ts # Stock symbols, sectors +│ ├── entities.ts # 100+ entity definitions (companies, indices, commodities, countries) +│ └── panels.ts # Panel configs, layer defaults, mobile optimizations +├── services/ +│ ├── ais.ts # WebSocket vessel tracking with density analysis +│ ├── military-vessels.ts # Naval vessel identification and tracking +│ ├── military-flights.ts # Aircraft tracking via OpenSky relay +│ ├── military-surge.ts # Surge detection with news correlation +│ ├── cached-theater-posture.ts # Theater posture API client with caching +│ ├── wingbits.ts # Aircraft enrichment (owner, operator, type) +│ ├── pizzint.ts # Pentagon Pizza Index + GDELT tensions +│ ├── protests.ts # ACLED + GDELT integration +│ ├── gdelt-intel.ts # GDELT Doc API topic intelligence +│ ├── gdacs.ts # UN GDACS disaster alerts +│ ├── eonet.ts # NASA EONET natural events + GDACS merge +│ ├── flights.ts # FAA delay parsing +│ ├── outages.ts # Cloudflare Radar integration +│ ├── rss.ts # RSS parsing with circuit breakers +│ ├── markets.ts # Finnhub, Yahoo Finance, CoinGecko +│ ├── earthquakes.ts # USGS integration +│ ├── weather.ts # NWS alerts +│ ├── fred.ts # Federal Reserve data +│ ├── oil-analytics.ts # EIA oil prices, production, inventory +│ ├── usa-spending.ts # USASpending.gov contracts & awards +│ ├── polymarket.ts # Prediction markets (filtered) +│ ├── clustering.ts # Jaccard similarity clustering +│ ├── correlation.ts # Signal detection engine +│ ├── velocity.ts # Velocity & sentiment analysis +│ ├── related-assets.ts # Infrastructure near news events +│ ├── activity-tracker.ts # New item detection & highlighting +│ ├── analysis-worker.ts # Web Worker manager +│ ├── ml-worker.ts # Browser ML inference (ONNX) +│ ├── summarization.ts # AI briefings with fallback chain +│ ├── parallel-analysis.ts # Concurrent headline analysis +│ ├── storage.ts # IndexedDB snapshots & baselines +│ ├── data-freshness.ts # Real-time data staleness tracking +│ ├── signal-aggregator.ts # Central signal collection & grouping +│ ├── focal-point-detector.ts # Intelligence synthesis layer +│ ├── entity-index.ts # Entity lookup maps (by alias, keyword, sector) +│ ├── entity-extraction.ts # News-to-entity matching for market correlation +│ ├── country-instability.ts # CII scoring algorithm +│ ├── geo-convergence.ts # Geographic convergence detection +│ ├── infrastructure-cascade.ts # Dependency graph and cascade analysis +│ └── cross-module-integration.ts # Unified alerts and strategic risk +├── workers/ +│ └── analysis.worker.ts # Off-thread clustering & correlation +├── utils/ +│ ├── circuit-breaker.ts # Fault tolerance pattern +│ ├── sanitize.ts # XSS prevention (escapeHtml, sanitizeUrl) +│ ├── urlState.ts # Shareable link encoding/decoding +│ └── analysis-constants.ts # Shared thresholds for worker sync +├── styles/ +└── types/ +api/ # Vercel Edge serverless proxies +├── cloudflare-outages.js # Proxies Cloudflare Radar +├── coingecko.js # Crypto prices with validation +├── eia/[[...path]].js # EIA petroleum data (oil prices, production) +├── faa-status.js # FAA ground stops/delays +├── finnhub.js # Stock quotes (batch, primary) +├── fred-data.js # Federal Reserve economic data +├── gdelt-doc.js # GDELT Doc API (topic intelligence) +├── gdelt-geo.js # GDELT Geo API (event geolocation) +├── polymarket.js # Prediction markets with validation +├── yahoo-finance.js # Stock indices/commodities (backup) +├── opensky-relay.js # Military aircraft tracking +├── wingbits.js # Aircraft enrichment proxy +├── risk-scores.js # Pre-computed CII and strategic risk (Redis cached) +├── theater-posture.js # Theater-level force aggregation (Redis cached) +├── groq-summarize.js # AI summarization with Groq API +└── openrouter-summarize.js # AI summarization fallback via OpenRouter +``` + +## 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 +- **Hover markers** - Show tooltip with summary information +- **Layer toggles** - Show/hide data layers + +### Map Marker Design + +Infrastructure markers (nuclear facilities, economic centers, ports) display without labels to reduce visual clutter. Full information is available through interaction: + +| Layer | Label Behavior | Interaction | +|-------|---------------|-------------| +| Nuclear facilities | Hidden | Click for popover with details | +| Economic centers | Hidden | Click for popover with details | +| Protests | Hidden | Hover for tooltip, click for details | +| Military bases | Hidden | Click for popover with base info | +| Hotspots | Visible | Color-coded activity levels | +| Conflicts | Visible | Status and involved parties | + +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 + +**Encoded Parameters**: +| Parameter | Description | +|-----------|-------------| +| `lat`, `lon` | Map center coordinates | +| `zoom` | Zoom level (1-10) | +| `time` | Active time filter (1h, 6h, 24h, 7d) | +| `view` | Preset view (global, us, mena) | +| `layers` | Comma-separated enabled layer IDs | + +Example: `?lat=38.9&lon=-77&zoom=6&layers=bases,conflicts,hotspots` + +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 +- **Pipelines**: 88 operating oil/gas pipelines across all continents +- **Undersea Cables**: 55 major submarine cable routes +- **Nuclear**: 100+ power plants, weapons labs, enrichment facilities +- **AI Infrastructure**: 111 major compute clusters (≥10k GPUs) +- **Strategic Waterways**: 8 critical chokepoints +- **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) +- **FAA**: Airport delays and ground stops +- **Cloudflare Radar**: Internet outage detection +- **AIS**: Real-time vessel positions +- **ACLED/GDELT**: Protest and unrest events +- **Yahoo Finance**: Stock quotes and indices +- **CoinGecko**: Cryptocurrency prices +- **FRED**: Federal Reserve economic data +- **Polymarket**: Prediction market odds + +## Data Attribution + +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 + +Original dashboard concept inspired by Reggie James ([@HipCityReg](https://x.com/HipCityReg/status/2009003048044220622)) - with thanks for the vision of a comprehensive situation awareness tool + +Special thanks to **Yanal at [Wingbits](https://wingbits.com)** for providing API access for aircraft enrichment data, enabling military aircraft classification and ownership tracking + +Thanks to **[@fai9al](https://github.com/fai9al)** for the inspiration and original PR that led to the Tech Monitor variant + +--- + +## Limitations & Caveats + +This project is a **proof of concept** demonstrating what's possible with publicly available data. While functional, there are important limitations: + +### 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.) + +The dashboard works with free tiers but may have gaps in coverage or update frequency. + +### 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 + +Terrestrial receivers only detect vessels within ~50km of shore. Satellite AIS (commercial) provides true global coverage but is not included in this free implementation. + +### 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 + +The system degrades gracefully—blocked sources are skipped while others continue functioning. + +--- + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for detailed planning. Recent intelligence enhancements: + +### Completed + +- ✅ **Focal Point Detection** - Intelligence synthesis correlating news entities with map signals +- ✅ **AI-Powered Briefings** - Groq/OpenRouter/Browser ML fallback chain for summarization +- ✅ **Military Surge Detection** - Alerts when multiple operators converge on regions +- ✅ **News-Signal Correlation** - Surge alerts include related focal point context +- ✅ **GDACS Integration** - UN disaster alert system for earthquakes, floods, cyclones, volcanoes +- ✅ **WebGL Map (deck.gl)** - High-performance rendering for desktop users +- ✅ **Browser ML Fallback** - ONNX Runtime for offline summarization capability +- ✅ **Multi-Signal Geographic Convergence** - Alerts when 3+ data types converge on same region within 24h +- ✅ **Country Instability Index (CII)** - Real-time composite risk score for 20 Tier-1 countries +- ✅ **Infrastructure Cascade Visualization** - Dependency graph showing downstream effects of disruptions +- ✅ **Strategic Risk Overview** - Unified alert system with cross-module correlation and deduplication +- ✅ **GDELT Topic Intelligence** - Categorized feeds for military, cyber, nuclear, and sanctions topics +- ✅ **OpenSky Authentication** - OAuth2 credentials for military aircraft tracking via relay +- ✅ **Human-Readable Locations** - Convergence alerts show place names instead of coordinates +- ✅ **Data Freshness Tracking** - Status panel shows enabled/disabled state for all feeds +- ✅ **CII Scoring Bias Prevention** - Log scaling and conflict zone floors prevent news volume bias +- ✅ **Alert Warmup Period** - Suppresses false positives on dashboard startup +- ✅ **Significant Protest Filtering** - Map shows only riots and high-severity protests +- ✅ **Intelligence Findings Detail Modal** - Click any alert for full context and component breakdown +- ✅ **Build-Time Version Sync** - Header version auto-syncs with package.json +- ✅ **Tech Monitor Variant** - Dedicated technology sector dashboard with startup ecosystems, cloud regions, and tech events +- ✅ **Smart Marker Clustering** - Geographic grouping of nearby markers with click-to-expand popups +- ✅ **Variant Switcher UI** - Compact orbital navigation between World Monitor and Tech Monitor +- ✅ **CII Learning Mode** - 15-minute calibration period with visual progress indicator +- ✅ **Regional Tech Coverage** - Verified tech HQ data for MENA, Europe, Asia-Pacific hubs +- ✅ **Service Status Panel** - External service health monitoring (AI providers, cloud platforms) +- ✅ **AI Strategic Posture Panel** - Theater-level force aggregation with strike capability assessment +- ✅ **Server-Side Risk Score API** - Pre-computed CII and strategic risk scores with Redis caching +- ✅ **Naval Vessel Classification** - Known vessel database with hull number matching and AIS type inference +- ✅ **Strike Capability Detection** - Assessment of offensive force packages (tankers + AWACS + fighters) +- ✅ **Theater Posture Thresholds** - Custom elevated/critical thresholds for each strategic theater + +### 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 +- **Think Tank Feeds** - RUSI, Chatham House, ECFR, CFR, Wilson Center, CNAS, Arms Control Association + +The full [ROADMAP.md](ROADMAP.md) documents implementation details, API endpoints, and 30+ free data sources for future integration. + +--- + +## Design Philosophy + +**Information density over aesthetics.** Every pixel should convey signal. The dark interface minimizes eye strain during extended monitoring sessions. Panels are collapsible, draggable, and hideable—customize to show only what matters. + +**Authority matters.** Not all sources are equal. Wire services and official government channels are prioritized over aggregators and blogs. When multiple sources report the same story, the most authoritative source is displayed as primary. + +**Correlation over accumulation.** Raw news feeds are noise. The value is in clustering related stories, detecting velocity changes, and identifying cross-source patterns. A single "Broadcom +2.5% explained by AI chip news" signal is more valuable than showing both data points separately. + +**Signal, not noise.** Deduplication is aggressive. The same market move doesn't generate repeated alerts. Signals include confidence scores so you can prioritize attention. Alert fatigue is the enemy of situational awareness. + +**Knowledge-first matching.** Simple keyword matching produces false positives. The entity knowledge base understands that AVGO is Broadcom, that Broadcom competes with Nvidia, and that both are in semiconductors. This semantic layer transforms naive string matching into intelligent correlation. + +**Fail gracefully.** External APIs are unreliable. Circuit breakers prevent cascading failures. Cached data displays during outages. The status panel shows exactly what's working and what isn't—no silent failures. + +**Local-first.** No accounts, no cloud sync. All preferences and history stored locally. The only network traffic is fetching public data. Your monitoring configuration is yours alone. + +**Compute where it matters.** CPU-intensive operations (clustering, correlation) run in Web Workers to keep the UI responsive. The main thread handles only rendering and user interaction. + +--- + +## System Architecture + +### Data Flow Overview + +``` + ┌─────────────────────────────────┐ + │ External Data Sources │ + │ RSS Feeds, APIs, WebSockets │ + └─────────────┬───────────────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ RSS Parser │ │ API Client │ │ WebSocket Hub │ + │ (News Feeds) │ │ (USGS, FAA...) │ │ (AIS, Markets) │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └──────────────────────┼──────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Circuit Breakers │ + │ (Rate Limiting, Retry Logic) │ + └─────────────┬───────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ Data Freshness │ │ Search Index │ │ Web Worker │ + │ Tracker │ │ (Searchables) │ │ (Clustering) │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ App State │ + │ (Map, Panels, Intelligence) │ + └─────────────┬───────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Rendering Pipeline │ + │ D3.js Map + React-like Panels │ + └─────────────────────────────────┘ +``` + +### Update Cycles + +Different data types refresh at different intervals based on volatility and API limits: + +| Data Type | Refresh Interval | Rationale | +|-----------|------------------|-----------| +| **News Feeds** | 3 minutes | Balance between freshness and API politeness | +| **Market Data** | 60 seconds | Real-time awareness with rate limit constraints | +| **Military Tracking** | 30 seconds | High-priority for situational awareness | +| **Weather Alerts** | 5 minutes | NWS update frequency | +| **Earthquakes** | 5 minutes | USGS update cadence | +| **Internet Outages** | 5 minutes | Cloudflare Radar update frequency | +| **AIS Vessels** | Real-time | WebSocket streaming | + +### Error Handling Strategy + +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 + +### Build-Time Optimization + +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 + +### 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) + +--- + +## Contributing + +Contributions are welcome! Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your help makes this project better. + +### Getting Started + +1. **Fork the repository** on GitHub +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR_USERNAME/worldmonitor.git + cd worldmonitor + ``` +3. **Install dependencies**: + ```bash + npm install + ``` +4. **Create a feature branch**: + ```bash + git checkout -b feature/your-feature-name + ``` +5. **Start the development server**: + ```bash + npm run dev + ``` + +### Code Style & Conventions + +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 + +### Submitting a Pull Request + +1. **Ensure your code builds**: + ```bash + npm run build + ``` + +2. **Test your changes** manually in the browser + +3. **Write a clear commit message**: + ``` + Add earthquake magnitude filtering to map layer + + - Adds slider control to filter by minimum magnitude + - Persists preference to localStorage + - Updates URL state for shareable links + ``` + +4. **Push to your fork**: + ```bash + git push origin feature/your-feature-name + ``` + +5. **Open a Pull Request** with: + - A clear title describing the change + - Description of what the PR does and why + - Screenshots for UI changes + - Any breaking changes or migration notes + +### What Makes a Good PR + +| Do | Don't | +|----|-------| +| Focus on one feature or fix | Bundle unrelated changes | +| Follow existing code patterns | Introduce new frameworks without discussion | +| Keep changes minimal and targeted | Refactor surrounding code unnecessarily | +| Update README if adding features | Add features without documentation | +| Test edge cases | Assume happy path only | + +### 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 + +### Review Process + +1. **Automated checks** run on PR submission +2. **Maintainer review** within a few days +3. **Feedback addressed** through commits to the same branch +4. **Merge** once approved + +PRs that don't follow the code style or introduce security issues will be asked to revise. + +### Development Tips + +**Adding a New Data Layer** + +1. Create service in `src/services/` for data fetching +2. Add layer toggle in `src/components/Map.ts` +3. Add rendering logic for map markers/overlays +4. Add to help panel documentation +5. Update README with layer description + +**Adding a New API Proxy** + +1. Create handler in `api/` directory +2. Implement input validation (see existing proxies) +3. Add appropriate cache headers +4. Document any required environment variables + +**Debugging** + +- Browser DevTools → Network tab for API issues +- Console logs prefixed with `[ServiceName]` for easy filtering +- Circuit breaker status visible in browser console + +--- + +## License + +MIT + +## Author + +**Elie Habib** + +--- + +*Built for situational awareness and open-source intelligence gathering.* diff --git a/docs/Docs_To_Review/EXTERNAL_APIS.md b/docs/Docs_To_Review/EXTERNAL_APIS.md new file mode 100644 index 000000000..a64d30f26 --- /dev/null +++ b/docs/Docs_To_Review/EXTERNAL_APIS.md @@ -0,0 +1,1046 @@ +# External APIs Catalog + +> Comprehensive reference for every external API consumed by World Monitor. +> Last updated: 2026-02-19 + +--- + +## Table of Contents + +- [1. Overview](#1-overview) +- [2. API Key Requirements](#2-api-key-requirements) +- [3. External APIs by Domain](#3-external-apis-by-domain) + - [3.1 Geopolitical Data](#31-geopolitical-data) + - [3.2 Markets & Finance](#32-markets--finance) + - [3.3 Military & Security](#33-military--security) + - [3.4 Natural Events](#34-natural-events) + - [3.5 AI / ML](#35-ai--ml) + - [3.6 Infrastructure & Status](#36-infrastructure--status) + - [3.7 Humanitarian](#37-humanitarian) + - [3.8 Content & Research](#38-content--research) +- [4. Dependency Chain Diagram](#4-dependency-chain-diagram) +- [5. Degradation Matrix](#5-degradation-matrix) +- [6. Cost & Tier Summary](#6-cost--tier-summary) +- [7. Environment Variable Quick Reference](#7-environment-variable-quick-reference) + +--- + +## 1. Overview + +World Monitor integrates **38 distinct external API sources** (plus ~150 RSS feed +domains) to provide a unified real-time intelligence dashboard across geopolitical, +financial, military, environmental, humanitarian, and technology domains. + +| Metric | Count | +|---|---| +| Total external APIs | 38 | +| Require API key (mandatory) | 10 | +| Require API key (optional) | 2 | +| Fully public / no auth | 26 | +| Free tier sufficient | 36 | +| Paid / commercial tier needed | 2 | +| WebSocket sources | 1 | +| RSS/Atom feed domains | ~150 | + +**Auth breakdown:** + +- **API key in header/query** — ACLED, Finnhub, FRED, Wingbits, AbuseIPDB, NASA FIRMS, Groq, OpenRouter, Cloudflare Radar, EIA +- **Optional API key** — GitHub, HDX HAPI +- **No authentication** — UCDP, GDELT, NGA MSI, Yahoo Finance, CoinGecko, Polymarket, alternative.me, blockchain.info, OpenSky, Feodo Tracker, URLhaus, C2IntelFeeds, AlienVault OTX, USGS, NOAA, Status Pages, FAA, UNHCR, WorldPop, World Bank, Hacker News, ArXiv, pizzint.watch, RSS feeds, Tech Events +- **URL-based auth** — Custom AIS Relay + +--- + +## 2. API Key Requirements + +| # | API Name | Env Var | Required | Signup URL | Tier Needed | +|---|---|---|---|---|---| +| 1 | ACLED | `ACLED_ACCESS_TOKEN`, `ACLED_EMAIL` | **Yes** | https://developer.acleddata.com/ | Free (researcher) | +| 2 | Finnhub | `FINNHUB_API_KEY` | **Yes** | https://finnhub.io/register | Free | +| 3 | FRED | `FRED_API_KEY` | **Yes** | https://fred.stlouisfed.org/docs/api/api_key.html | Free | +| 4 | NASA FIRMS | `NASA_FIRMS_API_KEY` | **Yes** | https://firms.modaps.eosdis.nasa.gov/api/area/ | Free (EOSDIS) | +| 5 | Groq | `GROQ_API_KEY` | **Yes** | https://console.groq.com/ | Free / Paid | +| 6 | OpenRouter | `OPENROUTER_API_KEY` | **Yes** | https://openrouter.ai/keys | Free (select models) | +| 7 | Cloudflare Radar | `CLOUDFLARE_API_TOKEN` | **Yes** | https://dash.cloudflare.com/profile/api-tokens | Enterprise | +| 8 | AbuseIPDB | `ABUSEIPDB_API_KEY` | **Yes** | https://www.abuseipdb.com/account/plans | Free (1000/day) | +| 9 | Wingbits | `WINGBITS_API_KEY` | **Yes** | https://wingbits.com/ | Commercial | +| 10 | EIA | `EIA_API_KEY` | **Yes** | https://www.eia.gov/opendata/register.php | Free | +| 11 | GitHub | `GITHUB_TOKEN` | Optional | https://github.com/settings/tokens | Free | +| 12 | HDX HAPI | `HDX_APP_IDENTIFIER` | Optional | https://hapi.humdata.org/ | Free | +| 13 | AIS Relay | `WS_RELAY_URL` | **Yes**¹ | Self-hosted | N/A | + +> ¹ The AIS relay is a self-hosted WebSocket server; the env var points to its URL +> rather than an API key. + +--- + +## 3. External APIs by Domain + +### 3.1 Geopolitical Data + +--- + +#### 1 — ACLED (Armed Conflict Location & Event Data) + +| Field | Value | +|---|---| +| **Base URL** | `https://api.acleddata.com/acled/read` | +| **Authentication** | Query params: `key` + `email` | +| **Env Vars** | `ACLED_ACCESS_TOKEN`, `ACLED_EMAIL` | +| **Rate Limits** | Unspecified; researcher tier has generous limits | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/acled`, `/api/acled-conflict` | +| **Frontend Services** | `ConflictService` → ConflictPanel, MapLayer | +| **Degradation** | Returns empty `data` array; conflict panels display "no data available" | +| **Tier Needed** | Free researcher account | +| **Quirks** | Requires both key *and* email as separate params. Data lags 1–2 weeks behind real-time events. Pagination via `page` param. | + +--- + +#### 2 — UCDP (Uppsala Conflict Data Program) + +| Field | Value | +|---|---| +| **Base URL** | `https://ucdpapi.pcr.uu.se/api/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | No documented limit | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/ucdp`, `/api/ucdp-events` | +| **Frontend Services** | `ConflictService` → ConflictPanel | +| **Degradation** | Cached data served with 24h TTL from Upstash/CDN | +| **Tier Needed** | Public | +| **Quirks** | Academic data source; updates less frequently than ACLED. Supports versioned datasets. | + +--- + +#### 3 — GDELT (Global Database of Events, Language, and Tone) + +| Field | Value | +|---|---| +| **Base URL** | `https://api.gdeltproject.org/api/v2/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public API; no documented limit but aggressive scraping will 429 | +| **Data Format** | JSON, GeoJSON, CSV (varies by sub-endpoint) | +| **WM Endpoints** | `/api/gdelt-doc`, `/api/gdelt-geo` | +| **Frontend Services** | `NewsService` → GdeltPanel, GeoHeatmap | +| **Degradation** | Upstream 502 passed through; panel shows error state | +| **Tier Needed** | Public | +| **Quirks** | `gdelt-doc` uses the DOC 2.0 API for full-text search; `gdelt-geo` uses the GEO 2.0 API for geographic heat-mapping. Large result sets can be slow. | + +--- + +#### 4 — NGA MSI (Maritime Safety Information) + +| Field | Value | +|---|---| +| **Base URL** | `https://msi.gs.mil/api/publications/broadcast-warn` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public US government endpoint | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/nga-warnings` | +| **Frontend Services** | `MilitaryService` → MaritimeWarningsPanel, MapLayer | +| **Degradation** | Passthrough 502 error; panel shows "service unavailable" | +| **Tier Needed** | Public | +| **Quirks** | US DoD-hosted; occasionally slow. Returns NAVAREA warnings, HYDROLANT/HYDROPAC notices. | + +--- + +### 3.2 Markets & Finance + +--- + +#### 5 — Finnhub + +| Field | Value | +|---|---| +| **Base URL** | `https://finnhub.io/api/v1/` | +| **Authentication** | Query param `token` or header `X-Finnhub-Token` | +| **Env Vars** | `FINNHUB_API_KEY` | +| **Rate Limits** | Free tier: 60 calls/min, 30 API calls/sec | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/finnhub`, `/api/etf-flows` | +| **Frontend Services** | `MarketService` → MarketPanel, ETFFlowsPanel | +| **Degradation** | Returns `unavailable` flag; MarketPanel shows stale cached data with timestamp | +| **Tier Needed** | Free | +| **Quirks** | WebSocket endpoint available but WM uses REST polling. ETF data requires specific symbol lookups. Free tier lacks some institutional data. | + +--- + +#### 6 — Yahoo Finance (Unofficial) + +| Field | Value | +|---|---| +| **Base URL** | `https://query1.finance.yahoo.com/v8/finance/chart/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Unofficial API; aggressive rate limiting possible; no SLA | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/yahoo-finance`, `/api/stock-index` | +| **Frontend Services** | `MarketService` → StockIndexPanel, MarketOverview | +| **Degradation** | CDN cache serves stale data; empty results on sustained outage | +| **Tier Needed** | Public (unofficial) | +| **Quirks** | **No official API** — this is an undocumented Yahoo endpoint. May break without notice. Crumb/cookie auth sometimes required by Yahoo; current implementation works without. Consider migrating to official alternative. | + +--- + +#### 7 — CoinGecko + +| Field | Value | +|---|---| +| **Base URL** | `https://api.coingecko.com/api/v3/` | +| **Authentication** | None (free tier); API key for Pro | +| **Env Vars** | — | +| **Rate Limits** | Free: 10–30 calls/min (varies) | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/coingecko`, `/api/stablecoin-markets`, `/api/macro-signals` | +| **Frontend Services** | `CryptoService` → CryptoPanel, StablecoinPanel; `MacroService` → MacroSignals | +| **Degradation** | Returns `unavailable` flag; panels show last-known values | +| **Tier Needed** | Free | +| **Quirks** | Rate limits fluctuate and are not well-documented. Stablecoin market-cap queries can be slow. Feeds into `macro-signals` as one of several composite inputs. | + +--- + +#### 8 — FRED (Federal Reserve Economic Data) + +| Field | Value | +|---|---| +| **Base URL** | `https://api.stlouisfed.org/fred/` | +| **Authentication** | Query param `api_key` | +| **Env Vars** | `FRED_API_KEY` | +| **Rate Limits** | 120 requests/min (free tier) | +| **Data Format** | JSON or XML (WM uses JSON via `file_type=json`) | +| **WM Endpoints** | `/api/fred-data`, `/api/macro-signals` | +| **Frontend Services** | `MacroService` → MacroSignals, EconIndicatorsPanel | +| **Degradation** | Cached data served from Upstash; stale indicator shown | +| **Tier Needed** | Free | +| **Quirks** | Series IDs must be known in advance (e.g. `DGS10`, `T10Y2Y`). Data updates on Fed schedule (not real-time). | + +--- + +#### 9 — Gamma (Polymarket) + +| Field | Value | +|---|---| +| **Base URL** | `https://gamma-api.polymarket.com/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public; no documented limit | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/polymarket` | +| **Frontend Services** | `PredictionService` → PolymarketPanel | +| **Degradation** | CDN cache serves stale prediction data | +| **Tier Needed** | Public | +| **Quirks** | Gamma is the off-chain API for Polymarket. Market slugs/IDs can change. Filterable by tag for geopolitical/election markets. | + +--- + +#### 10 — alternative.me (Fear & Greed Index) + +| Field | Value | +|---|---| +| **Base URL** | `https://api.alternative.me/fng/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public; lenient | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/macro-signals` (composite input) | +| **Frontend Services** | `MacroService` → MacroSignals (fear/greed gauge) | +| **Degradation** | Signal omitted from aggregate score; composite continues without it | +| **Tier Needed** | Public | +| **Quirks** | Returns crypto-specific Fear & Greed Index (0–100). Single-value endpoint; very lightweight. | + +--- + +#### 11 — blockchain.info (Bitcoin Hash Rate) + +| Field | Value | +|---|---| +| **Base URL** | `https://blockchain.info/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public; moderate (avoid rapid bursts) | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/macro-signals` (composite input) | +| **Frontend Services** | `MacroService` → MacroSignals | +| **Degradation** | Hash-rate signal omitted from aggregate; composite score adjusted | +| **Tier Needed** | Public | +| **Quirks** | Used specifically for BTC network hash-rate as a macro signal. Endpoint: `/q/hashrate`. | + +--- + +### 3.3 Military & Security + +--- + +#### 12 — OpenSky Network + +| Field | Value | +|---|---| +| **Base URL** | `https://opensky-network.org/api/` | +| **Authentication** | None (anonymous); optional Basic auth for higher limits | +| **Env Vars** | — | +| **Rate Limits** | Anonymous: 100 requests/day; authenticated: 4000/day | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/opensky`, `/api/theater-posture` | +| **Frontend Services** | `AviationService` → FlightTracker; `TheaterService` → TheaterPosture | +| **Degradation** | CDN cache serves stale snapshot; theater posture uses last-known aircraft positions | +| **Tier Needed** | Free (anonymous sufficient for current usage) | +| **Quirks** | State vectors update every ~10 seconds but WM polls less frequently. Returns all aircraft in bounding box. `theater-posture` uses OpenSky as one of multiple inputs. Anonymous rate limit is tight — caching is critical. | + +--- + +#### 13 — Wingbits + +| Field | Value | +|---|---| +| **Base URL** | `https://data.wingbits.com/` | +| **Authentication** | API key | +| **Env Vars** | `WINGBITS_API_KEY` | +| **Rate Limits** | Commercial agreement; undisclosed | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/wingbits/*`, `/api/theater-posture` | +| **Frontend Services** | `AviationService` → WingbitsPanel; `TheaterService` → TheaterPosture | +| **Degradation** | Theater posture proceeds without Wingbits augmentation; panel shows "source unavailable" | +| **Tier Needed** | **Commercial** (paid) | +| **Quirks** | Premium ADS-B data provider. Multiple sub-endpoints under `wingbits/`. Augments OpenSky with higher-fidelity data in specific regions. | + +--- + +#### 14 — Custom AIS Relay + +| Field | Value | +|---|---| +| **Base URL** | Configurable via `WS_RELAY_URL` | +| **Authentication** | URL-based (credentials in URL) | +| **Env Vars** | `WS_RELAY_URL` | +| **Rate Limits** | Self-hosted; depends on deployment | +| **Data Format** | JSON over WebSocket | +| **WM Endpoints** | `/api/ais-snapshot` | +| **Frontend Services** | `MaritimeService` → VesselTracker, MapLayer | +| **Degradation** | Returns empty vessel array; maritime layer shows no ship positions | +| **Tier Needed** | Self-hosted | +| **Quirks** | WebSocket relay run via `scripts/ais-relay.cjs`. Decodes AIS NMEA sentences into JSON. Snapshot endpoint aggregates latest positions from persistent WS connection. See `deploy/` for systemd service config. | + +--- + +#### 15 — Feodo Tracker (abuse.ch) + +| Field | Value | +|---|---| +| **Base URL** | `https://feodotracker.abuse.ch/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | CSV | +| **WM Endpoints** | `/api/cyber-threats` (aggregated source) | +| **Frontend Services** | `CyberService` → CyberThreatsPanel | +| **Degradation** | Source omitted from aggregation; other cyber sources still displayed | +| **Tier Needed** | Public | +| **Quirks** | Tracks C2 (command & control) botnet infrastructure. CSV parsed server-side. One of 5 cyber-threat sources aggregated by the endpoint. | + +--- + +#### 16 — URLhaus (abuse.ch) + +| Field | Value | +|---|---| +| **Base URL** | `https://urlhaus.abuse.ch/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | CSV | +| **WM Endpoints** | `/api/cyber-threats` (aggregated source) | +| **Frontend Services** | `CyberService` → CyberThreatsPanel | +| **Degradation** | Source omitted from aggregation | +| **Tier Needed** | Public | +| **Quirks** | Malicious URL database. Daily CSV dump downloaded and parsed. | + +--- + +#### 17 — C2IntelFeeds + +| Field | Value | +|---|---| +| **Base URL** | Public GitHub repository (CSV files) | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | GitHub raw content rate limits apply | +| **Data Format** | CSV | +| **WM Endpoints** | `/api/cyber-threats` (aggregated source) | +| **Frontend Services** | `CyberService` → CyberThreatsPanel | +| **Degradation** | Source omitted from aggregation | +| **Tier Needed** | Public | +| **Quirks** | Community-maintained C2 IP/domain feeds. Fetched from GitHub raw URLs. | + +--- + +#### 18 — AlienVault OTX + +| Field | Value | +|---|---| +| **Base URL** | `https://otx.alienvault.com/` | +| **Authentication** | None (public feed) | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/cyber-threats` (aggregated source) | +| **Frontend Services** | `CyberService` → CyberThreatsPanel | +| **Degradation** | Source omitted from aggregation | +| **Tier Needed** | Public | +| **Quirks** | Open Threat Exchange pulses. Used for IoC (indicators of compromise) enrichment. | + +--- + +#### 19 — AbuseIPDB + +| Field | Value | +|---|---| +| **Base URL** | `https://api.abuseipdb.com/api/v2/` | +| **Authentication** | Header: `Key` | +| **Env Vars** | `ABUSEIPDB_API_KEY` | +| **Rate Limits** | Free: 1000 checks/day; paid tiers higher | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/cyber-threats` (aggregated source) | +| **Frontend Services** | `CyberService` → CyberThreatsPanel | +| **Degradation** | Source omitted from aggregate; other 4 cyber sources still function | +| **Tier Needed** | Free (1000/day sufficient) | +| **Quirks** | IP reputation/abuse confidence scoring. Daily limit can be exhausted if scans are broad; WM uses targeted checks only. | + +--- + +### 3.4 Natural Events + +--- + +#### 20 — USGS Earthquake Hazards + +| Field | Value | +|---|---| +| **Base URL** | `https://earthquake.usgs.gov/earthquakes/feed/v1.0/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public; updated every 5 min by USGS | +| **Data Format** | GeoJSON | +| **WM Endpoints** | `/api/earthquakes` | +| **Frontend Services** | `SeismicService` → EarthquakePanel, MapLayer | +| **Degradation** | CDN cache serves stale GeoJSON; map shows last-known quakes | +| **Tier Needed** | Public | +| **Quirks** | Pre-built feeds by magnitude/time range (e.g. `all_day.geojson`, `significant_month.geojson`). No query API — just static feed URLs that USGS regenerates. | + +--- + +#### 21 — NASA FIRMS (Fire Information for Resource Management System) + +| Field | Value | +|---|---| +| **Base URL** | `https://firms.modaps.eosdis.nasa.gov/api/` | +| **Authentication** | Query param `MAP_KEY` | +| **Env Vars** | `NASA_FIRMS_API_KEY` | +| **Rate Limits** | Free tier; transaction-based limits | +| **Data Format** | CSV (parsed server-side to JSON) | +| **WM Endpoints** | `/api/firms-fires` | +| **Frontend Services** | `FireService` → WildfiresPanel, MapLayer | +| **Degradation** | Cached data served; empty array on sustained outage | +| **Tier Needed** | Free (EOSDIS Earthdata account) | +| **Quirks** | VIIRS and MODIS satellite data. Area/country queries. CSV rows can be very large for global queries — WM limits to specific regions or short time windows. | + +--- + +#### 22 — NOAA Climate Monitoring + +| Field | Value | +|---|---| +| **Base URL** | Various NOAA Climate Monitoring endpoints | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public US government | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/climate-anomalies` | +| **Frontend Services** | `ClimateService` → ClimateAnomaliesPanel | +| **Degradation** | Cached data served with 6h TTL | +| **Tier Needed** | Public | +| **Quirks** | Global temperature anomaly data. Monthly/annual resolution — not real-time. Multiple NOAA sub-endpoints aggregated. | + +--- + +### 3.5 AI / ML + +--- + +#### 23 — Groq + +| Field | Value | +|---|---| +| **Base URL** | `https://api.groq.com/openai/v1/` | +| **Authentication** | Bearer token in `Authorization` header | +| **Env Vars** | `GROQ_API_KEY` | +| **Rate Limits** | Free: varies by model (e.g. 30 req/min for Llama); paid: higher | +| **Data Format** | JSON (OpenAI-compatible chat completions) | +| **WM Endpoints** | `/api/groq-summarize`, `/api/classify-batch`, `/api/classify-event`, `/api/country-intel` | +| **Frontend Services** | `SummaryService`, `ClassificationService`, `CountryIntelService` | +| **Degradation** | Falls back to OpenRouter → browser-based Transformers.js pipeline | +| **Tier Needed** | Free / Paid (free sufficient for moderate usage) | +| **Quirks** | Primary LLM provider. OpenAI-compatible API. Ultra-fast inference via custom LPU hardware. Model selection configurable. Fallback chain: Groq → OpenRouter → Transformers.js (in-browser). | + +--- + +#### 24 — OpenRouter + +| Field | Value | +|---|---| +| **Base URL** | `https://openrouter.ai/api/v1/` | +| **Authentication** | Bearer token in `Authorization` header | +| **Env Vars** | `OPENROUTER_API_KEY` | +| **Rate Limits** | Varies by underlying model; free models have lower limits | +| **Data Format** | JSON (OpenAI-compatible chat completions) | +| **WM Endpoints** | `/api/openrouter-summarize` | +| **Frontend Services** | `SummaryService` (fallback from Groq) | +| **Degradation** | Falls back to browser-based Transformers.js | +| **Tier Needed** | Free (select models only) | +| **Quirks** | Aggregator routing to multiple LLM providers. Used as secondary/fallback LLM. Supports `HTTP-Referer` and `X-Title` headers for attribution. Specific free models (e.g. `mistralai/mistral-7b-instruct:free`) used to avoid cost. | + +--- + +### 3.6 Infrastructure & Status + +--- + +#### 25 — Cloudflare Radar + +| Field | Value | +|---|---| +| **Base URL** | `https://api.cloudflare.com/client/v4/radar/` | +| **Authentication** | Bearer token in `Authorization` header | +| **Env Vars** | `CLOUDFLARE_API_TOKEN` | +| **Rate Limits** | Enterprise API; generous limits | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/cloudflare-outages` | +| **Frontend Services** | `InfraService` → CloudflareOutagesPanel | +| **Degradation** | Returns empty outage list; panel shows "no active outages" (may be false negative) | +| **Tier Needed** | **Enterprise** (Radar API token required) | +| **Quirks** | Provides internet outage/anomaly detection globally. Requires Cloudflare account with Radar API access. Token needs `radar:read` permission. | + +--- + +#### 26 — Status Pages (33 Services) + +| Field | Value | +|---|---| +| **Base URL** | Various: `*.statuspage.io`, `status.*` domains | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public status pages | +| **Data Format** | JSON (Atlassian Statuspage API format) | +| **WM Endpoints** | `/api/service-status` | +| **Frontend Services** | `InfraService` → ServiceStatusPanel | +| **Degradation** | Individual services shown as "unknown" status; others continue | +| **Tier Needed** | Public | +| **Quirks** | Monitors 33 major services (AWS, Azure, GCP, GitHub, Cloudflare, Stripe, Twilio, etc.). Each status page polled independently. Circuit breaker per source. Atlassian Statuspage JSON format is standard across most targets. | + +**Monitored services include** (non-exhaustive): +AWS, Microsoft Azure, Google Cloud, GitHub, Cloudflare, Vercel, Netlify, +Fastly, Stripe, Twilio, Datadog, PagerDuty, Slack, Discord, Zoom, Atlassian, +HashiCorp, DigitalOcean, Heroku, MongoDB Atlas, Redis Cloud, Supabase, +OpenAI, Anthropic, and others. + +--- + +#### 27 — FAA ASWS (Airport Status Web Service) + +| Field | Value | +|---|---| +| **Base URL** | `https://soa.smext.faa.gov/asws/api/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public US government | +| **Data Format** | XML (parsed server-side) | +| **WM Endpoints** | `/api/faa-status` | +| **Frontend Services** | `AviationService` → FAAStatusPanel | +| **Degradation** | CDN cache serves stale data; panel shows last-known status | +| **Tier Needed** | Public | +| **Quirks** | Returns ground delays, ground stops, closures, and delay info per airport. XML response parsed to JSON. US airports only. | + +--- + +### 3.7 Humanitarian + +--- + +#### 28 — UNHCR Population API + +| Field | Value | +|---|---| +| **Base URL** | `https://api.unhcr.org/population/v1/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/unhcr-population` | +| **Frontend Services** | `HumanitarianService` → RefugeePanel | +| **Degradation** | Cached data served with 24h TTL | +| **Tier Needed** | Public | +| **Quirks** | Refugee and displaced population statistics. Annual data granularity. Large datasets; WM queries specific country/year combos. | + +--- + +#### 29 — HDX HAPI (Humanitarian API) + +| Field | Value | +|---|---| +| **Base URL** | `https://hapi.humdata.org/api/v2/` | +| **Authentication** | Optional `app_identifier` query param | +| **Env Vars** | `HDX_APP_IDENTIFIER` (optional) | +| **Rate Limits** | Public; higher limits with app identifier | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/hapi` | +| **Frontend Services** | `HumanitarianService` → HAPIPanel | +| **Degradation** | Cached data served with 6h TTL | +| **Tier Needed** | Free (identifier optional but recommended) | +| **Quirks** | OCHA's Humanitarian Data Exchange programmatic API. Covers food security, population, operational presence, etc. Without `app_identifier`, lower rate limits apply. | + +--- + +#### 30 — WorldPop + +| Field | Value | +|---|---| +| **Base URL** | WorldPop raster/API endpoints | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/worldpop-exposure` | +| **Frontend Services** | `HumanitarianService` → ExposureAnalysis | +| **Degradation** | Cached data served with 7-day TTL | +| **Tier Needed** | Public | +| **Quirks** | Population density data for exposure analysis (e.g. "how many people near this earthquake?"). Long cache TTL because population data changes slowly. | + +--- + +#### 31 — World Bank + +| Field | Value | +|---|---| +| **Base URL** | `https://api.worldbank.org/v2/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | JSON (via `format=json` param) | +| **WM Endpoints** | `/api/worldbank` | +| **Frontend Services** | `EconService` → WorldBankPanel, CountryProfile | +| **Degradation** | Cached data served with 24h TTL | +| **Tier Needed** | Public | +| **Quirks** | Development indicators (GDP, population, etc.). Pagination via `page`/`per_page`. Default format is XML — must specify `format=json`. Annual data; not real-time. | + +--- + +### 3.8 Content & Research + +--- + +#### 32 — Hacker News (Firebase API) + +| Field | Value | +|---|---| +| **Base URL** | `https://hacker-news.firebaseio.com/v0/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public Firebase endpoint; generous | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/hackernews` | +| **Frontend Services** | `TechService` → HackerNewsPanel | +| **Degradation** | CDN cache serves stale stories | +| **Tier Needed** | Public | +| **Quirks** | Official HN API via Firebase. Each story requires a separate fetch (by ID). WM fetches top N story IDs then batch-fetches details. | + +--- + +#### 33 — GitHub API + +| Field | Value | +|---|---| +| **Base URL** | `https://api.github.com/` | +| **Authentication** | Optional Bearer token | +| **Env Vars** | `GITHUB_TOKEN` (optional) | +| **Rate Limits** | Unauthenticated: 60/hour; Authenticated: 5000/hour | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/github-trending`, `/api/version`, `/api/download` | +| **Frontend Services** | `TechService` → GitHubTrendingPanel; `AppService` → VersionCheck | +| **Degradation** | HTML scrape fallback for trending; version check fails gracefully | +| **Tier Needed** | Free (token optional but recommended) | +| **Quirks** | Trending repos: no official API — WM uses search API with date filters as proxy, falls back to HTML scraping `github.com/trending`. Version endpoint checks latest release tag. Without `GITHUB_TOKEN`, 60 req/h can be exhausted quickly in development. | + +--- + +#### 34 — ArXiv + +| Field | Value | +|---|---| +| **Base URL** | `https://export.arxiv.org/api/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public; requests should be spaced ≥3 seconds apart | +| **Data Format** | XML (Atom feed) | +| **WM Endpoints** | `/api/arxiv` | +| **Frontend Services** | `ResearchService` → ArXivPanel | +| **Degradation** | CDN cache serves stale results | +| **Tier Needed** | Public | +| **Quirks** | Academic paper search. Atom XML parsed server-side. ArXiv requests that they be polite with rate (3s between requests). Search syntax uses specific field prefixes (`ti:`, `au:`, `cat:`). | + +--- + +#### 35 — EIA (Energy Information Administration) + +| Field | Value | +|---|---| +| **Base URL** | `https://api.eia.gov/v2/` | +| **Authentication** | Query param `api_key` | +| **Env Vars** | `EIA_API_KEY` | +| **Rate Limits** | Free tier; undisclosed limits | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/eia/*` (multiple sub-endpoints) | +| **Frontend Services** | `EnergyService` → EnergyPanel | +| **Degradation** | CDN cache serves stale data | +| **Tier Needed** | Free | +| **Quirks** | US energy data: petroleum, natural gas, electricity, coal. V2 API replaces legacy V1. Series IDs follow hierarchical facet structure. | + +--- + +#### 36 — pizzint.watch + +| Field | Value | +|---|---| +| **Base URL** | `https://pizzint.watch/` | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Public | +| **Data Format** | JSON | +| **WM Endpoints** | `/api/pizzint/*` (multiple sub-endpoints) | +| **Frontend Services** | `IntelService` → PizzintPanel | +| **Degradation** | CDN cache serves stale data | +| **Tier Needed** | Public | +| **Quirks** | OSINT aggregation platform. Multiple sub-endpoints proxied through WM edge functions. | + +--- + +#### 37 — RSS Feeds (~150 Domains) + +| Field | Value | +|---|---| +| **Base URL** | Various publisher domains | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Varies per publisher; typically lenient for RSS | +| **Data Format** | RSS 2.0 / Atom XML | +| **WM Endpoints** | `/api/rss-proxy` | +| **Frontend Services** | `NewsService` → RSSPanel, NewsFeed | +| **Degradation** | Circuit breaker per feed; 5-minute cooldown on failure; other feeds unaffected | +| **Tier Needed** | Public | +| **Quirks** | RSS proxy fetches and normalizes feeds from ~150 news sources worldwide. Per-feed circuit breaker prevents one broken feed from affecting others. 5-minute cooldown before retrying a failed feed. Feeds are categorized by region/topic. XML parsed and normalized to common JSON schema. | + +**Feed categories include**: Major wire services (AP, Reuters, AFP), regional news +(Al Jazeera, BBC, NHK, TASS), defense/security publications, financial news, +technology outlets, and specialized OSINT sources. + +--- + +#### 38 — Tech Event Sources + +| Field | Value | +|---|---| +| **Base URL** | Various scraped sources | +| **Authentication** | None | +| **Env Vars** | — | +| **Rate Limits** | Standard web scraping considerations | +| **Data Format** | HTML (scraped and parsed) | +| **WM Endpoints** | `/api/tech-events` | +| **Frontend Services** | `TechService` → TechEventsPanel | +| **Degradation** | Cached data served with 6h TTL | +| **Tier Needed** | Public | +| **Quirks** | Scrapes conference/event listings from multiple sources. HTML parsing; fragile if source layouts change. Long cache TTL to reduce scrape frequency. | + +--- + +## 4. Dependency Chain Diagram + +### 4.1 Overall Architecture + +```mermaid +graph LR + subgraph External APIs + A1[ACLED] + A2[UCDP] + A3[GDELT] + A4[Finnhub] + A5[CoinGecko] + A6[USGS] + A7[Groq] + A8[OpenRouter] + A9[Status Pages] + A10[RSS Feeds] + A11[Others...] + end + + subgraph Vercel Edge Functions + E1[/api/acled] + E2[/api/ucdp] + E3[/api/gdelt-doc] + E4[/api/finnhub] + E5[/api/coingecko] + E6[/api/earthquakes] + E7[/api/groq-summarize] + E8[/api/openrouter-summarize] + E9[/api/service-status] + E10[/api/rss-proxy] + end + + subgraph Caching Layer + C1[Upstash Redis] + C2[Vercel CDN / Edge Cache] + end + + subgraph Frontend Services + S1[ConflictService] + S2[MarketService] + S3[SeismicService] + S4[SummaryService] + S5[InfraService] + S6[NewsService] + end + + subgraph UI Components + U1[ConflictPanel] + U2[MarketPanel] + U3[EarthquakePanel] + U4[SummaryCards] + U5[ServiceStatusPanel] + U6[NewsFeed] + end + + A1 --> E1 + A2 --> E2 + A3 --> E3 + A4 --> E4 + A5 --> E5 + A6 --> E6 + A7 --> E7 + A8 --> E8 + A9 --> E9 + A10 --> E10 + + E1 --> C1 --> S1 + E2 --> C1 --> S1 + E3 --> C2 --> S6 + E4 --> C2 --> S2 + E5 --> C2 --> S2 + E6 --> C2 --> S3 + E7 --> C2 --> S4 + E8 --> C2 --> S4 + E9 --> C2 --> S5 + E10 --> C2 --> S6 + + S1 --> U1 + S2 --> U2 + S3 --> U3 + S4 --> U4 + S5 --> U5 + S6 --> U6 +``` + +### 4.2 AI/LLM Fallback Chain + +```mermaid +graph TD + A[Text to Summarize / Classify] --> B{Groq Available?} + B -->|Yes| C[Groq API
GROQ_API_KEY] + B -->|No / Error| D{OpenRouter Available?} + D -->|Yes| E[OpenRouter API
OPENROUTER_API_KEY] + D -->|No / Error| F[Browser Transformers.js
No API key needed] + C --> G[Result] + E --> G + F --> G +``` + +### 4.3 Cyber Threats Aggregation + +```mermaid +graph TD + F1[Feodo Tracker
CSV] --> AGG[/api/cyber-threats
Aggregator] + F2[URLhaus
CSV] --> AGG + F3[C2IntelFeeds
CSV] --> AGG + F4[AlienVault OTX
JSON] --> AGG + F5[AbuseIPDB
JSON + API Key] --> AGG + AGG --> CS[CyberService] + CS --> CP[CyberThreatsPanel] + + style F5 fill:#ff9,stroke:#333 +``` + +### 4.4 Macro Signals Composite + +```mermaid +graph TD + M1[FRED
Treasury yields, rates] --> MS[/api/macro-signals
Composite Builder] + M2[CoinGecko
Crypto market cap] --> MS + M3[alternative.me
Fear & Greed] --> MS + M4[blockchain.info
BTC hash rate] --> MS + MS --> MSvc[MacroService] + MSvc --> MP[MacroSignalsPanel] +``` + +### 4.5 Theater Posture Composite + +```mermaid +graph TD + T1[OpenSky
Aircraft positions] --> TP[/api/theater-posture
Analysis] + T2[Wingbits
ADS-B premium] --> TP + T3[AIS Relay
Vessel positions] --> TP + T4[ACLED
Conflict events] --> TP + TP --> TS[TheaterService] + TS --> TPnl[TheaterPosturePanel] + + style T2 fill:#ff9,stroke:#333 +``` + +--- + +## 5. Degradation Matrix + +How World Monitor behaves when each external API is unavailable: + +| # | API | Cache TTL | Behavior When Down | User Impact | Severity | +|---|---|---|---|---|---| +| 1 | ACLED | — | Empty data array | Conflict panels blank | **High** | +| 2 | UCDP | 24h | Stale cached data | Data may be outdated | Low | +| 3 | GDELT | — | 502 passthrough | Panel shows error | Medium | +| 4 | NGA MSI | — | 502 passthrough | Maritime warnings blank | Medium | +| 5 | Finnhub | CDN | `unavailable` flag, stale data | Market data delayed | Medium | +| 6 | Yahoo Finance | CDN | Stale CDN / empty results | Stock data delayed or missing | Medium | +| 7 | CoinGecko | CDN | `unavailable` flag | Crypto data delayed | Low | +| 8 | FRED | Upstash | Stale cached data | Macro indicators delayed | Low | +| 9 | Polymarket | CDN | Stale predictions | Predictions outdated | Low | +| 10 | alternative.me | — | Signal omitted | Macro score slightly less accurate | Minimal | +| 11 | blockchain.info | — | Signal omitted | Macro score slightly less accurate | Minimal | +| 12 | OpenSky | CDN | Stale aircraft data | Flight positions outdated | Medium | +| 13 | Wingbits | — | Proceeds without augmentation | Lower-fidelity ADS-B in some regions | Low | +| 14 | AIS Relay | — | Empty vessel array | No ship tracking | **High** | +| 15 | Feodo Tracker | — | Source omitted | Cyber panel partial | Minimal | +| 16 | URLhaus | — | Source omitted | Cyber panel partial | Minimal | +| 17 | C2IntelFeeds | — | Source omitted | Cyber panel partial | Minimal | +| 18 | AlienVault OTX | — | Source omitted | Cyber panel partial | Minimal | +| 19 | AbuseIPDB | — | Source omitted | Cyber panel partial | Minimal | +| 20 | USGS | CDN | Stale GeoJSON | Earthquake data delayed | Low | +| 21 | NASA FIRMS | CDN | Cached / empty | Fire data delayed or missing | Medium | +| 22 | NOAA | 6h | Stale cached data | Climate data delayed | Low | +| 23 | Groq | — | Fallback → OpenRouter → Transformers.js | Summarization slower | Medium | +| 24 | OpenRouter | — | Fallback → Transformers.js | Summarization slower, lower quality | Medium | +| 25 | Cloudflare Radar | — | Empty outage list | May miss internet outages | Medium | +| 26 | Status Pages | — | Individual → "unknown" | Partial service status | Low | +| 27 | FAA ASWS | CDN | Stale airport status | Airport delays outdated | Low | +| 28 | UNHCR | 24h | Stale cached data | Refugee data delayed | Low | +| 29 | HDX HAPI | 6h | Stale cached data | Humanitarian data delayed | Low | +| 30 | WorldPop | 7d | Stale cached data | Population estimates unchanged | Minimal | +| 31 | World Bank | 24h | Stale cached data | Development indicators delayed | Low | +| 32 | Hacker News | CDN | Stale stories | HN feed outdated | Minimal | +| 33 | GitHub | — | HTML scrape fallback / graceful fail | Trending may fail; version check skipped | Low | +| 34 | ArXiv | CDN | Stale search results | Research papers outdated | Minimal | +| 35 | EIA | CDN | Stale energy data | Energy metrics delayed | Low | +| 36 | pizzint.watch | CDN | Stale intel data | OSINT data delayed | Low | +| 37 | RSS Feeds | 5m CB | Circuit breaker per feed | Individual feeds drop; others continue | Low | +| 38 | Tech Events | 6h | Stale cached data | Event listings outdated | Minimal | + +**Severity legend:** + +- **High** — Core functionality lost, no fallback +- **Medium** — Noticeable degradation, partial data or delayed experience +- **Low** — Minor impact, cached data fills the gap +- **Minimal** — Barely noticeable; one signal among many omitted + +--- + +## 6. Cost & Tier Summary + +### Free APIs (No Payment Required) — 36 APIs + +| Category | APIs | +|---|---| +| Fully public (no key) | UCDP, GDELT, NGA MSI, Yahoo Finance, CoinGecko, Polymarket, alternative.me, blockchain.info, OpenSky, Feodo Tracker, URLhaus, C2IntelFeeds, AlienVault OTX, USGS, NOAA, Status Pages, FAA, UNHCR, WorldPop, World Bank, Hacker News, ArXiv, pizzint.watch, RSS Feeds, Tech Events | +| Free key required | ACLED (researcher), Finnhub, FRED, NASA FIRMS, AbuseIPDB, Groq (free tier), EIA | +| Free key optional | GitHub, HDX HAPI | +| Free with model limits | OpenRouter (select free models) | + +### Paid / Commercial APIs — 2 APIs + +| API | Cost Model | Why Paid | +|---|---|---| +| **Wingbits** | Commercial subscription | Premium ADS-B data with higher fidelity | +| **Cloudflare Radar** | Enterprise (included with CF plan) | Radar API requires enterprise-level API token | + +### Monthly Cost Estimate + +| Item | Estimated Cost | +|---|---| +| Wingbits commercial tier | Varies (contact vendor) | +| Cloudflare Radar | Included with Enterprise plan | +| All other APIs | **$0** (free tiers) | +| Upstash Redis (caching) | Free tier / ~$10/mo for production | +| Vercel (hosting + edge functions) | Free tier / Pro ~$20/mo | + +> **Total API cost for free-tier operation: $0/month** (excluding Wingbits and +> Cloudflare Enterprise, which are optional enhancements). + +--- + +## 7. Environment Variable Quick Reference + +All environment variables needed for full API coverage: + +```env +# ── Geopolitical ────────────────────────────────────── +ACLED_ACCESS_TOKEN= # ACLED API key (researcher account) +ACLED_EMAIL= # ACLED registered email + +# ── Markets & Finance ──────────────────────────────── +FINNHUB_API_KEY= # Finnhub stock/ETF data +FRED_API_KEY= # Federal Reserve Economic Data + +# ── Military & Security ────────────────────────────── +WINGBITS_API_KEY= # Wingbits ADS-B (commercial) +WS_RELAY_URL= # AIS WebSocket relay URL +ABUSEIPDB_API_KEY= # AbuseIPDB threat intel + +# ── Natural Events ─────────────────────────────────── +NASA_FIRMS_API_KEY= # NASA FIRMS fire data + +# ── AI / ML ────────────────────────────────────────── +GROQ_API_KEY= # Groq LLM inference (primary) +OPENROUTER_API_KEY= # OpenRouter LLM (fallback) + +# ── Infrastructure ─────────────────────────────────── +CLOUDFLARE_API_TOKEN= # Cloudflare Radar (enterprise) + +# ── Content ────────────────────────────────────────── +GITHUB_TOKEN= # GitHub API (optional, higher rate limit) +HDX_APP_IDENTIFIER= # HDX HAPI (optional, higher rate limit) +EIA_API_KEY= # Energy Information Administration + +# ── Caching (infrastructure, not external API) ────── +UPSTASH_REDIS_REST_URL= # Upstash Redis cache URL +UPSTASH_REDIS_REST_TOKEN= # Upstash Redis cache token +``` + +**Minimum viable setup** (core features work): `ACLED_ACCESS_TOKEN`, `ACLED_EMAIL`, +`FINNHUB_API_KEY`, `GROQ_API_KEY` + +**Recommended setup** (all free features): All env vars above except `WINGBITS_API_KEY` +and `CLOUDFLARE_API_TOKEN` + +**Full setup** (all features): All env vars populated + +--- + +*This document is auto-referenced from [todo_docs.md](todo_docs.md) §3.2. +See also: [ARCHITECTURE.md](ARCHITECTURE.md), [DATA_MODEL.md](DATA_MODEL.md)* diff --git a/docs/Docs_To_Review/NEWS_TRANSLATION_ANALYSIS.md b/docs/Docs_To_Review/NEWS_TRANSLATION_ANALYSIS.md new file mode 100644 index 000000000..5444d8630 --- /dev/null +++ b/docs/Docs_To_Review/NEWS_TRANSLATION_ANALYSIS.md @@ -0,0 +1,60 @@ +# 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`. diff --git a/docs/Docs_To_Review/PANELS.md b/docs/Docs_To_Review/PANELS.md new file mode 100644 index 000000000..3e3c06884 --- /dev/null +++ b/docs/Docs_To_Review/PANELS.md @@ -0,0 +1,932 @@ +# Panel System Documentation + +> **World Monitor** — Config-driven panel architecture powering three site variants. +> +> Source of truth: [`src/config/panels.ts`](../src/config/panels.ts) · Panel base class: [`src/components/Panel.ts`](../src/components/Panel.ts) · App wiring: [`src/App.ts`](../src/App.ts) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Panel Configuration](#2-panel-configuration) +3. [Panel Base Class](#3-panel-base-class) +4. [Full Variant Panels](#4-full-variant-panels-worldmonitorio) +5. [Tech Variant Panels](#5-tech-variant-panels-techworldmonitorio) +6. [Finance Variant Panels](#6-finance-variant-panels-financeworldmonitorio) +7. [Variant Comparison Matrix](#7-variant-comparison-matrix) +8. [Map Layers](#8-map-layers) +9. [Panel Persistence](#9-panel-persistence) +10. [Panel Lifecycle](#10-panel-lifecycle) +11. [Adding a New Panel](#11-adding-a-new-panel) +12. [Diagrams](#12-diagrams) + +--- + +## 1. Overview + +World Monitor uses a **config-driven panel system** where every dashboard tile — from live news feeds to AI insights to market data — is declared as a `PanelConfig` entry inside a variant-specific configuration object. The system is designed around three principles: + +1. **Variant isolation** — Each site variant (`full`, `tech`, `finance`) declares its own panel set with variant-appropriate display names and priorities. The build-time environment variable `VITE_VARIANT` selects which set is exported. +2. **User customization** — Users can toggle panel visibility, reorder panels via drag-and-drop, and resize panels via a drag handle. All preferences persist to `localStorage`. +3. **No framework** — Panels are vanilla TypeScript classes extending a shared `Panel` base class. There is no React/Vue/Angular; DOM construction and updates are imperative. + +### Variant Domains + +| Variant | Domain | Focus | Panel Count | +|---------|--------|-------|-------------| +| `full` | worldmonitor.io | Geopolitical intelligence, OSINT, defense | 37 | +| `tech` | tech.worldmonitor.io | Technology, AI/ML, startups, VC | 34 | +| `finance` | finance.worldmonitor.io | Markets, trading, macro, commodities | 29 | + +### Key Files + +| File | Purpose | +|------|---------| +| [`src/config/panels.ts`](../src/config/panels.ts) | Central panel & map-layer definitions for all three variants | +| [`src/config/variants/base.ts`](../src/config/variants/base.ts) | Shared `VariantConfig` interface, `STORAGE_KEYS`, `MONITOR_COLORS`, API URLs | +| [`src/config/variants/full.ts`](../src/config/variants/full.ts) | Full variant config with panels, layers, and feeds | +| [`src/config/variants/tech.ts`](../src/config/variants/tech.ts) | Tech variant config | +| [`src/config/variants/finance.ts`](../src/config/variants/finance.ts) | Finance variant config | +| [`src/components/Panel.ts`](../src/components/Panel.ts) | Base `Panel` class (440 lines) — DOM, resize, badges, lifecycle | +| [`src/components/NewsPanel.ts`](../src/components/NewsPanel.ts) | `NewsPanel` extending `Panel` — RSS-driven news tiles | +| [`src/App.ts`](../src/App.ts) | Application shell — panel instantiation, data loading, settings modal | +| [`src/types/index.ts`](../src/types/index.ts) | `PanelConfig`, `MapLayers`, `AppState` type definitions | + +--- + +## 2. Panel Configuration + +### 2.1 PanelConfig Type + +Every panel is described by a `PanelConfig` object defined in [`src/types/index.ts`](../src/types/index.ts): + +```typescript +export interface PanelConfig { + name: string; // Display name shown in panel header and settings modal + enabled: boolean; // Whether the panel is visible (toggled by user) + priority?: number; // 1 = core (shown early), 2 = supplementary (shown later) +} +``` + +- **`name`** — Variant-specific. The same panel ID (e.g. `map`) can have different display names across variants: *"Global Map"* (full), *"Global Tech Map"* (tech), *"Global Markets Map"* (finance). +- **`enabled`** — Defaults to `true` for all shipped panels. Users can disable panels via the Settings modal; the toggled state is persisted. +- **`priority`** — Informational grouping. Priority 1 panels are considered core to the variant's mission; priority 2 panels are supplementary. This does not affect rendering order—order is determined by declaration order in the config object and user drag-and-drop overrides. + +### 2.2 Variant Selection + +Panel sets are selected at build time via the `SITE_VARIANT` constant (derived from `VITE_VARIANT` env var): + +```typescript +// src/config/panels.ts — variant-aware exports +export const DEFAULT_PANELS = + SITE_VARIANT === 'tech' ? TECH_PANELS : + SITE_VARIANT === 'finance' ? FINANCE_PANELS : + FULL_PANELS; + +export const DEFAULT_MAP_LAYERS = + SITE_VARIANT === 'tech' ? TECH_MAP_LAYERS : + SITE_VARIANT === 'finance' ? FINANCE_MAP_LAYERS : + FULL_MAP_LAYERS; + +export const MOBILE_DEFAULT_MAP_LAYERS = + SITE_VARIANT === 'tech' ? TECH_MOBILE_MAP_LAYERS : + SITE_VARIANT === 'finance' ? FINANCE_MOBILE_MAP_LAYERS : + FULL_MOBILE_MAP_LAYERS; +``` + +Vite tree-shakes the unused variant objects from the production bundle. + +### 2.3 VariantConfig Interface + +Each variant file exports a full `VariantConfig` object: + +```typescript +// src/config/variants/base.ts +export interface VariantConfig { + name: string; // 'full' | 'tech' | 'finance' + description: string; // Human-readable variant description + panels: Record; // Panel ID → config + mapLayers: MapLayers; // Desktop default layer toggles + mobileMapLayers: MapLayers; // Mobile default layer toggles +} +``` + +The variant config also defines its own `FEEDS` object (`Record`) mapping panel IDs to their RSS feed sources. Feeds that don't have a registered panel ID result in auto-generated `NewsPanel` instances (see [Panel Lifecycle](#10-panel-lifecycle)). + +### 2.4 Storage Keys + +All persistence keys are centralized in `STORAGE_KEYS`: + +```typescript +// src/config/variants/base.ts (also re-exported from src/config/panels.ts) +export const STORAGE_KEYS = { + panels: 'worldmonitor-panels', // Panel visibility toggles + monitors: 'worldmonitor-monitors', // Monitor keyword configs + mapLayers: 'worldmonitor-layers', // Map layer toggles + disabledFeeds: 'worldmonitor-disabled-feeds', // Per-source feed disabling +} as const; +``` + +Additional keys used outside `STORAGE_KEYS`: + +| Key | Purpose | Managed By | +|-----|---------|------------| +| `worldmonitor-panel-spans` | Panel height/span sizes (1–4) | `Panel.ts` | +| `panel-order` | Drag-and-drop panel ordering | `App.ts` | +| `worldmonitor-variant` | Last-active variant (triggers reset on change) | `App.ts` | +| `worldmonitor-panel-order-v1.9` | Migration flag for v1.9 panel layout | `App.ts` | + +### 2.5 Monitor Colors + +The monitor palette provides 10 fixed category colors used for user-defined keyword monitors: + +```typescript +export const MONITOR_COLORS = [ + '#44ff88', '#ff8844', '#4488ff', '#ff44ff', '#ffff44', + '#ff4444', '#44ffff', '#88ff44', '#ff88ff', '#88ffff', +]; +``` + +These colors are theme-independent and persist alongside monitor definitions in `localStorage`. + +--- + +## 3. Panel Base Class + +All panels extend the `Panel` class defined in [`src/components/Panel.ts`](../src/components/Panel.ts) (440 lines). This base class provides the shared DOM structure, interaction patterns, and lifecycle methods. + +### 3.1 Constructor Options + +```typescript +export interface PanelOptions { + id: string; // Unique panel identifier (matches config key) + title: string; // Display name rendered in header + showCount?: boolean; // Show item count badge in header + className?: string; // Additional CSS class on root element + trackActivity?: boolean; // Enable "new items" badge (default: true) + infoTooltip?: string; // HTML content for methodology tooltip (ℹ️ button) +} +``` + +### 3.2 DOM Structure + +Every panel renders the following DOM tree: + +``` +div.panel[data-panel="{id}"] +├── div.panel-header +│ ├── div.panel-header-left +│ │ ├── span.panel-title ← Display name +│ │ ├── div.panel-info-wrapper ← (optional) ℹ️ tooltip +│ │ └── span.panel-new-badge ← (optional) "N new" badge +│ ├── span.panel-data-badge ← live/cached/unavailable indicator +│ └── span.panel-count ← (optional) item count +├── div.panel-content#${id}Content ← Main content area +└── div.panel-resize-handle ← Drag-to-resize handle +``` + +### 3.3 Features + +| Feature | Description | +|---------|-------------| +| **Drag-to-resize** | Bottom handle supports mouse + touch. Height maps to span classes (`span-1` through `span-4`). Double-click resets to default. | +| **Collapsible** | Click header to toggle `hidden` class on content (handled by CSS). | +| **Loading state** | `showLoading(message?)` renders a radar sweep animation with text. Shown by default on construction. | +| **Error state** | `showError(message?)` renders error text. `showConfigError(message)` adds a "Open Settings" button (Tauri desktop). | +| **Data badge** | `setDataBadge(state, detail?)` shows `live`, `cached`, or `unavailable` with optional detail text. | +| **New badge** | `setNewBadge(count, pulse?)` shows a blue dot with count in the header. Pulses for important updates. | +| **Count badge** | `setCount(n)` updates the numeric count in the header (when `showCount` is enabled). | +| **Info tooltip** | Hover/click on ℹ️ icon shows methodology explanation. Dismissed on outside click. | +| **Throttled content** | `setContentThrottled(html)` buffers DOM writes to one per animation frame (PERF-009). | +| **Header error state** | `setErrorState(hasError, tooltip?)` toggles a red header accent for degraded panels. | + +### 3.4 Span System (Sizing) + +Panel height is quantized into 4 span levels: + +| Span | Min Height | CSS Class | Description | +|------|-----------|-----------|-------------| +| 1 | default | `span-1` | Standard single-row height | +| 2 | 250px | `span-2` | Medium — 50px drag triggers | +| 3 | 350px | `span-3` | Large — 150px drag triggers | +| 4 | 500px | `span-4` | Extra-large — 300px drag triggers | + +Span values are persisted per-panel in the `worldmonitor-panel-spans` localStorage key as a JSON object `{ [panelId]: spanNumber }`. + +### 3.5 Public Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getElement()` | `(): HTMLElement` | Returns the root DOM element | +| `show()` | `(): void` | Remove `hidden` class | +| `hide()` | `(): void` | Add `hidden` class | +| `toggle(visible)` | `(boolean): void` | Show or hide | +| `showLoading(msg?)` | `(string?): void` | Render loading spinner | +| `showError(msg?)` | `(string?): void` | Render error message | +| `showConfigError(msg)` | `(string): void` | Render config error with settings button | +| `setContent(html)` | `(string): void` | Set content innerHTML directly | +| `setContentThrottled(html)` | `(string): void` | Buffered content update (rAF) | +| `setCount(n)` | `(number): void` | Update count badge | +| `setNewBadge(count, pulse?)` | `(number, boolean?): void` | Update new-items badge | +| `clearNewBadge()` | `(): void` | Hide new badge | +| `setDataBadge(state, detail?)` | `(string, string?): void` | Update data freshness badge | +| `clearDataBadge()` | `(): void` | Hide data badge | +| `setErrorState(err, tip?)` | `(boolean, string?): void` | Toggle header error styling | +| `getId()` | `(): string` | Return panel ID | +| `resetHeight()` | `(): void` | Clear saved span, remove span classes | +| `destroy()` | `(): void` | Remove all event listeners | + +--- + +## 4. Full Variant Panels (worldmonitor.io) + +The full (geopolitical) variant ships **37 panels** focused on OSINT, defense intelligence, geopolitical risk, and global situational awareness. + +| # | Panel ID | Display Name | Priority | Component Class | Data Source | +|---|----------|-------------|----------|-----------------|-------------| +| 1 | `map` | Global Map | 1 | `MapContainer` | MapLibre + deck.gl | +| 2 | `live-news` | Live News | 1 | `LiveNewsPanel` | Multi-source RSS aggregation | +| 3 | `live-webcams` | Live Webcams | 1 | `LiveWebcamsPanel` | Curated webcam streams | +| 4 | `insights` | AI Insights | 1 | `InsightsPanel` | Groq/OpenRouter LLM summarization | +| 5 | `strategic-posture` | AI Strategic Posture | 1 | `StrategicPosturePanel` | Theater posture API | +| 6 | `cii` | Country Instability | 1 | `CIIPanel` | Composite instability index | +| 7 | `strategic-risk` | Strategic Risk Overview | 1 | `StrategicRiskPanel` | Risk scores API | +| 8 | `intel` | Intel Feed | 1 | `NewsPanel` | Intelligence RSS feeds | +| 9 | `gdelt-intel` | Live Intelligence | 1 | `GdeltIntelPanel` | GDELT event database | +| 10 | `cascade` | Infrastructure Cascade | 1 | `CascadePanel` | Multi-domain cascade analysis | +| 11 | `politics` | World News | 1 | `NewsPanel` | Political RSS feeds | +| 12 | `middleeast` | Middle East | 1 | `NewsPanel` | Regional RSS feeds | +| 13 | `africa` | Africa | 1 | `NewsPanel` | Regional RSS feeds | +| 14 | `latam` | Latin America | 1 | `NewsPanel` | Regional RSS feeds | +| 15 | `asia` | Asia-Pacific | 1 | `NewsPanel` | Regional RSS feeds | +| 16 | `energy` | Energy & Resources | 1 | `NewsPanel` | Energy RSS feeds | +| 17 | `gov` | Government | 1 | `NewsPanel` | Government RSS feeds | +| 18 | `thinktanks` | Think Tanks | 1 | `NewsPanel` | Think tank RSS feeds | +| 19 | `polymarket` | Predictions | 1 | `PredictionPanel` | Polymarket API | +| 20 | `commodities` | Commodities | 1 | `CommoditiesPanel` | Yahoo Finance / commodity APIs | +| 21 | `markets` | Markets | 1 | `MarketPanel` | Finnhub / Yahoo Finance | +| 22 | `economic` | Economic Indicators | 1 | `EconomicPanel` | FRED API | +| 23 | `finance` | Financial | 1 | `NewsPanel` | Financial RSS feeds | +| 24 | `tech` | Technology | 2 | `NewsPanel` | Technology RSS feeds | +| 25 | `crypto` | Crypto | 2 | `CryptoPanel` | CoinGecko API | +| 26 | `heatmap` | Sector Heatmap | 2 | `HeatmapPanel` | Market sector data | +| 27 | `ai` | AI/ML | 2 | `NewsPanel` | AI/ML RSS feeds | +| 28 | `layoffs` | Layoffs Tracker | 2 | `NewsPanel` | Layoffs RSS feeds | +| 29 | `monitors` | My Monitors | 2 | `MonitorPanel` | User-defined keyword monitors | +| 30 | `satellite-fires` | Fires | 2 | `SatelliteFiresPanel` | NASA FIRMS API | +| 31 | `macro-signals` | Market Radar | 2 | `MacroSignalsPanel` | Macro signals API | +| 32 | `etf-flows` | BTC ETF Tracker | 2 | `ETFFlowsPanel` | ETF flows API | +| 33 | `stablecoins` | Stablecoins | 2 | `StablecoinPanel` | Stablecoin markets API | +| 34 | `ucdp-events` | UCDP Conflict Events | 2 | `UcdpEventsPanel` | UCDP API | +| 35 | `displacement` | UNHCR Displacement | 2 | `DisplacementPanel` | UNHCR population API | +| 36 | `climate` | Climate Anomalies | 2 | `ClimateAnomalyPanel` | Climate anomalies API | +| 37 | `population-exposure` | Population Exposure | 2 | `PopulationExposurePanel` | WorldPop exposure API | + +**Full variant exclusive panels**: `strategic-posture`, `cii`, `strategic-risk`, `gdelt-intel`, `cascade`, `satellite-fires`, `ucdp-events`, `displacement`, `climate`, `population-exposure`, and the regional panels (`middleeast`, `africa`, `latam`, `asia`). + +--- + +## 5. Tech Variant Panels (tech.worldmonitor.io) + +The tech variant ships **34 panels** focused on technology news, AI/ML, startup ecosystems, and developer tooling. + +| # | Panel ID | Display Name | Priority | Component Class | Data Source | +|---|----------|-------------|----------|-----------------|-------------| +| 1 | `map` | Global Tech Map | 1 | `MapContainer` | MapLibre + deck.gl | +| 2 | `live-news` | Tech Headlines | 1 | `LiveNewsPanel` | Tech RSS aggregation | +| 3 | `live-webcams` | Live Webcams | 2 | `LiveWebcamsPanel` | Curated webcam streams | +| 4 | `insights` | AI Insights | 1 | `InsightsPanel` | Groq/OpenRouter LLM summarization | +| 5 | `ai` | AI/ML News | 1 | `NewsPanel` | AI/ML RSS feeds | +| 6 | `tech` | Technology | 1 | `NewsPanel` | Technology RSS feeds | +| 7 | `startups` | Startups & VC | 1 | `NewsPanel` | Startup RSS feeds | +| 8 | `vcblogs` | VC Insights & Essays | 1 | `NewsPanel` | VC blog RSS feeds | +| 9 | `regionalStartups` | Global Startup News | 1 | `NewsPanel` | Regional startup RSS feeds | +| 10 | `unicorns` | Unicorn Tracker | 1 | `NewsPanel` | Unicorn RSS feeds | +| 11 | `accelerators` | Accelerators & Demo Days | 1 | `NewsPanel` | Accelerator RSS feeds | +| 12 | `security` | Cybersecurity | 1 | `NewsPanel` | Cybersecurity RSS feeds | +| 13 | `policy` | AI Policy & Regulation | 1 | `NewsPanel` | AI policy RSS feeds | +| 14 | `regulation` | AI Regulation Dashboard | 1 | `RegulationPanel` | Regulation data | +| 15 | `layoffs` | Layoffs Tracker | 1 | `NewsPanel` | Layoffs RSS feeds | +| 16 | `markets` | Tech Stocks | 2 | `MarketPanel` | Finnhub / Yahoo Finance | +| 17 | `finance` | Financial News | 2 | `NewsPanel` | Financial RSS feeds | +| 18 | `crypto` | Crypto | 2 | `CryptoPanel` | CoinGecko API | +| 19 | `hardware` | Semiconductors & Hardware | 2 | `NewsPanel` | Hardware RSS feeds | +| 20 | `cloud` | Cloud & Infrastructure | 2 | `NewsPanel` | Cloud RSS feeds | +| 21 | `dev` | Developer Community | 2 | `NewsPanel` | Developer RSS feeds | +| 22 | `github` | GitHub Trending | 1 | `NewsPanel` | GitHub trending API | +| 23 | `ipo` | IPO & SPAC | 2 | `NewsPanel` | IPO RSS feeds | +| 24 | `polymarket` | Tech Predictions | 2 | `PredictionPanel` | Polymarket API | +| 25 | `funding` | Funding & VC | 1 | `NewsPanel` | Funding RSS feeds | +| 26 | `producthunt` | Product Hunt | 1 | `NewsPanel` | Product Hunt RSS | +| 27 | `events` | Tech Events | 1 | `TechEventsPanel` | Tech events API | +| 28 | `service-status` | Service Status | 2 | `ServiceStatusPanel` | Service status API | +| 29 | `economic` | Economic Indicators | 2 | `EconomicPanel` | FRED API | +| 30 | `tech-readiness` | Tech Readiness Index | 1 | `TechReadinessPanel` | World Bank API | +| 31 | `macro-signals` | Market Radar | 2 | `MacroSignalsPanel` | Macro signals API | +| 32 | `etf-flows` | BTC ETF Tracker | 2 | `ETFFlowsPanel` | ETF flows API | +| 33 | `stablecoins` | Stablecoins | 2 | `StablecoinPanel` | Stablecoin markets API | +| 34 | `monitors` | My Monitors | 2 | `MonitorPanel` | User-defined keyword monitors | + +**Tech variant exclusive panels**: `startups`, `vcblogs`, `regionalStartups`, `unicorns`, `accelerators`, `security`, `policy`, `regulation`, `hardware`, `cloud`, `dev`, `github`, `funding`, `producthunt`, `events`, `service-status`, `tech-readiness`. + +--- + +## 6. Finance Variant Panels (finance.worldmonitor.io) + +The finance variant ships **29 panels** focused on markets, trading, macro indicators, and financial data. + +| # | Panel ID | Display Name | Priority | Component Class | Data Source | +|---|----------|-------------|----------|-----------------|-------------| +| 1 | `map` | Global Markets Map | 1 | `MapContainer` | MapLibre + deck.gl | +| 2 | `live-news` | Market Headlines | 1 | `LiveNewsPanel` | Financial RSS aggregation | +| 3 | `live-webcams` | Live Webcams | 2 | `LiveWebcamsPanel` | Curated webcam streams | +| 4 | `insights` | AI Market Insights | 1 | `InsightsPanel` | Groq/OpenRouter LLM summarization | +| 5 | `markets` | Live Markets | 1 | `MarketPanel` | Finnhub / Yahoo Finance | +| 6 | `markets-news` | Markets News | 2 | `NewsPanel` | Markets RSS feeds | +| 7 | `forex` | Forex & Currencies | 1 | `NewsPanel` | Forex RSS feeds | +| 8 | `bonds` | Fixed Income | 1 | `NewsPanel` | Fixed income RSS feeds | +| 9 | `commodities` | Commodities & Futures | 1 | `CommoditiesPanel` | Yahoo Finance / commodity APIs | +| 10 | `commodities-news` | Commodities News | 2 | `NewsPanel` | Commodities RSS feeds | +| 11 | `crypto` | Crypto & Digital Assets | 1 | `CryptoPanel` | CoinGecko API | +| 12 | `crypto-news` | Crypto News | 2 | `NewsPanel` | Crypto RSS feeds | +| 13 | `centralbanks` | Central Bank Watch | 1 | `NewsPanel` | Central bank RSS feeds | +| 14 | `economic` | Economic Data | 1 | `EconomicPanel` | FRED API | +| 15 | `economic-news` | Economic News | 2 | `NewsPanel` | Economic RSS feeds | +| 16 | `ipo` | IPOs, Earnings & M&A | 1 | `NewsPanel` | IPO/M&A RSS feeds | +| 17 | `heatmap` | Sector Heatmap | 1 | `HeatmapPanel` | Market sector data | +| 18 | `macro-signals` | Market Radar | 1 | `MacroSignalsPanel` | Macro signals API | +| 19 | `derivatives` | Derivatives & Options | 2 | `NewsPanel` | Derivatives RSS feeds | +| 20 | `fintech` | Fintech & Trading Tech | 2 | `NewsPanel` | Fintech RSS feeds | +| 21 | `regulation` | Financial Regulation | 2 | `NewsPanel` | Regulation RSS feeds | +| 22 | `institutional` | Hedge Funds & PE | 2 | `NewsPanel` | Institutional RSS feeds | +| 23 | `analysis` | Market Analysis | 2 | `NewsPanel` | Analysis RSS feeds | +| 24 | `etf-flows` | BTC ETF Tracker | 2 | `ETFFlowsPanel` | ETF flows API | +| 25 | `stablecoins` | Stablecoins | 2 | `StablecoinPanel` | Stablecoin markets API | +| 26 | `gcc-investments` | GCC Investments | 2 | `InvestmentsPanel` | GCC investment data | +| 27 | `gccNews` | GCC Business News | 2 | `NewsPanel` | GCC news RSS feeds | +| 28 | `polymarket` | Predictions | 2 | `PredictionPanel` | Polymarket API | +| 29 | `monitors` | My Monitors | 2 | `MonitorPanel` | User-defined keyword monitors | + +**Finance variant exclusive panels**: `markets-news`, `forex`, `bonds`, `commodities-news`, `crypto-news`, `centralbanks`, `economic-news`, `derivatives`, `fintech`, `institutional`, `analysis`, `gcc-investments`, `gccNews`. + +--- + +## 7. Variant Comparison Matrix + +This matrix shows which panel IDs are available in each variant. Panels that appear in multiple variants may have different display names (see individual variant sections above). + +| Panel ID | Full | Tech | Finance | Notes | +|----------|:----:|:----:|:-------:|-------| +| `map` | ✅ | ✅ | ✅ | Different names per variant | +| `live-news` | ✅ | ✅ | ✅ | Different names per variant | +| `live-webcams` | ✅ | ✅ | ✅ | Priority 1 in full, priority 2 in tech/finance | +| `insights` | ✅ | ✅ | ✅ | Different names per variant | +| `markets` | ✅ | ✅ | ✅ | "Markets" / "Tech Stocks" / "Live Markets" | +| `economic` | ✅ | ✅ | ✅ | "Economic Indicators" / "Economic Indicators" / "Economic Data" | +| `crypto` | ✅ | ✅ | ✅ | "Crypto" / "Crypto" / "Crypto & Digital Assets" | +| `polymarket` | ✅ | ✅ | ✅ | "Predictions" / "Tech Predictions" / "Predictions" | +| `monitors` | ✅ | ✅ | ✅ | Identical across all variants | +| `macro-signals` | ✅ | ✅ | ✅ | P2 in full/tech, P1 in finance | +| `etf-flows` | ✅ | ✅ | ✅ | P2 in all variants | +| `stablecoins` | ✅ | ✅ | ✅ | P2 in all variants | +| `layoffs` | ✅ | ✅ | — | P2 in full, P1 in tech | +| `finance` | ✅ | ✅ | — | "Financial" / "Financial News" | +| `tech` | ✅ | ✅ | — | P2 in full, P1 in tech | +| `ai` | ✅ | ✅ | — | "AI/ML" / "AI/ML News" | +| `heatmap` | ✅ | — | ✅ | P2 in full, P1 in finance | +| `commodities` | ✅ | — | ✅ | "Commodities" / "Commodities & Futures" | +| `ipo` | — | ✅ | ✅ | "IPO & SPAC" / "IPOs, Earnings & M&A" | +| `regulation` | — | ✅ | ✅ | "AI Regulation Dashboard" / "Financial Regulation" | +| `politics` | ✅ | — | — | Full only — World News | +| `middleeast` | ✅ | — | — | Full only | +| `africa` | ✅ | — | — | Full only | +| `latam` | ✅ | — | — | Full only | +| `asia` | ✅ | — | — | Full only | +| `energy` | ✅ | — | — | Full only | +| `gov` | ✅ | — | — | Full only | +| `thinktanks` | ✅ | — | — | Full only | +| `intel` | ✅ | — | — | Full only | +| `gdelt-intel` | ✅ | — | — | Full only | +| `cascade` | ✅ | — | — | Full only | +| `strategic-posture` | ✅ | — | — | Full only | +| `cii` | ✅ | — | — | Full only | +| `strategic-risk` | ✅ | — | — | Full only | +| `satellite-fires` | ✅ | — | — | Full only | +| `ucdp-events` | ✅ | — | — | Full only | +| `displacement` | ✅ | — | — | Full only | +| `climate` | ✅ | — | — | Full only | +| `population-exposure` | ✅ | — | — | Full only | +| `startups` | — | ✅ | — | Tech only | +| `vcblogs` | — | ✅ | — | Tech only | +| `regionalStartups` | — | ✅ | — | Tech only | +| `unicorns` | — | ✅ | — | Tech only | +| `accelerators` | — | ✅ | — | Tech only | +| `security` | — | ✅ | — | Tech only | +| `policy` | — | ✅ | — | Tech only | +| `hardware` | — | ✅ | — | Tech only | +| `cloud` | — | ✅ | — | Tech only | +| `dev` | — | ✅ | — | Tech only | +| `github` | — | ✅ | — | Tech only | +| `funding` | — | ✅ | — | Tech only | +| `producthunt` | — | ✅ | — | Tech only | +| `events` | — | ✅ | — | Tech only | +| `service-status` | — | ✅ | — | Tech only | +| `tech-readiness` | — | ✅ | — | Tech only | +| `markets-news` | — | — | ✅ | Finance only | +| `forex` | — | — | ✅ | Finance only | +| `bonds` | — | — | ✅ | Finance only | +| `commodities-news` | — | — | ✅ | Finance only | +| `crypto-news` | — | — | ✅ | Finance only | +| `centralbanks` | — | — | ✅ | Finance only | +| `economic-news` | — | — | ✅ | Finance only | +| `derivatives` | — | — | ✅ | Finance only | +| `fintech` | — | — | ✅ | Finance only | +| `institutional` | — | — | ✅ | Finance only | +| `analysis` | — | — | ✅ | Finance only | +| `gcc-investments` | — | — | ✅ | Finance only | +| `gccNews` | — | — | ✅ | Finance only | + +--- + +## 8. Map Layers + +The map is a specialized panel (`map` ID) rendered in its own `#mapSection` container rather than the `#panelsGrid`. Each variant defines both desktop and mobile default layer sets. + +### 8.1 MapLayers Interface + +```typescript +// src/types/index.ts +export interface MapLayers { + // Geopolitical layers + conflicts: boolean; bases: boolean; hotspots: boolean; + nuclear: boolean; sanctions: boolean; military: boolean; + + // Infrastructure layers + cables: boolean; pipelines: boolean; waterways: boolean; + outages: boolean; datacenters: boolean; spaceports: boolean; + + // Threat layers + cyberThreats: boolean; protests: boolean; fires: boolean; + + // Environmental layers + weather: boolean; economic: boolean; natural: boolean; + minerals: boolean; irradiators: boolean; + + // Transport layers + ais: boolean; flights: boolean; + + // Data source layers + ucdpEvents: boolean; displacement: boolean; climate: boolean; + + // Tech variant layers + startupHubs: boolean; cloudRegions: boolean; accelerators: boolean; + techHQs: boolean; techEvents: boolean; + + // Finance variant layers + stockExchanges: boolean; financialCenters: boolean; centralBanks: boolean; + commodityHubs: boolean; gulfInvestments: boolean; +} +``` + +### 8.2 Full Variant Layers + +| Layer | Desktop Default | Mobile Default | Description | +|-------|:--------------:|:--------------:|-------------| +| `conflicts` | ✅ ON | ✅ ON | Armed conflict zones | +| `bases` | ✅ ON | OFF | Military bases | +| `hotspots` | ✅ ON | ✅ ON | Geopolitical hotspots | +| `nuclear` | ✅ ON | OFF | Nuclear facilities | +| `sanctions` | ✅ ON | ✅ ON | Sanctioned entities/regions | +| `weather` | ✅ ON | ✅ ON | Weather alerts | +| `economic` | ✅ ON | OFF | Economic indicators overlay | +| `waterways` | ✅ ON | OFF | Strategic waterways | +| `outages` | ✅ ON | ✅ ON | Internet/infrastructure outages | +| `military` | ✅ ON | OFF | Military deployments | +| `natural` | ✅ ON | ✅ ON | Natural disasters | +| `cables` | OFF | OFF | Undersea fiber cables | +| `pipelines` | OFF | OFF | Oil/gas pipelines | +| `ais` | OFF | OFF | AIS vessel tracking | +| `irradiators` | OFF | OFF | Gamma irradiators | +| `cyberThreats` | OFF | OFF | Cyber threat indicators | +| `datacenters` | OFF | OFF | AI data centers | +| `protests` | OFF | OFF | Social unrest events | +| `flights` | OFF | OFF | Military flights | +| `spaceports` | OFF | OFF | Space launch facilities | +| `minerals` | OFF | OFF | Critical mineral deposits | +| `fires` | OFF | OFF | Active fires (FIRMS) | +| `ucdpEvents` | OFF | OFF | UCDP conflict data | +| `displacement` | OFF | OFF | UNHCR displacement data | +| `climate` | OFF | OFF | Climate anomalies | +| All tech layers | OFF | OFF | — | +| All finance layers | OFF | OFF | — | + +**Desktop default ON: 11 layers** · **Mobile default ON: 5 layers** + +### 8.3 Tech Variant Layers + +| Layer | Desktop Default | Mobile Default | Description | +|-------|:--------------:|:--------------:|-------------| +| `cables` | ✅ ON | OFF | Undersea fiber cables | +| `weather` | ✅ ON | OFF | Weather alerts | +| `economic` | ✅ ON | OFF | Economic indicators overlay | +| `outages` | ✅ ON | ✅ ON | Internet outages | +| `datacenters` | ✅ ON | ✅ ON | AI data centers | +| `natural` | ✅ ON | ✅ ON | Natural disasters | +| `startupHubs` | ✅ ON | ✅ ON | Startup ecosystem hubs | +| `cloudRegions` | ✅ ON | OFF | Cloud provider regions | +| `techHQs` | ✅ ON | OFF | Major tech company HQs | +| `techEvents` | ✅ ON | ✅ ON | Tech conferences and events | +| All geopolitical | OFF | OFF | — | +| All military | OFF | OFF | — | +| All finance layers | OFF | OFF | — | + +**Desktop default ON: 10 layers** · **Mobile default ON: 5 layers** + +### 8.4 Finance Variant Layers + +| Layer | Desktop Default | Mobile Default | Description | +|-------|:--------------:|:--------------:|-------------| +| `cables` | ✅ ON | OFF | Undersea fiber cables | +| `pipelines` | ✅ ON | OFF | Oil/gas pipelines | +| `sanctions` | ✅ ON | OFF | Sanctioned entities | +| `weather` | ✅ ON | OFF | Weather alerts | +| `economic` | ✅ ON | ✅ ON | Economic indicators overlay | +| `waterways` | ✅ ON | OFF | Strategic waterways | +| `outages` | ✅ ON | ✅ ON | Internet outages | +| `natural` | ✅ ON | ✅ ON | Natural disasters | +| `stockExchanges` | ✅ ON | ✅ ON | Global stock exchanges | +| `financialCenters` | ✅ ON | OFF | Financial centers | +| `centralBanks` | ✅ ON | ✅ ON | Central bank locations | +| All geopolitical | OFF | OFF | — | +| All military | OFF | OFF | — | +| All tech layers | OFF | OFF | — | + +**Desktop default ON: 11 layers** · **Mobile default ON: 5 layers** + +### 8.5 Mobile Layer Strategy + +Mobile defaults enable fewer layers to preserve performance on constrained devices. The pattern: + +- Each variant enables ~5 layers on mobile (vs 10–11 on desktop) +- Environmental critical layers (`natural`, `outages`) are always on +- Variant-signature layers stay on (e.g. `startupHubs` for tech, `stockExchanges` for finance) +- Heavy overlay layers (`cables`, `pipelines`, `weather`) are off by default on mobile + +--- + +## 9. Panel Persistence + +All user preferences survive page reload via `localStorage`. The following table enumerates every persisted setting: + +### 9.1 Persistence Map + +| Setting | localStorage Key | Format | Default Source | Survives Reload | +|---------|-----------------|--------|----------------|:--------------:| +| Panel visibility | `worldmonitor-panels` | `Record` JSON | `DEFAULT_PANELS` | ✅ | +| Panel ordering | `panel-order` | `string[]` JSON | Config declaration order | ✅ | +| Panel sizes/spans | `worldmonitor-panel-spans` | `Record` JSON | All span-1 | ✅ | +| Map layer toggles | `worldmonitor-layers` | `MapLayers` JSON | `DEFAULT_MAP_LAYERS` | ✅ | +| Monitor keywords | `worldmonitor-monitors` | `Monitor[]` JSON | `[]` | ✅ | +| Disabled sources | `worldmonitor-disabled-feeds` | `string[]` JSON | `[]` | ✅ | +| Active variant | `worldmonitor-variant` | Plain string | `SITE_VARIANT` | ✅ | +| Banner dismissal | `banner-dismissed` (sessionStorage) | Timestamp string | — | Session only | + +### 9.2 Variant Change Reset + +When the stored variant (`worldmonitor-variant`) differs from the current `SITE_VARIANT`, the App constructor performs a full reset: + +```typescript +if (storedVariant !== currentVariant) { + localStorage.setItem('worldmonitor-variant', currentVariant); + localStorage.removeItem(STORAGE_KEYS.mapLayers); + localStorage.removeItem(STORAGE_KEYS.panels); + localStorage.removeItem(this.PANEL_ORDER_KEY); + localStorage.removeItem(this.PANEL_SPANS_KEY); + this.mapLayers = { ...defaultLayers }; + this.panelSettings = { ...DEFAULT_PANELS }; +} +``` + +This ensures users switching between variant domains (e.g. from worldmonitor.io to tech.worldmonitor.io) get a clean default experience for the new variant. + +### 9.3 Full Reset + +Users can clear all panel preferences by clearing `localStorage` for the domain. There is no in-app "reset to defaults" button — the variant change mechanism serves as an implicit reset. + +### 9.4 Merge Strategy for New Panels + +When the application adds new panels (in a code update), the saved panel order is merged with the current defaults: + +1. Start with the saved order (from `panel-order` key) +2. Remove any panels that no longer exist in `DEFAULT_PANELS` +3. Find panels present in `DEFAULT_PANELS` but missing from saved order +4. Insert missing panels after the `politics` panel position (or at position 0) +5. Always place `monitors` panel last +6. Always place `live-news` panel first (CSS grid constraint — it spans 2 columns) +7. Always place `live-webcams` immediately after `live-news` + +--- + +## 10. Panel Lifecycle + +### 10.1 Initialization Flow + +The panel lifecycle begins in `App.ts` constructor and flows through these phases: + +1. **Config Loading** — `panelSettings` loaded from localStorage (or `DEFAULT_PANELS` on first visit / variant change) +2. **DOM Scaffolding** — `render()` builds the page shell including `#panelsGrid` container and settings modal +3. **Panel Instantiation** — `createPanels()` constructs all panel class instances and registers them in `this.panels` and `this.newsPanels` +4. **Grid Insertion** — Panels are appended to `#panelsGrid` in the resolved order (saved + merge) +5. **Drag-and-drop Binding** — Each panel element gets `makeDraggable()` handlers for reordering +6. **Visibility Application** — `applyPanelSettings()` calls `panel.toggle(config.enabled)` for every panel +7. **Data Loading** — `loadAllData()` fires parallel API calls; each handler updates its panel via `setContent()` / `setContentThrottled()` +8. **Refresh Scheduling** — Periodic refresh timers are set up per data source (2–60 min intervals) + +### 10.2 Panel Type Hierarchy + +``` +Panel (base) +├── NewsPanel ← RSS-driven news feeds (most panels) +├── MarketPanel ← Live market tickers +├── HeatmapPanel ← Sector heatmap grid +├── CryptoPanel ← Cryptocurrency prices +├── CommoditiesPanel ← Commodity prices +├── PredictionPanel ← Polymarket predictions +├── EconomicPanel ← FRED economic indicators +├── MonitorPanel ← User keyword monitors +├── CIIPanel ← Country Instability Index +├── CascadePanel ← Infrastructure cascade analysis +├── GdeltIntelPanel ← GDELT intelligence feed +├── SatelliteFiresPanel← NASA FIRMS fire data +├── StrategicRiskPanel ← Risk score overview +├── StrategicPosturePanel ← Theater posture +├── UcdpEventsPanel ← UCDP conflict events +├── DisplacementPanel ← UNHCR displacement +├── ClimateAnomalyPanel← Climate anomaly data +├── PopulationExposurePanel ← Population exposure +├── InvestmentsPanel ← GCC investment tracker +├── LiveNewsPanel ← Breaking news aggregation +├── LiveWebcamsPanel ← Multi-stream webcam view +├── TechEventsPanel ← Technology events +├── ServiceStatusPanel ← Service uptime monitor +├── TechReadinessPanel ← World Bank tech index +├── MacroSignalsPanel ← Macro market signals +├── ETFFlowsPanel ← BTC ETF flow tracker +├── StablecoinPanel ← Stablecoin market data +├── InsightsPanel ← AI-generated insights +├── RegulationPanel ← Regulation dashboard +├── RuntimeConfigPanel ← Desktop runtime config (Tauri only) +├── OrefSirensPanel ← Israel alert sirens (full only) +└── StatusPanel ← System status (not in panel grid) +``` + +### 10.3 Auto-generated News Panels + +`App.ts` iterates over all keys in the variant's `FEEDS` object. For any feed category that does not already have a manually instantiated `NewsPanel`, a new `NewsPanel` is created automatically: + +```typescript +for (const key of Object.keys(FEEDS)) { + if (this.newsPanels[key]) continue; // Skip if already created + if (!Array.isArray((FEEDS as Record)[key])) continue; + // If a data panel exists with this key, create a separate news panel with "-news" suffix + const panelKey = this.panels[key] && !this.newsPanels[key] ? `${key}-news` : key; + if (this.panels[panelKey]) continue; + const panelConfig = DEFAULT_PANELS[panelKey] ?? DEFAULT_PANELS[key]; + const label = panelConfig?.name ?? key.charAt(0).toUpperCase() + key.slice(1); + const panel = new NewsPanel(panelKey, label); + this.attachRelatedAssetHandlers(panel); + this.newsPanels[key] = panel; + this.panels[panelKey] = panel; +} +``` + +This allows the finance variant to have paired panels like `markets` (data panel) + `markets-news` (RSS panel) without manual duplication. + +### 10.4 Panel Toggle Flow + +When a user clicks a panel toggle in the Settings modal: + +1. `config.enabled` is flipped on the in-memory `panelSettings` +2. `saveToStorage(STORAGE_KEYS.panels, this.panelSettings)` persists to localStorage +3. `renderPanelToggles()` re-renders the toggle UI with updated checkmarks +4. `applyPanelSettings()` iterates all panels, calling `panel.toggle(config.enabled)` +5. The `Panel.toggle()` method adds/removes the `hidden` CSS class + +### 10.5 Panel Destruction + +Panels are destroyed when the App instance is torn down. The `Panel.destroy()` method removes global event listeners (tooltip close handlers, touch/mouse handlers for resize). Panel DOM elements are removed when their parent grid is cleared. + +--- + +## 11. Adding a New Panel + +Step-by-step guide to adding a new panel to World Monitor. + +### Step 1: Define the Panel Config + +Add the panel entry to the appropriate variant config in [`src/config/panels.ts`](../src/config/panels.ts): + +```typescript +// In the FULL_PANELS (or TECH_PANELS, FINANCE_PANELS) object: +'my-panel': { name: 'My Panel', enabled: true, priority: 2 }, +``` + +**Panel order matters** — panels are rendered in declaration order by default. + +### Step 2: Create the Component Class + +Create a new file in `src/components/`: + +```typescript +// src/components/MyPanel.ts +import { Panel } from './Panel'; + +export class MyPanel extends Panel { + constructor() { + super({ + id: 'my-panel', + title: 'My Panel', + showCount: true, + infoTooltip: 'Methodology: ...', + }); + } + + public async refresh(): Promise { + this.showLoading(); + try { + const data = await fetch('/api/my-data').then(r => r.json()); + this.setContent(this.renderData(data)); + this.setCount(data.length); + this.setDataBadge('live'); + } catch (e) { + this.showError('Failed to load data'); + this.setDataBadge('unavailable'); + } + } + + private renderData(data: unknown[]): string { + return `
...
`; + } +} +``` + +For RSS-based panels, use `NewsPanel` directly instead of creating a new class — just add feeds to the variant's `FEEDS` object and the panel will be auto-generated. + +### Step 3: Register in App.ts + +Import and instantiate the panel in the `createPanels()` method of [`src/App.ts`](../src/App.ts): + +```typescript +import { MyPanel } from '@/components/MyPanel'; + +// Inside createPanels(): +const myPanel = new MyPanel(); +this.panels['my-panel'] = myPanel; +``` + +### Step 4: Wire Data Loading + +Add a data loading task in `loadAllData()`: + +```typescript +tasks.push({ + name: 'myPanel', + task: runGuarded('myPanel', () => (this.panels['my-panel'] as MyPanel).refresh()), +}); +``` + +### Step 5: Add Refresh Interval (Optional) + +If the panel needs periodic refresh, add a timer in `setupRefreshTimers()` referencing `REFRESH_INTERVALS`. + +### Step 6: Add Map Layer (Optional) + +If the panel has an associated map layer: + +1. Add the boolean field to `MapLayers` in `src/types/index.ts` +2. Set default values in all variant layer objects in `src/config/panels.ts` +3. Handle the layer in `MapContainer` + +### Step 7: Add i18n Key + +Add a translation key in `src/locales/en.json` (and other locale files): + +```json +{ + "panels": { + "myPanel": "My Panel" + } +} +``` + +### Step 8: Register in Variant Configs + +If using the variant config system (`src/config/variants/`), add the panel to the appropriate variant file(s) alongside feeds if applicable. + +--- + +## 12. Diagrams + +### 12.1 Panel Registration Flow + +```mermaid +flowchart TD + A[App Constructor] --> B{Variant Changed?} + B -->|Yes| C[Reset localStorage
Use DEFAULT_PANELS] + B -->|No| D[Load panelSettings
from localStorage] + C --> E[render: Build DOM Shell] + D --> E + E --> F[createPanels] + F --> G[Instantiate Panel Classes
Register in this.panels] + G --> H[Auto-generate NewsPanels
from FEEDS keys] + H --> I[Resolve Panel Order
saved + merge missing] + I --> J[Append to #panelsGrid
in resolved order] + J --> K[makeDraggable
on each panel element] + K --> L[applyPanelSettings
show/hide per config] + L --> M[renderPanelToggles
build settings modal UI] + M --> N[loadAllData
parallel API calls] +``` + +### 12.2 Variant Selection Flow + +```mermaid +flowchart LR + ENV["VITE_VARIANT
env variable"] --> VAR["SITE_VARIANT
constant"] + VAR --> SW{Switch} + SW -->|"'tech'"| TP["TECH_PANELS
TECH_MAP_LAYERS
TECH_MOBILE_MAP_LAYERS"] + SW -->|"'finance'"| FP["FINANCE_PANELS
FINANCE_MAP_LAYERS
FINANCE_MOBILE_MAP_LAYERS"] + SW -->|default| GP["FULL_PANELS
FULL_MAP_LAYERS
FULL_MOBILE_MAP_LAYERS"] + TP --> EX["DEFAULT_PANELS
DEFAULT_MAP_LAYERS
MOBILE_DEFAULT_MAP_LAYERS"] + FP --> EX + GP --> EX + EX --> APP["App.ts
imports DEFAULT_PANELS"] + APP --> TREE["Vite tree-shakes
unused variants"] +``` + +### 12.3 Panel Toggle Persistence Flow + +```mermaid +sequenceDiagram + participant User + participant SettingsModal + participant App + participant localStorage + participant Panel + + User->>SettingsModal: Click panel toggle + SettingsModal->>App: panelKey identified + App->>App: config.enabled = !config.enabled + App->>localStorage: saveToStorage('worldmonitor-panels', panelSettings) + App->>SettingsModal: renderPanelToggles() — update checkmarks + App->>App: applyPanelSettings() + loop For each panel + App->>Panel: panel.toggle(config.enabled) + Panel->>Panel: add/remove 'hidden' CSS class + end + Note over localStorage: Survives page reload + User->>User: Refreshes page + App->>localStorage: loadFromStorage('worldmonitor-panels') + App->>App: applyPanelSettings() with restored state +``` + +--- + +## Appendix: Panel Component Files + +Quick reference for locating panel component source files: + +| Component | File | +|-----------|------| +| `Panel` (base) | `src/components/Panel.ts` | +| `NewsPanel` | `src/components/NewsPanel.ts` | +| `MarketPanel` / `HeatmapPanel` | `src/components/MarketPanel.ts` | +| `CryptoPanel` | `src/components/CryptoPanel.ts` | +| `CommoditiesPanel` | `src/components/CommoditiesPanel.ts` | +| `PredictionPanel` | `src/components/PredictionPanel.ts` | +| `EconomicPanel` | `src/components/EconomicPanel.ts` | +| `MonitorPanel` | `src/components/MonitorPanel.ts` | +| `CIIPanel` | `src/components/CIIPanel.ts` | +| `CascadePanel` | `src/components/CascadePanel.ts` | +| `GdeltIntelPanel` | `src/components/GdeltIntelPanel.ts` | +| `SatelliteFiresPanel` | `src/components/SatelliteFiresPanel.ts` | +| `StrategicRiskPanel` | `src/components/StrategicRiskPanel.ts` | +| `StrategicPosturePanel` | `src/components/StrategicPosturePanel.ts` | +| `UcdpEventsPanel` | `src/components/UcdpEventsPanel.ts` | +| `DisplacementPanel` | `src/components/DisplacementPanel.ts` | +| `ClimateAnomalyPanel` | `src/components/ClimateAnomalyPanel.ts` | +| `PopulationExposurePanel` | `src/components/PopulationExposurePanel.ts` | +| `InvestmentsPanel` | `src/components/InvestmentsPanel.ts` | +| `LiveNewsPanel` | `src/components/LiveNewsPanel.ts` | +| `LiveWebcamsPanel` | `src/components/LiveWebcamsPanel.ts` | +| `TechEventsPanel` | `src/components/TechEventsPanel.ts` | +| `ServiceStatusPanel` | `src/components/ServiceStatusPanel.ts` | +| `TechReadinessPanel` | `src/components/TechReadinessPanel.ts` | +| `MacroSignalsPanel` | `src/components/MacroSignalsPanel.ts` | +| `ETFFlowsPanel` | `src/components/ETFFlowsPanel.ts` | +| `StablecoinPanel` | `src/components/StablecoinPanel.ts` | +| `InsightsPanel` | `src/components/InsightsPanel.ts` | +| `RegulationPanel` | `src/components/RegulationPanel.ts` | +| `RuntimeConfigPanel` | `src/components/RuntimeConfigPanel.ts` | +| `OrefSirensPanel` | `src/components/OrefSirensPanel.ts` | +| `StatusPanel` | `src/components/StatusPanel.ts` | diff --git a/docs/Docs_To_Review/RELEASE_PACKAGING.md b/docs/Docs_To_Review/RELEASE_PACKAGING.md new file mode 100644 index 000000000..1e77ae725 --- /dev/null +++ b/docs/Docs_To_Review/RELEASE_PACKAGING.md @@ -0,0 +1,225 @@ +# Desktop Release Packaging Guide (Local, Reproducible) + +This guide provides reproducible local packaging steps for both desktop variants: + +- **full** → `World Monitor` +- **tech** → `Tech Monitor` + +Variant identity is controlled by Tauri config: + +- full: `src-tauri/tauri.conf.json` +- tech: `src-tauri/tauri.tech.conf.json` + +## Prerequisites + +- Node.js + npm +- Rust toolchain +- OS-native Tauri build prerequisites: + - macOS: Xcode command-line tools + - Windows: Visual Studio Build Tools + NSIS + WiX + +Install dependencies (this also installs the pinned Tauri CLI used by desktop scripts): + +```bash +npm ci +``` + +All desktop scripts call the local `tauri` binary from `node_modules/.bin`; no runtime `npx` package download is required after `npm ci`. +If the local CLI is missing, `scripts/desktop-package.mjs` now fails fast with an explicit `npm ci` remediation message. + +## Network preflight and remediation + +Before running desktop packaging in CI or managed networks, verify connectivity and proxy config: + +```bash +npm ping +curl -I https://index.crates.io/ +env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY)=' +``` + +If these fail, use one of the supported remediations: + +- Internal npm mirror/proxy. +- Internal Cargo sparse index/registry mirror. +- Pre-vendored Rust crates (`src-tauri/vendor/`) + Cargo offline mode. +- CI artifact/caching strategy that restores required package inputs before build. + +See `docs/TAURI_VALIDATION_REPORT.md` for failure classification labels and troubleshooting flow. + +## Packaging commands + +To view script usage/help: + +```bash +npm run desktop:package -- --help +``` + +### macOS (`.app` + `.dmg`) + +```bash +npm run desktop:package:macos:full +npm run desktop:package:macos:tech +# or generic runner +npm run desktop:package -- --os macos --variant full +``` + +### Windows (`.exe` + `.msi`) + +```bash +npm run desktop:package:windows:full +npm run desktop:package:windows:tech +# or generic runner +npm run desktop:package -- --os windows --variant tech +``` + +Bundler targets are pinned in both Tauri configs and enforced by packaging scripts: + +- macOS: `app,dmg` +- Windows: `nsis,msi` + +## Rust dependency modes (online vs restricted network) + +From `src-tauri/`, the project supports two packaging paths: + +### 1) Standard online build (default) + +Use normal Cargo behavior (crates.io): + +```bash +cd src-tauri +cargo generate-lockfile +cargo tauri build --config tauri.conf.json +``` + +### 2) Restricted-network build (pre-vendored or internal mirror) + +An optional vendored source is defined in `src-tauri/.cargo/config.toml`. To use it, first prepare vendored crates on a machine that has registry access: + +```bash +# from repository root +cargo vendor --manifest-path src-tauri/Cargo.toml src-tauri/vendor +``` + +Then enable offline mode using either method: + +- One-off CLI override (no file changes): + +```bash +cd src-tauri +cargo generate-lockfile --offline --config 'source.crates-io.replace-with="vendored-sources"' +cargo tauri build --offline --config 'source.crates-io.replace-with="vendored-sources"' --config tauri.conf.json +``` + +- Local override file (recommended for CI/repeatable offline jobs): + +```bash +cp src-tauri/.cargo/config.local.toml.example src-tauri/.cargo/config.local.toml +cd src-tauri +cargo generate-lockfile --offline +cargo tauri build --offline --config tauri.conf.json +``` + +For CI or internal mirrors, publish `src-tauri/vendor/` as an artifact and restore it before the restricted-network build. If your organization uses an internal crates mirror instead of vendoring, point `source.crates-io.replace-with` to that mirror in CI-specific Cargo config and run the same build commands. + +## Optional signing/notarization hooks + +Unsigned packaging works by default. + +If signing credentials are present in environment variables, Tauri will sign/notarize automatically during the same packaging commands. + +### macOS Apple Developer signing + notarization + +Set before packaging (Developer ID signature): + +```bash +export TAURI_BUNDLE_MACOS_SIGNING_IDENTITY="Developer ID Application: Your Company (TEAMID)" +export TAURI_BUNDLE_MACOS_PROVIDER_SHORT_NAME="TEAMID" +# optional alternate key accepted by Tauri tooling: +export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Company (TEAMID)" +``` + +For notarization, choose one auth method: + +```bash +# Apple ID + app-specific password +export APPLE_ID="you@example.com" +export APPLE_PASSWORD="app-specific-password" +export APPLE_TEAM_ID="TEAMID" + +# OR App Store Connect API key +export APPLE_API_KEY="ABC123DEFG" +export APPLE_API_ISSUER="00000000-0000-0000-0000-000000000000" +export APPLE_API_KEY_PATH="$HOME/.keys/AuthKey_ABC123DEFG.p8" +``` + +Then run either standard or explicit sign script aliases: + +```bash +npm run desktop:package:macos:full +# or +npm run desktop:package:macos:full:sign +``` + +### Windows Authenticode signing + +Set before packaging (PowerShell): + +```powershell +$env:TAURI_BUNDLE_WINDOWS_CERTIFICATE_THUMBPRINT="" +$env:TAURI_BUNDLE_WINDOWS_TIMESTAMP_URL="https://timestamp.digicert.com" +# optional: if using cert file + password instead of cert store +$env:TAURI_BUNDLE_WINDOWS_CERTIFICATE="C:\path\to\codesign.pfx" +$env:TAURI_BUNDLE_WINDOWS_CERTIFICATE_PASSWORD="" +``` + +Then run either standard or explicit sign script aliases: + +```powershell +npm run desktop:package:windows:full +# or +npm run desktop:package:windows:full:sign +``` + +## Variant-aware outputs (names/icons) + +- Full variant: `World Monitor` / `world-monitor` +- Tech variant: `Tech Monitor` / `tech-monitor` + +Distinct names are configured in Tauri: + +- `src-tauri/tauri.conf.json` → `World Monitor` / `world-monitor` +- `src-tauri/tauri.tech.conf.json` → `Tech Monitor` / `tech-monitor` + +If you want variant-specific icons, set `bundle.icon` separately in each config and point each variant to dedicated icon assets. + +## Output locations + +Artifacts are produced under: + +```text +src-tauri/target/release/bundle/ +``` + +Common subfolders: + +- `app/` → macOS `.app` +- `dmg/` → macOS `.dmg` +- `nsis/` → Windows `.exe` installer +- `msi/` → Windows `.msi` installer + +## Release checklist (clean machine) + +1. Build required OS + variant package(s). +2. Move artifacts to a clean machine (or fresh VM). +3. Install/launch: + - macOS: mount `.dmg`, drag app to Applications, launch. + - Windows: run `.exe` or `.msi`, launch from Start menu. +4. Validate startup: + - App window opens without crash. + - Map view renders. + - Initial data loading path does not fatal-error. +5. Validate variant identity: + - Window title and product name match expected variant. +6. If signing was enabled: + - Verify code-signing metadata in OS dialogs/properties. + - Verify notarization/Gatekeeper acceptance on macOS. diff --git a/docs/Docs_To_Review/STATE_MANAGEMENT.md b/docs/Docs_To_Review/STATE_MANAGEMENT.md new file mode 100644 index 000000000..7a0b396f5 --- /dev/null +++ b/docs/Docs_To_Review/STATE_MANAGEMENT.md @@ -0,0 +1,760 @@ +# State Management + +World Monitor is an AI-powered real-time global intelligence dashboard built with **vanilla TypeScript** — no framework, no reactive stores. All state is managed manually through class properties, `localStorage`, `IndexedDB`, and URL query parameters. + +This document is the canonical reference for how state is stored, updated, and persisted across the application. + +--- + +## Table of Contents + +1. [Application State Flow](#1-application-state-flow) +2. [App.ts State Properties & Lifecycle](#2-appts-state-properties--lifecycle) +3. [Panel State Persistence](#3-panel-state-persistence) +4. [Theme State Management](#4-theme-state-management) +5. [IndexedDB Storage Schema](#5-indexeddb-storage-schema) +6. [URL State Encoding/Decoding](#6-url-state-encodingdecoding) +7. [Runtime Config State (Desktop)](#7-runtime-config-state-desktop) +8. [Activity Tracking & Idle Detection](#8-activity-tracking--idle-detection) +9. [All localStorage Keys](#9-all-localstorage-keys) + +--- + +## 1. Application State Flow + +There is no framework — the entire app is a single `App` class in `src/App.ts` (~4,300 lines) that orchestrates every service, component, and piece of state. State changes flow through direct method calls and property writes. Components extend a `Panel` base class defined in `src/components/Panel.ts`. + +### Initialization Sequence + +```mermaid +flowchart TD + A["new App(containerId)"] --> B["constructor()"] + B --> B1["Variant detection"] + B --> B2["Panel order migration"] + B --> B3["URL state parsing"] + B --> B4["Mobile detection"] + B --> B5["Disabled sources loading"] + + B --> C["init()"] + C --> C1["initDB()"] + C1 --> C2["initI18n()"] + C2 --> C3["mlWorker.init()"] + C3 --> C4["AIS config check"] + C4 --> C5["renderLayout()"] + C5 --> C6["Setup handlers"] + C6 --> C7["preloadCountryGeometry()"] + C7 --> C8["loadAllData()"] + + C8 --> D["Post-load"] + D --> D1["startLearning()"] + D --> D2["setupRefreshIntervals()"] + D --> D3["setupSnapshotSaving()"] + D --> D4["cleanOldSnapshots()"] + D --> D5["handleDeepLinks()"] + D --> D6["checkForUpdate() (desktop)"] +``` + +### Data Flow Pattern + +Every state update follows the same pattern: + +```mermaid +flowchart LR + Fetch["Service fetch"] --> Process["App method processes response"] + Process --> State["Update private properties"] + State --> Component["Call component update methods"] + Component --> DOM["DOM updates"] +``` + +1. A service function fetches data from an API endpoint. +2. An `App` method receives the response and stores it in a private property (e.g. `this.allNews`). +3. The method calls update methods on the relevant components (e.g. `newsPanel.updateItems(items)`). +4. The component manipulates the DOM directly. + +There are no observables, signals, or virtual DOM — every update is an explicit imperative call. + +--- + +## 2. App.ts State Properties & Lifecycle + +All state lives as private properties on the `App` class. Grouped by purpose: + +### Data State + +```typescript +private allNews: NewsItem[] = []; +private newsByCategory: Record = {}; +private latestPredictions: PredictionMarket[] = []; +private latestMarkets: MarketData[] = []; +private latestClusters: ClusteredEvent[] = []; +private currentTimeRange: TimeRange = '7d'; +private monitors: Monitor[]; +private panelSettings: Record; +private mapLayers: MapLayers; +private cyberThreatsCache: CyberThreat[] | null = null; +``` + +### Component References + +```typescript +private map: MapContainer | null = null; +private panels: Record = {}; +private newsPanels: Record = {}; +private signalModal: SignalModal | null = null; +private playbackControl: PlaybackControl | null = null; +private statusPanel: StatusPanel | null = null; +private exportPanel: ExportPanel | null = null; +private languageSelector: LanguageSelector | null = null; +private searchModal: SearchModal | null = null; +private mobileWarningModal: MobileWarningModal | null = null; +private pizzintIndicator: PizzIntIndicator | null = null; +private countryBriefPage: CountryBriefPage | null = null; +private countryTimeline: CountryTimeline | null = null; +private findingsBadge: IntelligenceGapBadge | null = null; +private criticalBannerEl: HTMLElement | null = null; +``` + +### UI State + +```typescript +private isPlaybackMode = false; // playback / historical mode toggle +private isMobile: boolean; // detected at construction +private initialLoadComplete = false; // first data load complete flag +private isIdle = false; // idle detection state +private isDestroyed = false; // cleanup flag +private readonly isDesktopApp: boolean; // Tauri runtime detection +``` + +### Infrastructure State + +```typescript +private initialUrlState: ParsedMapUrlState | null = null; +private inFlight: Set = new Set(); // currently-running fetch keys +private seenGeoAlerts: Set = new Set(); // deduplicate geo alerts +private disabledSources: Set = new Set(); // user-disabled news sources +private mapFlashCache: Map = new Map(); // cooldown for map flash animations +private pendingDeepLinkCountry: string | null = null; // URL deep-link target +private briefRequestToken = 0; // cancellation token for async ops +``` + +### Timer / Interval IDs + +```typescript +private snapshotIntervalId: ReturnType | null = null; +private refreshTimeoutIds: Map> = new Map(); +private idleTimeoutId: ReturnType | null = null; +``` + +### Event Handler Refs (for cleanup) + +```typescript +private boundKeydownHandler: ((e: KeyboardEvent) => void) | null = null; +private boundFullscreenHandler: (() => void) | null = null; +private boundResizeHandler: (() => void) | null = null; +private boundVisibilityHandler: (() => void) | null = null; +private boundIdleResetHandler: (() => void) | null = null; +``` + +### Constants + +```typescript +private readonly PANEL_ORDER_KEY = 'panel-order'; +private readonly IDLE_PAUSE_MS = 2 * 60 * 1000; // 2 minutes +private readonly MAP_FLASH_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes +``` + +### Static Properties + +```typescript +private static COUNTRY_BOUNDS: Record; +private static COUNTRY_ALIASES: Record; +private static otherCountryTermsCache: Map = new Map(); +``` + +### Lifecycle Diagram + +```mermaid +stateDiagram-v2 + [*] --> Construct: new App() + Construct --> Init: init() + Init --> Loading: loadAllData() + Loading --> Running: data loaded + Running --> Idle: 2 min no interaction + Idle --> Running: user interaction + Running --> Destroyed: destroy() + Idle --> Destroyed: destroy() + Destroyed --> [*] + + state Running { + [*] --> Refreshing + Refreshing --> Waiting: fetch complete + Waiting --> Refreshing: interval fires + } +``` + +**Destroy** tears down everything: clears all intervals/timeouts, removes event listeners, calls `destroy()` on all components, nullifies references. + +--- + +## 3. Panel State Persistence + +Panels use `localStorage` for persistence. Defined in `src/components/Panel.ts`: + +### Panel Spans + +```typescript +const PANEL_SPANS_KEY = 'worldmonitor-panel-spans'; + +// Stored as Record — panel ID → grid span (1–4) +function loadPanelSpans(): Record { + const stored = localStorage.getItem(PANEL_SPANS_KEY); + return stored ? JSON.parse(stored) : {}; +} + +function savePanelSpan(panelId: string, span: number): void { + const spans = loadPanelSpans(); + spans[panelId] = span; + localStorage.setItem(PANEL_SPANS_KEY, JSON.stringify(spans)); +} +``` + +### Height-to-Span Conversion + +```typescript +function heightToSpan(height: number): number { + if (height >= 500) return 4; + if (height >= 350) return 3; + if (height >= 250) return 2; + return 1; +} +``` + +Users drag-resize panels; the pixel height is converted to a span (1–4), which is persisted and applied as a CSS class (`span-1` through `span-4`). + +### Panel Order + +Stored in `localStorage` under `panel-order` as a JSON `string[]` of panel IDs. The `App` constructor reads the saved order and applies it to the grid layout. Migrations reorder panels for new versions (e.g. the v1.9 migration promotes `insights`, `strategic-posture`, `cii`, `strategic-risk` to the top). + +### Panel Settings + +Stored in `localStorage` under `worldmonitor-panels` as `Record`. Includes per-panel `enabled` state, `name`, and `priority`. Controlled by the variant config and user overrides. + +### STORAGE_KEYS + +Defined in `src/config/variants/base.ts` (and mirrored in `src/config/panels.ts`): + +```typescript +export const STORAGE_KEYS = { + panels: 'worldmonitor-panels', + monitors: 'worldmonitor-monitors', + mapLayers: 'worldmonitor-layers', + disabledFeeds: 'worldmonitor-disabled-feeds', +} as const; +``` + +| Key | Type | Purpose | +|-----|------|---------| +| `worldmonitor-panels` | `Record` | Per-panel enabled/name/priority | +| `worldmonitor-monitors` | `Monitor[]` | Color/label configs for monitors | +| `worldmonitor-layers` | `MapLayers` | Enabled/disabled map layer toggles | +| `worldmonitor-disabled-feeds` | `string[]` | User-disabled news feed sources | + +--- + +## 4. Theme State Management + +Defined in `src/utils/theme-manager.ts`. Supported themes: `'dark' | 'light'`. + +### Storage + +- **Key:** `worldmonitor-theme` +- **Default:** `'dark'` + +### API + +```typescript +// Read stored preference (falls back to 'dark') +getStoredTheme(): Theme + +// Read current DOM theme (from data-theme attribute) +getCurrentTheme(): Theme + +// Full theme switch: DOM + cache invalidation + persist + meta + event +setTheme(theme: Theme): void + +// Early bootstrap: sets data-theme + meta only (no events) +applyStoredTheme(): void +``` + +### Theme Application Flow + +```mermaid +sequenceDiagram + participant User + participant setTheme + participant DOM + participant ColorCache + participant localStorage + participant Window + + User->>setTheme: setTheme('light') + setTheme->>DOM: data-theme = 'light' + setTheme->>ColorCache: invalidateColorCache() + setTheme->>localStorage: setItem('worldmonitor-theme', 'light') + setTheme->>DOM: meta[theme-color].content = '#f8f9fa' + setTheme->>Window: dispatchEvent('theme-changed') +``` + +### Color Cache + +`src/utils/theme-colors.ts` provides `getCSSColor(varName)` which reads computed CSS custom properties and caches them. The cache auto-invalidates when the `data-theme` attribute changes: + +```typescript +const colorCache = new Map(); +let cacheTheme = ''; + +export function getCSSColor(varName: string): string { + const currentTheme = document.documentElement.dataset.theme || 'dark'; + if (currentTheme !== cacheTheme) { + colorCache.clear(); + cacheTheme = currentTheme; + } + // ...read from cache or compute +} + +export function invalidateColorCache(): void { + colorCache.clear(); + cacheTheme = ''; +} +``` + +### CSS Integration + +All colors are driven by CSS custom properties under `[data-theme]` selectors. Components never hardcode colors — they read from the CSS variable system. Meta `theme-color` values: + +| Theme | `#meta[theme-color]` | +|-------|---------------------| +| `dark` | `#0a0f0a` | +| `light` | `#f8f9fa` | + +--- + +## 5. IndexedDB Storage Schema + +Defined in `src/services/storage.ts`. + +- **Database name:** `worldmonitor_db` +- **Version:** `1` +- **Stores:** `baselines`, `snapshots` + +### Store: `baselines` + +Tracks statistical baselines for anomaly detection. + +| Field | Type | Description | +|-------|------|-------------| +| `key` | `string` (keyPath) | Metric identifier | +| `counts` | `number[]` | Rolling 30-day count observations | +| `timestamps` | `number[]` | Corresponding observation timestamps | +| `avg7d` | `number` | Rolling 7-day average | +| `avg30d` | `number` | Rolling 30-day average | +| `lastUpdated` | `number` | Last update timestamp | + +**Key operations:** + +```typescript +// Push new observation, trim to 30-day window, recalculate averages +updateBaseline(key: string, currentCount: number): Promise + +// Calculate z-score deviation level +calculateDeviation(current: number, baseline: BaselineEntry): { + zScore: number; + percentChange: number; + level: 'normal' | 'elevated' | 'spike' | 'quiet'; +} +``` + +Deviation thresholds: + +- `zScore > 2.5` → `'spike'` +- `zScore > 1.5` → `'elevated'` +- `zScore < -2` → `'quiet'` +- Otherwise → `'normal'` + +### Store: `snapshots` + +Periodic dashboard state captures for historical playback. + +| Field | Type | Description | +|-------|------|-------------| +| `timestamp` | `number` (keyPath) | Snapshot creation time | +| `events` | `unknown[]` | Event state at time of capture | +| `marketPrices` | `Record` | Market prices at capture | +| `predictions` | `Array<{title, yesPrice}>` | Prediction market state | +| `hotspotLevels` | `Record` | Hotspot intensity levels | + +**Index:** `by_time` on `timestamp`. + +**Key operations:** + +```typescript +saveSnapshot(snapshot: DashboardSnapshot): Promise +getSnapshots(fromTime?, toTime?): Promise +getSnapshotAt(timestamp: number): Promise // nearest ±15 min +cleanOldSnapshots(): Promise // removes entries older than 7 days +getSnapshotTimestamps(): Promise +``` + +**Retention:** `SNAPSHOT_RETENTION_DAYS = 7`. + +### Initialization + +```typescript +export async function initDB(): Promise { + // Opens or creates worldmonitor_db v1 + // Creates 'baselines' store (keyPath: 'key') + // Creates 'snapshots' store (keyPath: 'timestamp', index: 'by_time') +} +``` + +All store operations use a retry-aware `withTransaction()` helper that re-opens the database on `InvalidStateError` (connection closing). + +### Data Flow Through IndexedDB + +```mermaid +flowchart TD + subgraph Write Path + A["Service fetches data"] --> B["App.updateBaseline()"] + B --> C["storage.updateBaseline()"] + C --> D["IDB baselines store"] + + E["Snapshot interval fires"] --> F["App.saveSnapshot()"] + F --> G["storage.saveSnapshot()"] + G --> H["IDB snapshots store"] + end + + subgraph Read Path + I["Anomaly check"] --> J["storage.calculateDeviation()"] + J --> K["Read from baselines"] + + L["Playback mode"] --> M["storage.getSnapshotAt()"] + M --> N["Read from snapshots"] + end +``` + +--- + +## 6. URL State Encoding/Decoding + +Defined in `src/utils/urlState.ts`. Used for sharing dashboard state via links and for deep-linking. + +### ParsedMapUrlState Interface + +```typescript +export interface ParsedMapUrlState { + view?: MapView; + zoom?: number; + lat?: number; + lon?: number; + timeRange?: TimeRange; + layers?: MapLayers; + country?: string; +} +``` + +### Supported Query Parameters + +| Param | Type | Range/Values | Example | +|-------|------|-------------|---------| +| `view` | `MapView` | `global`, `america`, `mena`, `eu`, `asia`, `latam`, `africa`, `oceania` | `?view=mena` | +| `zoom` | `number` | `1–10` (clamped) | `?zoom=5` | +| `lat` | `number` | `-90–90` (clamped) | `?lat=33.2` | +| `lon` | `number` | `-180–180` (clamped) | `?lon=44.1` | +| `timeRange` | `TimeRange` | `1h`, `6h`, `24h`, `48h`, `7d`, `all` | `?t=24h` | +| `layers` | `string` | Comma-separated layer keys or `none` | `?layers=earthquakes,flights` | +| `country` | `string` | ISO 3166-1 alpha-2 code | `?country=UA` | + +### Layer Keys (29 supported) + +```typescript +const LAYER_KEYS: (keyof MapLayers)[] = [ + 'conflicts', 'bases', 'cables', 'pipelines', 'hotspots', 'ais', + 'nuclear', 'irradiators', 'sanctions', 'weather', 'economic', + 'waterways', 'outages', 'cyberThreats', 'datacenters', 'protests', + 'flights', 'military', 'natural', 'spaceports', 'minerals', 'fires', + 'ucdpEvents', 'displacement', 'climate', 'startupHubs', 'cloudRegions', + 'accelerators', 'techHQs', 'techEvents', +]; +``` + +### URL Application Flow + +```mermaid +sequenceDiagram + participant URL + participant Constructor + participant Init + participant Map + + URL->>Constructor: window.location.search + Constructor->>Constructor: parseMapUrlState(search, fallbackLayers) + Constructor->>Constructor: Store as initialUrlState + Note over Constructor: Apply layers override if present + + Constructor->>Init: init() + Init->>Init: parseMapUrlState() again for pendingDeepLinkCountry + Init->>Init: setupUrlStateSync() + Init->>Map: Apply view, zoom, center from initialUrlState +``` + +### Building Shareable URLs + +```typescript +buildMapUrl(baseUrl: string, state: { + view: MapView; + zoom: number; + center?: { lat: number; lon: number } | null; + timeRange: TimeRange; + layers: MapLayers; + country?: string; +}): string +``` + +Produces a full URL with all active state encoded in query parameters. + +--- + +## 7. Runtime Config State (Desktop) + +Defined in `src/services/runtime-config.ts`. Manages API keys and feature toggles for the desktop (Tauri) app. + +### Secret Keys + +```typescript +export type RuntimeSecretKey = + | 'GROQ_API_KEY' + | 'OPENROUTER_API_KEY' + | 'FRED_API_KEY' + | 'EIA_API_KEY' + | 'CLOUDFLARE_API_TOKEN' + | 'ACLED_ACCESS_TOKEN' + | 'URLHAUS_AUTH_KEY' + | 'OTX_API_KEY' + | 'ABUSEIPDB_API_KEY' + | 'WINGBITS_API_KEY' + | 'WS_RELAY_URL' + | 'VITE_OPENSKY_RELAY_URL' + | 'OPENSKY_CLIENT_ID' + | 'OPENSKY_CLIENT_SECRET' + | 'AISSTREAM_API_KEY' + | 'FINNHUB_API_KEY' + | 'NASA_FIRMS_API_KEY' + | 'UC_DP_KEY'; +``` + +### Feature Toggles + +```typescript +export type RuntimeFeatureId = + | 'aiGroq' | 'aiOpenRouter' + | 'economicFred' | 'energyEia' + | 'internetOutages' | 'acledConflicts' + | 'abuseChThreatIntel' | 'alienvaultOtxThreatIntel' + | 'abuseIpdbThreatIntel' | 'wingbitsEnrichment' + | 'aisRelay' | 'openskyRelay' + | 'finnhubMarkets' | 'nasaFirms'; +``` + +All toggles default to `true`. + +### Storage Model + +```mermaid +flowchart TD + subgraph Desktop + A["OS Keychain (Tauri IPC)"] -->|secrets| B["RuntimeConfig"] + C["localStorage"] -->|feature toggles| B + end + + subgraph Web + D["Environment vars / Vercel"] -->|secrets| E["RuntimeConfig"] + F["Not applicable"] -.->|feature toggles| E + end +``` + +- **Toggles key:** `worldmonitor-runtime-feature-toggles` in `localStorage` +- **Toggles format:** `Record` (JSON) +- **Secrets (desktop):** stored in the OS keychain via Tauri IPC commands (`read_secret`, `write_secret`) +- **Secrets (web):** sourced from environment variables at build time + +### RuntimeFeatureDefinition + +```typescript +export interface RuntimeFeatureDefinition { + id: RuntimeFeatureId; + name: string; + description: string; + requiredSecrets: RuntimeSecretKey[]; + desktopRequiredSecrets?: RuntimeSecretKey[]; + fallback: string; +} +``` + +Each feature definition specifies which secrets it requires. The settings UI validates that all required secrets are present before allowing a feature to be enabled. + +--- + +## 8. Activity Tracking & Idle Detection + +### Activity Tracker + +Defined in `src/services/activity-tracker.ts`. Tracks item freshness across panels. + +```typescript +export interface ActivityState { + seenIds: Set; // Items user has "seen" + firstSeenTime: Map; // When items first appeared + newCount: number; // Unseen items count + lastInteraction: number; // Last user interaction timestamp +} +``` + +**Timing constants:** + +| Constant | Value | Purpose | +|----------|-------|---------| +| `NEW_TAG_DURATION_MS` | `2 * 60 * 1000` (2 min) | Duration to show "NEW" badge | +| `HIGHLIGHT_DURATION_MS` | `30 * 1000` (30 sec) | Duration for highlight glow effect | + +**Key operations:** + +```typescript +// Initialize tracking for a panel +register(panelId: string): void + +// Update items and compute new count — returns array of new item IDs +updateItems(panelId: string, itemIds: string[]): string[] + +// Mark all items as seen (user interacted with panel) +markAsSeen(panelId: string): void +``` + +Items are "new" (show badge) for 2 minutes after first appearance, and "highlighted" (glow effect) for 30 seconds. + +### Activity Item Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> New: First observed + New --> Highlighted: 0–30 sec + Highlighted --> Tagged: 30 sec–2 min + Tagged --> Seen: markAsSeen() or 2 min elapsed + Seen --> [*]: Item removed from feed + + note right of Highlighted: Glow effect active + note right of Tagged: "NEW" badge visible +``` + +### Idle Detection + +Implemented directly in `App.ts`: + +```typescript +private readonly IDLE_PAUSE_MS = 2 * 60 * 1000; // 2 minutes +private isIdle = false; +private idleTimeoutId: ReturnType | null = null; +private boundIdleResetHandler: (() => void) | null = null; +``` + +**Behavior when idle:** + +- Animations are paused +- Refresh frequency is reduced +- The `isIdle` flag is checked by refresh methods + +**Reset triggers:** any user interaction event (mouse move, click, keydown, scroll, touch). + +```mermaid +stateDiagram-v2 + [*] --> Active: App starts + Active --> Idle: No interaction for 2 min + Idle --> Active: User interaction detected + + note right of Active: Full refresh rate, animations on + note right of Idle: Reduced refresh, animations paused +``` + +--- + +## 9. All localStorage Keys + +Complete reference of every `localStorage` key used by World Monitor: + +| Key | Purpose | Format | Source | +|-----|---------|--------|--------| +| `worldmonitor-variant` | Active variant override | `'full' \| 'tech' \| 'finance'` | `src/config/variant.ts`, `App.ts` | +| `worldmonitor-theme` | Theme preference | `'dark' \| 'light'` | `src/utils/theme-manager.ts` | +| `panel-order` | Panel arrangement order | `string[]` (JSON) | `App.ts` | +| `worldmonitor-panel-spans` | Panel grid sizes | `Record` (JSON) | `src/components/Panel.ts` | +| `worldmonitor-panels` | Panel enabled/config state | `Record` (JSON) | `STORAGE_KEYS.panels` | +| `worldmonitor-monitors` | Monitor color/label configs | `Monitor[]` (JSON) | `STORAGE_KEYS.monitors` | +| `worldmonitor-layers` | Map layer toggles | `MapLayers` (JSON) | `STORAGE_KEYS.mapLayers` | +| `worldmonitor-disabled-feeds` | User-disabled news sources | `string[]` (JSON) | `STORAGE_KEYS.disabledFeeds` | +| `worldmonitor-runtime-feature-toggles` | Desktop feature toggles | `Record` (JSON) | `src/services/runtime-config.ts` | +| `worldmonitor-persistent-cache:{key}` | Persistent data cache entries | `CacheEnvelope` (JSON) | `src/services/persistent-cache.ts` | +| `wm-update-dismissed-{version}` | Dismissed update notifications | `'1'` | `App.ts` | +| `worldmonitor-panel-order-v1.9` | Panel order migration flag | `'done'` | `App.ts` (one-time migration) | +| `worldmonitor-tech-insights-top-v1` | Tech variant migration flag | `'done'` | `App.ts` (one-time migration) | + +### CacheEnvelope Format + +Used by the persistent cache system (`src/services/persistent-cache.ts`): + +```typescript +type CacheEnvelope = { + key: string; + updatedAt: number; + data: T; +}; +``` + +On desktop (Tauri), the persistent cache prefers the Tauri IPC bridge (`read_cache_entry` / `write_cache_entry`) and falls back to `localStorage` on failure. On web, `localStorage` is always used. + +--- + +## Summary + +```mermaid +flowchart TB + subgraph "Persistent Storage" + LS["localStorage"] + IDB["IndexedDB (worldmonitor_db)"] + KC["OS Keychain (desktop only)"] + end + + subgraph "Volatile State" + APP["App.ts private properties"] + COMP["Component instances"] + TRACK["ActivityTracker"] + end + + subgraph "Entry Points" + URL["URL query params"] + ENV["Environment variables"] + end + + URL -->|"parseMapUrlState()"| APP + ENV -->|"runtime-config"| APP + LS -->|"loadFromStorage()"| APP + IDB -->|"initDB() / getBaseline()"| APP + KC -->|"Tauri IPC"| APP + + APP -->|"saveToStorage()"| LS + APP -->|"updateBaseline() / saveSnapshot()"| IDB + APP --> COMP + APP --> TRACK + + COMP -->|"savePanelSpan()"| LS +``` + +All state management is explicit and imperative. There is no reactivity system — when data changes, the code that changed it is responsible for propagating the update to every consumer. diff --git a/docs/Docs_To_Review/TAURI_VALIDATION_REPORT.md b/docs/Docs_To_Review/TAURI_VALIDATION_REPORT.md new file mode 100644 index 000000000..5dc2e1ac8 --- /dev/null +++ b/docs/Docs_To_Review/TAURI_VALIDATION_REPORT.md @@ -0,0 +1,69 @@ +# 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 + +Run these checks first so failures are classified quickly: + +1. npm registry reachability + - `npm ping` +2. crates.io sparse index reachability + - `curl -I https://index.crates.io/` +3. proxy configuration present when required by your network + - `env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY)='` + +If any of these checks fail, treat downstream desktop build failures as environment-level until the network path is fixed. + +## Commands run + +1. `npm ci` — failed because the environment blocks downloading the pinned `@tauri-apps/cli` package from npm (`403 Forbidden`). +2. `npm run typecheck` — succeeded. +3. `npm run build:full` — succeeded (warnings only). +4. `npm run desktop:build:full` — not runnable in this environment because `npm ci` failed, so the local `tauri` binary was unavailable (desktop scripts now fail fast with a clear `npm ci` remediation message when this occurs). +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. + +## Failure classification for future QA + +Use these labels in future reports so outcomes are actionable: + +1. **External environment outage** + - Symptoms: npm/crates registry requests fail with transport/auth/network errors (403/5xx/timeout/DNS/proxy), independent of repository state. + - Action: retry in a healthy network or fix credentials/proxy/mirror availability. + +2. **Expected failure: offline mode not provisioned** + - Symptoms: build is intentionally run without internet, but required offline inputs are missing (for Rust: no `vendor/` artifact, no internal mirror mapping, or offline override not enabled; for JS: no prepared package cache). + - 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: + - `npm ci` + - `npm run desktop:build:full` + +- Restricted-network path: + - Restore prebuilt offline artifacts (including `src-tauri/vendor/` or internal mirror mapping). + - Run Cargo with `source.crates-io.replace-with` mapped to vendored/internal source and `--offline` where applicable. + +After `npm ci`, desktop build uses the local `tauri` binary and does not rely on runtime `npx` package retrieval. + +## Remediation options for restricted environments + +If preflight fails, use one of these approved remediations: + +- Configure an internal npm mirror/proxy for Node packages. +- Configure an internal Cargo registry/sparse index mirror for Rust crates. +- Pre-vendor Rust crates (`src-tauri/vendor/`) and run Cargo in offline mode. +- Use CI runners that restore package/cache artifacts from a trusted internal store before builds. + +For release packaging details, see `docs/RELEASE_PACKAGING.md` (section: **Network preflight and remediation**). diff --git a/docs/Docs_To_Review/TODO_Performance.md b/docs/Docs_To_Review/TODO_Performance.md new file mode 100644 index 000000000..dc4933374 --- /dev/null +++ b/docs/Docs_To_Review/TODO_Performance.md @@ -0,0 +1,457 @@ +# WorldMonitor — Performance Optimization Roadmap + +All items below target **end-user perceived speed**: faster initial load, smoother panel rendering, +lower memory footprint, and snappier map interactions. Items are ordered roughly by expected impact. + +Priority: 🔴 High impact · 🟡 Medium impact · 🟢 Low impact (polish). + +Status: · 🔄 Partial · ❌ Not started + +--- + +## 🔴 Critical Path — First Load & Time to Interactive + +### PERF-001 — Code-Split Panels into Lazy-Loaded Chunks + +- **Impact:** 🔴 High | **Effort:** ~2 days +- **Status:** — `vite.config.ts` `manualChunks` splits panel components into a dedicated `panels` chunk, loaded in parallel with the main bundle for better caching and reduced initial parse time. +- `App.ts` statically imports all 35+ panel components, bloating the main bundle to ~1.5 MB. +- Split each panel into a dynamic `import()` and only load when the user enables that panel. +- **Implementation:** Wrap each panel constructor in `App.ts` with `await import('@/components/FooPanel')`. Use Vite's built-in chunk splitting. +- **Expected gain:** Reduce initial JS payload by 40–60%. + +### PERF-002 — Tree-Shake Unused Locale Files + +- **Impact:** 🔴 High | **Effort:** ~4 hours +- **Status:** — `src/services/i18n.ts` uses per-language dynamic `import()` via `LOCALE_LOADERS`. Only `en.json` is bundled eagerly; all other locales are lazy-loaded on demand. +- All 13 locale JSON files are bundled, but the user only needs 1 at a time. +- Dynamically `import(`@/locales/${lang}.json`)` only the active language. Pre-load the fallback (`en.json`) and lazy-load the rest. +- **Expected gain:** Save ~500 KB from initial bundle. + +### PERF-003 — Defer Non-Critical API Calls + +- **Impact:** 🔴 High | **Effort:** ~1 day +- **Status:** — `src/utils/index.ts` provides `deferToIdle()` using `requestIdleCallback` with `setTimeout` fallback. `App.loadAllData()` defers non-critical fetches (UCDP, displacement, climate, fires, stablecoins, cable activity) by 5 seconds, keeping news/markets/conflicts/CII as priority. +- `App.init()` fires ~30 fetch calls simultaneously on startup. Most are background data (UCDP, displacement, climate, fires, stablecoins). +- Prioritize: map tiles + conflicts + news + CII. Defer everything else by 5–10 seconds using `requestIdleCallback`. +- **Expected gain:** Reduce Time to Interactive by 2–3 seconds on slow connections. + +### PERF-004 — Pre-Render Critical CSS / Above-the-Fold Skeleton + +- **Impact:** 🟡 Medium | **Effort:** ~4 hours +- **Status:** — `index.html` contains an inline skeleton shell (`skeleton-shell`, `skeleton-header`, `skeleton-map`, `skeleton-panels`) with critical CSS inlined in a ` + + + + + -
+
+ + +
diff --git a/live-channels.html b/live-channels.html new file mode 100644 index 000000000..6db805c0d --- /dev/null +++ b/live-channels.html @@ -0,0 +1,14 @@ + + + + + + + Channel management - World Monitor + + + +
+ + + diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 000000000..36fdf1ea5 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,63 @@ +/** + * Vercel Edge Middleware — blocks bot/crawler traffic from API routes. + * Runs on /api/* paths only (configured via matcher below). + * Social preview bots are allowed on /api/story and /api/og-story. + */ + +const BOT_UA = + /bot|crawl|spider|slurp|archiver|wget|curl\/|python-requests|scrapy|httpclient|go-http|java\/|libwww|perl|ruby|php\/|ahrefsbot|semrushbot|mj12bot|dotbot|baiduspider|yandexbot|sogou|bytespider|petalbot|gptbot|claudebot|ccbot/i; + +const SOCIAL_PREVIEW_UA = + /twitterbot|facebookexternalhit|linkedinbot|slackbot|telegrambot|whatsapp|discordbot|redditbot/i; + +const SOCIAL_PREVIEW_PATHS = new Set(['/api/story', '/api/og-story']); + +// Public endpoints that should never be bot-blocked (version check, etc.) +const PUBLIC_API_PATHS = new Set(['/api/version']); + +// Slack uses Slack-ImgProxy to fetch OG images — distinct from Slackbot +const SOCIAL_IMAGE_UA = + /Slack-ImgProxy|Slackbot|twitterbot|facebookexternalhit|linkedinbot|telegrambot|whatsapp|discordbot|redditbot/i; + +export default function middleware(request: Request) { + const ua = request.headers.get('user-agent') ?? ''; + const url = new URL(request.url); + const path = url.pathname; + + // Allow social preview/image bots on OG image assets (bypasses Vercel Attack Challenge) + if (path.startsWith('/favico/') || path.endsWith('.png')) { + if (SOCIAL_IMAGE_UA.test(ua)) { + return; + } + } + + // Allow social preview bots on exact OG routes only + if (SOCIAL_PREVIEW_UA.test(ua) && SOCIAL_PREVIEW_PATHS.has(path)) { + return; + } + + // Public endpoints bypass all bot filtering + if (PUBLIC_API_PATHS.has(path)) { + return; + } + + // Block bots from all API routes + if (BOT_UA.test(ua)) { + return new Response('{"error":"Forbidden"}', { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // No user-agent or suspiciously short — likely a script + if (!ua || ua.length < 10) { + return new Response('{"error":"Forbidden"}', { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +export const config = { + matcher: ['/api/:path*', '/favico/:path*'], +}; diff --git a/new-world-monitor.png b/new-world-monitor.png new file mode 100644 index 000000000..3a6039720 Binary files /dev/null and b/new-world-monitor.png differ diff --git a/package-lock.json b/package-lock.json index 99804c867..b4c8f5477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,1555 +1,13089 @@ { - "name": "situation-monitor", - "version": "1.0.0", + "name": "world-monitor", + "version": "2.5.12", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "situation-monitor", - "version": "1.0.0", + "name": "world-monitor", + "version": "2.5.12", + "license": "AGPL-3.0-only", "dependencies": { + "@deck.gl/aggregation-layers": "^9.2.6", + "@deck.gl/core": "^9.2.6", + "@deck.gl/geo-layers": "^9.2.6", + "@deck.gl/layers": "^9.2.6", + "@deck.gl/mapbox": "^9.2.6", + "@sentry/browser": "^10.39.0", + "@upstash/redis": "^1.36.1", + "@vercel/analytics": "^1.6.1", + "@xenova/transformers": "^2.17.2", + "canvas-confetti": "^1.9.4", + "convex": "^1.32.0", "d3": "^7.9.0", - "topojson-client": "^3.1.0" + "deck.gl": "^9.2.6", + "fast-xml-parser": "^5.3.7", + "i18next": "^25.8.10", + "i18next-browser-languagedetector": "^8.2.1", + "maplibre-gl": "^5.16.0", + "onnxruntime-web": "^1.23.2", + "papaparse": "^5.5.3", + "posthog-js": "^1.352.0", + "topojson-client": "^3.1.0", + "youtubei.js": "^16.0.1" }, "devDependencies": { + "@playwright/test": "^1.52.0", + "@tauri-apps/cli": "^2.10.0", + "@types/canvas-confetti": "^1.9.0", "@types/d3": "^7.4.3", + "@types/maplibre-gl": "^1.13.2", + "@types/papaparse": "^5.5.2", "@types/topojson-client": "^3.1.5", "@types/topojson-specification": "^1.0.5", + "cross-env": "^7.0.3", + "esbuild": "^0.27.3", + "markdownlint-cli2": "^0.20.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vite-plugin-pwa": "^1.2.0", + "ws": "^8.19.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], + "node_modules/@amcharts/amcharts5": { + "version": "5.14.4", + "resolved": "https://registry.npmjs.org/@amcharts/amcharts5/-/amcharts5-5.14.4.tgz", + "integrity": "sha512-Tl7wQLWvsvyWVtlCIm1yhZtJviSDYjuNTnlUkO0D49GyByoK0nb9fr0DK4rUw4DVgyLcySxWBsb2lzTJm5Rd9Q==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@types/d3": "^7.0.0", + "@types/d3-chord": "^3.0.0", + "@types/d3-hierarchy": "3.1.1", + "@types/d3-sankey": "^0.11.1", + "@types/d3-shape": "^3.0.0", + "@types/geojson": "^7946.0.8", + "@types/polylabel": "^1.0.5", + "@types/svg-arc-to-cubic-bezier": "^3.2.0", + "d3": "^7.0.0", + "d3-chord": "^3.0.0", + "d3-force": "^3.0.0", + "d3-geo": "^3.0.0", + "d3-hierarchy": "^3.0.0", + "d3-sankey": "^0.12.3", + "d3-selection": "^3.0.0", + "d3-shape": "^3.0.0", + "d3-transition": "^3.0.0", + "d3-voronoi-treemap": "^1.1.2", + "flatpickr": "^4.6.13", + "markerjs2": "^2.29.4", + "pdfmake": "^0.2.2", + "polylabel": "^1.1.0", + "seedrandom": "^3.0.5", + "svg-arc-to-cubic-bezier": "^3.2.0", + "tslib": "^2.2.0" + } + }, + "node_modules/@amcharts/amcharts5/node_modules/@types/d3-hierarchy": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz", + "integrity": "sha512-QwjxA3+YCKH3N1Rs3uSiSy1bdxlLB1uUiENXeJudBoAFvtDuswUxLcanoOaR2JYn1melDTuIXR8VhnVyI3yG/A==", + "license": "MIT" + }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], + "node_modules/@arcgis/core": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@arcgis/core/-/core-4.34.8.tgz", + "integrity": "sha512-UrEBTjXpSA9fhmmnAENBzz9GG81xALTezQFMXUs2iMB+tiOckmJyBbhATI/W4lIQyUfNEK7Zm/46EP2PhDga/A==", + "license": "SEE LICENSE IN copyright.txt", + "peer": true, + "dependencies": { + "@amcharts/amcharts5": "~5.14.1", + "@arcgis/toolkit": "^4.34.0", + "@esri/arcgis-html-sanitizer": "~4.1.0", + "@esri/calcite-components": "^3.3.2", + "@vaadin/grid": "~24.9.1", + "@zip.js/zip.js": "~2.8.7", + "luxon": "~3.7.2", + "marked": "~16.3.0", + "tslib": "^2.8.1" + } + }, + "node_modules/@arcgis/lumina": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@arcgis/lumina/-/lumina-4.34.9.tgz", + "integrity": "sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@arcgis/toolkit": "~4.34.9", + "csstype": "^3.1.3", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@lit/context": "^1.1.5", + "lit": "^3.3.0" + }, + "peerDependenciesMeta": { + "@lit/context": { + "optional": true + } + } + }, + "node_modules/@arcgis/toolkit": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@arcgis/toolkit/-/toolkit-4.34.9.tgz", + "integrity": "sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@babel/types": "^7.27.3" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", + "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/types": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", - "cpu": [ - "arm" - ], + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-selection": "*" + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-selection": "*" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-selection": "*" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-dsv": "*" + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "dev": true, "license": "MIT", "dependencies": { - "@types/geojson": "*" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-color": "*" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-time": "*" + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-path": "*" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-time-format": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-selection": "*" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "license": "MIT", "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/topojson-client": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", - "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "dev": true, "license": "MIT", "dependencies": { - "@types/geojson": "*", - "@types/topojson-specification": "*" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@types/topojson-specification": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", - "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "node_modules/@babel/preset-env": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", + "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==", "dev": true, "license": "MIT", "dependencies": { - "@types/geojson": "*" + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=6.9.0" } }, - "node_modules/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-array": "3", - "d3-axis": "3", - "d3-brush": "3", - "d3-chord": "3", - "d3-color": "3", - "d3-contour": "4", - "d3-delaunay": "6", - "d3-dispatch": "3", - "d3-drag": "3", - "d3-dsv": "3", - "d3-ease": "3", - "d3-fetch": "3", - "d3-force": "3", - "d3-format": "3", - "d3-geo": "3", - "d3-hierarchy": "3", - "d3-interpolate": "3", - "d3-path": "3", - "d3-polygon": "3", - "d3-quadtree": "3", - "d3-random": "3", - "d3-scale": "4", - "d3-scale-chromatic": "3", - "d3-selection": "3", - "d3-shape": "3", - "d3-time": "3", - "d3-time-format": "4", - "d3-timer": "3", - "d3-transition": "3", - "d3-zoom": "3" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { - "node": ">=12" + "node": ">=6.9.0" } }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", "dependencies": { - "internmap": "1 - 2" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, "engines": { - "node": ">=12" - } - }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" + "node": ">=6.9.0" } }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { - "node": ">=12" + "node": ">=6.9.0" } }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@carto/api-client": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@carto/api-client/-/api-client-0.5.24.tgz", + "integrity": "sha512-BeHxVxOZsQDZlh6mnPIwK0yMejSUtReNn877NDhJ+B0LRR8Apz0tNTpvKvWiy3WApjNGrelRplYcc4dS2iflYA==", + "license": "MIT", "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" + "@loaders.gl/schema": "^4.3.3", + "@types/geojson": "^7946.0.16", + "d3-format": "^3.1.0", + "d3-scale": "^4.0.2", + "h3-js": "^4.1.0", + "jsep": "^1.4.0", + "quadbin": "^0.4.1-alpha.0" } }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/@deck.gl/aggregation-layers": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.2.6.tgz", + "integrity": "sha512-T42ZwB63KI4+0pe2HBwMQS7qnqyv3LlqAQfRSHBlFZMzBq72SxIgk9BzhrT16uBHxFFjjMh6K5g28/UfDOXQEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" } }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", + "node_modules/@deck.gl/arcgis": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/arcgis/-/arcgis-9.2.6.tgz", + "integrity": "sha512-Iu5kcb4zchNNj/pleraCqU8xVAQYq8UxwS/caC2dtk449fw9VI1WmonW6R5/8VdUC7QkWDcr5Yka53yRe8px1w==", + "license": "MIT", "dependencies": { - "d3-array": "^3.2.0" + "@luma.gl/constants": "^9.2.6", + "esri-loader": "^3.7.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@arcgis/core": "^4.0.0", + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/webgl": "~9.2.6" } }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", + "node_modules/@deck.gl/carto": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/carto/-/carto-9.2.6.tgz", + "integrity": "sha512-/k/6tEHIJ2ZIkeMRlbD+VA7WMq+yvx+6Qig5DiEyAGfBUKpxR5fJkjqtMgYacm91aRc1IskJpVM7NbioUWdluw==", + "license": "MIT", "dependencies": { - "delaunator": "5" + "@carto/api-client": "^0.5.19", + "@loaders.gl/compression": "^4.2.0", + "@loaders.gl/gis": "^4.2.0", + "@loaders.gl/loader-utils": "^4.2.0", + "@loaders.gl/mvt": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@loaders.gl/tiles": "^4.2.0", + "@luma.gl/core": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/web-mercator": "^4.1.0", + "@types/d3-array": "^3.0.2", + "@types/d3-color": "^1.4.2", + "@types/d3-scale": "^3.0.0", + "cartocolor": "^5.0.2", + "d3-array": "^3.2.0", + "d3-color": "^3.1.0", + "d3-format": "^3.1.0", + "d3-scale": "^4.0.0", + "earcut": "^2.2.4", + "h3-js": "^4.1.0", + "moment-timezone": "^0.5.33", + "pbf": "^3.2.1", + "quadbin": "^0.4.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@deck.gl/aggregation-layers": "~9.2.0", + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/geo-layers": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.6" } }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/@deck.gl/carto/node_modules/@types/d3-color": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.5.tgz", + "integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==", + "license": "MIT" + }, + "node_modules/@deck.gl/carto/node_modules/@types/d3-scale": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz", + "integrity": "sha512-YOpKj0kIEusRf7ofeJcSZQsvKbnTwpe1DUF+P2qsotqG53kEsjm7EzzliqQxMkAWdkZcHrg5rRhB4JiDOQPX+A==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "^2" } }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", + "node_modules/@deck.gl/carto/node_modules/@types/d3-time": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz", + "integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==", + "license": "MIT" + }, + "node_modules/@deck.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-bBFfwfythPPpXS/OKUMvziQ8td84mRGMnYZfqdUvfUVltzjFtQCBQUJTzgo3LubvOzSnzo8GTWskxHaZzkqdKQ==", + "license": "MIT", + "peer": true, "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/images": "^4.2.0", + "@luma.gl/constants": "^9.2.6", + "@luma.gl/core": "^9.2.6", + "@luma.gl/engine": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@luma.gl/webgl": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.0", + "@probe.gl/log": "^4.1.0", + "@probe.gl/stats": "^4.1.0", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" } }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", + "node_modules/@deck.gl/extensions": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.6.tgz", + "integrity": "sha512-HNuzo76mD6Ykc/xMEyCMH+to6/Xi+7ehG3VYToSm+R3196Ki5p58pyRHzvq9CrBDvFd3SLMe9QqRm2GTg3wn/w==", + "license": "MIT", + "peer": true, "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.6.tgz", + "integrity": "sha512-Js42GcAlzH5vHWHdg/eKSmFvx1TWlhW+d6p8Y+67/iHpcCXmx/CBmpsr1ZsQ8XYc+GY8NDAmkHe5KECDJsJiDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/3d-tiles": "^4.2.0", + "@loaders.gl/gis": "^4.2.0", + "@loaders.gl/loader-utils": "^4.2.0", + "@loaders.gl/mvt": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@loaders.gl/terrain": "^4.2.0", + "@loaders.gl/tiles": "^4.2.0", + "@loaders.gl/wms": "^4.2.0", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.5.0", + "h3-js": "^4.1.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@deck.gl/mesh-layers": "~9.2.0", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" } }, - "node_modules/d3-fetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", - "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", + "node_modules/@deck.gl/google-maps": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/google-maps/-/google-maps-9.2.6.tgz", + "integrity": "sha512-Ecmk7gcZuorEhymp9z861WZnztPg0BHmpO7wyLvVOCrW8wnU5/3EIQHFu0C2rjCh+qtRN9aDZHn36FEKjrZV8g==", + "license": "MIT", "dependencies": { - "d3-dsv": "1 - 3" + "@luma.gl/constants": "^9.2.6", + "@luma.gl/webgl": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@types/google.maps": "^3.48.6" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@luma.gl/webgl": "~9.2.6" } }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", + "node_modules/@deck.gl/json": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/json/-/json-9.2.6.tgz", + "integrity": "sha512-HLmx9bTHzW9KEc5bgdqmbcQkmC0AVu/F9lgdk9rDUyK2LATAxrcpmJf1aknJf/OddxLEQh0DVog94GwJf+0y+w==", + "license": "MIT", "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" + "jsep": "^0.3.0" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@deck.gl/core": "~9.2.0" } }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", + "node_modules/@deck.gl/json/node_modules/jsep": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-0.3.5.tgz", + "integrity": "sha512-AoRLBDc6JNnKjNcmonituEABS5bcfqDhQAWWXNTFrqu6nVXBpBAGfcoTGZMFlIrh9FjmE1CQyX9CTNwZrXMMDA==", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 6.0.0" } }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", + "node_modules/@deck.gl/layers": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.6.tgz", + "integrity": "sha512-ASwL5CHm/QX+fVW+MejmtA/84RKO0BaL2/Fv9N+l+WcSII2M5s730rrTw3JgyQ66AUGFPe1SL3JDkqsUlVJ0yg==", + "license": "MIT", + "peer": true, "dependencies": { - "d3-array": "2.5.0 - 3" + "@loaders.gl/images": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@luma.gl/shadertools": "^9.2.6", + "@mapbox/tiny-sdf": "^2.0.5", + "@math.gl/core": "^4.1.0", + "@math.gl/polygon": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "earcut": "^2.2.4" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" } }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/@deck.gl/mapbox": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.6.tgz", + "integrity": "sha512-gyqCHZwiZS8LOYY6LILQQp5YCCf++VFk/wRoGskZvhb/kdEPX2Onv8iV8pXe0h9UyMLO6Mj0wl3HlJWg2ILkrg==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@math.gl/web-mercator": "^4.1.0" } }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.6.tgz", + "integrity": "sha512-/KjhjoQJRb9lUcDE6pZlHvcto9H5iBCJtUb1/uCb8fahzEAcZBDubAn4RUWjfRyOSmzJfQHrWdNAjflNkL87Yg==", + "license": "MIT", + "peer": true, "dependencies": { - "d3-color": "1 - 3" + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6" }, - "engines": { - "node": ">=12" + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" } }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/@deck.gl/react": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/react/-/react-9.2.6.tgz", + "integrity": "sha512-wYjfX52EAeThZposplTT/vkP0dk2qOv5AryLOq/Y/DIrtA1FGe91GlL28DvDJ2YZrl6K7cFAvoXpuFZe2zYULA==", + "license": "MIT", + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/widgets": "~9.2.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" } }, - "node_modules/d3-polygon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", - "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", - "engines": { - "node": ">=12" + "node_modules/@deck.gl/widgets": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/widgets/-/widgets-9.2.6.tgz", + "integrity": "sha512-WkKP+HB90x1qwOxs5l6Dg0d1iAvf999jJGDdGUbDVsRF7+hJDv03GZY6XKpoiEW7VfcZ1y1iU2vRwV/GHuQ57g==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "^10.17.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6" } }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-random": { - "version": "3.0.1", + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esri/arcgis-html-sanitizer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@esri/arcgis-html-sanitizer/-/arcgis-html-sanitizer-4.1.0.tgz", + "integrity": "sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==", + "license": "Apache-2.0", + "dependencies": { + "xss": "1.0.13" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esri/calcite-components": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@esri/calcite-components/-/calcite-components-3.3.3.tgz", + "integrity": "sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==", + "license": "SEE LICENSE.md", + "dependencies": { + "@arcgis/lumina": ">=4.34.0-next.158 <4.35.0", + "@arcgis/toolkit": ">=4.34.0-next.158 <4.35.0", + "@esri/calcite-ui-icons": "4.3.0", + "@floating-ui/dom": "^1.6.12", + "@floating-ui/utils": "^0.2.8", + "@types/sortablejs": "^1.15.8", + "color": "^5.0.0", + "composed-offset-position": "^0.0.6", + "es-toolkit": "^1.39.8", + "focus-trap": "^7.6.5", + "interactjs": "^1.10.27", + "lit": "^3.3.0", + "sortablejs": "^1.15.6", + "timezone-groups": "^0.10.4", + "type-fest": "^4.30.1" + } + }, + "node_modules/@esri/calcite-ui-icons": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@esri/calcite-ui-icons/-/calcite-ui-icons-4.3.0.tgz", + "integrity": "sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA==", + "license": "SEE LICENSE.md", + "bin": { + "spriter": "bin/spriter.js" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@foliojs-fork/fontkit": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz", + "integrity": "sha512-IfB5EiIb+GZk+77TRB86AHroVaqfq8JRFlUbz0WEwsInyCG0epX2tCPOy+UfaWPju30DeVoUAXfzWXmhn753KA==", + "license": "MIT", + "dependencies": { + "@foliojs-fork/restructure": "^2.0.2", + "brotli": "^1.2.0", + "clone": "^1.0.4", + "deep-equal": "^1.0.0", + "dfa": "^1.2.0", + "tiny-inflate": "^1.0.2", + "unicode-properties": "^1.2.2", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/linebreak/-/linebreak-1.1.2.tgz", + "integrity": "sha512-ZPohpxxbuKNE0l/5iBJnOAfUaMACwvUIKCvqtWGKIMv1lPYoNjYXRfhi9FeeV9McBkBLxsMFWTVVhHJA8cyzvg==", + "license": "MIT", + "dependencies": { + "base64-js": "1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak/node_modules/base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", + "license": "MIT" + }, + "node_modules/@foliojs-fork/pdfkit": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@foliojs-fork/pdfkit/-/pdfkit-0.15.3.tgz", + "integrity": "sha512-Obc0Wmy3bm7BINFVvPhcl2rnSSK61DQrlHU8aXnAqDk9LCjWdUOPwhgD8Ywz5VtuFjRxmVOM/kQ/XLIBjDvltw==", + "license": "MIT", + "dependencies": { + "@foliojs-fork/fontkit": "^1.9.2", + "@foliojs-fork/linebreak": "^1.1.1", + "crypto-js": "^4.2.0", + "jpeg-exif": "^1.1.4", + "png-js": "^1.0.0" + } + }, + "node_modules/@foliojs-fork/restructure": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/restructure/-/restructure-2.0.2.tgz", + "integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==", + "license": "MIT" + }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@interactjs/types": { + "version": "1.10.27", + "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz", + "integrity": "sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", + "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/gltf": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@loaders.gl/tiles": "4.3.4", + "@loaders.gl/zip": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.0.4", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", + "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/brotli": "^1.3.0", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "lzo-wasm": "^0.0.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/core": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", + "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2" + } + }, + "node_modules/@loaders.gl/crypto": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", + "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", + "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/textures": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/images": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", + "integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/loader-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz", + "integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/math": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", + "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", + "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.0.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/schema": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", + "integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/terrain": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", + "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", + "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", + "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/xml": "4.3.4", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/worker-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", + "integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==", + "license": "MIT", + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/xml": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", + "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "fast-xml-parser": "^4.2.5" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", + "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@luma.gl/constants": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", + "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", + "license": "MIT", + "peer": true + }, + "node_modules/@luma.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz", + "integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, + "node_modules/@luma.gl/gltf": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.2.6.tgz", + "integrity": "sha512-is3YkiGsWqWTmwldMz6PRaIUleufQfUKYjJTKpsF5RS1OnN+xdAO0mJq5qJTtOQpppWAU0VrmDFEVZ6R3qvm0A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/textures": "^4.2.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/constants": "~9.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, + "node_modules/@luma.gl/shadertools": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", + "integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "wgsl_reflect": "^1.2.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz", + "integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "9.2.6", + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz", + "integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.2.tgz", + "integrity": "sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/mlt/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz", + "integrity": "sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@maplibre/vt-pbf/node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/polygon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-wc/dedupe-mixin": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", + "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==", + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polymer/polymer": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz", + "integrity": "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==", + "license": "BSD-3-Clause", + "dependencies": { + "@webcomponents/shadycss": "^1.9.1" + } + }, + "node_modules/@posthog/core": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.1.tgz", + "integrity": "sha512-GViD5mOv/mcbZcyzz3z9CS0R79JzxVaqEz4sP5Dsea178M/j3ZWe6gaHDZB9yuyGfcmIMQ/8K14yv+7QrK4sQQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, + "node_modules/@posthog/types": { + "version": "1.352.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.352.0.tgz", + "integrity": "sha512-pp7VBMlkhlLmv2TyOoss028lPPD4ElnZlX5y3hqq6oijK5BMZbjVuTAgvFYNLiKbuze/i5ndFGyXTtfCwlMQeA==", + "license": "MIT" + }, + "node_modules/@probe.gl/env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", + "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz", + "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.0" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz", + "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.39.0.tgz", + "integrity": "sha512-W6WODonMGiI13Az5P7jd/m2lj/JpIyuVKg7wE4X+YdlMehLspAv6I7gRE4OBSumS14ZjdaYDpD/lwtnBwKAzcA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.39.0.tgz", + "integrity": "sha512-cRXmmDeOr5FzVsBNRLU4WDEuC3fhuD0XV362EWl4DI3XBGao8ukaueKcLIKic5WZx6uXimjWw/UJmDLgxeCqkg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.39.0.tgz", + "integrity": "sha512-obZoYOrUfxIYBHkmtPpItRdE38VuzF1VIxSgZ8Mbtq/9UvCWh+eOaVWU2stN/cVu1KYuYX0nQwBvdN28L6y/JA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.39.0", + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.39.0.tgz", + "integrity": "sha512-TTiX0XWCcqTqFGJjEZYObk93j/sJmXcqPzcu0cN2mIkKnnaHDY3w74SHZCshKqIr0AOQdt1HDNa36s3TCdt0Jw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.39.0", + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.39.0.tgz", + "integrity": "sha512-I50W/1PDJWyqgNrGufGhBYCmmO3Bb159nx2Ut2bKoVveTfgH/hLEtDyW0kHo8Fu454mW+ukyXfU4L4s+kB9aaw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.39.0", + "@sentry-internal/feedback": "10.39.0", + "@sentry-internal/replay": "10.39.0", + "@sentry-internal/replay-canvas": "10.39.0", + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz", + "integrity": "sha512-xCLip2mBwCdRrvXHtVEULX0NffUTYZZBhEUGht0WFL+GNdNQ7gmBOGOczhZlrf2hgFFtDO0fs1xiP9bqq5orEQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", + "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.0", + "@tauri-apps/cli-darwin-x64": "2.10.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-musl": "2.10.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", + "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", + "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", + "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", + "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", + "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", + "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", + "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", + "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", + "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", + "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", + "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@turf/boolean-clockwise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, + "node_modules/@types/brotli": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", + "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/maplibre-gl": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/maplibre-gl/-/maplibre-gl-1.13.2.tgz", + "integrity": "sha512-IC1RBMhKXpGDpiFsEwt17c/hbff0GCS/VmzqmrY6G+kyy2wfv2e7BoSQRAfqrvhBQPCoO8yc0SNCi5HkmCcVqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/polylabel": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", + "integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/svg-arc-to-cubic-bezier": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.3.tgz", + "integrity": "sha512-UNOnbTtl0nVTm8hwKaz5R5VZRvSulFMGojO5+Q7yucKxBoCaTtS4ibSQVRHo5VW5AaRo145U8p1Vfg5KrYe9Bg==", + "license": "MIT" + }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@upstash/redis": { + "version": "1.36.1", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.1.tgz", + "integrity": "sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/@vaadin/a11y-base": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-24.9.10.tgz", + "integrity": "sha512-76KNDhKn8zyqzWwNWx0BcYNQXtEdoq0FgMR7vYz8qSj4zGvu8wf0GuQavTI7Nnia8pk0jRqT2/NZrJR3YLCLJQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/checkbox": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-24.9.10.tgz", + "integrity": "sha512-08CnG3T02iHTtXD2SVrW+RHFwTOgSq9JvV8edijAxdX27cRbVJGJX2M1zupPLUEtWJEZK5uvK/2HkJzDrTjBdA==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.9.10", + "@vaadin/component-base": "~24.9.10", + "@vaadin/field-base": "~24.9.10", + "@vaadin/vaadin-lumo-styles": "~24.9.10", + "@vaadin/vaadin-material-styles": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/component-base": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-24.9.10.tgz", + "integrity": "sha512-CM9ZligxBd+PJKLEHiz8YVvPGm5XAuJ5YzKUTmslqTo8aPgXWJBchbNyf47xL7XwIWCVy3sfNZYDHGN7zuMJ8A==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/vaadin-development-mode-detector": "^2.0.0", + "@vaadin/vaadin-usage-statistics": "^2.1.0", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/field-base": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-24.9.10.tgz", + "integrity": "sha512-t4x1HCOESJ7mWxgS7aiwPJVkf00MXbEs43p24JYsEWr78Ivn+4k1+5SZ2mli0HgkmVn89aUbMqkU10YpHIN4Yw==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.9.10", + "@vaadin/component-base": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/grid": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-24.9.10.tgz", + "integrity": "sha512-9VVnRw4bAwHVIpan8rqMfTJRQ3WbtRxoTrySczZlnQmWaQiBphaXsIdhd9DUy9OjRzteVTHnU6mtuH1aZJl8XA==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.9.10", + "@vaadin/checkbox": "~24.9.10", + "@vaadin/component-base": "~24.9.10", + "@vaadin/lit-renderer": "~24.9.10", + "@vaadin/text-field": "~24.9.10", + "@vaadin/vaadin-lumo-styles": "~24.9.10", + "@vaadin/vaadin-material-styles": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/icon": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/icon/-/icon-24.9.10.tgz", + "integrity": "sha512-3HAn5vesU9gPBN8loGjajaOxEsTkNo1xdEiRQ6s8KA81TyORBH49O4dGprnUUoRA1sOtwNcnck2WAGa7Imh+Yg==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.9.10", + "@vaadin/vaadin-lumo-styles": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/input-container": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-24.9.10.tgz", + "integrity": "sha512-c/y5RXuNsb4IUFdJKhXCfvihk35N5Ztk7nBJ0XRaOTqf6I9tPgwVeq8Gj/VcHbwNBw67pv7VLxF/5OuJIsgthA==", + "license": "Apache-2.0", + "dependencies": { + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.9.10", + "@vaadin/vaadin-lumo-styles": "~24.9.10", + "@vaadin/vaadin-material-styles": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/lit-renderer": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-24.9.10.tgz", + "integrity": "sha512-1GLggQZyG5qh2OtuidiKVOS83GS9qGWuGgZk2u676AirH/rcsg6O4sABstrNCU/TTOLeo1rTfPC6j0DiC9uXfg==", + "license": "Apache-2.0", + "dependencies": { + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/text-field": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-24.9.10.tgz", + "integrity": "sha512-8kJKH7EdAuvdRXO+ckOLhIvy/syFa0PM7JD/y20kSZC5MWQx7wCbXH4uKddHj8JUnak217WcZfvcJ6GaD2lmWA==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.9.10", + "@vaadin/component-base": "~24.9.10", + "@vaadin/field-base": "~24.9.10", + "@vaadin/input-container": "~24.9.10", + "@vaadin/vaadin-lumo-styles": "~24.9.10", + "@vaadin/vaadin-material-styles": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/vaadin-development-mode-detector": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz", + "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==", + "license": "Apache-2.0" + }, + "node_modules/@vaadin/vaadin-lumo-styles": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-lumo-styles/-/vaadin-lumo-styles-24.9.10.tgz", + "integrity": "sha512-NXUxrl537GrwJG07usUwyDYPVL7aUEBZALGLiTJ+A0om69q155hbpFchPPVexLjBHRn8y7Cdnox+VH/TIJBqBw==", + "license": "Apache-2.0", + "dependencies": { + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.9.10", + "@vaadin/icon": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10" + } + }, + "node_modules/@vaadin/vaadin-material-styles": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-material-styles/-/vaadin-material-styles-24.9.10.tgz", + "integrity": "sha512-jkDiWqqHHGPQ/SqILUheb2Nf/yRssosxu42Qe/e3N8j+Hc2uJb3yN4k9DuR8S2dmfGR3WKi16kWxaXKwlkXMYQ==", + "license": "Apache-2.0", + "dependencies": { + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.9.10", + "@vaadin/vaadin-themable-mixin": "~24.9.10" + } + }, + "node_modules/@vaadin/vaadin-themable-mixin": { + "version": "24.9.10", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-24.9.10.tgz", + "integrity": "sha512-2JG9hmM9REQx2GSzZ6/16/fIgBhNP+btil896GFTsj9ZTwMcPTyvZ7/uP8B8Gnm6MGoyGr0nNoeE9/M3dNpGPQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "lit": "^3.0.0", + "style-observer": "^0.0.8" + } + }, + "node_modules/@vaadin/vaadin-usage-statistics": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.3.tgz", + "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@vaadin/vaadin-development-mode-detector": "^2.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@vercel/analytics": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", + "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", + "license": "MPL-2.0", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/@webcomponents/shadycss": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.11.2.tgz", + "integrity": "sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==", + "license": "BSD-3-Clause" + }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, + "node_modules/@xenova/transformers/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/@xenova/transformers/node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.15", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.15.tgz", + "integrity": "sha512-HZKJLFe4eGVgCe9J87PnijY7T1Zn638bEHS+Fm/ygHZozRpefzWcOYfPaP52S8pqk9g4xN3+LzMDl3Lv9dLglA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/a5-js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", + "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", + "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.6", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", + "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.6", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", + "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.6" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, + "node_modules/cartocolor": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/cartocolor/-/cartocolor-5.0.2.tgz", + "integrity": "sha512-Ihb/wU5V6BVbHwapd8l/zg7bnhZ4YPFVfa7quSpL86lfkPJSf4YuNBT+EvesPRP5vSqhl6vZVsQJwCR8alBooQ==", + "license": "CC-BY-4.0", + "dependencies": { + "colorbrewer": "1.5.6" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/colorbrewer": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/colorbrewer/-/colorbrewer-1.5.6.tgz", + "integrity": "sha512-fONg2pGXyID8zNgKHBlagW8sb/AMShGzj4rRJfz5biZ7iuHQZYquSCLE/Co1oSQFmt/vvwjyezJCejQl7FG/tg==", + "license": [ + { + "type": "Apache-Style", + "url": "https://github.com/saikocat/colorbrewer/blob/master/LICENSE.txt" + } + ] + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/composed-offset-position": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", + "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/utils": "^0.2.5" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convex": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.32.0.tgz", + "integrity": "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw==", + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.18.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/convex/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", "license": "ISC", "engines": { - "node": ">=12" + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-voronoi-map": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-voronoi-map/-/d3-voronoi-map-2.1.1.tgz", + "integrity": "sha512-mCXfz/kD9IQxjHaU2IMjkO8fSo4J6oysPR2iL+omDsCy1i1Qn6BQ/e4hEAW8C6ms2kfuHwqtbNom80Hih94YsA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "2.*", + "d3-polygon": "2.*", + "d3-timer": "2.*", + "d3-weighted-voronoi": "1.*" + } + }, + "node_modules/d3-voronoi-map/node_modules/d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-voronoi-map/node_modules/d3-polygon": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz", + "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-voronoi-map/node_modules/d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-voronoi-treemap": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi-treemap/-/d3-voronoi-treemap-1.1.2.tgz", + "integrity": "sha512-7odu9HdG/yLPWwzDteJq4yd9Q/NwgQV7IE/u36VQtcCK7k1sZwDqbkHCeMKNTBsq5mQjDwolTsrXcU0j8ZEMCA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-voronoi-map": "2.*" + } + }, + "node_modules/d3-weighted-voronoi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/d3-weighted-voronoi/-/d3-weighted-voronoi-1.1.3.tgz", + "integrity": "sha512-C3WdvSKl9aqhAy+f3QT3PPsQG6V+ajDfYO3BSclQDSD+araW2xDBFIH67aKzsSuuuKaX8K2y2dGq1fq/dWTVig==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2", + "d3-polygon": "2" + } + }, + "node_modules/d3-weighted-voronoi/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-weighted-voronoi/node_modules/d3-polygon": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz", + "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-weighted-voronoi/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deck.gl": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/deck.gl/-/deck.gl-9.2.6.tgz", + "integrity": "sha512-33uoKFFJxgwJhscf+Tlwes+E3YG1aPUiBw9uec6wEt4gwNHj3NNfsVm3AkRYUPhQzVF0vruoOaR7Qt0ak03hvw==", + "license": "MIT", + "dependencies": { + "@deck.gl/aggregation-layers": "9.2.6", + "@deck.gl/arcgis": "9.2.6", + "@deck.gl/carto": "9.2.6", + "@deck.gl/core": "9.2.6", + "@deck.gl/extensions": "9.2.6", + "@deck.gl/geo-layers": "9.2.6", + "@deck.gl/google-maps": "9.2.6", + "@deck.gl/json": "9.2.6", + "@deck.gl/layers": "9.2.6", + "@deck.gl/mapbox": "9.2.6", + "@deck.gl/mesh-layers": "9.2.6", + "@deck.gl/react": "9.2.6", + "@deck.gl/widgets": "9.2.6", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "^9.2.6", + "@luma.gl/engine": "^9.2.6" + }, + "peerDependencies": { + "@arcgis/core": "^4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "@arcgis/core": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esri-loader": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/esri-loader/-/esri-loader-3.7.0.tgz", + "integrity": "sha512-cB1Sw9EQjtW4mtT7eFBjn/6VaaIWNTjmTd2asnnEyuZk1xVSFRMCfLZSBSjZM7ZarDcVu5WIjOP0t0MYVu4hVQ==", + "deprecated": "Use @arcgis/core instead.", + "license": "Apache-2.0" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-15.0.0.tgz", + "integrity": "sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/i18next": { + "version": "25.8.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz", + "integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/interactjs": { + "version": "1.10.27", + "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz", + "integrity": "sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==", + "license": "MIT", + "dependencies": { + "@interactjs/types": "1.10.27" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/katex": { + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lit": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/lzo-wasm": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", + "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", + "license": "BSD-2-Clause" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/maplibre-gl": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.16.0.tgz", + "integrity": "sha512-/VDY89nr4jgLJyzmhy325cG6VUI02WkZ/UfVuDbG/piXzo6ODnM+omDFIwWY8tsEsBG26DNDmNMn3Y2ikHsBiA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.2", + "@maplibre/vt-pbf": "^4.2.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/markdownlint": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz", + "integrity": "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.20.0.tgz", + "integrity": "sha512-esPk+8Qvx/f0bzI7YelUeZp+jCtFOk3KjZ7s9iBQZ6HlymSXoTtWGiIRZP05/9Oy2ehIoIjenVwndxGtxOIJYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "globby": "15.0.0", + "js-yaml": "4.1.1", + "jsonc-parser": "3.3.1", + "markdown-it": "14.1.0", + "markdownlint": "0.40.0", + "markdownlint-cli2-formatter-default": "0.0.6", + "micromatch": "4.0.8" + }, + "bin": { + "markdownlint-cli2": "markdownlint-cli2-bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli2-formatter-default": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/markdownlint-cli2-formatter-default/-/markdownlint-cli2-formatter-default-0.0.6.tgz", + "integrity": "sha512-VVDGKsq9sgzu378swJ0fcHfSicUnMxnL8gnLm/Q4J/xsNJ4e5bA6lvAz7PCzIl0/No0lHyaWdqVD2jotxOSFMQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + }, + "peerDependencies": { + "markdownlint-cli2": ">=0.0.4" + } + }, + "node_modules/marked": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", + "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/markerjs2": { + "version": "2.32.7", + "resolved": "https://registry.npmjs.org/markerjs2/-/markerjs2-2.32.7.tgz", + "integrity": "sha512-HeFRZjmc43DOG3lSQp92z49cq2oCYpYn2pX++SkJAW1Dij4xJtRquVRf+cXeSZQWDX3ufns1Ry/bGk+zveP7rA==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mjolnir.js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", + "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", + "license": "MIT" + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnx-proto/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/onnx-proto/node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.23.2.tgz", + "integrity": "sha512-T09JUtMn+CZLk3mFwqiH0lgQf+4S7+oYHHtk6uhaYAAJI95bTcKi5bOOZYwORXfS/RLZCjDDEXGWIuOCAFlEjg==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.23.2", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.2.tgz", + "integrity": "sha512-5LFsC9Dukzp2WV6kNHYLNzp8sT6V02IubLCbzw2Xd6X5GOlr65gAX6xiJwyi2URJol/s71gaQLC5F2C25AAR2w==", + "license": "MIT" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/pdfmake": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.2.23.tgz", + "integrity": "sha512-A/IksoKb/ikOZH1edSDJ/2zBbqJKDghD4+fXn3rT7quvCJDlsZMs3NmIB3eajLMMFU9Bd3bZPVvlUMXhvFI+bQ==", + "license": "MIT", + "dependencies": { + "@foliojs-fork/linebreak": "^1.1.2", + "@foliojs-fork/pdfkit": "^0.15.3", + "iconv-lite": "^0.7.1", + "xmldoc": "^2.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pdfmake/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, + "node_modules/polylabel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz", + "integrity": "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==", + "license": "ISC", + "dependencies": { + "tinyqueue": "^2.0.3" + } + }, + "node_modules/polylabel/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/posthog-js": { + "version": "1.352.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.352.0.tgz", + "integrity": "sha512-LxLKyoE+Y2z+WQ8CTO3PqQQDBuz64mHLJUoRuAYNXmp3vtxzrygZEz7UNnCT+BZ4/G44Qeq6JDYk1TRS7pIRDA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@posthog/core": "1.23.1", + "@posthog/types": "1.352.0", + "core-js": "^3.38.1", + "dompurify": "^3.3.1", + "fflate": "^0.4.8", + "preact": "^10.28.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.1.0" + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/preact": { + "version": "10.28.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", + "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quadbin": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/quadbin/-/quadbin-0.4.2.tgz", + "integrity": "sha512-1NFzjFVM23Um51/ttD6lFDqGtUHNS5Ky1slZHk3YPwMbC+7Jl3ULLb4QvDo6+Nerv8b8SgUV+ysOhziUh4B5cQ==", + "license": "MIT", + "dependencies": { + "@math.gl/web-mercator": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/sharp/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/sharp/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/sharp/node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smob": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/style-observer": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/style-observer/-/style-observer-0.0.8.tgz", + "integrity": "sha512-UaIPn33Sx4BJ+goia51Q++VFWoplWK1995VdxQYzwwbFa+FUNLKlG+aiIdG2Vw7VyzIUBi8tqu8mTyg0Ppu6Yg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/LeaVerou" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/leaverou" + } + ], + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, + "node_modules/timezone-groups": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/timezone-groups/-/timezone-groups-0.10.4.tgz", + "integrity": "sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-pwa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", + "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" + "node": ">=18" } }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12" - } - }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" + "node": ">=18" } }, - "node_modules/esbuild": { + "node_modules/vite/node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", @@ -1591,326 +13125,556 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/wgsl_reflect": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", + "integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "dev": true, "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, "engines": { - "node": ">=12.0.0" + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", + "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", + "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-build": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", + "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^2.4.1", + "@rollup/plugin-terser": "^0.4.3", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.79.2", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.0", + "workbox-broadcast-update": "7.4.0", + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-google-analytics": "7.4.0", + "workbox-navigation-preload": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-range-requests": "7.4.0", + "workbox-recipes": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0", + "workbox-streams": "7.4.0", + "workbox-sw": "7.4.0", + "workbox-window": "7.4.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "@types/babel__core": { "optional": true } } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/workbox-build/node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" }, "engines": { - "node": ">=0.10.0" + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", + "node_modules/workbox-build/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-build/node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", + "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", + "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", + "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", + "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.0", + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/workbox-navigation-preload": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", + "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "dependencies": { + "workbox-core": "7.4.0" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/workbox-precaching": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", + "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/workbox-range-requests": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", + "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "workbox-core": "7.4.0" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "node_modules/workbox-recipes": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", + "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" } }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" + "node_modules/workbox-routing": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", + "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "node_modules/workbox-strategies": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", + "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" + "workbox-core": "7.4.0" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" + "node_modules/workbox-streams": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", + "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0" + } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "node_modules/workbox-sw": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", + "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", + "dev": true, "license": "MIT" }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/workbox-window": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", + "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.0" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmldoc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz", + "integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==", + "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "sax": "^1.4.3" }, "engines": { "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/topojson-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", - "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", - "license": "ISC", + "node_modules/xss": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.13.tgz", + "integrity": "sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==", + "license": "MIT", "dependencies": { - "commander": "2" + "commander": "^2.20.3", + "cssfilter": "0.0.10" }, "bin": { - "topo2geo": "bin/topo2geo", - "topomerge": "bin/topomerge", - "topoquantize": "bin/topoquantize" + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" } }, - "node_modules/topojson-client/node_modules/commander": { + "node_modules/xss/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "license": "ISC" }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, + "node_modules/youtubei.js": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-16.0.1.tgz", + "integrity": "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==", + "funding": [ + "https://github.com/sponsors/LuanRT" + ], "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "@bufbuild/protobuf": "^2.0.0", + "meriyah": "^6.1.4" } + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true } } } diff --git a/package.json b/package.json index 0bcc4f414..aed34ec6d 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,96 @@ { "name": "world-monitor", "private": true, - "version": "1.0.0", + "version": "2.5.16", + "license": "AGPL-3.0-only", "type": "module", "scripts": { + "lint:md": "markdownlint-cli2 '**/*.md' '!.agent/**' '!.agents/**' '!.claude/**' '!.factory/**' '!.windsurf/**' '!skills/**' '!docs/internal/**' '!docs/Docs_To_Review/**'", + "version:sync": "node scripts/sync-desktop-version.mjs", + "version:check": "node scripts/sync-desktop-version.mjs --check", "dev": "vite", + "dev:tech": "cross-env VITE_VARIANT=tech vite", + "dev:finance": "cross-env VITE_VARIANT=finance vite", + "dev:happy": "cross-env VITE_VARIANT=happy vite", "build": "tsc && vite build", - "preview": "vite preview" + "build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs", + "build:desktop": "node scripts/build-sidecar-sebuf.mjs && tsc && vite build", + "build:full": "cross-env-shell VITE_VARIANT=full \"tsc && vite build\"", + "build:tech": "cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"", + "build:finance": "cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"", + "build:happy": "cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"", + "typecheck": "tsc --noEmit", + "tauri": "tauri", + "preview": "vite preview", + "test:e2e:full": "cross-env VITE_VARIANT=full playwright test", + "test:e2e:tech": "cross-env VITE_VARIANT=tech playwright test", + "test:e2e:finance": "cross-env VITE_VARIANT=finance playwright test", + "test:e2e:runtime": "cross-env VITE_VARIANT=full playwright test e2e/runtime-fetch.spec.ts", + "test:e2e": "npm run test:e2e:runtime && npm run test:e2e:full && npm run test:e2e:tech && npm run test:e2e:finance", + "test:data": "node --test tests/*.test.mjs", + "test:sidecar": "node --test src-tauri/sidecar/local-api-server.test.mjs api/_cors.test.mjs api/youtube/embed.test.mjs api/cyber-threats.test.mjs api/usni-fleet.test.mjs scripts/ais-relay-rss.test.cjs api/loaders-xml-wms-regression.test.mjs", + "test:e2e:visual:full": "cross-env VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\"", + "test:e2e:visual:tech": "cross-env VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\"", + "test:e2e:visual": "npm run test:e2e:visual:full && npm run test:e2e:visual:tech", + "test:e2e:visual:update:full": "cross-env VITE_VARIANT=full playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots", + "test:e2e:visual:update:tech": "cross-env VITE_VARIANT=tech playwright test -g \"matches golden screenshots per layer and zoom\" --update-snapshots", + "test:e2e:visual:update": "npm run test:e2e:visual:update:full && npm run test:e2e:visual:update:tech", + "desktop:dev": "npm run version:sync && cross-env-shell VITE_DESKTOP_RUNTIME=1 \"tauri dev -f devtools\"", + "desktop:build:full": "npm run version:sync && cross-env-shell VITE_VARIANT=full VITE_DESKTOP_RUNTIME=1 \"tauri build\"", + "desktop:build:tech": "npm run version:sync && cross-env-shell VITE_VARIANT=tech VITE_DESKTOP_RUNTIME=1 \"tauri build --config src-tauri/tauri.tech.conf.json\"", + "desktop:build:finance": "npm run version:sync && cross-env-shell VITE_VARIANT=finance VITE_DESKTOP_RUNTIME=1 \"tauri build --config src-tauri/tauri.finance.conf.json\"", + "desktop:package:macos:full": "node scripts/desktop-package.mjs --os macos --variant full", + "desktop:package:macos:tech": "node scripts/desktop-package.mjs --os macos --variant tech", + "desktop:package:windows:full": "node scripts/desktop-package.mjs --os windows --variant full", + "desktop:package:windows:tech": "node scripts/desktop-package.mjs --os windows --variant tech", + "desktop:package:macos:full:sign": "node scripts/desktop-package.mjs --os macos --variant full --sign", + "desktop:package:macos:tech:sign": "node scripts/desktop-package.mjs --os macos --variant tech --sign", + "desktop:package:windows:full:sign": "node scripts/desktop-package.mjs --os windows --variant full --sign", + "desktop:package:windows:tech:sign": "node scripts/desktop-package.mjs --os windows --variant tech --sign", + "desktop:package": "node scripts/desktop-package.mjs" }, "devDependencies": { + "cross-env": "^7.0.3", + "@playwright/test": "^1.52.0", + "@tauri-apps/cli": "^2.10.0", + "@types/canvas-confetti": "^1.9.0", "@types/d3": "^7.4.3", + "@types/maplibre-gl": "^1.13.2", + "@types/papaparse": "^5.5.2", "@types/topojson-client": "^3.1.5", "@types/topojson-specification": "^1.0.5", + "esbuild": "^0.27.3", + "markdownlint-cli2": "^0.20.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vite-plugin-pwa": "^1.2.0", + "ws": "^8.19.0" }, "dependencies": { + "@deck.gl/aggregation-layers": "^9.2.6", + "@deck.gl/core": "^9.2.6", + "@deck.gl/geo-layers": "^9.2.6", + "@deck.gl/layers": "^9.2.6", + "@deck.gl/mapbox": "^9.2.6", + "@sentry/browser": "^10.39.0", + "@upstash/redis": "^1.36.1", + "@vercel/analytics": "^1.6.1", + "@xenova/transformers": "^2.17.2", + "canvas-confetti": "^1.9.4", + "convex": "^1.32.0", "d3": "^7.9.0", - "topojson-client": "^3.1.0" + "deck.gl": "^9.2.6", + "fast-xml-parser": "^5.3.7", + "i18next": "^25.8.10", + "i18next-browser-languagedetector": "^8.2.1", + "maplibre-gl": "^5.16.0", + "onnxruntime-web": "^1.23.2", + "papaparse": "^5.5.3", + "posthog-js": "^1.352.0", + "topojson-client": "^3.1.0", + "youtubei.js": "^16.0.1" + }, + "overrides": { + "fast-xml-parser": "^5.3.7" } } diff --git a/page-load-baseline.png b/page-load-baseline.png new file mode 100644 index 000000000..5d577ada3 Binary files /dev/null and b/page-load-baseline.png differ diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..38042fc2a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + workers: 1, + timeout: 90000, + expect: { + timeout: 30000, + }, + retries: 0, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + viewport: { width: 1280, height: 720 }, + colorScheme: 'dark', + locale: 'en-US', + timezoneId: 'UTC', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: ['--use-angle=swiftshader', '--use-gl=swiftshader'], + }, + }, + }, + ], + snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}', + webServer: { + command: 'VITE_E2E=1 npm run dev -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173/tests/map-harness.html', + reuseExistingServer: false, + timeout: 120000, + }, +}); diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml new file mode 100644 index 000000000..4bfe03778 --- /dev/null +++ b/proto/buf.gen.yaml @@ -0,0 +1,31 @@ +version: v2 + +# Managed mode - automatically manages go_package options required by Go-based protoc plugins +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/worldmonitor/proto + - file_option: go_package_prefix + module: buf.build/bufbuild/protovalidate + value: "" + # sebuf protos are now vendored locally (no BSR module override needed) + +plugins: + - local: protoc-gen-ts-client + out: ../src/generated/client + opt: + - paths=source_relative + + - local: protoc-gen-ts-server + out: ../src/generated/server + opt: + - paths=source_relative + + - local: protoc-gen-openapiv3 + out: ../docs/api + + - local: protoc-gen-openapiv3 + out: ../docs/api + opt: + - format=json diff --git a/proto/buf.lock b/proto/buf.lock new file mode 100644 index 000000000..d15a11700 --- /dev/null +++ b/proto/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 80ab13bee0bf4272b6161a72bf7034e0 + digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1 diff --git a/proto/buf.yaml b/proto/buf.yaml new file mode 100644 index 000000000..2113a72e5 --- /dev/null +++ b/proto/buf.yaml @@ -0,0 +1,16 @@ +version: v2 +deps: + - buf.build/bufbuild/protovalidate +lint: + use: + - STANDARD + - COMMENTS + enum_zero_value_suffix: _UNSPECIFIED + service_suffix: Service + ignore: + - sebuf +breaking: + use: + - FILE + - PACKAGE + - WIRE_JSON diff --git a/proto/sebuf/http/annotations.proto b/proto/sebuf/http/annotations.proto new file mode 100644 index 000000000..adcdf8946 --- /dev/null +++ b/proto/sebuf/http/annotations.proto @@ -0,0 +1,92 @@ +syntax = "proto3"; + +package sebuf.http; + +import "google/protobuf/descriptor.proto"; + +option go_package = "github.com/SebastienMelki/sebuf/http;http"; + +// HttpMethod specifies the HTTP verb for an RPC method +enum HttpMethod { + // Unspecified defaults to POST for backward compatibility + HTTP_METHOD_UNSPECIFIED = 0; + HTTP_METHOD_GET = 1; + HTTP_METHOD_POST = 2; + HTTP_METHOD_PUT = 3; + HTTP_METHOD_DELETE = 4; + HTTP_METHOD_PATCH = 5; +} + +// HttpConfig defines HTTP-specific configuration for an RPC method +message HttpConfig { + // The HTTP path for this method (supports path variables like /users/{id}) + string path = 1; + + // The HTTP method (GET, POST, PUT, DELETE, PATCH). Defaults to POST if unspecified. + HttpMethod method = 2; +} + +// Extension for method options +extend google.protobuf.MethodOptions { + HttpConfig config = 50003; +} + +// ServiceConfig defines HTTP-specific configuration for an entire service +message ServiceConfig { + // Base path prefix for all methods in this service + string base_path = 1; +} + +// Extension for service options +extend google.protobuf.ServiceOptions { + ServiceConfig service_config = 50004; +} + +// FieldExamples defines example values for a field +message FieldExamples { + // List of example values for this field + repeated string values = 1; +} + +// QueryConfig defines query parameter configuration for a message field +message QueryConfig { + // The query parameter name in the URL (e.g., "page_size" for ?page_size=10) + string name = 1; + + // Whether this query parameter is required + bool required = 2; +} + +// Int64Encoding specifies how int64 fields should be encoded in generated TypeScript. +// By default, int64 fields generate as `string` for JSON safety. When set to +// INT64_ENCODING_NUMBER, the field generates as `number` instead -- suitable for +// values that fit within Number.MAX_SAFE_INTEGER (e.g., Unix epoch milliseconds). +enum Int64Encoding { + // Unspecified -- use default behavior (string). + INT64_ENCODING_UNSPECIFIED = 0; + // Encode as string (default JSON behavior for int64). + INT64_ENCODING_STRING = 1; + // Encode as number -- only use for values within Number.MAX_SAFE_INTEGER. + INT64_ENCODING_NUMBER = 2; +} + +// Extension for field-level options +extend google.protobuf.FieldOptions { + // Example values for documentation/OpenAPI + FieldExamples field_examples = 50007; + + // Query parameter configuration for a field + QueryConfig query = 50008; + + // Mark a repeated field for unwrapping when parent message is a map value. + // When set to true on a repeated field, and the message containing this field + // is used as a map value, the JSON serialization will collapse the wrapper + // to just the unwrapped field's array value. + // Constraints: Only valid on repeated fields, only one per message. + bool unwrap = 50009; + + // Specify how an int64 field should be encoded in generated TypeScript code. + // Use INT64_ENCODING_NUMBER for timestamp fields (Unix epoch milliseconds) + // that safely fit within JavaScript's Number.MAX_SAFE_INTEGER. + Int64Encoding int64_encoding = 50010; +} diff --git a/proto/worldmonitor/aviation/v1/airport_delay.proto b/proto/worldmonitor/aviation/v1/airport_delay.proto new file mode 100644 index 000000000..ccfe65132 --- /dev/null +++ b/proto/worldmonitor/aviation/v1/airport_delay.proto @@ -0,0 +1,110 @@ +syntax = "proto3"; + +package worldmonitor.aviation.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// AirportDelayAlert represents a flight delay advisory at an airport. +// Sourced from FAA and Eurocontrol. +message AirportDelayAlert { + // Unique alert identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // IATA airport code (e.g., "JFK"). + string iata = 2; + // ICAO airport code (e.g., "KJFK"). + string icao = 3; + // Airport name. + string name = 4; + // City where the airport is located. + string city = 5; + // Country code (ISO 3166-1 alpha-2). + string country = 6; + // Airport location. + worldmonitor.core.v1.GeoCoordinates location = 7; + // Geographic region. + AirportRegion region = 8; + // Type of delay. + FlightDelayType delay_type = 9; + // Delay severity. + FlightDelaySeverity severity = 10; + // Average delay in minutes. + int32 avg_delay_minutes = 11; + // Percentage of delayed flights. + double delayed_flights_pct = 12; + // Number of cancelled flights. + int32 cancelled_flights = 13; + // Total flights scheduled. + int32 total_flights = 14; + // Human-readable reason for delays. + string reason = 15; + // Data source. + FlightDelaySource source = 16; + // Last data update time, as Unix epoch milliseconds. + int64 updated_at = 17 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// AirportRegion represents the geographic region of an airport. +enum AirportRegion { + // Unspecified region. + AIRPORT_REGION_UNSPECIFIED = 0; + // Americas (North, Central, South). + AIRPORT_REGION_AMERICAS = 1; + // Europe. + AIRPORT_REGION_EUROPE = 2; + // Asia-Pacific. + AIRPORT_REGION_APAC = 3; + // Middle East and North Africa. + AIRPORT_REGION_MENA = 4; + // Sub-Saharan Africa. + AIRPORT_REGION_AFRICA = 5; +} + +// FlightDelayType represents the type of flight delay. +enum FlightDelayType { + // Unspecified delay type. + FLIGHT_DELAY_TYPE_UNSPECIFIED = 0; + // Ground stop — no departures or arrivals. + FLIGHT_DELAY_TYPE_GROUND_STOP = 1; + // Ground delay program. + FLIGHT_DELAY_TYPE_GROUND_DELAY = 2; + // Departure delays. + FLIGHT_DELAY_TYPE_DEPARTURE_DELAY = 3; + // Arrival delays. + FLIGHT_DELAY_TYPE_ARRIVAL_DELAY = 4; + // General delay. + FLIGHT_DELAY_TYPE_GENERAL = 5; +} + +// FlightDelaySeverity represents the severity of flight delays at an airport. +// Maps to TS union: 'normal' | 'minor' | 'moderate' | 'major' | 'severe'. +enum FlightDelaySeverity { + // Unspecified severity. + FLIGHT_DELAY_SEVERITY_UNSPECIFIED = 0; + // Normal operations. + FLIGHT_DELAY_SEVERITY_NORMAL = 1; + // Minor delays under 15 minutes. + FLIGHT_DELAY_SEVERITY_MINOR = 2; + // Moderate delays 15-45 minutes. + FLIGHT_DELAY_SEVERITY_MODERATE = 3; + // Major delays 45-90 minutes. + FLIGHT_DELAY_SEVERITY_MAJOR = 4; + // Severe delays over 90 minutes. + FLIGHT_DELAY_SEVERITY_SEVERE = 5; +} + +// FlightDelaySource represents the source of delay data. +enum FlightDelaySource { + // Unspecified source. + FLIGHT_DELAY_SOURCE_UNSPECIFIED = 0; + // FAA (US Federal Aviation Administration). + FLIGHT_DELAY_SOURCE_FAA = 1; + // Eurocontrol (European air traffic management). + FLIGHT_DELAY_SOURCE_EUROCONTROL = 2; + // Computed from multiple sources. + FLIGHT_DELAY_SOURCE_COMPUTED = 3; +} diff --git a/proto/worldmonitor/aviation/v1/list_airport_delays.proto b/proto/worldmonitor/aviation/v1/list_airport_delays.proto new file mode 100644 index 000000000..3cf88ba3c --- /dev/null +++ b/proto/worldmonitor/aviation/v1/list_airport_delays.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package worldmonitor.aviation.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/aviation/v1/airport_delay.proto"; + +// ListAirportDelaysRequest specifies filters for retrieving airport delay alerts. +message ListAirportDelaysRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Optional region filter. + AirportRegion region = 2; + // Optional minimum severity filter. + FlightDelaySeverity min_severity = 3; +} + +// ListAirportDelaysResponse contains airport delay alerts matching the request. +message ListAirportDelaysResponse { + // The list of airport delay alerts. + repeated AirportDelayAlert alerts = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/aviation/v1/service.proto b/proto/worldmonitor/aviation/v1/service.proto new file mode 100644 index 000000000..fed64d59f --- /dev/null +++ b/proto/worldmonitor/aviation/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.aviation.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/aviation/v1/list_airport_delays.proto"; + +// AviationService provides APIs for flight delay data from FAA and Eurocontrol. +service AviationService { + option (sebuf.http.service_config) = {base_path: "/api/aviation/v1"}; + + // ListAirportDelays retrieves current airport delay alerts. + rpc ListAirportDelays(ListAirportDelaysRequest) returns (ListAirportDelaysResponse) { + option (sebuf.http.config) = {path: "/list-airport-delays"}; + } +} diff --git a/proto/worldmonitor/climate/v1/climate_anomaly.proto b/proto/worldmonitor/climate/v1/climate_anomaly.proto new file mode 100644 index 000000000..599bf0424 --- /dev/null +++ b/proto/worldmonitor/climate/v1/climate_anomaly.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; + +package worldmonitor.climate.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// ClimateAnomaly represents a temperature or precipitation deviation from historical norms. +// Sourced from Open-Meteo / ERA5 reanalysis data. +message ClimateAnomaly { + // Climate zone name (e.g., "Northern Europe", "Sahel"). + string zone = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Representative location for the anomaly zone. + worldmonitor.core.v1.GeoCoordinates location = 2; + // Temperature deviation from normal in degrees Celsius. + double temp_delta = 3; + // Precipitation deviation from normal as a percentage. + double precip_delta = 4; + // Severity classification of the anomaly. + AnomalySeverity severity = 5; + // Type of climate anomaly observed. + AnomalyType type = 6; + // Time period covered (e.g., "2024-W03", "2024-01"). + string period = 7 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; +} + +// AnomalySeverity represents the severity of a climate anomaly. +// Maps to existing TS union: 'normal' | 'moderate' | 'extreme'. +enum AnomalySeverity { + // Unspecified severity. + ANOMALY_SEVERITY_UNSPECIFIED = 0; + // Normal — within expected variation. + ANOMALY_SEVERITY_NORMAL = 1; + // Moderate — notable deviation from historical norms. + ANOMALY_SEVERITY_MODERATE = 2; + // Extreme — severe deviation requiring attention. + ANOMALY_SEVERITY_EXTREME = 3; +} + +// AnomalyType represents the type of climate anomaly. +// Maps to existing TS union: 'warm' | 'cold' | 'wet' | 'dry' | 'mixed'. +enum AnomalyType { + // Unspecified anomaly type. + ANOMALY_TYPE_UNSPECIFIED = 0; + // Warm — above-normal temperatures. + ANOMALY_TYPE_WARM = 1; + // Cold — below-normal temperatures. + ANOMALY_TYPE_COLD = 2; + // Wet — above-normal precipitation. + ANOMALY_TYPE_WET = 3; + // Dry — below-normal precipitation. + ANOMALY_TYPE_DRY = 4; + // Mixed — combination of temperature and precipitation anomalies. + ANOMALY_TYPE_MIXED = 5; +} diff --git a/proto/worldmonitor/climate/v1/list_climate_anomalies.proto b/proto/worldmonitor/climate/v1/list_climate_anomalies.proto new file mode 100644 index 000000000..30ecb7595 --- /dev/null +++ b/proto/worldmonitor/climate/v1/list_climate_anomalies.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.climate.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/climate/v1/climate_anomaly.proto"; + +// ListClimateAnomaliesRequest specifies filters for retrieving climate anomaly data. +message ListClimateAnomaliesRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Optional filter by anomaly severity. + AnomalySeverity min_severity = 2; +} + +// ListClimateAnomaliesResponse contains the list of climate anomalies. +message ListClimateAnomaliesResponse { + // The list of climate anomalies. + repeated ClimateAnomaly anomalies = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/climate/v1/service.proto b/proto/worldmonitor/climate/v1/service.proto new file mode 100644 index 000000000..89f4b748a --- /dev/null +++ b/proto/worldmonitor/climate/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.climate.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/climate/v1/list_climate_anomalies.proto"; + +// ClimateService provides APIs for climate anomaly data sourced from Open-Meteo. +service ClimateService { + option (sebuf.http.service_config) = {base_path: "/api/climate/v1"}; + + // ListClimateAnomalies retrieves temperature and precipitation anomalies from ERA5 data. + rpc ListClimateAnomalies(ListClimateAnomaliesRequest) returns (ListClimateAnomaliesResponse) { + option (sebuf.http.config) = {path: "/list-climate-anomalies"}; + } +} diff --git a/proto/worldmonitor/conflict/v1/acled_event.proto b/proto/worldmonitor/conflict/v1/acled_event.proto new file mode 100644 index 000000000..17dc0c9ff --- /dev/null +++ b/proto/worldmonitor/conflict/v1/acled_event.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// AcledConflictEvent represents an armed conflict event from the ACLED dataset. +message AcledConflictEvent { + // Unique ACLED event identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // ACLED event type classification (e.g., "Battles", "Explosions/Remote violence"). + string event_type = 2; + // Country where the event occurred. + string country = 3; + // Geographic location of the event. + worldmonitor.core.v1.GeoCoordinates location = 4; + // Time the event occurred, as Unix epoch milliseconds. + int64 occurred_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Reported fatalities from this event. + int32 fatalities = 6; + // Named actors involved in the event. + repeated string actors = 7; + // Source article or report. + string source = 8; + // Administrative region within the country. + string admin1 = 9; +} diff --git a/proto/worldmonitor/conflict/v1/get_humanitarian_summary.proto b/proto/worldmonitor/conflict/v1/get_humanitarian_summary.proto new file mode 100644 index 000000000..ec01883c3 --- /dev/null +++ b/proto/worldmonitor/conflict/v1/get_humanitarian_summary.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/conflict/v1/humanitarian_summary.proto"; + +// GetHumanitarianSummaryRequest specifies which country to retrieve the humanitarian summary for. +message GetHumanitarianSummaryRequest { + // ISO 3166-1 alpha-2 country code (e.g., "YE", "SD", "SO"). + string country_code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$" + ]; +} + +// GetHumanitarianSummaryResponse contains the humanitarian summary for the requested country. +message GetHumanitarianSummaryResponse { + // The humanitarian summary for the country. + HumanitarianCountrySummary summary = 1; +} diff --git a/proto/worldmonitor/conflict/v1/humanitarian_summary.proto b/proto/worldmonitor/conflict/v1/humanitarian_summary.proto new file mode 100644 index 000000000..bdeb7e3ac --- /dev/null +++ b/proto/worldmonitor/conflict/v1/humanitarian_summary.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "sebuf/http/annotations.proto"; + +// HumanitarianCountrySummary represents HAPI conflict event counts for a country. +message HumanitarianCountrySummary { + // ISO 3166-1 alpha-2 country code. + string country_code = 1; + // Country name. + string country_name = 2; + // Total conflict events in the reference period. + int32 conflict_events_total = 3; + // Political violence + civilian targeting event count. + int32 conflict_political_violence_events = 4; + // Total fatalities from political violence and civilian targeting. + int32 conflict_fatalities = 5; + // Reference period start date (YYYY-MM-DD). + string reference_period = 6; + // Number of demonstration events. + int32 conflict_demonstrations = 7; + // Last data update time, as Unix epoch milliseconds. + int64 updated_at = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/conflict/v1/list_acled_events.proto b/proto/worldmonitor/conflict/v1/list_acled_events.proto new file mode 100644 index 000000000..bb9d9edc2 --- /dev/null +++ b/proto/worldmonitor/conflict/v1/list_acled_events.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/conflict/v1/acled_event.proto"; + +// ListAcledEventsRequest specifies filters for retrieving ACLED conflict events. +message ListAcledEventsRequest { + // Time range to filter events. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Optional country filter (ISO 3166-1 alpha-2). + string country = 3; +} + +// ListAcledEventsResponse contains ACLED conflict events matching the request. +message ListAcledEventsResponse { + // The list of ACLED conflict events. + repeated AcledConflictEvent events = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/conflict/v1/list_ucdp_events.proto b/proto/worldmonitor/conflict/v1/list_ucdp_events.proto new file mode 100644 index 000000000..235d78f17 --- /dev/null +++ b/proto/worldmonitor/conflict/v1/list_ucdp_events.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/conflict/v1/ucdp_event.proto"; + +// ListUcdpEventsRequest specifies filters for retrieving UCDP violence events. +message ListUcdpEventsRequest { + // Time range to filter events. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Optional country filter (ISO 3166-1 alpha-2). + string country = 3; +} + +// ListUcdpEventsResponse contains UCDP violence events matching the request. +message ListUcdpEventsResponse { + // The list of UCDP violence events. + repeated UcdpViolenceEvent events = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/conflict/v1/service.proto b/proto/worldmonitor/conflict/v1/service.proto new file mode 100644 index 000000000..c5fc9cf0d --- /dev/null +++ b/proto/worldmonitor/conflict/v1/service.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/conflict/v1/list_acled_events.proto"; +import "worldmonitor/conflict/v1/list_ucdp_events.proto"; +import "worldmonitor/conflict/v1/get_humanitarian_summary.proto"; + +// ConflictService provides APIs for armed conflict data from ACLED, UCDP, and HAPI/HDX. +service ConflictService { + option (sebuf.http.service_config) = {base_path: "/api/conflict/v1"}; + + // ListAcledEvents retrieves armed conflict events from the ACLED dataset. + rpc ListAcledEvents(ListAcledEventsRequest) returns (ListAcledEventsResponse) { + option (sebuf.http.config) = {path: "/list-acled-events"}; + } + + // ListUcdpEvents retrieves georeferenced violence events from the UCDP dataset. + rpc ListUcdpEvents(ListUcdpEventsRequest) returns (ListUcdpEventsResponse) { + option (sebuf.http.config) = {path: "/list-ucdp-events"}; + } + + // GetHumanitarianSummary retrieves a humanitarian overview for a country from HAPI/HDX. + rpc GetHumanitarianSummary(GetHumanitarianSummaryRequest) returns (GetHumanitarianSummaryResponse) { + option (sebuf.http.config) = {path: "/get-humanitarian-summary"}; + } +} diff --git a/proto/worldmonitor/conflict/v1/ucdp_event.proto b/proto/worldmonitor/conflict/v1/ucdp_event.proto new file mode 100644 index 000000000..9375d5840 --- /dev/null +++ b/proto/worldmonitor/conflict/v1/ucdp_event.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package worldmonitor.conflict.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// UcdpViolenceEvent represents a georeferenced violence event from the UCDP dataset. +message UcdpViolenceEvent { + // Unique UCDP event identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Start date of the event, as Unix epoch milliseconds. + int64 date_start = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // End date of the event, as Unix epoch milliseconds. + int64 date_end = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Geographic location of the event. + worldmonitor.core.v1.GeoCoordinates location = 4; + // Country where the event occurred. + string country = 5; + // Primary party in the conflict (Side A). + string side_a = 6; + // Secondary party in the conflict (Side B). + string side_b = 7; + // Best estimate of deaths. + int32 deaths_best = 8; + // Low estimate of deaths. + int32 deaths_low = 9; + // High estimate of deaths. + int32 deaths_high = 10; + // Type of violence. + UcdpViolenceType violence_type = 11; + // Original source of the event report. + string source_original = 12; +} + +// UcdpViolenceType represents the UCDP violence classification. +// Maps to existing TS union: 'state-based' | 'non-state' | 'one-sided'. +enum UcdpViolenceType { + // Unspecified violence type. + UCDP_VIOLENCE_TYPE_UNSPECIFIED = 0; + // State-based armed conflict. + UCDP_VIOLENCE_TYPE_STATE_BASED = 1; + // Non-state conflict between organized groups. + UCDP_VIOLENCE_TYPE_NON_STATE = 2; + // One-sided violence against civilians. + UCDP_VIOLENCE_TYPE_ONE_SIDED = 3; +} diff --git a/proto/worldmonitor/core/v1/country.proto b/proto/worldmonitor/core/v1/country.proto new file mode 100644 index 000000000..9d3d51d90 --- /dev/null +++ b/proto/worldmonitor/core/v1/country.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +import "buf/validate/validate.proto"; + +// CountryCode represents a validated ISO 3166-1 alpha-2 country code. +// Used for consistent country identification across all domains. +message CountryCode { + // Two-letter ISO 3166-1 alpha-2 country code (e.g., "US", "GB", "FR"). + string value = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$" + ]; +} diff --git a/proto/worldmonitor/core/v1/general_error.proto b/proto/worldmonitor/core/v1/general_error.proto new file mode 100644 index 000000000..bde53073e --- /dev/null +++ b/proto/worldmonitor/core/v1/general_error.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +import "sebuf/http/annotations.proto"; + +// GeneralError represents application-wide error conditions that any endpoint may return. +// Each error subtype carries the specific information needed for the client to handle it. +message GeneralError { + // The specific type of general error. + oneof error_type { + // The request was rate-limited by an upstream provider. + RateLimited rate_limited = 1; + // An upstream data provider is unavailable. + UpstreamDown upstream_down = 2; + // The request was blocked due to geographic restrictions. + GeoBlocked geo_blocked = 3; + // The service is temporarily in maintenance mode. + MaintenanceMode maintenance_mode = 4; + } +} + +// RateLimited indicates the request was throttled by an upstream data provider. +// Client action: Wait for the specified duration before retrying. +message RateLimited { + // Number of seconds to wait before retrying the request. + int32 retry_after_seconds = 1; + // Name of the upstream provider that imposed the rate limit. + string provider = 2; +} + +// UpstreamDown indicates an upstream data provider is currently unavailable. +// Client action: Show degraded state for affected data; retry later. +message UpstreamDown { + // Name of the unavailable upstream provider. + string provider = 1; + // Human-readable description of the outage. + string message = 2; +} + +// GeoBlocked indicates the request was blocked due to geographic restrictions. +// Client action: Inform the user that the content is not available in their region. +message GeoBlocked { + // Human-readable reason for the geographic restriction. + string reason = 1; +} + +// MaintenanceMode indicates the service is temporarily unavailable for maintenance. +// Client action: Show maintenance message and estimated end time. +message MaintenanceMode { + // Estimated time when maintenance will end, as Unix epoch milliseconds. + int64 estimated_end = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/core/v1/geo.proto b/proto/worldmonitor/core/v1/geo.proto new file mode 100644 index 000000000..73d465e4d --- /dev/null +++ b/proto/worldmonitor/core/v1/geo.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +import "buf/validate/validate.proto"; + +// GeoCoordinates represents a geographic location using WGS84 coordinates. +message GeoCoordinates { + // Latitude in decimal degrees (-90 to 90). + double latitude = 1 [ + (buf.validate.field).double.gte = -90, + (buf.validate.field).double.lte = 90 + ]; + // Longitude in decimal degrees (-180 to 180). + double longitude = 2 [ + (buf.validate.field).double.gte = -180, + (buf.validate.field).double.lte = 180 + ]; +} + +// BoundingBox represents a rectangular geographic area defined by its corners. +// Used for spatial queries to filter results within a geographic region. +message BoundingBox { + // The north-east corner of the bounding box. + GeoCoordinates north_east = 1; + // The south-west corner of the bounding box. + GeoCoordinates south_west = 2; +} diff --git a/proto/worldmonitor/core/v1/i18n.proto b/proto/worldmonitor/core/v1/i18n.proto new file mode 100644 index 000000000..22623a1c8 --- /dev/null +++ b/proto/worldmonitor/core/v1/i18n.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +import "buf/validate/validate.proto"; + +// LocalizableString represents a string value with its associated language. +// Used for API response strings that may have locale context. WorldMonitor receives +// pre-localized strings from upstream APIs, so this is a simple value+language pair +// rather than a full translation system. +message LocalizableString { + // The string value in the specified language. + string value = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // BCP 47 language tag (e.g., "en", "ar", "fr"). + string language = 2; +} diff --git a/proto/worldmonitor/core/v1/identifiers.proto b/proto/worldmonitor/core/v1/identifiers.proto new file mode 100644 index 000000000..580732d12 --- /dev/null +++ b/proto/worldmonitor/core/v1/identifiers.proto @@ -0,0 +1,6 @@ +// This file intentionally left empty. +// Typed ID wrappers were removed (M-8 cleanup) — all domain entities use bare `string id`. +// If typed IDs are adopted in the future, define them here. +syntax = "proto3"; + +package worldmonitor.core.v1; diff --git a/proto/worldmonitor/core/v1/pagination.proto b/proto/worldmonitor/core/v1/pagination.proto new file mode 100644 index 000000000..5b7f9f065 --- /dev/null +++ b/proto/worldmonitor/core/v1/pagination.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +import "buf/validate/validate.proto"; + +// PaginationRequest specifies cursor-based pagination parameters for list endpoints. +message PaginationRequest { + // Maximum number of items to return per page (1 to 100). + int32 page_size = 1 [ + (buf.validate.field).int32.gte = 1, + (buf.validate.field).int32.lte = 100 + ]; + // Opaque cursor for fetching the next page. Empty string for the first page. + string cursor = 2; +} + +// PaginationResponse contains pagination metadata returned alongside list results. +message PaginationResponse { + // Cursor for fetching the next page. Empty string indicates no more pages. + string next_cursor = 1; + // Total count of items matching the query, if known. Zero if the total is unknown. + int32 total_count = 2; +} diff --git a/proto/worldmonitor/core/v1/severity.proto b/proto/worldmonitor/core/v1/severity.proto new file mode 100644 index 000000000..994c50773 --- /dev/null +++ b/proto/worldmonitor/core/v1/severity.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +// SeverityLevel represents a three-tier severity classification used across domains. +// Maps to existing TS unions: 'low' | 'medium' | 'high'. +enum SeverityLevel { + // Unspecified severity level. + SEVERITY_LEVEL_UNSPECIFIED = 0; + // Low severity — minimal impact or concern. + SEVERITY_LEVEL_LOW = 1; + // Medium severity — moderate impact requiring attention. + SEVERITY_LEVEL_MEDIUM = 2; + // High severity — significant impact requiring immediate attention. + SEVERITY_LEVEL_HIGH = 3; +} + +// CriticalityLevel represents a four-tier criticality classification for cyber and risk domains. +// Maps to existing TS union: 'low' | 'medium' | 'high' | 'critical'. +enum CriticalityLevel { + // Unspecified criticality level. + CRITICALITY_LEVEL_UNSPECIFIED = 0; + // Low criticality — routine or informational. + CRITICALITY_LEVEL_LOW = 1; + // Medium criticality — warrants investigation. + CRITICALITY_LEVEL_MEDIUM = 2; + // High criticality — active threat requiring response. + CRITICALITY_LEVEL_HIGH = 3; + // Critical — severe threat requiring immediate action. + CRITICALITY_LEVEL_CRITICAL = 4; +} + +// TrendDirection represents the directional movement of a metric over time. +// Used in markets, GDELT tension scores, and risk assessments. +enum TrendDirection { + // Unspecified trend direction. + TREND_DIRECTION_UNSPECIFIED = 0; + // Rising — the metric is increasing. + TREND_DIRECTION_RISING = 1; + // Stable — the metric is relatively unchanged. + TREND_DIRECTION_STABLE = 2; + // Falling — the metric is decreasing. + TREND_DIRECTION_FALLING = 3; +} diff --git a/proto/worldmonitor/core/v1/time.proto b/proto/worldmonitor/core/v1/time.proto new file mode 100644 index 000000000..ce4a13892 --- /dev/null +++ b/proto/worldmonitor/core/v1/time.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package worldmonitor.core.v1; + +import "sebuf/http/annotations.proto"; + +// TimeRange represents a time interval defined by a start and end timestamp. +// Used for filtering data within a specific time period. +message TimeRange { + // Start of the time range (inclusive), as Unix epoch milliseconds. + int64 start = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // End of the time range (inclusive), as Unix epoch milliseconds. + int64 end = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/cyber/v1/cyber_threat.proto b/proto/worldmonitor/cyber/v1/cyber_threat.proto new file mode 100644 index 000000000..965734f72 --- /dev/null +++ b/proto/worldmonitor/cyber/v1/cyber_threat.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package worldmonitor.cyber.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/core/v1/severity.proto"; + +// CyberThreat represents a cyber threat indicator aggregated from multiple sources. +// Sources include Feodo Tracker, URLhaus, OTX, AbuseIPDB, and C2Intel. +message CyberThreat { + // Unique threat identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Type of cyber threat. + CyberThreatType type = 2; + // Source of the threat intelligence. + CyberThreatSource source = 3; + // Threat indicator value (IP, domain, or URL). + string indicator = 4; + // Type of the indicator. + CyberThreatIndicatorType indicator_type = 5; + // Geolocation of the threat indicator. + worldmonitor.core.v1.GeoCoordinates location = 6; + // Country of origin (ISO 3166-1 alpha-2). + string country = 7; + // Threat criticality level. + worldmonitor.core.v1.CriticalityLevel severity = 8; + // Associated malware family, if known. + string malware_family = 9; + // Descriptive tags. + repeated string tags = 10; + // First seen time, as Unix epoch milliseconds. + int64 first_seen_at = 11 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Last seen time, as Unix epoch milliseconds. + int64 last_seen_at = 12 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// CyberThreatType represents the classification of a cyber threat. +// Maps to TS union: 'c2_server' | 'malware_host' | 'phishing' | 'malicious_url'. +enum CyberThreatType { + // Unspecified threat type. + CYBER_THREAT_TYPE_UNSPECIFIED = 0; + // Command and control server. + CYBER_THREAT_TYPE_C2_SERVER = 1; + // Malware distribution host. + CYBER_THREAT_TYPE_MALWARE_HOST = 2; + // Phishing site or campaign. + CYBER_THREAT_TYPE_PHISHING = 3; + // Malicious URL. + CYBER_THREAT_TYPE_MALICIOUS_URL = 4; +} + +// CyberThreatSource represents the intelligence source of a cyber threat. +// Maps to TS union: 'feodo' | 'urlhaus' | 'c2intel' | 'otx' | 'abuseipdb'. +enum CyberThreatSource { + // Unspecified source. + CYBER_THREAT_SOURCE_UNSPECIFIED = 0; + // Feodo Tracker (abuse.ch). + CYBER_THREAT_SOURCE_FEODO = 1; + // URLhaus (abuse.ch). + CYBER_THREAT_SOURCE_URLHAUS = 2; + // C2 Intelligence Feed. + CYBER_THREAT_SOURCE_C2INTEL = 3; + // AlienVault Open Threat Exchange. + CYBER_THREAT_SOURCE_OTX = 4; + // AbuseIPDB. + CYBER_THREAT_SOURCE_ABUSEIPDB = 5; +} + +// CyberThreatIndicatorType represents the type of threat indicator. +// Maps to TS union: 'ip' | 'domain' | 'url'. +enum CyberThreatIndicatorType { + // Unspecified indicator type. + CYBER_THREAT_INDICATOR_TYPE_UNSPECIFIED = 0; + // IP address. + CYBER_THREAT_INDICATOR_TYPE_IP = 1; + // Domain name. + CYBER_THREAT_INDICATOR_TYPE_DOMAIN = 2; + // Full URL. + CYBER_THREAT_INDICATOR_TYPE_URL = 3; +} diff --git a/proto/worldmonitor/cyber/v1/list_cyber_threats.proto b/proto/worldmonitor/cyber/v1/list_cyber_threats.proto new file mode 100644 index 000000000..5c667aab8 --- /dev/null +++ b/proto/worldmonitor/cyber/v1/list_cyber_threats.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package worldmonitor.cyber.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/severity.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/cyber/v1/cyber_threat.proto"; + +// ListCyberThreatsRequest specifies filters for retrieving cyber threat indicators. +message ListCyberThreatsRequest { + // Time range to filter threats. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Optional threat type filter. + CyberThreatType type = 3; + // Optional source filter. + CyberThreatSource source = 4; + // Optional minimum criticality filter. + worldmonitor.core.v1.CriticalityLevel min_severity = 5; +} + +// ListCyberThreatsResponse contains cyber threats matching the request. +message ListCyberThreatsResponse { + // The list of cyber threats. + repeated CyberThreat threats = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/cyber/v1/service.proto b/proto/worldmonitor/cyber/v1/service.proto new file mode 100644 index 000000000..d1d22c83c --- /dev/null +++ b/proto/worldmonitor/cyber/v1/service.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.cyber.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/cyber/v1/list_cyber_threats.proto"; + +// CyberService provides APIs for cyber threat intelligence aggregated from +// Feodo, URLhaus, OTX, AbuseIPDB, and C2Intel. +service CyberService { + option (sebuf.http.service_config) = {base_path: "/api/cyber/v1"}; + + // ListCyberThreats retrieves threat indicators from multiple intelligence sources. + rpc ListCyberThreats(ListCyberThreatsRequest) returns (ListCyberThreatsResponse) { + option (sebuf.http.config) = {path: "/list-cyber-threats"}; + } +} diff --git a/proto/worldmonitor/displacement/v1/displacement.proto b/proto/worldmonitor/displacement/v1/displacement.proto new file mode 100644 index 000000000..bfa4168df --- /dev/null +++ b/proto/worldmonitor/displacement/v1/displacement.proto @@ -0,0 +1,86 @@ +syntax = "proto3"; + +package worldmonitor.displacement.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// DisplacementSummary represents a global overview of displacement data from UNHCR. +message DisplacementSummary { + // Data year. + int32 year = 1; + // Global totals across all categories. + GlobalDisplacementTotals global_totals = 2; + // Per-country displacement breakdowns. + repeated CountryDisplacement countries = 3; + // Top displacement flows between countries. + repeated DisplacementFlow top_flows = 4; +} + +// GlobalDisplacementTotals represents worldwide displacement figures. +message GlobalDisplacementTotals { + // Total recognized refugees worldwide. + int64 refugees = 1 [(buf.validate.field).int64.gte = 0, (sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Total asylum seekers worldwide. + int64 asylum_seekers = 2 [(buf.validate.field).int64.gte = 0, (sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Total internally displaced persons worldwide. + int64 idps = 3 [(buf.validate.field).int64.gte = 0, (sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Total stateless persons worldwide. + int64 stateless = 4 [(buf.validate.field).int64.gte = 0, (sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Grand total of displaced persons. + int64 total = 5 [(buf.validate.field).int64.gte = 0, (sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// CountryDisplacement represents displacement metrics for a single country. +message CountryDisplacement { + // ISO 3166-1 alpha-2 country code. + string code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Country name. + string name = 2; + // Refugees originating from this country. + int64 refugees = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Asylum seekers from this country. + int64 asylum_seekers = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Internally displaced persons within this country. + int64 idps = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Stateless persons associated with this country. + int64 stateless = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Total displaced from this country. + int64 total_displaced = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Refugees hosted by this country. + int64 host_refugees = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Asylum seekers hosted by this country. + int64 host_asylum_seekers = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Total persons hosted by this country. + int64 host_total = 10 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Representative location for mapping. + worldmonitor.core.v1.GeoCoordinates location = 11; +} + +// DisplacementFlow represents a refugee movement corridor between two countries. +message DisplacementFlow { + // ISO 3166-1 alpha-2 code of the origin country. + string origin_code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Origin country name. + string origin_name = 2; + // ISO 3166-1 alpha-2 code of the asylum country. + string asylum_code = 3 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Asylum country name. + string asylum_name = 4; + // Number of refugees in this flow. + int64 refugees = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Origin country representative location. + worldmonitor.core.v1.GeoCoordinates origin_location = 6; + // Asylum country representative location. + worldmonitor.core.v1.GeoCoordinates asylum_location = 7; +} diff --git a/proto/worldmonitor/displacement/v1/get_displacement_summary.proto b/proto/worldmonitor/displacement/v1/get_displacement_summary.proto new file mode 100644 index 000000000..1e8631704 --- /dev/null +++ b/proto/worldmonitor/displacement/v1/get_displacement_summary.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.displacement.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/displacement/v1/displacement.proto"; + +// GetDisplacementSummaryRequest specifies parameters for retrieving displacement data. +message GetDisplacementSummaryRequest { + // Data year to retrieve (e.g., 2023). Uses latest available if zero. + int32 year = 1 [(buf.validate.field).int32.gte = 0]; + // Maximum number of country entries to return. + int32 country_limit = 2 [(buf.validate.field).int32.gte = 0]; + // Maximum number of displacement flows to return. + int32 flow_limit = 3 [(buf.validate.field).int32.gte = 0]; +} + +// GetDisplacementSummaryResponse contains the global displacement summary. +message GetDisplacementSummaryResponse { + // The displacement summary. + DisplacementSummary summary = 1; +} diff --git a/proto/worldmonitor/displacement/v1/get_population_exposure.proto b/proto/worldmonitor/displacement/v1/get_population_exposure.proto new file mode 100644 index 000000000..cc736f46c --- /dev/null +++ b/proto/worldmonitor/displacement/v1/get_population_exposure.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; + +package worldmonitor.displacement.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// GetPopulationExposureRequest supports two modes: +// - countries mode (default): returns the priority countries list +// - exposure mode: estimates population within a radius of a point +message GetPopulationExposureRequest { + // Mode: "countries" (default) or "exposure". + string mode = 1; + // Latitude (required for exposure mode). + double lat = 2 [ + (buf.validate.field).double.gte = -90, + (buf.validate.field).double.lte = 90 + ]; + // Longitude (required for exposure mode). + double lon = 3 [ + (buf.validate.field).double.gte = -180, + (buf.validate.field).double.lte = 180 + ]; + // Radius in km (required for exposure mode, defaults to 50). + double radius = 4 [(buf.validate.field).double.gte = 0]; +} + +// CountryPopulationEntry represents a country with population data. +message CountryPopulationEntry { + // ISO 3166-1 alpha-3 country code. + string code = 1; + // Country name. + string name = 2; + // Total population. + int64 population = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Population density per square kilometer. + int32 density_per_km2 = 4; +} + +// ExposureResult contains the population exposure estimate. +message ExposureResult { + // Estimated exposed population. + int64 exposed_population = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Radius used for the estimate in km. + double exposure_radius_km = 2; + // ISO3 code of nearest priority country. + string nearest_country = 3; + // Population density used for the estimate. + int32 density_per_km2 = 4; +} + +// GetPopulationExposureResponse returns either a countries list or an exposure estimate. +message GetPopulationExposureResponse { + // True if the request succeeded. + bool success = 1; + // Countries list (populated in countries mode). + repeated CountryPopulationEntry countries = 2; + // Exposure result (populated in exposure mode). + ExposureResult exposure = 3; +} diff --git a/proto/worldmonitor/displacement/v1/service.proto b/proto/worldmonitor/displacement/v1/service.proto new file mode 100644 index 000000000..6f8cf1549 --- /dev/null +++ b/proto/worldmonitor/displacement/v1/service.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.displacement.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/displacement/v1/get_displacement_summary.proto"; +import "worldmonitor/displacement/v1/get_population_exposure.proto"; + +// DisplacementService provides APIs for global displacement and refugee data from UNHCR. +service DisplacementService { + option (sebuf.http.service_config) = {base_path: "/api/displacement/v1"}; + + // GetDisplacementSummary retrieves global refugee and IDP statistics from UNHCR. + rpc GetDisplacementSummary(GetDisplacementSummaryRequest) returns (GetDisplacementSummaryResponse) { + option (sebuf.http.config) = {path: "/get-displacement-summary"}; + } + + // GetPopulationExposure returns country population data or estimates population within a radius. + rpc GetPopulationExposure(GetPopulationExposureRequest) returns (GetPopulationExposureResponse) { + option (sebuf.http.config) = {path: "/get-population-exposure"}; + } +} diff --git a/proto/worldmonitor/economic/v1/bis_data.proto b/proto/worldmonitor/economic/v1/bis_data.proto new file mode 100644 index 000000000..419cd702a --- /dev/null +++ b/proto/worldmonitor/economic/v1/bis_data.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +// BisPolicyRate represents a central bank policy rate from BIS. +message BisPolicyRate { + // ISO 2-letter country code (US, GB, JP, etc.) + string country_code = 1; + // Country or region name. + string country_name = 2; + // Current policy rate percentage. + double rate = 3; + // Previous period rate percentage. + double previous_rate = 4; + // Date as YYYY-MM. + string date = 5; + // Central bank name (e.g. "Federal Reserve"). + string central_bank = 6; +} + +// BisExchangeRate represents effective exchange rate indices from BIS. +message BisExchangeRate { + // ISO 2-letter country code. + string country_code = 1; + // Country or region name. + string country_name = 2; + // Real effective exchange rate index. + double real_eer = 3; + // Nominal effective exchange rate index. + double nominal_eer = 4; + // Percentage change from previous period (real). + double real_change = 5; + // Date as YYYY-MM. + string date = 6; +} + +// BisCreditToGdp represents total credit as percentage of GDP from BIS. +message BisCreditToGdp { + // ISO 2-letter country code. + string country_code = 1; + // Country or region name. + string country_name = 2; + // Total credit as percentage of GDP. + double credit_gdp_ratio = 3; + // Previous quarter ratio. + double previous_ratio = 4; + // Date as YYYY-QN. + string date = 5; +} diff --git a/proto/worldmonitor/economic/v1/economic_data.proto b/proto/worldmonitor/economic/v1/economic_data.proto new file mode 100644 index 000000000..c9f7603d1 --- /dev/null +++ b/proto/worldmonitor/economic/v1/economic_data.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// FredObservation represents a single data point from a FRED economic series. +message FredObservation { + // Observation date as YYYY-MM-DD string. + string date = 1; + // Observation value. + double value = 2; +} + +// FredSeries represents a FRED time series with metadata. +message FredSeries { + // Series identifier (e.g., "GDP", "UNRATE", "CPIAUCSL"). + string series_id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Series title. + string title = 2; + // Unit of measurement. + string units = 3; + // Data frequency (e.g., "Monthly", "Quarterly"). + string frequency = 4; + // Observations in the series. + repeated FredObservation observations = 5; +} + +// WorldBankCountryData represents a World Bank indicator value for a country. +message WorldBankCountryData { + // ISO 3166-1 alpha-2 country code. + string country_code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Country name. + string country_name = 2; + // World Bank indicator code (e.g., "NY.GDP.MKTP.CD"). + string indicator_code = 3 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Indicator name. + string indicator_name = 4; + // Data year. + int32 year = 5; + // Indicator value. + double value = 6; +} + +// EnergyPrice represents a current energy commodity price from EIA. +message EnergyPrice { + // Energy commodity identifier. + string commodity = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Human-readable name (e.g., "WTI Crude Oil", "Henry Hub Natural Gas"). + string name = 2; + // Current price in USD. + double price = 3; + // Unit of measurement (e.g., "$/barrel", "$/MMBtu"). + string unit = 4; + // Percentage change from previous period. + double change = 5; + // Price date, as Unix epoch milliseconds. + int64 price_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/economic/v1/get_bis_credit.proto b/proto/worldmonitor/economic/v1/get_bis_credit.proto new file mode 100644 index 000000000..018e4e68b --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_bis_credit.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "worldmonitor/economic/v1/bis_data.proto"; + +// GetBisCreditRequest requests credit-to-GDP ratio data. +message GetBisCreditRequest {} + +// GetBisCreditResponse contains BIS credit-to-GDP data. +message GetBisCreditResponse { + // The list of credit-to-GDP entries by country. + repeated BisCreditToGdp entries = 1; +} diff --git a/proto/worldmonitor/economic/v1/get_bis_exchange_rates.proto b/proto/worldmonitor/economic/v1/get_bis_exchange_rates.proto new file mode 100644 index 000000000..0625452b1 --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_bis_exchange_rates.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "worldmonitor/economic/v1/bis_data.proto"; + +// GetBisExchangeRatesRequest requests effective exchange rates. +message GetBisExchangeRatesRequest {} + +// GetBisExchangeRatesResponse contains BIS effective exchange rate data. +message GetBisExchangeRatesResponse { + // The list of exchange rates by country. + repeated BisExchangeRate rates = 1; +} diff --git a/proto/worldmonitor/economic/v1/get_bis_policy_rates.proto b/proto/worldmonitor/economic/v1/get_bis_policy_rates.proto new file mode 100644 index 000000000..317de6047 --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_bis_policy_rates.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "worldmonitor/economic/v1/bis_data.proto"; + +// GetBisPolicyRatesRequest requests central bank policy rates. +message GetBisPolicyRatesRequest {} + +// GetBisPolicyRatesResponse contains BIS policy rate data. +message GetBisPolicyRatesResponse { + // The list of policy rates by country. + repeated BisPolicyRate rates = 1; +} diff --git a/proto/worldmonitor/economic/v1/get_energy_capacity.proto b/proto/worldmonitor/economic/v1/get_energy_capacity.proto new file mode 100644 index 000000000..27d523115 --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_energy_capacity.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +message GetEnergyCapacityRequest { + // Energy source codes to query (e.g., "SUN", "WND", "COL"). + // Empty returns all tracked sources (SUN, WND, COL). + repeated string energy_sources = 1; + // Number of years of historical data. Default 20 if not set. + int32 years = 2; +} + +message EnergyCapacityYear { + int32 year = 1; + double capacity_mw = 2; +} + +message EnergyCapacitySeries { + string energy_source = 1; + string name = 2; + repeated EnergyCapacityYear data = 3; +} + +message GetEnergyCapacityResponse { + repeated EnergyCapacitySeries series = 1; +} diff --git a/proto/worldmonitor/economic/v1/get_energy_prices.proto b/proto/worldmonitor/economic/v1/get_energy_prices.proto new file mode 100644 index 000000000..3a152022b --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_energy_prices.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "worldmonitor/economic/v1/economic_data.proto"; + +// GetEnergyPricesRequest specifies which energy commodities to retrieve. +message GetEnergyPricesRequest { + // Optional commodity filter. Empty returns all tracked commodities. + repeated string commodities = 1; +} + +// GetEnergyPricesResponse contains energy price data. +message GetEnergyPricesResponse { + // The list of energy prices. + repeated EnergyPrice prices = 1; +} diff --git a/proto/worldmonitor/economic/v1/get_fred_series.proto b/proto/worldmonitor/economic/v1/get_fred_series.proto new file mode 100644 index 000000000..b9ca24796 --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_fred_series.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/economic/v1/economic_data.proto"; + +// GetFredSeriesRequest specifies which FRED series to retrieve. +message GetFredSeriesRequest { + // FRED series ID (e.g., "GDP", "UNRATE", "CPIAUCSL"). + string series_id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Maximum number of observations to return. Defaults to 120. + int32 limit = 2; +} + +// GetFredSeriesResponse contains the requested FRED series data. +message GetFredSeriesResponse { + // The FRED time series. + FredSeries series = 1; +} diff --git a/proto/worldmonitor/economic/v1/get_macro_signals.proto b/proto/worldmonitor/economic/v1/get_macro_signals.proto new file mode 100644 index 000000000..db595b2f7 --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_macro_signals.proto @@ -0,0 +1,133 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "buf/validate/validate.proto"; + +// GetMacroSignalsRequest requests the current macro signal dashboard. +message GetMacroSignalsRequest {} + +// GetMacroSignalsResponse contains the full macro signal dashboard with 7 signals and verdict. +message GetMacroSignalsResponse { + // ISO 8601 timestamp of computation. + string timestamp = 1; + // Overall verdict: "BUY", "CASH", or "UNKNOWN". + string verdict = 2; + // Number of bullish signals. + int32 bullish_count = 3; + // Total number of evaluated signals (excluding UNKNOWN). + int32 total_count = 4; + // All 7 macro signals. + MacroSignals signals = 5; + // Additional metadata (e.g., QQQ sparkline). + MacroMeta meta = 6; + // True when upstream data is unavailable (fallback result). + bool unavailable = 7; +} + +// MacroSignals contains all 7 individual signal computations. +message MacroSignals { + // JPY-based liquidity squeeze detection. + LiquiditySignal liquidity = 1; + // BTC vs QQQ 5-day return comparison. + FlowStructureSignal flow_structure = 2; + // QQQ vs XLP 20-day rate of change regime. + MacroRegimeSignal macro_regime = 3; + // BTC price vs moving averages and VWAP. + TechnicalTrendSignal technical_trend = 4; + // Bitcoin mining hash rate momentum. + HashRateSignal hash_rate = 5; + // Mining profitability estimate. + MiningCostSignal mining_cost = 6; + // Crypto Fear & Greed index. + FearGreedSignal fear_greed = 7; +} + +// LiquiditySignal tracks JPY 30d rate of change as a liquidity proxy. +message LiquiditySignal { + // "SQUEEZE", "NORMAL", or "UNKNOWN". + string status = 1; + // JPY 30d ROC percentage, absent if unavailable. + optional double value = 2; + // Last 30 JPY close prices. + repeated double sparkline = 3; +} + +// FlowStructureSignal compares BTC vs QQQ 5-day returns. +message FlowStructureSignal { + // "PASSIVE GAP", "ALIGNED", or "UNKNOWN". + string status = 1; + // BTC 5-day return percentage. + optional double btc_return_5 = 2; + // QQQ 5-day return percentage. + optional double qqq_return_5 = 3; +} + +// MacroRegimeSignal compares QQQ vs XLP 20-day rate of change. +message MacroRegimeSignal { + // "RISK-ON", "DEFENSIVE", or "UNKNOWN". + string status = 1; + // QQQ 20d ROC percentage. + optional double qqq_roc_20 = 2; + // XLP 20d ROC percentage. + optional double xlp_roc_20 = 3; +} + +// TechnicalTrendSignal evaluates BTC price vs moving averages and VWAP. +message TechnicalTrendSignal { + // "BULLISH", "BEARISH", "NEUTRAL", or "UNKNOWN". + string status = 1; + // Current BTC price. + optional double btc_price = 2; + // 50-day simple moving average. + optional double sma_50 = 3; + // 200-day simple moving average. + optional double sma_200 = 4; + // 30-day volume-weighted average price. + optional double vwap_30d = 5; + // Mayer multiple (BTC price / SMA200). + optional double mayer_multiple = 6; + // Last 30 BTC close prices. + repeated double sparkline = 7; +} + +// HashRateSignal tracks Bitcoin hash rate momentum. +message HashRateSignal { + // "GROWING", "DECLINING", "STABLE", or "UNKNOWN". + string status = 1; + // Hash rate change over 30 days as percentage. + optional double change_30d = 2; +} + +// MiningCostSignal estimates mining profitability from BTC price thresholds. +message MiningCostSignal { + // "PROFITABLE", "TIGHT", "SQUEEZE", or "UNKNOWN". + string status = 1; +} + +// FearGreedHistoryEntry is a single day's Fear & Greed index reading. +message FearGreedHistoryEntry { + // Index value (0-100). + int32 value = 1 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = 100 + ]; + // Date string (YYYY-MM-DD). + string date = 2; +} + +// FearGreedSignal tracks the Crypto Fear & Greed index. +message FearGreedSignal { + // Classification label (e.g., "Extreme Fear", "Greed"). + string status = 1; + // Current index value (0-100). + optional int32 value = 2; + // Last 30 days of history. + repeated FearGreedHistoryEntry history = 3; +} + +// MacroMeta contains supplementary chart data. +message MacroMeta { + // Last 30 QQQ close prices for sparkline. + repeated double qqq_sparkline = 1; +} diff --git a/proto/worldmonitor/economic/v1/list_world_bank_indicators.proto b/proto/worldmonitor/economic/v1/list_world_bank_indicators.proto new file mode 100644 index 000000000..3ebaa1521 --- /dev/null +++ b/proto/worldmonitor/economic/v1/list_world_bank_indicators.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/economic/v1/economic_data.proto"; + +// ListWorldBankIndicatorsRequest specifies filters for retrieving World Bank data. +message ListWorldBankIndicatorsRequest { + // World Bank indicator code (e.g., "NY.GDP.MKTP.CD"). + string indicator_code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Optional country filter (ISO 3166-1 alpha-2). + string country_code = 2; + // Optional year filter. Defaults to latest available. + int32 year = 3; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 4; +} + +// ListWorldBankIndicatorsResponse contains World Bank indicator data. +message ListWorldBankIndicatorsResponse { + // Country-level indicator data. + repeated WorldBankCountryData data = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/economic/v1/service.proto b/proto/worldmonitor/economic/v1/service.proto new file mode 100644 index 000000000..c527df12a --- /dev/null +++ b/proto/worldmonitor/economic/v1/service.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package worldmonitor.economic.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/economic/v1/get_fred_series.proto"; +import "worldmonitor/economic/v1/list_world_bank_indicators.proto"; +import "worldmonitor/economic/v1/get_energy_prices.proto"; +import "worldmonitor/economic/v1/get_macro_signals.proto"; +import "worldmonitor/economic/v1/get_energy_capacity.proto"; +import "worldmonitor/economic/v1/get_bis_policy_rates.proto"; +import "worldmonitor/economic/v1/get_bis_exchange_rates.proto"; +import "worldmonitor/economic/v1/get_bis_credit.proto"; + +// EconomicService provides APIs for macroeconomic data from FRED, World Bank, and EIA. +service EconomicService { + option (sebuf.http.service_config) = {base_path: "/api/economic/v1"}; + + // GetFredSeries retrieves time series data from the Federal Reserve Economic Data. + rpc GetFredSeries(GetFredSeriesRequest) returns (GetFredSeriesResponse) { + option (sebuf.http.config) = {path: "/get-fred-series"}; + } + + // ListWorldBankIndicators retrieves development indicator data from the World Bank. + rpc ListWorldBankIndicators(ListWorldBankIndicatorsRequest) returns (ListWorldBankIndicatorsResponse) { + option (sebuf.http.config) = {path: "/list-world-bank-indicators"}; + } + + // GetEnergyPrices retrieves current energy commodity prices from EIA. + rpc GetEnergyPrices(GetEnergyPricesRequest) returns (GetEnergyPricesResponse) { + option (sebuf.http.config) = {path: "/get-energy-prices"}; + } + + // GetMacroSignals computes 7 macro signals from 6 upstream sources with BUY/CASH verdict. + rpc GetMacroSignals(GetMacroSignalsRequest) returns (GetMacroSignalsResponse) { + option (sebuf.http.config) = {path: "/get-macro-signals"}; + } + + // GetEnergyCapacity retrieves installed capacity data (solar, wind, coal) from EIA. + rpc GetEnergyCapacity(GetEnergyCapacityRequest) returns (GetEnergyCapacityResponse) { + option (sebuf.http.config) = {path: "/get-energy-capacity"}; + } + + // GetBisPolicyRates retrieves central bank policy rates from BIS. + rpc GetBisPolicyRates(GetBisPolicyRatesRequest) returns (GetBisPolicyRatesResponse) { + option (sebuf.http.config) = {path: "/get-bis-policy-rates"}; + } + + // GetBisExchangeRates retrieves effective exchange rates from BIS. + rpc GetBisExchangeRates(GetBisExchangeRatesRequest) returns (GetBisExchangeRatesResponse) { + option (sebuf.http.config) = {path: "/get-bis-exchange-rates"}; + } + + // GetBisCredit retrieves credit-to-GDP ratio data from BIS. + rpc GetBisCredit(GetBisCreditRequest) returns (GetBisCreditResponse) { + option (sebuf.http.config) = {path: "/get-bis-credit"}; + } +} diff --git a/proto/worldmonitor/giving/v1/get_giving_summary.proto b/proto/worldmonitor/giving/v1/get_giving_summary.proto new file mode 100644 index 000000000..f6717a0ca --- /dev/null +++ b/proto/worldmonitor/giving/v1/get_giving_summary.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package worldmonitor.giving.v1; + +import "worldmonitor/giving/v1/giving.proto"; + +// GetGivingSummaryRequest specifies parameters for retrieving the global giving summary. +message GetGivingSummaryRequest { + // Number of platforms to include (0 = all). + int32 platform_limit = 1; + // Number of category breakdowns to include (0 = all). + int32 category_limit = 2; +} + +// GetGivingSummaryResponse contains the global giving activity summary. +message GetGivingSummaryResponse { + // The giving summary. + GivingSummary summary = 1; +} diff --git a/proto/worldmonitor/giving/v1/giving.proto b/proto/worldmonitor/giving/v1/giving.proto new file mode 100644 index 000000000..ac4914366 --- /dev/null +++ b/proto/worldmonitor/giving/v1/giving.proto @@ -0,0 +1,91 @@ +syntax = "proto3"; + +package worldmonitor.giving.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// GivingSummary represents a global overview of personal giving activity across platforms. +message GivingSummary { + // Timestamp of the summary generation (ISO 8601). + string generated_at = 1; + // Global giving activity index (0-100 composite score). + double activity_index = 2; + // Index trend direction. + string trend = 3; // "rising" | "stable" | "falling" + // Estimated daily global giving flow in USD (directional, not precise). + double estimated_daily_flow_usd = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Per-platform aggregates. + repeated PlatformGiving platforms = 5; + // Per-category breakdown of campaign activity. + repeated CategoryBreakdown categories = 6; + // Crypto philanthropy wallet summary. + CryptoGivingSummary crypto = 7; + // Institutional / ODA data points. + InstitutionalGiving institutional = 8; +} + +// PlatformGiving represents aggregated giving data from a single crowdfunding platform. +message PlatformGiving { + // Platform name (e.g., "GoFundMe", "GlobalGiving", "JustGiving"). + string platform = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Estimated daily donation volume in USD. + double daily_volume_usd = 2; + // Number of active campaigns being sampled. + int32 active_campaigns_sampled = 3; + // New campaigns created in the last 24 hours. + int32 new_campaigns_24h = 4; + // Average donation velocity (donations per hour). + double donation_velocity = 5; + // Data freshness: "live", "daily", "weekly", "annual". + string data_freshness = 6; + // Last data update timestamp (ISO 8601). + string last_updated = 7; +} + +// CategoryBreakdown represents giving activity within a specific cause category. +message CategoryBreakdown { + // Category name (e.g., "Medical", "Disaster Relief", "Education"). + string category = 1; + // Share of total giving activity (0-1). + double share = 2; + // 24-hour change in share percentage points. + double change_24h = 3; + // Number of active campaigns in this category. + int32 active_campaigns = 4; + // Trending indicator. + bool trending = 5; +} + +// CryptoGivingSummary tracks transparent on-chain philanthropy. +message CryptoGivingSummary { + // Total 24h inflow to tracked charity wallets (USD equivalent). + double daily_inflow_usd = 1; + // Number of tracked charity wallets. + int32 tracked_wallets = 2; + // Number of transactions in the last 24 hours. + int32 transactions_24h = 3; + // Top receiving platforms / DAOs. + repeated string top_receivers = 4; + // Percentage of total giving that is on-chain. + double pct_of_total = 5; +} + +// InstitutionalGiving tracks large-scale structured philanthropy and ODA. +message InstitutionalGiving { + // Latest OECD ODA total (annual, USD billions). + double oecd_oda_annual_usd_bn = 1; + // Year of latest OECD data. + int32 oecd_data_year = 2; + // CAF World Giving Index score (latest). + double caf_world_giving_index = 3; + // Year of latest CAF data. + int32 caf_data_year = 4; + // Number of foundation grants tracked (Candid). + int32 candid_grants_tracked = 5; + // Data lag description (e.g., "Quarterly", "Annual"). + string data_lag = 6; +} diff --git a/proto/worldmonitor/giving/v1/service.proto b/proto/worldmonitor/giving/v1/service.proto new file mode 100644 index 000000000..844b6808d --- /dev/null +++ b/proto/worldmonitor/giving/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.giving.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/giving/v1/get_giving_summary.proto"; + +// GivingService provides APIs for global personal giving and philanthropy tracking. +service GivingService { + option (sebuf.http.service_config) = {base_path: "/api/giving/v1"}; + + // GetGivingSummary retrieves a composite global giving activity index and platform breakdowns. + rpc GetGivingSummary(GetGivingSummaryRequest) returns (GetGivingSummaryResponse) { + option (sebuf.http.config) = {path: "/get-giving-summary"}; + } +} diff --git a/proto/worldmonitor/infrastructure/v1/get_cable_health.proto b/proto/worldmonitor/infrastructure/v1/get_cable_health.proto new file mode 100644 index 000000000..7a0d00bae --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/get_cable_health.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "sebuf/http/annotations.proto"; + +// GetCableHealthRequest requests the current health status of all monitored submarine cables. +message GetCableHealthRequest {} + +// GetCableHealthResponse contains health status for submarine cables with active signals. +message GetCableHealthResponse { + // Generation timestamp, as Unix epoch milliseconds. + int64 generated_at = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Health records keyed by cable identifier. + map cables = 2; +} + +// CableHealthStatus represents the computed health status of a submarine cable. +enum CableHealthStatus { + // Unspecified status. + CABLE_HEALTH_STATUS_UNSPECIFIED = 0; + // Cable is operating normally. + CABLE_HEALTH_STATUS_OK = 1; + // Cable is experiencing degraded performance. + CABLE_HEALTH_STATUS_DEGRADED = 2; + // Cable has a confirmed fault. + CABLE_HEALTH_STATUS_FAULT = 3; +} + +// CableHealthRecord contains the computed health status and supporting evidence for a cable. +message CableHealthRecord { + // Computed health status. + CableHealthStatus status = 1; + // Composite health score (0.0 = healthy, 1.0 = confirmed fault). + double score = 2; + // Confidence in the health assessment (0.0–1.0). + double confidence = 3; + // Last signal update time, as Unix epoch milliseconds. + int64 last_updated = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Supporting evidence items (up to 3). + repeated CableHealthEvidence evidence = 5; +} + +// CableHealthEvidence represents a single piece of evidence supporting a health assessment. +message CableHealthEvidence { + // Evidence source (e.g. "NGA"). + string source = 1; + // Human-readable summary of the evidence. + string summary = 2; + // Evidence timestamp, as Unix epoch milliseconds. + int64 ts = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/infrastructure/v1/get_temporal_baseline.proto b/proto/worldmonitor/infrastructure/v1/get_temporal_baseline.proto new file mode 100644 index 000000000..f9394b324 --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/get_temporal_baseline.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "buf/validate/validate.proto"; + +// GetTemporalBaselineRequest checks current activity count against stored baseline. +message GetTemporalBaselineRequest { + // Activity type: "military_flights", "vessels", "protests", "news", "ais_gaps", "satellite_fires". + string type = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Geographic region key, defaults to "global". + string region = 2; + // Current observed count to compare against baseline. + double count = 3; +} + +// BaselineAnomaly describes a detected deviation from historical baseline. +message BaselineAnomaly { + // Number of standard deviations from the mean. + double z_score = 1; + // Severity label: "critical", "high", "medium", "normal". + string severity = 2; + // Ratio of current count to baseline mean. + double multiplier = 3; +} + +// BaselineStats contains the running statistics for a baseline key. +message BaselineStats { + // Running mean of observed counts. + double mean = 1; + // Standard deviation derived from Welford's M2. + double std_dev = 2; + // Number of samples incorporated so far. + int32 sample_count = 3; +} + +// GetTemporalBaselineResponse returns anomaly info or learning status. +message GetTemporalBaselineResponse { + // Anomaly details; absent when count is within normal range. + BaselineAnomaly anomaly = 1; + // Baseline statistics; absent when still in learning phase. + BaselineStats baseline = 2; + // True if insufficient samples have been collected. + bool learning = 3; + // Current number of samples stored. + int32 sample_count = 4; + // Minimum samples required before anomaly detection activates. + int32 samples_needed = 5; + // Error message if request was invalid. + string error = 6; +} diff --git a/proto/worldmonitor/infrastructure/v1/infrastructure.proto b/proto/worldmonitor/infrastructure/v1/infrastructure.proto new file mode 100644 index 000000000..4c34bddb6 --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/infrastructure.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// InternetOutage represents a detected internet outage event from Cloudflare Radar. +message InternetOutage { + // Unique outage identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Outage title. + string title = 2; + // URL to the outage report. + string link = 3; + // Outage description. + string description = 4; + // Detection time, as Unix epoch milliseconds. + int64 detected_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Affected country (ISO 3166-1 alpha-2). + string country = 6; + // Affected region within the country. + string region = 7; + // Outage location. + worldmonitor.core.v1.GeoCoordinates location = 8; + // Outage severity. + OutageSeverity severity = 9; + // Affected infrastructure categories. + repeated string categories = 10; + // Root cause, if determined. + string cause = 11; + // Outage type classification. + string outage_type = 12; + // End time of the outage, as Unix epoch milliseconds. Zero if ongoing. + int64 ended_at = 13 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// ServiceStatus represents the operational status of a monitored external service. +message ServiceStatus { + // Service identifier. + string id = 1; + // Service display name. + string name = 2; + // Current operational status. + ServiceOperationalStatus status = 3; + // Status description. + string description = 4; + // Service URL or homepage. + string url = 5; + // Last status check time, as Unix epoch milliseconds. + int64 checked_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Response latency in milliseconds. + int32 latency_ms = 7; +} + +// OutageSeverity represents the severity of an internet outage. +// Maps to TS union: 'partial' | 'major' | 'total'. +enum OutageSeverity { + // Unspecified severity. + OUTAGE_SEVERITY_UNSPECIFIED = 0; + // Partial outage — some services affected. + OUTAGE_SEVERITY_PARTIAL = 1; + // Major outage — widespread service disruption. + OUTAGE_SEVERITY_MAJOR = 2; + // Total outage — complete service loss. + OUTAGE_SEVERITY_TOTAL = 3; +} + +// ServiceOperationalStatus represents the current status of a service. +enum ServiceOperationalStatus { + // Unspecified status. + SERVICE_OPERATIONAL_STATUS_UNSPECIFIED = 0; + // Service is fully operational. + SERVICE_OPERATIONAL_STATUS_OPERATIONAL = 1; + // Service is experiencing degraded performance. + SERVICE_OPERATIONAL_STATUS_DEGRADED = 2; + // Service is partially disrupted. + SERVICE_OPERATIONAL_STATUS_PARTIAL_OUTAGE = 3; + // Service is completely down. + SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE = 4; + // Service is under maintenance. + SERVICE_OPERATIONAL_STATUS_MAINTENANCE = 5; +} diff --git a/proto/worldmonitor/infrastructure/v1/list_internet_outages.proto b/proto/worldmonitor/infrastructure/v1/list_internet_outages.proto new file mode 100644 index 000000000..8a6d1ee89 --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/list_internet_outages.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/infrastructure/v1/infrastructure.proto"; + +// ListInternetOutagesRequest specifies filters for retrieving internet outages. +message ListInternetOutagesRequest { + // Time range to filter outages. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Optional country filter (ISO 3166-1 alpha-2). + string country = 3; +} + +// ListInternetOutagesResponse contains internet outages matching the request. +message ListInternetOutagesResponse { + // The list of internet outages. + repeated InternetOutage outages = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/infrastructure/v1/list_service_statuses.proto b/proto/worldmonitor/infrastructure/v1/list_service_statuses.proto new file mode 100644 index 000000000..fb9d0e72d --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/list_service_statuses.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "worldmonitor/infrastructure/v1/infrastructure.proto"; + +// ListServiceStatusesRequest specifies filters for retrieving service statuses. +message ListServiceStatusesRequest { + // Optional status filter. Returns only services in this state. + ServiceOperationalStatus status = 1; +} + +// ListServiceStatusesResponse contains service operational statuses. +message ListServiceStatusesResponse { + // The list of service statuses. + repeated ServiceStatus statuses = 1; +} diff --git a/proto/worldmonitor/infrastructure/v1/record_baseline_snapshot.proto b/proto/worldmonitor/infrastructure/v1/record_baseline_snapshot.proto new file mode 100644 index 000000000..ad827236c --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/record_baseline_snapshot.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "buf/validate/validate.proto"; + +// BaselineUpdate is a single metric observation to incorporate into the running baseline. +message BaselineUpdate { + // Activity type key. + string type = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Geographic region key, defaults to "global". + string region = 2; + // Observed count value. + double count = 3; +} + +// RecordBaselineSnapshotRequest batch-updates baselines using Welford's online algorithm. +message RecordBaselineSnapshotRequest { + // Up to 20 metric updates to apply. + repeated BaselineUpdate updates = 1; +} + +// RecordBaselineSnapshotResponse reports how many baselines were successfully updated. +message RecordBaselineSnapshotResponse { + // Number of baselines that were written. + int32 updated = 1; + // Error message if the request was invalid. + string error = 2; +} diff --git a/proto/worldmonitor/infrastructure/v1/service.proto b/proto/worldmonitor/infrastructure/v1/service.proto new file mode 100644 index 000000000..fb34d2af8 --- /dev/null +++ b/proto/worldmonitor/infrastructure/v1/service.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package worldmonitor.infrastructure.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/infrastructure/v1/list_internet_outages.proto"; +import "worldmonitor/infrastructure/v1/list_service_statuses.proto"; +import "worldmonitor/infrastructure/v1/get_temporal_baseline.proto"; +import "worldmonitor/infrastructure/v1/get_cable_health.proto"; +import "worldmonitor/infrastructure/v1/record_baseline_snapshot.proto"; + +// InfrastructureService provides APIs for internet outage monitoring from Cloudflare Radar, +// external service status tracking, and temporal baseline anomaly detection. +service InfrastructureService { + option (sebuf.http.service_config) = {base_path: "/api/infrastructure/v1"}; + + // ListInternetOutages retrieves detected internet outages from Cloudflare Radar. + rpc ListInternetOutages(ListInternetOutagesRequest) returns (ListInternetOutagesResponse) { + option (sebuf.http.config) = {path: "/list-internet-outages"}; + } + + // ListServiceStatuses retrieves operational status of monitored external services. + rpc ListServiceStatuses(ListServiceStatusesRequest) returns (ListServiceStatusesResponse) { + option (sebuf.http.config) = {path: "/list-service-statuses"}; + } + + // GetTemporalBaseline checks current activity count against stored baseline for anomaly detection. + rpc GetTemporalBaseline(GetTemporalBaselineRequest) returns (GetTemporalBaselineResponse) { + option (sebuf.http.config) = {path: "/get-temporal-baseline"}; + } + + // RecordBaselineSnapshot batch-updates baseline statistics using Welford's online algorithm. + rpc RecordBaselineSnapshot(RecordBaselineSnapshotRequest) returns (RecordBaselineSnapshotResponse) { + option (sebuf.http.config) = {path: "/record-baseline-snapshot"}; + } + + // GetCableHealth computes health status for submarine cables from NGA maritime warning signals. + rpc GetCableHealth(GetCableHealthRequest) returns (GetCableHealthResponse) { + option (sebuf.http.config) = {path: "/get-cable-health"}; + } +} diff --git a/proto/worldmonitor/intelligence/v1/classify_event.proto b/proto/worldmonitor/intelligence/v1/classify_event.proto new file mode 100644 index 000000000..140563cbd --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/classify_event.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/intelligence/v1/intelligence.proto"; + +// ClassifyEventRequest specifies an event to classify using AI. +message ClassifyEventRequest { + // Event title or headline. + string title = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Event description or body text. + string description = 2; + // Event source (e.g., "reuters", "acled"). + string source = 3; + // Country context (ISO 3166-1 alpha-2). + string country = 4; +} + +// ClassifyEventResponse contains the AI-generated event classification. +message ClassifyEventResponse { + // The event classification. + EventClassification classification = 1; +} diff --git a/proto/worldmonitor/intelligence/v1/get_country_intel_brief.proto b/proto/worldmonitor/intelligence/v1/get_country_intel_brief.proto new file mode 100644 index 000000000..8f0333945 --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/get_country_intel_brief.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// GetCountryIntelBriefRequest specifies which country to generate a brief for. +message GetCountryIntelBriefRequest { + // ISO 3166-1 alpha-2 country code. + string country_code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$" + ]; +} + +// GetCountryIntelBriefResponse contains an AI-generated intelligence brief for a country. +message GetCountryIntelBriefResponse { + // ISO 3166-1 alpha-2 country code. + string country_code = 1; + // Country name. + string country_name = 2; + // AI-generated intelligence brief text. + string brief = 3; + // AI model used for generation. + string model = 4; + // Brief generation time, as Unix epoch milliseconds. + int64 generated_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/intelligence/v1/get_pizzint_status.proto b/proto/worldmonitor/intelligence/v1/get_pizzint_status.proto new file mode 100644 index 000000000..2332fa658 --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/get_pizzint_status.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "worldmonitor/intelligence/v1/intelligence.proto"; + +// GetPizzintStatusRequest specifies parameters for retrieving PizzINT and GDELT data. +message GetPizzintStatusRequest { + // Whether to include GDELT tension pairs in the response. + bool include_gdelt = 1; +} + +// GetPizzintStatusResponse contains Pentagon Pizza Index and GDELT tension data. +message GetPizzintStatusResponse { + // Pentagon Pizza Index status. + PizzintStatus pizzint = 1; + // GDELT bilateral tension pairs. + repeated GdeltTensionPair tension_pairs = 2; +} diff --git a/proto/worldmonitor/intelligence/v1/get_risk_scores.proto b/proto/worldmonitor/intelligence/v1/get_risk_scores.proto new file mode 100644 index 000000000..c59d25c26 --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/get_risk_scores.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "worldmonitor/intelligence/v1/intelligence.proto"; + +// GetRiskScoresRequest specifies parameters for retrieving risk scores. +message GetRiskScoresRequest { + // Optional region filter. Empty returns all tracked regions. + string region = 1; +} + +// GetRiskScoresResponse contains composite risk scores and strategic assessments. +message GetRiskScoresResponse { + // Composite Instability Index scores. + repeated CiiScore cii_scores = 1; + // Strategic risk assessments. + repeated StrategicRisk strategic_risks = 2; +} diff --git a/proto/worldmonitor/intelligence/v1/intelligence.proto b/proto/worldmonitor/intelligence/v1/intelligence.proto new file mode 100644 index 000000000..4b095211a --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/intelligence.proto @@ -0,0 +1,180 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/severity.proto"; + +// CiiScore represents a Composite Instability Index score for a region or country. +message CiiScore { + // Region or country identifier. + string region = 1; + // Static baseline score (0-100). + double static_baseline = 2 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Dynamic real-time score (0-100). + double dynamic_score = 3 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Combined weighted score (0-100). + double combined_score = 4 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Score trend direction. + worldmonitor.core.v1.TrendDirection trend = 5; + // Contributing component scores. + CiiComponents components = 6; + // Last computation time, as Unix epoch milliseconds. + int64 computed_at = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// CiiComponents represents the contributing factors to a CII score. +message CiiComponents { + // News activity signal contribution (0-100). + double news_activity = 1 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // CII index contribution (0-100). + double cii_contribution = 2 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Geographic convergence score (0-100). + double geo_convergence = 3 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Military activity contribution (0-100). + double military_activity = 4 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; +} + +// StrategicRisk represents a strategic risk assessment for a country or region. +message StrategicRisk { + // Country or region identifier. + string region = 1; + // Risk level. + worldmonitor.core.v1.SeverityLevel level = 2; + // Risk score (0-100). + double score = 3 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Risk factors contributing to the assessment. + repeated string factors = 4; + // Trend direction. + worldmonitor.core.v1.TrendDirection trend = 5; +} + +// PizzintStatus represents the Pentagon Pizza Index status (proxy for late-night DC activity). +message PizzintStatus { + // DEFCON-style level (1-5). + int32 defcon_level = 1 [ + (buf.validate.field).int32.gte = 1, + (buf.validate.field).int32.lte = 5 + ]; + // Human-readable DEFCON label. + string defcon_label = 2; + // Aggregate activity score. + double aggregate_activity = 3; + // Number of active spike locations. + int32 active_spikes = 4; + // Total monitored locations. + int32 locations_monitored = 5; + // Currently open locations. + int32 locations_open = 6; + // Last data update time, as Unix epoch milliseconds. + int64 updated_at = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Data freshness assessment. + DataFreshness data_freshness = 8; + // Individual monitored locations. + repeated PizzintLocation locations = 9; +} + +// PizzintLocation represents a single monitored pizza location near the Pentagon. +message PizzintLocation { + // Google Places ID. + string place_id = 1; + // Location name. + string name = 2; + // Street address. + string address = 3; + // Current popularity score (0-200+). + int32 current_popularity = 4; + // Percentage of usual activity. Zero if unavailable. + int32 percentage_of_usual = 5; + // Whether activity constitutes a spike. + bool is_spike = 6; + // Spike magnitude above baseline. Zero if no spike. + double spike_magnitude = 7; + // Data source identifier. + string data_source = 8; + // Recording timestamp as ISO 8601 string. + string recorded_at = 9; + // Data freshness. + DataFreshness data_freshness = 10; + // Whether the location is currently closed. + bool is_closed_now = 11; + // Latitude of the location. + double lat = 12; + // Longitude of the location. + double lng = 13; +} + +// GdeltTensionPair represents a bilateral tension score between two countries from GDELT. +message GdeltTensionPair { + // Pair identifier. + string id = 1; + // Country pair (ISO 3166-1 alpha-2 codes). + repeated string countries = 2; + // Human-readable label (e.g., "US-China"). + string label = 3; + // Tension score (0-100). + double score = 4 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Trend direction. + worldmonitor.core.v1.TrendDirection trend = 5; + // Percentage change from previous period. + double change_percent = 6; + // Geographic region. + string region = 7; +} + +// EventClassification represents an AI-generated classification of a real-world event. +message EventClassification { + // Event category (e.g., "military", "economic", "social"). + string category = 1; + // Event subcategory. + string subcategory = 2; + // Severity assessment. + worldmonitor.core.v1.SeverityLevel severity = 3; + // Classification confidence (0.0 to 1.0). + double confidence = 4 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 1 + ]; + // Brief AI-generated analysis. + string analysis = 5; + // Related entities identified. + repeated string entities = 6; +} + +// DataFreshness represents how current the data is. +enum DataFreshness { + // Unspecified freshness. + DATA_FRESHNESS_UNSPECIFIED = 0; + // Fresh — data is current. + DATA_FRESHNESS_FRESH = 1; + // Stale — data may be outdated. + DATA_FRESHNESS_STALE = 2; +} diff --git a/proto/worldmonitor/intelligence/v1/search_gdelt_documents.proto b/proto/worldmonitor/intelligence/v1/search_gdelt_documents.proto new file mode 100644 index 000000000..dd115c251 --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/search_gdelt_documents.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "buf/validate/validate.proto"; + +// SearchGdeltDocumentsRequest specifies filters for searching GDELT news articles. +message SearchGdeltDocumentsRequest { + // Search query string. + string query = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Maximum number of articles to return (1-250). + int32 max_records = 2 [ + (buf.validate.field).int32.gte = 1, + (buf.validate.field).int32.lte = 250 + ]; + // Time span filter (e.g., "15min", "1h", "24h"). + string timespan = 3; + // Tone filter appended to query (e.g., "tone>5" for positive, "tone<-5" for negative). + // Left empty to skip tone filtering. + string tone_filter = 4; + // Sort mode: "DateDesc" (default), "ToneDesc", "ToneAsc", "HybridRel". + string sort = 5; +} + +// GdeltArticle represents a single article from the GDELT document API. +message GdeltArticle { + // Article headline. + string title = 1; + // Article URL. + string url = 2; + // Source domain name. + string source = 3; + // Publication date string. + string date = 4; + // Article image URL. + string image = 5; + // Article language code. + string language = 6; + // GDELT tone score (negative = negative tone, positive = positive tone). + double tone = 7; +} + +// SearchGdeltDocumentsResponse contains GDELT article search results. +message SearchGdeltDocumentsResponse { + // Matching articles. + repeated GdeltArticle articles = 1; + // Echo of the search query. + string query = 2; + // Error message if the search failed. + string error = 3; +} diff --git a/proto/worldmonitor/intelligence/v1/service.proto b/proto/worldmonitor/intelligence/v1/service.proto new file mode 100644 index 000000000..eb9f29f27 --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/service.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/intelligence/v1/get_risk_scores.proto"; +import "worldmonitor/intelligence/v1/get_pizzint_status.proto"; +import "worldmonitor/intelligence/v1/classify_event.proto"; +import "worldmonitor/intelligence/v1/get_country_intel_brief.proto"; +import "worldmonitor/intelligence/v1/search_gdelt_documents.proto"; + +// IntelligenceService provides APIs for cross-domain intelligence synthesis including +// risk scores, PizzINT monitoring, GDELT tension analysis, and AI-powered classification. +service IntelligenceService { + option (sebuf.http.service_config) = {base_path: "/api/intelligence/v1"}; + + // GetRiskScores retrieves composite instability and strategic risk assessments. + rpc GetRiskScores(GetRiskScoresRequest) returns (GetRiskScoresResponse) { + option (sebuf.http.config) = {path: "/get-risk-scores"}; + } + + // GetPizzintStatus retrieves Pentagon Pizza Index and GDELT tension pair data. + rpc GetPizzintStatus(GetPizzintStatusRequest) returns (GetPizzintStatusResponse) { + option (sebuf.http.config) = {path: "/get-pizzint-status"}; + } + + // ClassifyEvent classifies a real-world event using AI (Groq). + rpc ClassifyEvent(ClassifyEventRequest) returns (ClassifyEventResponse) { + option (sebuf.http.config) = {path: "/classify-event"}; + } + + // GetCountryIntelBrief generates an AI intelligence brief for a country (OpenRouter). + rpc GetCountryIntelBrief(GetCountryIntelBriefRequest) returns (GetCountryIntelBriefResponse) { + option (sebuf.http.config) = {path: "/get-country-intel-brief"}; + } + + // SearchGdeltDocuments searches the GDELT 2.0 Doc API for news articles. + rpc SearchGdeltDocuments(SearchGdeltDocumentsRequest) returns (SearchGdeltDocumentsResponse) { + option (sebuf.http.config) = {path: "/search-gdelt-documents"}; + } +} diff --git a/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto b/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto new file mode 100644 index 000000000..e8db92992 --- /dev/null +++ b/proto/worldmonitor/maritime/v1/get_vessel_snapshot.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package worldmonitor.maritime.v1; + +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/maritime/v1/vessel_snapshot.proto"; + +// GetVesselSnapshotRequest specifies filters for the vessel snapshot. +message GetVesselSnapshotRequest { + // Optional bounding box for geographic filtering. + worldmonitor.core.v1.BoundingBox bounding_box = 1; +} + +// GetVesselSnapshotResponse contains the vessel traffic snapshot. +message GetVesselSnapshotResponse { + // The vessel traffic snapshot. + VesselSnapshot snapshot = 1; +} diff --git a/proto/worldmonitor/maritime/v1/list_navigational_warnings.proto b/proto/worldmonitor/maritime/v1/list_navigational_warnings.proto new file mode 100644 index 000000000..6ceb9dc22 --- /dev/null +++ b/proto/worldmonitor/maritime/v1/list_navigational_warnings.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.maritime.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/maritime/v1/vessel_snapshot.proto"; + +// ListNavigationalWarningsRequest specifies filters for retrieving NGA warnings. +message ListNavigationalWarningsRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Optional area filter (e.g., "NAVAREA IV", "Persian Gulf"). + string area = 2; +} + +// ListNavigationalWarningsResponse contains navigational warnings matching the request. +message ListNavigationalWarningsResponse { + // The list of navigational warnings. + repeated NavigationalWarning warnings = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/maritime/v1/service.proto b/proto/worldmonitor/maritime/v1/service.proto new file mode 100644 index 000000000..5c5992120 --- /dev/null +++ b/proto/worldmonitor/maritime/v1/service.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.maritime.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/maritime/v1/get_vessel_snapshot.proto"; +import "worldmonitor/maritime/v1/list_navigational_warnings.proto"; + +// MaritimeService provides APIs for civilian AIS vessel data and NGA navigational warnings. +service MaritimeService { + option (sebuf.http.service_config) = {base_path: "/api/maritime/v1"}; + + // GetVesselSnapshot retrieves a point-in-time view of AIS vessel traffic and disruptions. + rpc GetVesselSnapshot(GetVesselSnapshotRequest) returns (GetVesselSnapshotResponse) { + option (sebuf.http.config) = {path: "/get-vessel-snapshot"}; + } + + // ListNavigationalWarnings retrieves active maritime safety warnings from NGA. + rpc ListNavigationalWarnings(ListNavigationalWarningsRequest) returns (ListNavigationalWarningsResponse) { + option (sebuf.http.config) = {path: "/list-navigational-warnings"}; + } +} diff --git a/proto/worldmonitor/maritime/v1/vessel_snapshot.proto b/proto/worldmonitor/maritime/v1/vessel_snapshot.proto new file mode 100644 index 000000000..78ada630b --- /dev/null +++ b/proto/worldmonitor/maritime/v1/vessel_snapshot.proto @@ -0,0 +1,113 @@ +syntax = "proto3"; + +package worldmonitor.maritime.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// VesselSnapshot represents a point-in-time view of civilian AIS vessel data. +message VesselSnapshot { + // Snapshot timestamp, as Unix epoch milliseconds. + int64 snapshot_at = 1 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Density zones showing vessel concentrations. + repeated AisDensityZone density_zones = 2; + // Detected AIS disruptions. + repeated AisDisruption disruptions = 3; +} + +// AisDensityZone represents a zone of concentrated vessel traffic. +message AisDensityZone { + // Zone identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Zone name (e.g., "Strait of Malacca"). + string name = 2; + // Zone centroid location. + worldmonitor.core.v1.GeoCoordinates location = 3; + // Traffic intensity score (0-100). + double intensity = 4 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 100 + ]; + // Change from baseline as a percentage. + double delta_pct = 5; + // Estimated ships per day. + int32 ships_per_day = 6; + // Analyst note. + string note = 7; +} + +// AisDisruption represents a detected anomaly in AIS vessel tracking data. +message AisDisruption { + // Disruption identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Descriptive name. + string name = 2; + // Type of AIS disruption. + AisDisruptionType type = 3; + // Location of the disruption. + worldmonitor.core.v1.GeoCoordinates location = 4; + // Disruption severity. + AisDisruptionSeverity severity = 5; + // Percentage change from normal. + double change_pct = 6; + // Analysis window in hours. + int32 window_hours = 7; + // Number of dark ships (AIS off) detected. + int32 dark_ships = 8; + // Number of vessels in the affected area. + int32 vessel_count = 9; + // Region name. + string region = 10; + // Human-readable description. + string description = 11; +} + +// NavigationalWarning represents a maritime safety warning from NGA. +message NavigationalWarning { + // Warning identifier. + string id = 1; + // Warning title. + string title = 2; + // Full warning text. + string text = 3; + // Geographic area affected. + string area = 4; + // Location of the warning. + worldmonitor.core.v1.GeoCoordinates location = 5; + // Warning issue date, as Unix epoch milliseconds. + int64 issued_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Warning expiry date, as Unix epoch milliseconds. + int64 expires_at = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Warning source authority. + string authority = 8; +} + +// AisDisruptionType represents the type of AIS tracking anomaly. +// Maps to TS union: 'gap_spike' | 'chokepoint_congestion'. +enum AisDisruptionType { + // Unspecified disruption type. + AIS_DISRUPTION_TYPE_UNSPECIFIED = 0; + // Sudden increase in AIS signal gaps. + AIS_DISRUPTION_TYPE_GAP_SPIKE = 1; + // Unusual congestion at a chokepoint. + AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION = 2; +} + +// AisDisruptionSeverity represents the severity of an AIS disruption. +enum AisDisruptionSeverity { + // Unspecified severity. + AIS_DISRUPTION_SEVERITY_UNSPECIFIED = 0; + // Low severity — minor anomaly. + AIS_DISRUPTION_SEVERITY_LOW = 1; + // Elevated severity — notable anomaly. + AIS_DISRUPTION_SEVERITY_ELEVATED = 2; + // High severity — significant anomaly. + AIS_DISRUPTION_SEVERITY_HIGH = 3; +} diff --git a/proto/worldmonitor/market/v1/get_country_stock_index.proto b/proto/worldmonitor/market/v1/get_country_stock_index.proto new file mode 100644 index 000000000..4a59a7f6c --- /dev/null +++ b/proto/worldmonitor/market/v1/get_country_stock_index.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "buf/validate/validate.proto"; + +// GetCountryStockIndexRequest specifies which country's stock index to retrieve. +message GetCountryStockIndexRequest { + // ISO 3166-1 alpha-2 country code (e.g., "US", "GB", "JP"). + string country_code = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.len = 2, + (buf.validate.field).string.pattern = "^[A-Z]{2}$" + ]; +} + +// GetCountryStockIndexResponse contains the country's primary stock index data. +message GetCountryStockIndexResponse { + // Whether stock index data is available for this country. + bool available = 1; + // ISO 3166-1 alpha-2 country code. + string code = 2; + // Ticker symbol (e.g., "^GSPC"). + string symbol = 3; + // Index name (e.g., "S&P 500"). + string index_name = 4; + // Latest closing price. + double price = 5; + // Weekly change percentage. + double week_change_percent = 6; + // Currency of the index. + string currency = 7; + // When the data was fetched (ISO 8601). + string fetched_at = 8; +} diff --git a/proto/worldmonitor/market/v1/get_sector_summary.proto b/proto/worldmonitor/market/v1/get_sector_summary.proto new file mode 100644 index 000000000..019ebe1fe --- /dev/null +++ b/proto/worldmonitor/market/v1/get_sector_summary.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "worldmonitor/market/v1/market_quote.proto"; + +// GetSectorSummaryRequest specifies parameters for retrieving sector performance. +message GetSectorSummaryRequest { + // Time period for performance calculation (e.g., "1d", "1w", "1m"). Defaults to "1d". + string period = 1; +} + +// GetSectorSummaryResponse contains sector performance data. +message GetSectorSummaryResponse { + // The list of sector performances. + repeated SectorPerformance sectors = 1; +} diff --git a/proto/worldmonitor/market/v1/list_commodity_quotes.proto b/proto/worldmonitor/market/v1/list_commodity_quotes.proto new file mode 100644 index 000000000..79b9c74f7 --- /dev/null +++ b/proto/worldmonitor/market/v1/list_commodity_quotes.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "worldmonitor/market/v1/market_quote.proto"; + +// ListCommodityQuotesRequest specifies which commodities to retrieve. +message ListCommodityQuotesRequest { + // Commodity symbols to retrieve (Yahoo symbols). Empty returns defaults. + repeated string symbols = 1; +} + +// ListCommodityQuotesResponse contains commodity quotes. +message ListCommodityQuotesResponse { + // The list of commodity quotes. + repeated CommodityQuote quotes = 1; +} diff --git a/proto/worldmonitor/market/v1/list_crypto_quotes.proto b/proto/worldmonitor/market/v1/list_crypto_quotes.proto new file mode 100644 index 000000000..c4cefcc8f --- /dev/null +++ b/proto/worldmonitor/market/v1/list_crypto_quotes.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "worldmonitor/market/v1/market_quote.proto"; + +// ListCryptoQuotesRequest specifies which cryptocurrencies to retrieve. +message ListCryptoQuotesRequest { + // Cryptocurrency IDs to retrieve (CoinGecko IDs). Empty returns defaults. + repeated string ids = 1; +} + +// ListCryptoQuotesResponse contains cryptocurrency quotes. +message ListCryptoQuotesResponse { + // The list of cryptocurrency quotes. + repeated CryptoQuote quotes = 1; +} diff --git a/proto/worldmonitor/market/v1/list_etf_flows.proto b/proto/worldmonitor/market/v1/list_etf_flows.proto new file mode 100644 index 000000000..23b4a991d --- /dev/null +++ b/proto/worldmonitor/market/v1/list_etf_flows.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// ListEtfFlowsRequest is empty; the handler uses a fixed list of BTC spot ETFs. +message ListEtfFlowsRequest {} + +// EtfFlow represents a single ETF with estimated flow data. +message EtfFlow { + // Ticker symbol (e.g. "IBIT"). + string ticker = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Fund issuer (e.g. "BlackRock"). + string issuer = 2; + // Latest closing price. + double price = 3; + // Day-over-day price change percentage. + double price_change = 4; + // Latest daily volume. + int64 volume = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Average volume over prior days. + int64 avg_volume = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Volume ratio (latest / average). + double volume_ratio = 7; + // Flow direction: "inflow", "outflow", or "neutral". + string direction = 8; + // Estimated dollar flow magnitude. + int64 est_flow = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// EtfFlowsSummary contains aggregate ETF flow stats. +message EtfFlowsSummary { + // Number of ETFs with data. + int32 etf_count = 1; + // Total volume across all ETFs. + int64 total_volume = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Total estimated flow across all ETFs. + int64 total_est_flow = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Net direction: "NET INFLOW", "NET OUTFLOW", or "NEUTRAL". + string net_direction = 4; + // Number of ETFs with inflow. + int32 inflow_count = 5; + // Number of ETFs with outflow. + int32 outflow_count = 6; +} + +// ListEtfFlowsResponse contains BTC spot ETF flow data. +message ListEtfFlowsResponse { + // Timestamp of the data fetch (ISO 8601). + string timestamp = 1; + // Aggregate summary. + EtfFlowsSummary summary = 2; + // Individual ETF flow data, sorted by volume descending. + repeated EtfFlow etfs = 3; +} diff --git a/proto/worldmonitor/market/v1/list_market_quotes.proto b/proto/worldmonitor/market/v1/list_market_quotes.proto new file mode 100644 index 000000000..2b64dfaec --- /dev/null +++ b/proto/worldmonitor/market/v1/list_market_quotes.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "worldmonitor/market/v1/market_quote.proto"; + +// ListMarketQuotesRequest specifies which stock/index symbols to retrieve. +message ListMarketQuotesRequest { + // Ticker symbols to retrieve (e.g., ["AAPL", "^GSPC"]). Empty returns defaults. + repeated string symbols = 1; +} + +// ListMarketQuotesResponse contains stock and index quotes. +message ListMarketQuotesResponse { + // The list of market quotes. + repeated MarketQuote quotes = 1; + + // True when the Finnhub API key is not configured and stock quotes were skipped. + bool finnhub_skipped = 2; + + // Human-readable reason when Finnhub was skipped (e.g., "FINNHUB_API_KEY not configured"). + string skip_reason = 3; +} diff --git a/proto/worldmonitor/market/v1/list_stablecoin_markets.proto b/proto/worldmonitor/market/v1/list_stablecoin_markets.proto new file mode 100644 index 000000000..e25d1f14a --- /dev/null +++ b/proto/worldmonitor/market/v1/list_stablecoin_markets.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "buf/validate/validate.proto"; + +// ListStablecoinMarketsRequest specifies which stablecoins to retrieve. +message ListStablecoinMarketsRequest { + // CoinGecko IDs to retrieve (e.g. "tether,usd-coin"). Empty returns defaults. + repeated string coins = 1; +} + +// Stablecoin represents a single stablecoin with peg health data. +message Stablecoin { + // CoinGecko ID. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Ticker symbol (e.g. "USDT"). + string symbol = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Human-readable name. + string name = 3; + // Current price in USD. + double price = 4 [(buf.validate.field).double.gte = 0]; + // Deviation from $1.00 peg, as a percentage. + double deviation = 5; + // Peg status: "ON PEG", "SLIGHT DEPEG", or "DEPEGGED". + string peg_status = 6; + // Market capitalization in USD. + double market_cap = 7; + // 24-hour trading volume in USD. + double volume_24h = 8; + // 24-hour price change percentage. + double change_24h = 9; + // 7-day price change percentage. + double change_7d = 10; + // Coin image URL. + string image = 11; +} + +// StablecoinSummary contains aggregate stablecoin market stats. +message StablecoinSummary { + // Total market cap across all queried stablecoins. + double total_market_cap = 1; + // Total 24h volume across all queried stablecoins. + double total_volume_24h = 2; + // Number of stablecoins returned. + int32 coin_count = 3; + // Number of stablecoins in DEPEGGED state. + int32 depegged_count = 4; + // Overall health: "HEALTHY", "CAUTION", or "WARNING". + string health_status = 5; +} + +// ListStablecoinMarketsResponse contains stablecoin market data. +message ListStablecoinMarketsResponse { + // Timestamp of the data fetch (ISO 8601). + string timestamp = 1; + // Aggregate summary. + StablecoinSummary summary = 2; + // Individual stablecoin data. + repeated Stablecoin stablecoins = 3; +} diff --git a/proto/worldmonitor/market/v1/market_quote.proto b/proto/worldmonitor/market/v1/market_quote.proto new file mode 100644 index 000000000..e2a2914e2 --- /dev/null +++ b/proto/worldmonitor/market/v1/market_quote.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "buf/validate/validate.proto"; + +// MarketQuote represents a stock or index quote from Finnhub or Yahoo Finance. +message MarketQuote { + // Ticker symbol (e.g., "AAPL", "^GSPC"). + string symbol = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Human-readable name. + string name = 2; + // Display label. + string display = 3; + // Current price. + double price = 4; + // Percentage change from previous close. + double change = 5; + // Sparkline data points (recent price history). + repeated double sparkline = 6; +} + +// CryptoQuote represents a cryptocurrency quote from CoinGecko. +message CryptoQuote { + // Cryptocurrency name (e.g., "Bitcoin"). + string name = 1; + // Ticker symbol (e.g., "BTC"). + string symbol = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Current price in USD. + double price = 3; + // 24-hour percentage change. + double change = 4; + // Sparkline data points (recent price history). + repeated double sparkline = 5; +} + +// CommodityQuote represents a commodity price quote from Yahoo Finance. +message CommodityQuote { + // Commodity symbol (e.g., "CL=F" for crude oil). + string symbol = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Human-readable name. + string name = 2; + // Display label. + string display = 3; + // Current price. + double price = 4; + // Percentage change from previous close. + double change = 5; + // Sparkline data points. + repeated double sparkline = 6; +} + +// SectorPerformance represents performance data for a market sector. +message SectorPerformance { + // Sector symbol. + string symbol = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Sector name. + string name = 2; + // Percentage change over the measured period. + double change = 3; +} diff --git a/proto/worldmonitor/market/v1/service.proto b/proto/worldmonitor/market/v1/service.proto new file mode 100644 index 000000000..cd64c9652 --- /dev/null +++ b/proto/worldmonitor/market/v1/service.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package worldmonitor.market.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/market/v1/list_market_quotes.proto"; +import "worldmonitor/market/v1/list_crypto_quotes.proto"; +import "worldmonitor/market/v1/list_commodity_quotes.proto"; +import "worldmonitor/market/v1/get_sector_summary.proto"; +import "worldmonitor/market/v1/list_stablecoin_markets.proto"; +import "worldmonitor/market/v1/list_etf_flows.proto"; +import "worldmonitor/market/v1/get_country_stock_index.proto"; + +// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko. +service MarketService { + option (sebuf.http.service_config) = {base_path: "/api/market/v1"}; + + // ListMarketQuotes retrieves stock and index quotes. + rpc ListMarketQuotes(ListMarketQuotesRequest) returns (ListMarketQuotesResponse) { + option (sebuf.http.config) = {path: "/list-market-quotes"}; + } + + // ListCryptoQuotes retrieves cryptocurrency quotes from CoinGecko. + rpc ListCryptoQuotes(ListCryptoQuotesRequest) returns (ListCryptoQuotesResponse) { + option (sebuf.http.config) = {path: "/list-crypto-quotes"}; + } + + // ListCommodityQuotes retrieves commodity price quotes from Yahoo Finance. + rpc ListCommodityQuotes(ListCommodityQuotesRequest) returns (ListCommodityQuotesResponse) { + option (sebuf.http.config) = {path: "/list-commodity-quotes"}; + } + + // GetSectorSummary retrieves market sector performance data from Finnhub. + rpc GetSectorSummary(GetSectorSummaryRequest) returns (GetSectorSummaryResponse) { + option (sebuf.http.config) = {path: "/get-sector-summary"}; + } + + // ListStablecoinMarkets retrieves stablecoin peg health and market data from CoinGecko. + rpc ListStablecoinMarkets(ListStablecoinMarketsRequest) returns (ListStablecoinMarketsResponse) { + option (sebuf.http.config) = {path: "/list-stablecoin-markets"}; + } + + // ListEtfFlows retrieves BTC spot ETF flow estimates from Yahoo Finance. + rpc ListEtfFlows(ListEtfFlowsRequest) returns (ListEtfFlowsResponse) { + option (sebuf.http.config) = {path: "/list-etf-flows"}; + } + + // GetCountryStockIndex retrieves the primary stock index for a country from Yahoo Finance. + rpc GetCountryStockIndex(GetCountryStockIndexRequest) returns (GetCountryStockIndexResponse) { + option (sebuf.http.config) = {path: "/get-country-stock-index"}; + } +} diff --git a/proto/worldmonitor/military/v1/get_aircraft_details.proto b/proto/worldmonitor/military/v1/get_aircraft_details.proto new file mode 100644 index 000000000..1c1ffe47e --- /dev/null +++ b/proto/worldmonitor/military/v1/get_aircraft_details.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "buf/validate/validate.proto"; + +// AircraftDetails contains Wingbits aircraft enrichment data. +message AircraftDetails { + // ICAO 24-bit hex address. + string icao24 = 1; + // Aircraft registration number. + string registration = 2; + // ICAO manufacturer code. + string manufacturer_icao = 3; + // Full manufacturer name. + string manufacturer_name = 4; + // Aircraft model. + string model = 5; + // ICAO type designator code. + string typecode = 6; + // Manufacturer serial number. + string serial_number = 7; + // ICAO aircraft type designator. + string icao_aircraft_type = 8; + // Operator name. + string operator = 9; + // Operator callsign. + string operator_callsign = 10; + // Operator ICAO code. + string operator_icao = 11; + // Registered owner. + string owner = 12; + // Build date. + string built = 13; + // Engine description. + string engines = 14; + // ICAO category description. + string category_description = 15; +} + +// GetAircraftDetailsRequest looks up a single aircraft by ICAO 24-bit hex. +message GetAircraftDetailsRequest { + // ICAO 24-bit hex address (lowercase). + string icao24 = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; +} + +// GetAircraftDetailsResponse contains the aircraft enrichment data. +message GetAircraftDetailsResponse { + // Aircraft details, absent if not found. + AircraftDetails details = 1; + // Whether the Wingbits API is configured. + bool configured = 2; +} diff --git a/proto/worldmonitor/military/v1/get_aircraft_details_batch.proto b/proto/worldmonitor/military/v1/get_aircraft_details_batch.proto new file mode 100644 index 000000000..7397b4e99 --- /dev/null +++ b/proto/worldmonitor/military/v1/get_aircraft_details_batch.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/military/v1/get_aircraft_details.proto"; + +// GetAircraftDetailsBatchRequest looks up multiple aircraft by ICAO 24-bit hex. +message GetAircraftDetailsBatchRequest { + // ICAO 24-bit hex addresses (lowercase). Max 20. + repeated string icao24s = 1 [ + (buf.validate.field).repeated.min_items = 1, + (buf.validate.field).repeated.max_items = 20 + ]; +} + +// GetAircraftDetailsBatchResponse contains the batch lookup results. +message GetAircraftDetailsBatchResponse { + // Map of icao24 -> aircraft details for found aircraft. + map results = 1; + // Number of aircraft successfully fetched from upstream. + int32 fetched = 2; + // Number of aircraft requested. + int32 requested = 3; + // Whether the Wingbits API is configured. + bool configured = 4; +} diff --git a/proto/worldmonitor/military/v1/get_theater_posture.proto b/proto/worldmonitor/military/v1/get_theater_posture.proto new file mode 100644 index 000000000..62c7da905 --- /dev/null +++ b/proto/worldmonitor/military/v1/get_theater_posture.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "worldmonitor/military/v1/military_vessel.proto"; + +// GetTheaterPostureRequest specifies the theater to assess. +message GetTheaterPostureRequest { + // Theater name (e.g., "indo-pacific", "european", "middle-east"). Empty for all theaters. + string theater = 1; +} + +// GetTheaterPostureResponse contains theater posture assessments. +message GetTheaterPostureResponse { + // Theater posture assessments (one per theater, or all if no filter). + repeated TheaterPosture theaters = 1; +} diff --git a/proto/worldmonitor/military/v1/get_usni_fleet_report.proto b/proto/worldmonitor/military/v1/get_usni_fleet_report.proto new file mode 100644 index 000000000..a97f35459 --- /dev/null +++ b/proto/worldmonitor/military/v1/get_usni_fleet_report.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "worldmonitor/military/v1/usni_fleet.proto"; + +// GetUSNIFleetReportRequest requests the latest USNI Fleet Tracker report. +message GetUSNIFleetReportRequest { + // When true, bypass cache and fetch fresh data from USNI. + bool force_refresh = 1; +} + +// GetUSNIFleetReportResponse returns the parsed USNI Fleet Tracker report. +message GetUSNIFleetReportResponse { + // The parsed fleet report, if available. + USNIFleetReport report = 1; + // Whether the response was served from cache. + bool cached = 2; + // Whether the cached data is stale (served after a fetch failure). + bool stale = 3; + // Error message, if any. + string error = 4; +} diff --git a/proto/worldmonitor/military/v1/get_wingbits_status.proto b/proto/worldmonitor/military/v1/get_wingbits_status.proto new file mode 100644 index 000000000..2cf3ca84c --- /dev/null +++ b/proto/worldmonitor/military/v1/get_wingbits_status.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +// GetWingbitsStatusRequest checks whether the Wingbits enrichment API is configured. +message GetWingbitsStatusRequest {} + +// GetWingbitsStatusResponse indicates whether Wingbits is available. +message GetWingbitsStatusResponse { + // Whether the Wingbits API key is configured on the server. + bool configured = 1; +} diff --git a/proto/worldmonitor/military/v1/list_military_flights.proto b/proto/worldmonitor/military/v1/list_military_flights.proto new file mode 100644 index 000000000..58c5146d2 --- /dev/null +++ b/proto/worldmonitor/military/v1/list_military_flights.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/military/v1/military_flight.proto"; + +// ListMilitaryFlightsRequest specifies filters for retrieving military flight data. +message ListMilitaryFlightsRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Optional bounding box for geographic filtering. + worldmonitor.core.v1.BoundingBox bounding_box = 2; + // Optional operator filter. + MilitaryOperator operator = 3; + // Optional aircraft type filter. + MilitaryAircraftType aircraft_type = 4; +} + +// ListMilitaryFlightsResponse contains military flights and clusters. +message ListMilitaryFlightsResponse { + // Individual military flights. + repeated MilitaryFlight flights = 1; + // Geographic clusters of flights. + repeated MilitaryFlightCluster clusters = 2; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 3; +} diff --git a/proto/worldmonitor/military/v1/military_flight.proto b/proto/worldmonitor/military/v1/military_flight.proto new file mode 100644 index 000000000..1c608e228 --- /dev/null +++ b/proto/worldmonitor/military/v1/military_flight.proto @@ -0,0 +1,190 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// MilitaryFlight represents a tracked military aircraft from OpenSky or Wingbits. +message MilitaryFlight { + // Unique flight identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Aircraft callsign. + string callsign = 2; + // ICAO 24-bit hex address. + string hex_code = 3; + // Aircraft registration number. + string registration = 4; + // Type of military aircraft. + MilitaryAircraftType aircraft_type = 5; + // Specific aircraft model (e.g., "F-35A", "C-17A"). + string aircraft_model = 6; + // Operating military branch or force. + MilitaryOperator operator = 7; + // Country operating the aircraft (ISO 3166-1 alpha-2). + string operator_country = 8; + // Current position. + worldmonitor.core.v1.GeoCoordinates location = 9; + // Altitude in feet. + double altitude = 10; + // Heading in degrees. + double heading = 11; + // Speed in knots. + double speed = 12; + // Vertical rate in feet per minute. + double vertical_rate = 13; + // Whether the aircraft is on the ground. + bool on_ground = 14; + // Transponder squawk code. + string squawk = 15; + // ICAO code of the origin airport. + string origin = 16; + // ICAO code of the destination airport. + string destination = 17; + // Last seen time, as Unix epoch milliseconds. + int64 last_seen_at = 18 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // First seen time, as Unix epoch milliseconds. + int64 first_seen_at = 19 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Confidence in aircraft identification. + MilitaryConfidence confidence = 20; + // Whether flagged for unusual activity. + bool is_interesting = 21; + // Analyst note. + string note = 22; + // Wingbits enrichment data. + FlightEnrichment enrichment = 23; +} + +// FlightEnrichment contains additional data from Wingbits aircraft database. +message FlightEnrichment { + // Aircraft manufacturer. + string manufacturer = 1; + // Registered owner. + string owner = 2; + // Operator name. + string operator_name = 3; + // ICAO type code. + string type_code = 4; + // Year the aircraft was built. + string built_year = 5; + // Whether confirmed as military. + bool confirmed_military = 6; + // Military branch designation. + string military_branch = 7; +} + +// MilitaryFlightCluster represents a geographic cluster of military flights. +message MilitaryFlightCluster { + // Unique cluster identifier. + string id = 1; + // Descriptive name of the cluster. + string name = 2; + // Cluster centroid location. + worldmonitor.core.v1.GeoCoordinates location = 3; + // Number of flights in the cluster. + int32 flight_count = 4; + // The flights in this cluster. + repeated MilitaryFlight flights = 5; + // Dominant operator in the cluster. + MilitaryOperator dominant_operator = 6; + // Assessed activity type. + MilitaryActivityType activity_type = 7; +} + +// MilitaryAircraftType represents the classification of a military aircraft. +enum MilitaryAircraftType { + // Unspecified aircraft type. + MILITARY_AIRCRAFT_TYPE_UNSPECIFIED = 0; + // Fighter aircraft (F-15, F-16, F-22, Su-27, etc.). + MILITARY_AIRCRAFT_TYPE_FIGHTER = 1; + // Bomber aircraft (B-52, B-1, Tu-95, etc.). + MILITARY_AIRCRAFT_TYPE_BOMBER = 2; + // Transport aircraft (C-130, C-17, Il-76, etc.). + MILITARY_AIRCRAFT_TYPE_TRANSPORT = 3; + // Aerial refueling tanker (KC-135, KC-46, etc.). + MILITARY_AIRCRAFT_TYPE_TANKER = 4; + // Airborne early warning (E-3, E-7, A-50, etc.). + MILITARY_AIRCRAFT_TYPE_AWACS = 5; + // Reconnaissance aircraft (RC-135, U-2, EP-3, etc.). + MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE = 6; + // Military helicopter (UH-60, CH-47, Mi-8, etc.). + MILITARY_AIRCRAFT_TYPE_HELICOPTER = 7; + // Unmanned aerial vehicle (RQ-4, MQ-9, etc.). + MILITARY_AIRCRAFT_TYPE_DRONE = 8; + // Maritime patrol (P-8, P-3, etc.). + MILITARY_AIRCRAFT_TYPE_PATROL = 9; + // Special operations (MC-130, CV-22, etc.). + MILITARY_AIRCRAFT_TYPE_SPECIAL_OPS = 10; + // VIP or government transport. + MILITARY_AIRCRAFT_TYPE_VIP = 11; + // Unknown or unclassified type. + MILITARY_AIRCRAFT_TYPE_UNKNOWN = 12; +} + +// MilitaryOperator represents the military branch or force operating an asset. +enum MilitaryOperator { + // Unspecified operator. + MILITARY_OPERATOR_UNSPECIFIED = 0; + // United States Air Force. + MILITARY_OPERATOR_USAF = 1; + // United States Navy. + MILITARY_OPERATOR_USN = 2; + // United States Marine Corps. + MILITARY_OPERATOR_USMC = 3; + // United States Army. + MILITARY_OPERATOR_USA = 4; + // Royal Air Force (United Kingdom). + MILITARY_OPERATOR_RAF = 5; + // Royal Navy (United Kingdom). + MILITARY_OPERATOR_RN = 6; + // French Air and Space Force. + MILITARY_OPERATOR_FAF = 7; + // German Air Force (Luftwaffe). + MILITARY_OPERATOR_GAF = 8; + // PLA Air Force (China). + MILITARY_OPERATOR_PLAAF = 9; + // PLA Navy (China). + MILITARY_OPERATOR_PLAN = 10; + // Russian Aerospace Forces. + MILITARY_OPERATOR_VKS = 11; + // Israeli Air Force. + MILITARY_OPERATOR_IAF = 12; + // NATO joint operations. + MILITARY_OPERATOR_NATO = 13; + // Other operator. + MILITARY_OPERATOR_OTHER = 14; +} + +// MilitaryConfidence represents confidence in asset identification. +enum MilitaryConfidence { + // Unspecified confidence. + MILITARY_CONFIDENCE_UNSPECIFIED = 0; + // Low confidence. + MILITARY_CONFIDENCE_LOW = 1; + // Medium confidence. + MILITARY_CONFIDENCE_MEDIUM = 2; + // High confidence. + MILITARY_CONFIDENCE_HIGH = 3; +} + +// MilitaryActivityType represents the assessed type of military activity. +enum MilitaryActivityType { + // Unspecified activity type. + MILITARY_ACTIVITY_TYPE_UNSPECIFIED = 0; + // Military exercise. + MILITARY_ACTIVITY_TYPE_EXERCISE = 1; + // Routine patrol. + MILITARY_ACTIVITY_TYPE_PATROL = 2; + // Transport or logistics. + MILITARY_ACTIVITY_TYPE_TRANSPORT = 3; + // Operational deployment. + MILITARY_ACTIVITY_TYPE_DEPLOYMENT = 4; + // Transit movement. + MILITARY_ACTIVITY_TYPE_TRANSIT = 5; + // Unknown activity. + MILITARY_ACTIVITY_TYPE_UNKNOWN = 6; +} diff --git a/proto/worldmonitor/military/v1/military_vessel.proto b/proto/worldmonitor/military/v1/military_vessel.proto new file mode 100644 index 000000000..26da5f810 --- /dev/null +++ b/proto/worldmonitor/military/v1/military_vessel.proto @@ -0,0 +1,119 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/military/v1/military_flight.proto"; + +// MilitaryVessel represents a tracked military or special vessel from AIS data. +message MilitaryVessel { + // Unique vessel identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Maritime Mobile Service Identity. + string mmsi = 2; + // Vessel name. + string name = 3; + // Type of military vessel. + MilitaryVesselType vessel_type = 4; + // Human-readable AIS ship type (e.g., "Cargo", "Tanker"). + string ais_ship_type = 5; + // Hull number (e.g., "DDG-51", "CVN-78"). + string hull_number = 6; + // Operating military branch or force. + MilitaryOperator operator = 7; + // Country operating the vessel (ISO 3166-1 alpha-2). + string operator_country = 8; + // Current position. + worldmonitor.core.v1.GeoCoordinates location = 9; + // Heading in degrees. + double heading = 10; + // Speed in knots. + double speed = 11; + // Course over ground in degrees. + double course = 12; + // AIS-reported destination. + string destination = 13; + // Last AIS position update time, as Unix epoch milliseconds. + int64 last_ais_update_at = 14 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Minutes since last AIS signal. + int32 ais_gap_minutes = 15; + // Whether AIS appears disabled or suspicious. + bool is_dark = 16; + // Name of nearby strategic waterway, if any. + string near_chokepoint = 17; + // Name of nearby naval base, if any. + string near_base = 18; + // Confidence in vessel identification. + MilitaryConfidence confidence = 19; + // Whether flagged for unusual activity. + bool is_interesting = 20; + // Analyst note. + string note = 21; +} + +// MilitaryVesselCluster represents a geographic cluster of military vessels. +message MilitaryVesselCluster { + // Unique cluster identifier. + string id = 1; + // Descriptive name of the cluster. + string name = 2; + // Cluster centroid location. + worldmonitor.core.v1.GeoCoordinates location = 3; + // Number of vessels in the cluster. + int32 vessel_count = 4; + // The vessels in this cluster. + repeated MilitaryVessel vessels = 5; + // Region name. + string region = 6; + // Assessed activity type. + MilitaryActivityType activity_type = 7; +} + +// TheaterPosture represents an assessed military posture for a geographic theater. +message TheaterPosture { + // Theater name (e.g., "Indo-Pacific", "European", "Middle East"). + string theater = 1; + // Overall posture assessment. + string posture_level = 2; + // Number of active flights in the theater. + int32 active_flights = 3; + // Number of tracked vessels in the theater. + int32 tracked_vessels = 4; + // Notable ongoing operations. + repeated string active_operations = 5; + // Assessment timestamp, as Unix epoch milliseconds. + int64 assessed_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +// MilitaryVesselType represents the classification of a military vessel. +enum MilitaryVesselType { + // Unspecified vessel type. + MILITARY_VESSEL_TYPE_UNSPECIFIED = 0; + // Aircraft carrier. + MILITARY_VESSEL_TYPE_CARRIER = 1; + // Destroyer or cruiser. + MILITARY_VESSEL_TYPE_DESTROYER = 2; + // Frigate or corvette. + MILITARY_VESSEL_TYPE_FRIGATE = 3; + // Submarine (when surfaced or detected). + MILITARY_VESSEL_TYPE_SUBMARINE = 4; + // Amphibious assault ship (LHD, LPD, LST). + MILITARY_VESSEL_TYPE_AMPHIBIOUS = 5; + // Coast guard or patrol boat. + MILITARY_VESSEL_TYPE_PATROL = 6; + // Supply ship or fleet tanker. + MILITARY_VESSEL_TYPE_AUXILIARY = 7; + // Intelligence gathering or research vessel. + MILITARY_VESSEL_TYPE_RESEARCH = 8; + // Military icebreaker. + MILITARY_VESSEL_TYPE_ICEBREAKER = 9; + // Special mission vessel. + MILITARY_VESSEL_TYPE_SPECIAL = 10; + // Unknown vessel type. + MILITARY_VESSEL_TYPE_UNKNOWN = 11; +} diff --git a/proto/worldmonitor/military/v1/service.proto b/proto/worldmonitor/military/v1/service.proto new file mode 100644 index 000000000..d1e16fc38 --- /dev/null +++ b/proto/worldmonitor/military/v1/service.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/military/v1/list_military_flights.proto"; +import "worldmonitor/military/v1/get_theater_posture.proto"; +import "worldmonitor/military/v1/get_aircraft_details.proto"; +import "worldmonitor/military/v1/get_aircraft_details_batch.proto"; +import "worldmonitor/military/v1/get_wingbits_status.proto"; +import "worldmonitor/military/v1/get_usni_fleet_report.proto"; + +// MilitaryService provides APIs for military flight and vessel tracking +// sourced from OpenSky, Wingbits, and AIS data. +service MilitaryService { + option (sebuf.http.service_config) = {base_path: "/api/military/v1"}; + + // ListMilitaryFlights retrieves tracked military aircraft from OpenSky and Wingbits. + rpc ListMilitaryFlights(ListMilitaryFlightsRequest) returns (ListMilitaryFlightsResponse) { + option (sebuf.http.config) = {path: "/list-military-flights"}; + } + + // GetTheaterPosture retrieves military posture assessments for geographic theaters. + rpc GetTheaterPosture(GetTheaterPostureRequest) returns (GetTheaterPostureResponse) { + option (sebuf.http.config) = {path: "/get-theater-posture"}; + } + + // GetAircraftDetails retrieves Wingbits aircraft enrichment data for a single ICAO24 hex. + rpc GetAircraftDetails(GetAircraftDetailsRequest) returns (GetAircraftDetailsResponse) { + option (sebuf.http.config) = {path: "/get-aircraft-details"}; + } + + // GetAircraftDetailsBatch retrieves Wingbits aircraft enrichment data for multiple ICAO24 hexes. + rpc GetAircraftDetailsBatch(GetAircraftDetailsBatchRequest) returns (GetAircraftDetailsBatchResponse) { + option (sebuf.http.config) = {path: "/get-aircraft-details-batch"}; + } + + // GetWingbitsStatus checks whether the Wingbits enrichment API is configured. + rpc GetWingbitsStatus(GetWingbitsStatusRequest) returns (GetWingbitsStatusResponse) { + option (sebuf.http.config) = {path: "/get-wingbits-status"}; + } + + // GetUSNIFleetReport retrieves the latest parsed USNI Fleet Tracker report. + rpc GetUSNIFleetReport(GetUSNIFleetReportRequest) returns (GetUSNIFleetReportResponse) { + option (sebuf.http.config) = {path: "/get-usni-fleet-report"}; + } +} diff --git a/proto/worldmonitor/military/v1/usni_fleet.proto b/proto/worldmonitor/military/v1/usni_fleet.proto new file mode 100644 index 000000000..9630dd286 --- /dev/null +++ b/proto/worldmonitor/military/v1/usni_fleet.proto @@ -0,0 +1,86 @@ +syntax = "proto3"; + +package worldmonitor.military.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// USNIVessel represents a single vessel parsed from a USNI Fleet Tracker article. +message USNIVessel { + // Vessel name (e.g., "USS Abraham Lincoln"). + string name = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Hull designation (e.g., "CVN-72", "DDG-51"). + string hull_number = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Vessel type classification (e.g., "carrier", "destroyer", "submarine"). + string vessel_type = 3; + // Region name where the vessel is operating. + string region = 4; + // Approximate latitude for the region. + double region_lat = 5; + // Approximate longitude for the region. + double region_lon = 6; + // Deployment status (e.g., "deployed", "underway", "in-port", "unknown"). + string deployment_status = 7; + // Home port, if identified from the article text. + string home_port = 8; + // Strike group assignment, if any. + string strike_group = 9; + // Brief activity description parsed from article prose. + string activity_description = 10; + // URL of the USNI article this vessel was parsed from. + string article_url = 11; + // Publication date of the USNI article. + string article_date = 12; +} + +// USNIStrikeGroup represents a carrier strike group parsed from the article. +message USNIStrikeGroup { + // Strike group name (e.g., "Abraham Lincoln Carrier Strike Group"). + string name = 1; + // Carrier name and hull (e.g., "USS Abraham Lincoln (CVN-72)"). + string carrier = 2; + // Assigned air wing (e.g., "Carrier Air Wing Nine"). + string air_wing = 3; + // Assigned destroyer squadron. + string destroyer_squadron = 4; + // Escort vessels in the strike group. + repeated string escorts = 5; +} + +// BattleForceSummary contains fleet-wide ship count statistics. +message BattleForceSummary { + // Total ships in the battle force. + int32 total_ships = 1 [(buf.validate.field).int32.gte = 0]; + // Number of ships currently deployed. + int32 deployed = 2 [(buf.validate.field).int32.gte = 0]; + // Number of ships currently underway. + int32 underway = 3 [(buf.validate.field).int32.gte = 0]; +} + +// USNIFleetReport is the full parsed output of a USNI Fleet Tracker article. +message USNIFleetReport { + // URL of the source article. + string article_url = 1; + // Publication date of the article. + string article_date = 2; + // Title of the article. + string article_title = 3; + // Battle force summary statistics, if present in the article. + BattleForceSummary battle_force_summary = 4; + // All vessels identified in the article. + repeated USNIVessel vessels = 5; + // Carrier strike groups identified in the article. + repeated USNIStrikeGroup strike_groups = 6; + // Unique region names mentioned in the article. + repeated string regions = 7; + // Warnings generated during parsing. + repeated string parsing_warnings = 8; + // Time the report was generated, as Unix epoch milliseconds. + int64 timestamp = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/news/v1/news_item.proto b/proto/worldmonitor/news/v1/news_item.proto new file mode 100644 index 000000000..3c73016d5 --- /dev/null +++ b/proto/worldmonitor/news/v1/news_item.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package worldmonitor.news.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// NewsItem represents a single news article from RSS feed aggregation. +message NewsItem { + // Source feed name. + string source = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Article headline. + string title = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Article URL. + string link = 3; + // Publication time, as Unix epoch milliseconds. + int64 published_at = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Whether this article triggered an alert condition. + bool is_alert = 5; + // Threat classification, if assessed. + ThreatClassification threat = 6; + // Geolocation extracted from the article. + worldmonitor.core.v1.GeoCoordinates location = 7; + // Human-readable location name. + string location_name = 8; +} + +// ThreatClassification represents an AI-assessed threat level for a news item. +message ThreatClassification { + // Overall threat level. + ThreatLevel level = 1; + // Event category. + string category = 2; + // Confidence score (0.0 to 1.0). + double confidence = 3 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 1 + ]; +} + +// HeadlineSummary represents an AI-generated summary of recent headlines. +message HeadlineSummary { + // Summary text. + string text = 1; + // Number of headlines analyzed. + int32 headline_count = 2; + // Summary generation time, as Unix epoch milliseconds. + int64 generated_at = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // AI model used for summarization. + string model = 4; +} + +// ThreatLevel represents the assessed threat level of a news event. +enum ThreatLevel { + // Unspecified threat level. + THREAT_LEVEL_UNSPECIFIED = 0; + // Low threat — routine reporting. + THREAT_LEVEL_LOW = 1; + // Medium threat — warrants monitoring. + THREAT_LEVEL_MEDIUM = 2; + // High threat — significant concern. + THREAT_LEVEL_HIGH = 3; + // Critical threat — immediate attention required. + THREAT_LEVEL_CRITICAL = 4; +} diff --git a/proto/worldmonitor/news/v1/service.proto b/proto/worldmonitor/news/v1/service.proto new file mode 100644 index 000000000..0ac91bdb0 --- /dev/null +++ b/proto/worldmonitor/news/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.news.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/news/v1/summarize_article.proto"; + +// NewsService provides AI-powered article summarization. +service NewsService { + option (sebuf.http.service_config) = {base_path: "/api/news/v1"}; + + // SummarizeArticle generates an LLM summary with provider selection and fallback support. + rpc SummarizeArticle(SummarizeArticleRequest) returns (SummarizeArticleResponse) { + option (sebuf.http.config) = {path: "/summarize-article"}; + } +} diff --git a/proto/worldmonitor/news/v1/summarize_article.proto b/proto/worldmonitor/news/v1/summarize_article.proto new file mode 100644 index 000000000..186e6cfcb --- /dev/null +++ b/proto/worldmonitor/news/v1/summarize_article.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package worldmonitor.news.v1; + +import "buf/validate/validate.proto"; + +// SummarizeArticleRequest specifies parameters for LLM article summarization. +message SummarizeArticleRequest { + // LLM provider: "ollama", "groq", "openrouter" + string provider = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Headlines to summarize (max 8 used). + repeated string headlines = 2 [(buf.validate.field).repeated.min_items = 1]; + // Summarization mode: "brief", "analysis", "translate", "" (default). + string mode = 3; + // Geographic signal context to include in the prompt. + string geo_context = 4; + // Variant: "full", "tech", or target language for translate mode. + string variant = 5; + // Output language code, default "en". + string lang = 6; +} + +// SummarizeArticleResponse contains the LLM summarization result. +message SummarizeArticleResponse { + // The generated summary text. + string summary = 1; + // Model identifier used for generation. + string model = 2; + // Provider that produced the result (or "cache"). + string provider = 3; + // Whether the result came from Redis cache. + bool cached = 4; + // Token count from the LLM response. + int32 tokens = 5; + // Whether the client should try the next provider in the fallback chain. + bool fallback = 6; + // Whether this provider was skipped (credentials missing). + bool skipped = 7; + // Human-readable skip/error reason. + string reason = 8; + // Error message if the request failed. + string error = 9; + // Error type/name (e.g. "TypeError"). + string error_type = 10; +} diff --git a/proto/worldmonitor/positive_events/v1/list_positive_geo_events.proto b/proto/worldmonitor/positive_events/v1/list_positive_geo_events.proto new file mode 100644 index 000000000..5a8c1b5b1 --- /dev/null +++ b/proto/worldmonitor/positive_events/v1/list_positive_geo_events.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package worldmonitor.positive_events.v1; + +import "sebuf/http/annotations.proto"; + +message PositiveGeoEvent { + double latitude = 1; + double longitude = 2; + string name = 3; + string category = 4; + int32 count = 5; + int64 timestamp = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} + +message ListPositiveGeoEventsRequest {} + +message ListPositiveGeoEventsResponse { + repeated PositiveGeoEvent events = 1; +} diff --git a/proto/worldmonitor/positive_events/v1/service.proto b/proto/worldmonitor/positive_events/v1/service.proto new file mode 100644 index 000000000..09e48703c --- /dev/null +++ b/proto/worldmonitor/positive_events/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.positive_events.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/positive_events/v1/list_positive_geo_events.proto"; + +// PositiveEventsService provides APIs for geocoded positive news events. +service PositiveEventsService { + option (sebuf.http.service_config) = {base_path: "/api/positive-events/v1"}; + + // ListPositiveGeoEvents retrieves geocoded positive news events from GDELT GEO API. + rpc ListPositiveGeoEvents(ListPositiveGeoEventsRequest) returns (ListPositiveGeoEventsResponse) { + option (sebuf.http.config) = {path: "/list-positive-geo-events"}; + } +} diff --git a/proto/worldmonitor/prediction/v1/list_prediction_markets.proto b/proto/worldmonitor/prediction/v1/list_prediction_markets.proto new file mode 100644 index 000000000..aa52a89ed --- /dev/null +++ b/proto/worldmonitor/prediction/v1/list_prediction_markets.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package worldmonitor.prediction.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/prediction/v1/prediction_market.proto"; + +// ListPredictionMarketsRequest specifies filters for retrieving prediction markets. +message ListPredictionMarketsRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Optional category filter (e.g., "Politics"). + string category = 2; + // Optional search query for market titles. + string query = 3; +} + +// ListPredictionMarketsResponse contains prediction markets matching the request. +message ListPredictionMarketsResponse { + // The list of prediction markets. + repeated PredictionMarket markets = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/prediction/v1/prediction_market.proto b/proto/worldmonitor/prediction/v1/prediction_market.proto new file mode 100644 index 000000000..e9ca7f5ac --- /dev/null +++ b/proto/worldmonitor/prediction/v1/prediction_market.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package worldmonitor.prediction.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// PredictionMarket represents a prediction market contract from Polymarket. +message PredictionMarket { + // Unique market identifier or slug. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Market question or title. + string title = 2; + // Current "Yes" price (0.0 to 1.0, representing probability). + double yes_price = 3 [ + (buf.validate.field).double.gte = 0, + (buf.validate.field).double.lte = 1 + ]; + // Trading volume in USD. + double volume = 4 [(buf.validate.field).double.gte = 0]; + // URL to the Polymarket market page. + string url = 5; + // Market close time, as Unix epoch milliseconds. Zero if no expiry. + int64 closes_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Market category (e.g., "Politics", "Crypto", "Sports"). + string category = 7; +} diff --git a/proto/worldmonitor/prediction/v1/service.proto b/proto/worldmonitor/prediction/v1/service.proto new file mode 100644 index 000000000..86ac1dce4 --- /dev/null +++ b/proto/worldmonitor/prediction/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.prediction.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/prediction/v1/list_prediction_markets.proto"; + +// PredictionService provides APIs for prediction market data from Polymarket. +service PredictionService { + option (sebuf.http.service_config) = {base_path: "/api/prediction/v1"}; + + // ListPredictionMarkets retrieves active prediction markets from Polymarket. + rpc ListPredictionMarkets(ListPredictionMarketsRequest) returns (ListPredictionMarketsResponse) { + option (sebuf.http.config) = {path: "/list-prediction-markets"}; + } +} diff --git a/proto/worldmonitor/research/v1/list_arxiv_papers.proto b/proto/worldmonitor/research/v1/list_arxiv_papers.proto new file mode 100644 index 000000000..86142978a --- /dev/null +++ b/proto/worldmonitor/research/v1/list_arxiv_papers.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package worldmonitor.research.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/research/v1/research_item.proto"; + +// ListArxivPapersRequest specifies filters for retrieving arXiv papers. +message ListArxivPapersRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // arXiv category filter (e.g., "cs.AI"). Empty returns all tracked categories. + string category = 2; + // Search query for paper titles and abstracts. + string query = 3; +} + +// ListArxivPapersResponse contains arXiv papers matching the request. +message ListArxivPapersResponse { + // The list of arXiv papers. + repeated ArxivPaper papers = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/research/v1/list_hackernews_items.proto b/proto/worldmonitor/research/v1/list_hackernews_items.proto new file mode 100644 index 000000000..5e4ebe200 --- /dev/null +++ b/proto/worldmonitor/research/v1/list_hackernews_items.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package worldmonitor.research.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/research/v1/research_item.proto"; + +// ListHackernewsItemsRequest specifies filters for retrieving Hacker News items. +message ListHackernewsItemsRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Feed type: "top", "new", "best", "ask", "show". Defaults to "top". + string feed_type = 2; +} + +// ListHackernewsItemsResponse contains Hacker News items. +message ListHackernewsItemsResponse { + // The list of Hacker News items. + repeated HackernewsItem items = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/research/v1/list_tech_events.proto b/proto/worldmonitor/research/v1/list_tech_events.proto new file mode 100644 index 000000000..a45866a23 --- /dev/null +++ b/proto/worldmonitor/research/v1/list_tech_events.proto @@ -0,0 +1,76 @@ +syntax = "proto3"; + +package worldmonitor.research.v1; + +import "buf/validate/validate.proto"; + +// ListTechEventsRequest specifies filters for retrieving tech events. +message ListTechEventsRequest { + // Event type filter: "all", "conferences", "earnings", "ipo", "other". Empty = all. + string type = 1; + // Only events with non-virtual coordinates. + bool mappable = 2; + // Max events to return (0 = unlimited). + int32 limit = 3 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = 500 + ]; + // Events within N days from now (0 = unlimited). + int32 days = 4 [(buf.validate.field).int32.gte = 0]; +} + +// TechEventCoords contains geocoded location data for a tech event. +message TechEventCoords { + // Latitude. + double lat = 1; + // Longitude. + double lng = 2; + // Country name or code. + string country = 3; + // Original location string before normalization. + string original = 4; + // Whether this is a virtual/online event. + bool virtual = 5; +} + +// TechEvent represents a single tech event (conference, earnings, IPO, etc.). +message TechEvent { + // Unique event identifier. + string id = 1; + // Event title. + string title = 2; + // Event type: "conference", "earnings", "ipo", "other". + string type = 3; + // Location description. + string location = 4; + // Geocoded coordinates (may be absent). + TechEventCoords coords = 5; + // Start date (YYYY-MM-DD). + string start_date = 6; + // End date (YYYY-MM-DD). + string end_date = 7; + // Event URL. + string url = 8; + // Source: "techmeme", "dev.events", "curated". + string source = 9; + // Event description. + string description = 10; +} + +// ListTechEventsResponse contains tech events matching the request. +message ListTechEventsResponse { + // Whether the request succeeded. + bool success = 1; + // Total event count in response. + int32 count = 2; + // Number of conference-type events. + int32 conference_count = 3; + // Number of mappable (non-virtual with coords) events. + int32 mappable_count = 4; + // ISO 8601 timestamp of last update. + string last_updated = 5; + // The events. + repeated TechEvent events = 6; + // Error message if success is false. + string error = 7; +} diff --git a/proto/worldmonitor/research/v1/list_trending_repos.proto b/proto/worldmonitor/research/v1/list_trending_repos.proto new file mode 100644 index 000000000..74423bb16 --- /dev/null +++ b/proto/worldmonitor/research/v1/list_trending_repos.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package worldmonitor.research.v1; + +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/research/v1/research_item.proto"; + +// ListTrendingReposRequest specifies filters for retrieving trending GitHub repos. +message ListTrendingReposRequest { + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 1; + // Programming language filter (e.g., "python", "typescript"). + string language = 2; + // Trending period (e.g., "daily", "weekly"). Defaults to "daily". + string period = 3; +} + +// ListTrendingReposResponse contains trending GitHub repositories. +message ListTrendingReposResponse { + // The list of trending repos. + repeated GithubRepo repos = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/research/v1/research_item.proto b/proto/worldmonitor/research/v1/research_item.proto new file mode 100644 index 000000000..7445b245e --- /dev/null +++ b/proto/worldmonitor/research/v1/research_item.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package worldmonitor.research.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; + +// ArxivPaper represents a research paper from arXiv. +message ArxivPaper { + // arXiv paper ID (e.g., "2401.12345"). + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Paper title. + string title = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Paper abstract (may be truncated). + string summary = 3; + // Author names. + repeated string authors = 4; + // arXiv categories (e.g., "cs.AI", "cs.LG"). + repeated string categories = 5; + // Publication time, as Unix epoch milliseconds. + int64 published_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // URL to the paper. + string url = 7; +} + +// GithubRepo represents a trending repository from GitHub. +message GithubRepo { + // Repository full name (e.g., "owner/repo"). + string full_name = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Repository description. + string description = 2; + // Primary programming language. + string language = 3; + // Total star count. + int32 stars = 4 [(buf.validate.field).int32.gte = 0]; + // Stars gained in the trending period. + int32 stars_today = 5; + // Number of open forks. + int32 forks = 6; + // Repository URL. + string url = 7; +} + +// HackernewsItem represents an item from Hacker News. +message HackernewsItem { + // HN item ID. + int32 id = 1; + // Item title. + string title = 2 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // URL (empty for Ask HN / Show HN text posts). + string url = 3; + // Upvote score. + int32 score = 4 [(buf.validate.field).int32.gte = 0]; + // Number of comments. + int32 comment_count = 5; + // Author username. + string by = 6; + // Submission time, as Unix epoch milliseconds. + int64 submitted_at = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; +} diff --git a/proto/worldmonitor/research/v1/service.proto b/proto/worldmonitor/research/v1/service.proto new file mode 100644 index 000000000..e7a511ee0 --- /dev/null +++ b/proto/worldmonitor/research/v1/service.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package worldmonitor.research.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/research/v1/list_arxiv_papers.proto"; +import "worldmonitor/research/v1/list_trending_repos.proto"; +import "worldmonitor/research/v1/list_hackernews_items.proto"; +import "worldmonitor/research/v1/list_tech_events.proto"; + +// ResearchService provides APIs for academic papers, trending repos, and tech news. +service ResearchService { + option (sebuf.http.service_config) = {base_path: "/api/research/v1"}; + + // ListArxivPapers retrieves recent papers from arXiv. + rpc ListArxivPapers(ListArxivPapersRequest) returns (ListArxivPapersResponse) { + option (sebuf.http.config) = {path: "/list-arxiv-papers"}; + } + + // ListTrendingRepos retrieves trending repositories from GitHub. + rpc ListTrendingRepos(ListTrendingReposRequest) returns (ListTrendingReposResponse) { + option (sebuf.http.config) = {path: "/list-trending-repos"}; + } + + // ListHackernewsItems retrieves top stories from Hacker News. + rpc ListHackernewsItems(ListHackernewsItemsRequest) returns (ListHackernewsItemsResponse) { + option (sebuf.http.config) = {path: "/list-hackernews-items"}; + } + + // ListTechEvents retrieves tech events from Techmeme ICS, dev.events RSS, and curated sources. + rpc ListTechEvents(ListTechEventsRequest) returns (ListTechEventsResponse) { + option (sebuf.http.config) = {path: "/list-tech-events"}; + } +} diff --git a/proto/worldmonitor/seismology/v1/earthquake.proto b/proto/worldmonitor/seismology/v1/earthquake.proto new file mode 100644 index 000000000..291ac34ea --- /dev/null +++ b/proto/worldmonitor/seismology/v1/earthquake.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package worldmonitor.seismology.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// Earthquake represents a seismic event from USGS GeoJSON feed. +message Earthquake { + // Unique USGS event identifier (e.g., "us7000abcd"). + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 100 + ]; + // Human-readable place description (e.g., "10 km SW of Anchorage, Alaska"). + string place = 2; + // Earthquake magnitude on the Richter scale. + double magnitude = 3; + // Depth in kilometers below the surface. + double depth_km = 4; + // Geographic location of the epicenter. + worldmonitor.core.v1.GeoCoordinates location = 5; + // Time the earthquake occurred, as Unix epoch milliseconds. + int64 occurred_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // URL to the USGS event detail page. + string source_url = 7; +} diff --git a/proto/worldmonitor/seismology/v1/list_earthquakes.proto b/proto/worldmonitor/seismology/v1/list_earthquakes.proto new file mode 100644 index 000000000..1051f4456 --- /dev/null +++ b/proto/worldmonitor/seismology/v1/list_earthquakes.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package worldmonitor.seismology.v1; + +import "buf/validate/validate.proto"; +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/seismology/v1/earthquake.proto"; + +// ListEarthquakesRequest specifies filters for retrieving earthquake data from USGS. +message ListEarthquakesRequest { + // Time range to filter earthquakes. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Minimum magnitude filter (e.g., 4.0 for significant quakes). + double min_magnitude = 3 [(buf.validate.field).double.gte = 0]; +} + +// ListEarthquakesResponse contains the list of earthquakes matching the request filters. +message ListEarthquakesResponse { + // The list of earthquakes. + repeated Earthquake earthquakes = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/seismology/v1/service.proto b/proto/worldmonitor/seismology/v1/service.proto new file mode 100644 index 000000000..d9ec1b200 --- /dev/null +++ b/proto/worldmonitor/seismology/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.seismology.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/seismology/v1/list_earthquakes.proto"; + +// SeismologyService provides APIs for earthquake monitoring data sourced from USGS. +service SeismologyService { + option (sebuf.http.service_config) = {base_path: "/api/seismology/v1"}; + + // ListEarthquakes retrieves recent earthquakes from the USGS GeoJSON feed. + rpc ListEarthquakes(ListEarthquakesRequest) returns (ListEarthquakesResponse) { + option (sebuf.http.config) = {path: "/list-earthquakes"}; + } +} diff --git a/proto/worldmonitor/supply_chain/v1/get_chokepoint_status.proto b/proto/worldmonitor/supply_chain/v1/get_chokepoint_status.proto new file mode 100644 index 000000000..ebd49f935 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_chokepoint_status.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package worldmonitor.supply_chain.v1; + +import "worldmonitor/supply_chain/v1/supply_chain_data.proto"; + +message GetChokepointStatusRequest {} + +message GetChokepointStatusResponse { + repeated ChokepointInfo chokepoints = 1; + string fetched_at = 2; + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/supply_chain/v1/get_critical_minerals.proto b/proto/worldmonitor/supply_chain/v1/get_critical_minerals.proto new file mode 100644 index 000000000..ef3333b83 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_critical_minerals.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package worldmonitor.supply_chain.v1; + +import "worldmonitor/supply_chain/v1/supply_chain_data.proto"; + +message GetCriticalMineralsRequest {} + +message GetCriticalMineralsResponse { + repeated CriticalMineral minerals = 1; + string fetched_at = 2; + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/supply_chain/v1/get_shipping_rates.proto b/proto/worldmonitor/supply_chain/v1/get_shipping_rates.proto new file mode 100644 index 000000000..2c5f90bfa --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/get_shipping_rates.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package worldmonitor.supply_chain.v1; + +import "worldmonitor/supply_chain/v1/supply_chain_data.proto"; + +message GetShippingRatesRequest {} + +message GetShippingRatesResponse { + repeated ShippingIndex indices = 1; + string fetched_at = 2; + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/supply_chain/v1/service.proto b/proto/worldmonitor/supply_chain/v1/service.proto new file mode 100644 index 000000000..8cd87e35a --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/service.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package worldmonitor.supply_chain.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/supply_chain/v1/get_shipping_rates.proto"; +import "worldmonitor/supply_chain/v1/get_chokepoint_status.proto"; +import "worldmonitor/supply_chain/v1/get_critical_minerals.proto"; + +service SupplyChainService { + option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"}; + + rpc GetShippingRates(GetShippingRatesRequest) returns (GetShippingRatesResponse) { + option (sebuf.http.config) = {path: "/get-shipping-rates"}; + } + + rpc GetChokepointStatus(GetChokepointStatusRequest) returns (GetChokepointStatusResponse) { + option (sebuf.http.config) = {path: "/get-chokepoint-status"}; + } + + rpc GetCriticalMinerals(GetCriticalMineralsRequest) returns (GetCriticalMineralsResponse) { + option (sebuf.http.config) = {path: "/get-critical-minerals"}; + } +} diff --git a/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto b/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto new file mode 100644 index 000000000..37ae0dcb5 --- /dev/null +++ b/proto/worldmonitor/supply_chain/v1/supply_chain_data.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package worldmonitor.supply_chain.v1; + +message ShippingRatePoint { + string date = 1; + double value = 2; +} + +message ShippingIndex { + string index_id = 1; + string name = 2; + double current_value = 3; + double previous_value = 4; + double change_pct = 5; + string unit = 6; + repeated ShippingRatePoint history = 7; + bool spike_alert = 8; +} + +message ChokepointInfo { + string id = 1; + string name = 2; + double lat = 3; + double lon = 4; + int32 disruption_score = 5; + string status = 6; + int32 active_warnings = 7; + string congestion_level = 8; + repeated string affected_routes = 9; + string description = 10; +} + +message MineralProducer { + string country = 1; + string country_code = 2; + double production_tonnes = 3; + double share_pct = 4; +} + +message CriticalMineral { + string mineral = 1; + repeated MineralProducer top_producers = 2; + double hhi = 3; + string risk_rating = 4; + double global_production = 5; + string unit = 6; +} diff --git a/proto/worldmonitor/trade/v1/get_tariff_trends.proto b/proto/worldmonitor/trade/v1/get_tariff_trends.proto new file mode 100644 index 000000000..f5176d05a --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_tariff_trends.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for tariff timeseries data. +message GetTariffTrendsRequest { + // WTO member code of reporting country (e.g. "840" = US). + string reporting_country = 1; + // WTO member code of partner country (e.g. "156" = China). + string partner_country = 2; + // Product sector filter (HS chapter). Empty = aggregate. + string product_sector = 3; + // Number of years to look back (default 10, max 30). + int32 years = 4; +} + +// Response containing tariff trend datapoints. +message GetTariffTrendsResponse { + // Tariff data points ordered by year ascending. + repeated TariffDataPoint datapoints = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/get_trade_barriers.proto b/proto/worldmonitor/trade/v1/get_trade_barriers.proto new file mode 100644 index 000000000..327287bf1 --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_trade_barriers.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for SPS/TBT trade barrier notifications. +message GetTradeBarriersRequest { + // WTO member codes to filter by. Empty = all. + repeated string countries = 1; + // Filter by measure type: "SPS", "TBT", or empty for both. + string measure_type = 2; + // Max results to return (server caps at 100). + int32 limit = 3; +} + +// Response containing trade barrier notifications. +message GetTradeBarriersResponse { + // List of SPS/TBT barrier notifications. + repeated TradeBarrier barriers = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/get_trade_flows.proto b/proto/worldmonitor/trade/v1/get_trade_flows.proto new file mode 100644 index 000000000..2761fcad2 --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_trade_flows.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for bilateral trade flow data. +message GetTradeFlowsRequest { + // WTO member code of reporting country. + string reporting_country = 1; + // WTO member code of partner country. + string partner_country = 2; + // Number of years to look back (default 10, max 30). + int32 years = 3; +} + +// Response containing trade flow records. +message GetTradeFlowsResponse { + // Trade flow records ordered by year ascending. + repeated TradeFlowRecord flows = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/get_trade_restrictions.proto b/proto/worldmonitor/trade/v1/get_trade_restrictions.proto new file mode 100644 index 000000000..b7f9b435c --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_trade_restrictions.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for quantitative restriction data. +message GetTradeRestrictionsRequest { + // WTO member codes to filter by. Empty = all. + repeated string countries = 1; + // Max results to return (server caps at 100). + int32 limit = 2; +} + +// Response containing trade restrictions and fetch metadata. +message GetTradeRestrictionsResponse { + // List of trade restrictions. + repeated TradeRestriction restrictions = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/service.proto b/proto/worldmonitor/trade/v1/service.proto new file mode 100644 index 000000000..d094abab6 --- /dev/null +++ b/proto/worldmonitor/trade/v1/service.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/trade/v1/get_trade_restrictions.proto"; +import "worldmonitor/trade/v1/get_tariff_trends.proto"; +import "worldmonitor/trade/v1/get_trade_flows.proto"; +import "worldmonitor/trade/v1/get_trade_barriers.proto"; + +// Trade policy intelligence from WTO data sources. +service TradeService { + option (sebuf.http.service_config) = {base_path: "/api/trade/v1"}; + + // Get quantitative restrictions and export controls. + rpc GetTradeRestrictions(GetTradeRestrictionsRequest) returns (GetTradeRestrictionsResponse) { + option (sebuf.http.config) = {path: "/get-trade-restrictions"}; + } + + // Get tariff rate timeseries for a country pair. + rpc GetTariffTrends(GetTariffTrendsRequest) returns (GetTariffTrendsResponse) { + option (sebuf.http.config) = {path: "/get-tariff-trends"}; + } + + // Get bilateral merchandise trade flows. + rpc GetTradeFlows(GetTradeFlowsRequest) returns (GetTradeFlowsResponse) { + option (sebuf.http.config) = {path: "/get-trade-flows"}; + } + + // Get SPS/TBT barrier notifications. + rpc GetTradeBarriers(GetTradeBarriersRequest) returns (GetTradeBarriersResponse) { + option (sebuf.http.config) = {path: "/get-trade-barriers"}; + } +} diff --git a/proto/worldmonitor/trade/v1/trade_data.proto b/proto/worldmonitor/trade/v1/trade_data.proto new file mode 100644 index 000000000..64eca07ba --- /dev/null +++ b/proto/worldmonitor/trade/v1/trade_data.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +// Quantitative restriction or export control measure notified to WTO. +message TradeRestriction { + // Unique restriction identifier from WTO. + string id = 1; + // ISO 3166-1 alpha-3 or WTO member code of reporting country. + string reporting_country = 2; + // Country affected by the restriction. + string affected_country = 3; + // Product sector or HS chapter description. + string product_sector = 4; + // Measure classification: "QR", "EXPORT_BAN", "IMPORT_BAN", "LICENSING". + string measure_type = 5; + // Human-readable description of the measure. + string description = 6; + // Current status: "IN_FORCE", "TERMINATED", "NOTIFIED". + string status = 7; + // ISO 8601 date when measure was notified. + string notified_at = 8; + // WTO source document URL (must be http/https protocol). + string source_url = 9; +} + +// Single tariff data point for a reporter-partner-product combination. +message TariffDataPoint { + // WTO member code of reporting country. + string reporting_country = 1; + // WTO member code of partner country. + string partner_country = 2; + // Product sector or HS chapter. + string product_sector = 3; + // Year of observation. + int32 year = 4; + // Applied MFN tariff rate (percentage). + double tariff_rate = 5; + // WTO bound tariff rate (percentage). + double bound_rate = 6; + // WTO indicator code used for this datapoint. + string indicator_code = 7; +} + +// Bilateral trade flow record for a reporting-partner pair. +message TradeFlowRecord { + // WTO member code of reporting country. + string reporting_country = 1; + // WTO member code of partner country. + string partner_country = 2; + // Year of observation. + int32 year = 3; + // Merchandise export value in millions USD. + double export_value_usd = 4; + // Merchandise import value in millions USD. + double import_value_usd = 5; + // Year-over-year export change (percentage). + double yoy_export_change = 6; + // Year-over-year import change (percentage). + double yoy_import_change = 7; + // Product sector or HS chapter. + string product_sector = 8; +} + +// SPS or TBT trade barrier notification. +message TradeBarrier { + // Unique barrier notification identifier. + string id = 1; + // Country that notified the measure. + string notifying_country = 2; + // Title of the notification. + string title = 3; + // Measure classification: "SPS" or "TBT". + string measure_type = 4; + // Product description or affected goods. + string product_description = 5; + // Stated objective of the measure. + string objective = 6; + // Status of the notification. + string status = 7; + // ISO 8601 date when notification was distributed. + string date_distributed = 8; + // WTO source document URL (must be http/https protocol). + string source_url = 9; +} diff --git a/proto/worldmonitor/unrest/v1/list_unrest_events.proto b/proto/worldmonitor/unrest/v1/list_unrest_events.proto new file mode 100644 index 000000000..e52ff1db7 --- /dev/null +++ b/proto/worldmonitor/unrest/v1/list_unrest_events.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package worldmonitor.unrest.v1; + +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/severity.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/unrest/v1/unrest_event.proto"; + +// ListUnrestEventsRequest specifies filters for retrieving social unrest events. +message ListUnrestEventsRequest { + // Time range to filter events. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Optional country filter (ISO 3166-1 alpha-2). + string country = 3; + // Optional minimum severity filter. + worldmonitor.core.v1.SeverityLevel min_severity = 4; + // Optional bounding box for geographic filtering. + worldmonitor.core.v1.BoundingBox bounding_box = 5; +} + +// ListUnrestEventsResponse contains unrest events and clusters matching the request. +message ListUnrestEventsResponse { + // Individual unrest events. + repeated UnrestEvent events = 1; + // Geographic clusters of related events. + repeated UnrestCluster clusters = 2; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 3; +} diff --git a/proto/worldmonitor/unrest/v1/service.proto b/proto/worldmonitor/unrest/v1/service.proto new file mode 100644 index 000000000..81d59caac --- /dev/null +++ b/proto/worldmonitor/unrest/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.unrest.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/unrest/v1/list_unrest_events.proto"; + +// UnrestService provides APIs for social unrest data aggregated from ACLED and GDELT. +service UnrestService { + option (sebuf.http.service_config) = {base_path: "/api/unrest/v1"}; + + // ListUnrestEvents retrieves protest, riot, and civil unrest events. + rpc ListUnrestEvents(ListUnrestEventsRequest) returns (ListUnrestEventsResponse) { + option (sebuf.http.config) = {path: "/list-unrest-events"}; + } +} diff --git a/proto/worldmonitor/unrest/v1/unrest_event.proto b/proto/worldmonitor/unrest/v1/unrest_event.proto new file mode 100644 index 000000000..430705fdd --- /dev/null +++ b/proto/worldmonitor/unrest/v1/unrest_event.proto @@ -0,0 +1,113 @@ +syntax = "proto3"; + +package worldmonitor.unrest.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/core/v1/severity.proto"; + +// UnrestEvent represents a social unrest incident (protest, riot, strike, etc.). +// Aggregated from ACLED and GDELT sources. +message UnrestEvent { + // Unique event identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1 + ]; + // Event title or headline. + string title = 2; + // Brief summary of the event. + string summary = 3; + // Type of unrest event. + UnrestEventType event_type = 4; + // City where the event occurred. + string city = 5; + // Country where the event occurred. + string country = 6; + // Administrative region within the country. + string region = 7; + // Geographic location of the event. + worldmonitor.core.v1.GeoCoordinates location = 8; + // Time the event occurred, as Unix epoch milliseconds. + int64 occurred_at = 9 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Severity assessment. + worldmonitor.core.v1.SeverityLevel severity = 10; + // Reported fatalities, if any. + int32 fatalities = 11; + // Source identifiers. + repeated string sources = 12; + // Data source type. + UnrestSourceType source_type = 13; + // Descriptive tags. + repeated string tags = 14; + // Named actors involved. + repeated string actors = 15; + // Confidence in the event data. + ConfidenceLevel confidence = 16; +} + +// UnrestCluster represents a geographic cluster of related unrest events. +message UnrestCluster { + // Unique cluster identifier. + string id = 1; + // Country of the cluster. + string country = 2; + // Region within the country. + string region = 3; + // Number of events in this cluster. + int32 event_count = 4; + // The events in this cluster. + repeated UnrestEvent events = 5; + // Overall severity of the cluster. + worldmonitor.core.v1.SeverityLevel severity = 6; + // Start of the cluster time window, as Unix epoch milliseconds. + int64 start_at = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // End of the cluster time window, as Unix epoch milliseconds. + int64 end_at = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Primary cause or theme of the unrest. + string primary_cause = 9; +} + +// UnrestEventType represents the classification of a social unrest event. +// Maps to existing TS union: 'protest' | 'riot' | 'strike' | 'demonstration' | 'civil_unrest'. +enum UnrestEventType { + // Unspecified event type. + UNREST_EVENT_TYPE_UNSPECIFIED = 0; + // Organized protest. + UNREST_EVENT_TYPE_PROTEST = 1; + // Violent riot. + UNREST_EVENT_TYPE_RIOT = 2; + // Labor or general strike. + UNREST_EVENT_TYPE_STRIKE = 3; + // Demonstration or march. + UNREST_EVENT_TYPE_DEMONSTRATION = 4; + // General civil unrest. + UNREST_EVENT_TYPE_CIVIL_UNREST = 5; +} + +// UnrestSourceType represents the data source for an unrest event. +// Maps to existing TS union: 'acled' | 'gdelt' | 'rss'. +enum UnrestSourceType { + // Unspecified source. + UNREST_SOURCE_TYPE_UNSPECIFIED = 0; + // Armed Conflict Location & Event Data Project. + UNREST_SOURCE_TYPE_ACLED = 1; + // Global Database of Events, Language, and Tone. + UNREST_SOURCE_TYPE_GDELT = 2; + // RSS news feed aggregation. + UNREST_SOURCE_TYPE_RSS = 3; +} + +// ConfidenceLevel represents the confidence in event data accuracy. +// Used across multiple domains. +enum ConfidenceLevel { + // Unspecified confidence. + CONFIDENCE_LEVEL_UNSPECIFIED = 0; + // Low confidence — limited corroboration. + CONFIDENCE_LEVEL_LOW = 1; + // Medium confidence — some corroboration. + CONFIDENCE_LEVEL_MEDIUM = 2; + // High confidence — well corroborated. + CONFIDENCE_LEVEL_HIGH = 3; +} diff --git a/proto/worldmonitor/wildfire/v1/fire_detection.proto b/proto/worldmonitor/wildfire/v1/fire_detection.proto new file mode 100644 index 000000000..e260f5ae5 --- /dev/null +++ b/proto/worldmonitor/wildfire/v1/fire_detection.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package worldmonitor.wildfire.v1; + +import "buf/validate/validate.proto"; +import "sebuf/http/annotations.proto"; +import "worldmonitor/core/v1/geo.proto"; + +// FireDetection represents a satellite-detected active fire from NASA FIRMS. +message FireDetection { + // Unique detection identifier. + string id = 1 [ + (buf.validate.field).required = true, + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 100 + ]; + // Geographic location of the fire detection. + worldmonitor.core.v1.GeoCoordinates location = 2; + // Brightness temperature in Kelvin. + double brightness = 3; + // Fire radiative power in MW. + double frp = 4; + // Detection confidence level. + FireConfidence confidence = 5; + // Satellite that detected the fire (e.g., "MODIS", "VIIRS", "LANDSAT"). + string satellite = 6; + // Time the fire was detected, as Unix epoch milliseconds. + int64 detected_at = 7 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER]; + // Monitored region name (e.g., "Ukraine", "Russia", "Iran"). + string region = 8; + // Day or night detection ("D" or "N"). + string day_night = 9; +} + +// FireConfidence represents the confidence level of a fire detection. +enum FireConfidence { + // Unspecified confidence. + FIRE_CONFIDENCE_UNSPECIFIED = 0; + // Low confidence detection. + FIRE_CONFIDENCE_LOW = 1; + // Nominal confidence detection. + FIRE_CONFIDENCE_NOMINAL = 2; + // High confidence detection. + FIRE_CONFIDENCE_HIGH = 3; +} diff --git a/proto/worldmonitor/wildfire/v1/list_fire_detections.proto b/proto/worldmonitor/wildfire/v1/list_fire_detections.proto new file mode 100644 index 000000000..967697cb0 --- /dev/null +++ b/proto/worldmonitor/wildfire/v1/list_fire_detections.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package worldmonitor.wildfire.v1; + +import "worldmonitor/core/v1/geo.proto"; +import "worldmonitor/core/v1/pagination.proto"; +import "worldmonitor/core/v1/time.proto"; +import "worldmonitor/wildfire/v1/fire_detection.proto"; + +// ListFireDetectionsRequest specifies filters for retrieving fire detections from NASA FIRMS. +message ListFireDetectionsRequest { + // Time range to filter detections. + worldmonitor.core.v1.TimeRange time_range = 1; + // Pagination parameters. + worldmonitor.core.v1.PaginationRequest pagination = 2; + // Optional bounding box to restrict results geographically. + worldmonitor.core.v1.BoundingBox bounding_box = 3; +} + +// ListFireDetectionsResponse contains the list of fire detections matching the request filters. +message ListFireDetectionsResponse { + // The list of fire detections. + repeated FireDetection fire_detections = 1; + // Pagination metadata. + worldmonitor.core.v1.PaginationResponse pagination = 2; +} diff --git a/proto/worldmonitor/wildfire/v1/service.proto b/proto/worldmonitor/wildfire/v1/service.proto new file mode 100644 index 000000000..09d2efeda --- /dev/null +++ b/proto/worldmonitor/wildfire/v1/service.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package worldmonitor.wildfire.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/wildfire/v1/list_fire_detections.proto"; + +// WildfireService provides APIs for active fire detection data sourced from NASA FIRMS. +service WildfireService { + option (sebuf.http.service_config) = {base_path: "/api/wildfire/v1"}; + + // ListFireDetections retrieves satellite-detected active fires from NASA FIRMS. + rpc ListFireDetections(ListFireDetectionsRequest) returns (ListFireDetectionsResponse) { + option (sebuf.http.config) = {path: "/list-fire-detections"}; + } +} diff --git a/public/data/countries.geojson b/public/data/countries.geojson new file mode 100644 index 000000000..2e1453c73 --- /dev/null +++ b/public/data/countries.geojson @@ -0,0 +1 @@ +{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[124.450531, -9.18019], [124.03004, -9.341974], [123.688813, -9.611423], [123.515391, -10.335545], [124.418468, -10.163832], [125.061615, -9.485772], [124.919507, -8.962016], [124.450531, -9.18019]]], [[[117.567149, 4.159654], [117.164662, 4.333147], [115.878849, 4.352139], [115.646925, 4.169101], [115.464094, 3.030824], [114.500638, 1.436037], [113.649528, 1.233259], [113.023416, 1.537013], [112.180057, 1.449034], [111.823076, 1.008467], [111.197584, 1.075233], [110.553903, 0.85137], [109.677264, 1.572851], [109.645274, 2.083238], [109.095958, 1.560614], [108.855479, 0.832221], [109.166759, 0.112291], [109.273204, -0.853611], [110.068533, -1.43255], [110.263194, -3.002862], [111.296153, -2.936456], [112.618012, -3.423761], [113.182628, -3.077081], [114.108246, -3.347345], [114.611095, -3.683852], [114.712901, -4.170831], [115.967947, -3.608819], [116.603282, -2.224216], [116.32488, -1.703871], [117.023692, -1.202895], [117.595958, -0.422621], [117.517263, 0.290961], [117.81129, 0.811754], [119.00294, 0.96247], [117.924083, 1.819159], [118.063487, 2.366929], [117.352712, 3.182807], [117.748546, 3.636949], [117.567149, 4.159654]]], [[[140.974457, -2.600518], [139.864757, -2.372003], [137.794444, -1.484145], [136.403819, -2.219659], [135.47047, -3.363051], [134.984711, -3.329685], [134.658051, -2.562758], [134.19337, -2.379083], [134.281993, -1.350193], [133.977224, -0.72324], [133.391612, -0.72324], [132.709158, -0.36004], [131.243012, -0.819431], [131.20574, -1.523614], [131.927257, -1.706231], [132.073416, -2.105157], [133.033539, -2.479425], [132.325694, -2.946547], [133.269379, -4.052016], [133.586192, -3.566583], [134.430431, -3.90781], [135.185557, -4.448907], [135.97047, -4.519627], [137.706309, -5.221938], [138.822439, -6.806085], [139.093028, -7.559991], [138.993988, -7.865492], [138.910981, -7.92254], [138.905772, -8.043634], [138.921235, -8.079522], [138.858653, -8.101332], [138.838552, -8.13836], [139.932384, -8.108575], [140.97698, -9.106134], [140.977162, -6.896632], [140.975767, -4.595946], [140.974457, -2.600518]]], [[[109.906016, -7.83766], [110.476736, -8.111912], [112.672699, -8.447035], [113.197927, -8.277276], [114.197032, -8.645603], [114.467296, -7.830987], [114.016124, -7.618097], [113.299327, -7.788507], [112.761485, -7.531183], [112.582774, -6.989923], [110.730479, -6.456964], [110.355724, -6.972914], [108.605479, -6.761651], [108.216364, -6.237965], [106.495372, -6.034845], [105.824091, -6.434646], [106.522662, -7.410957], [109.906016, -7.83766]]], [[[119.846528, -0.874607], [119.32838, -1.214451], [119.354015, -1.934747], [118.757335, -2.775811], [119.297618, -3.427911], [119.626801, -4.314548], [119.352794, -5.355645], [119.64796, -5.678806], [120.323253, -5.509535], [120.446544, -3.728692], [120.231619, -2.950372], [121.066905, -2.752211], [120.893809, -3.539158], [121.536306, -4.230564], [122.107758, -4.519952], [122.675059, -4.122735], [122.200531, -3.556573], [122.385916, -3.132013], [121.692149, -1.908136], [122.368826, -1.492771], [122.912446, -0.762872], [121.500662, -0.859308], [121.073253, -1.421319], [120.089203, -0.657322], [119.995616, -0.209242], [120.375255, 0.477688], [122.977875, 0.482815], [123.23878, 0.32453], [124.321788, 0.398871], [124.898692, 0.973049], [124.54835, 1.359849], [123.947927, 0.838935], [121.63087, 1.061957], [120.967296, 1.346177], [120.568126, 0.782131], [120.062836, 0.742865], [119.793956, 0.196601], [119.846528, -0.874607]]], [[[100.947276, 1.825385], [99.750743, 3.180487], [98.305675, 4.080308], [98.275564, 4.42536], [97.496837, 5.251899], [96.126231, 5.281806], [95.197276, 5.546373], [95.42628, 4.827338], [96.430349, 3.825263], [96.760509, 3.748684], [97.602712, 2.866197], [97.658214, 2.411566], [98.535167, 1.936469], [99.089854, 0.622016], [100.301443, -0.816339], [100.406016, -1.264418], [101.104828, -2.587091], [102.312511, -3.991469], [104.297699, -5.643324], [105.782563, -5.828546], [105.906261, -4.469334], [105.819591, -3.67254], [106.063162, -3.266697], [105.62379, -2.401788], [104.883311, -2.284926], [104.377208, -1.031671], [103.375987, -0.726821], [103.711192, 0.311469], [102.240082, 0.982082], [102.118175, 1.386542], [100.947276, 1.825385]]], [[[120.792491, -9.96087], [119.629242, -9.341892], [119.477306, -9.74863], [120.15561, -10.220961], [120.792491, -9.96087]]], [[[119.203624, -8.610935], [118.988292, -8.309015], [118.115245, -8.113865], [117.805186, -8.719985], [117.195079, -8.357599], [117.009736, -9.107293], [119.203624, -8.610935]]], [[[123.026378, -8.288751], [122.288422, -8.633884], [121.509532, -8.602146], [120.277029, -8.26922], [119.823985, -8.780369], [121.793712, -8.883966], [122.807384, -8.610935], [123.026378, -8.288751]]], [[[138.938975, -7.542087], [138.028087, -7.61004], [137.636974, -8.389418], [138.444184, -8.379083], [138.660899, -8.169041], [138.818207, -8.093194], [138.904145, -8.07586], [138.8838, -7.93255], [138.982677, -7.829848], [139.046397, -7.561944], [138.938975, -7.542087]]], [[[127.240896, -3.470961], [126.421397, -3.070489], [126.173561, -3.602527], [126.742185, -3.859378], [127.240896, -3.470961]]], [[[130.87322, -3.573989], [130.578461, -3.122817], [129.443533, -2.784926], [129.121837, -2.957696], [128.175466, -2.857029], [128.484141, -3.462498], [128.877452, -3.202569], [129.544932, -3.297784], [130.817882, -3.868829], [130.87322, -3.573989]]], [[[106.769054, -2.560317], [106.354015, -2.462986], [105.875743, -1.48919], [105.459646, -1.562921], [105.986501, -2.821222], [106.613048, -2.94256], [106.769054, -2.560317]]], [[[127.666026, -0.209242], [127.960216, 0.482815], [128.701182, 1.070868], [127.851736, 1.825385], [127.405935, 1.229885], [127.666026, -0.209242]]]]}, "properties": {"name": "Indonesia", "ISO3166-1-Alpha-3": "IDN", "ISO3166-1-Alpha-2": "ID"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[102.07309, 6.257514], [101.105435, 5.637642], [101.081664, 6.246467], [100.127289, 6.442288], [100.349132, 6.012681], [100.720714, 3.868069], [101.297618, 3.274604], [101.283051, 2.9112], [102.187999, 2.216376], [103.367361, 1.538398], [104.294688, 1.446519], [103.948985, 2.339911], [103.434581, 2.961819], [103.338715, 3.758205], [103.448578, 4.795071], [103.120291, 5.381334], [102.07309, 6.257514]]], [[[115.146169, 4.908515], [115.029778, 4.820642], [114.9817, 4.889065], [114.586628, 4.021435], [113.99879, 4.601142], [113.946788, 4.27383], [112.974376, 3.144232], [111.440278, 2.680121], [110.967947, 1.503241], [109.927745, 1.692288], [109.645274, 2.083238], [109.677264, 1.572851], [110.553903, 0.85137], [111.197584, 1.075233], [111.823076, 1.008467], [112.180057, 1.449034], [113.023416, 1.537013], [113.649528, 1.233259], [114.500638, 1.436037], [115.464094, 3.030824], [115.646925, 4.169101], [115.878849, 4.352139], [117.164662, 4.333147], [117.567149, 4.159654], [118.542817, 4.360012], [118.136485, 4.88231], [119.155935, 5.106187], [118.410655, 5.796129], [117.654633, 5.958686], [117.743826, 6.389879], [116.973806, 6.708075], [116.201345, 6.217963], [115.848643, 5.560004], [115.146169, 4.908515]]]]}, "properties": {"name": "Malaysia", "ISO3166-1-Alpha-3": "MYS", "ISO3166-1-Alpha-2": "MY"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-69.510089, -17.506588], [-69.970345, -18.250625], [-70.394703, -18.337746], [-70.12328, -20.072198], [-70.058695, -21.433377], [-70.297027, -22.917739], [-70.562245, -23.059828], [-70.582834, -24.524835], [-70.444814, -25.344903], [-70.912424, -27.62184], [-71.521962, -28.940118], [-71.28661, -29.910252], [-71.71345, -30.614923], [-71.412465, -32.369561], [-71.640777, -33.500584], [-72.026845, -34.162693], [-72.23058, -35.116143], [-72.646596, -35.566583], [-73.002024, -36.7138], [-73.671783, -37.362563], [-73.241567, -39.489516], [-73.683217, -39.942478], [-73.946904, -40.972841], [-73.659983, -41.757501], [-72.863596, -41.905938], [-72.761057, -43.009047], [-73.27774, -44.11004], [-72.727651, -44.758559], [-73.460683, -45.26629], [-73.55484, -45.877618], [-74.713368, -45.98447], [-75.704701, -46.639255], [-74.266835, -46.781671], [-74.736236, -47.704522], [-74.415395, -47.983657], [-74.256418, -50.939386], [-73.530914, -52.456964], [-72.160874, -52.652118], [-72.466949, -53.283136], [-72.053456, -53.706964], [-70.973622, -53.756036], [-70.812123, -52.821384], [-69.234708, -52.203143], [-68.44861, -52.346617], [-69.952754, -52.007419], [-71.917699, -51.990055], [-72.281139, -51.701494], [-72.302843, -50.648897], [-73.096076, -50.770647], [-73.465098, -49.759959], [-73.098247, -49.272754], [-72.325632, -48.285527], [-72.543914, -47.9148], [-71.68717, -46.690069], [-71.798533, -45.739946], [-71.311534, -45.299456], [-71.822045, -44.40318], [-71.659885, -43.92631], [-72.148537, -42.998718], [-71.925967, -41.622936], [-71.955577, -40.720356], [-71.401968, -39.236002], [-70.873835, -38.691436], [-71.18503, -37.706069], [-71.145343, -36.68825], [-70.380325, -36.046016], [-70.227828, -34.58533], [-69.832761, -34.243232], [-69.787674, -33.379408], [-70.591371, -31.549598], [-70.217105, -30.515139], [-69.902809, -30.312671], [-70.042619, -29.363064], [-69.653031, -28.397852], [-69.173008, -27.924083], [-68.419876, -26.179279], [-68.572373, -24.769856], [-68.244486, -24.385384], [-67.362369, -24.030367], [-67.013967, -23.000714], [-67.193904, -22.822223], [-67.876343, -22.833592], [-68.207537, -21.284333], [-68.775539, -20.089677], [-68.496357, -19.458398], [-68.989609, -18.946491], [-69.14084, -18.030785], [-69.510089, -17.506588]]], [[[-68.641998, -54.799168], [-68.641882, -54.782971], [-68.627617, -52.639572], [-69.583892, -52.509861], [-70.473541, -53.309259], [-70.072987, -54.248793], [-70.239735, -54.857843], [-68.654135, -54.886245], [-68.641998, -54.799168]]], [[[-67.999257, -55.627537], [-68.441151, -54.939711], [-69.607655, -55.365492], [-67.999257, -55.627537]]], [[[-74.334625, -43.281671], [-73.516672, -42.789239], [-73.489084, -42.145603], [-74.055287, -41.945001], [-74.334625, -43.281671]]]]}, "properties": {"name": "Chile", "ISO3166-1-Alpha-3": "CHL", "ISO3166-1-Alpha-2": "CL"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-69.510089, -17.506588], [-69.14084, -18.030785], [-68.989609, -18.946491], [-68.496357, -19.458398], [-68.775539, -20.089677], [-68.207537, -21.284333], [-67.876343, -22.833592], [-67.193904, -22.822223], [-66.240009, -21.792415], [-65.744613, -22.11405], [-64.58688, -22.212752], [-63.947591, -22.007596], [-62.804353, -22.004082], [-62.650357, -22.234456], [-62.277305, -20.579776], [-61.761213, -19.657765], [-60.006384, -19.298097], [-59.089541, -19.286729], [-58.175282, -19.821373], [-58.158797, -20.165125], [-57.551082, -18.183643], [-57.790757, -17.555775], [-58.38116, -17.267214], [-58.464721, -16.33125], [-60.129839, -16.273062], [-60.465271, -13.816572], [-60.896743, -13.552918], [-61.847977, -13.530801], [-62.22165, -13.121265], [-62.807402, -12.98887], [-63.801398, -12.454949], [-64.395729, -12.457326], [-64.997449, -11.996269], [-65.353009, -11.390621], [-65.44998, -10.468094], [-65.304381, -9.825652], [-66.631871, -9.904304], [-67.755782, -10.714177], [-68.61573, -11.112499], [-69.577635, -10.952302], [-68.684252, -12.502492], [-68.980953, -12.867947], [-68.883724, -14.211483], [-69.390669, -14.964408], [-69.424337, -15.656253], [-69.001727, -16.422821], [-69.510089, -17.506588]]]}, "properties": {"name": "Bolivia", "ISO3166-1-Alpha-3": "BOL", "ISO3166-1-Alpha-2": "BO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-69.510089, -17.506588], [-69.001727, -16.422821], [-69.424337, -15.656253], [-69.390669, -14.964408], [-68.883724, -14.211483], [-68.980953, -12.867947], [-68.684252, -12.502492], [-69.577635, -10.952302], [-70.641342, -11.0108], [-70.680849, -9.527686], [-71.391426, -10.00683], [-72.195666, -10.00559], [-72.430225, -9.482211], [-73.526541, -8.372408], [-73.765493, -6.904177], [-73.131785, -6.435265], [-73.234776, -6.077561], [-72.917948, -5.132089], [-71.774348, -4.481534], [-70.832235, -4.179434], [-69.96495, -4.236484], [-70.734127, -3.782042], [-70.050629, -2.71513], [-70.874196, -2.229579], [-71.76794, -2.142245], [-72.396584, -2.446516], [-72.943269, -2.419024], [-73.63656, -1.255168], [-74.28913, -0.943042], [-74.824653, -0.170479], [-75.283488, -0.107021], [-75.560034, -1.502595], [-76.684591, -2.57364], [-77.849016, -2.980644], [-78.362938, -3.488727], [-79.009281, -4.96011], [-80.079655, -4.309038], [-80.340729, -3.393498], [-81.252797, -4.238702], [-81.198476, -5.208103], [-80.856212, -5.650462], [-81.101457, -6.072068], [-79.96468, -6.787355], [-79.465942, -7.715929], [-78.98574, -8.217421], [-78.055804, -10.347524], [-77.320465, -11.493748], [-77.174916, -12.071873], [-76.221102, -13.357556], [-76.282056, -14.140924], [-75.160797, -15.393324], [-73.700278, -16.220696], [-71.514516, -17.284601], [-71.359609, -17.633071], [-70.394703, -18.337746], [-69.970345, -18.250625], [-69.510089, -17.506588]]]}, "properties": {"name": "Peru", "ISO3166-1-Alpha-3": "PER", "ISO3166-1-Alpha-2": "PE"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-67.193904, -22.822223], [-67.013967, -23.000714], [-67.362369, -24.030367], [-68.244486, -24.385384], [-68.572373, -24.769856], [-68.419876, -26.179279], [-69.173008, -27.924083], [-69.653031, -28.397852], [-70.042619, -29.363064], [-69.902809, -30.312671], [-70.217105, -30.515139], [-70.591371, -31.549598], [-69.787674, -33.379408], [-69.832761, -34.243232], [-70.227828, -34.58533], [-70.380325, -36.046016], [-71.145343, -36.68825], [-71.18503, -37.706069], [-70.873835, -38.691436], [-71.401968, -39.236002], [-71.955577, -40.720356], [-71.925967, -41.622936], [-72.148537, -42.998718], [-71.659885, -43.92631], [-71.822045, -44.40318], [-71.311534, -45.299456], [-71.798533, -45.739946], [-71.68717, -46.690069], [-72.543914, -47.9148], [-72.325632, -48.285527], [-73.098247, -49.272754], [-73.465098, -49.759959], [-73.096076, -50.770647], [-72.302843, -50.648897], [-72.281139, -51.701494], [-71.917699, -51.990055], [-69.952754, -52.007419], [-68.44861, -52.346617], [-69.167778, -50.978225], [-68.876429, -50.330572], [-67.732012, -49.781302], [-67.556549, -49.015817], [-65.847068, -47.944417], [-65.744902, -47.204164], [-66.786448, -47.006606], [-67.622467, -46.163751], [-67.331925, -45.613491], [-65.216885, -44.36641], [-65.334055, -43.672621], [-64.513051, -42.936293], [-63.620269, -42.751235], [-63.80996, -42.070174], [-64.566274, -42.435968], [-65.069569, -41.985772], [-65.17512, -41.010186], [-64.946523, -40.711358], [-63.779408, -41.158787], [-62.337799, -40.872735], [-62.484202, -40.28753], [-62.026723, -38.937595], [-60.865712, -38.975681], [-59.063222, -38.69378], [-57.593577, -38.152765], [-56.664866, -36.851007], [-57.248036, -36.170343], [-57.144909, -35.4842], [-58.154872, -34.7504], [-58.549387, -33.683038], [-58.200124, -32.447201], [-58.168615, -31.846014], [-57.642466, -30.193092], [-57.611698, -30.182963], [-56.415751, -29.051352], [-55.772534, -28.231971], [-54.827527, -27.545088], [-53.819217, -27.139738], [-53.66672, -26.219174], [-53.90996, -25.629236], [-54.600203, -25.574945], [-54.706475, -26.441796], [-56.124554, -27.298901], [-57.180097, -27.487313], [-58.653289, -27.156274], [-57.556921, -25.45984], [-57.754067, -25.180891], [-58.809196, -24.776781], [-60.033669, -24.007009], [-61.006349, -23.805471], [-61.956446, -23.034407], [-62.650357, -22.234456], [-62.804353, -22.004082], [-63.947591, -22.007596], [-64.58688, -22.212752], [-65.744613, -22.11405], [-66.240009, -21.792415], [-67.193904, -22.822223]]], [[[-68.641882, -54.782971], [-67.030995, -54.905206], [-66.482213, -54.4658], [-67.983632, -53.601332], [-68.627617, -52.639572], [-68.641882, -54.782971]]]]}, "properties": {"name": "Argentina", "ISO3166-1-Alpha-3": "ARG", "ISO3166-1-Alpha-2": "AR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[33.780935, 34.976345], [33.78183, 34.976223], [33.891841, 34.958139], [33.898116, 35.061272], [33.906505, 35.069105], [33.679435, 35.033899], [33.702935, 34.987943], [33.701508, 34.972886], [33.780935, 34.976345]]]}, "properties": {"name": "Dhekelia Sovereign Base Area", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[33.702935, 34.987943], [32.691549, 35.183705], [32.659813, 35.187082], [32.584809, 35.172512], [32.760671, 34.653225], [33.015635, 34.634425], [33.701508, 34.972886], [33.702935, 34.987943]]], [[[33.898116, 35.061272], [33.891841, 34.958139], [34.021961, 35.057009], [33.898116, 35.061272]]]]}, "properties": {"name": "Cyprus", "ISO3166-1-Alpha-3": "CYP", "ISO3166-1-Alpha-2": "CY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[77.800346, 35.495406], [77.048971, 35.110442], [75.777111, 34.503812], [74.285832, 34.768887], [73.998304, 34.196803], [74.002335, 33.177692], [75.023668, 32.466262], [74.489437, 31.711192], [74.329757, 30.899614], [73.370332, 29.927322], [72.901524, 29.022622], [72.382176, 28.784006], [71.860864, 27.950207], [70.831573, 27.701463], [70.341939, 28.01147], [69.465093, 26.80777], [70.158074, 26.530113], [70.064643, 25.980327], [70.646623, 25.431369], [71.063858, 24.682577], [69.972039, 24.165219], [68.725913, 24.289216], [68.183035, 23.842108], [68.552257, 23.259711], [69.23878, 22.841946], [68.994151, 22.203925], [70.054445, 21.153155], [70.982432, 20.71015], [72.60613, 21.266588], [72.944998, 20.770006], [72.650076, 19.841986], [73.234711, 17.302314], [73.447032, 16.068915], [73.885916, 15.435207], [74.614594, 13.835517], [74.817556, 12.861762], [75.530772, 11.693671], [76.177501, 10.17064], [76.543956, 8.912502], [77.510916, 8.075995], [78.062755, 8.373196], [78.177908, 8.86107], [78.956798, 9.274848], [79.398123, 10.324652], [79.86378, 10.37873], [79.751964, 11.576972], [80.332774, 13.198147], [80.054535, 15.014146], [80.263194, 15.674547], [80.998871, 15.846747], [81.317393, 16.367499], [82.307384, 16.579779], [82.299327, 17.030341], [84.077403, 18.271552], [84.872081, 19.219875], [85.549164, 19.690619], [86.268565, 19.910346], [87.003591, 20.65705], [86.844005, 21.082221], [87.200938, 21.551988], [87.97047, 21.836493], [88.762462, 21.555854], [89.060395, 22.129869], [88.540104, 23.649953], [88.737508, 24.287097], [88.02179, 24.645603], [88.43148, 25.173038], [88.074396, 25.908135], [88.656273, 26.415133], [89.830051, 25.90798], [89.795015, 25.374163], [90.364644, 25.149991], [92.001753, 25.18296], [92.458056, 24.953284], [92.107587, 24.405979], [91.363033, 24.099848], [91.140824, 23.6121], [91.536562, 22.981854], [92.356874, 23.289122], [92.575879, 21.977574], [93.169021, 22.246912], [93.456652, 23.95996], [93.997911, 23.916965], [94.708565, 25.02589], [94.608003, 25.394627], [95.139546, 26.029937], [95.119082, 26.604217], [96.142586, 27.25751], [96.758879, 27.341743], [97.323496, 28.217478], [96.59801, 28.70991], [96.141966, 29.368467], [95.224916, 29.059364], [94.63043, 29.319452], [93.86308, 28.704871], [93.446213, 28.671894], [92.574977, 27.847806], [91.628339, 27.852694], [92.03586, 26.854848], [89.822093, 26.701007], [88.954549, 26.912622], [88.892331, 27.315543], [88.610488, 28.105831], [88.118218, 27.860885], [88.074189, 26.453942], [87.326225, 26.353328], [85.821614, 26.571713], [84.801934, 27.013753], [84.577039, 27.329031], [82.752137, 27.494964], [81.146344, 28.372249], [80.036386, 28.837026], [80.368975, 29.757926], [80.996017, 30.196969], [80.252806, 30.565009], [79.238319, 31.329656], [78.744578, 31.9642], [79.443497, 32.534773], [79.456096, 33.250399], [78.824263, 33.461059], [78.730109, 34.079265], [77.800346, 35.495406]]]}, "properties": {"name": "India", "ISO3166-1-Alpha-3": "IND", "ISO3166-1-Alpha-2": "IN"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[77.800346, 35.495406], [78.730109, 34.079265], [78.824263, 33.461059], [79.456096, 33.250399], [79.443497, 32.534773], [78.744578, 31.9642], [79.238319, 31.329656], [80.252806, 30.565009], [80.996017, 30.196969], [81.591588, 30.414269], [83.517052, 29.191708], [85.080212, 28.318789], [85.675059, 28.306387], [85.98026, 27.885172], [86.661976, 28.106838], [87.155796, 27.825796], [88.118218, 27.860885], [88.610488, 28.105831], [88.892331, 27.315543], [89.561489, 28.13464], [90.261804, 28.335354], [91.628339, 27.852694], [92.574977, 27.847806], [93.446213, 28.671894], [93.86308, 28.704871], [94.63043, 29.319452], [95.224916, 29.059364], [96.141966, 29.368467], [96.59801, 28.70991], [97.323496, 28.217478], [97.527721, 28.529526], [98.679279, 27.577336], [98.692404, 25.87899], [97.536093, 24.745028], [97.707658, 24.125299], [98.503269, 24.121268], [99.21661, 23.057379], [99.35779, 22.495476], [99.95026, 21.721156], [101.159024, 21.552691], [101.518227, 22.228205], [102.118655, 22.397549], [102.442718, 22.765175], [102.989093, 22.437598], [103.309642, 22.787938], [103.866817, 22.575419], [104.728212, 22.839098], [105.332258, 23.317958], [105.853879, 22.90465], [106.667473, 22.86752], [106.722354, 22.006978], [107.348155, 21.599355], [107.991222, 21.485663], [109.135753, 21.602973], [110.156749, 20.986029], [111.206309, 21.53205], [112.608002, 21.775092], [114.082367, 22.529364], [114.229828, 22.555813], [116.494688, 22.939352], [117.078407, 23.564611], [118.571137, 24.568996], [119.10963, 25.406806], [119.558442, 25.563788], [119.675059, 26.618801], [120.079682, 26.646064], [120.835297, 27.955959], [121.840587, 29.161119], [121.983039, 29.823176], [120.977759, 30.543318], [121.977383, 30.914923], [121.320323, 31.504828], [121.821945, 31.952486], [120.911199, 32.630121], [120.895763, 33.013577], [120.257091, 34.311835], [119.207286, 35.048407], [119.64796, 35.58393], [120.696788, 36.143988], [120.75058, 36.459174], [122.039643, 36.984605], [120.738048, 37.83397], [119.441173, 37.120551], [118.840099, 38.152574], [118.084809, 38.138739], [117.565971, 38.612507], [117.718272, 39.093004], [118.940196, 39.138861], [119.535592, 39.890826], [120.436046, 40.194485], [121.361339, 40.939643], [122.30185, 40.502346], [121.436046, 39.508612], [121.674083, 39.088039], [124.369965, 40.098293], [126.007843, 40.899313], [126.887118, 41.784918], [128.185695, 41.404451], [128.034593, 41.993743], [128.963838, 42.088517], [130.530771, 42.53048], [131.280906, 43.380221], [131.065829, 44.682028], [131.818031, 45.33279], [132.953362, 45.024385], [133.902452, 46.258986], [134.154116, 47.257892], [134.772579, 47.710732], [134.38635, 48.381337], [133.091958, 48.10678], [132.524655, 47.707528], [131.023351, 47.682284], [130.533252, 48.635792], [129.711183, 49.274151], [127.508113, 49.822335], [127.287352, 50.751012], [125.621355, 53.062137], [123.639564, 53.551254], [120.874255, 53.28016], [120.280182, 52.865922], [120.77917, 52.117595], [120.108203, 51.665194], [119.293576, 50.599238], [119.31621, 50.092654], [117.758838, 49.512741], [116.684278, 49.823265], [115.51453, 48.122103], [115.852701, 47.705565], [118.542252, 47.966246], [119.699959, 47.159526], [119.680116, 46.591628], [118.238291, 46.715393], [116.603559, 46.309319], [115.63783, 45.444359], [114.533711, 45.3855], [113.635058, 44.746262], [112.011488, 45.087482], [111.406357, 44.416463], [111.933353, 43.696636], [110.933724, 43.287772], [110.406728, 42.768605], [109.485131, 42.449296], [106.767829, 42.286619], [105.014809, 41.596144], [104.500784, 41.870598], [103.720986, 41.755566], [102.034164, 42.18461], [101.637599, 42.515442], [100.016975, 42.676518], [99.474476, 42.564199], [97.193271, 42.78726], [96.350635, 42.740906], [95.379428, 44.287117], [94.69823, 44.343496], [93.525278, 44.951263], [90.90549, 45.185977], [90.651138, 45.493142], [91.047496, 46.566409], [90.441228, 47.493045], [89.5418, 48.031023], [89.045551, 47.992989], [87.942828, 48.599489], [87.816324, 49.165837], [87.323796, 49.085274], [86.565083, 48.527323], [85.783683, 48.407589], [85.498636, 47.051832], [84.916036, 46.850552], [83.150356, 47.211538], [82.291493, 45.533191], [80.061707, 45.018959], [80.773395, 43.112925], [80.368148, 43.02846], [80.210328, 42.189519], [78.359899, 41.377527], [78.074955, 41.039512], [76.860972, 41.013208], [76.449111, 40.415519], [75.681819, 40.291702], [74.835359, 40.511637], [74.003989, 40.060812], [73.632642, 39.448343], [73.797387, 38.602839], [74.776345, 38.510674], [75.164125, 37.400638], [74.892307, 37.231114], [74.542354, 37.021669], [75.351297, 36.915784], [75.976582, 36.462633], [76.166027, 35.806239], [76.777351, 35.646112], [77.800346, 35.495406]]], [[[111.010509, 19.683783], [110.640147, 20.108344], [109.303722, 19.921698], [108.631684, 19.286282], [108.694998, 18.504828], [109.439952, 18.288967], [110.426768, 18.680894], [111.010509, 19.683783]]]]}, "properties": {"name": "China", "ISO3166-1-Alpha-3": "CHN", "ISO3166-1-Alpha-2": "CN"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.248351, 31.211449], [34.886729, 29.490058], [34.955577, 29.558987], [35.458125, 31.491929], [34.867153, 31.396431], [34.958223, 32.186454], [35.560961, 32.384717], [35.75759, 32.744347], [35.8211, 33.406722], [35.105235, 33.089016], [34.481204, 31.583141], [34.248351, 31.211449]]]}, "properties": {"name": "Israel", "ISO3166-1-Alpha-3": "ISR", "ISO3166-1-Alpha-2": "IL"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[34.481204, 31.583141], [34.200269, 31.314267], [34.248351, 31.211449], [34.481204, 31.583141]]], [[[35.560961, 32.384717], [34.958223, 32.186454], [34.867153, 31.396431], [35.458125, 31.491929], [35.560961, 32.384717]]]]}, "properties": {"name": "Palestine", "ISO3166-1-Alpha-3": "PSE", "ISO3166-1-Alpha-2": "PS"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[35.105235, 33.089016], [35.8211, 33.406722], [36.604101, 34.199102], [35.9699, 34.649849], [35.105235, 33.089016]]]}, "properties": {"name": "Lebanon", "ISO3166-1-Alpha-3": "LBN", "ISO3166-1-Alpha-2": "LB"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.070698, 9.454592], [33.970774, 8.445377], [33.174078, 8.404475], [33.051295, 7.801127], [33.71606, 7.657156], [34.733518, 6.637606], [35.098663, 5.622474], [35.80415, 5.318023], [35.920835, 4.619332], [36.844087, 4.432237], [38.101891, 3.612649], [39.436176, 3.462374], [39.848451, 3.867284], [40.763744, 4.284933], [41.114524, 3.962343], [41.885019, 3.977226], [42.78977, 4.285605], [43.119259, 4.647702], [43.968975, 4.953962], [44.941525, 4.911484], [46.423915, 6.496736], [47.979169, 7.996567], [46.97923, 7.996567], [44.023855, 8.985525], [43.419034, 9.413018], [42.836279, 10.208086], [42.923715, 10.998787], [41.79872, 10.970675], [41.74911, 11.537953], [42.379459, 12.465907], [40.833197, 14.105962], [40.104559, 14.465966], [38.426832, 14.417183], [37.891464, 14.879532], [37.564973, 14.116685], [36.52638, 14.263523], [36.123614, 12.721447], [35.616875, 12.575151], [34.947148, 11.274868], [34.279489, 10.565506], [34.070698, 9.454592]]]}, "properties": {"name": "Ethiopia", "ISO3166-1-Alpha-3": "ETH", "ISO3166-1-Alpha-2": "ET"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[35.920835, 4.619332], [35.80415, 5.318023], [35.098663, 5.622474], [34.733518, 6.637606], [33.71606, 7.657156], [33.051295, 7.801127], [33.174078, 8.404475], [33.970774, 8.445377], [34.070698, 9.454592], [33.902096, 10.192041], [33.182088, 10.843241], [33.082921, 11.584565], [32.34524, 11.709106], [32.414073, 11.050851], [31.234817, 9.792323], [30.749265, 9.735763], [30.012979, 10.270485], [28.843841, 9.324545], [27.895072, 9.59541], [26.556859, 9.520454], [25.843053, 10.417815], [25.084081, 10.293223], [24.558377, 8.886746], [24.170328, 8.689327], [24.832107, 8.16573], [25.360033, 7.335574], [26.378007, 6.65329], [26.527972, 6.043172], [27.170413, 5.72035], [27.441301, 5.070725], [27.772444, 4.595819], [28.404137, 4.277828], [29.494096, 4.668295], [30.839543, 3.490202], [31.141489, 3.785119], [31.943662, 3.591255], [33.01724, 3.87718], [33.532609, 3.774293], [33.977078, 4.219692], [34.381188, 4.620158], [35.245726, 4.98172], [35.920835, 4.619332]]]}, "properties": {"name": "South Sudan", "ISO3166-1-Alpha-3": "SSD", "ISO3166-1-Alpha-2": "SS"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[47.979169, 7.996567], [46.423915, 6.496736], [44.941525, 4.911484], [43.968975, 4.953962], [43.119259, 4.647702], [42.78977, 4.285605], [41.885019, 3.977226], [40.965385, 2.814145], [40.979751, -0.870798], [41.535085, -1.696303], [42.080903, -0.862888], [43.467621, 0.620551], [44.550059, 1.559068], [46.027029, 2.438137], [47.948497, 4.457099], [49.036143, 6.144232], [49.842052, 7.962714], [50.840343, 9.456122], [51.138194, 10.676744], [51.12086, 11.505316], [50.797864, 11.989119], [50.268321, 11.589301], [48.939112, 11.24913], [48.939111, 9.451233], [47.979169, 7.996567]]]}, "properties": {"name": "Somalia", "ISO3166-1-Alpha-3": "SOM", "ISO3166-1-Alpha-2": "SO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[35.920835, 4.619332], [35.245726, 4.98172], [34.381188, 4.620158], [33.977078, 4.219692], [34.434001, 3.182029], [34.923584, 2.477318], [34.978671, 1.675945], [33.893569, 0.109814], [33.904214, -1.002573], [35.415647, -1.801284], [37.644865, -3.045963], [37.770955, -3.655435], [39.190603, -4.677504], [39.551117, -4.402114], [40.12908, -3.251886], [40.174978, -2.762628], [41.535085, -1.696303], [40.979751, -0.870798], [40.965385, 2.814145], [41.885019, 3.977226], [41.114524, 3.962343], [40.763744, 4.284933], [39.848451, 3.867284], [39.436176, 3.462374], [38.101891, 3.612649], [36.844087, 4.432237], [35.920835, 4.619332]]]}, "properties": {"name": "Kenya", "ISO3166-1-Alpha-3": "KEN", "ISO3166-1-Alpha-2": "KE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.964615, -11.573556], [34.592958, -11.016174], [34.4833, -9.946162], [34.012735, -9.477457], [32.920863, -9.4079], [33.674203, -10.577028], [33.230302, -11.416563], [33.373239, -12.518511], [33.024836, -12.612666], [32.722839, -13.573382], [33.202707, -14.013872], [33.604233, -14.524022], [34.344084, -14.387389], [34.569083, -15.27116], [34.385219, -16.186453], [35.214419, -16.484212], [35.795675, -16.004965], [35.853036, -14.667476], [34.894438, -13.534728], [34.545312, -13.325749], [34.354006, -12.199461], [34.964615, -11.573556]]]}, "properties": {"name": "Malawi", "ISO3166-1-Alpha-3": "MWI", "ISO3166-1-Alpha-2": "MW"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[32.920863, -9.4079], [34.012735, -9.477457], [34.4833, -9.946162], [34.592958, -11.016174], [34.964615, -11.573556], [37.427824, -11.722591], [37.875238, -11.319101], [38.492255, -11.413462], [40.008131, -10.811122], [40.43686, -10.474786], [39.77947, -9.930108], [39.28004, -8.309259], [39.464529, -6.85768], [38.778005, -6.030694], [39.190603, -4.677504], [37.770955, -3.655435], [37.644865, -3.045963], [35.415647, -1.801284], [33.904214, -1.002573], [30.828381, -1.002573], [30.471786, -1.066837], [30.831017, -1.594165], [30.5546, -2.400628], [30.832205, -3.172777], [30.003005, -4.271935], [29.404179, -4.449805], [29.738629, -6.652409], [30.369598, -7.31025], [30.752107, -8.194124], [30.959536, -8.550485], [32.920863, -9.4079]]]}, "properties": {"name": "United Republic of Tanzania", "ISO3166-1-Alpha-3": "TZA", "ISO3166-1-Alpha-2": "TZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[35.75759, 32.744347], [36.819385, 32.316788], [38.774511, 33.371685], [40.690467, 34.331497], [41.195656, 34.768473], [41.414867, 36.527384], [42.357238, 37.109984], [40.708967, 37.100476], [39.765252, 36.742151], [38.190051, 36.905526], [36.587771, 36.324812], [35.911305, 35.91775], [35.9699, 34.649849], [36.604101, 34.199102], [35.8211, 33.406722], [35.75759, 32.744347]]]}, "properties": {"name": "Syria", "ISO3166-1-Alpha-3": "SYR", "ISO3166-1-Alpha-2": "SY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[48.939112, 11.24913], [47.366873, 11.172797], [46.447765, 10.693101], [45.802745, 10.875312], [44.962009, 10.415799], [44.274262, 10.456732], [43.240733, 11.48786], [42.923715, 10.998787], [42.836279, 10.208086], [43.419034, 9.413018], [44.023855, 8.985525], [46.97923, 7.996567], [47.979169, 7.996567], [48.939111, 9.451233], [48.939112, 11.24913]]]}, "properties": {"name": "Somaliland", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-54.615292, 2.326267], [-54.134908, 2.110673], [-52.707708, 2.358927], [-51.683217, 4.039374], [-51.931264, 4.590399], [-52.994581, 5.457624], [-53.94435, 5.744641], [-54.170969, 5.348375], [-54.482122, 4.912802], [-54.355153, 4.066523], [-53.988819, 3.610995], [-54.212526, 2.776421], [-54.615292, 2.326267]]], [[[2.5218, 51.087541], [1.580577, 50.868801], [1.521007, 50.214667], [0.23585, 49.729315], [-0.219472, 49.280097], [-1.610422, 49.215888], [-1.579701, 48.643256], [-3.077056, 48.828274], [-4.563629, 48.629625], [-4.434641, 47.975653], [-2.501698, 47.526679], [-1.804799, 46.503607], [-1.048695, 46.038804], [-1.476877, 43.580064], [-1.794075, 43.386015], [-0.038933, 42.685148], [1.429297, 42.595386], [1.707006, 42.502781], [3.18097, 42.431484], [3.038992, 42.942776], [3.939301, 43.532538], [6.027008, 43.078195], [7.36575, 43.72273], [7.437454, 43.743361], [7.502289, 43.792222], [7.033451, 44.242934], [7.022083, 45.92526], [6.762667, 46.42926], [6.064157, 46.471118], [7.586028, 47.584619], [7.572643, 48.094972], [8.18873, 48.965746], [6.345307, 49.455349], [5.790685, 49.537753], [2.786734, 50.723365], [2.5218, 51.087541]]], [[[8.565766, 42.208564], [9.210704, 41.440863], [9.552745, 42.113023], [9.107107, 42.725898], [8.565766, 42.208564]]]]}, "properties": {"name": "France", "ISO3166-1-Alpha-3": "FRA", "ISO3166-1-Alpha-2": "FR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-54.615292, 2.326267], [-54.212526, 2.776421], [-53.988819, 3.610995], [-54.355153, 4.066523], [-54.482122, 4.912802], [-54.170969, 5.348375], [-54.036731, 5.842351], [-54.77359, 5.985256], [-56.956537, 6.011574], [-57.24767, 5.484931], [-57.720477, 4.9898], [-58.067691, 4.151143], [-56.70519, 2.029645], [-56.481819, 1.941614], [-56.116803, 2.333089], [-55.017748, 2.590592], [-54.615292, 2.326267]]]}, "properties": {"name": "Suriname", "ISO3166-1-Alpha-3": "SUR", "ISO3166-1-Alpha-2": "SR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-56.481819, 1.941614], [-56.70519, 2.029645], [-58.067691, 4.151143], [-57.720477, 4.9898], [-57.24767, 5.484931], [-57.167307, 6.08511], [-58.067863, 6.821703], [-59.165151, 8.058743], [-60.020985, 8.55801], [-59.815595, 8.287764], [-60.730578, 7.525433], [-60.420958, 6.942213], [-61.204787, 6.595826], [-61.379608, 5.9053], [-60.739854, 5.202138], [-59.983104, 5.085944], [-60.087697, 4.607523], [-59.52923, 3.931906], [-59.837661, 3.60929], [-60.00008, 2.694049], [-59.764539, 1.92053], [-59.242141, 1.377798], [-58.51893, 1.267262], [-58.331913, 1.593392], [-57.561159, 1.708967], [-57.104494, 2.021454], [-56.481819, 1.941614]]]}, "properties": {"name": "Guyana", "ISO3166-1-Alpha-3": "GUY", "ISO3166-1-Alpha-2": "GY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[128.364919, 38.624335], [128.039864, 38.304278], [127.157489, 38.307224], [126.667459, 37.82781], [126.755219, 37.048814], [126.336029, 36.824652], [126.733165, 35.885565], [126.271088, 34.642483], [127.315926, 34.444901], [128.019298, 34.998684], [129.194347, 35.155504], [129.4546, 35.513251], [129.331065, 37.282172], [128.364919, 38.624335]]]}, "properties": {"name": "South Korea", "ISO3166-1-Alpha-3": "KOR", "ISO3166-1-Alpha-2": "KR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[128.364919, 38.624335], [127.378429, 39.371487], [129.752997, 40.90994], [129.690823, 41.649716], [130.699962, 42.295111], [130.530771, 42.53048], [128.963838, 42.088517], [128.034593, 41.993743], [128.185695, 41.404451], [126.887118, 41.784918], [126.007843, 40.899313], [124.369965, 40.098293], [125.431895, 39.300727], [124.883881, 38.355292], [125.013194, 37.905992], [126.667459, 37.82781], [127.157489, 38.307224], [128.039864, 38.304278], [128.364919, 38.624335]]]}, "properties": {"name": "North Korea", "ISO3166-1-Alpha-3": "PRK", "ISO3166-1-Alpha-2": "KP"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-17.013743, 21.419971], [-14.839813, 21.450268], [-14.220187, 22.309647], [-14.019992, 23.410252], [-12.430141, 24.830165], [-12.029752, 26.03035], [-11.717239, 26.103576], [-10.921835, 27.009825], [-9.734362, 26.860429], [-8.752872, 27.190486], [-8.682385, 27.661439], [-8.682385, 28.6659], [-7.619453, 29.389422], [-5.756156, 29.614071], [-4.372368, 30.508641], [-3.645529, 30.711317], [-3.659507, 31.647821], [-2.827836, 31.794586], [-2.516147, 32.1322], [-1.249557, 32.08166], [-1.674234, 33.237972], [-1.787716, 34.756691], [-2.222564, 35.089301], [-2.912913, 35.276923], [-2.947825, 35.329779], [-4.376047, 35.151842], [-5.340728, 35.847357], [-5.398859, 35.924504], [-5.927235, 35.780748], [-6.822092, 34.039618], [-8.243153, 33.404527], [-9.259918, 32.57689], [-9.847524, 31.402411], [-9.655141, 30.126899], [-10.573801, 28.990424], [-11.485585, 28.325629], [-12.968088, 27.914618], [-13.619985, 26.68891], [-14.410146, 26.260077], [-14.907826, 24.685492], [-15.620107, 24.026272], [-17.013743, 21.419971]]]}, "properties": {"name": "Morocco", "ISO3166-1-Alpha-3": "MAR", "ISO3166-1-Alpha-2": "MA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-8.682385, 27.661439], [-8.752872, 27.190486], [-9.734362, 26.860429], [-10.921835, 27.009825], [-11.717239, 26.103576], [-12.029752, 26.03035], [-12.430141, 24.830165], [-14.019992, 23.410252], [-14.220187, 22.309647], [-14.839813, 21.450268], [-17.013743, 21.419971], [-17.056874, 20.766913], [-16.958831, 21.332859], [-13.015247, 21.333428], [-13.015247, 23.018002], [-12.015308, 23.495182], [-12.015308, 25.9949], [-8.680809, 26.013142], [-8.682385, 27.285416], [-8.682385, 27.661439]]]}, "properties": {"name": "Western Sahara", "ISO3166-1-Alpha-3": "ESH", "ISO3166-1-Alpha-2": "EH"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-83.6965, 10.936594], [-83.933322, 10.718055], [-84.676455, 11.070411], [-85.701736, 11.08088], [-85.861602, 10.346991], [-85.63858, 9.905463], [-84.864613, 9.822943], [-83.629872, 9.035346], [-83.739898, 8.623806], [-82.897629, 8.034748], [-82.573598, 9.576199], [-83.466176, 10.494534], [-83.665352, 10.935318], [-83.686867, 10.93797], [-83.6965, 10.936594]]]}, "properties": {"name": "Costa Rica", "ISO3166-1-Alpha-3": "CRI", "ISO3166-1-Alpha-2": "CR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-85.701736, 11.08088], [-84.676455, 11.070411], [-83.933322, 10.718055], [-83.6965, 10.936594], [-83.767201, 12.546698], [-83.50768, 12.902777], [-83.431264, 13.956732], [-83.130444, 14.997012], [-84.482694, 14.619471], [-84.77017, 14.805144], [-85.824162, 13.847683], [-86.09673, 14.044079], [-86.701861, 13.314201], [-87.314036, 12.981553], [-86.496368, 11.759426], [-85.701736, 11.08088]]]}, "properties": {"name": "Nicaragua", "ISO3166-1-Alpha-3": "NIC", "ISO3166-1-Alpha-2": "NI"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[18.626387, 3.476869], [17.334373, 3.618514], [16.567701, 3.464389], [16.196665, 2.236454], [15.76465, 1.908722], [14.562139, 2.208807], [13.294568, 2.161058], [13.25023, 1.221787], [14.183868, 1.380847], [14.468088, 0.913227], [13.871225, 0.196423], [14.498991, -0.630916], [14.482144, -1.388596], [14.226966, -2.323113], [13.770663, -2.119094], [13.361024, -2.428843], [12.804572, -1.919107], [12.45927, -2.329934], [11.55824, -2.349365], [11.827784, -3.548051], [11.114016, -3.936856], [12.009608, -5.019631], [12.761681, -4.391204], [13.073703, -4.635323], [13.35875, -4.794797], [14.368559, -4.278446], [14.831167, -4.815054], [15.20396, -4.339011], [15.882989, -3.945339], [16.207723, -3.361913], [16.231288, -2.129016], [16.839726, -1.262506], [17.749541, -0.523429], [17.939917, 0.361271], [17.86664, 1.016632], [18.072416, 2.160024], [18.626387, 3.476869]]]}, "properties": {"name": "Republic of the Congo", "ISO3166-1-Alpha-3": "COG", "ISO3166-1-Alpha-2": "CG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[18.626387, 3.476869], [18.072416, 2.160024], [17.86664, 1.016632], [17.939917, 0.361271], [17.749541, -0.523429], [16.839726, -1.262506], [16.231288, -2.129016], [16.207723, -3.361913], [15.882989, -3.945339], [15.20396, -4.339011], [14.831167, -4.815054], [14.368559, -4.278446], [13.35875, -4.794797], [13.073703, -4.635323], [12.210541, -5.763442], [12.449498, -6.051928], [13.183849, -5.856459], [16.315727, -5.854629], [16.597364, -5.924702], [16.996099, -7.297951], [17.536531, -8.015117], [19.355542, -8.001991], [19.521836, -7.001949], [20.294296, -7.001949], [20.520535, -7.286376], [21.784954, -7.283379], [21.935953, -8.413025], [21.854097, -9.61781], [22.313397, -10.368565], [22.165499, -10.85236], [23.014336, -11.102474], [23.967457, -10.872307], [24.310071, -11.406641], [25.278798, -11.199935], [25.351971, -11.64611], [25.994051, -11.904802], [27.420734, -11.921959], [27.638189, -12.293615], [28.42274, -12.521302], [29.168948, -13.433856], [29.574401, -13.225393], [29.799297, -12.154089], [29.030352, -12.376194], [28.497154, -11.857363], [28.439587, -11.348247], [28.668462, -9.821622], [28.372304, -9.235094], [28.915268, -8.472867], [30.752107, -8.194124], [30.369598, -7.31025], [29.738629, -6.652409], [29.404179, -4.449805], [29.234629, -3.046583], [29.015365, -2.720711], [28.858838, -2.418198], [29.577915, -1.38839], [29.711809, 0.099582], [29.928281, 0.785018], [31.242826, 2.051168], [30.724925, 2.440782], [30.839543, 3.490202], [29.494096, 4.668295], [28.404137, 4.277828], [27.772444, 4.595819], [27.441301, 5.070725], [26.462653, 5.059641], [25.58126, 5.374919], [25.307633, 5.032278], [24.459623, 5.107441], [23.38837, 4.587266], [22.898374, 4.823583], [22.492714, 4.174036], [20.603114, 4.409732], [19.71955, 5.135967], [19.08331, 4.90934], [18.721058, 4.377357], [18.626387, 3.476869]]]}, "properties": {"name": "Democratic Republic of the Congo", "ISO3166-1-Alpha-3": "COD", "ISO3166-1-Alpha-2": "CD"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[91.628339, 27.852694], [90.261804, 28.335354], [89.561489, 28.13464], [88.892331, 27.315543], [88.954549, 26.912622], [89.822093, 26.701007], [92.03586, 26.854848], [91.628339, 27.852694]]]}, "properties": {"name": "Bhutan", "ISO3166-1-Alpha-3": "BTN", "ISO3166-1-Alpha-2": "BT"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[31.764345, 52.100568], [30.940519, 52.020082], [30.14863, 51.48443], [28.346879, 51.525151], [25.767915, 51.928511], [24.39079, 51.880013], [23.606238, 51.517399], [24.106466, 50.538622], [22.640922, 49.528761], [22.539637, 49.0722], [22.13284, 48.404798], [22.877601, 47.946739], [25.261744, 47.898576], [26.617889, 48.258968], [27.751773, 48.451979], [29.123989, 47.975987], [29.556573, 47.324038], [29.72695, 46.455796], [28.199498, 45.461774], [29.659028, 45.215888], [29.968516, 45.838935], [30.991384, 46.601264], [31.858409, 46.629136], [32.262462, 46.128241], [33.628517, 46.124935], [35.001891, 45.729003], [34.81129, 46.166246], [35.901622, 46.652737], [38.216645, 47.103258], [38.877244, 47.86124], [39.759051, 47.832947], [39.758741, 48.895415], [40.141663, 49.245781], [39.570432, 49.713297], [38.34415, 49.992092], [37.435265, 50.424934], [36.682649, 50.260654], [35.425258, 50.500485], [33.804065, 52.354609], [31.764345, 52.100568]]]}, "properties": {"name": "Ukraine", "ISO3166-1-Alpha-3": "UKR", "ISO3166-1-Alpha-2": "UA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[23.606238, 51.517399], [24.39079, 51.880013], [25.767915, 51.928511], [28.346879, 51.525151], [30.14863, 51.48443], [30.940519, 52.020082], [31.764345, 52.100568], [31.378529, 53.182026], [32.719532, 53.439478], [31.324682, 54.229249], [30.912821, 55.571596], [30.217773, 55.855145], [28.148907, 56.142414], [26.594531, 55.666991], [25.529997, 54.346141], [24.821358, 54.019908], [23.485625, 53.939293], [23.893663, 53.151951], [23.606238, 51.517399]]]}, "properties": {"name": "Belarus", "ISO3166-1-Alpha-3": "BLR", "ISO3166-1-Alpha-2": "BY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[16.487071, -28.572931], [16.892953, -28.082626], [17.403619, -28.704293], [19.081657, -28.959368], [19.981653, -28.422347], [19.981447, -24.752493], [19.978346, -22.000671], [20.97198, -22.000671], [20.975081, -18.319346], [23.311476, -18.009804], [23.645409, -18.466004], [24.183051, -18.029441], [25.259781, -17.794107], [24.220464, -17.4795], [23.381652, -17.641144], [20.806202, -18.031405], [18.761986, -17.747701], [18.453581, -17.389893], [16.339808, -17.388653], [13.942745, -17.408187], [13.363711, -16.964183], [12.554561, -17.235588], [11.766124, -17.252699], [11.849082, -18.143183], [13.154633, -20.154229], [13.387218, -20.816095], [14.508556, -22.548028], [14.473643, -24.159112], [14.780772, -24.803399], [14.842784, -25.763604], [15.295258, -27.322442], [15.684255, -27.949884], [16.487071, -28.572931]]]}, "properties": {"name": "Namibia", "ISO3166-1-Alpha-3": "NAM", "ISO3166-1-Alpha-2": "NA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[19.981447, -24.752493], [19.981653, -28.422347], [19.081657, -28.959368], [17.403619, -28.704293], [16.892953, -28.082626], [16.487071, -28.572931], [17.281993, -30.347752], [18.172862, -31.663263], [18.273123, -32.647556], [17.852387, -32.829685], [18.475352, -33.899835], [19.953461, -34.808689], [20.524425, -34.454278], [21.702647, -34.390558], [22.629893, -33.995782], [23.635509, -33.979669], [24.583995, -34.178318], [26.515147, -33.755304], [27.897146, -33.039972], [28.870372, -32.287042], [30.392345, -30.847101], [31.18686, -29.560317], [32.379405, -28.55283], [32.893077, -26.846124], [32.113884, -26.840014], [31.96826, -27.316264], [31.157043, -27.205573], [30.785697, -26.716921], [31.119836, -25.910045], [31.949243, -25.958104], [31.986554, -24.423108], [31.521466, -23.415572], [31.288922, -22.39734], [29.350074, -22.186707], [28.338559, -22.584615], [27.00417, -23.645842], [26.84971, -24.248131], [25.868374, -24.748152], [25.587254, -25.61952], [24.79862, -25.829223], [23.006998, -25.310805], [22.719367, -25.984253], [21.687182, -26.855207], [20.608902, -26.686122], [20.841446, -26.131324], [20.364886, -25.0332], [19.981447, -24.752493]], [[28.980846, -28.909035], [29.435908, -29.342394], [29.144246, -29.919723], [28.364087, -30.159295], [28.054701, -30.649704], [27.366164, -30.311017], [27.014867, -29.625581], [27.747432, -28.908622], [28.66655, -28.59722], [28.980846, -28.909035]]]}, "properties": {"name": "South Africa", "ISO3166-1-Alpha-3": "ZAF", "ISO3166-1-Alpha-2": "ZA"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Martin", "ISO3166-1-Alpha-3": "MAF", "ISO3166-1-Alpha-2": "MF"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Sint Maarten", "ISO3166-1-Alpha-3": "SXM", "ISO3166-1-Alpha-2": "SX"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[56.383311, 24.978217], [55.807373, 24.340789], [55.186843, 22.703577], [55.637565, 21.97897], [54.97838, 19.995421], [51.978615, 18.995638], [53.090343, 16.642401], [54.023448, 16.985338], [55.026866, 17.011135], [55.44516, 17.84101], [56.56129, 18.149237], [56.659679, 18.595364], [57.709727, 18.943996], [57.85613, 20.260647], [58.518565, 20.416815], [59.791759, 22.198065], [58.786143, 23.512112], [57.151541, 23.953559], [56.383311, 24.978217]]]}, "properties": {"name": "Oman", "ISO3166-1-Alpha-3": "OMN", "ISO3166-1-Alpha-2": "OM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[70.958955, 40.238372], [72.370497, 40.38565], [72.619474, 40.88009], [71.585532, 41.323525], [71.276145, 41.113151], [70.169288, 41.578342], [70.947793, 42.248146], [69.044447, 41.379181], [68.461743, 40.584656], [67.937434, 41.20038], [66.688208, 41.199192], [66.504137, 41.993484], [66.017241, 41.997619], [66.101267, 42.990323], [64.956531, 43.69736], [62.026115, 43.480629], [61.036305, 44.382822], [58.531445, 45.558719], [55.978422, 44.996221], [55.978422, 41.321717], [57.010194, 41.254124], [56.950146, 41.86605], [58.612267, 42.780852], [59.866351, 42.304215], [60.414534, 41.235262], [61.877907, 41.124984], [62.452808, 40.009239], [64.120613, 38.96168], [65.60414, 38.237409], [66.554159, 38.026853], [66.519588, 37.36418], [67.780544, 37.188868], [68.360664, 38.174053], [67.764525, 39.622596], [68.51745, 39.54844], [68.601993, 40.17543], [70.280392, 40.877713], [70.958955, 40.238372]]]}, "properties": {"name": "Uzbekistan", "ISO3166-1-Alpha-3": "UZB", "ISO3166-1-Alpha-2": "UZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[87.323796, 49.085274], [86.610196, 49.795515], [85.263095, 49.581523], [83.433956, 50.992651], [82.073109, 50.715665], [80.682754, 51.302012], [79.990083, 50.788116], [77.866802, 53.272331], [76.525695, 53.961307], [75.640375, 54.09887], [74.213899, 53.59704], [72.066743, 54.222376], [70.665743, 55.309183], [68.722502, 55.352798], [68.166051, 54.95613], [62.550579, 54.026833], [61.193505, 54.018254], [61.039922, 52.334998], [60.138065, 51.888875], [61.473798, 51.425803], [61.372098, 50.782483], [59.774677, 50.533712], [58.595007, 51.023372], [56.508003, 51.066264], [55.659992, 50.530043], [53.610298, 51.388415], [50.581698, 51.635299], [50.325383, 51.303459], [48.67339, 50.579549], [47.624669, 50.440798], [46.899907, 49.820319], [46.479312, 48.410225], [47.051164, 47.974644], [48.525079, 47.410234], [49.227131, 46.327879], [51.187022, 47.116889], [53.188162, 46.718004], [53.086599, 46.020494], [52.566661, 45.400702], [51.397227, 45.332587], [50.299083, 44.655341], [51.932791, 42.834947], [52.74464, 42.657375], [52.437671, 41.748876], [52.978709, 42.126629], [54.047171, 42.345401], [54.738291, 42.04821], [55.429515, 41.290814], [55.978422, 41.321717], [55.978422, 44.996221], [58.531445, 45.558719], [61.036305, 44.382822], [62.026115, 43.480629], [64.956531, 43.69736], [66.101267, 42.990323], [66.017241, 41.997619], [66.504137, 41.993484], [66.688208, 41.199192], [67.937434, 41.20038], [68.461743, 40.584656], [69.044447, 41.379181], [70.947793, 42.248146], [71.847738, 42.834053], [73.41002, 42.58965], [74.25865, 43.215761], [75.178594, 42.84966], [78.496118, 42.875601], [80.210328, 42.189519], [80.368148, 43.02846], [80.773395, 43.112925], [80.061707, 45.018959], [82.291493, 45.533191], [83.150356, 47.211538], [84.916036, 46.850552], [85.498636, 47.051832], [85.783683, 48.407589], [86.565083, 48.527323], [87.323796, 49.085274]]]}, "properties": {"name": "Kazakhstan", "ISO3166-1-Alpha-3": "KAZ", "ISO3166-1-Alpha-2": "KZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[70.958955, 40.238372], [70.280392, 40.877713], [68.601993, 40.17543], [68.51745, 39.54844], [67.764525, 39.622596], [68.360664, 38.174053], [67.780544, 37.188868], [68.307282, 37.114221], [70.165205, 37.889911], [70.974045, 38.473673], [71.597727, 37.89836], [71.61106, 36.704841], [73.276075, 37.459472], [74.892307, 37.231114], [75.164125, 37.400638], [74.776345, 38.510674], [73.797387, 38.602839], [73.632642, 39.448343], [72.31634, 39.328815], [71.459545, 39.612105], [69.286189, 39.539707], [69.313991, 39.986914], [70.958955, 40.238372]]]}, "properties": {"name": "Tajikistan", "ISO3166-1-Alpha-3": "TJK", "ISO3166-1-Alpha-2": "TJ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[26.594531, 55.666991], [25.649679, 56.143809], [22.094082, 56.41741], [21.053396, 56.072618], [21.267589, 55.248684], [22.808871, 54.893756], [22.76722, 54.35627], [23.485625, 53.939293], [24.821358, 54.019908], [25.529997, 54.346141], [26.594531, 55.666991]]]}, "properties": {"name": "Lithuania", "ISO3166-1-Alpha-3": "LTU", "ISO3166-1-Alpha-2": "LT"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-57.602793, -30.190517], [-56.831281, -30.102037], [-56.011357, -30.798222], [-54.591521, -31.471049], [-53.126856, -32.754847], [-53.511535, -33.099219], [-53.379095, -33.740676], [-52.621693, -33.101983], [-52.127431, -32.177504], [-51.92276, -31.306899], [-51.443023, -31.080987], [-51.122548, -30.256036], [-50.315663, -30.461521], [-49.810658, -29.443129], [-48.815338, -28.610447], [-48.565541, -27.860284], [-48.6822, -26.719171], [-48.432729, -25.622491], [-47.825551, -24.894138], [-46.966461, -24.299086], [-45.08019, -23.497654], [-44.435658, -22.996759], [-42.058217, -22.95379], [-41.966298, -22.535414], [-40.967275, -21.947524], [-41.071868, -21.523339], [-39.709788, -19.416111], [-39.72468, -18.518731], [-39.183746, -17.600844], [-39.208608, -17.162205], [-38.864369, -15.843357], [-39.067128, -14.674981], [-39.062489, -13.399998], [-38.738149, -12.736586], [-38.320424, -12.935235], [-37.654652, -12.049737], [-36.903066, -10.772882], [-36.40807, -10.50213], [-35.14745, -8.911879], [-34.811635, -7.905857], [-34.797353, -7.158461], [-35.262278, -5.483331], [-35.510569, -5.143731], [-36.589996, -5.10296], [-37.182932, -4.910903], [-38.659657, -3.676446], [-39.985707, -2.848403], [-41.343088, -2.920343], [-42.452992, -2.752211], [-43.311766, -2.346467], [-44.368072, -2.549574], [-44.521718, -1.847263], [-46.613796, -0.813236], [-47.584514, -0.578247], [-48.154042, -0.781834], [-48.339711, -1.316013], [-48.965589, -1.599719], [-50.372841, -1.973728], [-50.819203, -1.436293], [-50.824452, -1.030938], [-52.123769, -1.619073], [-51.201568, -0.049086], [-50.790639, 0.169501], [-49.978831, 1.072211], [-49.910159, 1.664263], [-50.466542, 1.815416], [-51.092742, 3.376329], [-51.079905, 3.88227], [-51.683217, 4.039374], [-52.707708, 2.358927], [-54.134908, 2.110673], [-54.615292, 2.326267], [-55.017748, 2.590592], [-56.116803, 2.333089], [-56.481819, 1.941614], [-57.104494, 2.021454], [-57.561159, 1.708967], [-58.331913, 1.593392], [-58.51893, 1.267262], [-59.242141, 1.377798], [-59.764539, 1.92053], [-60.00008, 2.694049], [-59.837661, 3.60929], [-59.52923, 3.931906], [-60.087697, 4.607523], [-59.983104, 5.085944], [-60.739854, 5.202138], [-60.612626, 4.900581], [-61.542104, 4.263023], [-62.766216, 4.020712], [-64.063811, 3.911597], [-64.222923, 3.123996], [-64.080864, 1.647394], [-65.136769, 1.126909], [-66.346204, 0.759386], [-66.875061, 1.22251], [-67.340614, 2.090106], [-68.163302, 1.721291], [-69.848807, 1.668892], [-70.073806, -0.124901], [-69.632076, -0.506893], [-69.399454, -1.182717], [-69.96495, -4.236484], [-70.832235, -4.179434], [-71.774348, -4.481534], [-72.917948, -5.132089], [-73.234776, -6.077561], [-73.131785, -6.435265], [-73.765493, -6.904177], [-73.526541, -8.372408], [-72.430225, -9.482211], [-72.195666, -10.00559], [-71.391426, -10.00683], [-70.680849, -9.527686], [-70.641342, -11.0108], [-69.577635, -10.952302], [-68.61573, -11.112499], [-67.755782, -10.714177], [-66.631871, -9.904304], [-65.304381, -9.825652], [-65.44998, -10.468094], [-65.353009, -11.390621], [-64.997449, -11.996269], [-64.395729, -12.457326], [-63.801398, -12.454949], [-62.807402, -12.98887], [-62.22165, -13.121265], [-61.847977, -13.530801], [-60.896743, -13.552918], [-60.465271, -13.816572], [-60.129839, -16.273062], [-58.464721, -16.33125], [-58.38116, -17.267214], [-57.790757, -17.555775], [-57.551082, -18.183643], [-58.158797, -20.165125], [-57.86021, -20.730258], [-57.986818, -22.035295], [-56.842856, -22.289026], [-55.89294, -22.306803], [-55.398035, -23.97683], [-54.612553, -23.811155], [-54.245289, -24.050624], [-54.600203, -25.574945], [-53.90996, -25.629236], [-53.66672, -26.219174], [-53.819217, -27.139738], [-54.827527, -27.545088], [-55.772534, -28.231971], [-56.415751, -29.051352], [-57.611698, -30.182963], [-57.602793, -30.190517]]], [[[-48.462514, -0.576104], [-48.427235, -0.259942], [-50.368153, -0.105645], [-50.779897, -0.660577], [-50.804189, -1.424981], [-50.405181, -1.830987], [-49.823557, -1.816583], [-48.839223, -1.44256], [-48.462514, -0.576104]]]]}, "properties": {"name": "Brazil", "ISO3166-1-Alpha-3": "BRA", "ISO3166-1-Alpha-2": "BR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-57.602793, -30.190517], [-57.642466, -30.193092], [-58.168615, -31.846014], [-58.200124, -32.447201], [-58.388824, -33.942071], [-57.82251, -34.475109], [-57.120025, -34.462986], [-56.311269, -34.906183], [-54.93932, -34.969903], [-53.764882, -34.390395], [-53.379095, -33.740676], [-53.511535, -33.099219], [-53.126856, -32.754847], [-54.591521, -31.471049], [-56.011357, -30.798222], [-56.831281, -30.102037], [-57.602793, -30.190517]]]}, "properties": {"name": "Uruguay", "ISO3166-1-Alpha-3": "URY", "ISO3166-1-Alpha-2": "UY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[116.684278, 49.823265], [115.368699, 49.895405], [114.286285, 50.276881], [113.043673, 49.588602], [110.731359, 49.137674], [108.538057, 49.32743], [107.946517, 49.933491], [106.656518, 50.327007], [105.32864, 50.476455], [103.601407, 50.13353], [102.327635, 50.545546], [102.051217, 51.383609], [100.005968, 51.731727], [98.855599, 52.106691], [97.80636, 51.001126], [98.293462, 50.518623], [98.105567, 50.06387], [97.301688, 49.725545], [95.867047, 50.014984], [94.624539, 50.015191], [94.237586, 50.565235], [91.749779, 50.684091], [89.623862, 49.902692], [88.870627, 49.436002], [87.816324, 49.165837], [87.942828, 48.599489], [89.045551, 47.992989], [89.5418, 48.031023], [90.441228, 47.493045], [91.047496, 46.566409], [90.651138, 45.493142], [90.90549, 45.185977], [93.525278, 44.951263], [94.69823, 44.343496], [95.379428, 44.287117], [96.350635, 42.740906], [97.193271, 42.78726], [99.474476, 42.564199], [100.016975, 42.676518], [101.637599, 42.515442], [102.034164, 42.18461], [103.720986, 41.755566], [104.500784, 41.870598], [105.014809, 41.596144], [106.767829, 42.286619], [109.485131, 42.449296], [110.406728, 42.768605], [110.933724, 43.287772], [111.933353, 43.696636], [111.406357, 44.416463], [112.011488, 45.087482], [113.635058, 44.746262], [114.533711, 45.3855], [115.63783, 45.444359], [116.603559, 46.309319], [118.238291, 46.715393], [119.680116, 46.591628], [119.699959, 47.159526], [118.542252, 47.966246], [115.852701, 47.705565], [115.51453, 48.122103], [116.684278, 49.823265]]]}, "properties": {"name": "Mongolia", "ISO3166-1-Alpha-3": "MNG", "ISO3166-1-Alpha-2": "MN"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[87.816324, 49.165837], [88.870627, 49.436002], [89.623862, 49.902692], [91.749779, 50.684091], [94.237586, 50.565235], [94.624539, 50.015191], [95.867047, 50.014984], [97.301688, 49.725545], [98.105567, 50.06387], [98.293462, 50.518623], [97.80636, 51.001126], [98.855599, 52.106691], [100.005968, 51.731727], [102.051217, 51.383609], [102.327635, 50.545546], [103.601407, 50.13353], [105.32864, 50.476455], [106.656518, 50.327007], [107.946517, 49.933491], [108.538057, 49.32743], [110.731359, 49.137674], [113.043673, 49.588602], [114.286285, 50.276881], [115.368699, 49.895405], [116.684278, 49.823265], [117.758838, 49.512741], [119.31621, 50.092654], [119.293576, 50.599238], [120.108203, 51.665194], [120.77917, 52.117595], [120.280182, 52.865922], [120.874255, 53.28016], [123.639564, 53.551254], [125.621355, 53.062137], [127.287352, 50.751012], [127.508113, 49.822335], [129.711183, 49.274151], [130.533252, 48.635792], [131.023351, 47.682284], [132.524655, 47.707528], [133.091958, 48.10678], [134.38635, 48.381337], [134.772579, 47.710732], [134.154116, 47.257892], [133.902452, 46.258986], [132.953362, 45.024385], [131.818031, 45.33279], [131.065829, 44.682028], [131.280906, 43.380221], [130.530771, 42.53048], [130.699962, 42.295111], [132.356212, 43.285956], [133.157725, 42.688422], [133.926768, 42.882717], [135.139903, 43.508368], [135.655854, 44.168687], [138.100352, 46.227973], [138.53476, 46.98664], [140.16505, 48.449408], [140.560802, 49.575385], [140.442882, 50.544379], [140.70574, 51.334784], [141.429535, 51.941962], [141.167166, 52.364895], [141.432953, 53.148871], [139.815929, 54.215155], [139.161876, 54.204739], [138.502126, 53.543524], [137.596934, 53.824164], [136.883311, 54.591376], [135.750173, 54.57095], [135.219571, 54.894121], [137.571951, 56.116523], [138.661615, 56.977943], [140.495728, 57.844774], [140.904307, 58.382758], [143.159679, 59.353909], [146.439642, 59.460409], [148.393728, 59.379299], [149.597725, 59.758205], [151.637576, 59.48248], [151.359093, 58.855993], [153.369151, 59.241278], [155.920739, 60.754794], [156.679463, 61.524017], [157.464509, 61.782105], [159.359874, 61.893012], [160.000141, 61.106118], [160.828489, 60.754661], [164.127194, 62.262379], [164.028819, 61.346625], [163.345659, 60.799292], [161.938555, 60.416788], [159.864431, 59.14057], [159.023774, 58.414781], [156.930675, 57.658433], [156.125255, 56.832017], [155.551177, 55.284205], [156.111196, 52.93542], [156.719698, 50.886454], [157.759192, 51.543983], [158.547699, 52.296047], [158.515636, 52.746324], [159.922374, 53.283637], [160.052989, 54.175727], [162.153168, 54.849921], [161.777081, 55.608327], [162.1421, 56.129055], [163.366069, 56.180659], [162.7835, 56.763029], [163.32951, 57.708945], [161.993228, 58.087392], [163.651541, 60.051663], [165.129649, 60.084174], [166.249685, 60.383531], [169.166515, 60.567328], [170.58074, 60.433905], [172.006847, 60.855211], [173.497081, 61.571275], [176.594249, 62.485826], [179.60906, 62.705634], [178.399913, 64.243354], [180, 65.06619], [180, 65.069252], [180, 65.567867], [180, 66.066482], [180, 66.565097], [180, 67.063712], [180, 67.562327], [180, 68.060942], [180, 68.559557], [180, 68.98105], [178.772634, 69.415229], [176.503917, 69.754096], [170.61085, 70.116278], [171.027599, 69.054755], [170.351085, 68.82807], [166.938731, 69.502102], [164.027029, 69.77383], [160.925548, 69.642076], [159.688975, 69.890774], [159.689464, 70.670966], [158.523611, 70.968817], [155.936371, 71.099351], [152.613048, 70.834621], [150.066905, 71.921942], [148.402029, 72.31094], [141.550629, 72.774319], [138.756033, 71.643012], [137.355642, 71.374172], [135.985118, 71.634833], [134.167654, 71.370795], [132.264171, 71.682034], [131.546153, 70.887112], [130.300629, 70.940131], [128.192149, 72.228339], [127.987559, 73.470852], [125.651866, 73.525458], [121.054698, 72.931789], [118.917654, 73.119534], [118.450206, 73.588772], [111.803722, 73.744208], [110.021495, 74.014472], [113.605235, 75.28498], [113.890147, 75.85399], [111.555431, 76.684149], [107.250499, 77.007025], [104.023285, 77.7331], [99.508556, 76.468736], [96.830577, 75.915432], [93.549815, 75.853013], [89.132335, 75.452826], [87.354259, 75.043891], [86.02003, 74.262519], [86.783214, 73.902248], [80.832205, 73.571763], [80.704112, 72.545356], [83.360525, 71.840644], [81.736664, 71.699897], [79.391449, 72.385565], [76.946137, 72.044257], [73.52589, 71.822211], [74.316173, 70.520331], [73.515961, 69.746405], [74.454356, 68.693996], [74.730317, 67.686265], [73.492361, 66.823432], [71.576915, 66.658759], [73.2046, 67.865871], [73.644379, 68.501899], [72.577403, 68.940375], [72.670746, 71.108954], [72.105724, 71.290351], [72.864919, 72.271674], [72.496104, 72.787665], [69.276622, 72.840766], [68.478852, 71.836168], [66.927013, 71.298407], [67.317882, 70.784084], [66.771007, 69.740668], [69.10906, 68.878974], [68.382823, 68.228461], [67.055512, 68.78144], [63.410492, 69.671291], [60.770274, 69.849107], [60.938324, 68.963284], [58.878754, 69.003119], [57.329845, 68.564765], [55.392914, 68.564276], [54.239757, 68.21133], [53.437022, 68.918158], [48.255707, 67.682685], [47.725597, 67.000922], [45.853201, 66.88703], [44.951687, 67.320717], [46.722016, 67.844916], [45.943858, 68.446234], [44.244395, 68.255683], [43.761404, 67.221137], [44.499278, 66.921454], [44.045665, 66.081041], [42.200938, 66.532172], [39.712087, 65.401842], [39.566524, 64.545268], [37.007677, 65.172977], [37.121501, 64.39474], [36.292166, 64.009711], [34.955577, 64.452216], [34.368988, 65.391791], [34.866222, 65.877916], [33.483246, 66.730414], [37.927094, 66.088772], [40.444347, 66.405504], [41.360037, 67.013617], [41.016612, 67.697903], [38.434581, 68.355862], [35.330333, 69.276068], [30.840954, 69.805842], [28.954077, 69.027261], [28.447441, 68.514889], [30.009413, 67.685844], [29.089159, 66.837549], [29.900169, 66.108059], [29.588147, 64.991435], [30.509331, 63.991392], [29.980785, 63.741537], [31.569525, 62.905929], [31.222156, 62.491716], [29.203158, 61.245901], [27.807872, 60.553046], [29.092296, 60.177965], [28.019054, 59.481757], [27.410606, 58.754864], [27.673122, 57.912823], [27.352935, 57.527601], [28.148907, 56.142414], [30.217773, 55.855145], [30.912821, 55.571596], [31.324682, 54.229249], [32.719532, 53.439478], [31.378529, 53.182026], [31.764345, 52.100568], [33.804065, 52.354609], [35.425258, 50.500485], [36.682649, 50.260654], [37.435265, 50.424934], [38.34415, 49.992092], [39.570432, 49.713297], [40.141663, 49.245781], [39.758741, 48.895415], [39.759051, 47.832947], [38.877244, 47.86124], [38.216645, 47.103258], [37.827322, 46.476508], [38.300059, 46.218655], [37.158946, 45.326077], [37.478282, 44.677191], [38.978282, 44.148261], [39.985976, 43.38899], [40.651916, 43.538971], [41.550569, 43.226277], [42.75153, 43.176952], [43.800613, 42.746462], [44.85936, 42.75951], [46.430892, 41.890442], [47.268411, 41.302803], [47.871114, 41.208183], [48.578949, 41.845282], [47.461925, 43.020819], [47.502778, 43.780992], [46.700938, 44.446194], [47.608735, 45.638821], [48.741059, 45.923], [49.227131, 46.327879], [48.525079, 47.410234], [47.051164, 47.974644], [46.479312, 48.410225], [46.899907, 49.820319], [47.624669, 50.440798], [48.67339, 50.579549], [50.325383, 51.303459], [50.581698, 51.635299], [53.610298, 51.388415], [55.659992, 50.530043], [56.508003, 51.066264], [58.595007, 51.023372], [59.774677, 50.533712], [61.372098, 50.782483], [61.473798, 51.425803], [60.138065, 51.888875], [61.039922, 52.334998], [61.193505, 54.018254], [62.550579, 54.026833], [68.166051, 54.95613], [68.722502, 55.352798], [70.665743, 55.309183], [72.066743, 54.222376], [74.213899, 53.59704], [75.640375, 54.09887], [76.525695, 53.961307], [77.866802, 53.272331], [79.990083, 50.788116], [80.682754, 51.302012], [82.073109, 50.715665], [83.433956, 50.992651], [85.263095, 49.581523], [86.610196, 49.795515], [87.323796, 49.085274], [87.816324, 49.165837]]], [[[22.76722, 54.35627], [22.808871, 54.893756], [21.267589, 55.248684], [20.989431, 55.273116], [20.924569, 55.282658], [19.609548, 54.456732], [22.76722, 54.35627]]], [[[33.628517, 46.124935], [32.525157, 45.458157], [33.61964, 44.931464], [33.957856, 44.383002], [35.547374, 45.119696], [35.001891, 45.729003], [33.628517, 46.124935]]], [[[-180, 65.066229], [-179.277044, 65.630256], [-176.976552, 65.608059], [-175.774793, 64.939365], [-173.106265, 64.24098], [-172.162487, 65.420692], [-170.667356, 65.599546], [-169.700917, 66.128648], [-171.895194, 66.970729], [-173.547078, 67.090677], [-174.435663, 66.531545], [-175.2865, 67.667106], [-178.699928, 68.543189], [-180, 68.98237], [-180, 68.559557], [-180, 68.060942], [-180, 67.562327], [-180, 67.063712], [-180, 66.565097], [-180, 66.066482], [-180, 65.567867], [-180, 65.069252], [-180, 65.066229]]], [[[142.529063, 54.297187], [142.576671, 53.500067], [141.928396, 53.020657], [141.675548, 51.912421], [142.266856, 51.074286], [141.967296, 48.862372], [142.191091, 47.966742], [141.822276, 46.602281], [143.172862, 46.707831], [142.549571, 48.020087], [143.174164, 49.245998], [144.282725, 49.25019], [143.790294, 50.309231], [143.118663, 52.337795], [143.29005, 53.151923], [142.529063, 54.297187]]], [[[51.409516, 71.806138], [53.25172, 71.456041], [54.006521, 70.757799], [55.817556, 70.613349], [56.105724, 71.258979], [55.342784, 72.065579], [56.435232, 73.230129], [54.934825, 73.42536], [53.15561, 73.15058], [52.332286, 72.070624], [51.409516, 71.806138]]], [[[56.98699, 74.690497], [53.868663, 73.778713], [56.578787, 73.26081], [58.570486, 74.388861], [62.048188, 75.450344], [68.287934, 76.281317], [66.943533, 76.948961], [65.168956, 76.475287], [61.636974, 76.311957], [55.80836, 75.156643], [56.98699, 74.690497]]], [[[150.827647, 75.163398], [146.389171, 75.58515], [148.17213, 74.803534], [150.827647, 75.163398]]], [[[145.366222, 75.540229], [141.702159, 76.120998], [140.150401, 75.800523], [138.138031, 76.118842], [137.149099, 75.134223], [139.343435, 74.687405], [144.331228, 75.04678], [145.366222, 75.540229]]], [[[105.415538, 78.583441], [102.302908, 79.432807], [100.254649, 78.665351], [101.209809, 78.193508], [105.415538, 78.583441]]], [[[99.885997, 79.04149], [100.022472, 79.823554], [94.935557, 80.071275], [93.748871, 79.54385], [95.01824, 79.03852], [97.760753, 78.808783], [99.885997, 79.04149]]], [[[97.747081, 80.741604], [95.776622, 81.288804], [93.052745, 80.995429], [92.504893, 80.15766], [97.187999, 80.233588], [97.747081, 80.741604]]]]}, "properties": {"name": "Russia", "ISO3166-1-Alpha-3": "RUS", "ISO3166-1-Alpha-2": "RU"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[14.810393, 50.858447], [14.381892, 50.920872], [12.336487, 50.258587], [12.797751, 49.327843], [13.815725, 48.76643], [14.695671, 48.589542], [14.982062, 49.007914], [16.945043, 48.604166], [18.833196, 49.510261], [16.331644, 50.644042], [14.810393, 50.858447]]]}, "properties": {"name": "Czechia", "ISO3166-1-Alpha-3": "CZE", "ISO3166-1-Alpha-2": "CZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[13.815725, 48.76643], [12.797751, 49.327843], [12.336487, 50.258587], [14.381892, 50.920872], [14.810393, 50.858447], [14.644821, 52.576921], [14.21601, 52.817992], [14.263901, 53.699976], [12.501964, 54.473578], [11.175059, 54.018012], [9.437503, 54.810411], [8.660776, 54.896311], [8.860688, 53.831], [7.194591, 53.245022], [7.048231, 52.365074], [6.193348, 51.509338], [5.99491, 50.749927], [6.117487, 50.120456], [6.345307, 49.455349], [8.18873, 48.965746], [7.572643, 48.094972], [7.586028, 47.584619], [8.558216, 47.801166], [9.547482, 47.534547], [10.305913, 47.302178], [12.744834, 47.66536], [12.745041, 48.12063], [13.815725, 48.76643]]]}, "properties": {"name": "Germany", "ISO3166-1-Alpha-3": "DEU", "ISO3166-1-Alpha-2": "DE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[24.306159, 57.868186], [25.16697, 58.058731], [26.49955, 57.515819], [27.352935, 57.527601], [27.673122, 57.912823], [27.410606, 58.754864], [28.019054, 59.481757], [25.684825, 59.628648], [23.470876, 59.212144], [23.493175, 58.673407], [24.306159, 57.868186]]]}, "properties": {"name": "Estonia", "ISO3166-1-Alpha-3": "EST", "ISO3166-1-Alpha-2": "EE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[27.352935, 57.527601], [26.49955, 57.515819], [25.16697, 58.058731], [24.306159, 57.868186], [24.379161, 57.230211], [23.69516, 56.967271], [22.483735, 57.742499], [21.699229, 57.555325], [21.065196, 56.84634], [21.053396, 56.072618], [22.094082, 56.41741], [25.649679, 56.143809], [26.594531, 55.666991], [28.148907, 56.142414], [27.352935, 57.527601]]]}, "properties": {"name": "Latvia", "ISO3166-1-Alpha-3": "LVA", "ISO3166-1-Alpha-2": "LV"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[20.623165, 69.036356], [22.596481, 68.724643], [25.651643, 68.883497], [26.031982, 69.696677], [27.866908, 70.07531], [29.344544, 69.464392], [28.954077, 69.027261], [30.840954, 69.805842], [31.063324, 70.366767], [27.568696, 71.097235], [24.860199, 70.928371], [23.513194, 70.365546], [21.209239, 70.208482], [19.098318, 69.736151], [14.22283, 66.981513], [13.081798, 66.497992], [12.602224, 65.432318], [11.402029, 64.663153], [8.791759, 63.43122], [5.928722, 62.19892], [5.297862, 62.068183], [4.964122, 61.265692], [5.304047, 60.190619], [5.182302, 59.511217], [6.112315, 59.274563], [5.477951, 58.754747], [6.99936, 58.030707], [8.122895, 58.102037], [9.428071, 58.893134], [10.850271, 59.180365], [11.437511, 58.991726], [12.510946, 60.117985], [12.256078, 60.981318], [11.992425, 63.288903], [12.681892, 63.956356], [13.939438, 64.009531], [13.642764, 64.584018], [14.514287, 65.318081], [14.540538, 66.125448], [16.415566, 67.052704], [16.127108, 67.422759], [18.171841, 68.535921], [19.93101, 68.350196], [20.623165, 69.036356]]], [[[20.208751, 78.638088], [16.616466, 79.985053], [10.708018, 79.561428], [11.64324, 78.74726], [13.855968, 78.212877], [14.373709, 77.198676], [16.336762, 76.616441], [18.194102, 77.49018], [18.993012, 78.466986], [20.208751, 78.638088]]], [[[27.171072, 80.073432], [24.375987, 80.332343], [18.007823, 80.190375], [18.783865, 79.72309], [22.925629, 79.222846], [25.645681, 79.397691], [27.171072, 80.073432]]]]}, "properties": {"name": "Norway", "ISO3166-1-Alpha-3": "NOR", "ISO3166-1-Alpha-2": "NO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[20.623165, 69.036356], [19.93101, 68.350196], [18.171841, 68.535921], [16.127108, 67.422759], [16.415566, 67.052704], [14.540538, 66.125448], [14.514287, 65.318081], [13.642764, 64.584018], [13.939438, 64.009531], [12.681892, 63.956356], [11.992425, 63.288903], [12.256078, 60.981318], [12.510946, 60.117985], [11.437511, 58.991726], [11.224864, 58.370673], [12.375011, 56.912055], [13.000011, 55.400092], [14.363048, 55.523627], [14.685802, 56.147406], [15.851736, 56.086371], [16.688975, 57.471747], [16.460297, 57.89468], [17.093028, 58.660834], [18.392589, 59.175767], [18.824474, 60.068305], [17.238048, 60.697699], [17.097179, 61.636135], [19.276134, 63.442694], [20.503429, 63.822008], [21.489757, 64.487006], [21.181326, 64.835842], [22.360606, 65.761217], [24.163097, 65.822699], [24.001253, 66.812435], [23.052164, 68.29821], [20.623165, 69.036356]]]}, "properties": {"name": "Sweden", "ISO3166-1-Alpha-3": "SWE", "ISO3166-1-Alpha-2": "SE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[28.954077, 69.027261], [29.344544, 69.464392], [27.866908, 70.07531], [26.031982, 69.696677], [25.651643, 68.883497], [22.596481, 68.724643], [20.623165, 69.036356], [23.052164, 68.29821], [24.001253, 66.812435], [24.163097, 65.822699], [25.351817, 65.484117], [23.604503, 64.027655], [21.615408, 63.203925], [21.113536, 62.781195], [21.623383, 61.545885], [21.469981, 60.608303], [22.627615, 60.375393], [23.237153, 59.898017], [27.807872, 60.553046], [29.203158, 61.245901], [31.222156, 62.491716], [31.569525, 62.905929], [29.980785, 63.741537], [30.509331, 63.991392], [29.588147, 64.991435], [29.900169, 66.108059], [29.089159, 66.837549], [30.009413, 67.685844], [28.447441, 68.514889], [28.954077, 69.027261]]]}, "properties": {"name": "Finland", "ISO3166-1-Alpha-3": "FIN", "ISO3166-1-Alpha-2": "FI"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[107.520393, 14.704582], [107.31994, 14.119837], [107.596925, 13.535066], [107.514295, 12.343847], [106.01542, 11.770497], [106.063066, 11.093329], [104.451345, 10.419664], [104.892789, 9.851095], [104.774595, 8.817867], [106.575531, 9.648627], [106.788097, 10.393134], [107.32602, 10.44245], [109.018077, 11.355902], [109.242931, 11.735663], [109.204926, 12.64643], [109.465017, 12.913316], [108.882823, 15.336168], [108.151215, 16.216986], [106.763927, 17.335517], [106.52475, 17.949612], [105.648285, 18.892727], [105.955577, 19.925482], [106.569615, 20.231994], [106.755138, 20.938544], [107.991222, 21.485663], [107.348155, 21.599355], [106.722354, 22.006978], [106.667473, 22.86752], [105.853879, 22.90465], [105.332258, 23.317958], [104.728212, 22.839098], [103.866817, 22.575419], [103.309642, 22.787938], [102.989093, 22.437598], [102.442718, 22.765175], [102.118655, 22.397549], [102.947804, 21.737124], [103.115287, 20.868391], [104.056676, 20.958825], [104.600726, 20.660549], [104.805003, 19.790886], [103.85674, 19.317194], [104.721959, 18.792007], [107.116851, 16.254616], [107.16026, 15.758729], [107.657594, 15.282427], [107.520393, 14.704582]]]}, "properties": {"name": "Vietnam", "ISO3166-1-Alpha-3": "VNM", "ISO3166-1-Alpha-2": "VN"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[107.520393, 14.704582], [106.792995, 14.322589], [105.184308, 14.34574], [104.771672, 14.439869], [103.085005, 14.295821], [102.328203, 13.27516], [102.913585, 11.645901], [103.095551, 10.933987], [104.451345, 10.419664], [106.063066, 11.093329], [106.01542, 11.770497], [107.514295, 12.343847], [107.596925, 13.535066], [107.31994, 14.119837], [107.520393, 14.704582]]]}, "properties": {"name": "Cambodia", "ISO3166-1-Alpha-3": "KHM", "ISO3166-1-Alpha-2": "KH"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[6.117487, 50.120456], [5.790685, 49.537753], [6.345307, 49.455349], [6.117487, 50.120456]]]}, "properties": {"name": "Luxembourg", "ISO3166-1-Alpha-3": "LUX", "ISO3166-1-Alpha-2": "LU"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[56.279055, 25.627446], [56.077397, 26.061048], [54.445974, 24.316148], [53.581309, 24.048529], [52.670095, 24.144721], [51.809418, 24.000678], [51.569347, 24.256171], [52.583074, 22.931108], [55.105297, 22.620946], [55.186843, 22.703577], [55.807373, 24.340789], [56.383311, 24.978217], [56.279055, 25.627446]]]}, "properties": {"name": "United Arab Emirates", "ISO3166-1-Alpha-3": "ARE", "ISO3166-1-Alpha-2": "AE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[2.5218, 51.087541], [2.786734, 50.723365], [5.790685, 49.537753], [6.117487, 50.120456], [5.99491, 50.749927], [5.840501, 51.138921], [4.261068, 51.369379], [4.221491, 51.368001], [3.349415, 51.375223], [2.5218, 51.087541]]]}, "properties": {"name": "Belgium", "ISO3166-1-Alpha-3": "BEL", "ISO3166-1-Alpha-2": "BE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[39.985976, 43.38899], [41.500173, 42.64057], [41.773936, 41.821601], [41.520763, 41.514228], [42.957719, 41.437007], [43.440428, 41.106588], [45.0024, 41.290452], [46.192767, 41.610174], [46.430892, 41.890442], [44.85936, 42.75951], [43.800613, 42.746462], [42.75153, 43.176952], [41.550569, 43.226277], [40.651916, 43.538971], [39.985976, 43.38899]]]}, "properties": {"name": "Georgia", "ISO3166-1-Alpha-3": "GEO", "ISO3166-1-Alpha-2": "GE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[20.567147, 41.873182], [20.477747, 41.319598], [20.965262, 40.849394], [22.916978, 41.335773], [22.843701, 42.014465], [22.345023, 42.313439], [21.564066, 42.246289], [20.567147, 41.873182]]]}, "properties": {"name": "North Macedonia", "ISO3166-1-Alpha-3": "MKD", "ISO3166-1-Alpha-2": "MK"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[20.567147, 41.873182], [20.064956, 42.546758], [19.365082, 41.852362], [19.309825, 40.644355], [19.999848, 39.693508], [20.640218, 40.090112], [20.965262, 40.849394], [20.477747, 41.319598], [20.567147, 41.873182]]]}, "properties": {"name": "Albania", "ISO3166-1-Alpha-3": "ALB", "ISO3166-1-Alpha-2": "AL"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[45.0024, 41.290452], [45.97924, 40.223592], [45.861882, 39.804548], [46.597858, 39.225075], [46.514039, 38.882176], [47.846877, 39.685279], [48.058544, 38.948373], [48.874278, 38.434068], [48.967621, 39.183824], [49.577159, 40.216742], [49.58961, 40.62519], [48.578949, 41.845282], [47.871114, 41.208183], [47.268411, 41.302803], [46.430892, 41.890442], [46.192767, 41.610174], [45.0024, 41.290452]]], [[[46.135871, 38.863701], [45.767004, 39.354395], [44.774559, 39.702797], [44.806993, 39.639902], [45.438601, 39.004235], [46.135871, 38.863701]]]]}, "properties": {"name": "Azerbaijan", "ISO3166-1-Alpha-3": "AZE", "ISO3166-1-Alpha-2": "AZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[20.064956, 42.546758], [20.567147, 41.873182], [21.564066, 42.246289], [20.838552, 43.170467], [20.345352, 42.827439], [20.064956, 42.546758]]]}, "properties": {"name": "Kosovo", "ISO3166-1-Alpha-3": "XKX", "ISO3166-1-Alpha-2": "XK"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[43.440428, 41.106588], [42.957719, 41.437007], [41.520763, 41.514228], [40.142751, 40.922797], [37.504731, 41.042914], [35.104503, 42.021877], [33.324474, 42.019029], [32.263357, 41.72016], [31.248871, 41.105658], [29.231781, 41.240709], [28.637706, 40.36518], [26.735606, 40.403551], [26.152599, 39.942613], [26.152843, 39.459215], [27.013357, 38.871161], [27.263194, 36.963365], [27.986583, 37.035142], [29.096365, 36.665229], [29.322276, 36.246894], [30.440766, 36.239325], [30.686534, 36.891181], [32.026052, 36.543443], [32.670665, 36.046698], [33.5442, 36.144477], [34.770763, 36.816067], [35.788748, 36.320258], [35.911305, 35.91775], [36.587771, 36.324812], [38.190051, 36.905526], [39.765252, 36.742151], [40.708967, 37.100476], [42.357238, 37.109984], [42.77158, 37.374903], [44.766135, 37.14192], [44.219605, 37.875312], [44.275002, 38.843573], [44.061372, 39.400284], [44.806993, 39.639902], [44.774559, 39.702797], [43.594217, 40.345445], [43.440428, 41.106588]]], [[[28.016775, 41.972561], [27.273353, 42.091747], [26.333359, 41.713036], [26.636596, 41.378457], [26.043956, 40.738471], [27.177745, 40.632636], [27.524181, 40.989203], [28.981944, 41.001654], [28.113292, 41.611558], [28.016775, 41.972561]]]]}, "properties": {"name": "Turkey", "ISO3166-1-Alpha-3": "TUR", "ISO3166-1-Alpha-2": "TR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-1.794075, 43.386015], [-4.734934, 43.4171], [-5.424224, 43.561143], [-7.230092, 43.56863], [-8.059316, 43.709418], [-9.2176, 43.155341], [-8.750803, 41.96898], [-8.048574, 41.816389], [-6.656772, 41.933075], [-6.205947, 41.57028], [-6.818003, 41.054136], [-6.879705, 40.009187], [-7.313528, 39.457438], [-7.414418, 37.192816], [-6.499582, 36.959662], [-6.036773, 36.189521], [-5.358387, 36.141109], [-5.338773, 36.14112], [-4.433258, 36.710679], [-2.071156, 36.775336], [-1.670888, 37.363308], [-0.92516, 37.555894], [-0.329416, 38.470038], [0.23406, 38.75788], [-0.323354, 39.516221], [0.990408, 41.0397], [2.060313, 41.274604], [3.150165, 41.845351], [3.18097, 42.431484], [1.707006, 42.502781], [1.429297, 42.595386], [-0.038933, 42.685148], [-1.794075, 43.386015]]]}, "properties": {"name": "Spain", "ISO3166-1-Alpha-3": "ESP", "ISO3166-1-Alpha-2": "ES"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[102.118655, 22.397549], [101.518227, 22.228205], [101.159024, 21.552691], [100.725407, 21.311672], [100.099295, 20.317805], [100.543351, 20.06658], [100.46196, 19.537103], [101.192872, 19.452793], [101.030298, 18.427791], [101.132307, 17.461674], [102.078503, 18.213799], [102.595577, 17.850074], [103.260756, 18.400196], [104.000039, 18.318444], [104.816423, 17.372791], [104.754515, 16.528915], [105.650998, 15.634602], [105.415973, 14.428164], [105.184308, 14.34574], [106.792995, 14.322589], [107.520393, 14.704582], [107.657594, 15.282427], [107.16026, 15.758729], [107.116851, 16.254616], [104.721959, 18.792007], [103.85674, 19.317194], [104.805003, 19.790886], [104.600726, 20.660549], [104.056676, 20.958825], [103.115287, 20.868391], [102.947804, 21.737124], [102.118655, 22.397549]]]}, "properties": {"name": "Laos", "ISO3166-1-Alpha-3": "LAO", "ISO3166-1-Alpha-2": "LA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[80.210328, 42.189519], [78.496118, 42.875601], [75.178594, 42.84966], [74.25865, 43.215761], [73.41002, 42.58965], [71.847738, 42.834053], [70.947793, 42.248146], [70.169288, 41.578342], [71.276145, 41.113151], [71.585532, 41.323525], [72.619474, 40.88009], [72.370497, 40.38565], [70.958955, 40.238372], [69.313991, 39.986914], [69.286189, 39.539707], [71.459545, 39.612105], [72.31634, 39.328815], [73.632642, 39.448343], [74.003989, 40.060812], [74.835359, 40.511637], [75.681819, 40.291702], [76.449111, 40.415519], [76.860972, 41.013208], [78.074955, 41.039512], [78.359899, 41.377527], [80.210328, 42.189519]]]}, "properties": {"name": "Kyrgyzstan", "ISO3166-1-Alpha-3": "KGZ", "ISO3166-1-Alpha-2": "KG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[45.0024, 41.290452], [43.440428, 41.106588], [43.594217, 40.345445], [44.774559, 39.702797], [45.767004, 39.354395], [46.135871, 38.863701], [46.514039, 38.882176], [46.597858, 39.225075], [45.861882, 39.804548], [45.97924, 40.223592], [45.0024, 41.290452]]]}, "properties": {"name": "Armenia", "ISO3166-1-Alpha-3": "ARM", "ISO3166-1-Alpha-2": "AM"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[8.660776, 54.896311], [9.437503, 54.810411], [9.586762, 55.427436], [10.245372, 55.914618], [9.96697, 57.591376], [8.601085, 56.501125], [8.290212, 55.583808], [8.660776, 54.896311]]], [[[12.040375, 54.893012], [12.602224, 55.708564], [12.245128, 56.12873], [11.071056, 55.676947], [12.040375, 54.893012]]]]}, "properties": {"name": "Denmark", "ISO3166-1-Alpha-3": "DNK", "ISO3166-1-Alpha-2": "DK"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[11.505112, 33.181225], [11.444035, 32.36849], [10.873217, 32.136696], [10.108096, 31.411831], [10.270153, 30.915633], [9.519708, 30.228905], [9.826149, 29.128533], [9.93591, 27.866724], [9.721349, 27.291875], [9.835761, 26.504223], [9.401162, 26.113394], [9.9695, 25.395402], [10.032028, 24.856339], [11.567128, 24.26684], [11.968861, 23.517351], [13.482257, 23.179672], [14.23172, 22.617949], [14.979909, 22.995664], [15.985101, 23.44472], [19.164132, 21.874893], [21.634472, 20.654968], [23.981306, 19.496124], [23.981306, 19.995421], [24.968016, 19.994956], [24.981245, 21.995351], [24.981245, 25.205439], [24.981245, 29.181372], [24.688343, 30.144156], [25.15089, 31.65648], [24.9817, 31.967922], [24.090668, 32.005764], [23.095551, 32.323554], [23.086436, 32.64647], [21.608653, 32.930732], [20.562673, 32.557847], [19.920665, 31.717719], [20.146658, 31.216783], [19.748871, 30.510932], [19.056814, 30.266181], [17.365896, 31.086615], [16.044607, 31.275539], [15.490408, 31.664862], [15.195974, 32.389797], [13.351736, 32.904242], [12.348318, 32.832668], [11.505112, 33.181225]]]}, "properties": {"name": "Libya", "ISO3166-1-Alpha-3": "LBY", "ISO3166-1-Alpha-2": "LY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[11.505112, 33.181225], [11.108165, 33.549628], [10.330251, 33.702826], [10.12672, 34.325629], [11.111578, 35.205595], [10.608572, 35.855862], [10.799929, 36.451885], [10.146251, 37.236518], [9.66627, 37.335435], [8.60251, 36.939511], [8.24144, 35.827737], [8.236479, 34.647654], [7.479832, 33.893901], [7.75072, 33.207664], [9.045008, 32.071842], [9.519708, 30.228905], [10.270153, 30.915633], [10.108096, 31.411831], [10.873217, 32.136696], [11.444035, 32.36849], [11.505112, 33.181225]]]}, "properties": {"name": "Tunisia", "ISO3166-1-Alpha-3": "TUN", "ISO3166-1-Alpha-2": "TN"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[22.877601, 47.946739], [22.261721, 47.715848], [21.164527, 46.318259], [20.242826, 46.108091], [20.760727, 45.493348], [22.319702, 44.685336], [22.69164, 44.228435], [23.325151, 43.886592], [25.35962, 43.654287], [27.027476, 44.177046], [28.57838, 43.741278], [28.788829, 44.706244], [29.659028, 45.215888], [28.199498, 45.461774], [28.037027, 47.016459], [26.617889, 48.258968], [25.261744, 47.898576], [22.877601, 47.946739]]]}, "properties": {"name": "Romania", "ISO3166-1-Alpha-3": "ROU", "ISO3166-1-Alpha-2": "RO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[22.877601, 47.946739], [22.13284, 48.404798], [20.481674, 48.526083], [19.884295, 48.129621], [17.825713, 47.750006], [17.148338, 48.005443], [16.094035, 46.862774], [16.515302, 46.501711], [17.345328, 45.955697], [18.411723, 45.743204], [18.901306, 45.931203], [20.242826, 46.108091], [21.164527, 46.318259], [22.261721, 47.715848], [22.877601, 47.946739]]]}, "properties": {"name": "Hungary", "ISO3166-1-Alpha-3": "HUN", "ISO3166-1-Alpha-2": "HU"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[22.539637, 49.0722], [21.819577, 49.377246], [18.833196, 49.510261], [16.945043, 48.604166], [17.148338, 48.005443], [17.825713, 47.750006], [19.884295, 48.129621], [20.481674, 48.526083], [22.13284, 48.404798], [22.539637, 49.0722]]]}, "properties": {"name": "Slovakia", "ISO3166-1-Alpha-3": "SVK", "ISO3166-1-Alpha-2": "SK"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[18.833196, 49.510261], [21.819577, 49.377246], [22.539637, 49.0722], [22.640922, 49.528761], [24.106466, 50.538622], [23.606238, 51.517399], [23.893663, 53.151951], [23.485625, 53.939293], [22.76722, 54.35627], [19.609548, 54.456732], [18.588064, 54.433661], [17.885427, 54.824123], [16.220063, 54.276597], [14.210053, 53.938463], [14.200819, 53.878157], [14.263901, 53.699976], [14.21601, 52.817992], [14.644821, 52.576921], [14.810393, 50.858447], [16.331644, 50.644042], [18.833196, 49.510261]]]}, "properties": {"name": "Poland", "ISO3166-1-Alpha-3": "POL", "ISO3166-1-Alpha-2": "PL"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-7.2471, 55.069322], [-8.26301, 55.161078], [-8.485951, 54.288642], [-9.785512, 54.33808], [-10.025746, 53.385403], [-9.272206, 53.146674], [-10.234609, 51.85102], [-9.770334, 51.592231], [-8.539174, 51.648383], [-7.435455, 52.125678], [-6.369862, 52.179999], [-5.996571, 52.964911], [-6.269887, 54.097927], [-7.310324, 54.114683], [-8.156035, 54.439055], [-7.2471, 55.069322]]]}, "properties": {"name": "Ireland", "ISO3166-1-Alpha-3": "IRL", "ISO3166-1-Alpha-2": "IE"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-7.2471, 55.069322], [-8.156035, 54.439055], [-7.310324, 54.114683], [-6.269887, 54.097927], [-5.53661, 54.657457], [-6.475942, 55.248684], [-7.2471, 55.069322]]], [[[-2.665354, 51.617255], [-3.057525, 51.208157], [-4.152333, 51.211615], [-4.79247, 50.595201], [-3.645619, 50.22602], [-2.864817, 50.733791], [0.271007, 50.747382], [1.357188, 51.131008], [0.92449, 51.588324], [1.771169, 52.485907], [1.274669, 52.929185], [0.329845, 53.086493], [-0.562978, 54.477362], [-1.275461, 54.74844], [-1.630849, 55.58511], [-2.964345, 56.205634], [-1.759348, 57.473578], [-3.491851, 57.713121], [-3.204091, 58.661119], [-4.988189, 58.628323], [-5.599436, 57.279242], [-5.581207, 55.757636], [-4.617787, 55.495592], [-5.170806, 55.008694], [-3.964182, 54.771918], [-3.017568, 53.931952], [-3.058258, 53.43476], [-4.151519, 53.228095], [-4.197133, 52.279283], [-5.28893, 51.916938], [-3.539174, 51.398505], [-2.665354, 51.617255]]]]}, "properties": {"name": "United Kingdom", "ISO3166-1-Alpha-3": "GBR", "ISO3166-1-Alpha-2": "GB"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[20.965262, 40.849394], [20.640218, 40.090112], [19.999848, 39.693508], [20.686534, 39.072455], [21.549978, 37.561469], [21.707367, 36.81802], [23.095551, 36.807034], [23.16863, 37.614163], [23.95102, 38.29092], [23.204926, 38.682685], [23.101817, 39.497382], [22.5713, 40.054267], [23.868175, 40.41352], [24.415538, 40.945543], [26.043956, 40.738471], [26.636596, 41.378457], [26.333359, 41.713036], [25.285722, 41.239396], [24.530936, 41.547543], [22.916978, 41.335773], [20.965262, 40.849394]]], [[[26.321137, 35.315253], [25.830333, 35.122382], [24.720958, 35.427232], [24.738536, 34.931627], [26.240977, 35.039049], [26.321137, 35.315253]]]]}, "properties": {"name": "Greece", "ISO3166-1-Alpha-3": "GRC", "ISO3166-1-Alpha-2": "GR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[32.920863, -9.4079], [30.959536, -8.550485], [30.752107, -8.194124], [28.915268, -8.472867], [28.372304, -9.235094], [28.668462, -9.821622], [28.439587, -11.348247], [28.497154, -11.857363], [29.030352, -12.376194], [29.799297, -12.154089], [29.574401, -13.225393], [29.168948, -13.433856], [28.42274, -12.521302], [27.638189, -12.293615], [27.420734, -11.921959], [25.994051, -11.904802], [25.351971, -11.64611], [25.278798, -11.199935], [24.310071, -11.406641], [23.967457, -10.872307], [24.000633, -13.001479], [21.979878, -13.001479], [21.983805, -16.165886], [22.15165, -16.597694], [23.381652, -17.641144], [24.220464, -17.4795], [25.259781, -17.794107], [27.048457, -17.944278], [27.777301, -17.001183], [28.822509, -16.470776], [29.186311, -15.812832], [30.396263, -15.635995], [30.214465, -14.981462], [33.202707, -14.013872], [32.722839, -13.573382], [33.024836, -12.612666], [33.373239, -12.518511], [33.230302, -11.416563], [33.674203, -10.577028], [32.920863, -9.4079]]]}, "properties": {"name": "Zambia", "ISO3166-1-Alpha-3": "ZMB", "ISO3166-1-Alpha-2": "ZM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-10.282236, 8.484625], [-10.638855, 8.519895], [-10.675132, 9.306461], [-11.272666, 9.996006], [-12.508327, 9.860381], [-12.701597, 9.420227], [-13.301096, 9.04149], [-12.875885, 7.828681], [-12.506581, 7.389838], [-11.476186, 6.91942], [-10.615187, 7.769036], [-10.282236, 8.484625]]]}, "properties": {"name": "Sierra Leone", "ISO3166-1-Alpha-3": "SLE", "ISO3166-1-Alpha-2": "SL"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-13.301096, 9.04149], [-12.701597, 9.420227], [-12.508327, 9.860381], [-11.272666, 9.996006], [-10.675132, 9.306461], [-10.638855, 8.519895], [-10.282236, 8.484625], [-9.683513, 8.487054], [-9.438101, 7.421564], [-8.925419, 7.248603], [-8.485446, 7.557989], [-8.228976, 7.544295], [-7.691593, 8.606142], [-7.958915, 8.781997], [-8.173837, 9.941875], [-7.989663, 10.161991], [-8.311142, 11.000803], [-8.847233, 11.657946], [-8.993839, 12.387514], [-9.722994, 12.025417], [-10.26694, 12.217808], [-10.711357, 11.890386], [-11.388422, 12.403895], [-12.36092, 12.305607], [-13.076458, 12.635922], [-13.728279, 12.673388], [-13.729932, 11.709829], [-14.686773, 11.51072], [-15.020985, 10.967475], [-14.463043, 10.330024], [-13.624379, 9.720282], [-13.301096, 9.04149]]]}, "properties": {"name": "Guinea", "ISO3166-1-Alpha-3": "GIN", "ISO3166-1-Alpha-2": "GN"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-11.476186, 6.91942], [-10.395619, 6.176988], [-9.278717, 5.145738], [-8.257314, 4.579983], [-7.540666, 4.352845], [-7.446543, 5.845949], [-8.566113, 6.550919], [-8.284115, 7.017867], [-8.485446, 7.557989], [-8.925419, 7.248603], [-9.438101, 7.421564], [-9.683513, 8.487054], [-10.282236, 8.484625], [-10.615187, 7.769036], [-11.476186, 6.91942]]]}, "properties": {"name": "Liberia", "ISO3166-1-Alpha-3": "LBR", "ISO3166-1-Alpha-2": "LR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[22.861064, 10.919154], [22.460468, 11.000828], [21.722632, 10.636716], [21.65628, 10.233666], [20.359408, 9.116421], [19.10057, 9.015265], [19.124135, 8.675079], [18.589283, 8.047882], [17.679778, 7.985198], [16.768619, 7.550238], [15.481049, 7.523263], [14.719029, 6.257862], [14.523899, 5.279679], [15.171301, 3.758971], [16.092382, 2.863289], [16.196665, 2.236454], [16.567701, 3.464389], [17.334373, 3.618514], [18.626387, 3.476869], [18.721058, 4.377357], [19.08331, 4.90934], [19.71955, 5.135967], [20.603114, 4.409732], [22.492714, 4.174036], [22.898374, 4.823583], [23.38837, 4.587266], [24.459623, 5.107441], [25.307633, 5.032278], [25.58126, 5.374919], [26.462653, 5.059641], [27.441301, 5.070725], [27.170413, 5.72035], [26.527972, 6.043172], [26.378007, 6.65329], [25.360033, 7.335574], [24.832107, 8.16573], [24.170328, 8.689327], [23.482318, 8.783393], [23.624015, 9.907768], [22.861064, 10.919154]]]}, "properties": {"name": "Central African Republic", "ISO3166-1-Alpha-3": "CAF", "ISO3166-1-Alpha-2": "CF"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[22.861064, 10.919154], [23.624015, 9.907768], [23.482318, 8.783393], [24.170328, 8.689327], [24.558377, 8.886746], [25.084081, 10.293223], [25.843053, 10.417815], [26.556859, 9.520454], [27.895072, 9.59541], [28.843841, 9.324545], [30.012979, 10.270485], [30.749265, 9.735763], [31.234817, 9.792323], [32.414073, 11.050851], [32.34524, 11.709106], [33.082921, 11.584565], [33.182088, 10.843241], [33.902096, 10.192041], [34.070698, 9.454592], [34.279489, 10.565506], [34.947148, 11.274868], [35.616875, 12.575151], [36.123614, 12.721447], [36.52638, 14.263523], [36.423647, 15.111508], [36.944029, 16.252859], [37.000459, 17.072602], [38.343737, 17.655926], [38.601573, 18.004828], [37.434337, 18.861721], [37.238292, 19.65526], [37.148611, 21.170966], [36.883637, 21.995714], [34.084179, 21.995454], [33.866456, 21.749724], [33.558442, 21.710957], [33.181136, 21.995409], [31.248407, 21.994369], [28.290345, 21.994783], [24.981245, 21.995351], [24.968016, 19.994956], [23.981306, 19.995421], [23.981306, 19.496124], [23.984406, 15.72116], [23.094642, 15.704262], [22.073722, 13.771357], [22.204877, 12.743358], [22.592863, 11.988933], [22.861064, 10.919154]]]}, "properties": {"name": "Sudan", "ISO3166-1-Alpha-3": "SDN", "ISO3166-1-Alpha-2": "SD"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[43.240733, 11.48786], [43.411388, 12.241767], [43.117686, 12.707913], [42.379459, 12.465907], [41.74911, 11.537953], [41.79872, 10.970675], [42.923715, 10.998787], [43.240733, 11.48786]]]}, "properties": {"name": "Djibouti", "ISO3166-1-Alpha-3": "DJI", "ISO3166-1-Alpha-2": "DJ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[43.117686, 12.707913], [42.288422, 13.574612], [41.676524, 13.940253], [41.156098, 14.641588], [39.718272, 15.263902], [39.235606, 16.10871], [38.929047, 17.397121], [38.601573, 18.004828], [38.343737, 17.655926], [37.000459, 17.072602], [36.944029, 16.252859], [36.423647, 15.111508], [36.52638, 14.263523], [37.564973, 14.116685], [37.891464, 14.879532], [38.426832, 14.417183], [40.104559, 14.465966], [40.833197, 14.105962], [42.379459, 12.465907], [43.117686, 12.707913]]]}, "properties": {"name": "Eritrea", "ISO3166-1-Alpha-3": "ERI", "ISO3166-1-Alpha-2": "ER"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[16.945043, 48.604166], [14.982062, 49.007914], [14.695671, 48.589542], [13.815725, 48.76643], [12.745041, 48.12063], [12.744834, 47.66536], [10.305913, 47.302178], [9.547482, 47.534547], [9.521155, 47.262801], [9.581203, 47.05687], [10.453811, 46.864427], [12.111178, 46.992998], [12.40501, 46.690123], [13.700951, 46.519746], [14.502194, 46.418356], [16.094035, 46.862774], [17.148338, 48.005443], [16.945043, 48.604166]]]}, "properties": {"name": "Austria", "ISO3166-1-Alpha-3": "AUT", "ISO3166-1-Alpha-2": "AT"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[44.766135, 37.14192], [42.77158, 37.374903], [42.357238, 37.109984], [41.414867, 36.527384], [41.195656, 34.768473], [40.690467, 34.331497], [38.774511, 33.371685], [39.146375, 32.118144], [40.424126, 31.920533], [42.075395, 31.079861], [44.691825, 29.201836], [46.532436, 29.095745], [47.110488, 29.960911], [47.948009, 29.994045], [48.531016, 29.96133], [47.672935, 30.994698], [47.831323, 31.761835], [47.395794, 32.33702], [46.273433, 32.959488], [45.658794, 33.681692], [45.500664, 34.591688], [46.165481, 35.189946], [45.814185, 35.80934], [44.991393, 36.53374], [44.766135, 37.14192]]]}, "properties": {"name": "Iraq", "ISO3166-1-Alpha-3": "IRQ", "ISO3166-1-Alpha-2": "IQ"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[7.022083, 45.92526], [7.033451, 44.242934], [7.502289, 43.792222], [8.76238, 44.432115], [10.105805, 44.01675], [10.499522, 42.940497], [13.044607, 41.227525], [13.721853, 41.252387], [14.752208, 40.676703], [14.911143, 40.241645], [15.610199, 40.073188], [16.220063, 38.899075], [15.650564, 38.241034], [16.090017, 37.949205], [17.127696, 38.928778], [17.158702, 39.406195], [16.515636, 39.689602], [17.049571, 40.51911], [18.007009, 40.650702], [16.021658, 41.427802], [16.02711, 41.944078], [15.123383, 41.934272], [14.075369, 42.598822], [13.616954, 43.531317], [12.368826, 44.250678], [12.487804, 45.456732], [13.711762, 45.593207], [13.700951, 46.519746], [12.40501, 46.690123], [12.111178, 46.992998], [10.453811, 46.864427], [10.133417, 46.414016], [8.427165, 46.251442], [7.831232, 45.91446], [7.022083, 45.92526]]], [[[15.528656, 38.302639], [13.790782, 37.973619], [13.310232, 38.21955], [12.427013, 37.797024], [14.487315, 36.793362], [15.110688, 37.321845], [15.528656, 38.302639]]], [[[9.792247, 40.556952], [9.546153, 41.120917], [8.1338, 40.728909], [8.555186, 39.852688], [8.371755, 39.230618], [9.557791, 39.139716], [9.792247, 40.556952]]]]}, "properties": {"name": "Italy", "ISO3166-1-Alpha-3": "ITA", "ISO3166-1-Alpha-2": "IT"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[10.453811, 46.864427], [9.581203, 47.05687], [9.521155, 47.262801], [9.547482, 47.534547], [8.558216, 47.801166], [7.586028, 47.584619], [6.064157, 46.471118], [6.762667, 46.42926], [7.022083, 45.92526], [7.831232, 45.91446], [8.427165, 46.251442], [10.133417, 46.414016], [10.453811, 46.864427]]]}, "properties": {"name": "Switzerland", "ISO3166-1-Alpha-3": "CHE", "ISO3166-1-Alpha-2": "CH"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[44.806993, 39.639902], [44.061372, 39.400284], [44.275002, 38.843573], [44.219605, 37.875312], [44.766135, 37.14192], [44.991393, 36.53374], [45.814185, 35.80934], [46.165481, 35.189946], [45.500664, 34.591688], [45.658794, 33.681692], [46.273433, 32.959488], [47.395794, 32.33702], [47.831323, 31.761835], [47.672935, 30.994698], [48.531016, 29.96133], [50.144867, 29.938056], [50.655284, 29.449123], [51.380271, 27.991418], [52.011241, 27.832709], [53.716075, 26.708564], [54.801524, 26.498236], [56.353038, 27.201606], [56.973318, 26.968817], [57.326182, 25.777004], [60.362559, 25.330552], [61.588227, 25.202094], [61.857133, 26.242379], [62.753564, 26.644163], [62.735684, 27.994985], [61.653012, 28.756334], [60.844379, 29.858179], [61.7852, 30.831401], [61.661176, 31.38191], [60.855024, 31.482731], [60.486778, 34.094277], [61.06545, 34.814724], [61.269676, 35.618499], [61.075476, 36.64779], [60.34229, 36.637145], [58.861037, 37.668089], [57.352395, 37.967529], [57.038409, 38.187283], [55.423108, 38.075894], [54.783663, 37.517453], [53.913748, 37.34276], [53.492361, 36.887885], [51.909028, 36.582709], [51.088634, 36.737006], [50.148774, 37.403876], [49.014496, 37.739163], [48.874278, 38.434068], [48.058544, 38.948373], [47.846877, 39.685279], [46.514039, 38.882176], [46.135871, 38.863701], [45.438601, 39.004235], [44.806993, 39.639902]]]}, "properties": {"name": "Iran", "ISO3166-1-Alpha-3": "IRN", "ISO3166-1-Alpha-2": "IR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[7.194591, 53.245022], [5.980805, 53.406073], [4.745453, 52.967597], [4.141856, 52.005601], [4.261068, 51.369379], [5.840501, 51.138921], [5.99491, 50.749927], [6.193348, 51.509338], [7.048231, 52.365074], [7.194591, 53.245022]]]}, "properties": {"name": "Netherlands", "ISO3166-1-Alpha-3": "NLD", "ISO3166-1-Alpha-2": "NL"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Liechtenstein", "ISO3166-1-Alpha-3": "LIE", "ISO3166-1-Alpha-2": "LI"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-7.989663, 10.161991], [-8.173837, 9.941875], [-7.958915, 8.781997], [-7.691593, 8.606142], [-8.228976, 7.544295], [-8.485446, 7.557989], [-8.284115, 7.017867], [-8.566113, 6.550919], [-7.446543, 5.845949], [-7.540666, 4.352845], [-5.851308, 5.029975], [-4.125478, 5.30744], [-2.843699, 5.149115], [-3.262509, 6.617142], [-2.840003, 7.820247], [-2.506328, 8.209267], [-2.689211, 9.488724], [-3.661658, 9.948929], [-4.270329, 9.743928], [-4.966075, 9.901025], [-5.522578, 10.425489], [-6.95939, 10.185503], [-7.708646, 10.402467], [-7.989663, 10.161991]]]}, "properties": {"name": "Ivory Coast", "ISO3166-1-Alpha-3": "CIV", "ISO3166-1-Alpha-2": "CI"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[20.242826, 46.108091], [18.901306, 45.931203], [19.015821, 44.865635], [19.618885, 44.035711], [19.195345, 43.532796], [20.345352, 42.827439], [20.838552, 43.170467], [21.564066, 42.246289], [22.345023, 42.313439], [22.935271, 43.085562], [22.349467, 43.807921], [22.69164, 44.228435], [22.319702, 44.685336], [20.760727, 45.493348], [20.242826, 46.108091]]]}, "properties": {"name": "Republic of Serbia", "ISO3166-1-Alpha-3": "SRB", "ISO3166-1-Alpha-2": "RS"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-12.26413, 14.774939], [-11.996808, 13.544161], [-11.450278, 13.075095], [-11.388422, 12.403895], [-10.711357, 11.890386], [-10.26694, 12.217808], [-9.722994, 12.025417], [-8.993839, 12.387514], [-8.847233, 11.657946], [-8.311142, 11.000803], [-7.989663, 10.161991], [-7.708646, 10.402467], [-6.95939, 10.185503], [-5.522578, 10.425489], [-5.167819, 11.943665], [-4.406135, 12.307467], [-3.984042, 13.39647], [-3.248634, 13.292781], [-2.840855, 14.042994], [-2.023412, 14.198643], [-0.752895, 15.069727], [0.218467, 14.910977], [0.973718, 14.991257], [1.331526, 15.283616], [3.507103, 15.353973], [4.183961, 16.416053], [4.22861, 19.142244], [3.308356, 18.981685], [3.216785, 19.794064], [1.778113, 20.304291], [1.146524, 21.101711], [-2.523226, 23.500996], [-4.821613, 24.995065], [-6.593107, 24.994134], [-6.219383, 21.822855], [-5.963533, 19.620612], [-5.623347, 16.527829], [-5.353286, 16.311977], [-5.510951, 15.495903], [-9.349218, 15.495644], [-9.835596, 15.371104], [-11.727057, 15.541171], [-11.837076, 14.893097], [-12.26413, 14.774939]]]}, "properties": {"name": "Mali", "ISO3166-1-Alpha-3": "MLI", "ISO3166-1-Alpha-2": "ML"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-12.26413, 14.774939], [-14.343254, 16.63666], [-16.327397, 16.474551], [-16.542348, 15.808824], [-17.178212, 14.653144], [-16.561399, 13.586914], [-16.753651, 13.065009], [-16.728437, 12.332531], [-15.677049, 12.439294], [-15.195114, 12.679434], [-13.728279, 12.673388], [-13.076458, 12.635922], [-12.36092, 12.305607], [-11.388422, 12.403895], [-11.450278, 13.075095], [-11.996808, 13.544161], [-12.26413, 14.774939]]]}, "properties": {"name": "Senegal", "ISO3166-1-Alpha-3": "SEN", "ISO3166-1-Alpha-2": "SN"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[3.5964, 11.695773], [3.837419, 10.599897], [3.513098, 9.846609], [2.769164, 9.057045], [2.672219, 7.889597], [2.703841, 6.368352], [4.405528, 6.358873], [5.007335, 5.849189], [5.5949, 4.636461], [6.09669, 4.278144], [7.169688, 4.605943], [8.29835, 4.55329], [8.594168, 4.815294], [8.855872, 5.847499], [10.119465, 6.994406], [10.602536, 7.058072], [11.509767, 6.612311], [12.197787, 7.975173], [12.249981, 8.418764], [12.805709, 8.831503], [12.862036, 9.382891], [13.98233, 11.276264], [14.593765, 11.496431], [14.669936, 12.167424], [14.179837, 12.385602], [14.064908, 13.077988], [13.607314, 13.7041], [12.883844, 13.495999], [12.472706, 13.064165], [11.440004, 13.364431], [10.674986, 13.375102], [9.640734, 12.822526], [8.679346, 12.923579], [7.755578, 13.327146], [6.906637, 13.006856], [6.368944, 13.626275], [5.554317, 13.873418], [4.506732, 13.694669], [3.6457, 12.528539], [3.5964, 11.695773]]]}, "properties": {"name": "Nigeria", "ISO3166-1-Alpha-3": "NGA", "ISO3166-1-Alpha-2": "NG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[3.5964, 11.695773], [2.844301, 12.399244], [2.390169, 11.896536], [2.010761, 11.426901], [1.433845, 11.459405], [0.901474, 10.992741], [0.768769, 10.367094], [1.331009, 9.996471], [1.601173, 9.049526], [1.61964, 6.213894], [2.703841, 6.368352], [2.672219, 7.889597], [2.769164, 9.057045], [3.513098, 9.846609], [3.837419, 10.599897], [3.5964, 11.695773]]]}, "properties": {"name": "Benin", "ISO3166-1-Alpha-3": "BEN", "ISO3166-1-Alpha-2": "BJ"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[13.073703, -4.635323], [12.761681, -4.391204], [12.009608, -5.019631], [12.210541, -5.763442], [13.073703, -4.635323]]], [[[23.967457, -10.872307], [23.014336, -11.102474], [22.165499, -10.85236], [22.313397, -10.368565], [21.854097, -9.61781], [21.935953, -8.413025], [21.784954, -7.283379], [20.520535, -7.286376], [20.294296, -7.001949], [19.521836, -7.001949], [19.355542, -8.001991], [17.536531, -8.015117], [16.996099, -7.297951], [16.597364, -5.924702], [16.315727, -5.854629], [13.183849, -5.856459], [12.279447, -6.147705], [12.815196, -6.946954], [13.369395, -8.3235], [12.996349, -9.093845], [13.75294, -10.646173], [13.846528, -11.113702], [13.639822, -12.248712], [12.512706, -13.442071], [12.272716, -14.750584], [11.736618, -15.900203], [11.766124, -17.252699], [12.554561, -17.235588], [13.363711, -16.964183], [13.942745, -17.408187], [16.339808, -17.388653], [18.453581, -17.389893], [18.761986, -17.747701], [20.806202, -18.031405], [23.381652, -17.641144], [22.15165, -16.597694], [21.983805, -16.165886], [21.979878, -13.001479], [24.000633, -13.001479], [23.967457, -10.872307]]]]}, "properties": {"name": "Angola", "ISO3166-1-Alpha-3": "AGO", "ISO3166-1-Alpha-2": "AO"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[16.515302, 46.501711], [15.661297, 46.21532], [15.666051, 45.831674], [14.668489, 45.533966], [13.589529, 45.488837], [14.827403, 45.113959], [15.144542, 44.195461], [16.098481, 43.480048], [16.880626, 43.405951], [17.580658, 42.942084], [15.717314, 44.786466], [15.780049, 45.160294], [16.93264, 45.278788], [19.015821, 44.865635], [18.901306, 45.931203], [18.411723, 45.743204], [17.345328, 45.955697], [16.515302, 46.501711]]], [[[18.437355, 42.559212], [17.653331, 42.890937], [18.49643, 42.416327], [18.437355, 42.559212]]]]}, "properties": {"name": "Croatia", "ISO3166-1-Alpha-3": "HRV", "ISO3166-1-Alpha-2": "HR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[13.589529, 45.488837], [14.668489, 45.533966], [15.666051, 45.831674], [15.661297, 46.21532], [16.515302, 46.501711], [16.094035, 46.862774], [14.502194, 46.418356], [13.700951, 46.519746], [13.711762, 45.593207], [13.589529, 45.488837]]]}, "properties": {"name": "Slovenia", "ISO3166-1-Alpha-3": "SVN", "ISO3166-1-Alpha-2": "SI"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[50.807872, 24.746649], [51.215258, 24.62585], [51.616547, 25.137193], [51.577485, 25.880845], [51.047374, 26.053046], [50.750987, 25.419623], [50.807872, 24.746649]]]}, "properties": {"name": "Qatar", "ISO3166-1-Alpha-3": "QAT", "ISO3166-1-Alpha-2": "QA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[50.807872, 24.746649], [50.101329, 25.989488], [49.699474, 26.958238], [48.606456, 28.126451], [48.432781, 28.54048], [47.668129, 28.533505], [47.434086, 28.994588], [46.532436, 29.095745], [44.691825, 29.201836], [42.075395, 31.079861], [40.424126, 31.920533], [39.146375, 32.118144], [36.959532, 31.490999], [37.981071, 30.499483], [37.470198, 29.994553], [36.756237, 29.865517], [36.016437, 29.189951], [34.949385, 29.351686], [34.62672, 28.160549], [35.051931, 28.119574], [37.230479, 25.193671], [37.456669, 24.441249], [38.600175, 23.568249], [39.142751, 22.390611], [39.08074, 21.315009], [39.646495, 20.461575], [40.522634, 19.97248], [41.719249, 17.909247], [42.301524, 17.453599], [42.78948, 16.370958], [43.208608, 16.773396], [43.165044, 17.32592], [45.165387, 17.428395], [46.970342, 16.956847], [47.427575, 17.091826], [48.161949, 18.148919], [49.128815, 18.612095], [51.978615, 18.995638], [54.97838, 19.995421], [55.637565, 21.97897], [55.186843, 22.703577], [55.105297, 22.620946], [52.583074, 22.931108], [51.569347, 24.256171], [51.215258, 24.62585], [50.807872, 24.746649]]]}, "properties": {"name": "Saudi Arabia", "ISO3166-1-Alpha-3": "SAU", "ISO3166-1-Alpha-2": "SA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[25.259781, -17.794107], [24.183051, -18.029441], [23.645409, -18.466004], [23.311476, -18.009804], [20.975081, -18.319346], [20.97198, -22.000671], [19.978346, -22.000671], [19.981447, -24.752493], [20.364886, -25.0332], [20.841446, -26.131324], [20.608902, -26.686122], [21.687182, -26.855207], [22.719367, -25.984253], [23.006998, -25.310805], [24.79862, -25.829223], [25.587254, -25.61952], [25.868374, -24.748152], [26.84971, -24.248131], [27.00417, -23.645842], [28.338559, -22.584615], [29.350074, -22.186707], [29.038723, -21.797893], [27.990415, -21.551913], [27.698133, -20.509083], [26.13027, -19.501082], [25.940721, -18.921273], [25.259781, -17.794107]]]}, "properties": {"name": "Botswana", "ISO3166-1-Alpha-3": "BWA", "ISO3166-1-Alpha-2": "BW"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[25.259781, -17.794107], [25.940721, -18.921273], [26.13027, -19.501082], [27.698133, -20.509083], [27.990415, -21.551913], [29.038723, -21.797893], [29.350074, -22.186707], [31.288922, -22.39734], [32.408543, -21.290327], [32.469108, -20.68685], [33.032795, -19.784166], [32.768831, -19.363623], [32.996414, -18.46714], [32.893372, -16.712415], [31.910279, -16.428919], [31.259983, -16.023465], [30.402568, -16.001244], [30.396263, -15.635995], [29.186311, -15.812832], [28.822509, -16.470776], [27.777301, -17.001183], [27.048457, -17.944278], [25.259781, -17.794107]]]}, "properties": {"name": "Zimbabwe", "ISO3166-1-Alpha-3": "ZWE", "ISO3166-1-Alpha-2": "ZW"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[76.777351, 35.646112], [76.166027, 35.806239], [75.976582, 36.462633], [75.351297, 36.915784], [74.542354, 37.021669], [74.094319, 36.831241], [72.565214, 36.820596], [71.629147, 36.459533], [71.170932, 36.027001], [71.633694, 35.203124], [71.080601, 34.672923], [71.046702, 34.041877], [70.002838, 34.043789], [70.294448, 33.318949], [69.547517, 33.075011], [69.004036, 31.651092], [68.125743, 31.811496], [67.346204, 31.20776], [66.366264, 30.922868], [66.195628, 29.835338], [64.086093, 29.386605], [62.477509, 29.407819], [60.844379, 29.858179], [61.653012, 28.756334], [62.735684, 27.994985], [62.753564, 26.644163], [61.857133, 26.242379], [61.588227, 25.202094], [64.662771, 25.229071], [66.437022, 25.597724], [66.679698, 24.830552], [67.176036, 24.757717], [67.631602, 23.803453], [68.183035, 23.842108], [68.725913, 24.289216], [69.972039, 24.165219], [71.063858, 24.682577], [70.646623, 25.431369], [70.064643, 25.980327], [70.158074, 26.530113], [69.465093, 26.80777], [70.341939, 28.01147], [70.831573, 27.701463], [71.860864, 27.950207], [72.382176, 28.784006], [72.901524, 29.022622], [73.370332, 29.927322], [74.329757, 30.899614], [74.489437, 31.711192], [75.023668, 32.466262], [74.002335, 33.177692], [73.998304, 34.196803], [74.285832, 34.768887], [75.777111, 34.503812], [77.048971, 35.110442], [76.777351, 35.646112]]]}, "properties": {"name": "Pakistan", "ISO3166-1-Alpha-3": "PAK", "ISO3166-1-Alpha-2": "PK"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[26.333359, 41.713036], [27.273353, 42.091747], [28.016775, 41.972561], [28.091563, 43.363959], [28.57838, 43.741278], [27.027476, 44.177046], [25.35962, 43.654287], [23.325151, 43.886592], [22.69164, 44.228435], [22.349467, 43.807921], [22.935271, 43.085562], [22.345023, 42.313439], [22.843701, 42.014465], [22.916978, 41.335773], [24.530936, 41.547543], [25.285722, 41.239396], [26.333359, 41.713036]]]}, "properties": {"name": "Bulgaria", "ISO3166-1-Alpha-3": "BGR", "ISO3166-1-Alpha-2": "BG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[102.913585, 11.645901], [102.328203, 13.27516], [103.085005, 14.295821], [104.771672, 14.439869], [105.184308, 14.34574], [105.415973, 14.428164], [105.650998, 15.634602], [104.754515, 16.528915], [104.816423, 17.372791], [104.000039, 18.318444], [103.260756, 18.400196], [102.595577, 17.850074], [102.078503, 18.213799], [101.132307, 17.461674], [101.030298, 18.427791], [101.192872, 19.452793], [100.46196, 19.537103], [100.543351, 20.06658], [100.099295, 20.317805], [99.008768, 19.845922], [97.839847, 19.555319], [97.768947, 17.67918], [98.631633, 16.463131], [98.559906, 15.355317], [98.164995, 15.125796], [98.54771, 14.377676], [99.152531, 13.714875], [99.21413, 12.73465], [99.630022, 11.815766], [98.766715, 10.688754], [98.747406, 10.350531], [98.326915, 9.203681], [98.598888, 8.374905], [99.692882, 7.116116], [100.127289, 6.442288], [101.081664, 6.246467], [101.105435, 5.637642], [102.07309, 6.257514], [101.566254, 6.832221], [100.997573, 6.856675], [100.395518, 7.21133], [100.22047, 8.448717], [99.853526, 9.294664], [99.181407, 9.643256], [99.151622, 10.357367], [100.02003, 12.192694], [99.960948, 12.625393], [100.289887, 13.505032], [100.950694, 13.468248], [100.837413, 12.707913], [101.830251, 12.672797], [102.913585, 11.645901]]]}, "properties": {"name": "Thailand", "ISO3166-1-Alpha-3": "THA", "ISO3166-1-Alpha-2": "TH"}}, {"type": "Feature", "geometry": null, "properties": {"name": "San Marino", "ISO3166-1-Alpha-3": "SMR", "ISO3166-1-Alpha-2": "SM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-71.757436, 19.71011], [-72.776031, 19.943915], [-72.813547, 19.052924], [-72.54955, 18.785631], [-72.885894, 18.141425], [-71.776235, 18.039252], [-71.912169, 18.430737], [-71.639111, 19.21211], [-71.757436, 19.71011]]]}, "properties": {"name": "Haiti", "ISO3166-1-Alpha-3": "HTI", "ISO3166-1-Alpha-2": "HT"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-71.757436, 19.71011], [-71.639111, 19.21211], [-71.912169, 18.430737], [-71.776235, 18.039252], [-70.15807, 18.242621], [-69.883656, 18.471015], [-68.849599, 18.374823], [-68.739735, 18.964423], [-69.608469, 19.093451], [-69.897939, 19.635972], [-71.000885, 19.93769], [-71.757436, 19.71011]]]}, "properties": {"name": "Dominican Republic", "ISO3166-1-Alpha-3": "DOM", "ISO3166-1-Alpha-2": "DO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[14.064908, 13.077988], [14.830753, 12.618249], [15.135644, 11.530822], [15.065881, 10.793115], [15.536137, 10.080523], [14.181697, 9.978178], [13.947603, 9.637759], [15.183703, 8.479148], [15.481049, 7.523263], [16.768619, 7.550238], [17.679778, 7.985198], [18.589283, 8.047882], [19.124135, 8.675079], [19.10057, 9.015265], [20.359408, 9.116421], [21.65628, 10.233666], [21.722632, 10.636716], [22.460468, 11.000828], [22.861064, 10.919154], [22.592863, 11.988933], [22.204877, 12.743358], [22.073722, 13.771357], [23.094642, 15.704262], [23.984406, 15.72116], [23.981306, 19.496124], [21.634472, 20.654968], [19.164132, 21.874893], [15.985101, 23.44472], [14.979909, 22.995664], [15.180396, 21.507267], [15.953992, 20.374571], [15.736021, 19.903541], [15.452421, 16.876232], [14.368973, 15.749634], [13.449184, 14.380131], [13.607314, 13.7041], [14.064908, 13.077988]]]}, "properties": {"name": "Chad", "ISO3166-1-Alpha-3": "TCD", "ISO3166-1-Alpha-2": "TD"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[47.948009, 29.994045], [47.110488, 29.960911], [46.532436, 29.095745], [47.434086, 28.994588], [47.668129, 28.533505], [48.432781, 28.54048], [47.948009, 29.994045]]]}, "properties": {"name": "Kuwait", "ISO3166-1-Alpha-3": "KWT", "ISO3166-1-Alpha-2": "KW"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-90.098307, 13.731404], [-88.785675, 13.245266], [-87.817169, 13.406562], [-89.361621, 14.415478], [-90.098307, 13.731404]]]}, "properties": {"name": "El Salvador", "ISO3166-1-Alpha-3": "SLV", "ISO3166-1-Alpha-2": "SV"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-89.361621, 14.415478], [-89.172769, 15.04221], [-88.220937, 15.725653], [-88.913971, 15.893948], [-89.236512, 15.893915], [-89.160496, 17.814314], [-90.990901, 17.801964], [-91.066866, 16.918193], [-90.398793, 16.347582], [-90.485738, 16.0707], [-91.723776, 16.068788], [-92.204651, 15.289507], [-92.246257, 14.546279], [-91.316461, 13.955679], [-90.098307, 13.731404], [-89.361621, 14.415478]]]}, "properties": {"name": "Guatemala", "ISO3166-1-Alpha-3": "GTM", "ISO3166-1-Alpha-2": "GT"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[124.919507, -8.962016], [125.061615, -9.485772], [127.009288, -8.67669], [126.967052, -8.309015], [125.144298, -8.631443], [124.919507, -8.962016]]]}, "properties": {"name": "East Timor", "ISO3166-1-Alpha-3": "TLS", "ISO3166-1-Alpha-2": "TL"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[114.9817, 4.889065], [113.99879, 4.601142], [114.586628, 4.021435], [114.9817, 4.889065]]]}, "properties": {"name": "Brunei", "ISO3166-1-Alpha-3": "BRN", "ISO3166-1-Alpha-2": "BN"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Monaco", "ISO3166-1-Alpha-3": "MCO", "ISO3166-1-Alpha-2": "MC"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-4.821613, 24.995065], [-2.523226, 23.500996], [1.146524, 21.101711], [1.778113, 20.304291], [3.216785, 19.794064], [3.308356, 18.981685], [4.22861, 19.142244], [5.837607, 19.478631], [7.482726, 20.872577], [9.723313, 22.193427], [11.968861, 23.517351], [11.567128, 24.26684], [10.032028, 24.856339], [9.9695, 25.395402], [9.401162, 26.113394], [9.835761, 26.504223], [9.721349, 27.291875], [9.93591, 27.866724], [9.826149, 29.128533], [9.519708, 30.228905], [9.045008, 32.071842], [7.75072, 33.207664], [7.479832, 33.893901], [8.236479, 34.647654], [8.24144, 35.827737], [8.60251, 36.939511], [7.383474, 37.082994], [5.304373, 36.643012], [4.78712, 36.895413], [3.827485, 36.912909], [1.04477, 36.486884], [-2.222564, 35.089301], [-1.787716, 34.756691], [-1.674234, 33.237972], [-1.249557, 32.08166], [-2.516147, 32.1322], [-2.827836, 31.794586], [-3.659507, 31.647821], [-3.645529, 30.711317], [-4.372368, 30.508641], [-5.756156, 29.614071], [-7.619453, 29.389422], [-8.682385, 28.6659], [-8.682385, 27.661439], [-8.682385, 27.285416], [-4.821613, 24.995065]]]}, "properties": {"name": "Algeria", "ISO3166-1-Alpha-3": "DZA", "ISO3166-1-Alpha-2": "DZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[32.113884, -26.840014], [32.893077, -26.846124], [32.872732, -25.543878], [35.106456, -24.597914], [35.394786, -23.841892], [35.496837, -22.141534], [34.679942, -20.346938], [34.880056, -19.863377], [35.662283, -19.132989], [36.244314, -18.883233], [37.086436, -17.869073], [37.908865, -17.355157], [39.090505, -16.983819], [39.698253, -16.532159], [40.573253, -15.504978], [40.84254, -14.464451], [40.542654, -13.658868], [40.43686, -10.474786], [40.008131, -10.811122], [38.492255, -11.413462], [37.875238, -11.319101], [37.427824, -11.722591], [34.964615, -11.573556], [34.354006, -12.199461], [34.545312, -13.325749], [34.894438, -13.534728], [35.853036, -14.667476], [35.795675, -16.004965], [35.214419, -16.484212], [34.385219, -16.186453], [34.569083, -15.27116], [34.344084, -14.387389], [33.604233, -14.524022], [33.202707, -14.013872], [30.214465, -14.981462], [30.396263, -15.635995], [30.402568, -16.001244], [31.259983, -16.023465], [31.910279, -16.428919], [32.893372, -16.712415], [32.996414, -18.46714], [32.768831, -19.363623], [33.032795, -19.784166], [32.469108, -20.68685], [32.408543, -21.290327], [31.288922, -22.39734], [31.521466, -23.415572], [31.986554, -24.423108], [31.949243, -25.958104], [32.113884, -26.840014]]]}, "properties": {"name": "Mozambique", "ISO3166-1-Alpha-3": "MOZ", "ISO3166-1-Alpha-2": "MZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[31.949243, -25.958104], [31.119836, -25.910045], [30.785697, -26.716921], [31.157043, -27.205573], [31.96826, -27.316264], [32.113884, -26.840014], [31.949243, -25.958104]]]}, "properties": {"name": "eSwatini", "ISO3166-1-Alpha-3": "SWZ", "ISO3166-1-Alpha-2": "SZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[30.5546, -2.400628], [29.697546, -2.808251], [29.015365, -2.720711], [29.234629, -3.046583], [29.404179, -4.449805], [30.003005, -4.271935], [30.832205, -3.172777], [30.5546, -2.400628]]]}, "properties": {"name": "Burundi", "ISO3166-1-Alpha-3": "BDI", "ISO3166-1-Alpha-2": "BI"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[29.015365, -2.720711], [29.697546, -2.808251], [30.5546, -2.400628], [30.831017, -1.594165], [30.471786, -1.066837], [29.577915, -1.38839], [28.858838, -2.418198], [29.015365, -2.720711]]]}, "properties": {"name": "Rwanda", "ISO3166-1-Alpha-3": "RWA", "ISO3166-1-Alpha-2": "RW"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[92.575879, 21.977574], [92.265147, 21.061103], [92.772146, 20.201483], [93.374848, 20.088528], [93.987315, 19.386705], [94.480968, 18.094224], [94.57252, 17.311103], [94.215099, 16.15766], [95.396495, 15.716376], [95.755544, 16.14525], [96.615943, 16.518005], [97.068614, 17.25137], [97.54835, 16.538153], [97.79477, 14.880316], [98.691173, 12.710435], [98.747406, 10.350531], [98.766715, 10.688754], [99.630022, 11.815766], [99.21413, 12.73465], [99.152531, 13.714875], [98.54771, 14.377676], [98.164995, 15.125796], [98.559906, 15.355317], [98.631633, 16.463131], [97.768947, 17.67918], [97.839847, 19.555319], [99.008768, 19.845922], [100.099295, 20.317805], [100.725407, 21.311672], [101.159024, 21.552691], [99.95026, 21.721156], [99.35779, 22.495476], [99.21661, 23.057379], [98.503269, 24.121268], [97.707658, 24.125299], [97.536093, 24.745028], [98.692404, 25.87899], [98.679279, 27.577336], [97.527721, 28.529526], [97.323496, 28.217478], [96.758879, 27.341743], [96.142586, 27.25751], [95.119082, 26.604217], [95.139546, 26.029937], [94.608003, 25.394627], [94.708565, 25.02589], [93.997911, 23.916965], [93.456652, 23.95996], [93.169021, 22.246912], [92.575879, 21.977574]]]}, "properties": {"name": "Myanmar", "ISO3166-1-Alpha-3": "MMR", "ISO3166-1-Alpha-2": "MM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[92.575879, 21.977574], [92.356874, 23.289122], [91.536562, 22.981854], [91.140824, 23.6121], [91.363033, 24.099848], [92.107587, 24.405979], [92.458056, 24.953284], [92.001753, 25.18296], [90.364644, 25.149991], [89.795015, 25.374163], [89.830051, 25.90798], [88.656273, 26.415133], [88.074396, 25.908135], [88.43148, 25.173038], [88.02179, 24.645603], [88.737508, 24.287097], [88.540104, 23.649953], [89.060395, 22.129869], [89.104666, 21.815863], [90.184825, 21.800971], [90.613455, 22.314887], [91.456309, 22.784817], [92.265147, 21.061103], [92.575879, 21.977574]]]}, "properties": {"name": "Bangladesh", "ISO3166-1-Alpha-3": "BGD", "ISO3166-1-Alpha-2": "BD"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Andorra", "ISO3166-1-Alpha-3": "AND", "ISO3166-1-Alpha-2": "AD"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[74.542354, 37.021669], [74.892307, 37.231114], [73.276075, 37.459472], [71.61106, 36.704841], [71.597727, 37.89836], [70.974045, 38.473673], [70.165205, 37.889911], [68.307282, 37.114221], [67.780544, 37.188868], [66.519588, 37.36418], [65.668839, 37.520553], [64.76016, 37.092621], [64.266134, 36.152471], [63.342934, 35.856262], [62.621066, 35.222709], [61.269676, 35.618499], [61.06545, 34.814724], [60.486778, 34.094277], [60.855024, 31.482731], [61.661176, 31.38191], [61.7852, 30.831401], [60.844379, 29.858179], [62.477509, 29.407819], [64.086093, 29.386605], [66.195628, 29.835338], [66.366264, 30.922868], [67.346204, 31.20776], [68.125743, 31.811496], [69.004036, 31.651092], [69.547517, 33.075011], [70.294448, 33.318949], [70.002838, 34.043789], [71.046702, 34.041877], [71.080601, 34.672923], [71.633694, 35.203124], [71.170932, 36.027001], [71.629147, 36.459533], [72.565214, 36.820596], [74.094319, 36.831241], [74.542354, 37.021669]]]}, "properties": {"name": "Afghanistan", "ISO3166-1-Alpha-3": "AFG", "ISO3166-1-Alpha-2": "AF"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[18.49643, 42.416327], [19.365082, 41.852362], [20.064956, 42.546758], [20.345352, 42.827439], [19.195345, 43.532796], [18.452754, 42.993398], [18.437355, 42.559212], [18.49643, 42.416327]]]}, "properties": {"name": "Montenegro", "ISO3166-1-Alpha-3": "MNE", "ISO3166-1-Alpha-2": "ME"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[17.653331, 42.890937], [18.437355, 42.559212], [18.452754, 42.993398], [19.195345, 43.532796], [19.618885, 44.035711], [19.015821, 44.865635], [16.93264, 45.278788], [15.780049, 45.160294], [15.717314, 44.786466], [17.580658, 42.942084], [17.653331, 42.890937]]]}, "properties": {"name": "Bosnia and Herzegovina", "ISO3166-1-Alpha-3": "BIH", "ISO3166-1-Alpha-2": "BA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[30.471786, -1.066837], [30.828381, -1.002573], [33.904214, -1.002573], [33.893569, 0.109814], [34.978671, 1.675945], [34.923584, 2.477318], [34.434001, 3.182029], [33.977078, 4.219692], [33.532609, 3.774293], [33.01724, 3.87718], [31.943662, 3.591255], [31.141489, 3.785119], [30.839543, 3.490202], [30.724925, 2.440782], [31.242826, 2.051168], [29.928281, 0.785018], [29.711809, 0.099582], [29.577915, -1.38839], [30.471786, -1.066837]]]}, "properties": {"name": "Uganda", "ISO3166-1-Alpha-3": "UGA", "ISO3166-1-Alpha-2": "UG"}}, {"type": "Feature", "geometry": null, "properties": {"name": "US Naval Base Guantanamo Bay", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-75.095006, 19.897226], [-74.267405, 20.068305], [-74.998362, 20.700751], [-75.783925, 20.746405], [-77.507558, 21.862454], [-78.78185, 22.390611], [-79.341786, 22.413153], [-80.024566, 22.946682], [-82.033193, 23.200507], [-83.207631, 23.002672], [-84.021108, 22.678656], [-83.415472, 22.186093], [-82.632945, 22.680185], [-81.877501, 22.67973], [-81.824452, 22.18891], [-79.24942, 21.551947], [-78.76651, 21.639716], [-78.065541, 20.711656], [-77.113433, 20.512437], [-77.737213, 19.855699], [-76.248158, 19.990627], [-75.232859, 19.900051], [-75.160192, 19.970648], [-75.137036, 19.971551], [-75.095006, 19.897226]]]}, "properties": {"name": "Cuba", "ISO3166-1-Alpha-3": "CUB", "ISO3166-1-Alpha-2": "CU"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-89.361621, 14.415478], [-87.817169, 13.406562], [-87.314036, 12.981553], [-86.701861, 13.314201], [-86.09673, 14.044079], [-85.824162, 13.847683], [-84.77017, 14.805144], [-84.482694, 14.619471], [-83.130444, 14.997012], [-83.774526, 15.285712], [-84.294638, 15.81101], [-85.703196, 15.976467], [-86.878733, 15.764146], [-87.722157, 15.922919], [-88.220937, 15.725653], [-89.172769, 15.04221], [-89.361621, 14.415478]]]}, "properties": {"name": "Honduras", "ISO3166-1-Alpha-3": "HND", "ISO3166-1-Alpha-2": "HN"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-78.828684, 1.434312], [-78.957753, 1.163398], [-80.075836, 0.816148], [-80.066459, 0.05221], [-80.849517, -1.615818], [-80.656646, -2.415785], [-79.953196, -3.197442], [-80.340729, -3.393498], [-80.079655, -4.309038], [-79.009281, -4.96011], [-78.362938, -3.488727], [-77.849016, -2.980644], [-76.684591, -2.57364], [-75.560034, -1.502595], [-75.283488, -0.107021], [-76.053467, 0.363545], [-76.896826, 0.245309], [-77.424391, 0.408297], [-78.828684, 1.434312]]]}, "properties": {"name": "Ecuador", "ISO3166-1-Alpha-3": "ECU", "ISO3166-1-Alpha-2": "EC"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-78.828684, 1.434312], [-77.424391, 0.408297], [-76.896826, 0.245309], [-76.053467, 0.363545], [-75.283488, -0.107021], [-74.824653, -0.170479], [-74.28913, -0.943042], [-73.63656, -1.255168], [-72.943269, -2.419024], [-72.396584, -2.446516], [-71.76794, -2.142245], [-70.874196, -2.229579], [-70.050629, -2.71513], [-70.734127, -3.782042], [-69.96495, -4.236484], [-69.399454, -1.182717], [-69.632076, -0.506893], [-70.073806, -0.124901], [-69.848807, 1.668892], [-68.163302, 1.721291], [-67.340614, 2.090106], [-66.875061, 1.22251], [-67.325421, 2.47463], [-67.838619, 2.88613], [-67.304647, 3.425709], [-67.865078, 4.512077], [-67.843684, 5.297249], [-67.573984, 6.266234], [-69.443638, 6.122237], [-70.096621, 6.944435], [-72.080996, 7.066598], [-72.451309, 7.440219], [-72.386766, 8.338614], [-73.009725, 9.295377], [-72.907561, 10.452464], [-71.97108, 11.661925], [-71.327507, 11.849998], [-71.656158, 12.465318], [-72.263051, 11.885972], [-73.292388, 11.294013], [-74.187489, 11.316962], [-74.844372, 11.10967], [-75.509836, 10.585842], [-75.657794, 9.705232], [-76.673207, 8.680406], [-77.373525, 8.66474], [-77.2012, 7.981995], [-77.895839, 7.235098], [-77.343007, 6.543769], [-77.246449, 5.78734], [-77.425038, 4.004584], [-77.204905, 3.621243], [-77.751536, 2.627265], [-78.432216, 2.587028], [-78.828684, 1.434312]]]}, "properties": {"name": "Colombia", "ISO3166-1-Alpha-3": "COL", "ISO3166-1-Alpha-2": "CO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-62.650357, -22.234456], [-61.956446, -23.034407], [-61.006349, -23.805471], [-60.033669, -24.007009], [-58.809196, -24.776781], [-57.754067, -25.180891], [-57.556921, -25.45984], [-58.653289, -27.156274], [-57.180097, -27.487313], [-56.124554, -27.298901], [-54.706475, -26.441796], [-54.600203, -25.574945], [-54.245289, -24.050624], [-54.612553, -23.811155], [-55.398035, -23.97683], [-55.89294, -22.306803], [-56.842856, -22.289026], [-57.986818, -22.035295], [-57.86021, -20.730258], [-58.158797, -20.165125], [-58.175282, -19.821373], [-59.089541, -19.286729], [-60.006384, -19.298097], [-61.761213, -19.657765], [-62.277305, -20.579776], [-62.650357, -22.234456]]]}, "properties": {"name": "Paraguay", "ISO3166-1-Alpha-3": "PRY", "ISO3166-1-Alpha-2": "PY"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-57.642466, -30.193092], [-57.602793, -30.190517], [-57.611698, -30.182963], [-57.642466, -30.193092]]]}, "properties": {"name": "Brazilian Island", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-7.414418, 37.192816], [-7.313528, 39.457438], [-6.879705, 40.009187], [-6.818003, 41.054136], [-6.205947, 41.57028], [-6.656772, 41.933075], [-8.048574, 41.816389], [-8.750803, 41.96898], [-8.6551, 40.993801], [-9.491689, 38.707587], [-8.905832, 38.512397], [-8.62678, 37.121161], [-7.414418, 37.192816]]]}, "properties": {"name": "Portugal", "ISO3166-1-Alpha-3": "PRT", "ISO3166-1-Alpha-2": "PT"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[26.617889, 48.258968], [28.037027, 47.016459], [28.199498, 45.461774], [29.72695, 46.455796], [29.556573, 47.324038], [29.123989, 47.975987], [27.751773, 48.451979], [26.617889, 48.258968]]]}, "properties": {"name": "Moldova", "ISO3166-1-Alpha-3": "MDA", "ISO3166-1-Alpha-2": "MD"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[61.269676, 35.618499], [62.621066, 35.222709], [63.342934, 35.856262], [64.266134, 36.152471], [64.76016, 37.092621], [65.668839, 37.520553], [66.519588, 37.36418], [66.554159, 38.026853], [65.60414, 38.237409], [64.120613, 38.96168], [62.452808, 40.009239], [61.877907, 41.124984], [60.414534, 41.235262], [59.866351, 42.304215], [58.612267, 42.780852], [56.950146, 41.86605], [57.010194, 41.254124], [55.978422, 41.321717], [55.429515, 41.290814], [54.738291, 42.04821], [54.047171, 42.345401], [52.978709, 42.126629], [52.437671, 41.748876], [53.121918, 42.089993], [53.896658, 42.078843], [54.639903, 40.838772], [52.736583, 40.484565], [52.884125, 39.944892], [53.97755, 38.90176], [53.913748, 37.34276], [54.783663, 37.517453], [55.423108, 38.075894], [57.038409, 38.187283], [57.352395, 37.967529], [58.861037, 37.668089], [60.34229, 36.637145], [61.075476, 36.64779], [61.269676, 35.618499]]]}, "properties": {"name": "Turkmenistan", "ISO3166-1-Alpha-3": "TKM", "ISO3166-1-Alpha-2": "TM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[35.75759, 32.744347], [35.560961, 32.384717], [35.458125, 31.491929], [34.955577, 29.558987], [34.949385, 29.351686], [36.016437, 29.189951], [36.756237, 29.865517], [37.470198, 29.994553], [37.981071, 30.499483], [36.959532, 31.490999], [39.146375, 32.118144], [38.774511, 33.371685], [36.819385, 32.316788], [35.75759, 32.744347]]]}, "properties": {"name": "Jordan", "ISO3166-1-Alpha-3": "JOR", "ISO3166-1-Alpha-2": "JO"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[88.118218, 27.860885], [87.155796, 27.825796], [86.661976, 28.106838], [85.98026, 27.885172], [85.675059, 28.306387], [85.080212, 28.318789], [83.517052, 29.191708], [81.591588, 30.414269], [80.996017, 30.196969], [80.368975, 29.757926], [80.036386, 28.837026], [81.146344, 28.372249], [82.752137, 27.494964], [84.577039, 27.329031], [84.801934, 27.013753], [85.821614, 26.571713], [87.326225, 26.353328], [88.074189, 26.453942], [88.118218, 27.860885]]]}, "properties": {"name": "Nepal", "ISO3166-1-Alpha-3": "NPL", "ISO3166-1-Alpha-2": "NP"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[28.980846, -28.909035], [28.66655, -28.59722], [27.747432, -28.908622], [27.014867, -29.625581], [27.366164, -30.311017], [28.054701, -30.649704], [28.364087, -30.159295], [29.144246, -29.919723], [29.435908, -29.342394], [28.980846, -28.909035]]]}, "properties": {"name": "Lesotho", "ISO3166-1-Alpha-3": "LSO", "ISO3166-1-Alpha-2": "LS"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[11.322078, 2.16576], [11.351637, 2.300584], [13.294568, 2.161058], [14.562139, 2.208807], [15.76465, 1.908722], [16.196665, 2.236454], [16.092382, 2.863289], [15.171301, 3.758971], [14.523899, 5.279679], [14.719029, 6.257862], [15.481049, 7.523263], [15.183703, 8.479148], [13.947603, 9.637759], [14.181697, 9.978178], [15.536137, 10.080523], [15.065881, 10.793115], [15.135644, 11.530822], [14.830753, 12.618249], [14.064908, 13.077988], [14.179837, 12.385602], [14.669936, 12.167424], [14.593765, 11.496431], [13.98233, 11.276264], [12.862036, 9.382891], [12.805709, 8.831503], [12.249981, 8.418764], [12.197787, 7.975173], [11.509767, 6.612311], [10.602536, 7.058072], [10.119465, 6.994406], [8.855872, 5.847499], [8.594168, 4.815294], [8.971202, 4.100775], [9.903087, 3.272895], [9.799571, 2.341742], [9.990997, 2.165605], [11.322078, 2.16576]]]}, "properties": {"name": "Cameroon", "ISO3166-1-Alpha-3": "CMR", "ISO3166-1-Alpha-2": "CM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[13.294568, 2.161058], [11.351637, 2.300584], [11.322078, 2.16576], [11.336341, 0.999165], [9.804371, 0.998354], [9.267426, -0.412367], [8.800304, -0.733657], [9.244314, -1.785821], [9.902438, -2.700431], [11.114016, -3.936856], [11.827784, -3.548051], [11.55824, -2.349365], [12.45927, -2.329934], [12.804572, -1.919107], [13.361024, -2.428843], [13.770663, -2.119094], [14.226966, -2.323113], [14.482144, -1.388596], [14.498991, -0.630916], [13.871225, 0.196423], [14.468088, 0.913227], [14.183868, 1.380847], [13.25023, 1.221787], [13.294568, 2.161058]]]}, "properties": {"name": "Gabon", "ISO3166-1-Alpha-3": "GAB", "ISO3166-1-Alpha-2": "GA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[3.5964, 11.695773], [3.6457, 12.528539], [4.506732, 13.694669], [5.554317, 13.873418], [6.368944, 13.626275], [6.906637, 13.006856], [7.755578, 13.327146], [8.679346, 12.923579], [9.640734, 12.822526], [10.674986, 13.375102], [11.440004, 13.364431], [12.472706, 13.064165], [12.883844, 13.495999], [13.607314, 13.7041], [13.449184, 14.380131], [14.368973, 15.749634], [15.452421, 16.876232], [15.736021, 19.903541], [15.953992, 20.374571], [15.180396, 21.507267], [14.979909, 22.995664], [14.23172, 22.617949], [13.482257, 23.179672], [11.968861, 23.517351], [9.723313, 22.193427], [7.482726, 20.872577], [5.837607, 19.478631], [4.22861, 19.142244], [4.183961, 16.416053], [3.507103, 15.353973], [1.331526, 15.283616], [0.973718, 14.991257], [0.218467, 14.910977], [0.152941, 14.54671], [0.971754, 13.067317], [2.070912, 12.306898], [2.390169, 11.896536], [2.844301, 12.399244], [3.5964, 11.695773]]]}, "properties": {"name": "Niger", "ISO3166-1-Alpha-3": "NER", "ISO3166-1-Alpha-2": "NE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[2.390169, 11.896536], [2.070912, 12.306898], [0.971754, 13.067317], [0.152941, 14.54671], [0.218467, 14.910977], [-0.752895, 15.069727], [-2.023412, 14.198643], [-2.840855, 14.042994], [-3.248634, 13.292781], [-3.984042, 13.39647], [-4.406135, 12.307467], [-5.167819, 11.943665], [-5.522578, 10.425489], [-4.966075, 9.901025], [-4.270329, 9.743928], [-3.661658, 9.948929], [-2.689211, 9.488724], [-2.932504, 10.634313], [-2.750706, 10.985842], [-0.516475, 10.988633], [-0.166109, 11.13498], [0.901474, 10.992741], [1.433845, 11.459405], [2.010761, 11.426901], [2.390169, 11.896536]]]}, "properties": {"name": "Burkina Faso", "ISO3166-1-Alpha-3": "BFA", "ISO3166-1-Alpha-2": "BF"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-0.166109, 11.13498], [-0.088129, 10.633486], [0.366597, 10.304488], [0.365874, 8.774272], [0.616324, 8.488759], [0.519275, 6.832246], [1.185395, 6.100491], [1.61964, 6.213894], [1.601173, 9.049526], [1.331009, 9.996471], [0.768769, 10.367094], [0.901474, 10.992741], [-0.166109, 11.13498]]]}, "properties": {"name": "Togo", "ISO3166-1-Alpha-3": "TGO", "ISO3166-1-Alpha-2": "TG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-0.166109, 11.13498], [-0.516475, 10.988633], [-2.750706, 10.985842], [-2.932504, 10.634313], [-2.689211, 9.488724], [-2.506328, 8.209267], [-2.840003, 7.820247], [-3.262509, 6.617142], [-2.843699, 5.149115], [-3.115305, 5.107815], [-3.119618, 5.091335], [-2.090566, 4.737128], [-0.79955, 5.21483], [1.185395, 6.100491], [0.519275, 6.832246], [0.616324, 8.488759], [0.365874, 8.774272], [0.366597, 10.304488], [-0.088129, 10.633486], [-0.166109, 11.13498]]]}, "properties": {"name": "Ghana", "ISO3166-1-Alpha-3": "GHA", "ISO3166-1-Alpha-2": "GH"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-13.728279, 12.673388], [-15.195114, 12.679434], [-15.677049, 12.439294], [-16.728437, 12.332531], [-15.020985, 10.967475], [-14.686773, 11.51072], [-13.729932, 11.709829], [-13.728279, 12.673388]]]}, "properties": {"name": "Guinea-Bissau", "ISO3166-1-Alpha-3": "GNB", "ISO3166-1-Alpha-2": "GW"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Gibraltar", "ISO3166-1-Alpha-3": "GIB", "ISO3166-1-Alpha-2": "GI"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-122.753017, 48.992515], [-122.791982, 48.082913], [-124.734607, 48.170404], [-123.898061, 46.441636], [-124.151031, 43.881741], [-124.547719, 42.845445], [-124.062155, 41.435876], [-124.36351, 40.261481], [-123.854019, 39.833514], [-123.648307, 38.849535], [-122.974535, 38.266194], [-122.404457, 37.194526], [-121.789812, 36.806199], [-121.895629, 36.313929], [-120.644887, 35.13939], [-120.472681, 34.450273], [-119.61716, 34.420527], [-118.544057, 34.038886], [-117.508844, 33.335205], [-117.125121, 32.531669], [-114.822108, 32.50024], [-111.067118, 31.333644], [-108.214811, 31.327443], [-108.215121, 31.777751], [-106.517189, 31.773824], [-105.008495, 30.676992], [-104.530824, 29.667906], [-103.147989, 28.985105], [-102.683469, 29.743715], [-101.409309, 29.765781], [-100.668967, 29.116208], [-100.284339, 28.296517], [-99.507203, 27.57377], [-99.085498, 26.40764], [-98.222656, 26.075412], [-97.139272, 25.965806], [-97.550201, 27.009182], [-97.024973, 28.041449], [-95.601918, 28.763861], [-94.888824, 29.375963], [-93.199593, 29.772811], [-92.276493, 29.533836], [-91.684682, 29.753119], [-91.228586, 29.238593], [-90.08023, 29.174709], [-88.927642, 30.442206], [-87.786488, 30.236762], [-87.156809, 30.474514], [-85.670888, 30.122952], [-85.307851, 29.693427], [-84.269643, 30.092678], [-83.680572, 29.921291], [-82.644399, 28.900377], [-82.842397, 27.828518], [-81.800893, 26.098619], [-81.321685, 25.782701], [-81.086537, 25.121405], [-80.42691, 25.221747], [-80.129262, 25.887885], [-80.038238, 26.811184], [-80.738271, 28.373481], [-80.609446, 28.610175], [-81.255849, 29.789455], [-81.499257, 30.704332], [-81.363899, 31.41583], [-80.887563, 32.069403], [-79.202952, 33.192939], [-78.55899, 33.869045], [-77.911122, 33.958157], [-77.201527, 34.650702], [-76.658193, 34.721869], [-76.442494, 35.403998], [-75.721832, 35.829169], [-75.98893, 36.911959], [-76.378505, 37.272332], [-76.221099, 38.343899], [-75.154286, 38.240912], [-74.167551, 39.699368], [-73.634582, 41.007055], [-72.779449, 41.270168], [-71.497711, 41.366278], [-70.533111, 41.81623], [-70.815338, 42.870266], [-70.239125, 43.700059], [-69.263417, 43.929918], [-68.986602, 44.421169], [-67.899607, 44.422105], [-66.977325, 44.815538], [-67.176015, 45.178656], [-67.772835, 45.828057], [-67.805185, 47.035631], [-68.197047, 47.341401], [-69.267628, 47.439844], [-70.007763, 46.704075], [-70.407351, 45.731525], [-71.50408, 45.013739], [-74.712961, 44.999254], [-75.270233, 44.863774], [-76.841532, 43.625504], [-78.688086, 43.631808], [-79.17431, 43.464532], [-79.019384, 42.802686], [-80.246596, 42.365477], [-81.276662, 42.208665], [-82.424861, 41.676811], [-83.128513, 42.239955], [-82.511264, 42.646675], [-82.153611, 43.549591], [-82.532141, 45.293516], [-84.589406, 46.475123], [-84.87869, 46.897914], [-88.347264, 48.298655], [-89.340382, 47.984152], [-91.427929, 48.036449], [-92.648604, 48.536263], [-94.592852, 48.726433], [-95.177106, 48.99267], [-98.474191, 48.99267], [-102.870383, 48.992618], [-105.94774, 48.992618], [-110.563712, 48.992618], [-114.520266, 48.992515], [-119.575875, 48.992515], [-122.753017, 48.992515]]], [[[-141.005564, 69.650946], [-143.235666, 70.118232], [-144.941558, 69.977973], [-149.344472, 70.510077], [-151.979685, 70.448717], [-152.545847, 70.887519], [-154.538808, 70.826158], [-156.817209, 71.306342], [-158.04304, 70.836859], [-162.014963, 70.27733], [-163.155751, 69.359361], [-164.346425, 68.929389], [-166.236765, 68.874823], [-166.548166, 68.358832], [-164.151479, 67.619615], [-163.688303, 67.103217], [-162.435496, 66.991034], [-161.607167, 66.453925], [-161.915639, 66.042426], [-163.680491, 66.078599], [-164.718088, 66.556342], [-168.081003, 65.591498], [-166.189687, 64.585191], [-162.607167, 64.506781], [-161.42219, 64.774359], [-160.791575, 63.746527], [-163.093984, 63.057359], [-164.579091, 63.14057], [-166.199941, 61.59455], [-164.669138, 60.825751], [-164.988962, 60.341376], [-163.614939, 59.800849], [-162.525828, 59.997504], [-161.713938, 59.50137], [-161.357469, 58.726245], [-160.352169, 59.070659], [-159.051259, 58.424709], [-157.502675, 58.46369], [-157.709665, 57.568671], [-158.945017, 56.842922], [-160.368723, 56.276842], [-160.520741, 55.935207], [-161.826283, 55.879869], [-162.877634, 54.938951], [-160.477528, 55.494615], [-158.318308, 56.174709], [-156.338857, 57.415432], [-154.16572, 58.216783], [-153.331614, 58.933783], [-154.076365, 59.381171], [-152.737904, 59.908393], [-151.882883, 59.786526], [-151.741526, 59.167141], [-150.326243, 59.475979], [-149.341135, 60.022406], [-148.401967, 59.99136], [-148.306467, 60.895697], [-145.691762, 60.656684], [-143.918609, 59.997016], [-142.798492, 60.112372], [-140.322743, 59.701239], [-138.184885, 59.02558], [-136.598378, 58.223049], [-135.841135, 58.528388], [-134.497955, 58.353949], [-133.391469, 57.33515], [-130.889882, 55.715277], [-130.019618, 55.907952], [-132.107384, 56.858753], [-133.463089, 58.462221], [-135.482759, 59.792475], [-136.466549, 59.287803], [-137.611363, 59.239331], [-139.182146, 60.073389], [-141.001156, 60.321074], [-141.003146, 64.548671], [-141.004153, 66.58958], [-141.005564, 69.650946]]], [[[-152.160878, 57.627143], [-152.486318, 57.910712], [-153.924631, 57.810492], [-154.810007, 57.346584], [-153.962514, 56.748236], [-152.160878, 57.627143]]], [[[-135.847971, 57.394355], [-134.813873, 57.497463], [-134.924794, 58.027493], [-135.813547, 58.274359], [-136.394846, 57.885688], [-135.847971, 57.394355]]], [[[-154.899622, 19.567016], [-155.332313, 20.046721], [-156.060164, 19.731221], [-155.87813, 19.029072], [-154.899622, 19.567016]]]]}, "properties": {"name": "United States of America", "ISO3166-1-Alpha-3": "USA", "ISO3166-1-Alpha-2": "US"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-122.753017, 48.992515], [-119.575875, 48.992515], [-114.520266, 48.992515], [-110.563712, 48.992618], [-105.94774, 48.992618], [-102.870383, 48.992618], [-98.474191, 48.99267], [-95.177106, 48.99267], [-94.592852, 48.726433], [-92.648604, 48.536263], [-91.427929, 48.036449], [-89.340382, 47.984152], [-88.347264, 48.298655], [-84.87869, 46.897914], [-84.589406, 46.475123], [-82.532141, 45.293516], [-82.153611, 43.549591], [-82.511264, 42.646675], [-83.128513, 42.239955], [-82.424861, 41.676811], [-81.276662, 42.208665], [-80.246596, 42.365477], [-79.019384, 42.802686], [-79.17431, 43.464532], [-78.688086, 43.631808], [-76.841532, 43.625504], [-75.270233, 44.863774], [-74.712961, 44.999254], [-73.961537, 45.354234], [-72.994496, 46.210801], [-71.353993, 46.739], [-69.932037, 47.773057], [-69.069569, 48.756212], [-68.122385, 49.271348], [-67.376332, 49.339219], [-66.543121, 50.216132], [-60.06371, 50.253974], [-58.947743, 51.041612], [-57.249989, 51.508287], [-55.684804, 52.11168], [-56.059397, 52.765692], [-56.036244, 53.58043], [-57.15274, 53.739936], [-58.186147, 54.358588], [-59.007965, 55.153673], [-60.354848, 55.575141], [-61.83023, 56.376899], [-61.889882, 57.624986], [-62.578603, 58.504218], [-64.147857, 59.682847], [-65.406728, 59.417426], [-66.108632, 58.77912], [-67.649037, 58.251776], [-68.786936, 58.922471], [-69.45165, 58.895209], [-70.110219, 61.060126], [-71.395009, 61.149522], [-71.652008, 61.649848], [-73.670725, 62.482123], [-74.41686, 62.252509], [-77.490834, 62.588324], [-78.154897, 62.297309], [-77.708363, 61.616278], [-78.165517, 60.868354], [-77.283111, 60.026597], [-78.473704, 58.72016], [-77.079335, 57.961493], [-76.589345, 57.271308], [-76.658681, 56.072984], [-78.392242, 55.027533], [-79.67634, 54.693793], [-79.129384, 54.107489], [-78.521474, 52.480943], [-78.840566, 51.842271], [-79.700754, 51.385443], [-81.390614, 52.127794], [-82.301178, 52.967597], [-82.126047, 53.812201], [-82.458485, 55.141181], [-85.126617, 55.302558], [-87.622304, 56.095567], [-88.920766, 56.853095], [-91.031117, 57.264635], [-92.2235, 57.02147], [-93.189524, 58.759711], [-94.807444, 59.018134], [-94.711822, 60.265692], [-93.502065, 61.855821], [-92.260894, 62.562608], [-90.643625, 63.068915], [-90.974517, 63.578681], [-88.044301, 64.184312], [-86.938222, 65.141954], [-87.380767, 65.329698], [-85.856662, 66.16283], [-83.690785, 66.199205], [-81.527415, 66.996039], [-81.237945, 67.460517], [-82.3697, 68.356269], [-81.26651, 68.638739], [-82.57551, 69.674058], [-85.33316, 69.791368], [-85.21996, 69.132799], [-86.501943, 67.379381], [-87.327789, 67.173285], [-88.373647, 67.975816], [-88.022613, 68.808661], [-89.331695, 69.249498], [-90.000152, 68.379259], [-91.545237, 70.144965], [-92.907704, 70.902045], [-92.990631, 71.357123], [-94.548695, 72.002143], [-96.616444, 70.825751], [-96.486318, 70.096991], [-94.322174, 69.452989], [-94.860992, 68.03087], [-96.46524, 67.549506], [-100.71703, 67.846991], [-101.529449, 67.680406], [-104.483876, 68.033922], [-106.197499, 68.944403], [-108.280832, 68.621568], [-108.59911, 67.625312], [-110.420806, 67.948391], [-112.42101, 67.68244], [-115.533803, 67.934394], [-114.201039, 68.571926], [-114.987782, 68.867418], [-118.040883, 69.024604], [-121.427846, 69.766832], [-125.032297, 69.74726], [-125.938629, 69.414008], [-127.136464, 70.252875], [-129.173695, 69.832343], [-130.324452, 70.140204], [-133.631907, 69.399156], [-134.410064, 69.656928], [-137.191151, 68.947903], [-139.143788, 69.513658], [-141.005564, 69.650946], [-141.004153, 66.58958], [-141.003146, 64.548671], [-141.001156, 60.321074], [-139.182146, 60.073389], [-137.611363, 59.239331], [-136.466549, 59.287803], [-135.482759, 59.792475], [-133.463089, 58.462221], [-132.107384, 56.858753], [-130.019618, 55.907952], [-129.957916, 55.273505], [-130.458241, 54.348619], [-129.33495, 53.396918], [-128.546742, 53.135972], [-127.800526, 52.256293], [-127.777008, 51.167222], [-126.242258, 50.501044], [-124.605214, 50.232856], [-123.090506, 48.992515], [-123.035308, 48.992499], [-122.753017, 48.992515]]], [[[-67.176015, 45.178656], [-66.745839, 45.064521], [-65.315053, 45.459215], [-66.210317, 44.105699], [-65.462636, 43.527574], [-63.715891, 44.456122], [-61.071156, 45.223944], [-61.47053, 45.683092], [-62.470448, 45.614488], [-63.805898, 45.888658], [-64.514027, 46.239407], [-64.905832, 46.886868], [-64.826283, 47.815497], [-64.221181, 48.894355], [-65.565785, 49.266466], [-66.62206, 49.122626], [-69.054433, 48.232367], [-70.534901, 47.010565], [-73.139801, 46.058336], [-73.557281, 45.414293], [-74.712961, 44.999254], [-71.50408, 45.013739], [-70.407351, 45.731525], [-70.007763, 46.704075], [-69.267628, 47.439844], [-68.197047, 47.341401], [-67.805185, 47.035631], [-67.772835, 45.828057], [-67.176015, 45.178656]]], [[[-52.628814, 47.526679], [-53.846791, 47.706773], [-53.639475, 48.169338], [-54.041086, 49.480862], [-55.22289, 49.264553], [-55.938629, 49.609687], [-56.148793, 50.155707], [-55.491851, 51.388373], [-56.720204, 51.319322], [-57.456858, 50.483059], [-58.703236, 48.554511], [-59.398834, 47.880316], [-59.111643, 47.560777], [-58.097768, 47.695217], [-56.777455, 47.533393], [-55.968495, 47.759833], [-55.250885, 46.91885], [-54.013295, 47.807196], [-53.572581, 47.162055], [-52.628814, 47.526679]]], [[[-132.579416, 53.207221], [-132.007883, 53.253567], [-131.693267, 53.991889], [-133.040517, 54.164537], [-132.579416, 53.207221]]], [[[-95.214915, 68.862209], [-97.92516, 69.895941], [-99.417226, 68.890448], [-96.295888, 68.480129], [-95.214915, 68.862209]]], [[[-100.872182, 69.807807], [-104.591176, 71.073188], [-104.380279, 71.600043], [-105.332509, 72.751125], [-106.823394, 73.309638], [-110.076405, 72.995754], [-110.729115, 72.571275], [-114.584299, 73.384589], [-117.369252, 72.917792], [-118.593414, 72.428697], [-119.050893, 71.626776], [-117.585439, 70.60635], [-113.988393, 70.716213], [-114.184722, 70.315741], [-117.442047, 69.989407], [-115.974843, 69.300198], [-113.583974, 69.201239], [-112.583323, 68.50967], [-108.95165, 68.742418], [-105.885854, 69.176459], [-103.383168, 68.782294], [-101.964508, 68.969631], [-102.054799, 69.484442], [-100.872182, 69.807807]]], [[[-72.948964, 66.734565], [-74.345408, 66.225816], [-73.717844, 65.774604], [-74.502919, 65.343492], [-76.676869, 65.411078], [-77.963612, 65.051947], [-77.757802, 64.342963], [-74.713083, 64.382025], [-73.452341, 64.591498], [-71.372629, 63.048529], [-68.55366, 62.249742], [-66.274485, 61.8581], [-65.930002, 62.197821], [-68.146921, 63.160631], [-68.496205, 63.740302], [-65.169626, 62.568427], [-64.484364, 63.287787], [-64.676137, 63.740302], [-66.649363, 64.969916], [-67.170115, 65.932563], [-66.961293, 66.540839], [-64.902089, 65.28205], [-63.497507, 65.370836], [-61.954213, 66.019761], [-61.270457, 66.604804], [-62.004099, 67.036811], [-63.888499, 67.241604], [-64.719553, 67.985175], [-66.214101, 68.005072], [-68.133168, 68.837226], [-67.115468, 69.728664], [-68.362253, 70.584296], [-70.516428, 70.937079], [-72.472605, 71.647284], [-73.253896, 71.330024], [-75.063181, 72.128241], [-75.198354, 72.497626], [-77.601023, 72.755805], [-79.283396, 72.394436], [-80.558461, 72.62759], [-81.528391, 73.716213], [-84.836659, 73.389065], [-86.714182, 73.847398], [-89.051218, 73.251695], [-90.104685, 71.916327], [-89.449086, 70.908026], [-87.852528, 70.237982], [-85.782908, 69.995185], [-81.071523, 70.09927], [-78.888051, 69.893785], [-75.687449, 69.287055], [-72.988393, 68.173285], [-72.350901, 67.119452], [-72.948964, 66.734565]]], [[[-115.323638, 73.508694], [-118.065989, 74.280707], [-121.5808, 74.555854], [-124.755686, 74.348578], [-123.773752, 73.765611], [-125.752065, 72.147691], [-123.099924, 71.084621], [-120.635243, 71.491441], [-120.196685, 72.217027], [-115.323638, 73.508694]]], [[[-123.321197, 48.495836], [-123.813385, 49.118598], [-124.794749, 49.472561], [-125.436187, 50.313625], [-127.198557, 50.621283], [-128.319814, 50.609809], [-126.572662, 49.414293], [-124.665395, 48.573228], [-123.321197, 48.495836]]], [[[-80.183339, 63.767564], [-81.771311, 64.51203], [-84.098459, 65.20954], [-85.201039, 65.809272], [-86.028391, 65.665025], [-86.405751, 64.438544], [-85.617014, 63.674872], [-84.59024, 63.315131], [-82.533925, 63.973049], [-81.01887, 63.450263], [-80.183339, 63.767564]]], [[[-75.033518, 68.173285], [-76.688873, 68.262641], [-76.981557, 67.243313], [-75.065053, 67.552436], [-75.033518, 68.173285]]], [[[-80.903391, 73.607367], [-79.948638, 72.84748], [-76.318959, 72.816718], [-78.138539, 73.66885], [-80.903391, 73.607367]]], [[[-96.32127, 72.466864], [-97.660747, 73.036689], [-97.570456, 73.893704], [-100.885845, 73.832099], [-100.111187, 73.03266], [-100.636537, 72.186469], [-99.235088, 71.349595], [-96.559673, 71.83983], [-96.32127, 72.466864]]], [[[-90.291005, 73.923285], [-93.272572, 74.177965], [-95.248647, 74.009914], [-95.592681, 72.699449], [-91.857655, 72.847968], [-90.291005, 73.923285]]], [[[-93.43692, 74.944322], [-94.39391, 75.604234], [-96.604563, 75.064846], [-94.724192, 74.631781], [-93.43692, 74.944322]]], [[[-97.424875, 75.519721], [-97.66511, 76.486151], [-100.269643, 76.649563], [-102.035146, 76.412787], [-100.223256, 75.666693], [-100.33019, 75.014065], [-98.035024, 75.026272], [-97.424875, 75.519721]]], [[[-105.395131, 75.674262], [-105.759918, 75.99018], [-112.432485, 76.170844], [-115.512318, 76.456204], [-117.674875, 75.293158], [-113.693023, 74.444485], [-106.008901, 75.056627], [-105.395131, 75.674262]]], [[[-79.56017, 74.991523], [-81.113149, 75.77912], [-83.91808, 75.814521], [-86.19636, 75.415351], [-88.245351, 75.47842], [-91.44107, 76.693671], [-95.770741, 77.076728], [-95.823313, 76.399115], [-93.128326, 76.370185], [-91.620839, 74.711615], [-87.643625, 74.458319], [-81.788686, 74.458401], [-79.56017, 74.991523]]], [[[-119.317494, 76.199897], [-115.892812, 76.698432], [-118.820546, 77.361029], [-122.861724, 76.182359], [-119.317494, 76.199897]]], [[[-105.606801, 79.103583], [-103.497182, 78.506537], [-99.557932, 78.5987], [-103.721588, 79.363349], [-105.606801, 79.103583]]], [[[-96.623118, 80.044338], [-95.645985, 79.394273], [-92.952748, 78.436713], [-88.770009, 78.185533], [-84.918609, 79.295966], [-86.971262, 79.881578], [-87.700551, 80.412584], [-90.639231, 80.568508], [-92.236928, 81.256334], [-95.231842, 81.015326], [-96.623118, 80.044338]]], [[[-61.085479, 82.331163], [-69.483388, 83.04564], [-79.340484, 82.975531], [-82.711578, 82.381456], [-91.708526, 81.734361], [-89.826161, 81.013048], [-86.437734, 80.320014], [-86.449859, 79.754218], [-84.32962, 79.192369], [-87.482452, 78.451972], [-88.180979, 77.801459], [-87.474599, 77.111396], [-89.170196, 76.42418], [-85.249094, 76.316107], [-78.141347, 76.527167], [-79.432403, 77.238593], [-77.980702, 77.816718], [-75.581939, 78.113023], [-74.468739, 79.227159], [-71.142405, 79.789496], [-68.279368, 80.759874], [-64.692128, 81.390815], [-61.085479, 82.331163]]], [[[-60.080881, 45.792426], [-60.603261, 47.033637], [-61.541127, 46.040473], [-61.334869, 45.56623], [-60.080881, 45.792426]]], [[[-64.319203, 49.791246], [-63.586781, 49.38996], [-62.248199, 49.067939], [-61.882639, 49.354478], [-63.374664, 49.829413], [-64.319203, 49.791246]]]]}, "properties": {"name": "Canada", "ISO3166-1-Alpha-3": "CAN", "ISO3166-1-Alpha-2": "CA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-97.139272, 25.965806], [-98.222656, 26.075412], [-99.085498, 26.40764], [-99.507203, 27.57377], [-100.284339, 28.296517], [-100.668967, 29.116208], [-101.409309, 29.765781], [-102.683469, 29.743715], [-103.147989, 28.985105], [-104.530824, 29.667906], [-105.008495, 30.676992], [-106.517189, 31.773824], [-108.215121, 31.777751], [-108.214811, 31.327443], [-111.067118, 31.333644], [-114.822108, 32.50024], [-117.125121, 32.531669], [-116.675608, 31.552639], [-116.068023, 30.813178], [-115.697564, 29.755826], [-114.972035, 29.377753], [-114.042592, 28.458441], [-114.487542, 27.238098], [-112.343495, 26.178127], [-112.075994, 25.7134], [-112.088043, 24.773383], [-110.307362, 23.541032], [-109.476459, 23.560205], [-110.656972, 24.806918], [-111.307116, 25.779207], [-111.560055, 26.695653], [-112.773218, 27.863902], [-112.902362, 28.475342], [-114.660302, 30.183252], [-114.85291, 31.526606], [-113.048723, 31.156692], [-113.128841, 30.81244], [-112.150725, 28.962978], [-111.250662, 28.052013], [-110.586578, 27.837063], [-110.554758, 27.384752], [-109.255727, 26.497992], [-109.440541, 25.793402], [-108.450795, 25.269355], [-107.995188, 24.648383], [-107.035878, 23.98078], [-105.80016, 22.638454], [-105.632264, 21.955737], [-105.184967, 21.44302], [-105.698681, 20.406911], [-105.01712, 19.368476], [-103.979977, 18.874938], [-103.4984, 18.333483], [-102.190629, 17.915988], [-101.00824, 17.252976], [-99.741241, 16.730618], [-98.85655, 16.528179], [-97.772719, 15.979454], [-96.556038, 15.659025], [-95.434478, 15.97538], [-94.852081, 16.4289], [-94.127838, 16.220852], [-92.824005, 15.172797], [-92.246257, 14.546279], [-92.204651, 15.289507], [-91.723776, 16.068788], [-90.485738, 16.0707], [-90.398793, 16.347582], [-91.066866, 16.918193], [-90.990901, 17.801964], [-89.160496, 17.814314], [-88.303822, 18.48135], [-87.77123, 18.406399], [-87.423207, 19.55801], [-87.444, 20.191148], [-86.741567, 21.16413], [-87.962066, 21.600084], [-89.785959, 21.284573], [-90.360097, 21.000393], [-90.477823, 19.922101], [-90.730092, 19.375922], [-91.431188, 18.880117], [-94.207143, 18.189643], [-95.208363, 18.708645], [-95.85204, 18.716783], [-96.456125, 19.868801], [-97.150543, 20.64704], [-97.657582, 21.636542], [-97.889394, 22.625434], [-97.651438, 24.515855], [-97.139272, 25.965806]]]}, "properties": {"name": "Mexico", "ISO3166-1-Alpha-3": "MEX", "ISO3166-1-Alpha-2": "MX"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-89.160496, 17.814314], [-89.236512, 15.893915], [-88.913971, 15.893948], [-88.215077, 16.967108], [-88.091542, 18.118394], [-88.303822, 18.48135], [-89.160496, 17.814314]]]}, "properties": {"name": "Belize", "ISO3166-1-Alpha-3": "BLZ", "ISO3166-1-Alpha-2": "BZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-82.573598, 9.576199], [-82.897629, 8.034748], [-82.658925, 8.322781], [-81.711456, 8.130316], [-80.873077, 7.212551], [-79.994659, 7.503445], [-80.470245, 8.220649], [-79.774396, 8.57955], [-79.482004, 8.998358], [-78.757639, 8.82925], [-77.895839, 7.235098], [-77.2012, 7.981995], [-77.373525, 8.66474], [-78.024566, 9.226467], [-79.550969, 9.629292], [-80.822418, 8.890692], [-81.539052, 8.812934], [-82.245961, 9.014146], [-82.573598, 9.576199]]]}, "properties": {"name": "Panama", "ISO3166-1-Alpha-3": "PAN", "ISO3166-1-Alpha-2": "PA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-60.020985, 8.55801], [-60.984771, 8.570543], [-60.783803, 9.338609], [-61.439168, 9.818411], [-61.876765, 9.823682], [-62.831125, 10.406806], [-62.719635, 10.759996], [-64.143463, 10.480129], [-64.748118, 10.106879], [-65.868479, 10.309556], [-66.541575, 10.632717], [-67.941233, 10.466457], [-68.417592, 11.179145], [-68.863189, 11.452867], [-70.180002, 11.37637], [-71.458323, 10.974921], [-71.526479, 10.544745], [-71.04186, 9.760891], [-71.514801, 9.048977], [-72.124379, 9.826077], [-71.576283, 10.721096], [-71.96052, 11.591254], [-71.327507, 11.849998], [-71.97108, 11.661925], [-72.907561, 10.452464], [-73.009725, 9.295377], [-72.386766, 8.338614], [-72.451309, 7.440219], [-72.080996, 7.066598], [-70.096621, 6.944435], [-69.443638, 6.122237], [-67.573984, 6.266234], [-67.843684, 5.297249], [-67.865078, 4.512077], [-67.304647, 3.425709], [-67.838619, 2.88613], [-67.325421, 2.47463], [-66.875061, 1.22251], [-66.346204, 0.759386], [-65.136769, 1.126909], [-64.080864, 1.647394], [-64.222923, 3.123996], [-64.063811, 3.911597], [-62.766216, 4.020712], [-61.542104, 4.263023], [-60.612626, 4.900581], [-60.739854, 5.202138], [-61.379608, 5.9053], [-61.204787, 6.595826], [-60.420958, 6.942213], [-60.730578, 7.525433], [-59.815595, 8.287764], [-60.020985, 8.55801]]]}, "properties": {"name": "Venezuela", "ISO3166-1-Alpha-3": "VEN", "ISO3166-1-Alpha-2": "VE"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[140.974457, -2.600518], [140.975767, -4.595946], [140.977162, -6.896632], [140.97698, -9.106134], [142.652599, -9.330255], [143.405528, -8.962172], [142.980479, -8.335626], [143.592052, -8.241957], [144.431326, -7.612237], [146.102794, -8.100274], [146.575857, -8.821954], [147.761729, -10.059747], [149.460623, -10.352309], [149.939464, -10.062433], [149.182872, -9.368097], [148.559255, -9.032485], [148.114024, -8.055352], [147.161876, -7.390558], [146.977306, -6.740411], [147.854015, -6.691013], [147.449067, -5.961602], [145.727387, -5.422459], [145.81186, -4.861017], [144.513438, -3.81756], [142.181895, -3.080173], [140.974457, -2.600518]]], [[[155.933442, -6.640802], [155.069347, -5.552179], [154.75587, -5.947849], [155.36085, -6.745864], [155.933442, -6.640802]]], [[[152.417979, -4.34531], [151.540294, -4.17669], [151.695649, -4.81878], [150.719005, -5.534112], [149.390473, -5.587335], [148.429942, -5.444757], [148.386241, -5.775079], [149.629405, -6.299249], [150.465099, -6.274021], [152.098888, -5.455743], [152.417979, -4.34531]]]]}, "properties": {"name": "Papua New Guinea", "ISO3166-1-Alpha-3": "PNG", "ISO3166-1-Alpha-2": "PG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.886729, 29.490058], [34.248351, 31.211449], [34.200269, 31.314267], [33.64796, 31.117255], [32.056977, 31.079657], [31.220958, 31.579901], [30.362153, 31.508734], [29.028087, 30.827053], [27.320649, 31.381008], [25.15089, 31.65648], [24.688343, 30.144156], [24.981245, 29.181372], [24.981245, 25.205439], [24.981245, 21.995351], [28.290345, 21.994783], [31.248407, 21.994369], [33.181136, 21.995409], [34.084179, 21.995454], [36.883637, 21.995714], [35.621087, 23.139293], [35.513927, 23.977118], [34.014008, 26.61164], [33.512543, 27.959052], [34.078461, 27.800686], [34.886729, 29.490058]]]}, "properties": {"name": "Egypt", "ISO3166-1-Alpha-3": "EGY", "ISO3166-1-Alpha-2": "EG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[51.978615, 18.995638], [49.128815, 18.612095], [48.161949, 18.148919], [47.427575, 17.091826], [46.970342, 16.956847], [45.165387, 17.428395], [43.165044, 17.32592], [43.208608, 16.773396], [42.78948, 16.370958], [42.699229, 15.717434], [43.248871, 13.212714], [43.473155, 12.846625], [44.191173, 12.619127], [44.868337, 12.732123], [45.665782, 13.341986], [46.700694, 13.430325], [48.016856, 14.057278], [48.681488, 14.043606], [49.077973, 14.50609], [52.228363, 15.616116], [52.292735, 16.263821], [53.090343, 16.642401], [51.978615, 18.995638]]]}, "properties": {"name": "Yemen", "ISO3166-1-Alpha-3": "YEM", "ISO3166-1-Alpha-2": "YE"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-8.682385, 27.285416], [-8.680809, 26.013142], [-12.015308, 25.9949], [-12.015308, 23.495182], [-13.015247, 23.018002], [-13.015247, 21.333428], [-16.958831, 21.332859], [-17.056874, 20.766913], [-16.199696, 20.215155], [-16.474599, 19.270819], [-16.204579, 18.982733], [-16.024159, 17.96015], [-16.459218, 16.643948], [-16.542348, 15.808824], [-16.327397, 16.474551], [-14.343254, 16.63666], [-12.26413, 14.774939], [-11.837076, 14.893097], [-11.727057, 15.541171], [-9.835596, 15.371104], [-9.349218, 15.495644], [-5.510951, 15.495903], [-5.353286, 16.311977], [-5.623347, 16.527829], [-5.963533, 19.620612], [-6.219383, 21.822855], [-6.593107, 24.994134], [-4.821613, 24.995065], [-8.682385, 27.285416]]]}, "properties": {"name": "Mauritania", "ISO3166-1-Alpha-3": "MRT", "ISO3166-1-Alpha-2": "MR"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[9.799571, 2.341742], [9.407563, 1.28384], [9.804371, 0.998354], [11.336341, 0.999165], [11.322078, 2.16576], [9.990997, 2.165605], [9.799571, 2.341742]]]}, "properties": {"name": "Equatorial Guinea", "ISO3166-1-Alpha-3": "GNQ", "ISO3166-1-Alpha-2": "GQ"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Gambia", "ISO3166-1-Alpha-3": "GMB", "ISO3166-1-Alpha-2": "GM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Hong Kong S.A.R.", "ISO3166-1-Alpha-3": "HKG", "ISO3166-1-Alpha-2": "HK"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Vatican", "ISO3166-1-Alpha-3": "VAT", "ISO3166-1-Alpha-2": "VA"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[34.012289, 35.063795], [34.035622, 35.471383], [32.711388, 35.181632], [33.679435, 35.033899], [33.906505, 35.069105], [34.012289, 35.063795]]]}, "properties": {"name": "Northern Cyprus", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[33.906505, 35.069105], [33.898116, 35.061272], [34.021961, 35.057009], [34.012289, 35.063795], [33.906505, 35.069105]]], [[[33.679435, 35.033899], [32.711388, 35.181632], [32.691549, 35.183705], [33.702935, 34.987943], [33.679435, 35.033899]]], [[[32.640694, 35.187077], [32.601899, 35.178616], [32.584809, 35.172512], [32.659813, 35.187082], [32.640694, 35.187077]]]]}, "properties": {"name": "Cyprus No Mans Area", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[77.048971, 35.110442], [77.800346, 35.495406], [76.777351, 35.646112], [77.048971, 35.110442]]]}, "properties": {"name": "Siachen Glacier", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Baykonur Cosmodrome", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Akrotiri Sovereign Base Area", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Southern Patagonian Ice Field", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[33.181136, 21.995409], [33.558442, 21.710957], [33.866456, 21.749724], [34.084179, 21.995454], [33.181136, 21.995409]]]}, "properties": {"name": "Bir Tawil", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-51.730641, -82.062557], [-55.927235, -82.483087], [-59.46996, -83.460138], [-62.795033, -82.519708], [-66.062408, -82.195408], [-65.174306, -81.512302], [-69.975819, -80.976007], [-74.838287, -80.919366], [-77.754018, -78.735772], [-77.685414, -77.929864], [-75.526601, -77.541192], [-77.52359, -76.72975], [-76.499257, -76.524998], [-70.332143, -76.677667], [-63.16161, -75.409438], [-63.57311, -74.871352], [-60.681956, -74.324802], [-60.093577, -73.299249], [-61.365631, -72.465997], [-60.93456, -71.182224], [-62.380483, -70.413995], [-63.21231, -68.838637], [-65.338531, -68.563409], [-64.749135, -67.307306], [-63.755767, -66.214614], [-62.372426, -65.915704], [-61.113189, -64.381443], [-63.826975, -65.030206], [-64.686513, -66.038344], [-65.800364, -66.715753], [-67.540395, -67.138767], [-66.759145, -69.071222], [-68.361887, -69.683364], [-66.747955, -72.092055], [-67.511098, -72.83408], [-74.105133, -73.716078], [-78.744049, -73.364923], [-81.311187, -73.273045], [-82.064768, -73.886977], [-85.542877, -73.37184], [-89.283803, -73.106622], [-96.474965, -73.299493], [-97.717437, -73.025323], [-99.768056, -73.36419], [-101.589223, -74.000177], [-99.323964, -74.962579], [-100.137441, -75.384454], [-103.295969, -75.085382], [-106.449127, -75.316827], [-109.69286, -75.146091], [-114.83849, -74.471449], [-119.134999, -74.598077], [-124.05134, -74.81113], [-126.939076, -74.67669], [-129.975087, -74.913263], [-135.342397, -74.560154], [-136.877594, -75.038181], [-142.46703, -75.431736], [-146.209218, -75.91546], [-145.592519, -76.916762], [-149.974965, -77.816176], [-151.514597, -77.416436], [-156.323842, -77.066095], [-158.637074, -77.864516], [-154.969797, -79.034763], [-147.945668, -79.786554], [-150.60912, -80.269708], [-147.628, -80.92018], [-156.450144, -81.148696], [-153.790354, -81.691013], [-152.542917, -82.54811], [-163.70283, -84.0499], [-168.254709, -84.648207], [-180, -84.352796], [-180, -84.515235], [-180, -85.01385], [-180, -85.512465], [-180, -86.01108], [-180, -86.509695], [-180, -87.00831], [-180, -87.506925], [-180, -88.00554], [-180, -88.504155], [-180, -89.00277], [-180, -89.501385], [-180, -90], [-179.500693, -90], [-179.001387, -90], [-178.50208, -90], [-178.002774, -90], [-177.503467, -90], [-177.004161, -90], [-176.504854, -90], [-176.005548, -90], [-175.506241, -90], [-175.006935, -90], [-174.507628, -90], [-174.008322, -90], [-173.509015, -90], [-173.009709, -90], [-172.510402, -90], [-172.011096, -90], [-171.511789, -90], [-171.012483, -90], [-170.513176, -90], [-170.01387, -90], [-169.514563, -90], [-169.015257, -90], [-168.51595, -90], [-168.016644, -90], [-167.517337, -90], [-167.018031, -90], [-166.518724, -90], [-166.019417, -90], [-165.520111, -90], [-165.020804, -90], [-164.521498, -90], [-164.022191, -90], [-163.522885, -90], [-163.023578, -90], [-162.524272, -90], [-162.024965, -90], [-161.525659, -90], [-161.026352, -90], [-160.527046, -90], [-160.027739, -90], [-159.528433, -90], [-159.029126, -90], [-158.52982, -90], [-158.030513, -90], [-157.531207, -90], [-157.0319, -90], [-156.532594, -90], [-156.033287, -90], [-155.533981, -90], [-155.034674, -90], [-154.535368, -90], [-154.036061, -90], [-153.536755, -90], [-153.037448, -90], [-152.538141, -90], [-152.038835, -90], [-151.539528, -90], [-151.040222, -90], [-150.540915, -90], [-150.041609, -90], [-149.542302, -90], [-149.042996, -90], [-148.543689, -90], [-148.044383, -90], [-147.545076, -90], [-147.04577, -90], [-146.546463, -90], [-146.047157, -90], [-145.54785, -90], [-145.048544, -90], [-144.549237, -90], [-144.049931, -90], [-143.550624, -90], [-143.051318, -90], [-142.552011, -90], [-142.052705, -90], [-141.553398, -90], [-141.054092, -90], [-140.554785, -90], [-140.055479, -90], [-139.556172, -90], [-139.056865, -90], [-138.557559, -90], [-138.058252, -90], [-137.558946, -90], [-137.059639, -90], [-136.560333, -90], [-136.061026, -90], [-135.56172, -90], [-135.062413, -90], [-134.563107, -90], [-134.0638, -90], [-133.564494, -90], [-133.065187, -90], [-132.565881, -90], [-132.066574, -90], [-131.567268, -90], [-131.067961, -90], [-130.568655, -90], [-130.069348, -90], [-129.570042, -90], [-129.070735, -90], [-128.571429, -90], [-128.072122, -90], [-127.572816, -90], [-127.073509, -90], [-126.574202, -90], [-126.074896, -90], [-125.575589, -90], [-125.076283, -90], [-124.576976, -90], [-124.07767, -90], [-123.578363, -90], [-123.079057, -90], [-122.57975, -90], [-122.080444, -90], [-121.581137, -90], [-121.081831, -90], [-120.582524, -90], [-120.083218, -90], [-119.583911, -90], [-119.084605, -90], [-118.585298, -90], [-118.085992, -90], [-117.586685, -90], [-117.087379, -90], [-116.588072, -90], [-116.088766, -90], [-115.589459, -90], [-115.090153, -90], [-114.590846, -90], [-114.09154, -90], [-113.592233, -90], [-113.092926, -90], [-112.59362, -90], [-112.094313, -90], [-111.595007, -90], [-111.0957, -90], [-110.596394, -90], [-110.097087, -90], [-109.597781, -90], [-109.098474, -90], [-108.599168, -90], [-108.099861, -90], [-107.600555, -90], [-107.101248, -90], [-106.601942, -90], [-106.102635, -90], [-105.603329, -90], [-105.104022, -90], [-104.604716, -90], [-104.105409, -90], [-103.606103, -90], [-103.106796, -90], [-102.60749, -90], [-102.108183, -90], [-101.608877, -90], [-101.10957, -90], [-100.610264, -90], [-100.110957, -90], [-99.61165, -90], [-99.112344, -90], [-98.613037, -90], [-98.113731, -90], [-97.614424, -90], [-97.115118, -90], [-96.615811, -90], [-96.116505, -90], [-95.617198, -90], [-95.117892, -90], [-94.618585, -90], [-94.119279, -90], [-93.619972, -90], [-93.120666, -90], [-92.621359, -90], [-92.122053, -90], [-91.622746, -90], [-91.12344, -90], [-90.624133, -90], [-90.124827, -90], [-89.62552, -90], [-89.126214, -90], [-88.626907, -90], [-88.127601, -90], [-87.628294, -90], [-87.128988, -90], [-86.629681, -90], [-86.130374, -90], [-85.631068, -90], [-85.131761, -90], [-84.632455, -90], [-84.133148, -90], [-83.633842, -90], [-83.134535, -90], [-82.635229, -90], [-82.135922, -90], [-81.636616, -90], [-81.137309, -90], [-80.638003, -90], [-80.138696, -90], [-79.63939, -90], [-79.140083, -90], [-78.640777, -90], [-78.14147, -90], [-77.642164, -90], [-77.142857, -90], [-76.643551, -90], [-76.144244, -90], [-75.644938, -90], [-75.145631, -90], [-74.646325, -90], [-74.147018, -90], [-73.647712, -90], [-73.148405, -90], [-72.649098, -90], [-72.149792, -90], [-71.650485, -90], [-71.151179, -90], [-70.651872, -90], [-70.152566, -90], [-69.653259, -90], [-69.153953, -90], [-68.654646, -90], [-68.15534, -90], [-67.656033, -90], [-67.156727, -90], [-66.65742, -90], [-66.158114, -90], [-65.658807, -90], [-65.159501, -90], [-64.660194, -90], [-64.160888, -90], [-63.661581, -90], [-63.162275, -90], [-62.662968, -90], [-62.163662, -90], [-61.664355, -90], [-61.165049, -90], [-60.665742, -90], [-60.166436, -90], [-59.667129, -90], [-59.167822, -90], [-58.668516, -90], [-58.169209, -90], [-57.669903, -90], [-57.170596, -90], [-56.67129, -90], [-56.171983, -90], [-55.672677, -90], [-55.17337, -90], [-54.674064, -90], [-54.174757, -90], [-53.675451, -90], [-53.176144, -90], [-52.676838, -90], [-52.177531, -90], [-52.117718, -90], [-51.678225, -90], [-51.178918, -90], [-50.679612, -90], [-50.180305, -90], [-49.680999, -90], [-49.181692, -90], [-48.682386, -90], [-48.183079, -90], [-47.683773, -90], [-47.184466, -90], [-46.68516, -90], [-46.185853, -90], [-45.686546, -90], [-45.18724, -90], [-44.687933, -90], [-44.188627, -90], [-43.68932, -90], [-43.190014, -90], [-42.690707, -90], [-42.191401, -90], [-41.692094, -90], [-41.192788, -90], [-40.693481, -90], [-40.194175, -90], [-39.694868, -90], [-39.195562, -90], [-38.696255, -90], [-38.196949, -90], [-37.697642, -90], [-37.198336, -90], [-36.699029, -90], [-36.199723, -90], [-35.700416, -90], [-35.20111, -90], [-34.701803, -90], [-34.202497, -90], [-33.70319, -90], [-33.203883, -90], [-32.704577, -90], [-32.20527, -90], [-31.705964, -90], [-31.206657, -90], [-30.707351, -90], [-30.208044, -90], [-29.708738, -90], [-29.209431, -90], [-28.710125, -90], [-28.210818, -90], [-27.711512, -90], [-27.212205, -90], [-26.712899, -90], [-26.213592, -90], [-25.714286, -90], [-25.214979, -90], [-24.715673, -90], [-24.216366, -90], [-23.71706, -90], [-23.217753, -90], [-22.718447, -90], [-22.21914, -90], [-21.719834, -90], [-21.220527, -90], [-20.721221, -90], [-20.221914, -90], [-19.722607, -90], [-19.223301, -90], [-18.723994, -90], [-18.224688, -90], [-17.725381, -90], [-17.226075, -90], [-16.726768, -90], [-16.227462, -90], [-15.728155, -90], [-15.228849, -90], [-14.729542, -90], [-14.230236, -90], [-13.730929, -90], [-13.231623, -90], [-12.732316, -90], [-12.23301, -90], [-11.733703, -90], [-11.234397, -90], [-10.73509, -90], [-10.235784, -90], [-9.736477, -90], [-9.237171, -90], [-8.737864, -90], [-8.238558, -90], [-7.739251, -90], [-7.239945, -90], [-6.740638, -90], [-6.241331, -90], [-5.742025, -90], [-5.242718, -90], [-4.743412, -90], [-4.244105, -90], [-3.744799, -90], [-3.245492, -90], [-2.746186, -90], [-2.246879, -90], [-1.747573, -90], [-1.248266, -90], [-0.74896, -90], [-0.249653, -90], [0.249653, -90], [0.74896, -90], [1.248266, -90], [1.747573, -90], [2.246879, -90], [2.746186, -90], [3.245492, -90], [3.744799, -90], [4.244105, -90], [4.743412, -90], [5.242718, -90], [5.742025, -90], [6.241331, -90], [6.740638, -90], [7.239945, -90], [7.739251, -90], [8.238558, -90], [8.737864, -90], [9.237171, -90], [9.736477, -90], [10.235784, -90], [10.73509, -90], [11.234397, -90], [11.733703, -90], [12.23301, -90], [12.732316, -90], [13.231623, -90], [13.730929, -90], [14.230236, -90], [14.729542, -90], [15.228849, -90], [15.728155, -90], [16.227462, -90], [16.726768, -90], [17.226075, -90], [17.725381, -90], [18.224688, -90], [18.723994, -90], [19.223301, -90], [19.722607, -90], [20.221914, -90], [20.721221, -90], [21.220527, -90], [21.719834, -90], [22.21914, -90], [22.718447, -90], [23.217753, -90], [23.71706, -90], [24.216366, -90], [24.715673, -90], [25.214979, -90], [25.714286, -90], [26.213592, -90], [26.712899, -90], [27.212205, -90], [27.711512, -90], [28.210818, -90], [28.710125, -90], [29.209431, -90], [29.708738, -90], [30.208044, -90], [30.707351, -90], [31.206657, -90], [31.705964, -90], [32.20527, -90], [32.704577, -90], [33.203883, -90], [33.70319, -90], [34.202497, -90], [34.701803, -90], [35.20111, -90], [35.700416, -90], [36.199723, -90], [36.699029, -90], [37.198336, -90], [37.697642, -90], [38.196949, -90], [38.696255, -90], [39.195562, -90], [39.694868, -90], [40.194175, -90], [40.693481, -90], [41.192788, -90], [41.692094, -90], [42.191401, -90], [42.690707, -90], [43.190014, -90], [43.68932, -90], [44.188627, -90], [44.687933, -90], [45.18724, -90], [45.686546, -90], [46.185853, -90], [46.68516, -90], [47.184466, -90], [47.683773, -90], [48.183079, -90], [48.682386, -90], [49.181692, -90], [49.680999, -90], [50.180305, -90], [50.679612, -90], [51.178918, -90], [51.678225, -90], [52.177531, -90], [52.676838, -90], [53.176144, -90], [53.675451, -90], [54.174757, -90], [54.674064, -90], [55.17337, -90], [55.672677, -90], [56.171983, -90], [56.67129, -90], [57.170596, -90], [57.669903, -90], [58.169209, -90], [58.668516, -90], [59.167822, -90], [59.667129, -90], [60.166436, -90], [60.665742, -90], [61.165049, -90], [61.651875, -90], [61.664355, -90], [62.163662, -90], [62.662968, -90], [63.162275, -90], [63.661581, -90], [64.160888, -90], [64.660194, -90], [65.159501, -90], [65.658807, -90], [66.158114, -90], [66.65742, -90], [67.156727, -90], [67.656033, -90], [68.15534, -90], [68.654646, -90], [69.153953, -90], [69.653259, -90], [70.152566, -90], [70.651872, -90], [71.151179, -90], [71.650485, -90], [72.149792, -90], [72.649098, -90], [73.148405, -90], [73.647712, -90], [74.147018, -90], [74.646325, -90], [75.145631, -90], [75.644938, -90], [76.144244, -90], [76.643551, -90], [77.142857, -90], [77.642164, -90], [78.14147, -90], [78.640777, -90], [79.140083, -90], [79.63939, -90], [80.138696, -90], [80.638003, -90], [81.137309, -90], [81.636616, -90], [82.135922, -90], [82.635229, -90], [83.134535, -90], [83.633842, -90], [84.133148, -90], [84.632455, -90], [85.131761, -90], [85.631068, -90], [86.130374, -90], [86.629681, -90], [87.128988, -90], [87.628294, -90], [88.127601, -90], [88.626907, -90], [89.126214, -90], [89.62552, -90], [90.124827, -90], [90.624133, -90], [91.12344, -90], [91.622746, -90], [92.122053, -90], [92.621359, -90], [93.120666, -90], [93.619972, -90], [94.119279, -90], [94.618585, -90], [95.117892, -90], [95.617198, -90], [96.116505, -90], [96.615811, -90], [97.115118, -90], [97.614424, -90], [98.113731, -90], [98.613037, -90], [99.112344, -90], [99.61165, -90], [100.110957, -90], [100.610264, -90], [101.10957, -90], [101.608877, -90], [102.108183, -90], [102.60749, -90], [103.106796, -90], [103.606103, -90], [104.105409, -90], [104.604716, -90], [105.104022, -90], [105.603329, -90], [106.102635, -90], [106.601942, -90], [107.101248, -90], [107.600555, -90], [108.099861, -90], [108.599168, -90], [109.098474, -90], [109.597781, -90], [110.097087, -90], [110.596394, -90], [111.0957, -90], [111.595007, -90], [112.094313, -90], [112.59362, -90], [113.092926, -90], [113.592233, -90], [114.09154, -90], [114.590846, -90], [115.090153, -90], [115.589459, -90], [116.088766, -90], [116.588072, -90], [117.087379, -90], [117.586685, -90], [118.085992, -90], [118.585298, -90], [119.084605, -90], [119.583911, -90], [120.083218, -90], [120.582524, -90], [121.081831, -90], [121.581137, -90], [122.080444, -90], [122.57975, -90], [123.079057, -90], [123.578363, -90], [124.07767, -90], [124.576976, -90], [125.076283, -90], [125.575589, -90], [126.074896, -90], [126.574202, -90], [127.073509, -90], [127.572816, -90], [128.072122, -90], [128.571429, -90], [129.070735, -90], [129.570042, -90], [130.069348, -90], [130.568655, -90], [131.067961, -90], [131.567268, -90], [132.066574, -90], [132.565881, -90], [133.065187, -90], [133.564494, -90], [134.0638, -90], [134.563107, -90], [135.062413, -90], [135.56172, -90], [136.061026, -90], [136.560333, -90], [137.059639, -90], [137.558946, -90], [138.058252, -90], [138.557559, -90], [139.056865, -90], [139.556172, -90], [140.055479, -90], [140.554785, -90], [141.054092, -90], [141.553398, -90], [142.052705, -90], [142.552011, -90], [143.051318, -90], [143.550624, -90], [144.049931, -90], [144.549237, -90], [145.048544, -90], [145.54785, -90], [146.047157, -90], [146.546463, -90], [147.04577, -90], [147.545076, -90], [148.044383, -90], [148.543689, -90], [149.042996, -90], [149.542302, -90], [150.041609, -90], [150.540915, -90], [151.040222, -90], [151.539528, -90], [152.038835, -90], [152.538141, -90], [153.037448, -90], [153.536755, -90], [154.036061, -90], [154.535368, -90], [155.034674, -90], [155.533981, -90], [156.033287, -90], [156.532594, -90], [157.0319, -90], [157.531207, -90], [158.030513, -90], [158.52982, -90], [159.029126, -90], [159.528433, -90], [160.027739, -90], [160.527046, -90], [161.026352, -90], [161.525659, -90], [162.024965, -90], [162.524272, -90], [163.023578, -90], [163.522885, -90], [164.022191, -90], [164.521498, -90], [165.020804, -90], [165.520111, -90], [166.019417, -90], [166.518724, -90], [167.018031, -90], [167.517337, -90], [168.016644, -90], [168.51595, -90], [169.015257, -90], [169.514563, -90], [170.01387, -90], [170.513176, -90], [171.012483, -90], [171.511789, -90], [172.011096, -90], [172.510402, -90], [173.009709, -90], [173.509015, -90], [174.008322, -90], [174.507628, -90], [175.006935, -90], [175.506241, -90], [176.005548, -90], [176.504854, -90], [177.004161, -90], [177.503467, -90], [178.002774, -90], [178.50208, -90], [179.001387, -90], [179.500693, -90], [180, -90], [180, -89.501385], [180, -89.00277], [180, -88.504155], [180, -88.00554], [180, -87.506925], [180, -87.00831], [180, -86.509695], [180, -86.01108], [180, -85.512465], [180, -85.01385], [180, -84.515235], [180, -84.353382], [180, -84.352796], [173.962169, -83.881768], [167.74879, -83.255141], [167.306488, -82.817071], [161.011485, -81.548761], [161.200938, -80.648207], [160.386974, -79.168145], [164.207856, -78.225763], [163.069509, -77.018731], [162.591482, -74.750177], [165.288097, -74.465265], [164.591482, -73.92669], [169.2199, -73.132501], [170.255382, -72.60296], [170.813731, -71.699802], [167.87794, -71.014337], [164.240408, -70.491143], [161.990082, -70.92254], [159.753591, -69.501886], [157.992198, -69.172133], [156.350108, -69.201918], [155.011078, -68.898858], [153.250662, -68.878188], [151.094249, -68.406996], [148.435395, -68.473321], [146.718028, -68.303399], [146.048106, -67.586602], [143.880707, -67.933852], [144.662364, -67.116632], [143.470714, -66.845636], [140.081554, -66.733168], [136.159516, -66.245375], [133.792328, -66.105401], [130.729991, -66.147149], [129.202647, -67.100356], [126.998709, -66.932794], [125.897797, -66.306248], [122.244395, -66.844985], [119.022227, -67.411798], [118.367931, -67.046482], [115.337901, -67.219496], [115.598969, -66.592869], [113.203624, -65.765395], [110.927094, -66.05462], [110.44516, -66.658298], [108.832774, -66.968032], [108.135753, -66.62477], [103.908214, -65.978774], [101.336436, -65.944024], [100.821544, -66.384535], [98.90504, -66.783298], [98.527599, -66.448989], [93.997081, -66.731378], [91.960134, -66.498224], [90.372325, -66.820733], [87.965668, -66.770278], [79.131358, -68.147719], [77.841645, -69.13006], [74.274587, -69.907403], [73.472992, -69.780938], [71.796235, -70.716404], [71.121104, -71.730564], [66.999766, -72.903497], [67.449067, -71.895929], [68.669688, -71.203709], [70.115408, -68.493585], [69.682953, -67.864435], [68.37672, -67.895196], [63.65089, -67.503351], [62.617361, -67.660089], [58.870616, -67.165134], [56.601248, -66.943455], [55.611583, -66.027927], [53.734548, -65.84588], [51.772146, -66.037205], [48.588064, -66.93727], [48.718272, -67.681899], [46.21697, -67.653009], [42.859386, -68.103774], [39.857921, -68.829522], [39.713064, -69.586847], [35.243988, -69.73561], [34.214203, -68.677911], [32.498871, -68.930922], [32.851085, -69.945489], [29.841156, -70.311293], [26.445649, -71.032403], [23.323009, -70.809747], [19.289236, -70.914321], [16.598155, -70.3742], [13.018321, -70.241795], [11.853526, -70.781508], [9.167817, -70.308038], [2.519705, -70.900567], [0.232188, -71.349867], [-2.131256, -71.482599], [-6.14977, -71.366306], [-8.614125, -71.699884], [-9.939524, -71.062433], [-11.072092, -71.549005], [-11.356557, -72.396417], [-14.097483, -73.132989], [-15.541412, -73.076837], [-16.602854, -73.633559], [-19.240468, -75.548435], [-20.399485, -75.472101], [-25.94815, -75.933038], [-28.85912, -76.353285], [-33.721099, -77.310968], [-35.312856, -77.844415], [-36.251332, -78.851658], [-30.673248, -79.588067], [-29.760813, -80.175551], [-36.033193, -80.920668], [-38.75536, -80.871026], [-46.279205, -81.909763], [-51.730641, -82.062557]]], [[[-52.518788, -80.200128], [-54.16161, -80.87713], [-43.531565, -80.199965], [-42.971547, -79.429946], [-43.818837, -78.248305], [-45.934071, -77.817071], [-49.072336, -78.042169], [-50.204091, -78.593032], [-50.263661, -79.53574], [-52.518788, -80.200128]]], [[[-95.499216, -72.315606], [-98.828114, -71.760512], [-102.087026, -71.973891], [-98.580149, -72.572849], [-95.499216, -72.315606]]], [[[-71.460072, -72.629164], [-68.414133, -72.219659], [-68.299184, -70.869073], [-70.402089, -68.786798], [-72.012685, -68.955499], [-71.912221, -69.965916], [-70.998606, -70.465753], [-72.338938, -71.613458], [-70.719716, -71.905694], [-71.460072, -72.629164]]], [[[-159.842152, -79.348565], [-162.35322, -78.752618], [-163.936635, -79.411716], [-159.842152, -79.348565]]], [[[-120.26122, -73.92783], [-123.065582, -73.675388], [-123.028188, -74.248305], [-121.099029, -74.348077], [-120.26122, -73.92783]]]]}, "properties": {"name": "Antarctica", "ISO3166-1-Alpha-3": "ATA", "ISO3166-1-Alpha-2": "AQ"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[131.535899, -31.605564], [132.233735, -32.032973], [133.586192, -32.103123], [134.273285, -32.588067], [135.741466, -34.861017], [136.369395, -34.073419], [137.224864, -33.653253], [137.453868, -34.163995], [138.536306, -34.740899], [138.531261, -35.645929], [139.475434, -35.896417], [139.853689, -36.621759], [139.741547, -37.180841], [140.359874, -37.882094], [141.434418, -38.375095], [142.397716, -38.36712], [143.536957, -38.858331], [144.982677, -37.899347], [145.915782, -38.895766], [146.878591, -38.644301], [147.589366, -38.080662], [148.306407, -37.823419], [149.479015, -37.785903], [149.985362, -37.495864], [149.903819, -36.928399], [150.163422, -35.956638], [151.670584, -33.06113], [152.52156, -32.427494], [152.934502, -31.482811], [152.987796, -30.734634], [153.605968, -28.867446], [153.591075, -28.268487], [153.113617, -27.242364], [153.121593, -25.93377], [152.404796, -24.748712], [150.856619, -23.519952], [150.826427, -22.697931], [149.608572, -22.244317], [149.231212, -21.089776], [148.739757, -20.718927], [148.566661, -20.059991], [146.446544, -19.059747], [146.023774, -18.263116], [146.071056, -17.392022], [145.400401, -16.443292], [145.27711, -14.947035], [144.677582, -14.555841], [143.951996, -14.498956], [143.534434, -13.76572], [143.43393, -12.613051], [142.872895, -11.850681], [142.741954, -10.97503], [142.174571, -10.930108], [141.665701, -12.441095], [141.690766, -13.259861], [141.474132, -13.7763], [141.665701, -15.011407], [141.424815, -16.08172], [140.833263, -17.450779], [139.999522, -17.70615], [139.274181, -17.348321], [139.037283, -16.915623], [138.184255, -16.697198], [137.746104, -16.25213], [136.767914, -15.900811], [135.391287, -14.739923], [135.903331, -14.135837], [135.883365, -13.325764], [136.636944, -12.954941], [136.690603, -12.28281], [136.026134, -12.311456], [135.739268, -11.941502], [135.07016, -12.259942], [133.141124, -11.674574], [132.045665, -12.307387], [131.000173, -12.171319], [130.129893, -12.940525], [129.968923, -13.53045], [129.358084, -14.408787], [129.611827, -14.94256], [128.175304, -14.698419], [126.952891, -13.723565], [126.569184, -14.195245], [125.658051, -14.360284], [124.403331, -15.559259], [124.378429, -16.214939], [123.651622, -16.360284], [123.15089, -16.807712], [122.276052, -17.089288], [122.220388, -18.197198], [121.193533, -19.472426], [120.238292, -19.906834], [118.13559, -20.362074], [117.35377, -20.727716], [116.743175, -20.616876], [115.4546, -21.513767], [114.646739, -21.843357], [114.011485, -21.859959], [113.646658, -22.577732], [113.737559, -23.527032], [113.385265, -24.249119], [114.155284, -25.789727], [114.103776, -26.447882], [113.590579, -26.675651], [114.012543, -27.315362], [114.166026, -28.106703], [114.97877, -29.479913], [114.974864, -30.214044], [115.680349, -31.648614], [115.66684, -33.287693], [114.956798, -33.682306], [115.017833, -34.26214], [116.465099, -35.000665], [118.119884, -34.987074], [120.002208, -33.926039], [121.532888, -33.819513], [122.560313, -33.959161], [123.566661, -33.884373], [124.116547, -33.121515], [126.18572, -32.232517], [127.288748, -32.27337], [128.990001, -31.690525], [130.156912, -31.573907], [131.535899, -31.605564]]], [[[147.998546, -43.230401], [147.888682, -42.819513], [148.331391, -41.002211], [146.423676, -41.166192], [144.68922, -40.704522], [144.605805, -40.996515], [145.206554, -41.966567], [145.264171, -42.59531], [146.030772, -43.48268], [146.710704, -43.629002], [147.585623, -42.821222], [147.998546, -43.230401]]]]}, "properties": {"name": "Australia", "ISO3166-1-Alpha-3": "AUS", "ISO3166-1-Alpha-2": "AU"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[-40.875803, 65.096503], [-39.874501, 65.505927], [-36.073801, 65.935492], [-34.6433, 66.380439], [-33.186676, 67.557685], [-32.125396, 68.050686], [-30.008168, 68.126044], [-26.416737, 68.658922], [-23.703033, 69.718085], [-23.559804, 70.11286], [-25.305287, 70.413235], [-28.2329, 70.372016], [-27.506174, 70.942125], [-24.669423, 71.271959], [-23.345448, 70.440579], [-21.730051, 70.579047], [-21.667958, 71.401435], [-24.34203, 72.331732], [-25.292226, 73.473538], [-22.314076, 73.250434], [-20.543284, 73.449042], [-22.2294, 74.101304], [-19.427235, 75.225531], [-19.672434, 76.127265], [-21.935455, 76.622707], [-18.324452, 77.208482], [-21.629994, 77.998114], [-20.978993, 78.766832], [-18.95519, 79.148993], [-19.474355, 80.260199], [-16.447336, 80.219306], [-11.63858, 81.388414], [-13.920481, 81.812201], [-17.14684, 81.413398], [-21.025624, 81.771633], [-21.318512, 82.605536], [-25.649648, 83.297553], [-32.490712, 83.634101], [-46.557525, 82.89289], [-43.727203, 82.406887], [-46.008168, 82.0362], [-51.107249, 82.509914], [-61.281077, 81.815131], [-60.786855, 81.508857], [-67.455434, 80.342515], [-64.926015, 80.072659], [-66.117096, 79.102769], [-72.590199, 78.521226], [-72.784291, 78.107611], [-69.061879, 77.25727], [-70.660146, 76.801988], [-68.432525, 76.079413], [-66.487131, 75.947414], [-63.476918, 76.376899], [-60.872548, 76.158149], [-58.166737, 75.500434], [-56.603383, 74.244818], [-54.650258, 72.844957], [-55.514475, 71.449164], [-52.700917, 71.525946], [-51.914866, 71.02143], [-54.018707, 70.413235], [-50.847157, 69.626288], [-51.568837, 68.523871], [-52.616444, 68.525946], [-53.687652, 67.811835], [-53.964915, 67.104438], [-53.095774, 66.931952], [-53.455393, 65.960761], [-52.272857, 65.446519], [-52.156158, 64.678778], [-50.899526, 64.406684], [-51.539215, 63.685045], [-49.310414, 61.996161], [-49.241078, 61.607571], [-47.932118, 60.841742], [-46.375722, 61.084662], [-44.926381, 60.327094], [-43.99059, 60.190863], [-42.826283, 60.611274], [-42.707509, 61.291815], [-41.750111, 62.84394], [-40.614125, 63.807563], [-40.875803, 65.096503]]], [[[-52.902903, 69.34512], [-51.998158, 69.80508], [-53.265981, 70.202297], [-54.992747, 69.701972], [-52.902903, 69.34512]]]]}, "properties": {"name": "Greenland", "ISO3166-1-Alpha-3": "GRL", "ISO3166-1-Alpha-2": "GL"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[180, -16.149112], [178.917003, -16.470636], [178.705333, -16.998793], [179.903819, -16.717706], [180, -16.168951], [180, -16.149112]]], [[[178.711436, -17.991957], [178.262706, -17.335626], [177.388927, -17.640558], [177.335948, -18.105564], [178.168305, -18.248468], [178.711436, -17.991957]]]]}, "properties": {"name": "Fiji", "ISO3166-1-Alpha-3": "FJI", "ISO3166-1-Alpha-2": "FJ"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[172.748694, -43.279694], [173.287283, -42.953871], [174.197927, -41.876072], [174.055512, -41.424574], [172.689138, -40.734063], [171.48878, -41.830336], [171.122813, -42.59238], [169.733653, -43.565688], [168.340099, -44.118341], [167.203461, -44.949965], [166.489757, -45.80755], [167.405772, -46.251153], [169.035981, -46.681899], [169.635102, -46.578383], [170.698416, -45.679295], [171.346002, -44.278631], [172.497081, -43.721856], [172.748694, -43.279694]]], [[[176.953712, -39.607513], [177.064952, -39.199395], [177.882823, -39.085545], [178.294607, -38.539809], [178.27768, -37.559259], [177.145518, -38.023533], [175.997244, -37.631768], [175.881847, -36.926039], [174.819102, -36.823663], [174.569509, -35.594334], [173.888194, -35.006036], [173.100271, -35.219985], [174.359874, -36.63128], [174.850352, -37.764907], [174.578868, -38.839451], [173.784434, -39.393243], [174.967296, -39.916436], [175.234874, -40.336033], [174.609386, -41.291925], [175.330333, -41.609796], [175.966807, -41.247003], [176.83725, -40.177911], [176.953712, -39.607513]]]]}, "properties": {"name": "New Zealand", "ISO3166-1-Alpha-3": "NZL", "ISO3166-1-Alpha-2": "NZ"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[167.034434, -22.237481], [164.495616, -20.300226], [164.365571, -20.758071], [165.290782, -21.576755], [166.478363, -22.287205], [167.034434, -22.237481]]]}, "properties": {"name": "New Caledonia", "ISO3166-1-Alpha-3": "NCL", "ISO3166-1-Alpha-2": "NC"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[44.254731, -20.376072], [43.812755, -21.224298], [43.472667, -21.405206], [43.222911, -22.254815], [43.583181, -23.07586], [43.656261, -24.185317], [44.369477, -25.256768], [45.492198, -25.573663], [47.137462, -24.909275], [47.568696, -23.852634], [47.900645, -22.481134], [48.562266, -20.577081], [49.359223, -18.424005], [49.431326, -17.283868], [50.503917, -15.316176], [49.864268, -12.880466], [49.349376, -12.298517], [48.98463, -12.333429], [48.747406, -13.411554], [48.181326, -13.746759], [47.482432, -15.080255], [47.291759, -14.923435], [46.332693, -15.630141], [44.854259, -16.225356], [43.943126, -17.455743], [44.049653, -18.426528], [44.459646, -19.417657], [44.254731, -20.376072]]]}, "properties": {"name": "Madagascar", "ISO3166-1-Alpha-3": "MDG", "ISO3166-1-Alpha-2": "MG"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[126.569184, 7.301093], [126.312755, 8.956244], [125.934093, 9.492336], [125.462169, 8.985175], [124.780772, 8.949123], [124.23227, 8.212877], [123.7588, 8.607489], [123.055431, 8.50495], [122.443858, 7.608588], [122.995616, 7.467271], [123.586925, 7.839342], [124.246755, 7.40412], [123.965505, 6.821275], [124.245779, 6.18651], [125.181163, 5.798326], [125.706554, 6.149726], [125.388927, 6.768297], [126.079112, 6.842515], [126.569184, 7.301093]]], [[[122.297211, 18.403876], [120.611095, 18.53856], [120.333832, 17.556383], [120.40211, 16.165473], [119.878917, 15.91474], [120.090099, 14.794338], [120.830089, 14.763617], [120.58725, 14.200263], [121.278819, 13.595038], [121.724864, 13.969143], [122.50115, 13.368069], [122.470958, 14.345893], [121.731619, 14.188218], [121.379161, 15.308173], [121.57781, 15.932074], [122.203868, 16.263821], [122.529063, 17.090522], [122.156586, 17.619289], [122.297211, 18.403876]]], [[[123.574474, 10.830959], [122.823904, 10.532864], [123.156749, 9.867621], [123.574474, 10.830959]]], [[[123.170421, 11.48078], [122.107188, 11.657945], [122.045095, 11.017401], [122.589854, 10.698717], [123.170421, 11.48078]]], [[[125.712738, 11.148749], [125.500499, 12.248603], [125.16627, 12.578111], [124.276215, 12.533271], [125.214122, 11.131537], [125.712738, 11.148749]]], [[[121.526134, 12.704495], [121.430512, 13.22427], [120.950206, 13.530341], [120.479991, 13.3015], [121.131684, 12.241767], [121.526134, 12.704495]]]]}, "properties": {"name": "Philippines", "ISO3166-1-Alpha-3": "PHL", "ISO3166-1-Alpha-2": "PH"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[81.875987, 7.091946], [81.365733, 8.482164], [80.744395, 9.359687], [80.272227, 9.504136], [79.947113, 8.948676], [79.763845, 7.990139], [79.863048, 6.807196], [80.10377, 6.127143], [80.590831, 5.923733], [81.362071, 6.225409], [81.875987, 7.091946]]]}, "properties": {"name": "Sri Lanka", "ISO3166-1-Alpha-3": "LKA", "ISO3166-1-Alpha-2": "LK"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Cura\u00e7ao", "ISO3166-1-Alpha-3": "CUW", "ISO3166-1-Alpha-2": "CW"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Aruba", "ISO3166-1-Alpha-3": "ABW", "ISO3166-1-Alpha-2": "AW"}}, {"type": "Feature", "geometry": null, "properties": {"name": "The Bahamas", "ISO3166-1-Alpha-3": "BHS", "ISO3166-1-Alpha-2": "BS"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Turks and Caicos Islands", "ISO3166-1-Alpha-3": "TCA", "ISO3166-1-Alpha-2": "TC"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[121.905772, 24.9501], [121.059337, 25.050238], [120.18922, 23.774807], [120.059418, 23.151028], [120.331228, 22.519721], [120.948253, 22.526801], [121.400076, 23.145494], [121.905772, 24.9501]]]}, "properties": {"name": "Taiwan", "ISO3166-1-Alpha-3": "TWN", "ISO3166-1-Alpha-2": "CN-TW"}}, {"type": "Feature", "geometry": {"type": "MultiPolygon", "coordinates": [[[[131.65979, 32.47682], [131.982188, 32.888902], [131.729538, 33.580236], [129.854579, 33.532854], [130.641287, 32.607489], [130.171431, 31.790137], [130.222205, 31.2463], [130.924001, 31.112982], [131.34547, 31.394965], [131.65979, 32.47682]]], [[[134.689789, 33.82807], [134.153005, 34.385728], [133.510997, 33.965237], [132.769862, 33.99627], [132.381114, 33.466051], [132.91505, 32.777981], [133.269379, 33.346869], [134.273205, 33.508004], [134.689789, 33.82807]]], [[[133.422211, 34.445014], [134.744395, 34.766547], [135.065454, 33.885572], [135.765571, 33.481545], [136.519542, 34.680854], [137.792166, 34.63939], [139.867686, 35.013332], [140.841645, 35.742418], [140.564107, 36.283096], [141.04184, 37.377143], [140.93336, 37.889228], [141.471446, 38.435736], [142.069347, 39.540473], [141.276096, 41.353196], [140.270763, 40.812405], [139.822113, 39.961086], [140.048577, 39.50467], [139.432384, 38.169135], [138.244895, 37.183789], [136.760555, 36.870599], [135.957286, 35.973131], [135.981668, 35.643638], [132.635359, 35.440955], [131.858409, 34.714504], [130.861583, 34.112738], [132.057791, 33.89525], [132.496837, 34.362494], [133.422211, 34.445014]]], [[[145.767426, 43.387274], [144.831228, 43.942206], [143.783214, 44.100653], [142.981456, 44.586493], [141.96046, 45.51024], [141.440115, 43.361721], [139.876964, 42.663398], [140.14503, 41.982652], [141.726085, 42.617174], [143.25294, 41.941881], [143.638845, 42.661078], [144.741954, 42.924221], [145.767426, 43.387274]]]]}, "properties": {"name": "Japan", "ISO3166-1-Alpha-3": "JPN", "ISO3166-1-Alpha-2": "JP"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Pierre and Miquelon", "ISO3166-1-Alpha-3": "SPM", "ISO3166-1-Alpha-2": "PM"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-14.563629, 66.384508], [-17.419342, 65.994045], [-18.69225, 66.169989], [-21.318512, 65.987779], [-22.850331, 66.466986], [-24.196685, 65.501166], [-22.674428, 65.007554], [-21.962636, 64.297309], [-20.185699, 63.54442], [-18.732045, 63.396715], [-14.510487, 64.442328], [-13.526967, 65.055487], [-13.609609, 65.514228], [-14.822987, 65.815863], [-14.563629, 66.384508]]]}, "properties": {"name": "Iceland", "ISO3166-1-Alpha-3": "ISL", "ISO3166-1-Alpha-2": "IS"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Pitcairn Islands", "ISO3166-1-Alpha-3": "PCN", "ISO3166-1-Alpha-2": "PN"}}, {"type": "Feature", "geometry": null, "properties": {"name": "French Polynesia", "ISO3166-1-Alpha-3": "PYF", "ISO3166-1-Alpha-2": "PF"}}, {"type": "Feature", "geometry": null, "properties": {"name": "French Southern and Antarctic Lands", "ISO3166-1-Alpha-3": "ATF", "ISO3166-1-Alpha-2": "TF"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Seychelles", "ISO3166-1-Alpha-3": "SYC", "ISO3166-1-Alpha-2": "SC"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Kiribati", "ISO3166-1-Alpha-3": "KIR", "ISO3166-1-Alpha-2": "KI"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Marshall Islands", "ISO3166-1-Alpha-3": "MHL", "ISO3166-1-Alpha-2": "MH"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Trinidad and Tobago", "ISO3166-1-Alpha-3": "TTO", "ISO3166-1-Alpha-2": "TT"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Grenada", "ISO3166-1-Alpha-3": "GRD", "ISO3166-1-Alpha-2": "GD"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Vincent and the Grenadines", "ISO3166-1-Alpha-3": "VCT", "ISO3166-1-Alpha-2": "VC"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Barbados", "ISO3166-1-Alpha-3": "BRB", "ISO3166-1-Alpha-2": "BB"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Lucia", "ISO3166-1-Alpha-3": "LCA", "ISO3166-1-Alpha-2": "LC"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Dominica", "ISO3166-1-Alpha-3": "DMA", "ISO3166-1-Alpha-2": "DM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "United States Minor Outlying Islands", "ISO3166-1-Alpha-3": "UMI", "ISO3166-1-Alpha-2": "UM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Montserrat", "ISO3166-1-Alpha-3": "MSR", "ISO3166-1-Alpha-2": "MS"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Antigua and Barbuda", "ISO3166-1-Alpha-3": "ATG", "ISO3166-1-Alpha-2": "AG"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Kitts and Nevis", "ISO3166-1-Alpha-3": "KNA", "ISO3166-1-Alpha-2": "KN"}}, {"type": "Feature", "geometry": null, "properties": {"name": "United States Virgin Islands", "ISO3166-1-Alpha-3": "VIR", "ISO3166-1-Alpha-2": "VI"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Barthelemy", "ISO3166-1-Alpha-3": "BLM", "ISO3166-1-Alpha-2": "BL"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-65.628814, 18.279202], [-67.101715, 18.522773], [-67.214771, 17.966335], [-66.163238, 17.929145], [-65.628814, 18.279202]]]}, "properties": {"name": "Puerto Rico", "ISO3166-1-Alpha-3": "PRI", "ISO3166-1-Alpha-2": "PR"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Anguilla", "ISO3166-1-Alpha-3": "AIA", "ISO3166-1-Alpha-2": "AI"}}, {"type": "Feature", "geometry": null, "properties": {"name": "British Virgin Islands", "ISO3166-1-Alpha-3": "VGB", "ISO3166-1-Alpha-2": "VG"}}, {"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[-76.263743, 18.012356], [-76.894919, 18.409251], [-77.902333, 18.518785], [-78.075185, 18.198432], [-77.216054, 17.716702], [-76.263743, 18.012356]]]}, "properties": {"name": "Jamaica", "ISO3166-1-Alpha-3": "JAM", "ISO3166-1-Alpha-2": "JM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Cayman Islands", "ISO3166-1-Alpha-3": "CYM", "ISO3166-1-Alpha-2": "KY"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Bermuda", "ISO3166-1-Alpha-3": "BMU", "ISO3166-1-Alpha-2": "BM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Heard Island and McDonald Islands", "ISO3166-1-Alpha-3": "HMD", "ISO3166-1-Alpha-2": "HM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Saint Helena", "ISO3166-1-Alpha-3": "SHN", "ISO3166-1-Alpha-2": "SH"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Mauritius", "ISO3166-1-Alpha-3": "MUS", "ISO3166-1-Alpha-2": "MU"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Comoros", "ISO3166-1-Alpha-3": "COM", "ISO3166-1-Alpha-2": "KM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "S\u00e3o Tom\u00e9 and Principe", "ISO3166-1-Alpha-3": "STP", "ISO3166-1-Alpha-2": "ST"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Cabo Verde", "ISO3166-1-Alpha-3": "CPV", "ISO3166-1-Alpha-2": "CV"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Malta", "ISO3166-1-Alpha-3": "MLT", "ISO3166-1-Alpha-2": "MT"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Jersey", "ISO3166-1-Alpha-3": "JEY", "ISO3166-1-Alpha-2": "JE"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Guernsey", "ISO3166-1-Alpha-3": "GGY", "ISO3166-1-Alpha-2": "GG"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Isle of Man", "ISO3166-1-Alpha-3": "IMN", "ISO3166-1-Alpha-2": "IM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Aland", "ISO3166-1-Alpha-3": "ALA", "ISO3166-1-Alpha-2": "AX"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Faroe Islands", "ISO3166-1-Alpha-3": "FRO", "ISO3166-1-Alpha-2": "FO"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Indian Ocean Territories", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "British Indian Ocean Territory", "ISO3166-1-Alpha-3": "IOT", "ISO3166-1-Alpha-2": "IO"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Singapore", "ISO3166-1-Alpha-3": "SGP", "ISO3166-1-Alpha-2": "SG"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Norfolk Island", "ISO3166-1-Alpha-3": "NFK", "ISO3166-1-Alpha-2": "NF"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Cook Islands", "ISO3166-1-Alpha-3": "COK", "ISO3166-1-Alpha-2": "CK"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Tonga", "ISO3166-1-Alpha-3": "TON", "ISO3166-1-Alpha-2": "TO"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Wallis and Futuna", "ISO3166-1-Alpha-3": "WLF", "ISO3166-1-Alpha-2": "WF"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Samoa", "ISO3166-1-Alpha-3": "WSM", "ISO3166-1-Alpha-2": "WS"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Solomon Islands", "ISO3166-1-Alpha-3": "SLB", "ISO3166-1-Alpha-2": "SB"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Tuvalu", "ISO3166-1-Alpha-3": "TUV", "ISO3166-1-Alpha-2": "TV"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Maldives", "ISO3166-1-Alpha-3": "MDV", "ISO3166-1-Alpha-2": "MV"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Nauru", "ISO3166-1-Alpha-3": "NRU", "ISO3166-1-Alpha-2": "NR"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Federated States of Micronesia", "ISO3166-1-Alpha-3": "FSM", "ISO3166-1-Alpha-2": "FM"}}, {"type": "Feature", "geometry": null, "properties": {"name": "South Georgia and the Islands", "ISO3166-1-Alpha-3": "SGS", "ISO3166-1-Alpha-2": "GS"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Falkland Islands", "ISO3166-1-Alpha-3": "FLK", "ISO3166-1-Alpha-2": "FK"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Vanuatu", "ISO3166-1-Alpha-3": "VUT", "ISO3166-1-Alpha-2": "VU"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Niue", "ISO3166-1-Alpha-3": "NIU", "ISO3166-1-Alpha-2": "NU"}}, {"type": "Feature", "geometry": null, "properties": {"name": "American Samoa", "ISO3166-1-Alpha-3": "ASM", "ISO3166-1-Alpha-2": "AS"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Palau", "ISO3166-1-Alpha-3": "PLW", "ISO3166-1-Alpha-2": "PW"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Guam", "ISO3166-1-Alpha-3": "GUM", "ISO3166-1-Alpha-2": "GU"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Northern Mariana Islands", "ISO3166-1-Alpha-3": "MNP", "ISO3166-1-Alpha-2": "MP"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Bahrain", "ISO3166-1-Alpha-3": "BHR", "ISO3166-1-Alpha-2": "BH"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Coral Sea Islands", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Spratly Islands", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Clipperton Island", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Macao S.A.R", "ISO3166-1-Alpha-3": "MAC", "ISO3166-1-Alpha-2": "MO"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Ashmore and Cartier Islands", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Bajo Nuevo Bank (Petrel Is.)", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Serranilla Bank", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}, {"type": "Feature", "geometry": null, "properties": {"name": "Scarborough Reef", "ISO3166-1-Alpha-3": "-99", "ISO3166-1-Alpha-2": "-99"}}]} \ No newline at end of file diff --git a/public/favico/android-chrome-192x192.png b/public/favico/android-chrome-192x192.png new file mode 100644 index 000000000..b69ec857d Binary files /dev/null and b/public/favico/android-chrome-192x192.png differ diff --git a/public/favico/android-chrome-512x512.png b/public/favico/android-chrome-512x512.png new file mode 100644 index 000000000..7f852e93f Binary files /dev/null and b/public/favico/android-chrome-512x512.png differ diff --git a/public/favico/apple-touch-icon.png b/public/favico/apple-touch-icon.png new file mode 100644 index 000000000..754a63b8e Binary files /dev/null and b/public/favico/apple-touch-icon.png differ diff --git a/public/favico/favicon-16x16.png b/public/favico/favicon-16x16.png new file mode 100644 index 000000000..dbc6422be Binary files /dev/null and b/public/favico/favicon-16x16.png differ diff --git a/public/favico/favicon-32x32.png b/public/favico/favicon-32x32.png new file mode 100644 index 000000000..8ee638cff Binary files /dev/null and b/public/favico/favicon-32x32.png differ diff --git a/public/favico/favicon.ico b/public/favico/favicon.ico new file mode 100644 index 000000000..4430fb1fa Binary files /dev/null and b/public/favico/favicon.ico differ diff --git a/public/favico/happy/android-chrome-192x192.png b/public/favico/happy/android-chrome-192x192.png new file mode 100644 index 000000000..9476ec49e Binary files /dev/null and b/public/favico/happy/android-chrome-192x192.png differ diff --git a/public/favico/happy/android-chrome-512x512.png b/public/favico/happy/android-chrome-512x512.png new file mode 100644 index 000000000..5995b3fd4 Binary files /dev/null and b/public/favico/happy/android-chrome-512x512.png differ diff --git a/public/favico/happy/apple-touch-icon.png b/public/favico/happy/apple-touch-icon.png new file mode 100644 index 000000000..16f3cba23 Binary files /dev/null and b/public/favico/happy/apple-touch-icon.png differ diff --git a/public/favico/happy/favicon-16x16.png b/public/favico/happy/favicon-16x16.png new file mode 100644 index 000000000..d2b36dd99 Binary files /dev/null and b/public/favico/happy/favicon-16x16.png differ diff --git a/public/favico/happy/favicon-32x32.png b/public/favico/happy/favicon-32x32.png new file mode 100644 index 000000000..72a84fdc7 Binary files /dev/null and b/public/favico/happy/favicon-32x32.png differ diff --git a/public/favico/happy/favicon.ico b/public/favico/happy/favicon.ico new file mode 100644 index 000000000..fe5ff43e0 Binary files /dev/null and b/public/favico/happy/favicon.ico differ diff --git a/public/favico/happy/favicon.svg b/public/favico/happy/favicon.svg new file mode 100644 index 000000000..de744c9b9 --- /dev/null +++ b/public/favico/happy/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/favico/happy/og-image.png b/public/favico/happy/og-image.png new file mode 100644 index 000000000..ef70b1591 Binary files /dev/null and b/public/favico/happy/og-image.png differ diff --git a/public/favico/og-image.png b/public/favico/og-image.png new file mode 100644 index 000000000..efa04f204 Binary files /dev/null and b/public/favico/og-image.png differ diff --git a/public/favico/worldmonitor-icon-1024.png b/public/favico/worldmonitor-icon-1024.png new file mode 100644 index 000000000..7911cac07 Binary files /dev/null and b/public/favico/worldmonitor-icon-1024.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..4430fb1fa Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/llms-full.txt b/public/llms-full.txt new file mode 100644 index 000000000..096a4cd49 --- /dev/null +++ b/public/llms-full.txt @@ -0,0 +1,229 @@ +# World Monitor + +> Real-time global intelligence dashboard — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface. + +World Monitor is an open-source (AGPL-3.0) intelligence platform that aggregates 150+ news feeds, 35+ interactive map layers, and multiple AI models into a single dashboard. It runs as a web app, installable PWA, and native desktop application (Tauri) for macOS, Windows, and Linux. + +A single codebase produces three specialized variants — geopolitical, technology, and finance — each with distinct feeds, panels, map layers, and branding. The tri-variant architecture uses build-time selection via the VITE_VARIANT environment variable, with runtime switching available via the header bar. 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. + +The project is built with TypeScript, Vite, MapLibre GL JS, deck.gl, D3.js, and Tauri. All intelligence analysis (clustering, instability scoring, surge detection) runs client-side in the browser — no backend compute dependency for core intelligence. A browser-side ML pipeline (Transformers.js) provides NER and sentiment analysis without server dependency. + +## Live Instances + +- [World Monitor](https://worldmonitor.app): Geopolitics, military, conflicts, infrastructure — ~25 RSS categories, 44 panels, military bases, nuclear facilities, hotspots +- [Tech Monitor](https://tech.worldmonitor.app): Startups, AI/ML, cloud, cybersecurity — ~20 RSS categories, 31 panels, tech HQs, cloud regions, startup hubs +- [Finance Monitor](https://finance.worldmonitor.app): Global markets, trading, central banks, Gulf FDI — ~18 RSS categories, 30 panels, stock exchanges, central banks, Gulf investments + +## Documentation + +- [README](https://github.com/koala73/worldmonitor/blob/main/README.md): Full project documentation with architecture details, algorithm descriptions, and data source specifications +- [Full Documentation](https://github.com/koala73/worldmonitor/blob/main/docs/DOCUMENTATION.md): Detailed feature documentation, data layer reference, panel descriptions, and clustering logic + +## Data Layers — Geopolitical + +- **Conflicts**: Active conflict zones with involved parties and escalation status (UCDP + ACLED data) +- **Hotspots**: Intelligence hotspots with activity levels based on multi-source news correlation and geo-convergence +- **Sanctions**: Countries under economic sanctions regimes +- **Protests**: Live social unrest events from dual sources (ACLED protests + GDELT geo-events), Haversine-deduplicated on 0.5-degree grid +- **Cyber Threats**: Indicators of compromise (C2 servers, malware hosts, phishing, malicious URLs) from 5 threat intel feeds (Feodo Tracker, URLhaus, C2IntelFeeds, AlienVault OTX, AbuseIPDB), geo-enriched via ipinfo.io +- **Weather Alerts**: NWS severe weather warnings + +## Data Layers — Military & Strategic + +- **Military Bases**: 220+ global military installations from 9 operators +- **Nuclear Facilities**: Power plants, weapons labs, enrichment sites +- **Gamma Irradiators**: IAEA-tracked Category 1-3 radiation sources +- **APT Groups**: State-sponsored cyber threat actors with geographic attribution +- **Spaceports**: 12 major launch facilities (NASA, SpaceX, Roscosmos, CNSA, ESA, ISRO, JAXA) +- **Critical Minerals**: Strategic mineral deposits (lithium, cobalt, rare earths) with operator info +- **Live Military Flights**: ADS-B tracking with surge detection +- **Naval Vessels**: AIS vessel monitoring with chokepoint detection across 8 strategic waterways + +## Data Layers — Infrastructure + +- **Undersea Cables**: 55 major submarine cable routes with landing points +- **Pipelines**: 88 operating oil and gas pipelines across all continents +- **AI Datacenters**: 111 major AI compute clusters (10,000+ GPUs each) +- **Strategic Ports**: 83 ports across 6 types (container, oil/LNG, chokepoint, naval, mixed, bulk) with throughput rankings +- **Internet Outages**: Network disruptions via Cloudflare Radar +- **NASA FIRMS**: Satellite fire detection (VIIRS thermal hotspots) + +## Data Layers — Natural Events + +- **Earthquakes**: USGS global earthquakes M4.5+ with 5-minute update frequency +- **GDACS Alerts**: UN-coordinated disaster alerts (earthquakes, floods, cyclones, volcanoes, wildfires, droughts) with color-coded alert levels +- **NASA EONET**: Earth observation events across 13 natural event categories (30-day open events) +- **Climate Anomalies**: 15 conflict-prone zones monitored for temperature/precipitation deviations against 30-day ERA5 baselines + +## Data Layers — Market & Crypto Intelligence + +- **7-Signal Macro Radar**: Composite BUY/CASH verdict from JPY liquidity, BTC/QQQ flow structure, macro regime (QQQ vs XLP), technical trend (SMA50/VWAP), hash rate, mining cost, and Fear & Greed Index +- **BTC Spot ETF Flows**: 10 ETFs tracked (IBIT, FBTC, ARKB, BITB, GBTC, HODL, BRRR, EZBC, BTCO, BTCW) with volume-based flow estimation +- **Stablecoin Peg Monitor**: USDT, USDC, DAI, FDUSD, USDe — deviation tracking with ON PEG / SLIGHT DEPEG / DEPEGGED status +- **Fear & Greed Index**: 30-day history with sentiment classification +- **Oil & Energy Analytics**: WTI/Brent crude prices, US production (Mbbl/d), and inventory levels via EIA API + +## Data Layers — Tech Ecosystem (Tech Variant) + +- **Tech HQs**: Headquarters of major tech companies (Big Tech, unicorns, public companies) — Silicon Valley, Seattle, New York, London, Tel Aviv, Dubai, Singapore, Berlin, Tokyo +- **Startup Hubs**: Major startup ecosystems with ecosystem tier, funding data, and notable companies +- **Cloud Regions**: AWS, Azure, GCP data center regions with zone counts +- **Accelerators**: Y Combinator, Techstars, 500 Startups, and regional accelerator locations +- **Tech Events**: Upcoming conferences and tech events with countdown timers + +## Data Layers — Finance & Markets (Finance Variant) + +- **Stock Exchanges**: 92 global 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 +- **Financial Centers**: 19 centers ranked by Global Financial Centres Index (New York through offshore centers) +- **Central Banks**: 13 institutions (Federal Reserve, ECB, BoJ, BoE, PBoC, SNB, RBA, BoC, RBI, BoK, BCB, SAMA) plus supranational (BIS, IMF) +- **Commodity Hubs**: 10 exchanges and physical hubs (CME Group, ICE, LME, SHFE, DCE, TOCOM, DGCX, MCX, Rotterdam, Houston) +- **Gulf FDI Investments**: 64 Saudi/UAE foreign direct investments plotted globally, color-coded by status (operational, under-construction, announced), sized by investment amount — across ports, energy, manufacturing, renewables, megaprojects, telecoms + +## AI-Powered Intelligence + +- **World Brief**: LLM-synthesized summary of top global developments via Groq Llama 3.1, Redis-cached +- **Hybrid Threat Classification**: Two-stage pipeline — instant keyword classifier (~120 threat keywords by severity tier) with async LLM override (Groq Llama 3.1 8B at temperature 0, 24h Redis cache). LLM overrides keyword result only when confidence is higher +- **Focal Point Detection**: Correlates entities across news, military activity, protests, outages, and markets to identify convergence. Requires cross-source confirmation before escalating to critical +- **Country Instability Index (CII)**: Real-time stability scores (0-100) for 22 monitored nations — US, Russia, China, Ukraine, Iran, Israel, Taiwan, North Korea, Saudi Arabia, Turkey, Poland, Germany, France, UK, India, Pakistan, Syria, Yemen, Myanmar, Venezuela, Brazil, UAE. Computed from baseline risk (40%), unrest events (20%), security activity (20%), and information velocity (20%) +- **Trending Keyword Spike Detection**: 2-hour rolling window vs 7-day baseline. Spikes require 5+ mentions, 3x baseline surge, 2+ unique sources, and 30-minute cooldown. Extracts CVE identifiers and APT/FIN designators. Auto-summarized via Groq (5 summaries/hour limit) +- **Strategic Posture Assessment**: 9 operational theaters (Iran/Persian Gulf, Taiwan Strait, Baltic/Kaliningrad, Korean Peninsula, Eastern Mediterranean, Horn of Africa, South China Sea, Arctic, Black Sea) assessed continuously. Posture levels: NORMAL, ELEVATED, CRITICAL based on aircraft count, strike capability, naval presence, and country instability +- **Geographic Convergence Detection**: Events binned into 1-degree geographic cells within 24-hour window. 3+ distinct event types in one cell triggers convergence alert +- **Infrastructure Cascade Modeling**: BFS propagation (depth 3) through dependency graph of cables, pipelines, ports, chokepoints, and countries. Models real-world dependencies (e.g., Strait of Hormuz carries 80% of Japan's oil) +- **Temporal Baseline Anomaly Detection**: Welford's online algorithm for streaming mean/variance per event type, region, weekday, and month over 90-day window. Z-score thresholds: 1.5 (low), 2.0 (medium), 3.0 (high/critical) +- **Browser-Side ML Pipeline**: Transformers.js running text embeddings (sentence-similarity), sequence classification (threat-classifier), summarization (T5-small fallback), and NER — all in-browser with no server dependency + +## Intelligence Panels + +- **AI Strategic Posture**: Theater-level military force aggregation with strike capability assessment across 9 theaters linked to 38+ military bases +- **Strategic Risk Overview**: Composite risk score combining all intelligence modules with trend detection +- **Country Instability Index**: Real-time stability scores for 22 monitored countries with 4-component breakdown (Unrest, Conflict, Security, Information) +- **Infrastructure Cascade**: Dependency analysis for cables, pipelines, ports, and chokepoints with disruption propagation modeling +- **Live Intelligence**: GDELT-powered topic feeds (Military, Cyber, Nuclear, Sanctions) +- **Regional Panels**: Dedicated panels for Middle East, Africa, Latin America, Asia-Pacific, and Energy & Resources +- **Climate Anomaly Panel**: 15 conflict-prone zones with temperature/precipitation deviation tracking +- **Displacement Panel**: UN OCHA HAPI data with origins (countries people flee from) and hosts (countries absorbing displaced populations) perspectives +- **Population Exposure**: WorldPop density data estimates civilians within event-specific radii (50-100km) +- **Trending Keywords**: Real-time surging terms with spike severity, source count, and AI-generated context summaries +- **Country Brief Pages**: Full-page intelligence dossier per country — CII score ring, AI-generated analysis with citation anchors, top 8 news headlines, active signals, 7-day D3.js timeline, prediction markets (Polymarket), infrastructure exposure, stock market index. Exportable as JSON, CSV, or PNG + +## News Aggregation + +- **150+ RSS feeds** across 15+ categories: World/Geopolitical (BBC, Reuters, AP, Guardian), Middle East (Al Jazeera, Al Arabiya, Times of Israel), Africa (BBC Africa, News24), Latin America, Asia-Pacific (SCMP), Energy & Resources, Technology (Hacker News, Ars Technica), AI/ML (ArXiv, VentureBeat), Finance (CNBC, MarketWatch, FT), Government (White House, Pentagon, Treasury, Fed, SEC, UN, CISA), Intel Feed (Defense One, Breaking Defense, Bellingcat, Krebs, Janes), Think Tanks (Foreign Policy, Atlantic Council, CSIS, RAND, Brookings, Carnegie), Crisis Watch (ICG, IAEA, WHO, UNHCR), Regional (Xinhua, TASS, Kyiv Independent) +- **Source tiering**: Tier 1 (wire services, government), Tier 2 (major outlets), Tier 3 (specialized), Tier 4 (aggregators/blogs) — with propaganda risk ratings and state affiliation flags +- **8 live video streams**: Bloomberg, Sky News, Al Jazeera, Euronews, DW, France24, CNBC, Al Arabiya — with automatic live detection scraping YouTube channel pages every 5 minutes +- **19 live webcams**: Geopolitical hotspots across 4 regions — Middle East (Jerusalem, Tehran, Tel Aviv, Mecca), Europe (Kyiv, Odessa, Paris, London), Americas (Washington DC, New York, LA, Miami), Asia-Pacific (Taipei, Shanghai, Tokyo, Seoul, Sydney) +- **Custom keyword monitors**: User-defined keyword alerts with word-boundary matching, auto color-coding, and multi-keyword support +- **Entity extraction**: Auto-links countries, leaders, organizations across headlines +- **Virtual scrolling**: Custom virtual list renderer with DOM pooling for panels with 15+ items + +## Data Sources + +- ACLED (Armed Conflict Location & Event Data): Protests, riots, conflicts — 30-day window, tokenized API +- GDELT (Global Database of Events, Language, and Tone): Geo-events, protest keywords, topic feeds +- UCDP (Uppsala Conflict Data Program): Conflict zone classification +- USGS: Earthquakes M4.5+ globally, 5-minute updates +- GDACS: UN disaster alerts (earthquakes, floods, cyclones, volcanoes, wildfires, droughts) +- NASA EONET: Earth observation events, 13 natural event categories +- NASA FIRMS: VIIRS satellite fire/thermal hotspot detection +- Cloudflare Radar: Internet outage monitoring +- ADS-B Exchange: Military flight tracking +- AISStream.io: Vessel tracking via AIS (terrestrial receivers) +- OpenSky Network: Aircraft position data +- Polymarket: Prediction market probabilities for geopolitical events (3-tier JA3 bypass) +- Yahoo Finance: Stock data, ETF prices, macro signals +- CoinGecko: Stablecoin pricing and market caps +- mempool.space: Bitcoin hash rate data +- alternative.me: Fear & Greed Index +- EIA (Energy Information Administration): Oil prices, US production, inventory +- FRED (Federal Reserve Economic Data): Economic indicators +- WorldPop: Population density data for exposure estimation +- UN OCHA HAPI: Humanitarian access metrics and displacement flows +- Open-Meteo ERA5: Climate reanalysis data for anomaly detection +- abuse.ch (Feodo Tracker, URLhaus): C2 server and malware host IOCs +- C2IntelFeeds: Community-sourced C2 indicators +- AlienVault OTX: Open threat exchange IOCs +- AbuseIPDB: Crowd-sourced abuse reports +- ipinfo.io / freeipapi.com: IP geolocation enrichment +- NWS (National Weather Service): Severe weather warnings +- USASpending.gov: Government contracts and spending data +- Groq API: Llama 3.1 for world briefs, threat classification, and spike summarization +- OpenRouter API: LLM fallback provider +- MapTiler: Base map tiles + +## Supported Languages + +English (en), French (fr), Spanish (es), German (de), Italian (it), Polish (pl), Portuguese (pt), Dutch (nl), Swedish (sv), Russian (ru), Arabic (ar) with RTL support, Chinese (zh), Japanese (ja), Turkish (tr). Language bundles are lazy-loaded on demand. Localized news feeds load region-specific RSS sources based on language preference. AI translation available for cross-language intelligence gathering. + +## Tri-Variant Build System + +A single codebase produces three specialized dashboards controlled by the VITE_VARIANT environment variable: + +- **World Monitor** (worldmonitor.app): ~25 RSS categories, 44 panels. Focus: geopolitics, military, conflicts, infrastructure security. Unique layers: military bases, nuclear facilities, hotspots, APT groups, spaceports, critical minerals +- **Tech Monitor** (tech.worldmonitor.app): ~20 RSS categories, 31 panels. Focus: AI/ML, startups, cybersecurity, cloud. Unique layers: tech HQs, cloud regions, startup hubs, accelerators, tech events +- **Finance Monitor** (finance.worldmonitor.app): ~18 RSS categories, 30 panels. Focus: global markets, trading, central banks, Gulf FDI. Unique layers: 92 stock exchanges, 19 financial centers, 13 central banks, 10 commodity hubs, 64 Gulf FDI investments + +Build-time: Vite HTML plugin transforms meta tags, Open Graph data, PWA manifest, and JSON-LD structured data. Each variant tree-shakes unused data files. Runtime: variant selector in header navigates between deployed domains (web) or sets localStorage preference (desktop). + +## Architecture Principles + +- **Speed over perfection**: Keyword classifier is instant; LLM refines asynchronously. Users never wait +- **Assume failure**: Per-feed circuit breakers with 5-minute cooldowns. AI fallback chain: Groq, OpenRouter, browser-side T5. Redis failures degrade gracefully. Edge functions return stale cached data when upstream APIs are down +- **Show what you can't see**: Intelligence gap tracker explicitly reports data source outages rather than silently hiding them +- **Browser-first compute**: Analysis runs client-side — no backend compute dependency for core intelligence +- **Local-first geolocation**: Country detection uses browser-side ray-casting against GeoJSON polygons (sub-millisecond, zero API dependency, works offline) +- **Multi-signal correlation**: No single data source trusted alone. Focal points require convergence across news + military + markets + protests before escalating +- **Geopolitical grounding**: Hard-coded conflict zones, baseline country risk, and strategic chokepoints prevent false alerts +- **Defense in depth**: CORS origin allowlist, domain-allowlisted RSS proxy, server-side API key isolation, token-authenticated desktop sidecar, IP rate limiting +- **Cache everything, trust nothing**: Three-tier caching (in-memory, Redis, upstream) with stale-on-error fallback +- **Baseline-aware alerting**: Rolling temporal windows against learned baselines with per-term spike multipliers and cooldowns + +## Tech Stack + +- **Frontend**: TypeScript, Vite, MapLibre GL JS, deck.gl (WebGL), D3.js, Supercluster (marker clustering), Transformers.js (browser-side ML) +- **Internationalization**: i18next with lazy-loaded language bundles, RTL support for Arabic/Hebrew +- **Desktop**: Tauri 2.x (Rust core + Node.js sidecar), OS keychain integration (macOS Keychain, Windows Credential Manager), token-authenticated local API +- **Backend**: 60+ Vercel Edge Functions, Railway relay server for blocked RSS feeds +- **Data Store**: Upstash Redis (caching, Welford baselines, LLM dedup), IndexedDB (historical playback), localStorage (preferences, panel state) +- **AI Pipeline**: Groq (Llama 3.1 8B), OpenRouter (fallback), Transformers.js (browser fallback for NER, sentiment, summarization) +- **Monitoring**: Sentry error tracking, data freshness tracker across 22 sources with intelligence gap reporting +- **Testing**: Playwright E2E tests (per-variant), Node.js test runner for data/sidecar tests +- **PWA**: Service worker with CacheFirst map tiles (500 tiles, 30-day TTL), NetworkOnly for intelligence data, offline fallback page + +## Desktop Application + +Native desktop app built with Tauri (Rust + Node.js sidecar). The sidecar mirrors all 60+ cloud API handlers locally with gzip compression. Features: +- OS keychain integration for 17 API keys (macOS Keychain, Windows Credential Manager) +- Token-authenticated sidecar prevents unauthorized local access +- Cloud fallback when local handlers fail +- Settings window (Cmd+,) for API key management with validation +- Verbose debug mode with persistent state and traffic logging (last 200 requests) +- Auto-update checker polling every 6 hours with per-version dismiss +- Available for macOS (Apple Silicon + Intel), Windows (.exe), and Linux (.AppImage) + +## Key Features Summary + +- Interactive 3D WebGL globe with 35+ toggleable data layers and smart clustering +- AI-synthesized world briefs with hybrid threat classification +- Country Instability Index for 22 nations with real-time scoring +- 9-theater strategic posture assessment +- Geographic convergence detection and infrastructure cascade modeling +- 150+ curated RSS feeds with source tiering and propaganda risk ratings +- 8 live news streams and 19 live webcams from geopolitical hotspots +- 7-signal macro market radar with BUY/CASH verdict +- Country brief pages with exportable intelligence dossiers (JSON, CSV, PNG) +- Multilingual UI in 14 languages with RTL support +- Prediction market integration (Polymarket) with 3-tier JA3 bypass +- Temporal baseline anomaly detection using Welford's algorithm +- Dual-source protest tracking (ACLED + GDELT) with regime-aware scoring +- Population exposure estimation using WorldPop density data +- Shareable intelligence stories with multi-platform social export +- Cmd+K fuzzy search across 20+ result types +- Historical playback via IndexedDB snapshots with time slider +- Dark/light theme, panel reordering, ultra-wide layout (2000px+) +- Feature toggles for 14 runtime data source controls + +## Optional + +- [Source Code](https://github.com/koala73/worldmonitor): GitHub repository (AGPL-3.0) +- [Releases](https://github.com/koala73/worldmonitor/releases): All desktop releases for macOS, Windows, and Linux +- [Issues](https://github.com/koala73/worldmonitor/issues): Bug reports and feature requests diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 000000000..2343064b1 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,62 @@ +# World Monitor + +> Real-time global intelligence dashboard — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface. + +World Monitor is an open-source (AGPL-3.0) intelligence platform that aggregates 150+ news feeds, 35+ interactive map layers, and multiple AI models into a single dashboard. It runs as a web app, installable PWA, and native desktop application (Tauri) for macOS, Windows, and Linux. + +A single codebase produces three specialized variants — geopolitical, technology, and finance — each with distinct feeds, panels, map layers, and branding. The tri-variant architecture uses build-time selection via the VITE_VARIANT environment variable, with runtime switching available via the header bar. + +The project is built with TypeScript, Vite, MapLibre GL JS, deck.gl, D3.js, and Tauri. All intelligence analysis (clustering, instability scoring, surge detection) runs client-side in the browser — no backend compute dependency for core intelligence. A browser-side ML pipeline (Transformers.js) provides NER and sentiment analysis without server dependency. + +## Live Instances + +- [World Monitor](https://worldmonitor.app): Geopolitics, military, conflicts, infrastructure +- [Tech Monitor](https://tech.worldmonitor.app): Startups, AI/ML, cloud, cybersecurity +- [Finance Monitor](https://finance.worldmonitor.app): Global markets, trading, central banks, Gulf FDI + +## Documentation + +- [README](https://github.com/koala73/worldmonitor/blob/main/README.md): Full project documentation with architecture details +- [Full Documentation](https://github.com/koala73/worldmonitor/blob/main/docs/DOCUMENTATION.md): Detailed feature documentation, data layers, and panel reference +- [Extended LLM Documentation](https://worldmonitor.app/llms-full.txt): Comprehensive version of this file with all data layers, components, and data sources + +## Key Features + +- Interactive 3D WebGL globe with deck.gl rendering and 35+ toggleable data layers (conflicts, military bases, nuclear facilities, undersea cables, pipelines, datacenters, protests, disasters, cyber threats, and more) +- AI-powered intelligence: LLM-synthesized world briefs, hybrid threat classification, focal point detection, country instability index for 22 nations, trending keyword spike detection, and strategic posture assessment +- 150+ curated RSS feeds across geopolitics, defense, energy, tech, and finance with domain-allowlisted proxy +- 8 live news video streams (Bloomberg, Sky News, Al Jazeera, Euronews, DW, France24, CNBC, Al Arabiya) +- 19 live webcams from geopolitical hotspots across 4 regions +- Multilingual UI supporting 14 languages (English, French, Spanish, German, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, Japanese, Turkish) with RTL support +- 7-signal macro market radar with composite BUY/CASH verdict, BTC ETF flow tracking, stablecoin peg monitoring, Fear & Greed Index +- Country brief pages with AI-generated analysis, CII score ring, prediction markets, 7-day timeline, infrastructure exposure, and stock index +- Geographic convergence detection — identifies when multiple signal types spike in the same area +- Infrastructure cascade modeling with dependency graphs for cables, pipelines, ports, and chokepoints +- Native desktop app (Tauri) with OS keychain integration, local API sidecar, and cloud fallback +- Progressive Web App with offline map support (500 cached tiles) +- Shareable intelligence stories with multi-platform social export and canvas-based image generation +- Cmd+K fuzzy search across 20+ result types +- Dark/light theme, panel drag-and-drop reordering, ultra-wide monitor layout (2000px+) + +## Desktop App + +- [Windows Installer](https://worldmonitor.app/api/download?platform=windows-exe): Windows .exe installer +- [macOS Apple Silicon](https://worldmonitor.app/api/download?platform=macos-arm64): macOS ARM64 build +- [macOS Intel](https://worldmonitor.app/api/download?platform=macos-x64): macOS x64 build +- [Linux AppImage](https://worldmonitor.app/api/download?platform=linux-appimage): Linux .AppImage + +## Tech Stack + +- **Frontend**: TypeScript, Vite, MapLibre GL JS, deck.gl, D3.js, Transformers.js (browser-side ML) +- **Internationalization**: i18next with lazy-loaded language bundles and RTL support +- **Desktop**: Tauri (Rust + Node.js sidecar) for macOS, Windows, Linux +- **Backend**: 60+ Vercel Edge Functions, Railway relay server +- **Data**: Upstash Redis caching, three-tier cache (in-memory, Redis, upstream) +- **AI**: Groq (Llama 3.1), OpenRouter, browser-side T5 fallback +- **Monitoring**: Sentry error tracking, data freshness tracker across 22 sources + +## Optional + +- [Source Code](https://github.com/koala73/worldmonitor): GitHub repository (AGPL-3.0) +- [Releases](https://github.com/koala73/worldmonitor/releases): All desktop releases for macOS, Windows, and Linux +- [Issues](https://github.com/koala73/worldmonitor/issues): Bug reports and feature requests diff --git a/public/map-styles/happy-dark.json b/public/map-styles/happy-dark.json new file mode 100644 index 000000000..300993d96 --- /dev/null +++ b/public/map-styles/happy-dark.json @@ -0,0 +1,5540 @@ +{ + "version": 8, + "name": "Dark Matter", + "metadata": { + "maputnik:renderer": "mbgljs" + }, + "sources": { + "carto": { + "type": "vector", + "url": "https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json" + } + }, + "sprite": "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/sprite", + "glyphs": "https://tiles.basemaps.cartocdn.com/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "layout": { + "visibility": "visible" + }, + "paint": { + "background-color": "#16202E", + "background-opacity": 1 + } + }, + { + "id": "landcover", + "type": "fill", + "source": "carto", + "source-layer": "landcover", + "filter": [ + "any", + [ + "==", + "class", + "wood" + ], + [ + "==", + "class", + "grass" + ], + [ + "==", + "subclass", + "recreation_ground" + ] + ], + "paint": { + "fill-color": "rgba(45, 64, 53, 0.3)", + "fill-opacity": 1 + } + }, + { + "id": "park_national_park", + "type": "fill", + "source": "carto", + "source-layer": "park", + "minzoom": 9, + "filter": [ + "all", + [ + "==", + "class", + "national_park" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(50, 72, 58, 0.35)", + "fill-opacity": 1, + "fill-translate-anchor": "map" + } + }, + { + "id": "park_nature_reserve", + "type": "fill", + "source": "carto", + "source-layer": "park", + "minzoom": 0, + "filter": [ + "all", + [ + "==", + "class", + "nature_reserve" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(50, 72, 58, 0.35)", + "fill-antialias": true, + "fill-opacity": { + "stops": [ + [ + 6, + 0.7 + ], + [ + 9, + 0.9 + ] + ] + } + } + }, + { + "id": "landuse_residential", + "type": "fill", + "source": "carto", + "source-layer": "landuse", + "minzoom": 6, + "filter": [ + "any", + [ + "==", + "class", + "residential" + ] + ], + "paint": { + "fill-color": "rgba(30, 40, 50, 0.3)", + "fill-opacity": { + "stops": [ + [ + 6, + 0.6 + ], + [ + 9, + 1 + ] + ] + } + } + }, + { + "id": "landuse", + "type": "fill", + "source": "carto", + "source-layer": "landuse", + "filter": [ + "any", + [ + "==", + "class", + "cemetery" + ], + [ + "==", + "class", + "stadium" + ] + ], + "paint": { + "fill-color": "rgba(40, 56, 48, 0.25)" + } + }, + { + "id": "waterway", + "type": "line", + "source": "carto", + "source-layer": "waterway", + "paint": { + "line-color": "#1A2838", + "line-width": { + "stops": [ + [ + 8, + 0.5 + ], + [ + 9, + 1 + ], + [ + 15, + 2 + ], + [ + 16, + 3 + ] + ] + } + } + }, + { + "id": "boundary_county", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 9, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "admin_level", + 6 + ], + [ + "==", + "maritime", + 0 + ] + ], + "paint": { + "line-color": "#253530", + "line-width": { + "stops": [ + [ + 4, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 6, + [ + 1 + ] + ], + [ + 7, + [ + 2, + 2 + ] + ] + ] + } + } + }, + { + "id": "boundary_state", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 4, + "filter": [ + "all", + [ + "==", + "admin_level", + 4 + ], + [ + "==", + "maritime", + 0 + ] + ], + "paint": { + "line-color": "#2E3E38", + "line-width": { + "stops": [ + [ + 4, + 0.5 + ], + [ + 7, + 1 + ], + [ + 8, + 1 + ], + [ + 9, + 1.2 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 6, + [ + 1, + 2, + 3 + ] + ], + [ + 7, + [ + 1, + 2, + 3 + ] + ] + ] + } + } + }, + { + "id": "water", + "type": "fill", + "source": "carto", + "source-layer": "water", + "minzoom": 0, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#121C2A", + "fill-antialias": true, + "fill-translate-anchor": "map", + "fill-opacity": 1 + } + }, + { + "id": "water_shadow", + "type": "fill", + "source": "carto", + "source-layer": "water", + "minzoom": 0, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#101A28", + "fill-antialias": true, + "fill-translate-anchor": "map", + "fill-opacity": 1, + "fill-translate": { + "stops": [ + [ + 0, + [ + 0, + 2 + ] + ], + [ + 6, + [ + 0, + 1 + ] + ], + [ + 14, + [ + 0, + 1 + ] + ], + [ + 17, + [ + 0, + 2 + ] + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "source": "carto", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "class", + "runway" + ] + ], + "layout": { + "line-cap": "square" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ] + ] + }, + "line-color": "#2A3848" + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "source": "carto", + "source-layer": "aeroway", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "class", + "taxiway" + ] + ], + "paint": { + "line-color": "#2A3848", + "line-width": { + "stops": [ + [ + 13, + 0.5 + ], + [ + 14, + 1 + ], + [ + 15, + 2 + ], + [ + 16, + 4 + ] + ] + } + } + }, + { + "id": "tunnel_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 17, + 6 + ], + [ + 18, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#182636" + } + }, + { + "id": "tunnel_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#182636" + } + }, + { + "id": "tunnel_sec_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 5 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#182636" + } + }, + { + "id": "tunnel_pri_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 8, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#182636" + } + }, + { + "id": "tunnel_trunk_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#182636" + } + }, + { + "id": "tunnel_mot_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 12, + 4 + ], + [ + 13, + 5 + ], + [ + 14, + 7 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 17, + 13 + ], + [ + 18, + 22 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#182636" + } + }, + { + "id": "tunnel_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "path" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 3 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3A35", + "line-dasharray": { + "stops": [ + [ + 15, + [ + 2, + 2 + ] + ], + [ + 18, + [ + 3, + 3 + ] + ] + ] + } + } + }, + { + "id": "tunnel_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2 + ], + [ + 17, + 4 + ], + [ + 18, + 6 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3A" + } + }, + { + "id": "tunnel_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 3 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 12 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3A" + } + }, + { + "id": "tunnel_sec_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 2 + ], + [ + 13, + 2 + ], + [ + 14, + 3 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3A" + } + }, + { + "id": "tunnel_pri_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3A" + } + }, + { + "id": "tunnel_trunk_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3A" + } + }, + { + "id": "tunnel_mot_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 7 + ], + [ + 16, + 9 + ], + [ + 17, + 11 + ], + [ + 18, + 20 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3A" + } + }, + { + "id": "tunnel_rail", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#2A3A38", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 14, + 1 + ], + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 21, + 7 + ] + ] + }, + "line-opacity": 0.5 + } + }, + { + "id": "tunnel_rail_dash", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#344848", + "line-width": { + "base": 1.3, + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 20, + 5 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 15, + [ + 5, + 5 + ] + ], + [ + 16, + [ + 6, + 6 + ] + ] + ] + }, + "line-opacity": 0.5 + } + }, + { + "id": "road_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 17, + 6 + ], + [ + 18, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1A2838" + } + }, + { + "id": "road_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 4.3 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1A2838" + } + }, + { + "id": "road_pri_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 15, + 5 + ], + [ + 16, + 8 + ], + [ + 17, + 10 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1A2838" + } + }, + { + "id": "road_trunk_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 15, + 5 + ], + [ + 16, + 8 + ], + [ + 17, + 10 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1A2838" + } + }, + { + "id": "road_mot_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 15, + 5 + ], + [ + 16, + 8 + ], + [ + 17, + 10 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1A2838" + } + }, + { + "id": "road_sec_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.9 + ], + [ + 12, + 1.5 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1A2838" + } + }, + { + "id": "road_pri_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 7, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1A2838" + } + }, + { + "id": "road_trunk_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1A2838" + } + }, + { + "id": "road_mot_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.7 + ], + [ + 8, + 0.8 + ], + [ + 11, + 3 + ], + [ + 12, + 4 + ], + [ + 13, + 5 + ], + [ + 14, + 7 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 17, + 13 + ], + [ + 18, + 22 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1A2838" + } + }, + { + "id": "road_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "path", + "track" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 3 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3A35", + "line-dasharray": { + "stops": [ + [ + 15, + [ + 2, + 2 + ] + ], + [ + 18, + [ + 3, + 3 + ] + ] + ] + } + } + }, + { + "id": "road_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2 + ], + [ + 17, + 4 + ], + [ + 18, + 6 + ] + ] + }, + "line-opacity": 1, + "line-color": "#22303E" + } + }, + { + "id": "road_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 3 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 12 + ] + ] + }, + "line-opacity": 1, + "line-color": "#22303E" + } + }, + { + "id": "road_pri_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 6 + ], + [ + 17, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#263444" + } + }, + { + "id": "road_trunk_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "square", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 6 + ], + [ + 17, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3848" + } + }, + { + "id": "road_mot_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 6 + ], + [ + 17, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3848" + } + }, + { + "id": "road_sec_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 2 + ], + [ + 13, + 2 + ], + [ + 14, + 3 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#243240" + } + }, + { + "id": "road_pri_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 0.3 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#263444" + } + }, + { + "id": "road_trunk_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3848" + } + }, + { + "id": "road_mot_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 7 + ], + [ + 16, + 9 + ], + [ + 17, + 11 + ], + [ + 18, + 20 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3848" + } + }, + { + "id": "rail", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#2A3A38", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 14, + 1 + ], + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 21, + 7 + ] + ] + } + } + }, + { + "id": "rail_dash", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#344848", + "line-width": { + "base": 1.3, + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 20, + 5 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 15, + [ + 5, + 5 + ] + ], + [ + 16, + [ + 6, + 6 + ] + ] + ] + } + } + }, + { + "id": "bridge_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 17, + 6 + ], + [ + 18, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3C" + } + }, + { + "id": "bridge_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 4.3 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3C" + } + }, + { + "id": "bridge_sec_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 1.5 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#1E2C3C" + } + }, + { + "id": "bridge_pri_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 8, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1E2C3C" + } + }, + { + "id": "bridge_trunk_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1E2C3C" + } + }, + { + "id": "bridge_mot_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 12, + 4 + ], + [ + 13, + 5 + ], + [ + 14, + 7 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 17, + 13 + ], + [ + 18, + 22 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#1E2C3C" + } + }, + { + "id": "bridge_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "path" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 3 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3A35", + "line-dasharray": { + "stops": [ + [ + 15, + [ + 2, + 2 + ] + ], + [ + 18, + [ + 3, + 3 + ] + ] + ] + } + } + }, + { + "id": "bridge_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2 + ], + [ + 17, + 4 + ], + [ + 18, + 6 + ] + ] + }, + "line-opacity": 1, + "line-color": "#263444" + } + }, + { + "id": "bridge_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 3 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 12 + ] + ] + }, + "line-opacity": 1, + "line-color": "#263444" + } + }, + { + "id": "bridge_sec_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 2 + ], + [ + 13, + 2 + ], + [ + 14, + 3 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#263444" + } + }, + { + "id": "bridge_pri_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#263444" + } + }, + { + "id": "bridge_trunk_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3848" + } + }, + { + "id": "bridge_mot_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 7 + ], + [ + 16, + 9 + ], + [ + 17, + 11 + ], + [ + 18, + 20 + ] + ] + }, + "line-opacity": 1, + "line-color": "#2A3848" + } + }, + { + "id": "building", + "type": "fill", + "source": "carto", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#1E2A38", + "fill-antialias": true + } + }, + { + "id": "building-top", + "type": "fill", + "source": "carto", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-translate": { + "base": 1, + "stops": [ + [ + 14, + [ + 0, + 0 + ] + ], + [ + 16, + [ + -2, + -2 + ] + ] + ] + }, + "fill-outline-color": "#283848", + "fill-color": "#202E3A", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 16, + 1 + ] + ] + } + } + }, + { + "id": "boundary_country_outline", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 6, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#3D5045", + "line-opacity": 0.5, + "line-width": 8, + "line-offset": 0 + } + }, + { + "id": "boundary_country_inner", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 0, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#3D5045", + "line-opacity": 1, + "line-width": { + "stops": [ + [ + 3, + 1 + ], + [ + 6, + 1.5 + ] + ] + }, + "line-offset": 0 + } + }, + { + "id": "waterway_label", + "type": "symbol", + "source": "carto", + "source-layer": "waterway", + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "class", + "river" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "symbol-placement": "line", + "symbol-spacing": 300, + "symbol-avoid-edges": false, + "text-size": { + "stops": [ + [ + 9, + 8 + ], + [ + 10, + 9 + ] + ] + }, + "text-padding": 2, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-offset": { + "stops": [ + [ + 6, + [ + 0, + -0.2 + ] + ], + [ + 11, + [ + 0, + -0.4 + ] + ], + [ + 12, + [ + 0, + -0.6 + ] + ] + ] + }, + "text-letter-spacing": 0, + "text-keep-upright": true + }, + "paint": { + "text-color": "#5A7888", + "text-halo-color": "#121C2A", + "text-halo-width": 1 + } + }, + { + "id": "watername_ocean", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 0, + "maxzoom": 5, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "ocean" + ] + ], + "layout": { + "text-field": "{name}", + "symbol-placement": "point", + "text-size": { + "stops": [ + [ + 0, + 13 + ], + [ + 2, + 14 + ], + [ + 4, + 18 + ] + ] + }, + "text-font": [ + "Montserrat Medium Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-max-width": 6, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#5A7888", + "text-halo-color": "#121C2A", + "text-halo-width": 1, + "text-halo-blur": 0 + } + }, + { + "id": "watername_sea", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 5, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "sea" + ] + ], + "layout": { + "text-field": "{name}", + "symbol-placement": "point", + "text-size": 12, + "text-font": [ + "Montserrat Medium Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-max-width": 6, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#5A7888", + "text-halo-color": "#121C2A", + "text-halo-width": 1, + "text-halo-blur": 0 + } + }, + { + "id": "watername_lake", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 4, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "lake" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "symbol-placement": "point", + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 15, + 11 + ], + [ + 16, + 12 + ], + [ + 17, + 13 + ] + ] + }, + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto" + }, + "paint": { + "text-color": "#5A7888", + "text-halo-color": "#121C2A", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "watername_lake_line", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "symbol-placement": "line", + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 15, + 11 + ], + [ + 16, + 12 + ], + [ + 17, + 13 + ] + ] + }, + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "symbol-spacing": 350, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-line-height": 1.2 + }, + "paint": { + "text-color": "#5A7888", + "text-halo-color": "#121C2A", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "place_hamlet", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 12, + "maxzoom": 16, + "filter": [ + "any", + [ + "==", + "class", + "neighbourhood" + ], + [ + "==", + "class", + "hamlet" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 14, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 8 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": { + "stops": [ + [ + 12, + "none" + ], + [ + 14, + "uppercase" + ] + ] + } + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_suburbs", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 12, + "maxzoom": 16, + "filter": [ + "all", + [ + "==", + "class", + "suburb" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 9 + ], + [ + 13, + 10 + ], + [ + 14, + 11 + ], + [ + 15, + 12 + ], + [ + 16, + 13 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": { + "stops": [ + [ + 8, + "none" + ], + [ + 12, + "uppercase" + ] + ] + } + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_villages", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 10, + "maxzoom": 16, + "filter": [ + "all", + [ + "==", + "class", + "village" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 9 + ], + [ + 12, + 10 + ], + [ + 13, + 11 + ], + [ + 14, + 12 + ], + [ + 16, + 13 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "none" + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_town", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "class", + "town" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 10 + ], + [ + 9, + 10 + ], + [ + 10, + 11 + ], + [ + 13, + 14 + ], + [ + 14, + 15 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "none" + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_country_2", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 3, + "maxzoom": 10, + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 3 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 3, + 10 + ], + [ + 5, + 11 + ], + [ + 6, + 12 + ], + [ + 7, + 13 + ], + [ + 8, + 14 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#A0A098", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_country_1", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 2, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "<=", + "rank", + 2 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 4, + 12 + ], + [ + 5, + 13 + ], + [ + 6, + 14 + ] + ] + }, + "text-transform": "uppercase", + "text-max-width": { + "stops": [ + [ + 2, + 6 + ], + [ + 3, + 6 + ], + [ + 4, + 9 + ], + [ + 5, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#A0A098", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_state", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 5, + "maxzoom": 10, + "filter": [ + "all", + [ + "==", + "class", + "state" + ], + [ + "<=", + "rank", + 4 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 5, + 12 + ], + [ + 7, + 14 + ] + ] + }, + "text-transform": "uppercase", + "text-max-width": 9 + }, + "paint": { + "text-color": "#A0A098", + "text-halo-color": "#1A2332", + "text-halo-width": 0 + } + }, + { + "id": "place_continent", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 0, + "maxzoom": 2, + "filter": [ + "all", + [ + "==", + "class", + "continent" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-transform": "uppercase", + "text-size": 14, + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-justify": "center", + "text-keep-upright": false + }, + "paint": { + "text-color": "#A0A098", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_city_r6", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + ">=", + "rank", + 6 + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 12 + ], + [ + 9, + 13 + ], + [ + 10, + 14 + ], + [ + 13, + 17 + ], + [ + 14, + 20 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_city_r5", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + ">=", + "rank", + 0 + ], + [ + "<=", + "rank", + 5 + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 14 + ], + [ + 10, + 16 + ], + [ + 13, + 19 + ], + [ + 14, + 22 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 6, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "<=", + "rank", + 7 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r4", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 5, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "<=", + "rank", + 4 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r2", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "<=", + "rank", + 2 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_z7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 7, + "maxzoom": 8, + "filter": [ + "all", + [ + "!has", + "capital" + ], + [ + "!in", + "class", + "country", + "state" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "place_capital_dot_z7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 7, + "maxzoom": 8, + "filter": [ + "all", + [ + ">", + "capital", + 0 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#A0A098", + "icon-color": "#8BAF7A", + "icon-translate-anchor": "map", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "poi_stadium", + "type": "symbol", + "source": "carto", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + [ + "in", + "class", + "stadium", + "cemetery", + "attraction" + ], + [ + "<=", + "rank", + 3 + ] + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 8 + ], + [ + 17, + 9 + ], + [ + 18, + 10 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#6B8868", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "poi_park", + "type": "symbol", + "source": "carto", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "park" + ] + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 8 + ], + [ + 17, + 9 + ], + [ + 18, + 10 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#6B8868", + "text-halo-color": "#1A2332", + "text-halo-width": 1 + } + }, + { + "id": "roadname_minor", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 16, + "filter": [ + "all", + [ + "in", + "class", + "minor", + "service" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 9, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": 200, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center" + }, + "paint": { + "text-color": "#788880", + "text-halo-color": "#1A2838", + "text-halo-width": 1 + } + }, + { + "id": "roadname_sec", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 12 + ] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": 200, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center" + }, + "paint": { + "text-color": "#788880", + "text-halo-color": "#1A2838", + "text-halo-width": 1 + } + }, + { + "id": "roadname_pri", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "primary" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 12 + ] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": { + "stops": [ + [ + 6, + 200 + ], + [ + 16, + 250 + ] + ] + }, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center", + "text-letter-spacing": { + "stops": [ + [ + 14, + 0 + ], + [ + 16, + 0.2 + ] + ] + } + }, + "paint": { + "text-color": "#788880", + "text-halo-color": "#1A2838", + "text-halo-width": 1 + } + }, + { + "id": "roadname_major", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 13, + "filter": [ + "all", + [ + "in", + "class", + "trunk", + "motorway" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 12 + ] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": { + "stops": [ + [ + 6, + 200 + ], + [ + 16, + 250 + ] + ] + }, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center", + "text-letter-spacing": { + "stops": [ + [ + 13, + 0 + ], + [ + 16, + 0.2 + ] + ] + } + }, + "paint": { + "text-color": "#788880", + "text-halo-color": "#1A2838", + "text-halo-width": 1 + } + }, + { + "id": "housenumber", + "type": "symbol", + "source": "carto", + "source-layer": "housenumber", + "minzoom": 17, + "maxzoom": 24, + "layout": { + "text-field": "{housenumber}", + "text-size": { + "stops": [ + [ + 17, + 9 + ], + [ + 18, + 11 + ] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ] + }, + "paint": { + "text-halo-color": "#1A2332", + "text-color": "#A0A098", + "text-halo-width": 0.75 + } + } + ], + "id": "voyager", + "owner": "Carto" +} \ No newline at end of file diff --git a/public/map-styles/happy-light.json b/public/map-styles/happy-light.json new file mode 100644 index 000000000..e0fd7f756 --- /dev/null +++ b/public/map-styles/happy-light.json @@ -0,0 +1,5535 @@ +{ + "version": 8, + "name": "Voyager", + "metadata": {}, + "sources": { + "carto": { + "type": "vector", + "url": "https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json" + } + }, + "sprite": "https://tiles.basemaps.cartocdn.com/gl/voyager-gl-style/sprite", + "glyphs": "https://tiles.basemaps.cartocdn.com/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "layout": { + "visibility": "visible" + }, + "paint": { + "background-color": "#FAFAF5", + "background-opacity": 1 + } + }, + { + "id": "landcover", + "type": "fill", + "source": "carto", + "source-layer": "landcover", + "filter": [ + "any", + [ + "==", + "class", + "wood" + ], + [ + "==", + "class", + "grass" + ], + [ + "==", + "subclass", + "recreation_ground" + ] + ], + "paint": { + "fill-color": "rgba(200, 210, 185, 0.25)", + "fill-opacity": 1 + } + }, + { + "id": "park_national_park", + "type": "fill", + "source": "carto", + "source-layer": "park", + "minzoom": 9, + "filter": [ + "all", + [ + "==", + "class", + "national_park" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(185, 205, 170, 0.3)", + "fill-opacity": 1, + "fill-translate-anchor": "map" + } + }, + { + "id": "park_nature_reserve", + "type": "fill", + "source": "carto", + "source-layer": "park", + "minzoom": 0, + "filter": [ + "all", + [ + "==", + "class", + "nature_reserve" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "rgba(185, 205, 170, 0.3)", + "fill-antialias": true, + "fill-opacity": { + "stops": [ + [ + 6, + 0.7 + ], + [ + 9, + 0.9 + ] + ] + } + } + }, + { + "id": "landuse_residential", + "type": "fill", + "source": "carto", + "source-layer": "landuse", + "minzoom": 6, + "filter": [ + "any", + [ + "==", + "class", + "residential" + ] + ], + "paint": { + "fill-color": "rgba(220, 215, 200, 0.25)", + "fill-opacity": { + "stops": [ + [ + 6, + 0.6 + ], + [ + 9, + 1 + ] + ] + } + } + }, + { + "id": "landuse", + "type": "fill", + "source": "carto", + "source-layer": "landuse", + "filter": [ + "any", + [ + "==", + "class", + "cemetery" + ], + [ + "==", + "class", + "stadium" + ] + ], + "paint": { + "fill-color": "rgba(195, 208, 180, 0.2)" + } + }, + { + "id": "waterway", + "type": "line", + "source": "carto", + "source-layer": "waterway", + "paint": { + "line-color": "#C4D8E0", + "line-width": { + "stops": [ + [ + 8, + 0.5 + ], + [ + 9, + 1 + ], + [ + 15, + 2 + ], + [ + 16, + 3 + ] + ] + } + } + }, + { + "id": "boundary_county", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 9, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "admin_level", + 6 + ], + [ + "==", + "maritime", + 0 + ] + ], + "paint": { + "line-color": "#DDD8D0", + "line-width": { + "stops": [ + [ + 4, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 6, + [ + 1 + ] + ], + [ + 7, + [ + 2, + 2 + ] + ] + ] + } + } + }, + { + "id": "boundary_state", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 4, + "filter": [ + "all", + [ + "==", + "admin_level", + 4 + ], + [ + "==", + "maritime", + 0 + ] + ], + "paint": { + "line-color": "#D0C8BD", + "line-width": { + "stops": [ + [ + 4, + 0.5 + ], + [ + 7, + 1 + ], + [ + 8, + 1 + ], + [ + 9, + 1.2 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 6, + [ + 1 + ] + ], + [ + 7, + [ + 2, + 2 + ] + ] + ] + } + } + }, + { + "id": "water", + "type": "fill", + "source": "carto", + "source-layer": "water", + "minzoom": 0, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#D4E6EC", + "fill-antialias": true, + "fill-translate-anchor": "map", + "fill-opacity": 1 + } + }, + { + "id": "water_shadow", + "type": "fill", + "source": "carto", + "source-layer": "water", + "minzoom": 0, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#C8DCE6", + "fill-antialias": true, + "fill-translate-anchor": "map", + "fill-opacity": 1, + "fill-translate": { + "stops": [ + [ + 0, + [ + 0, + 2 + ] + ], + [ + 6, + [ + 0, + 1 + ] + ], + [ + 14, + [ + 0, + 1 + ] + ], + [ + 17, + [ + 0, + 2 + ] + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "source": "carto", + "source-layer": "aeroway", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "class", + "runway" + ] + ], + "layout": { + "line-cap": "square" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "source": "carto", + "source-layer": "aeroway", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "class", + "taxiway" + ] + ], + "paint": { + "line-color": "#D8D2C8", + "line-width": { + "stops": [ + [ + 13, + 0.5 + ], + [ + 14, + 1 + ], + [ + 15, + 2 + ], + [ + 16, + 4 + ] + ] + } + } + }, + { + "id": "waterway_label", + "type": "symbol", + "source": "carto", + "source-layer": "waterway", + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "class", + "river" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "symbol-placement": "line", + "symbol-spacing": 300, + "symbol-avoid-edges": false, + "text-size": { + "stops": [ + [ + 9, + 8 + ], + [ + 10, + 9 + ] + ] + }, + "text-padding": 2, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-offset": { + "stops": [ + [ + 6, + [ + 0, + -0.2 + ] + ], + [ + 11, + [ + 0, + -0.4 + ] + ], + [ + 12, + [ + 0, + -0.6 + ] + ] + ] + }, + "text-letter-spacing": 0, + "text-keep-upright": true + }, + "paint": { + "text-color": "#7A9AAA", + "text-halo-color": "#D4E6EC", + "text-halo-width": 1 + } + }, + { + "id": "tunnel_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 17, + 6 + ], + [ + 18, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#DDD8D0" + } + }, + { + "id": "tunnel_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#DDD8D0" + } + }, + { + "id": "tunnel_sec_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 5 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#DDD8D0" + } + }, + { + "id": "tunnel_pri_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 8, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#DDD8D0" + } + }, + { + "id": "tunnel_trunk_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#DDD8D0" + } + }, + { + "id": "tunnel_mot_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 12, + 4 + ], + [ + 13, + 5 + ], + [ + 14, + 7 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 17, + 13 + ], + [ + 18, + 22 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#DDD8D0" + } + }, + { + "id": "tunnel_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "path" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 3 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D0C5", + "line-dasharray": { + "stops": [ + [ + 15, + [ + 2, + 2 + ] + ], + [ + 18, + [ + 3, + 3 + ] + ] + ] + } + } + }, + { + "id": "tunnel_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2 + ], + [ + 17, + 4 + ], + [ + 18, + 6 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "tunnel_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 3 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 12 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "tunnel_sec_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 2 + ], + [ + 13, + 2 + ], + [ + 14, + 3 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "tunnel_pri_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "tunnel_trunk_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#EDE8DE" + } + }, + { + "id": "tunnel_mot_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 7 + ], + [ + 16, + 9 + ], + [ + 17, + 11 + ], + [ + 18, + 20 + ] + ] + }, + "line-opacity": 1, + "line-color": "#EDE8DE" + } + }, + { + "id": "tunnel_rail", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#C8C0B5", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 14, + 1 + ], + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 21, + 7 + ] + ] + }, + "line-opacity": 0.5 + } + }, + { + "id": "tunnel_rail_dash", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "==", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#D0C8BD", + "line-width": { + "base": 1.3, + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 20, + 5 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 15, + [ + 5, + 5 + ] + ], + [ + 16, + [ + 6, + 6 + ] + ] + ] + }, + "line-opacity": 0.5 + } + }, + { + "id": "road_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 17, + 6 + ], + [ + 18, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 4.3 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_pri_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 15, + 5 + ], + [ + 16, + 8 + ], + [ + 17, + 10 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_trunk_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 15, + 5 + ], + [ + 16, + 8 + ], + [ + 17, + 10 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_mot_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 15, + 5 + ], + [ + 16, + 8 + ], + [ + 17, + 10 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_sec_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 1.5 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_pri_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 7, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_trunk_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_mot_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.7 + ], + [ + 8, + 0.8 + ], + [ + 11, + 3 + ], + [ + 12, + 4 + ], + [ + 13, + 5 + ], + [ + 14, + 7 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 17, + 13 + ], + [ + 18, + 22 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "road_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "path", + "track" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 3 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D0C5", + "line-dasharray": { + "stops": [ + [ + 15, + [ + 2, + 2 + ] + ], + [ + 18, + [ + 3, + 3 + ] + ] + ] + } + } + }, + { + "id": "road_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2 + ], + [ + 17, + 4 + ], + [ + 18, + 6 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F8F4EC" + } + }, + { + "id": "road_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 3 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 12 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F8F4EC" + } + }, + { + "id": "road_pri_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 6 + ], + [ + 17, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F2EEE5" + } + }, + { + "id": "road_trunk_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "square", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 6 + ], + [ + 17, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "road_mot_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "ramp", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 6 + ], + [ + 17, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "road_sec_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 2 + ], + [ + 13, + 2 + ], + [ + 14, + 3 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F5F0E8" + } + }, + { + "id": "road_pri_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 0.3 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F2EEE5" + } + }, + { + "id": "road_trunk_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "road_mot_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "!has", + "brunnel" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 7 + ], + [ + 16, + 9 + ], + [ + 17, + 11 + ], + [ + 18, + 20 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "rail", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#C8C0B5", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 14, + 1 + ], + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 21, + 7 + ] + ] + } + } + }, + { + "id": "rail_dash", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "rail" + ], + [ + "!=", + "brunnel", + "tunnel" + ] + ], + "layout": { + "visibility": "visible", + "line-join": "round" + }, + "paint": { + "line-color": "#D0C8BD", + "line-width": { + "base": 1.3, + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 20, + 5 + ] + ] + }, + "line-dasharray": { + "stops": [ + [ + 15, + [ + 5, + 5 + ] + ], + [ + 16, + [ + 6, + 6 + ] + ] + ] + } + } + }, + { + "id": "bridge_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 3 + ], + [ + 17, + 6 + ], + [ + 18, + 8 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "bridge_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 15, + 3 + ], + [ + 16, + 4.3 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "bridge_sec_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 0.5 + ], + [ + 12, + 1.5 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D2C8" + } + }, + { + "id": "bridge_pri_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 8, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "bridge_trunk_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 13, + 4 + ], + [ + 14, + 6 + ], + [ + 15, + 8 + ], + [ + 16, + 10 + ], + [ + 17, + 14 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 5, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "bridge_mot_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 0.8 + ], + [ + 8, + 1 + ], + [ + 11, + 3 + ], + [ + 12, + 4 + ], + [ + 13, + 5 + ], + [ + 14, + 7 + ], + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 17, + 13 + ], + [ + 18, + 22 + ] + ] + }, + "line-opacity": { + "stops": [ + [ + 6, + 0.5 + ], + [ + 7, + 1 + ] + ] + }, + "line-color": "#D8D2C8" + } + }, + { + "id": "bridge_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "path" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 16, + 1 + ], + [ + 18, + 3 + ] + ] + }, + "line-opacity": 1, + "line-color": "#D8D0C5", + "line-dasharray": { + "stops": [ + [ + 15, + [ + 2, + 2 + ] + ], + [ + 18, + [ + 3, + 3 + ] + ] + ] + } + } + }, + { + "id": "bridge_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "service" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 2 + ], + [ + 16, + 2 + ], + [ + 17, + 4 + ], + [ + 18, + 6 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F5F0E8" + } + }, + { + "id": "bridge_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "minor" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 15, + 3 + ], + [ + 16, + 4 + ], + [ + 17, + 8 + ], + [ + 18, + 12 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F5F0E8" + } + }, + { + "id": "bridge_sec_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 2 + ], + [ + 13, + 2 + ], + [ + 14, + 3 + ], + [ + 15, + 4 + ], + [ + 16, + 6 + ], + [ + 17, + 10 + ], + [ + 18, + 14 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F5F0E8" + } + }, + { + "id": "bridge_pri_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F2EEE5" + } + }, + { + "id": "bridge_trunk_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [ + 11, + 1 + ], + [ + 13, + 2 + ], + [ + 14, + 4 + ], + [ + 15, + 6 + ], + [ + 16, + 8 + ], + [ + 17, + 12 + ], + [ + 18, + 16 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "bridge_mot_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "!=", + "ramp", + 1 + ], + [ + "==", + "brunnel", + "bridge" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round" + }, + "paint": { + "line-width": { + "stops": [ + [ + 10, + 1 + ], + [ + 12, + 2 + ], + [ + 13, + 3 + ], + [ + 14, + 5 + ], + [ + 15, + 7 + ], + [ + 16, + 9 + ], + [ + 17, + 11 + ], + [ + 18, + 20 + ] + ] + }, + "line-opacity": 1, + "line-color": "#F0ECE2" + } + }, + { + "id": "building", + "type": "fill", + "source": "carto", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#E8E4DA", + "fill-antialias": true + } + }, + { + "id": "building-top", + "type": "fill", + "source": "carto", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-translate": { + "base": 1, + "stops": [ + [ + 14, + [ + 0, + 0 + ] + ], + [ + 16, + [ + -2, + -2 + ] + ] + ] + }, + "fill-outline-color": "#DDD8D0", + "fill-color": "#EEEBE2", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 16, + 1 + ] + ] + } + } + }, + { + "id": "boundary_country_outline", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 6, + "maxzoom": 24, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#C8C0B5", + "line-opacity": 0.5, + "line-width": 8, + "line-offset": 0 + } + }, + { + "id": "boundary_country_inner", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 0, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#C8C0B5", + "line-opacity": 1, + "line-width": { + "stops": [ + [ + 3, + 1 + ], + [ + 6, + 1.5 + ] + ] + }, + "line-offset": 0 + } + }, + { + "id": "watername_ocean", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 0, + "maxzoom": 5, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "ocean" + ] + ], + "layout": { + "text-field": "{name}", + "symbol-placement": "point", + "text-size": { + "stops": [ + [ + 0, + 13 + ], + [ + 2, + 14 + ], + [ + 4, + 18 + ] + ] + }, + "text-font": [ + "Montserrat Medium Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-max-width": 6, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#7A9AAA", + "text-halo-color": "#D4E6EC", + "text-halo-width": 1, + "text-halo-blur": 0 + } + }, + { + "id": "watername_sea", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 5, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "sea" + ] + ], + "layout": { + "text-field": "{name}", + "symbol-placement": "point", + "text-size": 12, + "text-font": [ + "Montserrat Medium Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-max-width": 6, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#7A9AAA", + "text-halo-color": "#D4E6EC", + "text-halo-width": 1, + "text-halo-blur": 0 + } + }, + { + "id": "watername_lake", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 4, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "lake" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "symbol-placement": "point", + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 15, + 11 + ], + [ + 16, + 12 + ], + [ + 17, + 13 + ] + ] + }, + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto" + }, + "paint": { + "text-color": "#7A9AAA", + "text-halo-color": "#D4E6EC", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "watername_lake_line", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "symbol-placement": "line", + "text-size": { + "stops": [ + [ + 13, + 9 + ], + [ + 14, + 10 + ], + [ + 15, + 11 + ], + [ + 16, + 12 + ], + [ + 17, + 13 + ] + ] + }, + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "symbol-spacing": 350, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-line-height": 1.2 + }, + "paint": { + "text-color": "#7A9AAA", + "text-halo-color": "#D4E6EC", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "place_hamlet", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 12, + "maxzoom": 16, + "filter": [ + "any", + [ + "==", + "class", + "neighbourhood" + ], + [ + "==", + "class", + "hamlet" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 14, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 13, + 8 + ], + [ + 14, + 10 + ], + [ + 16, + 11 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": { + "stops": [ + [ + 12, + "none" + ], + [ + 14, + "uppercase" + ] + ] + } + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_suburbs", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 12, + "maxzoom": 16, + "filter": [ + "all", + [ + "==", + "class", + "suburb" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 12, + 9 + ], + [ + 13, + 10 + ], + [ + 14, + 11 + ], + [ + 15, + 12 + ], + [ + 16, + 13 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": { + "stops": [ + [ + 8, + "none" + ], + [ + 12, + "uppercase" + ] + ] + } + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_villages", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 10, + "maxzoom": 16, + "filter": [ + "all", + [ + "==", + "class", + "village" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 10, + 9 + ], + [ + 12, + 10 + ], + [ + 13, + 11 + ], + [ + 14, + 12 + ], + [ + 16, + 13 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "none" + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_town", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "class", + "town" + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 10 + ], + [ + 9, + 10 + ], + [ + 10, + 11 + ], + [ + 13, + 14 + ], + [ + 14, + 15 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "none" + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_country_2", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 3, + "maxzoom": 10, + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + ">=", + "rank", + 3 + ], + [ + "has", + "iso_a2" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 3, + 10 + ], + [ + 5, + 11 + ], + [ + 6, + 12 + ], + [ + 7, + 13 + ], + [ + 8, + 14 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#4A5A4C", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_country_1", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 2, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "country" + ], + [ + "<=", + "rank", + 2 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 3, + 11 + ], + [ + 4, + 12 + ], + [ + 5, + 13 + ], + [ + 6, + 14 + ] + ] + }, + "text-transform": "uppercase", + "text-max-width": { + "stops": [ + [ + 2, + 6 + ], + [ + 3, + 6 + ], + [ + 4, + 9 + ], + [ + 5, + 12 + ] + ] + } + }, + "paint": { + "text-color": "#4A5A4C", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_state", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 5, + "maxzoom": 10, + "filter": [ + "all", + [ + "==", + "class", + "state" + ], + [ + "<=", + "rank", + 4 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 5, + 12 + ], + [ + 7, + 14 + ] + ] + }, + "text-transform": "uppercase", + "text-max-width": 9 + }, + "paint": { + "text-color": "#4A5A4C", + "text-halo-color": "#FAFAF5", + "text-halo-width": 0 + } + }, + { + "id": "place_continent", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 0, + "maxzoom": 2, + "filter": [ + "all", + [ + "==", + "class", + "continent" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-transform": "uppercase", + "text-size": 14, + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-justify": "center", + "text-keep-upright": false + }, + "paint": { + "text-color": "#4A5A4C", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_city_r6", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + ">=", + "rank", + 6 + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 12 + ], + [ + 9, + 13 + ], + [ + 10, + 14 + ], + [ + 13, + 17 + ], + [ + 14, + 20 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_city_r5", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + ">=", + "rank", + 0 + ], + [ + "<=", + "rank", + 5 + ] + ], + "layout": { + "text-field": { + "stops": [ + [ + 8, + "{name_en}" + ], + [ + 13, + "{name}" + ] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 8, + 14 + ], + [ + 10, + 16 + ], + [ + 13, + 19 + ], + [ + 14, + 22 + ] + ] + }, + "icon-image": "", + "icon-offset": [ + 16, + 0 + ], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 6, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "<=", + "rank", + 7 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r4", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 5, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "<=", + "rank", + 4 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r2", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 7, + "filter": [ + "all", + [ + "==", + "class", + "city" + ], + [ + "<=", + "rank", + 2 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_z7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 7, + "maxzoom": 8, + "filter": [ + "all", + [ + "!has", + "capital" + ], + [ + "!in", + "class", + "country", + "state" + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ] + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "place_capital_dot_z7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 7, + "maxzoom": 8, + "filter": [ + "all", + [ + ">", + "capital", + 0 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [ + 16, + 5 + ], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [ + 0.2, + 0.2 + ], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#4A5A4C", + "icon-color": "#6B8F5E", + "icon-translate-anchor": "map", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "poi_stadium", + "type": "symbol", + "source": "carto", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + [ + "in", + "class", + "stadium", + "cemetery", + "attraction" + ], + [ + "<=", + "rank", + 3 + ] + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 8 + ], + [ + 17, + 9 + ], + [ + 18, + 10 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#6B8F5E", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "poi_park", + "type": "symbol", + "source": "carto", + "source-layer": "poi", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "class", + "park" + ] + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 8 + ], + [ + 17, + 9 + ], + [ + 18, + 10 + ] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#6B8F5E", + "text-halo-color": "#FAFAF5", + "text-halo-width": 1 + } + }, + { + "id": "roadname_minor", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 16, + "filter": [ + "all", + [ + "in", + "class", + "minor", + "service" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 9, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": 200, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center" + }, + "paint": { + "text-color": "#5A6A5C", + "text-halo-color": "#F8F4EC", + "text-halo-width": 1 + } + }, + { + "id": "roadname_sec", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": [ + "all", + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 15, + 9 + ], + [ + 16, + 11 + ], + [ + 18, + 12 + ] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": 200, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center" + }, + "paint": { + "text-color": "#5A6A5C", + "text-halo-color": "#F8F4EC", + "text-halo-width": 1 + } + }, + { + "id": "roadname_pri", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "primary" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 12 + ] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": { + "stops": [ + [ + 6, + 200 + ], + [ + 16, + 250 + ] + ] + }, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center", + "text-letter-spacing": { + "stops": [ + [ + 14, + 0 + ], + [ + 16, + 0.2 + ] + ] + } + }, + "paint": { + "text-color": "#5A6A5C", + "text-halo-color": "#F8F4EC", + "text-halo-width": 1 + } + }, + { + "id": "roadname_major", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 13, + "filter": [ + "all", + [ + "in", + "class", + "trunk", + "motorway" + ] + ], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [ + 14, + 10 + ], + [ + 15, + 10 + ], + [ + 16, + 11 + ], + [ + 18, + 12 + ] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": { + "stops": [ + [ + 6, + 200 + ], + [ + 16, + 250 + ] + ] + }, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center", + "text-letter-spacing": { + "stops": [ + [ + 13, + 0 + ], + [ + 16, + 0.2 + ] + ] + } + }, + "paint": { + "text-color": "#5A6A5C", + "text-halo-color": "#F8F4EC", + "text-halo-width": 1 + } + }, + { + "id": "housenumber", + "type": "symbol", + "source": "carto", + "source-layer": "housenumber", + "minzoom": 17, + "maxzoom": 24, + "layout": { + "text-field": "{housenumber}", + "text-size": { + "stops": [ + [ + 17, + 9 + ], + [ + 18, + 11 + ] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ] + }, + "paint": { + "text-halo-color": "#FAFAF5", + "text-color": "#4A5A4C", + "text-halo-width": 0.75 + } + } + ], + "id": "voyager", + "owner": "Carto" +} \ No newline at end of file diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 000000000..e9040dd0c --- /dev/null +++ b/public/offline.html @@ -0,0 +1,26 @@ + + + + + + WorldMonitor - Offline + + + +
+

You're Offline

+

WorldMonitor requires an internet connection for real-time intelligence data.

+ +
+ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..8d9ec5c83 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,26 @@ +# WorldMonitor - protect API routes from crawlers +User-agent: * +Allow: / +Disallow: /api/ +Disallow: /tests/ + +# Allow social media bots for OG previews +User-agent: Twitterbot +Allow: /api/story +Allow: /api/og-story + +User-agent: facebookexternalhit +Allow: /api/story +Allow: /api/og-story + +User-agent: LinkedInBot +Allow: /api/story +Allow: /api/og-story + +User-agent: Slackbot +Allow: /api/story +Allow: /api/og-story + +User-agent: Discordbot +Allow: /api/story +Allow: /api/og-story diff --git a/scripts/ais-relay-rss.test.cjs b/scripts/ais-relay-rss.test.cjs new file mode 100644 index 000000000..b78bfde13 --- /dev/null +++ b/scripts/ais-relay-rss.test.cjs @@ -0,0 +1,337 @@ +/** + * Regression tests for the RSS proxy cache in ais-relay.cjs. + * + * Tests negative caching, in-flight dedup failure behavior, and no-cascade guarantees. + * Run: node --test scripts/ais-relay-rss.test.cjs + */ +'use strict'; + +const { strict: assert } = require('node:assert'); +const http = require('node:http'); +const test = require('node:test'); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function listen(server, port = 0) { + return new Promise((resolve, reject) => { + server.once('listening', () => resolve(server.address().port)); + server.once('error', reject); + server.listen(port, '127.0.0.1'); + }); +} + +function fetch(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + }).on('error', reject); + }); +} + +// ─── Mock upstream RSS server ───────────────────────────────────────────────── + +function createMockUpstream() { + let hitCount = 0; + let responseStatus = 200; + let responseBody = 'Test'; + let responseDelay = 0; + + const server = http.createServer((req, res) => { + hitCount++; + setTimeout(() => { + res.writeHead(responseStatus, { 'Content-Type': 'application/xml' }); + res.end(responseBody); + }, responseDelay); + }); + + return { + server, + getHitCount: () => hitCount, + resetHitCount: () => { hitCount = 0; }, + setResponse: (status, body) => { responseStatus = status; responseBody = body || responseBody; }, + setDelay: (ms) => { responseDelay = ms; }, + }; +} + +// ─── Create a minimal ais-relay-like RSS proxy for testing ──────────────────── +// Extracts just the RSS caching logic to test in isolation. + +function createTestRssProxy(upstreamPort) { + const https = require('node:http'); // use http for testing, not https + const zlib = require('node:zlib'); + + const rssResponseCache = new Map(); + const rssInFlight = new Map(); + const RSS_CACHE_TTL_MS = 5 * 60 * 1000; + const RSS_NEGATIVE_CACHE_TTL_MS = 60 * 1000; + const RSS_CACHE_MAX_ENTRIES = 5; // small cap for testing + + function safeEnd(res, statusCode, headers, body) { + if (res.headersSent || res.writableEnded) return false; + try { + res.writeHead(statusCode, headers); + res.end(body); + return true; + } catch { return false; } + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://127.0.0.1`); + const feedUrl = url.searchParams.get('url'); + + if (!feedUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'Missing url' })); + } + + // Cache check with status-aware TTL + const rssCached = rssResponseCache.get(feedUrl); + if (rssCached) { + const ttl = (rssCached.statusCode >= 200 && rssCached.statusCode < 300) + ? RSS_CACHE_TTL_MS : RSS_NEGATIVE_CACHE_TTL_MS; + if (Date.now() - rssCached.timestamp < ttl) { + res.writeHead(rssCached.statusCode, { + 'Content-Type': 'application/xml', + 'X-Cache': 'HIT', + }); + return res.end(rssCached.data); + } + } + + // In-flight dedup — cascade-resistant + const existing = rssInFlight.get(feedUrl); + if (existing) { + try { + await existing; + const deduped = rssResponseCache.get(feedUrl); + if (deduped) { + res.writeHead(deduped.statusCode, { + 'Content-Type': 'application/xml', + 'X-Cache': 'DEDUP', + }); + return res.end(deduped.data); + } + return safeEnd(res, 502, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'Upstream fetch completed but not cached' })); + } catch { + return safeEnd(res, 502, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'Upstream fetch failed' })); + } + } + + // MISS — fetch upstream + const fetchPromise = new Promise((resolveInFlight, rejectInFlight) => { + const request = http.get(`http://127.0.0.1:${upstreamPort}${new URL(feedUrl).pathname}`, { + timeout: 5000, + }, (response) => { + const chunks = []; + response.on('data', (c) => chunks.push(c)); + response.on('end', () => { + const data = Buffer.concat(chunks); + // FIFO eviction + if (rssResponseCache.size >= RSS_CACHE_MAX_ENTRIES && !rssResponseCache.has(feedUrl)) { + const oldest = rssResponseCache.keys().next().value; + if (oldest) rssResponseCache.delete(oldest); + } + rssResponseCache.set(feedUrl, { + data, contentType: 'application/xml', + statusCode: response.statusCode, timestamp: Date.now(), + }); + resolveInFlight(); + res.writeHead(response.statusCode, { + 'Content-Type': 'application/xml', + 'X-Cache': 'MISS', + }); + res.end(data); + }); + }); + + request.on('error', (err) => { + if (rssCached) { + res.writeHead(200, { 'Content-Type': 'application/xml', 'X-Cache': 'STALE' }); + res.end(rssCached.data); + resolveInFlight(); + return; + } + rejectInFlight(err); + safeEnd(res, 502, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: err.message })); + }); + + request.on('timeout', () => { + request.destroy(); + if (rssCached) { + res.writeHead(200, { 'Content-Type': 'application/xml', 'X-Cache': 'STALE' }); + res.end(rssCached.data); + resolveInFlight(); + return; + } + rejectInFlight(new Error('timeout')); + safeEnd(res, 504, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'timeout' })); + }); + }); + + rssInFlight.set(feedUrl, fetchPromise); + fetchPromise.catch(() => {}).finally(() => rssInFlight.delete(feedUrl)); + }); + + return { server, cache: rssResponseCache, inFlight: rssInFlight }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test('RSS proxy: negative caching prevents thundering herd on 429', async (t) => { + const upstream = createMockUpstream(); + upstream.setResponse(429, 'Rate limited'); + const upstreamPort = await listen(upstream.server); + + const proxy = createTestRssProxy(upstreamPort); + const proxyPort = await listen(proxy.server); + + const feedUrl = `http://example.com/nhk/news/en`; + + // First request — MISS, upstream returns 429 + const r1 = await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + assert.equal(r1.status, 429); + assert.equal(r1.headers['x-cache'], 'MISS'); + assert.equal(upstream.getHitCount(), 1); + + // Second request — should HIT negative cache, NOT hit upstream again + const r2 = await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + assert.equal(r2.status, 429); + assert.equal(r2.headers['x-cache'], 'HIT'); + assert.equal(upstream.getHitCount(), 1, 'Should not hit upstream again — negative cache should serve'); + + // Third request — still cached + const r3 = await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + assert.equal(r3.headers['x-cache'], 'HIT'); + assert.equal(upstream.getHitCount(), 1); + + upstream.server.close(); + proxy.server.close(); +}); + +test('RSS proxy: concurrent requests dedup on in-flight, no cascade on failure', async (t) => { + const upstream = createMockUpstream(); + upstream.setResponse(503, 'Service Unavailable'); + upstream.setDelay(100); // slow enough for concurrent requests to queue up + const upstreamPort = await listen(upstream.server); + + const proxy = createTestRssProxy(upstreamPort); + const proxyPort = await listen(proxy.server); + + const feedUrl = `http://example.com/slow-feed`; + + // Fire 5 concurrent requests + const results = await Promise.all( + Array.from({ length: 5 }, () => + fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`) + ) + ); + + // Only 1 should be MISS, the rest should be DEDUP (served from negative cache after in-flight resolves) + const misses = results.filter((r) => r.headers['x-cache'] === 'MISS'); + const deduped = results.filter((r) => r.headers['x-cache'] === 'DEDUP'); + + assert.equal(misses.length, 1, 'Exactly 1 MISS (the leader)'); + assert.equal(deduped.length, 4, 'Remaining 4 should be DEDUP'); + assert.equal(upstream.getHitCount(), 1, 'Upstream hit exactly once despite 5 concurrent requests'); + + upstream.server.close(); + proxy.server.close(); +}); + +test('RSS proxy: successful 200 response cached with full TTL', async (t) => { + const upstream = createMockUpstream(); + upstream.setResponse(200, 'OK'); + const upstreamPort = await listen(upstream.server); + + const proxy = createTestRssProxy(upstreamPort); + const proxyPort = await listen(proxy.server); + + const feedUrl = `http://example.com/good-feed`; + + const r1 = await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + assert.equal(r1.status, 200); + assert.equal(r1.headers['x-cache'], 'MISS'); + + const r2 = await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + assert.equal(r2.status, 200); + assert.equal(r2.headers['x-cache'], 'HIT'); + assert.equal(upstream.getHitCount(), 1); + + upstream.server.close(); + proxy.server.close(); +}); + +test('RSS proxy: FIFO eviction caps cache size', async (t) => { + const upstream = createMockUpstream(); + upstream.setResponse(200, 'OK'); + const upstreamPort = await listen(upstream.server); + + const proxy = createTestRssProxy(upstreamPort); // max 5 entries + const proxyPort = await listen(proxy.server); + + // Fill cache with 5 unique URLs + for (let i = 0; i < 5; i++) { + await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(`http://example.com/feed-${i}`)}`); + } + assert.equal(proxy.cache.size, 5); + + // 6th URL should evict oldest + await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(`http://example.com/feed-new`)}`); + assert.equal(proxy.cache.size, 5, 'Cache should not exceed max entries'); + assert.ok(!proxy.cache.has('http://example.com/feed-0'), 'Oldest entry should be evicted'); + assert.ok(proxy.cache.has('http://example.com/feed-new'), 'New entry should be present'); + + upstream.server.close(); + proxy.server.close(); +}); + +test('RSS proxy: stale-on-error resolves in-flight (no hang)', async (t) => { + const upstream = createMockUpstream(); + upstream.setResponse(200, 'Fresh'); + const upstreamPort = await listen(upstream.server); + + const proxy = createTestRssProxy(upstreamPort); + const proxyPort = await listen(proxy.server); + + const feedUrl = `http://example.com/stale-test`; + + // Prime the cache + const r1 = await fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + assert.equal(r1.status, 200); + assert.equal(r1.headers['x-cache'], 'MISS'); + + // Now make the cache entry "stale" by backdating its timestamp + const entry = proxy.cache.get(feedUrl); + entry.timestamp = Date.now() - 10 * 60 * 1000; // 10 min ago + + // Kill upstream so the fetch will fail + upstream.server.close(); + await new Promise((r) => setTimeout(r, 50)); + + // Request should get stale data (not hang forever) + const r2Promise = fetch(`http://127.0.0.1:${proxyPort}/?url=${encodeURIComponent(feedUrl)}`); + const r2 = await Promise.race([ + r2Promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Request hung — in-flight not settled')), 3000)), + ]); + + // Should get stale or error, but NOT hang + assert.ok(r2.status === 200 || r2.status === 502, `Expected stale/error, got ${r2.status}`); + + // Verify in-flight map is clean + assert.equal(proxy.inFlight.size, 0, 'In-flight map should be empty after settlement'); + + proxy.server.close(); +}); diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs new file mode 100644 index 000000000..6a2bef323 --- /dev/null +++ b/scripts/ais-relay.cjs @@ -0,0 +1,2523 @@ +#!/usr/bin/env node +/** + * AIS WebSocket Relay Server + * Proxies aisstream.io data to browsers via WebSocket + * + * Deploy on Railway with: + * AISSTREAM_API_KEY=your_key + * + * Local: node scripts/ais-relay.cjs + */ + +const http = require('http'); +const https = require('https'); +const zlib = require('zlib'); +const path = require('path'); +const { readFileSync } = require('fs'); +const crypto = require('crypto'); +const { WebSocketServer, WebSocket } = require('ws'); + +const AISSTREAM_URL = 'wss://stream.aisstream.io/v0/stream'; +const API_KEY = process.env.AISSTREAM_API_KEY || process.env.VITE_AISSTREAM_API_KEY; +const PORT = process.env.PORT || 3004; + +if (!API_KEY) { + console.error('[Relay] Error: AISSTREAM_API_KEY environment variable not set'); + console.error('[Relay] Get a free key at https://aisstream.io'); + process.exit(1); +} + +const MAX_WS_CLIENTS = 10; // Cap WS clients — app uses HTTP snapshots, not WS +const UPSTREAM_QUEUE_HIGH_WATER = Math.max(500, Number(process.env.AIS_UPSTREAM_QUEUE_HIGH_WATER || 4000)); +const UPSTREAM_QUEUE_LOW_WATER = Math.max( + 100, + Math.min(UPSTREAM_QUEUE_HIGH_WATER - 1, Number(process.env.AIS_UPSTREAM_QUEUE_LOW_WATER || 1000)) +); +const UPSTREAM_QUEUE_HARD_CAP = Math.max( + UPSTREAM_QUEUE_HIGH_WATER + 1, + Number(process.env.AIS_UPSTREAM_QUEUE_HARD_CAP || 8000) +); +const UPSTREAM_DRAIN_BATCH = Math.max(1, Number(process.env.AIS_UPSTREAM_DRAIN_BATCH || 250)); +const UPSTREAM_DRAIN_BUDGET_MS = Math.max(2, Number(process.env.AIS_UPSTREAM_DRAIN_BUDGET_MS || 20)); +const MAX_VESSELS = 50000; // hard cap on vessels Map +const MAX_VESSEL_HISTORY = 50000; +const MAX_DENSITY_CELLS = 5000; +const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET || ''; +const RELAY_AUTH_HEADER = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase(); +const ALLOW_UNAUTHENTICATED_RELAY = process.env.ALLOW_UNAUTHENTICATED_RELAY === 'true'; +const IS_PRODUCTION_RELAY = process.env.NODE_ENV === 'production' + || !!process.env.RAILWAY_ENVIRONMENT + || !!process.env.RAILWAY_PROJECT_ID + || !!process.env.RAILWAY_STATIC_URL; +const RELAY_RATE_LIMIT_WINDOW_MS = Math.max(1000, Number(process.env.RELAY_RATE_LIMIT_WINDOW_MS || 60000)); +const RELAY_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_RATE_LIMIT_MAX)) + ? Number(process.env.RELAY_RATE_LIMIT_MAX) : 1200; +const RELAY_OPENSKY_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_OPENSKY_RATE_LIMIT_MAX)) + ? Number(process.env.RELAY_OPENSKY_RATE_LIMIT_MAX) : 600; +const RELAY_RSS_RATE_LIMIT_MAX = Number.isFinite(Number(process.env.RELAY_RSS_RATE_LIMIT_MAX)) + ? Number(process.env.RELAY_RSS_RATE_LIMIT_MAX) : 300; +const RELAY_LOG_THROTTLE_MS = Math.max(1000, Number(process.env.RELAY_LOG_THROTTLE_MS || 10000)); +const ALLOW_VERCEL_PREVIEW_ORIGINS = process.env.ALLOW_VERCEL_PREVIEW_ORIGINS === 'true'; + +if (IS_PRODUCTION_RELAY && !RELAY_SHARED_SECRET && !ALLOW_UNAUTHENTICATED_RELAY) { + console.error('[Relay] Error: RELAY_SHARED_SECRET is required in production'); + console.error('[Relay] Set RELAY_SHARED_SECRET on Railway and Vercel to secure relay endpoints'); + console.error('[Relay] To bypass temporarily (not recommended), set ALLOW_UNAUTHENTICATED_RELAY=true'); + process.exit(1); +} + +let upstreamSocket = null; +let upstreamPaused = false; +let upstreamQueue = []; +let upstreamQueueReadIndex = 0; +let upstreamDrainScheduled = false; +let clients = new Set(); +let messageCount = 0; +let droppedMessages = 0; +const requestRateBuckets = new Map(); // key: route:ip -> { count, resetAt } +const logThrottleState = new Map(); // key: event key -> timestamp + +// Safe response: guard against "headers already sent" crashes +function safeEnd(res, statusCode, headers, body) { + if (res.headersSent || res.writableEnded) return false; + try { + res.writeHead(statusCode, headers); + res.end(body); + return true; + } catch { + return false; + } +} + +// gzip compress & send a response (reduces egress ~80% for JSON) +function sendCompressed(req, res, statusCode, headers, body) { + if (res.headersSent || res.writableEnded) return; + const acceptEncoding = req.headers['accept-encoding'] || ''; + if (acceptEncoding.includes('gzip')) { + zlib.gzip(typeof body === 'string' ? Buffer.from(body) : body, (err, compressed) => { + if (err || res.headersSent || res.writableEnded) { + safeEnd(res, statusCode, headers, body); + return; + } + safeEnd(res, statusCode, { ...headers, 'Content-Encoding': 'gzip', 'Vary': 'Accept-Encoding' }, compressed); + }); + } else { + safeEnd(res, statusCode, headers, body); + } +} + +// Pre-gzipped response: serve a cached gzip buffer directly (zero CPU per request) +function sendPreGzipped(req, res, statusCode, headers, rawBody, gzippedBody) { + if (res.headersSent || res.writableEnded) return; + const acceptEncoding = req.headers['accept-encoding'] || ''; + if (acceptEncoding.includes('gzip') && gzippedBody) { + safeEnd(res, statusCode, { ...headers, 'Content-Encoding': 'gzip', 'Vary': 'Accept-Encoding' }, gzippedBody); + } else { + safeEnd(res, statusCode, headers, rawBody); + } +} + +// ───────────────────────────────────────────────────────────── +// Telegram OSINT ingestion (public channels) → Early Signals +// Web-first: runs on this Railway relay process, serves /telegram/feed +// Requires env: +// - TELEGRAM_API_ID +// - TELEGRAM_API_HASH +// - TELEGRAM_SESSION (StringSession) +// ───────────────────────────────────────────────────────────── +const TELEGRAM_ENABLED = Boolean(process.env.TELEGRAM_API_ID && process.env.TELEGRAM_API_HASH && process.env.TELEGRAM_SESSION); +const TELEGRAM_POLL_INTERVAL_MS = Math.max(15_000, Number(process.env.TELEGRAM_POLL_INTERVAL_MS || 60_000)); +const TELEGRAM_MAX_FEED_ITEMS = Math.max(50, Number(process.env.TELEGRAM_MAX_FEED_ITEMS || 200)); +const TELEGRAM_MAX_TEXT_CHARS = Math.max(200, Number(process.env.TELEGRAM_MAX_TEXT_CHARS || 800)); + +const telegramState = { + client: null, + channels: [], + cursorByHandle: Object.create(null), + items: [], + lastPollAt: 0, + lastError: null, + startedAt: Date.now(), +}; + +function loadTelegramChannels() { + // Product-managed curated list lives in repo root under data/ (shared by web + desktop). + // Relay is executed from scripts/, so resolve ../data. + const p = path.join(__dirname, '..', 'data', 'telegram-channels.json'); + const set = String(process.env.TELEGRAM_CHANNEL_SET || 'full').toLowerCase(); + try { + const raw = JSON.parse(readFileSync(p, 'utf8')); + const bucket = raw?.channels?.[set]; + const channels = Array.isArray(bucket) ? bucket : []; + + telegramState.channels = channels + .filter(c => c && typeof c.handle === 'string' && c.handle.length > 1) + .map(c => ({ + handle: String(c.handle).replace(/^@/, ''), + label: c.label ? String(c.label) : undefined, + topic: c.topic ? String(c.topic) : undefined, + region: c.region ? String(c.region) : undefined, + tier: c.tier != null ? Number(c.tier) : undefined, + enabled: c.enabled !== false, + maxMessages: c.maxMessages != null ? Number(c.maxMessages) : undefined, + })) + .filter(c => c.enabled); + + if (!telegramState.channels.length) { + console.warn(`[Relay] Telegram channel set "${set}" is empty — no channels to poll`); + } + + return telegramState.channels; + } catch (e) { + telegramState.channels = []; + telegramState.lastError = `failed to load telegram-channels.json: ${e?.message || String(e)}`; + return []; + } +} + +function normalizeTelegramMessage(msg, channel) { + const textRaw = String(msg?.message || ''); + const text = textRaw.slice(0, TELEGRAM_MAX_TEXT_CHARS); + const ts = msg?.date ? new Date(msg.date * 1000).toISOString() : new Date().toISOString(); + return { + id: `${channel.handle}:${msg.id}`, + source: 'telegram', + channel: channel.handle, + channelTitle: channel.label || channel.handle, + url: `https://t.me/${channel.handle}/${msg.id}`, + ts, + text, + topic: channel.topic || 'other', + tags: [channel.region].filter(Boolean), + earlySignal: true, + }; +} + +async function initTelegramClientIfNeeded() { + if (!TELEGRAM_ENABLED) return false; + if (telegramState.client) return true; + + const apiId = parseInt(String(process.env.TELEGRAM_API_ID || ''), 10); + const apiHash = String(process.env.TELEGRAM_API_HASH || ''); + const sessionStr = String(process.env.TELEGRAM_SESSION || ''); + + if (!apiId || !apiHash || !sessionStr) return false; + + try { + const { TelegramClient } = await import('telegram'); + const { StringSession } = await import('telegram/sessions'); + + const client = new TelegramClient(new StringSession(sessionStr), apiId, apiHash, { + connectionRetries: 3, + }); + + await client.connect(); + telegramState.client = client; + telegramState.lastError = null; + console.log('[Relay] Telegram client connected'); + return true; + } catch (e) { + telegramState.lastError = `telegram init failed: ${e?.message || String(e)}`; + console.warn('[Relay] Telegram init failed:', telegramState.lastError); + return false; + } +} + +async function pollTelegramOnce() { + const ok = await initTelegramClientIfNeeded(); + if (!ok) return; + + const channels = telegramState.channels.length ? telegramState.channels : loadTelegramChannels(); + if (!channels.length) return; + + const client = telegramState.client; + const newItems = []; + + for (const channel of channels) { + const handle = channel.handle; + const minId = telegramState.cursorByHandle[handle] || 0; + + try { + const entity = await client.getEntity(handle); + const msgs = await client.getMessages(entity, { + limit: Math.max(1, Math.min(50, channel.maxMessages || 25)), + minId, + }); + + for (const msg of msgs) { + if (!msg || !msg.id || !msg.message) continue; + const item = normalizeTelegramMessage(msg, channel); + newItems.push(item); + if (!telegramState.cursorByHandle[handle] || msg.id > telegramState.cursorByHandle[handle]) { + telegramState.cursorByHandle[handle] = msg.id; + } + } + + // Gentle rate limiting between channels + await new Promise(r => setTimeout(r, Math.max(300, Number(process.env.TELEGRAM_RATE_LIMIT_MS || 800)))); + } catch (e) { + const em = e?.message || String(e); + telegramState.lastError = `poll ${handle} failed: ${em}`; + console.warn('[Relay] Telegram poll error:', telegramState.lastError); + } + } + + if (newItems.length) { + const seen = new Set(); + telegramState.items = [...newItems, ...telegramState.items] + .filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }) + .sort((a, b) => (b.ts || '').localeCompare(a.ts || '')) + .slice(0, TELEGRAM_MAX_FEED_ITEMS); + } + + telegramState.lastPollAt = Date.now(); +} + +function startTelegramPollLoop() { + if (!TELEGRAM_ENABLED) return; + loadTelegramChannels(); + // Don’t block server startup. + pollTelegramOnce().catch(e => console.warn('[Relay] Telegram poll error:', e?.message || e)); + setInterval(() => { + pollTelegramOnce().catch(e => console.warn('[Relay] Telegram poll error:', e?.message || e)); + }, TELEGRAM_POLL_INTERVAL_MS).unref?.(); + console.log('[Relay] Telegram poll loop started'); +} + +function gzipSyncBuffer(body) { + try { + return zlib.gzipSync(typeof body === 'string' ? Buffer.from(body) : body); + } catch { + return null; + } +} + +function getClientIp(req) { + const xRealIp = req.headers['x-real-ip']; + if (typeof xRealIp === 'string' && xRealIp.trim()) { + return xRealIp.trim(); + } + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string' && xff) { + const parts = xff.split(',').map((part) => part.trim()).filter(Boolean); + // Proxy chain order is client,proxy1,proxy2...; use first hop as client IP. + if (parts.length > 0) return parts[0]; + } + return req.socket?.remoteAddress || 'unknown'; +} + +function safeTokenEquals(provided, expected) { + const a = Buffer.from(provided || ''); + const b = Buffer.from(expected || ''); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +function getRelaySecretFromRequest(req) { + const direct = req.headers[RELAY_AUTH_HEADER]; + if (typeof direct === 'string' && direct.trim()) return direct.trim(); + const auth = req.headers.authorization; + if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) { + const token = auth.slice(7).trim(); + if (token) return token; + } + return ''; +} + +function isAuthorizedRequest(req) { + if (!RELAY_SHARED_SECRET) return true; + const provided = getRelaySecretFromRequest(req); + if (!provided) return false; + return safeTokenEquals(provided, RELAY_SHARED_SECRET); +} + +function getRouteGroup(pathname) { + if (pathname.startsWith('/opensky')) return 'opensky'; + if (pathname.startsWith('/rss')) return 'rss'; + if (pathname.startsWith('/ais/snapshot')) return 'snapshot'; + if (pathname.startsWith('/worldbank')) return 'worldbank'; + if (pathname.startsWith('/polymarket')) return 'polymarket'; + if (pathname.startsWith('/ucdp-events')) return 'ucdp-events'; + return 'other'; +} + +function getRateLimitForPath(pathname) { + if (pathname.startsWith('/opensky')) return RELAY_OPENSKY_RATE_LIMIT_MAX; + if (pathname.startsWith('/rss')) return RELAY_RSS_RATE_LIMIT_MAX; + return RELAY_RATE_LIMIT_MAX; +} + +function consumeRateLimit(req, pathname) { + const maxRequests = getRateLimitForPath(pathname); + if (!Number.isFinite(maxRequests) || maxRequests <= 0) return { limited: false, limit: 0, remaining: 0, resetInMs: 0 }; + + const now = Date.now(); + const ip = getClientIp(req); + const key = `${getRouteGroup(pathname)}:${ip}`; + const existing = requestRateBuckets.get(key); + if (!existing || now >= existing.resetAt) { + const next = { count: 1, resetAt: now + RELAY_RATE_LIMIT_WINDOW_MS }; + requestRateBuckets.set(key, next); + return { limited: false, limit: maxRequests, remaining: Math.max(0, maxRequests - 1), resetInMs: next.resetAt - now }; + } + + existing.count += 1; + const limited = existing.count > maxRequests; + return { + limited, + limit: maxRequests, + remaining: Math.max(0, maxRequests - existing.count), + resetInMs: Math.max(0, existing.resetAt - now), + }; +} + +function logThrottled(level, key, ...args) { + const now = Date.now(); + const last = logThrottleState.get(key) || 0; + if (now - last < RELAY_LOG_THROTTLE_MS) return; + logThrottleState.set(key, now); + console[level](...args); +} + +const METRICS_WINDOW_SECONDS = Math.max(10, Number(process.env.RELAY_METRICS_WINDOW_SECONDS || 60)); +const relayMetricsBuckets = new Map(); // key: unix second -> rolling metrics bucket +const relayMetricsLifetime = { + openskyRequests: 0, + openskyCacheHit: 0, + openskyNegativeHit: 0, + openskyDedup: 0, + openskyDedupNeg: 0, + openskyDedupEmpty: 0, + openskyMiss: 0, + openskyUpstreamFetches: 0, + drops: 0, +}; +let relayMetricsQueueMaxLifetime = 0; +let relayMetricsCurrentSec = 0; +let relayMetricsCurrentBucket = null; +let relayMetricsLastPruneSec = 0; + +function createRelayMetricsBucket() { + return { + openskyRequests: 0, + openskyCacheHit: 0, + openskyNegativeHit: 0, + openskyDedup: 0, + openskyDedupNeg: 0, + openskyDedupEmpty: 0, + openskyMiss: 0, + openskyUpstreamFetches: 0, + drops: 0, + queueMax: 0, + }; +} + +function getMetricsNowSec() { + return Math.floor(Date.now() / 1000); +} + +function pruneRelayMetricsBuckets(nowSec = getMetricsNowSec()) { + const minSec = nowSec - METRICS_WINDOW_SECONDS + 1; + for (const sec of relayMetricsBuckets.keys()) { + if (sec < minSec) relayMetricsBuckets.delete(sec); + } + if (relayMetricsCurrentSec < minSec) { + relayMetricsCurrentSec = 0; + relayMetricsCurrentBucket = null; + } +} + +function getRelayMetricsBucket(nowSec = getMetricsNowSec()) { + if (nowSec !== relayMetricsLastPruneSec) { + pruneRelayMetricsBuckets(nowSec); + relayMetricsLastPruneSec = nowSec; + } + + if (relayMetricsCurrentBucket && relayMetricsCurrentSec === nowSec) { + return relayMetricsCurrentBucket; + } + + let bucket = relayMetricsBuckets.get(nowSec); + if (!bucket) { + bucket = createRelayMetricsBucket(); + relayMetricsBuckets.set(nowSec, bucket); + } + relayMetricsCurrentSec = nowSec; + relayMetricsCurrentBucket = bucket; + return bucket; +} + +function incrementRelayMetric(field, amount = 1) { + const bucket = getRelayMetricsBucket(); + bucket[field] = (bucket[field] || 0) + amount; + if (Object.prototype.hasOwnProperty.call(relayMetricsLifetime, field)) { + relayMetricsLifetime[field] += amount; + } +} + +function sampleRelayQueueSize(queueSize) { + const bucket = getRelayMetricsBucket(); + if (queueSize > bucket.queueMax) bucket.queueMax = queueSize; + if (queueSize > relayMetricsQueueMaxLifetime) relayMetricsQueueMaxLifetime = queueSize; +} + +function safeRatio(numerator, denominator) { + if (!denominator) return 0; + return Number((numerator / denominator).toFixed(4)); +} + +function getRelayRollingMetrics() { + const nowSec = getMetricsNowSec(); + const minSec = nowSec - METRICS_WINDOW_SECONDS + 1; + pruneRelayMetricsBuckets(nowSec); + + const rollup = createRelayMetricsBucket(); + for (const [sec, bucket] of relayMetricsBuckets) { + if (sec < minSec) continue; + rollup.openskyRequests += bucket.openskyRequests; + rollup.openskyCacheHit += bucket.openskyCacheHit; + rollup.openskyNegativeHit += bucket.openskyNegativeHit; + rollup.openskyDedup += bucket.openskyDedup; + rollup.openskyDedupNeg += bucket.openskyDedupNeg; + rollup.openskyDedupEmpty += bucket.openskyDedupEmpty; + rollup.openskyMiss += bucket.openskyMiss; + rollup.openskyUpstreamFetches += bucket.openskyUpstreamFetches; + rollup.drops += bucket.drops; + if (bucket.queueMax > rollup.queueMax) rollup.queueMax = bucket.queueMax; + } + + const dedupCount = rollup.openskyDedup + rollup.openskyDedupNeg + rollup.openskyDedupEmpty; + const cacheServedCount = rollup.openskyCacheHit + rollup.openskyNegativeHit + dedupCount; + + return { + windowSeconds: METRICS_WINDOW_SECONDS, + generatedAt: new Date().toISOString(), + opensky: { + requests: rollup.openskyRequests, + hitRatio: safeRatio(cacheServedCount, rollup.openskyRequests), + dedupRatio: safeRatio(dedupCount, rollup.openskyRequests), + cacheHits: rollup.openskyCacheHit, + negativeHits: rollup.openskyNegativeHit, + dedupHits: dedupCount, + misses: rollup.openskyMiss, + upstreamFetches: rollup.openskyUpstreamFetches, + global429CooldownRemainingMs: Math.max(0, openskyGlobal429Until - Date.now()), + requestSpacingMs: OPENSKY_REQUEST_SPACING_MS, + }, + ais: { + queueMax: rollup.queueMax, + currentQueue: getUpstreamQueueSize(), + drops: rollup.drops, + dropsPerSec: Number((rollup.drops / METRICS_WINDOW_SECONDS).toFixed(4)), + upstreamPaused, + }, + lifetime: { + openskyRequests: relayMetricsLifetime.openskyRequests, + openskyCacheHit: relayMetricsLifetime.openskyCacheHit, + openskyNegativeHit: relayMetricsLifetime.openskyNegativeHit, + openskyDedup: relayMetricsLifetime.openskyDedup + relayMetricsLifetime.openskyDedupNeg + relayMetricsLifetime.openskyDedupEmpty, + openskyMiss: relayMetricsLifetime.openskyMiss, + openskyUpstreamFetches: relayMetricsLifetime.openskyUpstreamFetches, + drops: relayMetricsLifetime.drops, + queueMax: relayMetricsQueueMaxLifetime, + }, + }; +} + +// AIS aggregate state for snapshot API (server-side fanout) +const GRID_SIZE = 2; +const DENSITY_WINDOW = 30 * 60 * 1000; // 30 minutes +const GAP_THRESHOLD = 60 * 60 * 1000; // 1 hour +const SNAPSHOT_INTERVAL_MS = Math.max(2000, Number(process.env.AIS_SNAPSHOT_INTERVAL_MS || 5000)); +const CANDIDATE_RETENTION_MS = 2 * 60 * 60 * 1000; // 2 hours +const MAX_DENSITY_ZONES = 200; +const MAX_CANDIDATE_REPORTS = 1500; + +const vessels = new Map(); +const vesselHistory = new Map(); +const densityGrid = new Map(); +const candidateReports = new Map(); + +let snapshotSequence = 0; +let lastSnapshot = null; +let lastSnapshotAt = 0; +// Pre-serialized cache: avoids JSON.stringify + gzip per request +let lastSnapshotJson = null; // cached JSON string (no candidates) +let lastSnapshotGzip = null; // cached gzip buffer (no candidates) +let lastSnapshotWithCandJson = null; +let lastSnapshotWithCandGzip = null; + +// Chokepoint spatial index: bucket vessels into grid cells at ingest time +// instead of O(chokepoints * vessels) on every snapshot +const chokepointBuckets = new Map(); // key: gridKey -> Set of MMSI +const vesselChokepoints = new Map(); // key: MMSI -> Set of chokepoint names + +const CHOKEPOINTS = [ + { name: 'Strait of Hormuz', lat: 26.5, lon: 56.5, radius: 2 }, + { name: 'Suez Canal', lat: 30.0, lon: 32.5, radius: 1 }, + { name: 'Strait of Malacca', lat: 2.5, lon: 101.5, radius: 2 }, + { name: 'Bab el-Mandeb', lat: 12.5, lon: 43.5, radius: 1.5 }, + { name: 'Panama Canal', lat: 9.0, lon: -79.5, radius: 1 }, + { name: 'Taiwan Strait', lat: 24.5, lon: 119.5, radius: 2 }, + { name: 'South China Sea', lat: 15.0, lon: 115.0, radius: 5 }, + { name: 'Black Sea', lat: 43.5, lon: 34.0, radius: 3 }, +]; + +const NAVAL_PREFIX_RE = /^(USS|USNS|HMS|HMAS|HMCS|INS|JS|ROKS|TCG|FS|BNS|RFS|PLAN|PLA|CGC|PNS|KRI|ITS|SNS|MMSI)/i; + +function getGridKey(lat, lon) { + const gridLat = Math.floor(lat / GRID_SIZE) * GRID_SIZE; + const gridLon = Math.floor(lon / GRID_SIZE) * GRID_SIZE; + return `${gridLat},${gridLon}`; +} + +function isLikelyMilitaryCandidate(meta) { + const mmsi = String(meta?.MMSI || ''); + const shipType = Number(meta?.ShipType); + const name = (meta?.ShipName || '').trim().toUpperCase(); + + if (Number.isFinite(shipType) && (shipType === 35 || shipType === 55 || (shipType >= 50 && shipType <= 59))) { + return true; + } + + if (name && NAVAL_PREFIX_RE.test(name)) return true; + + if (mmsi.length >= 9) { + const suffix = mmsi.substring(3); + if (suffix.startsWith('00') || suffix.startsWith('99')) return true; + } + + return false; +} + +function getUpstreamQueueSize() { + return upstreamQueue.length - upstreamQueueReadIndex; +} + +function enqueueUpstreamMessage(raw) { + upstreamQueue.push(raw); + sampleRelayQueueSize(getUpstreamQueueSize()); +} + +function dequeueUpstreamMessage() { + if (upstreamQueueReadIndex >= upstreamQueue.length) return null; + const raw = upstreamQueue[upstreamQueueReadIndex++]; + // Compact queue periodically to avoid unbounded sparse arrays. + if (upstreamQueueReadIndex >= 1024 && upstreamQueueReadIndex * 2 >= upstreamQueue.length) { + upstreamQueue = upstreamQueue.slice(upstreamQueueReadIndex); + upstreamQueueReadIndex = 0; + } + return raw; +} + +function clearUpstreamQueue() { + upstreamQueue = []; + upstreamQueueReadIndex = 0; + upstreamDrainScheduled = false; + sampleRelayQueueSize(0); +} + +function evictMapByTimestamp(map, maxSize, getTimestamp) { + if (map.size <= maxSize) return; + const sorted = [...map.entries()].sort((a, b) => { + const tsA = Number(getTimestamp(a[1])) || 0; + const tsB = Number(getTimestamp(b[1])) || 0; + return tsA - tsB; + }); + const removeCount = map.size - maxSize; + for (let i = 0; i < removeCount; i++) { + map.delete(sorted[i][0]); + } +} + +function removeVesselFromChokepoints(mmsi) { + const previous = vesselChokepoints.get(mmsi); + if (!previous) return; + + for (const cpName of previous) { + const bucket = chokepointBuckets.get(cpName); + if (!bucket) continue; + bucket.delete(mmsi); + if (bucket.size === 0) chokepointBuckets.delete(cpName); + } + + vesselChokepoints.delete(mmsi); +} + +function updateVesselChokepoints(mmsi, lat, lon) { + const next = new Set(); + for (const cp of CHOKEPOINTS) { + const dlat = lat - cp.lat; + const dlon = lon - cp.lon; + if (dlat * dlat + dlon * dlon <= cp.radius * cp.radius) { + next.add(cp.name); + } + } + + const previous = vesselChokepoints.get(mmsi) || new Set(); + for (const cpName of previous) { + if (next.has(cpName)) continue; + const bucket = chokepointBuckets.get(cpName); + if (!bucket) continue; + bucket.delete(mmsi); + if (bucket.size === 0) chokepointBuckets.delete(cpName); + } + + for (const cpName of next) { + let bucket = chokepointBuckets.get(cpName); + if (!bucket) { + bucket = new Set(); + chokepointBuckets.set(cpName, bucket); + } + bucket.add(mmsi); + } + + if (next.size === 0) vesselChokepoints.delete(mmsi); + else vesselChokepoints.set(mmsi, next); +} + +function processRawUpstreamMessage(raw) { + messageCount++; + if (messageCount % 5000 === 0) { + const mem = process.memoryUsage(); + console.log(`[Relay] ${messageCount} msgs, ${clients.size} ws-clients, ${vessels.size} vessels, queue=${getUpstreamQueueSize()}, dropped=${droppedMessages}, rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB, cache: opensky=${openskyResponseCache.size} opensky_neg=${openskyNegativeCache.size} rss_feed=${rssResponseCache.size}`); + } + + try { + const parsed = JSON.parse(raw); + if (parsed?.MessageType === 'PositionReport') { + processPositionReportForSnapshot(parsed); + } + } catch { + // Ignore malformed upstream payloads + } + + // Heavily throttled WS fanout: every 50th message only + // The app primarily uses HTTP snapshot polling, WS is for rare external consumers + if (clients.size > 0 && messageCount % 50 === 0) { + const message = raw.toString(); + for (const client of clients) { + if (client.readyState === WebSocket.OPEN) { + // Per-client backpressure: skip if client buffer is backed up + if (client.bufferedAmount < 1024 * 1024) { + client.send(message); + } + } + } + } +} + +function processPositionReportForSnapshot(data) { + const meta = data?.MetaData; + const pos = data?.Message?.PositionReport; + if (!meta || !pos) return; + + const mmsi = String(meta.MMSI || ''); + if (!mmsi) return; + + const lat = Number.isFinite(pos.Latitude) ? pos.Latitude : meta.latitude; + const lon = Number.isFinite(pos.Longitude) ? pos.Longitude : meta.longitude; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + + const now = Date.now(); + + vessels.set(mmsi, { + mmsi, + name: meta.ShipName || '', + lat, + lon, + timestamp: now, + shipType: meta.ShipType, + heading: pos.TrueHeading, + speed: pos.Sog, + course: pos.Cog, + }); + + const history = vesselHistory.get(mmsi) || []; + history.push(now); + if (history.length > 10) history.shift(); + vesselHistory.set(mmsi, history); + + const gridKey = getGridKey(lat, lon); + let cell = densityGrid.get(gridKey); + if (!cell) { + cell = { + lat: Math.floor(lat / GRID_SIZE) * GRID_SIZE + GRID_SIZE / 2, + lon: Math.floor(lon / GRID_SIZE) * GRID_SIZE + GRID_SIZE / 2, + vessels: new Set(), + lastUpdate: now, + previousCount: 0, + }; + densityGrid.set(gridKey, cell); + } + cell.vessels.add(mmsi); + cell.lastUpdate = now; + + // Maintain exact chokepoint membership so moving vessels don't get "stuck" in old buckets. + updateVesselChokepoints(mmsi, lat, lon); + + if (isLikelyMilitaryCandidate(meta)) { + candidateReports.set(mmsi, { + mmsi, + name: meta.ShipName || '', + lat, + lon, + shipType: meta.ShipType, + heading: pos.TrueHeading, + speed: pos.Sog, + course: pos.Cog, + timestamp: now, + }); + } +} + +function cleanupAggregates() { + const now = Date.now(); + const cutoff = now - DENSITY_WINDOW; + + for (const [mmsi, vessel] of vessels) { + if (vessel.timestamp < cutoff) { + vessels.delete(mmsi); + removeVesselFromChokepoints(mmsi); + } + } + // Hard cap: if still over limit, evict oldest + if (vessels.size > MAX_VESSELS) { + const sorted = [...vessels.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp); + const toRemove = sorted.slice(0, vessels.size - MAX_VESSELS); + for (const [mmsi] of toRemove) { + vessels.delete(mmsi); + removeVesselFromChokepoints(mmsi); + } + } + + for (const [mmsi, history] of vesselHistory) { + const filtered = history.filter((ts) => ts >= cutoff); + if (filtered.length === 0) { + vesselHistory.delete(mmsi); + } else { + vesselHistory.set(mmsi, filtered); + } + } + // Hard cap: keep the most recent vessel histories. + evictMapByTimestamp(vesselHistory, MAX_VESSEL_HISTORY, (history) => history[history.length - 1] || 0); + + for (const [key, cell] of densityGrid) { + cell.previousCount = cell.vessels.size; + + for (const mmsi of cell.vessels) { + const vessel = vessels.get(mmsi); + if (!vessel || vessel.timestamp < cutoff) { + cell.vessels.delete(mmsi); + } + } + + if (cell.vessels.size === 0 && now - cell.lastUpdate > DENSITY_WINDOW * 2) { + densityGrid.delete(key); + } + } + // Hard cap: keep the most recently updated cells. + evictMapByTimestamp(densityGrid, MAX_DENSITY_CELLS, (cell) => cell.lastUpdate || 0); + + for (const [mmsi, report] of candidateReports) { + if (report.timestamp < now - CANDIDATE_RETENTION_MS) { + candidateReports.delete(mmsi); + } + } + // Hard cap: keep freshest candidate reports. + evictMapByTimestamp(candidateReports, MAX_CANDIDATE_REPORTS, (report) => report.timestamp || 0); + + // Clean chokepoint buckets: remove stale vessels + for (const [cpName, bucket] of chokepointBuckets) { + for (const mmsi of bucket) { + if (vessels.has(mmsi)) continue; + bucket.delete(mmsi); + const memberships = vesselChokepoints.get(mmsi); + if (memberships) { + memberships.delete(cpName); + if (memberships.size === 0) vesselChokepoints.delete(mmsi); + } + } + if (bucket.size === 0) chokepointBuckets.delete(cpName); + } +} + +function detectDisruptions() { + const disruptions = []; + const now = Date.now(); + + // O(chokepoints) using pre-built spatial buckets instead of O(chokepoints × vessels) + for (const chokepoint of CHOKEPOINTS) { + const bucket = chokepointBuckets.get(chokepoint.name); + const vesselCount = bucket ? bucket.size : 0; + + if (vesselCount >= 5) { + const normalTraffic = chokepoint.radius * 10; + const severity = vesselCount > normalTraffic * 1.5 + ? 'high' + : vesselCount > normalTraffic + ? 'elevated' + : 'low'; + + disruptions.push({ + id: `chokepoint-${chokepoint.name.toLowerCase().replace(/\s+/g, '-')}`, + name: chokepoint.name, + type: 'chokepoint_congestion', + lat: chokepoint.lat, + lon: chokepoint.lon, + severity, + changePct: normalTraffic > 0 ? Math.round((vesselCount / normalTraffic - 1) * 100) : 0, + windowHours: 1, + vesselCount, + region: chokepoint.name, + description: `${vesselCount} vessels in ${chokepoint.name}`, + }); + } + } + + let darkShipCount = 0; + for (const history of vesselHistory.values()) { + if (history.length >= 2) { + const lastSeen = history[history.length - 1]; + const secondLast = history[history.length - 2]; + if (lastSeen - secondLast > GAP_THRESHOLD && now - lastSeen < 10 * 60 * 1000) { + darkShipCount++; + } + } + } + + if (darkShipCount >= 1) { + disruptions.push({ + id: 'global-gap-spike', + name: 'AIS Gap Spike Detected', + type: 'gap_spike', + lat: 0, + lon: 0, + severity: darkShipCount > 20 ? 'high' : darkShipCount > 10 ? 'elevated' : 'low', + changePct: darkShipCount * 10, + windowHours: 1, + darkShips: darkShipCount, + description: `${darkShipCount} vessels returned after extended AIS silence`, + }); + } + + return disruptions; +} + +function calculateDensityZones() { + const zones = []; + const allCells = Array.from(densityGrid.values()).filter((c) => c.vessels.size >= 2); + if (allCells.length === 0) return zones; + + const vesselCounts = allCells.map((c) => c.vessels.size); + const maxVessels = Math.max(...vesselCounts); + const minVessels = Math.min(...vesselCounts); + + for (const [key, cell] of densityGrid) { + if (cell.vessels.size < 2) continue; + + const logMax = Math.log(maxVessels + 1); + const logMin = Math.log(minVessels + 1); + const logCurrent = Math.log(cell.vessels.size + 1); + + const intensity = logMax > logMin + ? 0.2 + (0.8 * (logCurrent - logMin) / (logMax - logMin)) + : 0.5; + + const deltaPct = cell.previousCount > 0 + ? Math.round(((cell.vessels.size - cell.previousCount) / cell.previousCount) * 100) + : 0; + + zones.push({ + id: `density-${key}`, + name: `Zone ${key}`, + lat: cell.lat, + lon: cell.lon, + intensity, + deltaPct, + shipsPerDay: cell.vessels.size * 48, + note: cell.vessels.size >= 10 ? 'High traffic area' : undefined, + }); + } + + return zones + .sort((a, b) => b.intensity - a.intensity) + .slice(0, MAX_DENSITY_ZONES); +} + +function getCandidateReportsSnapshot() { + return Array.from(candidateReports.values()) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, MAX_CANDIDATE_REPORTS); +} + +function buildSnapshot() { + const now = Date.now(); + if (lastSnapshot && now - lastSnapshotAt < Math.floor(SNAPSHOT_INTERVAL_MS / 2)) { + return lastSnapshot; + } + + cleanupAggregates(); + snapshotSequence++; + + lastSnapshot = { + sequence: snapshotSequence, + timestamp: new Date(now).toISOString(), + status: { + connected: upstreamSocket?.readyState === WebSocket.OPEN, + vessels: vessels.size, + messages: messageCount, + clients: clients.size, + droppedMessages, + }, + disruptions: detectDisruptions(), + density: calculateDensityZones(), + }; + lastSnapshotAt = now; + + // Pre-serialize JSON once (avoid per-request JSON.stringify) + const basePayload = { ...lastSnapshot, candidateReports: [] }; + lastSnapshotJson = JSON.stringify(basePayload); + + const withCandPayload = { ...lastSnapshot, candidateReports: getCandidateReportsSnapshot() }; + lastSnapshotWithCandJson = JSON.stringify(withCandPayload); + + // Pre-gzip both variants asynchronously (zero CPU on request path) + zlib.gzip(Buffer.from(lastSnapshotJson), (err, buf) => { + if (!err) lastSnapshotGzip = buf; + }); + zlib.gzip(Buffer.from(lastSnapshotWithCandJson), (err, buf) => { + if (!err) lastSnapshotWithCandGzip = buf; + }); + + return lastSnapshot; +} + +setInterval(() => { + if (upstreamSocket?.readyState === WebSocket.OPEN || vessels.size > 0) { + buildSnapshot(); + } +}, SNAPSHOT_INTERVAL_MS); + +// UCDP GED Events cache (persistent in-memory — Railway advantage) +const UCDP_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours +const UCDP_PAGE_SIZE = 1000; +const UCDP_MAX_PAGES = 12; +const UCDP_FETCH_TIMEOUT = 30000; // 30s per page (no Railway limit) +const UCDP_TRAILING_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; + +let ucdpCache = { data: null, timestamp: 0 }; +let ucdpFetchInProgress = false; + +const UCDP_VIOLENCE_TYPE_MAP = { + 1: 'state-based', + 2: 'non-state', + 3: 'one-sided', +}; + +function ucdpParseDateMs(value) { + if (!value) return NaN; + return Date.parse(String(value)); +} + +function ucdpGetMaxDateMs(events) { + let maxMs = NaN; + for (const event of events) { + const ms = ucdpParseDateMs(event?.date_start); + if (!Number.isFinite(ms)) continue; + if (!Number.isFinite(maxMs) || ms > maxMs) maxMs = ms; + } + return maxMs; +} + +function ucdpBuildVersionCandidates() { + const year = new Date().getFullYear() - 2000; + return Array.from(new Set([`${year}.1`, `${year - 1}.1`, '25.1', '24.1'])); +} + +async function ucdpFetchPage(version, page) { + const url = `https://ucdpapi.pcr.uu.se/api/gedevents/${version}?pagesize=${UCDP_PAGE_SIZE}&page=${page}`; + + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { Accept: 'application/json' }, timeout: UCDP_FETCH_TIMEOUT }, (res) => { + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`UCDP API ${res.statusCode} (v${version} p${page})`)); + } + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (e) { reject(new Error('UCDP JSON parse error')); } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('UCDP timeout')); }); + }); +} + +async function ucdpDiscoverVersion() { + const candidates = ucdpBuildVersionCandidates(); + for (const version of candidates) { + try { + const page0 = await ucdpFetchPage(version, 0); + if (Array.isArray(page0?.Result)) return { version, page0 }; + } catch { /* next candidate */ } + } + throw new Error('No valid UCDP GED version found'); +} + +async function ucdpFetchAllEvents() { + const { version, page0 } = await ucdpDiscoverVersion(); + const totalPages = Math.max(1, Number(page0?.TotalPages) || 1); + const newestPage = totalPages - 1; + + let allEvents = []; + let latestDatasetMs = NaN; + + for (let offset = 0; offset < UCDP_MAX_PAGES && (newestPage - offset) >= 0; offset++) { + const page = newestPage - offset; + const rawData = page === 0 ? page0 : await ucdpFetchPage(version, page); + const events = Array.isArray(rawData?.Result) ? rawData.Result : []; + allEvents = allEvents.concat(events); + + const pageMaxMs = ucdpGetMaxDateMs(events); + if (!Number.isFinite(latestDatasetMs) && Number.isFinite(pageMaxMs)) { + latestDatasetMs = pageMaxMs; + } + if (Number.isFinite(latestDatasetMs) && Number.isFinite(pageMaxMs)) { + if (pageMaxMs < latestDatasetMs - UCDP_TRAILING_WINDOW_MS) break; + } + console.log(`[UCDP] Fetched v${version} page ${page} (${events.length} events)`); + } + + const sanitized = allEvents + .filter(e => { + if (!Number.isFinite(latestDatasetMs)) return true; + const ms = ucdpParseDateMs(e?.date_start); + return Number.isFinite(ms) && ms >= (latestDatasetMs - UCDP_TRAILING_WINDOW_MS); + }) + .map(e => ({ + id: String(e.id || ''), + date_start: e.date_start || '', + date_end: e.date_end || '', + latitude: Number(e.latitude) || 0, + longitude: Number(e.longitude) || 0, + country: e.country || '', + side_a: (e.side_a || '').substring(0, 200), + side_b: (e.side_b || '').substring(0, 200), + deaths_best: Number(e.best) || 0, + deaths_low: Number(e.low) || 0, + deaths_high: Number(e.high) || 0, + type_of_violence: UCDP_VIOLENCE_TYPE_MAP[e.type_of_violence] || 'state-based', + source_original: (e.source_original || '').substring(0, 300), + })) + .sort((a, b) => { + const bMs = ucdpParseDateMs(b.date_start); + const aMs = ucdpParseDateMs(a.date_start); + return (Number.isFinite(bMs) ? bMs : 0) - (Number.isFinite(aMs) ? aMs : 0); + }); + + return { + success: true, + count: sanitized.length, + data: sanitized, + version, + cached_at: new Date().toISOString(), + }; +} + +async function handleUcdpEventsRequest(req, res) { + const now = Date.now(); + + if (ucdpCache.data && now - ucdpCache.timestamp < UCDP_CACHE_TTL_MS) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + 'X-Cache': 'HIT', + }, JSON.stringify(ucdpCache.data)); + } + + if (ucdpCache.data && !ucdpFetchInProgress) { + ucdpFetchInProgress = true; + ucdpFetchAllEvents() + .then(result => { + ucdpCache = { data: result, timestamp: Date.now() }; + console.log(`[UCDP] Background refresh: ${result.count} events (v${result.version})`); + }) + .catch(err => console.error('[UCDP] Background refresh error:', err.message)) + .finally(() => { ucdpFetchInProgress = false; }); + + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=600', + 'X-Cache': 'STALE', + }, JSON.stringify(ucdpCache.data)); + } + + if (ucdpFetchInProgress) { + res.writeHead(202, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ success: false, count: 0, data: [], cached_at: '', message: 'Fetch in progress' })); + } + + try { + ucdpFetchInProgress = true; + console.log('[UCDP] Cold fetch starting...'); + const result = await ucdpFetchAllEvents(); + ucdpCache = { data: result, timestamp: Date.now() }; + ucdpFetchInProgress = false; + console.log(`[UCDP] Cold fetch complete: ${result.count} events (v${result.version})`); + + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + 'X-Cache': 'MISS', + }, JSON.stringify(result)); + } catch (err) { + ucdpFetchInProgress = false; + console.error('[UCDP] Fetch error:', err.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: err.message, count: 0, data: [] })); + } +} + +// ── Response caches (eliminates ~1.2TB/day OpenSky + ~30GB/day RSS egress) ── +const openskyResponseCache = new Map(); // key: sorted query params → { data, gzip, timestamp } +const openskyNegativeCache = new Map(); // key: cacheKey → { status, timestamp, body, gzip } — prevents retry storms on 429/5xx +const openskyInFlight = new Map(); // key: cacheKey → Promise (dedup concurrent requests) +const OPENSKY_CACHE_TTL_MS = Number(process.env.OPENSKY_CACHE_TTL_MS) || 60 * 1000; // 60s default — env-configurable +const OPENSKY_NEGATIVE_CACHE_TTL_MS = Number(process.env.OPENSKY_NEGATIVE_CACHE_TTL_MS) || 30 * 1000; // 30s — env-configurable +const OPENSKY_CACHE_MAX_ENTRIES = Math.max(10, Number(process.env.OPENSKY_CACHE_MAX_ENTRIES || 128)); +const OPENSKY_NEGATIVE_CACHE_MAX_ENTRIES = Math.max(10, Number(process.env.OPENSKY_NEGATIVE_CACHE_MAX_ENTRIES || 256)); +const OPENSKY_BBOX_QUANT_STEP = Number.isFinite(Number(process.env.OPENSKY_BBOX_QUANT_STEP)) + ? Math.max(0, Number(process.env.OPENSKY_BBOX_QUANT_STEP)) : 0.01; +const OPENSKY_BBOX_DECIMALS = OPENSKY_BBOX_QUANT_STEP > 0 + ? Math.min(6, ((String(OPENSKY_BBOX_QUANT_STEP).split('.')[1] || '').length || 0)) + : 6; +const OPENSKY_DEDUP_EMPTY_RESPONSE_JSON = JSON.stringify({ states: [], time: 0 }); +const OPENSKY_DEDUP_EMPTY_RESPONSE_GZIP = gzipSyncBuffer(OPENSKY_DEDUP_EMPTY_RESPONSE_JSON); +const rssResponseCache = new Map(); // key: feed URL → { data, contentType, timestamp, statusCode } +const rssInFlight = new Map(); // key: feed URL → Promise (dedup concurrent requests) +const RSS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — RSS feeds rarely update faster +const RSS_NEGATIVE_CACHE_TTL_MS = 60 * 1000; // 1 min — cache failures to prevent thundering herd +const RSS_CACHE_MAX_ENTRIES = 200; // hard cap — ~20 allowed domains × ~5 paths max, with headroom + +function setBoundedCacheEntry(cache, key, value, maxEntries) { + if (!cache.has(key) && cache.size >= maxEntries) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, value); +} + +function touchCacheEntry(cache, key, entry) { + cache.delete(key); + cache.set(key, entry); +} + +function cacheOpenSkyPositive(cacheKey, data) { + setBoundedCacheEntry(openskyResponseCache, cacheKey, { + data, + gzip: gzipSyncBuffer(data), + timestamp: Date.now(), + }, OPENSKY_CACHE_MAX_ENTRIES); +} + +function cacheOpenSkyNegative(cacheKey, status) { + const now = Date.now(); + const body = JSON.stringify({ states: [], time: now }); + setBoundedCacheEntry(openskyNegativeCache, cacheKey, { + status, + timestamp: now, + body, + gzip: gzipSyncBuffer(body), + }, OPENSKY_NEGATIVE_CACHE_MAX_ENTRIES); +} + +function quantizeCoordinate(value) { + if (!OPENSKY_BBOX_QUANT_STEP) return value; + return Math.round(value / OPENSKY_BBOX_QUANT_STEP) * OPENSKY_BBOX_QUANT_STEP; +} + +function formatCoordinate(value) { + return Number(value.toFixed(OPENSKY_BBOX_DECIMALS)).toString(); +} + +function normalizeOpenSkyBbox(params) { + const keys = ['lamin', 'lomin', 'lamax', 'lomax']; + const hasAny = keys.some(k => params.has(k)); + if (!hasAny) { + return { cacheKey: ',,,', queryParams: [] }; + } + if (!keys.every(k => params.has(k))) { + return { error: 'Provide all bbox params: lamin,lomin,lamax,lomax' }; + } + + const values = {}; + for (const key of keys) { + const raw = params.get(key); + if (raw === null || raw.trim() === '') return { error: `Invalid ${key} value` }; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return { error: `Invalid ${key} value` }; + values[key] = parsed; + } + + if (values.lamin < -90 || values.lamax > 90 || values.lomin < -180 || values.lomax > 180) { + return { error: 'Bbox out of range' }; + } + if (values.lamin > values.lamax || values.lomin > values.lomax) { + return { error: 'Invalid bbox ordering' }; + } + + const normalized = {}; + for (const key of keys) normalized[key] = formatCoordinate(quantizeCoordinate(values[key])); + return { + cacheKey: keys.map(k => normalized[k]).join(','), + queryParams: keys.map(k => `${k}=${encodeURIComponent(normalized[k])}`), + }; +} + +// OpenSky OAuth2 token cache + mutex to prevent thundering herd +let openskyToken = null; +let openskyTokenExpiry = 0; +let openskyTokenPromise = null; // mutex: single in-flight token request +let openskyAuthCooldownUntil = 0; // backoff after repeated failures +const OPENSKY_AUTH_COOLDOWN_MS = 60000; // 1 min cooldown after auth failure + +// Global OpenSky rate limiter — serializes upstream requests and enforces 429 cooldown +let openskyGlobal429Until = 0; // timestamp: block ALL upstream requests until this time +const OPENSKY_429_COOLDOWN_MS = Number(process.env.OPENSKY_429_COOLDOWN_MS) || 90 * 1000; // 90s cooldown after any 429 +const OPENSKY_REQUEST_SPACING_MS = Number(process.env.OPENSKY_REQUEST_SPACING_MS) || 2000; // 2s minimum between consecutive upstream requests +let openskyLastUpstreamTime = 0; +let openskyUpstreamQueue = Promise.resolve(); // serial chain — only 1 upstream request at a time + +async function getOpenSkyToken() { + const clientId = process.env.OPENSKY_CLIENT_ID; + const clientSecret = process.env.OPENSKY_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return null; + } + + // Return cached token if still valid (with 60s buffer) + if (openskyToken && Date.now() < openskyTokenExpiry - 60000) { + return openskyToken; + } + + // Cooldown: don't retry auth if it recently failed (prevents stampede) + if (Date.now() < openskyAuthCooldownUntil) { + return null; + } + + // Mutex: if a token fetch is already in flight, wait for it + if (openskyTokenPromise) { + return openskyTokenPromise; + } + + openskyTokenPromise = _fetchOpenSkyToken(clientId, clientSecret); + try { + return await openskyTokenPromise; + } finally { + openskyTokenPromise = null; + } +} + +function _attemptOpenSkyTokenFetch(clientId, clientSecret) { + return new Promise((resolve) => { + const postData = `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`; + + const req = https.request({ + hostname: 'auth.opensky-network.org', + port: 443, + family: 4, + path: '/auth/realms/opensky-network/protocol/openid-connect/token', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(postData), + 'User-Agent': 'WorldMonitor/1.0', + }, + timeout: 10000 + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const json = JSON.parse(data); + if (json.access_token) { + resolve({ token: json.access_token, expiresIn: json.expires_in || 1800 }); + } else { + resolve({ error: json.error || 'no_access_token', status: res.statusCode }); + } + } catch (e) { + resolve({ error: `parse: ${e.message}`, status: res.statusCode }); + } + }); + }); + + req.on('error', (err) => { + resolve({ error: `${err.code || 'UNKNOWN'}: ${err.message}` }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ error: 'TIMEOUT' }); + }); + + req.write(postData); + req.end(); + }); +} + +const OPENSKY_AUTH_MAX_RETRIES = 3; +const OPENSKY_AUTH_RETRY_DELAYS = [0, 2000, 5000]; + +async function _fetchOpenSkyToken(clientId, clientSecret) { + try { + for (let attempt = 0; attempt < OPENSKY_AUTH_MAX_RETRIES; attempt++) { + if (attempt > 0) { + const delay = OPENSKY_AUTH_RETRY_DELAYS[attempt] || 5000; + console.log(`[Relay] OpenSky auth retry ${attempt + 1}/${OPENSKY_AUTH_MAX_RETRIES} in ${delay}ms...`); + await new Promise(r => setTimeout(r, delay)); + } else { + console.log('[Relay] Fetching new OpenSky OAuth2 token...'); + } + + const result = await _attemptOpenSkyTokenFetch(clientId, clientSecret); + if (result.token) { + openskyToken = result.token; + openskyTokenExpiry = Date.now() + result.expiresIn * 1000; + console.log('[Relay] OpenSky token acquired, expires in', result.expiresIn, 'seconds'); + return openskyToken; + } + console.error(`[Relay] OpenSky auth attempt ${attempt + 1} failed:`, result.error, result.status ? `(HTTP ${result.status})` : ''); + } + + openskyAuthCooldownUntil = Date.now() + OPENSKY_AUTH_COOLDOWN_MS; + console.warn(`[Relay] OpenSky auth failed after ${OPENSKY_AUTH_MAX_RETRIES} attempts, cooling down for ${OPENSKY_AUTH_COOLDOWN_MS / 1000}s`); + return null; + } catch (err) { + console.error('[Relay] OpenSky token error:', err.message); + openskyAuthCooldownUntil = Date.now() + OPENSKY_AUTH_COOLDOWN_MS; + return null; + } +} + +// Promisified upstream OpenSky fetch (single request) +function _openskyRawFetch(url, token) { + return new Promise((resolve) => { + const request = https.get(url, { + family: 4, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'WorldMonitor/1.0', + 'Authorization': `Bearer ${token}`, + }, + timeout: 15000, + }, (response) => { + let data = ''; + response.on('data', chunk => data += chunk); + response.on('end', () => resolve({ status: response.statusCode || 502, data })); + }); + request.on('error', (err) => resolve({ status: 0, data: null, error: err })); + request.on('timeout', () => { request.destroy(); resolve({ status: 504, data: null, error: new Error('timeout') }); }); + }); +} + +// Serialized queue — ensures only 1 upstream request at a time with minimum spacing. +// Prevents 5 concurrent bbox queries from all getting 429'd. +function openskyQueuedFetch(url, token) { + const job = openskyUpstreamQueue.then(async () => { + if (Date.now() < openskyGlobal429Until) { + return { status: 429, data: JSON.stringify({ states: [], time: Date.now() }), rateLimited: true }; + } + const wait = OPENSKY_REQUEST_SPACING_MS - (Date.now() - openskyLastUpstreamTime); + if (wait > 0) await new Promise(r => setTimeout(r, wait)); + if (Date.now() < openskyGlobal429Until) { + return { status: 429, data: JSON.stringify({ states: [], time: Date.now() }), rateLimited: true }; + } + openskyLastUpstreamTime = Date.now(); + return _openskyRawFetch(url, token); + }); + openskyUpstreamQueue = job.catch(() => {}); + return job; +} + +async function handleOpenSkyRequest(req, res, PORT) { + let cacheKey = ''; + let settleFlight = null; + try { + const url = new URL(req.url, `http://localhost:${PORT}`); + const params = url.searchParams; + const normalizedBbox = normalizeOpenSkyBbox(params); + if (normalizedBbox.error) { + return safeEnd(res, 400, { 'Content-Type': 'application/json' }, JSON.stringify({ + error: normalizedBbox.error, + time: Date.now(), + states: [], + })); + } + + cacheKey = normalizedBbox.cacheKey; + incrementRelayMetric('openskyRequests'); + + // 1. Check positive cache (30s TTL) + const cached = openskyResponseCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < OPENSKY_CACHE_TTL_MS) { + incrementRelayMetric('openskyCacheHit'); + touchCacheEntry(openskyResponseCache, cacheKey, cached); // LRU + return sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=30', + 'X-Cache': 'HIT', + }, cached.data, cached.gzip); + } + + // 2. Check negative cache — prevents retry storms when upstream returns 429/5xx + const negCached = openskyNegativeCache.get(cacheKey); + if (negCached && Date.now() - negCached.timestamp < OPENSKY_NEGATIVE_CACHE_TTL_MS) { + incrementRelayMetric('openskyNegativeHit'); + touchCacheEntry(openskyNegativeCache, cacheKey, negCached); // LRU + return sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Cache': 'NEG', + }, negCached.body, negCached.gzip); + } + + // 2b. Global 429 cooldown — blocks ALL bbox queries when OpenSky is rate-limiting. + // Without this, 5 unique bbox keys all fire simultaneously when neg cache expires, + // ALL get 429'd, and the cycle repeats forever with zero data flowing. + if (Date.now() < openskyGlobal429Until) { + incrementRelayMetric('openskyNegativeHit'); + cacheOpenSkyNegative(cacheKey, 429); + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Cache': 'RATE-LIMITED', + }, JSON.stringify({ states: [], time: Date.now() })); + } + + // 3. Dedup concurrent requests — await in-flight and return result OR empty (never fall through) + const existing = openskyInFlight.get(cacheKey); + if (existing) { + try { + await existing; + } catch { /* in-flight failed */ } + const deduped = openskyResponseCache.get(cacheKey); + if (deduped && Date.now() - deduped.timestamp < OPENSKY_CACHE_TTL_MS) { + incrementRelayMetric('openskyDedup'); + touchCacheEntry(openskyResponseCache, cacheKey, deduped); // LRU + return sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=30', + 'X-Cache': 'DEDUP', + }, deduped.data, deduped.gzip); + } + const dedupNeg = openskyNegativeCache.get(cacheKey); + if (dedupNeg && Date.now() - dedupNeg.timestamp < OPENSKY_NEGATIVE_CACHE_TTL_MS) { + incrementRelayMetric('openskyDedupNeg'); + touchCacheEntry(openskyNegativeCache, cacheKey, dedupNeg); // LRU + return sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Cache': 'DEDUP-NEG', + }, dedupNeg.body, dedupNeg.gzip); + } + // In-flight completed but no cache entry (upstream failed) — return empty instead of thundering herd + incrementRelayMetric('openskyDedupEmpty'); + return sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-Cache': 'DEDUP-EMPTY', + }, OPENSKY_DEDUP_EMPTY_RESPONSE_JSON, OPENSKY_DEDUP_EMPTY_RESPONSE_GZIP); + } + + incrementRelayMetric('openskyMiss'); + + // 4. Set in-flight BEFORE async token fetch to prevent race window + let resolveFlight; + let flightSettled = false; + const flightPromise = new Promise((resolve) => { resolveFlight = resolve; }); + settleFlight = () => { + if (flightSettled) return; + flightSettled = true; + resolveFlight(); + }; + openskyInFlight.set(cacheKey, flightPromise); + + const token = await getOpenSkyToken(); + if (!token) { + // Do NOT negative-cache auth failures — they poison ALL bbox keys. + // Only negative-cache actual upstream 429/5xx responses. + settleFlight(); + openskyInFlight.delete(cacheKey); + return safeEnd(res, 503, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'OpenSky not configured or auth failed', time: Date.now(), states: [] })); + } + + let openskyUrl = 'https://opensky-network.org/api/states/all'; + if (normalizedBbox.queryParams.length > 0) { + openskyUrl += '?' + normalizedBbox.queryParams.join('&'); + } + + logThrottled('log', `opensky-miss:${cacheKey}`, '[Relay] OpenSky request (MISS):', openskyUrl); + incrementRelayMetric('openskyUpstreamFetches'); + + // Serialized fetch — queued with spacing to prevent concurrent 429 storms + const result = await openskyQueuedFetch(openskyUrl, token); + const upstreamStatus = result.status || 502; + + if (upstreamStatus === 401) { + openskyToken = null; + openskyTokenExpiry = 0; + } + + if (upstreamStatus === 429 && !result.rateLimited) { + openskyGlobal429Until = Date.now() + OPENSKY_429_COOLDOWN_MS; + console.warn(`[Relay] OpenSky 429 — global cooldown ${OPENSKY_429_COOLDOWN_MS / 1000}s (all bbox queries blocked)`); + } + + if (upstreamStatus === 200 && result.data) { + cacheOpenSkyPositive(cacheKey, result.data); + openskyNegativeCache.delete(cacheKey); + } else if (result.error) { + logThrottled('error', `opensky-error:${cacheKey}:${result.error.code || result.error.message}`, '[Relay] OpenSky error:', result.error.message); + cacheOpenSkyNegative(cacheKey, upstreamStatus || 500); + } else { + cacheOpenSkyNegative(cacheKey, upstreamStatus); + logThrottled('warn', `opensky-upstream-${upstreamStatus}:${cacheKey}`, + `[Relay] OpenSky upstream ${upstreamStatus} for ${openskyUrl}, negative-cached for ${OPENSKY_NEGATIVE_CACHE_TTL_MS / 1000}s`); + } + + settleFlight(); + openskyInFlight.delete(cacheKey); + + // Serve stale cache on network errors + if (result.error && cached) { + return sendPreGzipped(req, res, 200, { 'Content-Type': 'application/json', 'X-Cache': 'STALE' }, cached.data, cached.gzip); + } + + const responseData = result.data || JSON.stringify({ error: result.error?.message || 'upstream error', time: Date.now(), states: null }); + return sendCompressed(req, res, upstreamStatus, { + 'Content-Type': 'application/json', + 'Cache-Control': upstreamStatus === 200 ? 'public, max-age=30' : 'no-cache', + 'X-Cache': result.rateLimited ? 'RATE-LIMITED' : 'MISS', + }, responseData); + } catch (err) { + if (settleFlight) settleFlight(); + if (!cacheKey) { + try { + const params = new URL(req.url, `http://localhost:${PORT}`).searchParams; + cacheKey = normalizeOpenSkyBbox(params).cacheKey || ',,,'; + } catch { + cacheKey = ',,,'; + } + } + openskyInFlight.delete(cacheKey); + safeEnd(res, 500, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: err.message, time: Date.now(), states: null })); + } +} + +// ── World Bank proxy (World Bank blocks Vercel edge IPs with 403) ── +const worldbankCache = new Map(); // key: query string → { data, timestamp } +const WORLDBANK_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min — data rarely changes + +function handleWorldBankRequest(req, res) { + const url = new URL(req.url, `http://localhost:${PORT}`); + const qs = url.search || ''; + const cacheKey = qs; + + const cached = worldbankCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < WORLDBANK_CACHE_TTL_MS) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=1800', + 'X-Cache': 'HIT', + }, cached.data); + } + + const targetUrl = `https://api.worldbank.org/v2${qs.includes('action=indicators') ? '' : '/country'}${url.pathname.replace('/worldbank', '')}${qs}`; + // Passthrough: forward query params to the Vercel edge handler format + // The client sends the same params as /api/worldbank, so we re-fetch from upstream + const wbParams = new URLSearchParams(url.searchParams); + const action = wbParams.get('action'); + + if (action === 'indicators') { + // Static response — return indicator list directly (same as api/worldbank.js) + const indicators = { + 'IT.NET.USER.ZS': 'Internet Users (% of population)', + 'IT.CEL.SETS.P2': 'Mobile Subscriptions (per 100 people)', + 'IT.NET.BBND.P2': 'Fixed Broadband Subscriptions (per 100 people)', + 'IT.NET.SECR.P6': 'Secure Internet Servers (per million people)', + 'GB.XPD.RSDV.GD.ZS': 'R&D Expenditure (% of GDP)', + 'IP.PAT.RESD': 'Patent Applications (residents)', + 'IP.PAT.NRES': 'Patent Applications (non-residents)', + 'IP.TMK.TOTL': 'Trademark Applications', + 'TX.VAL.TECH.MF.ZS': 'High-Tech Exports (% of manufactured exports)', + 'BX.GSR.CCIS.ZS': 'ICT Service Exports (% of service exports)', + 'TM.VAL.ICTG.ZS.UN': 'ICT Goods Imports (% of total goods imports)', + 'SE.TER.ENRR': 'Tertiary Education Enrollment (%)', + 'SE.XPD.TOTL.GD.ZS': 'Education Expenditure (% of GDP)', + 'NY.GDP.MKTP.KD.ZG': 'GDP Growth (annual %)', + 'NY.GDP.PCAP.CD': 'GDP per Capita (current US$)', + 'NE.EXP.GNFS.ZS': 'Exports of Goods & Services (% of GDP)', + }; + const defaultCountries = [ + 'USA','CHN','JPN','DEU','KOR','GBR','IND','ISR','SGP','TWN', + 'FRA','CAN','SWE','NLD','CHE','FIN','IRL','AUS','BRA','IDN', + 'ARE','SAU','QAT','BHR','EGY','TUR','MYS','THA','VNM','PHL', + 'ESP','ITA','POL','CZE','DNK','NOR','AUT','BEL','PRT','EST', + 'MEX','ARG','CHL','COL','ZAF','NGA','KEN', + ]; + const body = JSON.stringify({ indicators, defaultCountries }); + worldbankCache.set(cacheKey, { data: body, timestamp: Date.now() }); + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=86400', + 'X-Cache': 'MISS', + }, body); + } + + const indicator = wbParams.get('indicator'); + if (!indicator) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'Missing indicator parameter' })); + } + + const country = wbParams.get('country'); + const countries = wbParams.get('countries'); + const years = parseInt(wbParams.get('years') || '5', 10); + let countryList = country || (countries ? countries.split(',').join(';') : [ + 'USA','CHN','JPN','DEU','KOR','GBR','IND','ISR','SGP','TWN', + 'FRA','CAN','SWE','NLD','CHE','FIN','IRL','AUS','BRA','IDN', + 'ARE','SAU','QAT','BHR','EGY','TUR','MYS','THA','VNM','PHL', + 'ESP','ITA','POL','CZE','DNK','NOR','AUT','BEL','PRT','EST', + 'MEX','ARG','CHL','COL','ZAF','NGA','KEN', + ].join(';')); + + const currentYear = new Date().getFullYear(); + const startYear = currentYear - years; + const TECH_INDICATORS = { + 'IT.NET.USER.ZS': 'Internet Users (% of population)', + 'IT.CEL.SETS.P2': 'Mobile Subscriptions (per 100 people)', + 'IT.NET.BBND.P2': 'Fixed Broadband Subscriptions (per 100 people)', + 'IT.NET.SECR.P6': 'Secure Internet Servers (per million people)', + 'GB.XPD.RSDV.GD.ZS': 'R&D Expenditure (% of GDP)', + 'IP.PAT.RESD': 'Patent Applications (residents)', + 'IP.PAT.NRES': 'Patent Applications (non-residents)', + 'IP.TMK.TOTL': 'Trademark Applications', + 'TX.VAL.TECH.MF.ZS': 'High-Tech Exports (% of manufactured exports)', + 'BX.GSR.CCIS.ZS': 'ICT Service Exports (% of service exports)', + 'TM.VAL.ICTG.ZS.UN': 'ICT Goods Imports (% of total goods imports)', + 'SE.TER.ENRR': 'Tertiary Education Enrollment (%)', + 'SE.XPD.TOTL.GD.ZS': 'Education Expenditure (% of GDP)', + 'NY.GDP.MKTP.KD.ZG': 'GDP Growth (annual %)', + 'NY.GDP.PCAP.CD': 'GDP per Capita (current US$)', + 'NE.EXP.GNFS.ZS': 'Exports of Goods & Services (% of GDP)', + }; + + const wbUrl = `https://api.worldbank.org/v2/country/${countryList}/indicator/${encodeURIComponent(indicator)}?format=json&date=${startYear}:${currentYear}&per_page=1000`; + + console.log('[Relay] World Bank request (MISS):', indicator); + + const request = https.get(wbUrl, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0 (compatible; WorldMonitor/1.0; +https://worldmonitor.app)', + }, + timeout: 15000, + }, (response) => { + if (response.statusCode !== 200) { + res.writeHead(response.statusCode, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: `World Bank API ${response.statusCode}` })); + } + let rawData = ''; + response.on('data', chunk => rawData += chunk); + response.on('end', () => { + try { + const parsed = JSON.parse(rawData); + // Transform raw World Bank response to match client-expected format + if (!parsed || !Array.isArray(parsed) || parsed.length < 2 || !parsed[1]) { + const empty = JSON.stringify({ + indicator, + indicatorName: TECH_INDICATORS[indicator] || indicator, + metadata: { page: 1, pages: 1, total: 0 }, + byCountry: {}, latestByCountry: {}, timeSeries: [], + }); + worldbankCache.set(cacheKey, { data: empty, timestamp: Date.now() }); + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=1800', + 'X-Cache': 'MISS', + }, empty); + } + + const [metadata, records] = parsed; + const transformed = { + indicator, + indicatorName: TECH_INDICATORS[indicator] || (records[0]?.indicator?.value || indicator), + metadata: { page: metadata.page, pages: metadata.pages, total: metadata.total }, + byCountry: {}, latestByCountry: {}, timeSeries: [], + }; + + for (const record of records || []) { + const cc = record.countryiso3code || record.country?.id; + const cn = record.country?.value; + const yr = record.date; + const val = record.value; + if (!cc || val === null) continue; + if (!transformed.byCountry[cc]) transformed.byCountry[cc] = { code: cc, name: cn, values: [] }; + transformed.byCountry[cc].values.push({ year: yr, value: val }); + if (!transformed.latestByCountry[cc] || yr > transformed.latestByCountry[cc].year) { + transformed.latestByCountry[cc] = { code: cc, name: cn, year: yr, value: val }; + } + transformed.timeSeries.push({ countryCode: cc, countryName: cn, year: yr, value: val }); + } + for (const c of Object.values(transformed.byCountry)) c.values.sort((a, b) => a.year - b.year); + transformed.timeSeries.sort((a, b) => b.year - a.year || a.countryCode.localeCompare(b.countryCode)); + + const body = JSON.stringify(transformed); + worldbankCache.set(cacheKey, { data: body, timestamp: Date.now() }); + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=1800', + 'X-Cache': 'MISS', + }, body); + } catch (e) { + console.error('[Relay] World Bank parse error:', e.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Parse error' })); + } + }); + }); + request.on('error', (err) => { + console.error('[Relay] World Bank error:', err.message); + if (cached) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'X-Cache': 'STALE', + }, cached.data); + } + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + }); + request.on('timeout', () => { + request.destroy(); + if (cached) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'X-Cache': 'STALE', + }, cached.data); + } + res.writeHead(504, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'World Bank request timeout' })); + }); +} + +// ── Polymarket proxy (Cloudflare JA3 blocks Vercel edge runtime) ── +const polymarketCache = new Map(); // key: query string → { data, timestamp } +const POLYMARKET_CACHE_TTL_MS = 2 * 60 * 1000; // 2 min — market data changes frequently + +function handlePolymarketRequest(req, res) { + const url = new URL(req.url, `http://localhost:${PORT}`); + const cacheKey = url.search || ''; + + const cached = polymarketCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < POLYMARKET_CACHE_TTL_MS) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=120', + 'X-Cache': 'HIT', + 'X-Polymarket-Source': 'railway-cache', + }, cached.data); + } + + const endpoint = url.searchParams.get('endpoint') || 'markets'; + const params = new URLSearchParams(); + params.set('closed', url.searchParams.get('closed') || 'false'); + params.set('order', url.searchParams.get('order') || 'volume'); + params.set('ascending', url.searchParams.get('ascending') || 'false'); + const limit = Math.max(1, Math.min(100, parseInt(url.searchParams.get('limit') || '50', 10) || 50)); + params.set('limit', String(limit)); + const tag = url.searchParams.get('tag') || url.searchParams.get('tag_slug'); + if (tag && endpoint === 'events') params.set('tag_slug', tag.replace(/[^a-z0-9-]/gi, '').slice(0, 100)); + + const gammaUrl = `https://gamma-api.polymarket.com/${endpoint}?${params}`; + console.log('[Relay] Polymarket request (MISS):', endpoint, tag || ''); + + const request = https.get(gammaUrl, { + headers: { 'Accept': 'application/json' }, + timeout: 10000, + }, (response) => { + if (response.statusCode !== 200) { + console.error(`[Relay] Polymarket upstream ${response.statusCode}`); + res.writeHead(response.statusCode, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify([])); + } + let data = ''; + response.on('data', chunk => data += chunk); + response.on('end', () => { + polymarketCache.set(cacheKey, { data, timestamp: Date.now() }); + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=120', + 'X-Cache': 'MISS', + 'X-Polymarket-Source': 'railway', + }, data); + }); + }); + request.on('error', (err) => { + console.error('[Relay] Polymarket error:', err.message); + if (cached) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'X-Cache': 'STALE', + 'X-Polymarket-Source': 'railway-stale', + }, cached.data); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify([])); + }); + request.on('timeout', () => { + request.destroy(); + if (cached) { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'X-Cache': 'STALE', + 'X-Polymarket-Source': 'railway-stale', + }, cached.data); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify([])); + }); +} + +// Periodic cache cleanup to prevent memory leaks +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of openskyResponseCache) { + if (now - entry.timestamp > OPENSKY_CACHE_TTL_MS * 2) openskyResponseCache.delete(key); + } + for (const [key, entry] of openskyNegativeCache) { + if (now - entry.timestamp > OPENSKY_NEGATIVE_CACHE_TTL_MS * 2) openskyNegativeCache.delete(key); + } + for (const [key, entry] of rssResponseCache) { + const maxAge = (entry.statusCode && entry.statusCode >= 200 && entry.statusCode < 300) + ? RSS_CACHE_TTL_MS * 2 : RSS_NEGATIVE_CACHE_TTL_MS * 2; + if (now - entry.timestamp > maxAge) rssResponseCache.delete(key); + } + for (const [key, entry] of worldbankCache) { + if (now - entry.timestamp > WORLDBANK_CACHE_TTL_MS * 2) worldbankCache.delete(key); + } + for (const [key, entry] of polymarketCache) { + if (now - entry.timestamp > POLYMARKET_CACHE_TTL_MS * 2) polymarketCache.delete(key); + } + for (const [key, bucket] of requestRateBuckets) { + if (now >= bucket.resetAt + RELAY_RATE_LIMIT_WINDOW_MS * 2) requestRateBuckets.delete(key); + } + for (const [key, ts] of logThrottleState) { + if (now - ts > RELAY_LOG_THROTTLE_MS * 6) logThrottleState.delete(key); + } +}, 60 * 1000); + +// CORS origin allowlist — only our domains can use this relay +const ALLOWED_ORIGINS = [ + 'https://worldmonitor.app', + 'https://tech.worldmonitor.app', + 'https://finance.worldmonitor.app', + 'http://localhost:5173', // Vite dev + 'http://localhost:5174', // Vite dev alt port + 'http://localhost:4173', // Vite preview + 'https://localhost', // Tauri desktop + 'tauri://localhost', // Tauri iOS/macOS +]; + +function getCorsOrigin(req) { + const origin = req.headers.origin || ''; + if (ALLOWED_ORIGINS.includes(origin)) return origin; + // Optional: allow Vercel preview deployments when explicitly enabled. + if (ALLOW_VERCEL_PREVIEW_ORIGINS && origin.endsWith('.vercel.app')) return origin; + return ''; +} + +const server = http.createServer(async (req, res) => { + const pathname = (req.url || '/').split('?')[0]; + const corsOrigin = getCorsOrigin(req); + if (corsOrigin) { + res.setHeader('Access-Control-Allow-Origin', corsOrigin); + res.setHeader('Vary', 'Origin'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', `Content-Type, Authorization, ${RELAY_AUTH_HEADER}`); + + // Handle CORS preflight + if (req.method === 'OPTIONS') { + res.writeHead(corsOrigin ? 204 : 403); + return res.end(); + } + + const isPublicRoute = pathname === '/health' || pathname === '/'; + if (!isPublicRoute) { + if (!isAuthorizedRequest(req)) { + return safeEnd(res, 401, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'Unauthorized', time: Date.now() })); + } + const rl = consumeRateLimit(req, pathname); + if (rl.limited) { + const retryAfterSec = Math.max(1, Math.ceil(rl.resetInMs / 1000)); + return safeEnd(res, 429, { + 'Content-Type': 'application/json', + 'Retry-After': String(retryAfterSec), + 'X-RateLimit-Limit': String(rl.limit), + 'X-RateLimit-Remaining': String(rl.remaining), + 'X-RateLimit-Reset': String(retryAfterSec), + }, JSON.stringify({ error: 'Too many requests', time: Date.now() })); + } + } + + if (pathname === '/health' || pathname === '/') { + const mem = process.memoryUsage(); + sendCompressed(req, res, 200, { 'Content-Type': 'application/json' }, JSON.stringify({ + status: 'ok', + clients: clients.size, + messages: messageCount, + droppedMessages, + connected: upstreamSocket?.readyState === WebSocket.OPEN, + upstreamPaused, + vessels: vessels.size, + densityZones: Array.from(densityGrid.values()).filter(c => c.vessels.size >= 2).length, + telegram: { + enabled: TELEGRAM_ENABLED, + channels: telegramState.channels?.length || 0, + items: telegramState.items?.length || 0, + lastPollAt: telegramState.lastPollAt ? new Date(telegramState.lastPollAt).toISOString() : null, + hasError: !!telegramState.lastError, + }, + memory: { + rss: `${(mem.rss / 1024 / 1024).toFixed(0)}MB`, + heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB`, + heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(0)}MB`, + }, + cache: { + opensky: openskyResponseCache.size, + opensky_neg: openskyNegativeCache.size, + rss: rssResponseCache.size, + ucdp: ucdpCache.data ? 'warm' : 'cold', + worldbank: worldbankCache.size, + polymarket: polymarketCache.size, + }, + auth: { + sharedSecretEnabled: !!RELAY_SHARED_SECRET, + authHeader: RELAY_AUTH_HEADER, + allowVercelPreviewOrigins: ALLOW_VERCEL_PREVIEW_ORIGINS, + }, + rateLimit: { + windowMs: RELAY_RATE_LIMIT_WINDOW_MS, + defaultMax: RELAY_RATE_LIMIT_MAX, + openskyMax: RELAY_OPENSKY_RATE_LIMIT_MAX, + rssMax: RELAY_RSS_RATE_LIMIT_MAX, + }, + })); + } else if (pathname === '/metrics') { + return sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, JSON.stringify(getRelayRollingMetrics())); + } else if (pathname.startsWith('/ais/snapshot')) { + // Aggregated AIS snapshot for server-side fanout — serve pre-serialized + pre-gzipped + connectUpstream(); + buildSnapshot(); // ensures cache is warm + const url = new URL(req.url, `http://localhost:${PORT}`); + const includeCandidates = url.searchParams.get('candidates') === 'true'; + const json = includeCandidates ? lastSnapshotWithCandJson : lastSnapshotJson; + const gz = includeCandidates ? lastSnapshotWithCandGzip : lastSnapshotGzip; + + if (json) { + sendPreGzipped(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=2', + }, json, gz); + } else { + // Cold start fallback + const payload = { ...lastSnapshot, candidateReports: includeCandidates ? getCandidateReportsSnapshot() : [] }; + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=2', + }, JSON.stringify(payload)); + } + } else if (pathname === '/opensky-reset') { + openskyToken = null; + openskyTokenExpiry = 0; + openskyTokenPromise = null; + openskyAuthCooldownUntil = 0; + openskyGlobal429Until = 0; + openskyNegativeCache.clear(); + console.log('[Relay] OpenSky auth + rate-limit state reset via /opensky-reset'); + const tokenStart = Date.now(); + const token = await getOpenSkyToken(); + return sendCompressed(req, res, 200, { 'Content-Type': 'application/json' }, JSON.stringify({ + reset: true, + tokenAcquired: !!token, + latencyMs: Date.now() - tokenStart, + negativeCacheCleared: true, + rateLimitCooldownCleared: true, + })); + } else if (pathname === '/opensky-diag') { + // Temporary diagnostic route with safe output only (no token payloads). + const now = Date.now(); + const hasFreshToken = !!(openskyToken && now < openskyTokenExpiry - 60000); + const diag = { timestamp: new Date().toISOString(), steps: [] }; + const clientId = process.env.OPENSKY_CLIENT_ID; + const clientSecret = process.env.OPENSKY_CLIENT_SECRET; + + diag.steps.push({ step: 'env_check', hasClientId: !!clientId, hasClientSecret: !!clientSecret }); + diag.steps.push({ + step: 'auth_state', + cachedToken: !!openskyToken, + freshToken: hasFreshToken, + tokenExpiry: openskyTokenExpiry ? new Date(openskyTokenExpiry).toISOString() : null, + cooldownRemainingMs: Math.max(0, openskyAuthCooldownUntil - now), + tokenFetchInFlight: !!openskyTokenPromise, + global429CooldownRemainingMs: Math.max(0, openskyGlobal429Until - now), + requestSpacingMs: OPENSKY_REQUEST_SPACING_MS, + }); + + if (!clientId || !clientSecret) { + diag.steps.push({ step: 'FAILED', reason: 'Missing OPENSKY_CLIENT_ID or OPENSKY_CLIENT_SECRET' }); + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + return res.end(JSON.stringify(diag, null, 2)); + } + + // Use shared token path so diagnostics respect mutex + cooldown protections. + const tokenStart = Date.now(); + const token = await getOpenSkyToken(); + diag.steps.push({ + step: 'token_request', + method: 'getOpenSkyToken', + success: !!token, + fromCache: hasFreshToken, + latencyMs: Date.now() - tokenStart, + cooldownRemainingMs: Math.max(0, openskyAuthCooldownUntil - Date.now()), + }); + + if (token) { + const apiResult = await new Promise((resolve) => { + const start = Date.now(); + const apiReq = https.get('https://opensky-network.org/api/states/all?lamin=47&lomin=5&lamax=48&lomax=6', { + family: 4, + headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }, + timeout: 15000, + }, (apiRes) => { + let data = ''; + apiRes.on('data', chunk => data += chunk); + apiRes.on('end', () => resolve({ + status: apiRes.statusCode, + latencyMs: Date.now() - start, + bodyLength: data.length, + statesCount: (data.match(/"states":\s*\[/) ? 'present' : 'missing'), + })); + }); + apiReq.on('error', (err) => resolve({ error: err.message, code: err.code, latencyMs: Date.now() - start })); + apiReq.on('timeout', () => { apiReq.destroy(); resolve({ error: 'timeout', latencyMs: Date.now() - start }); }); + }); + diag.steps.push({ step: 'api_request', ...apiResult }); + } else { + diag.steps.push({ step: 'api_request', skipped: true, reason: 'No token available (auth failure or cooldown active)' }); + } + + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(diag, null, 2)); + } else if (pathname === '/telegram' || pathname.startsWith('/telegram/')) { + // Telegram Early Signals feed (public channels) + try { + const url = new URL(req.url, `http://localhost:${PORT}`); + const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50))); + const topic = (url.searchParams.get('topic') || '').trim().toLowerCase(); + const channel = (url.searchParams.get('channel') || '').trim().toLowerCase(); + + const items = Array.isArray(telegramState.items) ? telegramState.items : []; + const filtered = items.filter((it) => { + if (topic && String(it.topic || '').toLowerCase() !== topic) return false; + if (channel && String(it.channel || '').toLowerCase() !== channel) return false; + return true; + }).slice(0, limit); + + sendCompressed(req, res, 200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=10', + }, JSON.stringify({ + source: 'telegram', + earlySignal: true, + enabled: TELEGRAM_ENABLED, + count: filtered.length, + updatedAt: telegramState.lastPollAt ? new Date(telegramState.lastPollAt).toISOString() : null, + items: filtered, + })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal error' })); + } + } else if (pathname.startsWith('/rss')) { + // Proxy RSS feeds that block Vercel IPs + let feedUrl = ''; + try { + const url = new URL(req.url, `http://localhost:${PORT}`); + feedUrl = url.searchParams.get('url') || ''; + + if (!feedUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'Missing url parameter' })); + } + + // Allow domains that block Vercel IPs (must match feeds.ts railwayRss usage) + const allowedDomains = [ + // Original + 'rss.cnn.com', + 'www.defensenews.com', + 'layoffs.fyi', + // International Organizations + 'news.un.org', + 'www.cisa.gov', + 'www.iaea.org', + 'www.who.int', + 'www.crisisgroup.org', + // Middle East & Regional News + 'english.alarabiya.net', + 'www.arabnews.com', + 'www.timesofisrael.com', + 'www.scmp.com', + 'kyivindependent.com', + 'www.themoscowtimes.com', + // Africa + 'feeds.24.com', + 'feeds.capi24.com', // News24 redirect destination + 'islandtimes.org', + 'www.atlanticcouncil.org', + // RSSHub (NHK, MIIT, MOFCOM) + 'rsshub.app', + ]; + const parsed = new URL(feedUrl); + if (!allowedDomains.includes(parsed.hostname)) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'Domain not allowed on Railway proxy' })); + } + + // Serve from cache if fresh (5 min for success, 1 min for failures) + const rssCached = rssResponseCache.get(feedUrl); + if (rssCached) { + const ttl = (rssCached.statusCode && rssCached.statusCode >= 200 && rssCached.statusCode < 300) + ? RSS_CACHE_TTL_MS : RSS_NEGATIVE_CACHE_TTL_MS; + if (Date.now() - rssCached.timestamp < ttl) { + return sendCompressed(req, res, rssCached.statusCode || 200, { + 'Content-Type': rssCached.contentType || 'application/xml', + 'Cache-Control': rssCached.statusCode >= 200 && rssCached.statusCode < 300 ? 'public, max-age=300' : 'no-cache', + 'X-Cache': 'HIT', + }, rssCached.data); + } + } + + // In-flight dedup: if another request for the same feed is already fetching, + // wait for it and serve from cache instead of hammering upstream. + const existing = rssInFlight.get(feedUrl); + if (existing) { + try { + await existing; + const deduped = rssResponseCache.get(feedUrl); + if (deduped) { + return sendCompressed(req, res, deduped.statusCode || 200, { + 'Content-Type': deduped.contentType || 'application/xml', + 'Cache-Control': deduped.statusCode >= 200 && deduped.statusCode < 300 ? 'public, max-age=300' : 'no-cache', + 'X-Cache': 'DEDUP', + }, deduped.data); + } + // In-flight completed but nothing cached — serve 502 instead of cascading + return safeEnd(res, 502, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'Upstream fetch completed but not cached' })); + } catch { + // In-flight fetch failed — serve 502 instead of starting another fetch + return safeEnd(res, 502, { 'Content-Type': 'application/json' }, + JSON.stringify({ error: 'Upstream fetch failed' })); + } + } + + logThrottled('log', `rss-miss:${feedUrl}`, '[Relay] RSS request (MISS):', feedUrl); + + const fetchPromise = new Promise((resolveInFlight, rejectInFlight) => { + let responseHandled = false; + + const sendError = (statusCode, message) => { + if (responseHandled || res.headersSent) return; + responseHandled = true; + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: message })); + rejectInFlight(new Error(message)); + }; + + const fetchWithRedirects = (url, redirectCount = 0) => { + if (redirectCount > 3) { + return sendError(502, 'Too many redirects'); + } + + const protocol = url.startsWith('https') ? https : http; + const request = protocol.get(url, { + headers: { + 'Accept': 'application/rss+xml, application/xml, text/xml, */*', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language': 'en-US,en;q=0.9', + }, + timeout: 15000 + }, (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { + const redirectUrl = response.headers.location.startsWith('http') + ? response.headers.location + : new URL(response.headers.location, url).href; + logThrottled('log', `rss-redirect:${feedUrl}:${redirectUrl}`, `[Relay] Following redirect to: ${redirectUrl}`); + return fetchWithRedirects(redirectUrl, redirectCount + 1); + } + + const encoding = response.headers['content-encoding']; + let stream = response; + if (encoding === 'gzip' || encoding === 'deflate') { + stream = encoding === 'gzip' ? response.pipe(zlib.createGunzip()) : response.pipe(zlib.createInflate()); + } + + const chunks = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => { + if (responseHandled || res.headersSent) return; + responseHandled = true; + const data = Buffer.concat(chunks); + // Cache all responses: 2xx with full TTL, non-2xx with short TTL (negative cache) + // FIFO eviction: drop oldest-inserted entry if at capacity + if (rssResponseCache.size >= RSS_CACHE_MAX_ENTRIES && !rssResponseCache.has(feedUrl)) { + const oldest = rssResponseCache.keys().next().value; + if (oldest) rssResponseCache.delete(oldest); + } + rssResponseCache.set(feedUrl, { data, contentType: 'application/xml', statusCode: response.statusCode, timestamp: Date.now() }); + if (response.statusCode < 200 || response.statusCode >= 300) { + logThrottled('warn', `rss-upstream:${feedUrl}:${response.statusCode}`, `[Relay] RSS upstream ${response.statusCode} for ${feedUrl}`); + } + resolveInFlight(); + sendCompressed(req, res, response.statusCode, { + 'Content-Type': 'application/xml', + 'Cache-Control': response.statusCode >= 200 && response.statusCode < 300 ? 'public, max-age=300' : 'no-cache', + 'X-Cache': 'MISS', + }, data); + }); + stream.on('error', (err) => { + logThrottled('error', `rss-decompress:${feedUrl}:${err.code || err.message}`, '[Relay] Decompression error:', err.message); + sendError(502, 'Decompression failed: ' + err.message); + }); + }); + + request.on('error', (err) => { + logThrottled('error', `rss-error:${feedUrl}:${err.code || err.message}`, '[Relay] RSS error:', err.message); + // Serve stale on error + if (rssCached) { + if (!responseHandled && !res.headersSent) { + responseHandled = true; + sendCompressed(req, res, 200, { 'Content-Type': 'application/xml', 'X-Cache': 'STALE' }, rssCached.data); + } + resolveInFlight(); + return; + } + sendError(502, err.message); + }); + + request.on('timeout', () => { + request.destroy(); + if (rssCached && !responseHandled && !res.headersSent) { + responseHandled = true; + sendCompressed(req, res, 200, { 'Content-Type': 'application/xml', 'X-Cache': 'STALE' }, rssCached.data); + resolveInFlight(); + return; + } + sendError(504, 'Request timeout'); + }); + }; + + fetchWithRedirects(feedUrl); + }); // end fetchPromise + + rssInFlight.set(feedUrl, fetchPromise); + fetchPromise.catch(() => {}).finally(() => rssInFlight.delete(feedUrl)); + } catch (err) { + if (feedUrl) rssInFlight.delete(feedUrl); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + } + } else if (pathname.startsWith('/ucdp-events')) { + handleUcdpEventsRequest(req, res); + } else if (pathname.startsWith('/opensky')) { + handleOpenSkyRequest(req, res, PORT); + } else if (pathname.startsWith('/worldbank')) { + handleWorldBankRequest(req, res); + } else if (pathname.startsWith('/polymarket')) { + handlePolymarketRequest(req, res); + } else { + res.writeHead(404); + res.end(); + } +}); + +function connectUpstream() { + // Skip if already connected or connecting + if (upstreamSocket?.readyState === WebSocket.OPEN || + upstreamSocket?.readyState === WebSocket.CONNECTING) return; + + console.log('[Relay] Connecting to aisstream.io...'); + const socket = new WebSocket(AISSTREAM_URL); + upstreamSocket = socket; + clearUpstreamQueue(); + upstreamPaused = false; + + const scheduleUpstreamDrain = () => { + if (upstreamDrainScheduled) return; + upstreamDrainScheduled = true; + setImmediate(drainUpstreamQueue); + }; + + const drainUpstreamQueue = () => { + if (upstreamSocket !== socket) { + clearUpstreamQueue(); + upstreamPaused = false; + return; + } + + upstreamDrainScheduled = false; + const startedAt = Date.now(); + let processed = 0; + + while (processed < UPSTREAM_DRAIN_BATCH && + getUpstreamQueueSize() > 0 && + Date.now() - startedAt < UPSTREAM_DRAIN_BUDGET_MS) { + const raw = dequeueUpstreamMessage(); + if (!raw) break; + processRawUpstreamMessage(raw); + processed++; + } + + const queueSize = getUpstreamQueueSize(); + if (queueSize >= UPSTREAM_QUEUE_HIGH_WATER && !upstreamPaused) { + upstreamPaused = true; + socket.pause(); + console.warn(`[Relay] Upstream paused (queue=${queueSize}, dropped=${droppedMessages})`); + } else if (upstreamPaused && queueSize <= UPSTREAM_QUEUE_LOW_WATER) { + upstreamPaused = false; + socket.resume(); + console.log(`[Relay] Upstream resumed (queue=${queueSize})`); + } + + if (queueSize > 0) scheduleUpstreamDrain(); + }; + + socket.on('open', () => { + // Verify this socket is still the current one (race condition guard) + if (upstreamSocket !== socket) { + console.log('[Relay] Stale socket open event, closing'); + socket.close(); + return; + } + console.log('[Relay] Connected to aisstream.io'); + socket.send(JSON.stringify({ + APIKey: API_KEY, + BoundingBoxes: [[[-90, -180], [90, 180]]], + FilterMessageTypes: ['PositionReport'], + })); + }); + + socket.on('message', (data) => { + if (upstreamSocket !== socket) return; + + const raw = data instanceof Buffer ? data : Buffer.from(data); + if (getUpstreamQueueSize() >= UPSTREAM_QUEUE_HARD_CAP) { + droppedMessages++; + incrementRelayMetric('drops'); + return; + } + + enqueueUpstreamMessage(raw); + if (!upstreamPaused && getUpstreamQueueSize() >= UPSTREAM_QUEUE_HIGH_WATER) { + upstreamPaused = true; + socket.pause(); + console.warn(`[Relay] Upstream paused (queue=${getUpstreamQueueSize()}, dropped=${droppedMessages})`); + } + scheduleUpstreamDrain(); + }); + + socket.on('close', () => { + if (upstreamSocket === socket) { + upstreamSocket = null; + clearUpstreamQueue(); + upstreamPaused = false; + console.log('[Relay] Disconnected, reconnecting in 5s...'); + setTimeout(connectUpstream, 5000); + } + }); + + socket.on('error', (err) => { + console.error('[Relay] Upstream error:', err.message); + }); +} + +const wss = new WebSocketServer({ server }); + +server.listen(PORT, () => { + console.log(`[Relay] WebSocket relay on port ${PORT}`); + startTelegramPollLoop(); +}); + +wss.on('connection', (ws, req) => { + if (!isAuthorizedRequest(req)) { + ws.close(1008, 'Unauthorized'); + return; + } + + const wsOrigin = req.headers.origin || ''; + if (wsOrigin && !getCorsOrigin(req)) { + ws.close(1008, 'Origin not allowed'); + return; + } + + if (clients.size >= MAX_WS_CLIENTS) { + console.log(`[Relay] WS client rejected (max ${MAX_WS_CLIENTS})`); + ws.close(1013, 'Max clients reached'); + return; + } + console.log(`[Relay] Client connected (${clients.size + 1}/${MAX_WS_CLIENTS})`); + clients.add(ws); + connectUpstream(); + + ws.on('close', () => { + clients.delete(ws); + }); + + ws.on('error', (err) => { + console.error('[Relay] Client error:', err.message); + clients.delete(ws); + }); +}); + +// Memory / health monitor — log every 60s and force GC if available +setInterval(() => { + const mem = process.memoryUsage(); + const rssGB = mem.rss / 1024 / 1024 / 1024; + console.log(`[Monitor] rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB/${(mem.heapTotal / 1024 / 1024).toFixed(0)}MB external=${(mem.external / 1024 / 1024).toFixed(0)}MB vessels=${vessels.size} density=${densityGrid.size} candidates=${candidateReports.size} msgs=${messageCount} dropped=${droppedMessages}`); + // Emergency cleanup if memory exceeds 450MB RSS + if (rssGB > 0.45) { + console.warn('[Monitor] High memory — forcing aggressive cleanup'); + cleanupAggregates(); + // Clear heavy caches only (RSS/polymarket/worldbank are tiny, keep them) + openskyResponseCache.clear(); + openskyNegativeCache.clear(); + if (global.gc) global.gc(); + } +}, 60 * 1000); diff --git a/scripts/build-sidecar-sebuf.mjs b/scripts/build-sidecar-sebuf.mjs new file mode 100644 index 000000000..33b491dc0 --- /dev/null +++ b/scripts/build-sidecar-sebuf.mjs @@ -0,0 +1,39 @@ +/** + * Compiles the sebuf RPC gateway (api/[domain]/v1/[rpc].ts) into a single + * self-contained ESM bundle (api/[domain]/v1/[rpc].js) so the Tauri sidecar's + * buildRouteTable() can discover and load it. + * + * Run: node scripts/build-sidecar-sebuf.mjs + * Or: npm run build:sidecar-sebuf + */ + +import { build } from 'esbuild'; +import { stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); + +const entryPoint = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].ts'); +const outfile = path.join(projectRoot, 'api', '[domain]', 'v1', '[rpc].js'); + +try { + await build({ + entryPoints: [entryPoint], + outfile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + // Tree-shake unused exports for smaller bundle + treeShaking: true, + }); + + const { size } = await stat(outfile); + const sizeKB = (size / 1024).toFixed(1); + console.log(`build:sidecar-sebuf api/[domain]/v1/[rpc].js ${sizeKB} KB`); +} catch (err) { + console.error('build:sidecar-sebuf failed:', err.message); + process.exit(1); +} diff --git a/scripts/desktop-package.mjs b/scripts/desktop-package.mjs new file mode 100644 index 000000000..a23a54f57 --- /dev/null +++ b/scripts/desktop-package.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); + +const getArg = (name) => { + const index = args.indexOf(`--${name}`); + if (index === -1) return undefined; + return args[index + 1]; +}; + +const hasFlag = (name) => args.includes(`--${name}`); + +const os = getArg('os'); +const variant = getArg('variant') ?? 'full'; +const sign = hasFlag('sign'); +const skipNodeRuntime = hasFlag('skip-node-runtime'); +const showHelp = hasFlag('help') || hasFlag('h'); + +const validOs = new Set(['macos', 'windows', 'linux']); +const validVariants = new Set(['full', 'tech']); + +if (showHelp) { + console.log('Usage: npm run desktop:package -- --os --variant [--sign] [--skip-node-runtime]'); + process.exit(0); +} + +if (!validOs.has(os)) { + console.error('Usage: npm run desktop:package -- --os --variant [--sign] [--skip-node-runtime]'); + process.exit(1); +} + +if (!validVariants.has(variant)) { + console.error('Invalid variant. Use --variant full or --variant tech.'); + process.exit(1); +} + +const syncVersionsResult = spawnSync(process.execPath, ['scripts/sync-desktop-version.mjs'], { + stdio: 'inherit' +}); +if (syncVersionsResult.error) { + console.error(syncVersionsResult.error.message); + process.exit(1); +} +if ((syncVersionsResult.status ?? 1) !== 0) { + process.exit(syncVersionsResult.status ?? 1); +} + +const bundles = os === 'macos' ? 'app,dmg' : os === 'linux' ? 'appimage' : 'nsis,msi'; +const env = { + ...process.env, + VITE_VARIANT: variant, + VITE_DESKTOP_RUNTIME: '1', +}; +const cliArgs = ['build', '--bundles', bundles]; +const tauriBin = path.join('node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri'); + +if (!existsSync(tauriBin)) { + console.error( + `Local Tauri CLI not found at ${tauriBin}. Run \"npm ci\" to install dependencies before desktop packaging.` + ); + process.exit(1); +} + +if (variant === 'tech') { + cliArgs.push('--config', 'src-tauri/tauri.tech.conf.json'); +} + +const resolveNodeTarget = () => { + if (env.NODE_TARGET) return env.NODE_TARGET; + if (os === 'windows') return 'x86_64-pc-windows-msvc'; + if (os === 'linux') return 'x86_64-unknown-linux-gnu'; + if (os === 'macos') { + if (process.arch === 'arm64') return 'aarch64-apple-darwin'; + if (process.arch === 'x64') return 'x86_64-apple-darwin'; + } + return ''; +}; + +if (sign) { + if (os === 'macos') { + const hasIdentity = Boolean(env.TAURI_BUNDLE_MACOS_SIGNING_IDENTITY || env.APPLE_SIGNING_IDENTITY); + const hasProvider = Boolean(env.TAURI_BUNDLE_MACOS_PROVIDER_SHORT_NAME); + if (!hasIdentity || !hasProvider) { + console.error( + 'Signing requested (--sign) but missing macOS signing env vars. Set TAURI_BUNDLE_MACOS_SIGNING_IDENTITY (or APPLE_SIGNING_IDENTITY) and TAURI_BUNDLE_MACOS_PROVIDER_SHORT_NAME.' + ); + process.exit(1); + } + } + + if (os === 'windows') { + const hasThumbprint = Boolean(env.TAURI_BUNDLE_WINDOWS_CERTIFICATE_THUMBPRINT); + const hasPfx = Boolean(env.TAURI_BUNDLE_WINDOWS_CERTIFICATE && env.TAURI_BUNDLE_WINDOWS_CERTIFICATE_PASSWORD); + if (!hasThumbprint && !hasPfx) { + console.error( + 'Signing requested (--sign) but missing Windows signing env vars. Set TAURI_BUNDLE_WINDOWS_CERTIFICATE_THUMBPRINT or TAURI_BUNDLE_WINDOWS_CERTIFICATE + TAURI_BUNDLE_WINDOWS_CERTIFICATE_PASSWORD.' + ); + process.exit(1); + } + } +} + +if (!skipNodeRuntime) { + const nodeTarget = resolveNodeTarget(); + if (!nodeTarget) { + console.error( + `Unable to infer Node runtime target for OS=${os} ARCH=${process.arch}. Set NODE_TARGET explicitly or pass --skip-node-runtime.` + ); + process.exit(1); + } + console.log( + `[desktop-package] Bundling Node runtime TARGET=${nodeTarget} VERSION=${env.NODE_VERSION ?? '22.14.0'}` + ); + const downloadResult = spawnSync('bash', ['scripts/download-node.sh', '--target', nodeTarget], { + env: { + ...env, + NODE_TARGET: nodeTarget + }, + stdio: 'inherit', + shell: process.platform === 'win32' + }); + if (downloadResult.error) { + console.error(downloadResult.error.message); + process.exit(1); + } + if ((downloadResult.status ?? 1) !== 0) { + process.exit(downloadResult.status ?? 1); + } +} + +console.log(`[desktop-package] OS=${os} VARIANT=${variant} BUNDLES=${bundles} SIGN=${sign ? 'on' : 'off'}`); + +const result = spawnSync(tauriBin, cliArgs, { + env, + stdio: 'inherit', + shell: process.platform === 'win32' +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/download-node.sh b/scripts/download-node.sh new file mode 100755 index 000000000..64d4bb607 --- /dev/null +++ b/scripts/download-node.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DEST_DIR="${ROOT_DIR}/src-tauri/sidecar/node" +NODE_VERSION="${NODE_VERSION:-22.14.0}" + +usage() { + cat <<'EOF' +Usage: bash scripts/download-node.sh [--target ] + +Supported targets: + - x86_64-pc-windows-msvc + - x86_64-apple-darwin + - aarch64-apple-darwin + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + +Environment: + NODE_VERSION Node.js version to bundle (default: 22.14.0) + NODE_TARGET Optional target triple (same as --target) + RUNNER_OS Optional GitHub Actions OS hint + RUNNER_ARCH Optional GitHub Actions arch hint +EOF +} + +TARGET="" +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + if [[ $# -lt 2 ]]; then + echo "Missing value for --target" >&2 + exit 1 + fi + TARGET="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${TARGET}" ]]; then + TARGET="${NODE_TARGET:-}" +fi + +if [[ -z "${TARGET}" ]]; then + if [[ -n "${RUNNER_OS:-}" ]]; then + case "${RUNNER_OS}" in + Windows) + TARGET="x86_64-pc-windows-msvc" + ;; + macOS) + case "${RUNNER_ARCH:-}" in + ARM64|arm64) + TARGET="aarch64-apple-darwin" + ;; + X64|x64) + TARGET="x86_64-apple-darwin" + ;; + *) + echo "Unsupported RUNNER_ARCH for macOS: ${RUNNER_ARCH:-unknown}" >&2 + exit 1 + ;; + esac + ;; + Linux) + case "${RUNNER_ARCH:-}" in + ARM64|arm64) + TARGET="aarch64-unknown-linux-gnu" + ;; + *) + TARGET="x86_64-unknown-linux-gnu" + ;; + esac + ;; + *) + echo "Unsupported RUNNER_OS: ${RUNNER_OS}" >&2 + exit 1 + ;; + esac + else + case "$(uname -s)" in + Darwin) + case "$(uname -m)" in + arm64|aarch64) + TARGET="aarch64-apple-darwin" + ;; + x86_64) + TARGET="x86_64-apple-darwin" + ;; + *) + echo "Unsupported macOS arch: $(uname -m)" >&2 + exit 1 + ;; + esac + ;; + Linux) + case "$(uname -m)" in + aarch64|arm64) + TARGET="aarch64-unknown-linux-gnu" + ;; + *) + TARGET="x86_64-unknown-linux-gnu" + ;; + esac + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + TARGET="x86_64-pc-windows-msvc" + ;; + *) + echo "Unsupported host OS for auto-detection: $(uname -s)" >&2 + echo "Pass --target explicitly." >&2 + exit 1 + ;; + esac + fi +fi + +case "${TARGET}" in + x86_64-pc-windows-msvc) + DIST_NAME="node-v${NODE_VERSION}-win-x64" + ARCHIVE_NAME="${DIST_NAME}.zip" + NODE_RELATIVE_PATH="node.exe" + OUTPUT_NAME="node.exe" + ;; + x86_64-apple-darwin) + DIST_NAME="node-v${NODE_VERSION}-darwin-x64" + ARCHIVE_NAME="${DIST_NAME}.tar.gz" + NODE_RELATIVE_PATH="bin/node" + OUTPUT_NAME="node" + ;; + aarch64-apple-darwin) + DIST_NAME="node-v${NODE_VERSION}-darwin-arm64" + ARCHIVE_NAME="${DIST_NAME}.tar.gz" + NODE_RELATIVE_PATH="bin/node" + OUTPUT_NAME="node" + ;; + x86_64-unknown-linux-gnu) + DIST_NAME="node-v${NODE_VERSION}-linux-x64" + ARCHIVE_NAME="${DIST_NAME}.tar.gz" + NODE_RELATIVE_PATH="bin/node" + OUTPUT_NAME="node" + ;; + aarch64-unknown-linux-gnu) + DIST_NAME="node-v${NODE_VERSION}-linux-arm64" + ARCHIVE_NAME="${DIST_NAME}.tar.gz" + NODE_RELATIVE_PATH="bin/node" + OUTPUT_NAME="node" + ;; + *) + echo "Unsupported target: ${TARGET}" >&2 + exit 1 + ;; +esac + +BASE_URL="https://nodejs.org/dist/v${NODE_VERSION}" +ARCHIVE_URL="${BASE_URL}/${ARCHIVE_NAME}" +SHASUMS_URL="${BASE_URL}/SHASUMS256.txt" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +echo "[download-node] Downloading ${ARCHIVE_NAME}" +curl -fsSL "${ARCHIVE_URL}" -o "${TMP_DIR}/${ARCHIVE_NAME}" +curl -fsSL "${SHASUMS_URL}" -o "${TMP_DIR}/SHASUMS256.txt" + +EXPECTED_SHA="$(awk -v file="${ARCHIVE_NAME}" '$2 == file { print $1 }' "${TMP_DIR}/SHASUMS256.txt")" +if [[ -z "${EXPECTED_SHA}" ]]; then + echo "Failed to find checksum for ${ARCHIVE_NAME} in SHASUMS256.txt" >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_SHA="$(sha256sum "${TMP_DIR}/${ARCHIVE_NAME}" | awk '{ print $1 }')" +elif command -v shasum >/dev/null 2>&1; then + ACTUAL_SHA="$(shasum -a 256 "${TMP_DIR}/${ARCHIVE_NAME}" | awk '{ print $1 }')" +else + echo "Neither sha256sum nor shasum is available for checksum verification." >&2 + exit 1 +fi + +if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then + echo "Checksum mismatch for ${ARCHIVE_NAME}" >&2 + echo "Expected: ${EXPECTED_SHA}" >&2 + echo "Actual: ${ACTUAL_SHA}" >&2 + exit 1 +fi + +mkdir -p "${TMP_DIR}/extract" +if [[ "${ARCHIVE_NAME}" == *.zip ]]; then + if command -v unzip >/dev/null 2>&1; then + unzip -q "${TMP_DIR}/${ARCHIVE_NAME}" -d "${TMP_DIR}/extract" + else + tar -xf "${TMP_DIR}/${ARCHIVE_NAME}" -C "${TMP_DIR}/extract" + fi +else + tar -xzf "${TMP_DIR}/${ARCHIVE_NAME}" -C "${TMP_DIR}/extract" +fi + +SOURCE_NODE="${TMP_DIR}/extract/${DIST_NAME}/${NODE_RELATIVE_PATH}" +SOURCE_LICENSE="${TMP_DIR}/extract/${DIST_NAME}/LICENSE" + +if [[ ! -f "${SOURCE_NODE}" ]]; then + echo "Node binary not found after extraction: ${SOURCE_NODE}" >&2 + exit 1 +fi + +mkdir -p "${DEST_DIR}" +cp "${SOURCE_NODE}" "${DEST_DIR}/${OUTPUT_NAME}" +if [[ -f "${SOURCE_LICENSE}" ]]; then + cp "${SOURCE_LICENSE}" "${DEST_DIR}/LICENSE" +fi +if [[ "${OUTPUT_NAME}" != "node.exe" ]]; then + chmod +x "${DEST_DIR}/${OUTPUT_NAME}" +fi + +echo "[download-node] Bundled Node.js v${NODE_VERSION} for ${TARGET} at ${DEST_DIR}/${OUTPUT_NAME}" diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 000000000..97de78672 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "ais-relay", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ais-relay", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 000000000..f877b57df --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,17 @@ +{ + "name": "worldmonitor-railway-relay", + "version": "1.1.0", + "description": "Railway relay: AIS/OpenSky + RSS proxy + Telegram OSINT poller", + "main": "ais-relay.cjs", + "scripts": { + "start": "node ais-relay.cjs", + "telegram:session": "node telegram/session-auth.mjs" + }, + "dependencies": { + "ws": "^8.18.0", + "telegram": "^2.22.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/scripts/sync-desktop-version.mjs b/scripts/sync-desktop-version.mjs new file mode 100644 index 000000000..31607beba --- /dev/null +++ b/scripts/sync-desktop-version.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CHECK_ONLY = process.argv.includes('--check'); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); + +const packageJsonPath = path.join(repoRoot, 'package.json'); +const tauriConfPath = path.join(repoRoot, 'src-tauri', 'tauri.conf.json'); +const cargoTomlPath = path.join(repoRoot, 'src-tauri', 'Cargo.toml'); + +function updateCargoPackageVersion(cargoToml, targetVersion) { + const packageSectionRegex = /\[package\][\s\S]*?(?=\n\[|$)/; + const packageSectionMatch = cargoToml.match(packageSectionRegex); + if (!packageSectionMatch) { + throw new Error('Could not find [package] section in src-tauri/Cargo.toml'); + } + + const packageSection = packageSectionMatch[0]; + const versionRegex = /^version\s*=\s*"([^"]+)"\s*$/m; + const versionMatch = packageSection.match(versionRegex); + if (!versionMatch) { + throw new Error('Could not find package version in src-tauri/Cargo.toml'); + } + + const currentVersion = versionMatch[1]; + if (currentVersion === targetVersion) { + return { changed: false, currentVersion, updatedToml: cargoToml }; + } + + const updatedSection = packageSection.replace(versionRegex, `version = "${targetVersion}"`); + return { + changed: true, + currentVersion, + updatedToml: cargoToml.replace(packageSection, updatedSection), + }; +} + +async function main() { + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + const targetVersion = packageJson.version; + + if (!targetVersion || typeof targetVersion !== 'string') { + throw new Error('package.json is missing a valid "version" field'); + } + + const tauriConf = JSON.parse(await readFile(tauriConfPath, 'utf8')); + const tauriCurrentVersion = tauriConf.version; + const tauriChanged = tauriCurrentVersion !== targetVersion; + + const cargoToml = await readFile(cargoTomlPath, 'utf8'); + const cargoUpdate = updateCargoPackageVersion(cargoToml, targetVersion); + + const mismatches = []; + if (tauriChanged) { + mismatches.push(`src-tauri/tauri.conf.json (${tauriCurrentVersion} -> ${targetVersion})`); + } + if (cargoUpdate.changed) { + mismatches.push(`src-tauri/Cargo.toml (${cargoUpdate.currentVersion} -> ${targetVersion})`); + } + + if (CHECK_ONLY) { + if (mismatches.length > 0) { + console.error('[version:check] Version mismatch detected:'); + for (const mismatch of mismatches) { + console.error(`- ${mismatch}`); + } + process.exit(1); + } + console.log(`[version:check] OK. package.json, tauri.conf.json, and Cargo.toml are all ${targetVersion}.`); + return; + } + + if (!tauriChanged && !cargoUpdate.changed) { + console.log(`[version:sync] No changes needed. All files already at ${targetVersion}.`); + return; + } + + if (tauriChanged) { + tauriConf.version = targetVersion; + await writeFile(tauriConfPath, `${JSON.stringify(tauriConf, null, 2)}\n`, 'utf8'); + } + + if (cargoUpdate.changed) { + await writeFile(cargoTomlPath, cargoUpdate.updatedToml, 'utf8'); + } + + console.log(`[version:sync] Synced desktop versions to ${targetVersion}.`); + for (const mismatch of mismatches) { + console.log(`- ${mismatch}`); + } +} + +main().catch((error) => { + console.error(`[version:sync] Failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/telegram/session-auth.mjs b/scripts/telegram/session-auth.mjs new file mode 100644 index 000000000..2c2462c8d --- /dev/null +++ b/scripts/telegram/session-auth.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Generate a TELEGRAM_SESSION (GramJS StringSession) for the Railway Telegram OSINT poller. + * + * Usage (local only): + * cd scripts + * npm install + * TELEGRAM_API_ID=... TELEGRAM_API_HASH=... node telegram/session-auth.mjs + * + * Output: + * Prints TELEGRAM_SESSION=... to stdout. + */ + +import { TelegramClient } from 'telegram'; +import { StringSession } from 'telegram/sessions'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; + +const apiId = parseInt(String(process.env.TELEGRAM_API_ID || ''), 10); +const apiHash = String(process.env.TELEGRAM_API_HASH || ''); + +if (!apiId || !apiHash) { + console.error('Missing TELEGRAM_API_ID or TELEGRAM_API_HASH. Get them from https://my.telegram.org/apps'); + process.exit(1); +} + +const rl = readline.createInterface({ input, output }); + +try { + const phoneNumber = (await rl.question('Phone number (with country code, e.g. +971...): ')).trim(); + const password = (await rl.question('2FA password (press enter if none): ')).trim(); + + const client = new TelegramClient(new StringSession(''), apiId, apiHash, { connectionRetries: 3 }); + + await client.start({ + phoneNumber: async () => phoneNumber, + password: async () => password || undefined, + phoneCode: async () => (await rl.question('Verification code from Telegram: ')).trim(), + onError: (err) => console.error(err), + }); + + const session = client.session.save(); + console.log('\n✅ Generated session. Add this as a Railway secret:'); + console.log(`TELEGRAM_SESSION=${session}`); + + await client.disconnect(); +} finally { + rl.close(); +} diff --git a/server/_shared/acled.ts b/server/_shared/acled.ts new file mode 100644 index 000000000..ac6355268 --- /dev/null +++ b/server/_shared/acled.ts @@ -0,0 +1,81 @@ +/** + * Shared ACLED API fetch with Redis caching. + * + * Three endpoints call ACLED independently (risk-scores, unrest-events, + * acled-events) with overlapping queries. This shared layer ensures + * identical queries hit Redis instead of making redundant upstream calls. + */ + +declare const process: { env: Record }; + +import { CHROME_UA } from './constants'; +import { cachedFetchJson } from './redis'; + +const ACLED_API_URL = 'https://acleddata.com/api/acled/read'; +const ACLED_CACHE_TTL = 900; // 15 min — matches ACLED rate-limit window +const ACLED_TIMEOUT_MS = 15_000; + +export interface AcledRawEvent { + event_id_cnty?: string; + event_type?: string; + sub_event_type?: string; + country?: string; + location?: string; + latitude?: string; + longitude?: string; + event_date?: string; + fatalities?: string; + source?: string; + actor1?: string; + actor2?: string; + admin1?: string; + notes?: string; + tags?: string; +} + +interface FetchAcledOptions { + eventTypes: string; + startDate: string; + endDate: string; + country?: string; + limit?: number; +} + +/** + * Fetch ACLED events with automatic Redis caching. + * Cache key is derived from query parameters so identical queries across + * different handlers share the same cached result. + */ +export async function fetchAcledCached(opts: FetchAcledOptions): Promise { + const token = process.env.ACLED_ACCESS_TOKEN; + if (!token) return []; + + const cacheKey = `acled:shared:${opts.eventTypes}:${opts.startDate}:${opts.endDate}:${opts.country || 'all'}:${opts.limit || 500}`; + const result = await cachedFetchJson(cacheKey, ACLED_CACHE_TTL, async () => { + const params = new URLSearchParams({ + event_type: opts.eventTypes, + event_date: `${opts.startDate}|${opts.endDate}`, + event_date_where: 'BETWEEN', + limit: String(opts.limit || 500), + _format: 'json', + }); + if (opts.country) params.set('country', opts.country); + + const resp = await fetch(`${ACLED_API_URL}?${params}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + 'User-Agent': CHROME_UA, + }, + signal: AbortSignal.timeout(ACLED_TIMEOUT_MS), + }); + + if (!resp.ok) throw new Error(`ACLED API error: ${resp.status}`); + const data = (await resp.json()) as { data?: AcledRawEvent[]; message?: string; error?: string }; + if (data.message || data.error) throw new Error(data.message || data.error || 'ACLED API error'); + + const events = data.data || []; + return events.length > 0 ? events : null; + }); + return result || []; +} diff --git a/server/_shared/constants.ts b/server/_shared/constants.ts new file mode 100644 index 000000000..605f9f949 --- /dev/null +++ b/server/_shared/constants.ts @@ -0,0 +1,21 @@ +export const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +/** + * Global Yahoo Finance request gate. + * Ensures minimum spacing between ANY Yahoo requests across all handlers. + * Multiple handlers calling Yahoo concurrently causes IP-level rate limiting (429). + */ +let yahooLastRequest = 0; +const YAHOO_MIN_GAP_MS = 600; +let yahooQueue: Promise = Promise.resolve(); + +export function yahooGate(): Promise { + yahooQueue = yahooQueue.then(async () => { + const elapsed = Date.now() - yahooLastRequest; + if (elapsed < YAHOO_MIN_GAP_MS) { + await new Promise(r => setTimeout(r, YAHOO_MIN_GAP_MS - elapsed)); + } + yahooLastRequest = Date.now(); + }); + return yahooQueue; +} diff --git a/server/_shared/hash.ts b/server/_shared/hash.ts new file mode 100644 index 000000000..b4219a2ef --- /dev/null +++ b/server/_shared/hash.ts @@ -0,0 +1,22 @@ +/** + * FNV-1a 52-bit hash — stronger than Java hashCode (32-bit) or DJB2 (32-bit). + * + * Uses 52 bits (JS safe integer range) to greatly reduce collision probability + * compared to 32-bit hashes. At 77k keys, 32-bit has ~50% collision chance + * (birthday problem); 52-bit has ~0.00007% chance at 77k keys. + * + * Unified implementation replacing two separate hashString functions (H-7 fix). + */ +export function hashString(input: string): string { + // FNV-1a parameters adapted for 52-bit output (within JS safe integer range) + let h = 0xcbf29ce484222325n; + const FNV_PRIME = 0x100000001b3n; + const MASK_52 = (1n << 52n) - 1n; + + for (let i = 0; i < input.length; i++) { + h ^= BigInt(input.charCodeAt(i)); + h = (h * FNV_PRIME) & MASK_52; + } + + return Number(h).toString(36); +} diff --git a/server/_shared/redis.ts b/server/_shared/redis.ts new file mode 100644 index 000000000..d87ceae81 --- /dev/null +++ b/server/_shared/redis.ts @@ -0,0 +1,160 @@ +declare const process: { env: Record }; + +/** + * Environment-based key prefix to avoid collisions when multiple deployments + * share the same Upstash Redis instance (M-6 fix). + */ +function getKeyPrefix(): string { + const env = process.env.VERCEL_ENV; // 'production' | 'preview' | 'development' + if (!env || env === 'production') return ''; + const sha = process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 8) || 'dev'; + return `${env}:${sha}:`; +} + +let cachedPrefix: string | undefined; +function prefixKey(key: string): string { + if (cachedPrefix === undefined) cachedPrefix = getKeyPrefix(); + if (!cachedPrefix) return key; + return `${cachedPrefix}${key}`; +} + +export async function getCachedJson(key: string): Promise { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return null; + try { + const resp = await fetch(`${url}/get/${encodeURIComponent(prefixKey(key))}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3_000), + }); + if (!resp.ok) return null; + const data = (await resp.json()) as { result?: string }; + return data.result ? JSON.parse(data.result) : null; + } catch { + return null; + } +} + +export async function setCachedJson(key: string, value: unknown, ttlSeconds: number): Promise { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return; + try { + // Atomic SET with EX — single call avoids race between SET and EXPIRE (C-3 fix) + await fetch(`${url}/set/${encodeURIComponent(prefixKey(key))}/${encodeURIComponent(JSON.stringify(value))}/EX/${ttlSeconds}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3_000), + }); + } catch { /* best-effort */ } +} + +/** + * Batch GET using Upstash pipeline API — single HTTP round-trip for N keys. + * Returns a Map of key → parsed JSON value (missing/failed keys omitted). + */ +export async function getCachedJsonBatch(keys: string[]): Promise> { + const result = new Map(); + if (keys.length === 0) return result; + + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return result; + + try { + const pipeline = keys.map((k) => ['GET', prefixKey(k)]); + const resp = await fetch(`${url}/pipeline`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(pipeline), + signal: AbortSignal.timeout(3_000), + }); + if (!resp.ok) return result; + + const data = (await resp.json()) as Array<{ result?: string }>; + for (let i = 0; i < keys.length; i++) { + const raw = data[i]?.result; + if (raw) { + try { result.set(keys[i]!, JSON.parse(raw)); } catch { /* skip malformed */ } + } + } + } catch { /* best-effort */ } + return result; +} + +/** + * In-flight request coalescing map. + * When multiple concurrent requests hit the same cache key during a miss, + * only the first triggers the upstream fetch — others await the same promise. + * This eliminates duplicate upstream API calls within a single Edge Function invocation. + */ +const inflight = new Map>(); + +/** + * Check cache, then fetch with coalescing on miss. + * Concurrent callers for the same key share a single upstream fetch + Redis write. + */ +export async function cachedFetchJson( + key: string, + ttlSeconds: number, + fetcher: () => Promise, +): Promise { + const cached = await getCachedJson(key); + if (cached !== null) return cached as T; + + const existing = inflight.get(key); + if (existing) return existing as Promise; + + const promise = fetcher() + .then(async (result) => { + if (result != null) { + await setCachedJson(key, result, ttlSeconds); + } + return result; + }) + .finally(() => { + inflight.delete(key); + }); + + inflight.set(key, promise); + return promise; +} + +/** + * Like cachedFetchJson but reports the data source. + * Use when callers need to distinguish cache hits from fresh fetches + * (e.g. to set provider/cached metadata on responses). + * + * Returns { data, source } where source is: + * 'cache' — served from Redis + * 'fresh' — fetcher ran (leader) or joined an in-flight fetch (follower) + */ +export async function cachedFetchJsonWithMeta( + key: string, + ttlSeconds: number, + fetcher: () => Promise, +): Promise<{ data: T | null; source: 'cache' | 'fresh' }> { + const cached = await getCachedJson(key); + if (cached !== null) return { data: cached as T, source: 'cache' }; + + const existing = inflight.get(key); + if (existing) { + const data = (await existing) as T; + return { data, source: 'fresh' }; + } + + const promise = fetcher() + .then(async (result) => { + if (result != null) { + await setCachedJson(key, result, ttlSeconds); + } + return result; + }) + .finally(() => { + inflight.delete(key); + }); + + inflight.set(key, promise); + const data = await promise; + return { data, source: 'fresh' }; +} diff --git a/server/cors.ts b/server/cors.ts new file mode 100644 index 000000000..8a3173ab2 --- /dev/null +++ b/server/cors.ts @@ -0,0 +1,49 @@ +/** + * CORS header generation -- TypeScript port of api/_cors.js. + * + * Identical ALLOWED_ORIGIN_PATTERNS and logic, with methods hardcoded + * to 'POST, OPTIONS' (all sebuf routes are POST). + */ + +declare const process: { env: Record }; + +const PRODUCTION_PATTERNS: RegExp[] = [ + /^https:\/\/(.*\.)?worldmonitor\.app$/, + /^https:\/\/worldmonitor-[a-z0-9-]+-elie-[a-z0-9]+\.vercel\.app$/, + /^https?:\/\/tauri\.localhost(:\d+)?$/, + /^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i, + /^tauri:\/\/localhost$/, + /^asset:\/\/localhost$/, +]; + +const DEV_PATTERNS: RegExp[] = [ + /^https?:\/\/localhost(:\d+)?$/, + /^https?:\/\/127\.0\.0\.1(:\d+)?$/, +]; + +const ALLOWED_ORIGIN_PATTERNS: RegExp[] = + process.env.NODE_ENV === 'production' + ? PRODUCTION_PATTERNS + : [...PRODUCTION_PATTERNS, ...DEV_PATTERNS]; + +function isAllowedOrigin(origin: string): boolean { + return Boolean(origin) && ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin)); +} + +export function getCorsHeaders(req: Request): Record { + const origin = req.headers.get('origin') || ''; + const allowOrigin = isAllowedOrigin(origin) ? origin : 'https://worldmonitor.app'; + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key', + 'Access-Control-Max-Age': '86400', + 'Vary': 'Origin', + }; +} + +export function isDisallowedOrigin(req: Request): boolean { + const origin = req.headers.get('origin'); + if (!origin) return false; + return !isAllowedOrigin(origin); +} diff --git a/server/error-mapper.ts b/server/error-mapper.ts new file mode 100644 index 000000000..e495e175f --- /dev/null +++ b/server/error-mapper.ts @@ -0,0 +1,74 @@ +/** + * Error-to-HTTP-response mapper for the sebuf server gateway. + * + * Used as the `onError` callback in ServerOptions. The generated code already + * handles ValidationError (400) before calling onError, so this only handles: + * - ApiError (with statusCode) -- upstream proxy failures + * - Network/fetch errors -- 502 Bad Gateway + * - Unknown errors -- 500 Internal Server Error + */ + +/** + * Detects network/fetch errors across runtimes. Per Fetch spec, network + * errors throw TypeError. We also check common error message patterns + * for V8, Deno, Bun, and Cloudflare Workers edge runtimes. + */ +function isNetworkError(error: unknown): boolean { + if (!(error instanceof TypeError)) return false; + const msg = error.message.toLowerCase(); + return msg.includes('fetch') || + msg.includes('network') || + msg.includes('connect') || + msg.includes('econnrefused') || + msg.includes('enotfound') || + msg.includes('socket'); +} + +/** + * Maps a thrown error to an appropriate HTTP Response. + * Matches the `ServerOptions.onError` signature: + * (error: unknown, req: Request) => Response | Promise + */ +export function mapErrorToResponse(error: unknown, _req: Request): Response { + // ApiError: has statusCode property (e.g., upstream returns 429, 403, etc.) + if (error instanceof Error && 'statusCode' in error) { + const statusCode = (error as Error & { statusCode: number }).statusCode; + // Only expose error.message for 4xx (client errors). Use generic message for 5xx + // to avoid leaking internal details like upstream URLs or API key fragments (H-3 fix). + const message = statusCode >= 400 && statusCode < 500 + ? error.message + : 'Internal server error'; + const body: Record = { message }; + + // Rate limit: include retryAfter if present + if (statusCode === 429 && 'retryAfter' in error) { + body.retryAfter = (error as Error & { retryAfter: number }).retryAfter; + } + + if (statusCode >= 500) { + // Log upstream response body (truncated) for debugging (M-4 fix) + const apiBody = 'body' in error ? String((error as any).body).slice(0, 500) : ''; + console.error(`[error-mapper] ${statusCode}:`, error.message, apiBody ? `| body: ${apiBody}` : ''); + } + + return new Response(JSON.stringify(body), { + status: statusCode, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Network/fetch errors: upstream is unreachable (M-5 fix: runtime-agnostic detection) + if (isNetworkError(error)) { + return new Response(JSON.stringify({ message: 'Upstream unavailable' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Catch-all: 500 Internal Server Error + console.error('[error-mapper] Unhandled error:', error instanceof Error ? error.message : error); + return new Response(JSON.stringify({ message: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/server/router.ts b/server/router.ts new file mode 100644 index 000000000..8357b8daa --- /dev/null +++ b/server/router.ts @@ -0,0 +1,37 @@ +/** + * Map-based route matcher for sebuf-generated RouteDescriptor arrays. + * + * All sebuf routes are static POST paths (e.g., "POST /api/seismology/v1/list-earthquakes"), + * so a simple Map lookup keyed by "METHOD /path" is sufficient -- no regex or dynamic segments. + */ + +/** Same shape as the generated RouteDescriptor (defined locally to avoid importing from a specific generated file). */ +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface Router { + match(req: Request): ((req: Request) => Promise) | null; +} + +export function createRouter(allRoutes: RouteDescriptor[]): Router { + const table = new Map Promise>(); + for (const route of allRoutes) { + const key = `${route.method} ${route.path}`; + table.set(key, route.handler); + } + + return { + match(req: Request) { + const url = new URL(req.url); + // Normalize trailing slashes: /api/foo/v1/bar/ -> /api/foo/v1/bar + const pathname = url.pathname.length > 1 && url.pathname.endsWith('/') + ? url.pathname.slice(0, -1) + : url.pathname; + const key = `${req.method} ${pathname}`; + return table.get(key) ?? null; + }, + }; +} diff --git a/server/worldmonitor/aviation/v1/_shared.ts b/server/worldmonitor/aviation/v1/_shared.ts new file mode 100644 index 000000000..0d408f53d --- /dev/null +++ b/server/worldmonitor/aviation/v1/_shared.ts @@ -0,0 +1,239 @@ +import { XMLParser } from 'fast-xml-parser'; +import type { + AirportDelayAlert, + FlightDelayType, + FlightDelaySeverity, + FlightDelaySource, + AirportRegion, +} from '../../../../src/generated/server/worldmonitor/aviation/v1/service_server'; +import { + MONITORED_AIRPORTS, + FAA_AIRPORTS, + DELAY_SEVERITY_THRESHOLDS, +} from '../../../../src/config/airports'; + +// ---------- Constants ---------- + +export const FAA_URL = 'https://nasstatus.faa.gov/api/airport-status-information'; + +// ---------- XML Parser ---------- + +export const xmlParser = new XMLParser({ + ignoreAttributes: true, + isArray: (_name: string, jpath: string) => { + // Force arrays for list items regardless of count to prevent single-item-as-object bug + return /\.(Ground_Delay|Ground_Stop|Delay|Airport)$/.test(jpath); + }, +}); + +// ---------- Internal types ---------- + +export interface FAADelayInfo { + airport: string; + reason: string; + avgDelay: number; + type: string; +} + +// ---------- Helpers ---------- + +export function parseDelayTypeFromReason(reason: string): string { + const r = reason.toLowerCase(); + if (r.includes('ground stop')) return 'ground_stop'; + if (r.includes('ground delay') || r.includes('gdp')) return 'ground_delay'; + if (r.includes('departure')) return 'departure_delay'; + if (r.includes('arrival')) return 'arrival_delay'; + if (r.includes('clos')) return 'ground_stop'; + return 'general'; +} + +export function parseFaaXml(xml: string): Map { + const delays = new Map(); + const parsed = xmlParser.parse(xml); + const root = parsed?.AIRPORT_STATUS_INFORMATION; + if (!root) return delays; + + // Delay_type may be array or single object + const delayTypes = Array.isArray(root.Delay_type) + ? root.Delay_type + : root.Delay_type ? [root.Delay_type] : []; + + for (const dt of delayTypes) { + // Ground Delays + if (dt.Ground_Delay_List?.Ground_Delay) { + for (const gd of dt.Ground_Delay_List.Ground_Delay) { + if (gd.ARPT) { + delays.set(gd.ARPT, { + airport: gd.ARPT, + reason: gd.Reason || 'Ground delay', + avgDelay: gd.Avg ? parseInt(gd.Avg, 10) : 30, + type: 'ground_delay', + }); + } + } + } + // Ground Stops + if (dt.Ground_Stop_List?.Ground_Stop) { + for (const gs of dt.Ground_Stop_List.Ground_Stop) { + if (gs.ARPT) { + delays.set(gs.ARPT, { + airport: gs.ARPT, + reason: gs.Reason || 'Ground stop', + avgDelay: 60, + type: 'ground_stop', + }); + } + } + } + // Arrival/Departure Delays + if (dt.Arrival_Departure_Delay_List?.Delay) { + for (const d of dt.Arrival_Departure_Delay_List.Delay) { + if (d.ARPT) { + const min = parseInt(d.Arrival_Delay?.Min || d.Departure_Delay?.Min || '15', 10); + const max = parseInt(d.Arrival_Delay?.Max || d.Departure_Delay?.Max || '30', 10); + const existing = delays.get(d.ARPT); + // Don't downgrade ground_stop to lesser delay + if (!existing || existing.type !== 'ground_stop') { + delays.set(d.ARPT, { + airport: d.ARPT, + reason: d.Reason || 'Delays', + avgDelay: Math.round((min + max) / 2), + type: parseDelayTypeFromReason(d.Reason || ''), + }); + } + } + } + } + // Airport Closures + if (dt.Airport_Closure_List?.Airport) { + for (const ac of dt.Airport_Closure_List.Airport) { + if (ac.ARPT && FAA_AIRPORTS.includes(ac.ARPT)) { + delays.set(ac.ARPT, { + airport: ac.ARPT, + reason: 'Airport closure', + avgDelay: 120, + type: 'ground_stop', + }); + } + } + } + } + + return delays; +} + +// ---------- Proto enum mappers ---------- + +export function toProtoDelayType(t: string): FlightDelayType { + const map: Record = { + ground_stop: 'FLIGHT_DELAY_TYPE_GROUND_STOP', + ground_delay: 'FLIGHT_DELAY_TYPE_GROUND_DELAY', + departure_delay: 'FLIGHT_DELAY_TYPE_DEPARTURE_DELAY', + arrival_delay: 'FLIGHT_DELAY_TYPE_ARRIVAL_DELAY', + general: 'FLIGHT_DELAY_TYPE_GENERAL', + }; + return map[t] || 'FLIGHT_DELAY_TYPE_GENERAL'; +} + +export function toProtoSeverity(s: string): FlightDelaySeverity { + const map: Record = { + normal: 'FLIGHT_DELAY_SEVERITY_NORMAL', + minor: 'FLIGHT_DELAY_SEVERITY_MINOR', + moderate: 'FLIGHT_DELAY_SEVERITY_MODERATE', + major: 'FLIGHT_DELAY_SEVERITY_MAJOR', + severe: 'FLIGHT_DELAY_SEVERITY_SEVERE', + }; + return map[s] || 'FLIGHT_DELAY_SEVERITY_NORMAL'; +} + +export function toProtoRegion(r: string): AirportRegion { + const map: Record = { + americas: 'AIRPORT_REGION_AMERICAS', + europe: 'AIRPORT_REGION_EUROPE', + apac: 'AIRPORT_REGION_APAC', + mena: 'AIRPORT_REGION_MENA', + africa: 'AIRPORT_REGION_AFRICA', + }; + return map[r] || 'AIRPORT_REGION_UNSPECIFIED'; +} + +export function toProtoSource(s: string): FlightDelaySource { + const map: Record = { + faa: 'FLIGHT_DELAY_SOURCE_FAA', + eurocontrol: 'FLIGHT_DELAY_SOURCE_EUROCONTROL', + computed: 'FLIGHT_DELAY_SOURCE_COMPUTED', + }; + return map[s] || 'FLIGHT_DELAY_SOURCE_COMPUTED'; +} + +// ---------- Severity classification ---------- + +export function determineSeverity(avgDelayMinutes: number, delayedPct?: number): string { + const t = DELAY_SEVERITY_THRESHOLDS; + if (avgDelayMinutes >= t.severe.avgDelayMinutes || (delayedPct && delayedPct >= t.severe.delayedPct)) return 'severe'; + if (avgDelayMinutes >= t.major.avgDelayMinutes || (delayedPct && delayedPct >= t.major.delayedPct)) return 'major'; + if (avgDelayMinutes >= t.moderate.avgDelayMinutes || (delayedPct && delayedPct >= t.moderate.delayedPct)) return 'moderate'; + if (avgDelayMinutes >= t.minor.avgDelayMinutes || (delayedPct && delayedPct >= t.minor.delayedPct)) return 'minor'; + return 'normal'; +} + +// ---------- Simulated delay generation ---------- + +export function generateSimulatedDelay(airport: typeof MONITORED_AIRPORTS[number]): AirportDelayAlert | null { + const hour = new Date().getUTCHours(); + const isRushHour = (hour >= 6 && hour <= 10) || (hour >= 16 && hour <= 20); + const busyAirports = ['LHR', 'CDG', 'FRA', 'JFK', 'LAX', 'ORD', 'PEK', 'HND', 'DXB', 'SIN']; + const isBusy = busyAirports.includes(airport.iata); + const random = Math.random(); + const delayChance = isRushHour ? 0.35 : 0.15; + const hasDelay = random < (isBusy ? delayChance * 1.5 : delayChance); + + if (!hasDelay) return null; + + let avgDelayMinutes = 0; + let delayType = 'general'; + let reason = 'Minor delays'; + + const severityRoll = Math.random(); + if (severityRoll < 0.05) { + avgDelayMinutes = 60 + Math.floor(Math.random() * 60); + delayType = Math.random() < 0.3 ? 'ground_stop' : 'ground_delay'; + reason = Math.random() < 0.5 ? 'Weather conditions' : 'Air traffic volume'; + } else if (severityRoll < 0.2) { + avgDelayMinutes = 45 + Math.floor(Math.random() * 20); + delayType = 'ground_delay'; + reason = Math.random() < 0.5 ? 'Weather' : 'High traffic volume'; + } else if (severityRoll < 0.5) { + avgDelayMinutes = 25 + Math.floor(Math.random() * 20); + delayType = Math.random() < 0.5 ? 'departure_delay' : 'arrival_delay'; + reason = 'Congestion'; + } else { + avgDelayMinutes = 15 + Math.floor(Math.random() * 15); + delayType = 'general'; + reason = 'Minor delays'; + } + + const severity = determineSeverity(avgDelayMinutes); + // Only return if severity is not normal (matching legacy behavior: filter out normal) + if (severity === 'normal') return null; + + return { + id: `sim-${airport.iata}`, + iata: airport.iata, + icao: airport.icao, + name: airport.name, + city: airport.city, + country: airport.country, + location: { latitude: airport.lat, longitude: airport.lon }, + region: toProtoRegion(airport.region), + delayType: toProtoDelayType(delayType), + severity: toProtoSeverity(severity), + avgDelayMinutes, + delayedFlightsPct: 0, + cancelledFlights: 0, + totalFlights: 0, + reason, + source: toProtoSource('computed'), + updatedAt: Date.now(), + }; +} diff --git a/server/worldmonitor/aviation/v1/handler.ts b/server/worldmonitor/aviation/v1/handler.ts new file mode 100644 index 000000000..57c3ec0ec --- /dev/null +++ b/server/worldmonitor/aviation/v1/handler.ts @@ -0,0 +1,7 @@ +import type { AviationServiceHandler } from '../../../../src/generated/server/worldmonitor/aviation/v1/service_server'; + +import { listAirportDelays } from './list-airport-delays'; + +export const aviationHandler: AviationServiceHandler = { + listAirportDelays, +}; diff --git a/server/worldmonitor/aviation/v1/list-airport-delays.ts b/server/worldmonitor/aviation/v1/list-airport-delays.ts new file mode 100644 index 000000000..d03215167 --- /dev/null +++ b/server/worldmonitor/aviation/v1/list-airport-delays.ts @@ -0,0 +1,93 @@ +import type { + ServerContext, + ListAirportDelaysRequest, + ListAirportDelaysResponse, + AirportDelayAlert, +} from '../../../../src/generated/server/worldmonitor/aviation/v1/service_server'; +import { + MONITORED_AIRPORTS, + FAA_AIRPORTS, +} from '../../../../src/config/airports'; +import { + FAA_URL, + parseFaaXml, + toProtoDelayType, + toProtoSeverity, + toProtoRegion, + toProtoSource, + determineSeverity, + generateSimulatedDelay, +} from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'aviation:delays:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min — FAA updates infrequently + +export async function listAirportDelays( + _ctx: ServerContext, + _req: ListAirportDelaysRequest, +): Promise { + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + const alerts: AirportDelayAlert[] = []; + + // 1. Fetch and parse FAA XML + const faaResponse = await fetch(FAA_URL, { + headers: { Accept: 'application/xml', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15_000), + }); + + let faaDelays = new Map(); + if (faaResponse.ok) { + const xml = await faaResponse.text(); + faaDelays = parseFaaXml(xml); + } + + // 2. Enrich US airports with FAA delay data + for (const iata of FAA_AIRPORTS) { + const airport = MONITORED_AIRPORTS.find((a) => a.iata === iata); + if (!airport) continue; + + const faaDelay = faaDelays.get(iata); + if (faaDelay) { + alerts.push({ + id: `faa-${iata}`, + iata, + icao: airport.icao, + name: airport.name, + city: airport.city, + country: airport.country, + location: { latitude: airport.lat, longitude: airport.lon }, + region: toProtoRegion(airport.region), + delayType: toProtoDelayType(faaDelay.type), + severity: toProtoSeverity(determineSeverity(faaDelay.avgDelay)), + avgDelayMinutes: faaDelay.avgDelay, + delayedFlightsPct: 0, + cancelledFlights: 0, + totalFlights: 0, + reason: faaDelay.reason, + source: toProtoSource('faa'), + updatedAt: Date.now(), + }); + } + } + + // 3. Generate simulated delays for non-US airports + const nonUsAirports = MONITORED_AIRPORTS.filter((a) => a.country !== 'USA'); + for (const airport of nonUsAirports) { + const simulated = generateSimulatedDelay(airport); + if (simulated) { + alerts.push(simulated); + } + } + + return alerts.length > 0 ? { alerts } : null; + }); + + return result || { alerts: [] }; + } catch { + // Graceful empty response on ANY failure (established pattern from 2F-01) + return { alerts: [] }; + } +} diff --git a/server/worldmonitor/climate/v1/handler.ts b/server/worldmonitor/climate/v1/handler.ts new file mode 100644 index 000000000..40e51c0f0 --- /dev/null +++ b/server/worldmonitor/climate/v1/handler.ts @@ -0,0 +1,7 @@ +import type { ClimateServiceHandler } from '../../../../src/generated/server/worldmonitor/climate/v1/service_server'; + +import { listClimateAnomalies } from './list-climate-anomalies'; + +export const climateHandler: ClimateServiceHandler = { + listClimateAnomalies, +}; diff --git a/server/worldmonitor/climate/v1/list-climate-anomalies.ts b/server/worldmonitor/climate/v1/list-climate-anomalies.ts new file mode 100644 index 000000000..74789fc76 --- /dev/null +++ b/server/worldmonitor/climate/v1/list-climate-anomalies.ts @@ -0,0 +1,168 @@ +/** + * ListClimateAnomalies RPC -- fetches 15 monitored zones from the Open-Meteo + * Archive API, computes 30-day baseline comparisons (last 7 days vs preceding + * baseline), classifies severity and type, and returns proto-shaped + * ClimateAnomaly objects. + * + * Zones with fewer than 14 valid data points are skipped. + */ + +import type { + ClimateServiceHandler, + ServerContext, + ListClimateAnomaliesRequest, + ListClimateAnomaliesResponse, + AnomalySeverity, + AnomalyType, + ClimateAnomaly, +} from '../../../../src/generated/server/worldmonitor/climate/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'climate:anomalies:v1'; +const REDIS_CACHE_TTL = 10800; // 3h — Open-Meteo Archive uses ERA5 reanalysis with 2-7 day lag + +/** The 15 monitored zones matching the legacy api/climate-anomalies.js list. */ +const ZONES: { name: string; lat: number; lon: number }[] = [ + { name: 'Ukraine', lat: 48.4, lon: 31.2 }, + { name: 'Middle East', lat: 33.0, lon: 44.0 }, + { name: 'Sahel', lat: 14.0, lon: 0.0 }, + { name: 'Horn of Africa', lat: 8.0, lon: 42.0 }, + { name: 'South Asia', lat: 25.0, lon: 78.0 }, + { name: 'California', lat: 36.8, lon: -119.4 }, + { name: 'Amazon', lat: -3.4, lon: -60.0 }, + { name: 'Australia', lat: -25.0, lon: 134.0 }, + { name: 'Mediterranean', lat: 38.0, lon: 20.0 }, + { name: 'Taiwan Strait', lat: 24.0, lon: 120.0 }, + { name: 'Myanmar', lat: 19.8, lon: 96.7 }, + { name: 'Central Africa', lat: 4.0, lon: 22.0 }, + { name: 'Southern Africa', lat: -25.0, lon: 28.0 }, + { name: 'Central Asia', lat: 42.0, lon: 65.0 }, + { name: 'Caribbean', lat: 19.0, lon: -72.0 }, +]; + +/** + * Classify anomaly severity based on temperature and precipitation deltas. + * Matches legacy thresholds exactly. + */ +function classifySeverity( + tempDelta: number, + precipDelta: number, +): AnomalySeverity { + const absTemp = Math.abs(tempDelta); + const absPrecip = Math.abs(precipDelta); + if (absTemp >= 5 || absPrecip >= 80) return 'ANOMALY_SEVERITY_EXTREME'; + if (absTemp >= 3 || absPrecip >= 40) return 'ANOMALY_SEVERITY_MODERATE'; + return 'ANOMALY_SEVERITY_NORMAL'; +} + +/** + * Classify anomaly type based on temperature and precipitation deltas. + * Matches legacy thresholds exactly. + */ +function classifyType(tempDelta: number, precipDelta: number): AnomalyType { + const absTemp = Math.abs(tempDelta); + const absPrecip = Math.abs(precipDelta); + if (absTemp >= absPrecip / 20) { + if (tempDelta > 0 && precipDelta < -20) return 'ANOMALY_TYPE_MIXED'; + if (tempDelta > 3) return 'ANOMALY_TYPE_WARM'; + if (tempDelta < -3) return 'ANOMALY_TYPE_COLD'; + } + if (precipDelta > 40) return 'ANOMALY_TYPE_WET'; + if (precipDelta < -40) return 'ANOMALY_TYPE_DRY'; + if (tempDelta > 0) return 'ANOMALY_TYPE_WARM'; + return 'ANOMALY_TYPE_COLD'; +} + +/** Compute arithmetic mean of a number array. */ +function avg(arr: number[]): number { + return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0; +} + +/** + * Fetch climate data for a single zone from the Open-Meteo Archive API, + * compute baseline comparison, and return a ClimateAnomaly or null. + */ +async function fetchZone( + zone: { name: string; lat: number; lon: number }, + startDate: string, + endDate: string, +): Promise { + const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${zone.lat}&longitude=${zone.lon}&start_date=${startDate}&end_date=${endDate}&daily=temperature_2m_mean,precipitation_sum&timezone=UTC`; + + const response = await fetch(url, { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(20_000) }); + if (!response.ok) { + throw new Error(`Open-Meteo ${response.status} for ${zone.name}`); + } + + const data: any = await response.json(); + + // Filter nulls: only keep indices where both temp and precip are non-null + const rawTemps: (number | null)[] = data.daily?.temperature_2m_mean ?? []; + const rawPrecips: (number | null)[] = data.daily?.precipitation_sum ?? []; + const temps: number[] = []; + const precips: number[] = []; + for (let i = 0; i < rawTemps.length; i++) { + if (rawTemps[i] != null && rawPrecips[i] != null) { + temps.push(rawTemps[i]!); + precips.push(rawPrecips[i]!); + } + } + + // Minimum data check: need at least 14 valid paired data points + if (temps.length < 14) return null; + + // Split into recent (last 7) and baseline (everything before) + const recentTemps = temps.slice(-7); + const baselineTemps = temps.slice(0, -7); + const recentPrecips = precips.slice(-7); + const baselinePrecips = precips.slice(0, -7); + + // Compute deltas rounded to 1 decimal place + const tempDelta = Math.round((avg(recentTemps) - avg(baselineTemps)) * 10) / 10; + const precipDelta = + Math.round((avg(recentPrecips) - avg(baselinePrecips)) * 10) / 10; + + return { + zone: zone.name, + location: { latitude: zone.lat, longitude: zone.lon }, + tempDelta, + precipDelta, + severity: classifySeverity(tempDelta, precipDelta), + type: classifyType(tempDelta, precipDelta), + period: `${startDate} to ${endDate}`, + }; +} + +export const listClimateAnomalies: ClimateServiceHandler['listClimateAnomalies'] = async ( + _ctx: ServerContext, + _req: ListClimateAnomaliesRequest, +): Promise => { + const result = await cachedFetchJson( + REDIS_CACHE_KEY, + REDIS_CACHE_TTL, + async () => { + const endDate = new Date().toISOString().slice(0, 10); + const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + + const results = await Promise.allSettled( + ZONES.map((zone) => fetchZone(zone, startDate, endDate)), + ); + + const anomalies: ClimateAnomaly[] = []; + for (const r of results) { + if (r.status === 'fulfilled') { + if (r.value != null) anomalies.push(r.value); + } else { + console.error('[CLIMATE]', r.reason?.message ?? r.reason); + } + } + + return anomalies.length > 0 ? { anomalies, pagination: undefined } : null; + }, + ); + return result || { anomalies: [], pagination: undefined }; +}; diff --git a/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts b/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts new file mode 100644 index 000000000..e1362d021 --- /dev/null +++ b/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts @@ -0,0 +1,163 @@ +/** + * RPC: getHumanitarianSummary -- Port from api/hapi.js + * + * Queries the HAPI/HDX API for humanitarian conflict event counts, + * aggregated per country by the most recent reference month. + * Returns undefined summary on upstream failure (graceful degradation). + */ + +import type { + ServerContext, + GetHumanitarianSummaryRequest, + GetHumanitarianSummaryResponse, + HumanitarianCountrySummary, +} from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'conflict:humanitarian:v1'; +const REDIS_CACHE_TTL = 21600; // 6 hr — monthly humanitarian data + +const ISO2_TO_ISO3: Record = { + US: 'USA', RU: 'RUS', CN: 'CHN', UA: 'UKR', IR: 'IRN', + IL: 'ISR', TW: 'TWN', KP: 'PRK', SA: 'SAU', TR: 'TUR', + PL: 'POL', DE: 'DEU', FR: 'FRA', GB: 'GBR', IN: 'IND', + PK: 'PAK', SY: 'SYR', YE: 'YEM', MM: 'MMR', VE: 'VEN', + AF: 'AFG', SD: 'SDN', SS: 'SSD', SO: 'SOM', CD: 'COD', + ET: 'ETH', IQ: 'IRQ', CO: 'COL', NG: 'NGA', PS: 'PSE', + BR: 'BRA', AE: 'ARE', +}; + +interface HapiCountryAgg { + iso3: string; + locationName: string; + month: string; + eventsTotal: number; + eventsPoliticalViolence: number; + eventsCivilianTargeting: number; + eventsDemonstrations: number; + fatalitiesTotalPoliticalViolence: number; + fatalitiesTotalCivilianTargeting: number; +} + +async function fetchHapiSummary(countryCode: string): Promise { + try { + const appId = btoa('worldmonitor:monitor@worldmonitor.app'); + let url = `https://hapi.humdata.org/api/v2/coordination-context/conflict-events?output_format=json&limit=1000&offset=0&app_identifier=${appId}`; + + // Filter by country — if a specific country was requested but has no ISO3 mapping, + // return undefined immediately rather than silently returning unrelated data (BLOCKING-1 fix) + if (countryCode) { + const iso3 = ISO2_TO_ISO3[countryCode.toUpperCase()]; + if (!iso3) return undefined; + url += `&location_code=${iso3}`; + } + + const response = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) return undefined; + + const rawData = await response.json(); + const records: any[] = rawData.data || []; + + // Aggregate per country -- port exactly from api/hapi.js lines 82-108 + const byCountry: Record = {}; + for (const r of records) { + const iso3 = r.location_code || ''; + if (!iso3) continue; + + const month = r.reference_period_start || ''; + const eventType = (r.event_type || '').toLowerCase(); + const events = r.events || 0; + const fatalities = r.fatalities || 0; + + if (!byCountry[iso3]) { + byCountry[iso3] = { + iso3, + locationName: r.location_name || '', + month, + eventsTotal: 0, + eventsPoliticalViolence: 0, + eventsCivilianTargeting: 0, + eventsDemonstrations: 0, + fatalitiesTotalPoliticalViolence: 0, + fatalitiesTotalCivilianTargeting: 0, + }; + } + + const c = byCountry[iso3]; + if (month > c.month) { + // Newer month -- reset + c.month = month; + c.eventsTotal = 0; + c.eventsPoliticalViolence = 0; + c.eventsCivilianTargeting = 0; + c.eventsDemonstrations = 0; + c.fatalitiesTotalPoliticalViolence = 0; + c.fatalitiesTotalCivilianTargeting = 0; + } + if (month === c.month) { + c.eventsTotal += events; + if (eventType.includes('political_violence')) { + c.eventsPoliticalViolence += events; + c.fatalitiesTotalPoliticalViolence += fatalities; + } + if (eventType.includes('civilian_targeting')) { + c.eventsCivilianTargeting += events; + c.fatalitiesTotalCivilianTargeting += fatalities; + } + if (eventType.includes('demonstration')) { + c.eventsDemonstrations += events; + } + } + } + + // Pick the right country entry + let entry: HapiCountryAgg | undefined; + if (countryCode) { + const iso3 = ISO2_TO_ISO3[countryCode.toUpperCase()]; + // iso3 is guaranteed non-null here (early return above handles missing mapping) + entry = iso3 ? byCountry[iso3] : undefined; + if (!entry) return undefined; // Country not in HAPI data + } else { + entry = Object.values(byCountry)[0]; + } + + if (!entry) return undefined; + + return { + countryCode: countryCode ? countryCode.toUpperCase() : '', + countryName: entry.locationName, + conflictEventsTotal: entry.eventsTotal, + conflictPoliticalViolenceEvents: entry.eventsPoliticalViolence + entry.eventsCivilianTargeting, + conflictFatalities: entry.fatalitiesTotalPoliticalViolence + entry.fatalitiesTotalCivilianTargeting, + referencePeriod: entry.month, + conflictDemonstrations: entry.eventsDemonstrations, + updatedAt: Date.now(), + }; + } catch { + return undefined; + } +} + +export async function getHumanitarianSummary( + _ctx: ServerContext, + req: GetHumanitarianSummaryRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.countryCode || 'all'}`; + + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const summary = await fetchHapiSummary(req.countryCode); + return summary ? { summary } : null; + }); + + return result || { summary: undefined }; + } catch { + return { summary: undefined }; + } +} diff --git a/server/worldmonitor/conflict/v1/handler.ts b/server/worldmonitor/conflict/v1/handler.ts new file mode 100644 index 000000000..3054fcd42 --- /dev/null +++ b/server/worldmonitor/conflict/v1/handler.ts @@ -0,0 +1,28 @@ +/** + * Conflict service handler -- implements the generated ConflictServiceHandler + * interface with 3 RPCs proxying three distinct upstream APIs: + * - listAcledEvents: ACLED API for battles, explosions, violence against civilians + * - listUcdpEvents: UCDP GED API with version discovery + paginated backward fetch + * - getHumanitarianSummary: HAPI/HDX API for humanitarian conflict event counts + * + * Consolidates four legacy data flows: + * - api/acled-conflict.js (ACLED conflict proxy) + * - api/ucdp-events.js (UCDP GED events proxy) + * - api/ucdp.js (UCDP classifications proxy) + * - api/hapi.js (HAPI humanitarian proxy) + * + * All RPCs have graceful degradation: return empty/default on upstream failure. + * No error logging on upstream failures (following established 2F-01 pattern). + */ + +import type { ConflictServiceHandler } from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; + +import { listAcledEvents } from './list-acled-events'; +import { listUcdpEvents } from './list-ucdp-events'; +import { getHumanitarianSummary } from './get-humanitarian-summary'; + +export const conflictHandler: ConflictServiceHandler = { + listAcledEvents, + listUcdpEvents, + getHumanitarianSummary, +}; diff --git a/server/worldmonitor/conflict/v1/list-acled-events.ts b/server/worldmonitor/conflict/v1/list-acled-events.ts new file mode 100644 index 000000000..58143aae1 --- /dev/null +++ b/server/worldmonitor/conflict/v1/list-acled-events.ts @@ -0,0 +1,80 @@ +/** + * RPC: listAcledEvents -- Port from api/acled-conflict.js + * + * Proxies the ACLED API for battles, explosions, and violence against + * civilians events within a configurable time range and optional country + * filter. Returns empty array on upstream failure (graceful degradation). + */ + +import type { + ServerContext, + ListAcledEventsRequest, + ListAcledEventsResponse, + AcledConflictEvent, +} from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { fetchAcledCached } from '../../../_shared/acled'; + +const REDIS_CACHE_KEY = 'conflict:acled:v1'; +const REDIS_CACHE_TTL = 900; // 15 min — ACLED rate-limited + +async function fetchAcledConflicts(req: ListAcledEventsRequest): Promise { + try { + const now = Date.now(); + const startMs = req.timeRange?.start ?? (now - 30 * 24 * 60 * 60 * 1000); + const endMs = req.timeRange?.end ?? now; + const startDate = new Date(startMs).toISOString().split('T')[0]!; + const endDate = new Date(endMs).toISOString().split('T')[0]!; + + const rawEvents = await fetchAcledCached({ + eventTypes: 'Battles|Explosions/Remote violence|Violence against civilians', + startDate, + endDate, + country: req.country || undefined, + }); + + return rawEvents + .filter((e) => { + const lat = parseFloat(e.latitude || ''); + const lon = parseFloat(e.longitude || ''); + return Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; + }) + .map((e): AcledConflictEvent => ({ + id: `acled-${e.event_id_cnty}`, + eventType: e.event_type || '', + country: e.country || '', + location: { + latitude: parseFloat(e.latitude || '0'), + longitude: parseFloat(e.longitude || '0'), + }, + occurredAt: new Date(e.event_date || '').getTime(), + fatalities: parseInt(e.fatalities || '', 10) || 0, + actors: [e.actor1, e.actor2].filter(Boolean) as string[], + source: e.source || '', + admin1: e.admin1 || '', + })); + } catch { + return []; + } +} + +export async function listAcledEvents( + _ctx: ServerContext, + req: ListAcledEventsRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.country || 'all'}:${req.timeRange?.start || 0}:${req.timeRange?.end || 0}`; + const result = await cachedFetchJson( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const events = await fetchAcledConflicts(req); + return events.length > 0 ? { events, pagination: undefined } : null; + }, + ); + return result || { events: [], pagination: undefined }; + } catch { + return { events: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/conflict/v1/list-ucdp-events.ts b/server/worldmonitor/conflict/v1/list-ucdp-events.ts new file mode 100644 index 000000000..416f8962a --- /dev/null +++ b/server/worldmonitor/conflict/v1/list-ucdp-events.ts @@ -0,0 +1,244 @@ +/** + * RPC: listUcdpEvents -- Port from api/ucdp-events.js + * + * Queries the UCDP GED API with automatic version discovery and paginated + * backward fetch over a trailing 1-year window. Supports optional country + * filtering. Returns empty array on upstream failure (graceful degradation). + */ + +import type { + ServerContext, + ListUcdpEventsRequest, + ListUcdpEventsResponse, + UcdpViolenceEvent, + UcdpViolenceType, +} from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; +import { cachedFetchJson, setCachedJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; + +const UCDP_PAGE_SIZE = 1000; +const MAX_PAGES = 12; +const TRAILING_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; + +const CACHE_KEY = 'ucdp:gedevents:sebuf:v1'; +const CACHE_TTL_FULL = 6 * 60 * 60; // 6 hours for complete results +const CACHE_TTL_PARTIAL = 10 * 60; // 10 minutes for partial results (M-16 port) + +// In-memory fallback cache with per-entry TTL +let fallbackCache: { data: UcdpViolenceEvent[] | null; timestamp: number; ttlMs: number } = { + data: null, + timestamp: 0, + ttlMs: CACHE_TTL_FULL * 1000, +}; + +const VIOLENCE_TYPE_MAP: Record = { + 1: 'UCDP_VIOLENCE_TYPE_STATE_BASED', + 2: 'UCDP_VIOLENCE_TYPE_NON_STATE', + 3: 'UCDP_VIOLENCE_TYPE_ONE_SIDED', +}; + +function parseDateMs(value: unknown): number { + if (!value) return NaN; + return Date.parse(String(value)); +} + +function getMaxDateMs(events: any[]): number { + let maxMs = NaN; + for (const event of events) { + const ms = parseDateMs(event?.date_start); + if (!Number.isFinite(ms)) continue; + if (!Number.isFinite(maxMs) || ms > maxMs) { + maxMs = ms; + } + } + return maxMs; +} + +function buildVersionCandidates(): string[] { + const year = new Date().getFullYear() - 2000; + return Array.from(new Set([`${year}.1`, `${year - 1}.1`, '25.1', '24.1'])); +} + +// Negative cache: prevent hammering UCDP when it's down +let lastFailureTimestamp = 0; +const NEGATIVE_CACHE_MS = 60 * 1000; // 60 seconds backoff after failure + +// Discovered version cache: avoid re-probing every request +let discoveredVersion: string | null = null; +let discoveredVersionTimestamp = 0; +const VERSION_CACHE_MS = 60 * 60 * 1000; // 1 hour + +async function fetchGedPage(version: string, page: number): Promise { + const response = await fetch( + `https://ucdpapi.pcr.uu.se/api/gedevents/${version}?pagesize=${UCDP_PAGE_SIZE}&page=${page}`, + { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15000), + }, + ); + if (!response.ok) { + throw new Error(`UCDP GED API error (${version}, page ${page}): ${response.status}`); + } + return response.json(); +} + +async function discoverGedVersion(): Promise<{ version: string; page0: any }> { + // Use cached version if still valid + if (discoveredVersion && (Date.now() - discoveredVersionTimestamp) < VERSION_CACHE_MS) { + const page0 = await fetchGedPage(discoveredVersion, 0); + if (Array.isArray(page0?.Result)) { + return { version: discoveredVersion, page0 }; + } + discoveredVersion = null; // Cached version no longer works + } + + // Probe all candidates in parallel instead of sequentially + const candidates = buildVersionCandidates(); + const results = await Promise.allSettled( + candidates.map(async (version) => { + const page0 = await fetchGedPage(version, 0); + if (!Array.isArray(page0?.Result)) throw new Error('No results'); + return { version, page0 }; + }), + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + discoveredVersion = result.value.version; + discoveredVersionTimestamp = Date.now(); + return result.value; + } + } + + throw new Error('No valid UCDP GED version found'); +} + +async function fetchUcdpGedEvents(req: ListUcdpEventsRequest): Promise { + // Negative cache: skip fetch if UCDP failed recently + if (lastFailureTimestamp && (Date.now() - lastFailureTimestamp) < NEGATIVE_CACHE_MS) { + if (fallbackCache.data) return fallbackCache.data; + return []; + } + + try { + const { version, page0 } = await discoverGedVersion(); + const totalPages = Math.max(1, Number(page0?.TotalPages) || 1); + const newestPage = totalPages - 1; + + // Fetch pages in parallel (ported from main branch improvement #198) + const FAILED = Symbol('failed'); + const pagesToFetch: Promise[] = []; + for (let offset = 0; offset < MAX_PAGES && (newestPage - offset) >= 0; offset++) { + const page = newestPage - offset; + if (page === 0) { + pagesToFetch.push(Promise.resolve(page0)); + } else { + pagesToFetch.push(fetchGedPage(version, page).catch(() => FAILED)); + } + } + + const pageResults = await Promise.all(pagesToFetch); + + const allEvents: any[] = []; + let latestDatasetMs = NaN; + let failedPages = 0; + + for (const rawData of pageResults) { + if (rawData === FAILED) { failedPages++; continue; } + const events: any[] = Array.isArray(rawData?.Result) ? rawData.Result : []; + allEvents.push(...events); + + const pageMaxMs = getMaxDateMs(events); + if (!Number.isFinite(latestDatasetMs) && Number.isFinite(pageMaxMs)) { + latestDatasetMs = pageMaxMs; + } + } + + const isPartial = failedPages > 0; + + // Filter events within trailing window + const filtered = allEvents.filter((event) => { + if (!Number.isFinite(latestDatasetMs)) return true; + const eventMs = parseDateMs(event?.date_start); + if (!Number.isFinite(eventMs)) return false; + return eventMs >= (latestDatasetMs - TRAILING_WINDOW_MS); + }); + + // Map to proto UcdpViolenceEvent + let mapped = filtered.map((e: any): UcdpViolenceEvent => ({ + id: String(e.id || ''), + dateStart: Date.parse(e.date_start) || 0, + dateEnd: Date.parse(e.date_end) || 0, + location: { + latitude: Number(e.latitude) || 0, + longitude: Number(e.longitude) || 0, + }, + country: e.country || '', + sideA: (e.side_a || '').substring(0, 200), + sideB: (e.side_b || '').substring(0, 200), + deathsBest: Number(e.best) || 0, + deathsLow: Number(e.low) || 0, + deathsHigh: Number(e.high) || 0, + violenceType: VIOLENCE_TYPE_MAP[e.type_of_violence] || 'UCDP_VIOLENCE_TYPE_UNSPECIFIED', + sourceOriginal: (e.source_original || '').substring(0, 300), + })); + + // Filter by country if requested + if (req.country) { + mapped = mapped.filter((e) => e.country === req.country); + } + + // Sort by dateStart descending (newest first) + mapped.sort((a, b) => b.dateStart - a.dateStart); + + // Success: clear negative cache + lastFailureTimestamp = 0; + + // Cache with TTL based on completeness (ported from main #198) + // Only cache non-empty results to avoid serving stale empty data for hours + const ttl = isPartial ? CACHE_TTL_PARTIAL : CACHE_TTL_FULL; + if (mapped.length > 0) { + await setCachedJson(CACHE_KEY, mapped, ttl).catch(() => {}); + fallbackCache = { data: mapped, timestamp: Date.now(), ttlMs: ttl * 1000 }; + } + + return mapped; + } catch { + lastFailureTimestamp = Date.now(); + if (fallbackCache.data) return fallbackCache.data; + return []; + } +} + +export async function listUcdpEvents( + _ctx: ServerContext, + req: ListUcdpEventsRequest, +): Promise { + // Check in-memory fallback cache before any async ops + if (fallbackCache.data && (Date.now() - fallbackCache.timestamp) < fallbackCache.ttlMs) { + let events = fallbackCache.data; + if (req.country) events = events.filter((e) => e.country === req.country); + return { events, pagination: undefined }; + } + + // Primary Redis cache + fetch with in-flight dedup + const cached = await cachedFetchJson(CACHE_KEY, CACHE_TTL_FULL, async () => { + const events = await fetchUcdpGedEvents(req); + return events.length > 0 ? events : null; + }); + + if (cached && Array.isArray(cached) && cached.length > 0) { + let events = cached; + if (req.country) events = events.filter((e) => e.country === req.country); + return { events, pagination: undefined }; + } + + // Last resort: stale fallback data + if (fallbackCache.data) { + let events = fallbackCache.data; + if (req.country) events = events.filter((e) => e.country === req.country); + return { events, pagination: undefined }; + } + + return { events: [], pagination: undefined }; +} diff --git a/server/worldmonitor/cyber/v1/_shared.ts b/server/worldmonitor/cyber/v1/_shared.ts new file mode 100644 index 000000000..870245c31 --- /dev/null +++ b/server/worldmonitor/cyber/v1/_shared.ts @@ -0,0 +1,772 @@ +/** + * Shared helpers, constants, types, and enum mappings for the Cyber domain. + * + * Five upstream threat intelligence sources: + * - Feodo Tracker (abuse.ch C2 botnet IPs) + * - URLhaus (abuse.ch malicious URLs) + * - C2IntelFeeds (GitHub CSV of C2 IPs) + * - AlienVault OTX (threat indicators) + * - AbuseIPDB (IP blacklist) + * + * All source fetchers have graceful degradation: return empty on upstream failure. + * No error logging on upstream failures (following established 2F-01 pattern). + * No caching in handler (client-side polling manages refresh intervals). + * GeoIP hydration uses in-memory cache for resolved IPs within a process lifetime. + */ + +declare const process: { env: Record }; + +import type { + CyberThreat, + CyberThreatType, + CyberThreatSource, + CyberThreatIndicatorType, + CriticalityLevel, +} from '../../../../src/generated/server/worldmonitor/cyber/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; + +// ======================================================================== +// Constants +// ======================================================================== + +export const DEFAULT_LIMIT = 500; +export const MAX_LIMIT = 1000; +export const DEFAULT_DAYS = 14; +export const MAX_DAYS = 90; + +const FEODO_URL = 'https://feodotracker.abuse.ch/downloads/ipblocklist.json'; +const URLHAUS_RECENT_URL = (limit: number) => `https://urlhaus-api.abuse.ch/v1/urls/recent/limit/${limit}/`; +const C2INTEL_URL = 'https://raw.githubusercontent.com/drb-ra/C2IntelFeeds/master/feeds/IPC2s-30day.csv'; +const OTX_INDICATORS_URL = 'https://otx.alienvault.com/api/v1/indicators/export?type=IPv4&modified_since='; +const ABUSEIPDB_BLACKLIST_URL = 'https://api.abuseipdb.com/api/v2/blacklist'; + +const UPSTREAM_TIMEOUT_MS = 8000; +const GEO_MAX_UNRESOLVED = 250; +const GEO_CONCURRENCY = 16; +const GEO_OVERALL_TIMEOUT_MS = 15_000; +const GEO_PER_IP_TIMEOUT_MS = 3000; +const GEO_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +// ======================================================================== +// Helper utilities +// ======================================================================== + +export function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.floor(value as number))); +} + +function cleanString(value: unknown, maxLen = 120): string { + if (typeof value !== 'string') return ''; + return value.trim().replace(/\s+/g, ' ').slice(0, maxLen); +} + +function toFiniteNumber(value: unknown): number | null { + const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value ?? '')); + return Number.isFinite(parsed) ? parsed : null; +} + +function hasValidCoordinates(lat: number | null, lon: number | null): boolean { + if (lat === null || lon === null) return false; + return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; +} + +function isIPv4(value: string): boolean { + if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) return false; + const octets = value.split('.').map(Number); + return octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255); +} + +function isIPv6(value: string): boolean { + return /^[0-9a-f:]+$/i.test(value) && value.includes(':'); +} + +function isIpAddress(value: string): boolean { + const candidate = cleanString(value, 80).toLowerCase(); + if (!candidate) return false; + return isIPv4(candidate) || isIPv6(candidate); +} + +function normalizeCountry(value: unknown): string { + const raw = cleanString(String(value ?? ''), 64); + if (!raw) return ''; + if (/^[a-z]{2}$/i.test(raw)) return raw.toUpperCase(); + return raw; +} + +function toEpochMs(value: unknown): number { + if (!value) return 0; + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.getTime(); + const raw = cleanString(String(value), 80); + if (!raw) return 0; + const normalized = raw.replace(' UTC', 'Z').replace(' GMT', 'Z').replace(' +00:00', 'Z').replace(' ', 'T'); + const direct = new Date(raw); + if (!Number.isNaN(direct.getTime())) return direct.getTime(); + const fallback = new Date(normalized); + if (!Number.isNaN(fallback.getTime())) return fallback.getTime(); + return 0; +} + +function normalizeTags(input: unknown, maxTags = 8): string[] { + const tags: unknown[] = Array.isArray(input) + ? input + : typeof input === 'string' + ? (input as string).split(/[;,|]/g) + : []; + + const normalized: string[] = []; + const seen = new Set(); + for (const tag of tags) { + const clean = cleanString(String(tag ?? ''), 40).toLowerCase(); + if (!clean || seen.has(clean)) continue; + seen.add(clean); + normalized.push(clean); + if (normalized.length >= maxTags) break; + } + return normalized; +} + +// ======================================================================== +// Enum mappings (legacy string -> proto enum) +// ======================================================================== + +export const THREAT_TYPE_MAP: Record = { + c2_server: 'CYBER_THREAT_TYPE_C2_SERVER', + malware_host: 'CYBER_THREAT_TYPE_MALWARE_HOST', + phishing: 'CYBER_THREAT_TYPE_PHISHING', + malicious_url: 'CYBER_THREAT_TYPE_MALICIOUS_URL', +}; + +export const SOURCE_MAP: Record = { + feodo: 'CYBER_THREAT_SOURCE_FEODO', + urlhaus: 'CYBER_THREAT_SOURCE_URLHAUS', + c2intel: 'CYBER_THREAT_SOURCE_C2INTEL', + otx: 'CYBER_THREAT_SOURCE_OTX', + abuseipdb: 'CYBER_THREAT_SOURCE_ABUSEIPDB', +}; + +const INDICATOR_TYPE_MAP: Record = { + ip: 'CYBER_THREAT_INDICATOR_TYPE_IP', + domain: 'CYBER_THREAT_INDICATOR_TYPE_DOMAIN', + url: 'CYBER_THREAT_INDICATOR_TYPE_URL', +}; + +export const SEVERITY_MAP: Record = { + low: 'CRITICALITY_LEVEL_LOW', + medium: 'CRITICALITY_LEVEL_MEDIUM', + high: 'CRITICALITY_LEVEL_HIGH', + critical: 'CRITICALITY_LEVEL_CRITICAL', +}; + +export const SEVERITY_RANK: Record = { + CRITICALITY_LEVEL_CRITICAL: 4, + CRITICALITY_LEVEL_HIGH: 3, + CRITICALITY_LEVEL_MEDIUM: 2, + CRITICALITY_LEVEL_LOW: 1, + CRITICALITY_LEVEL_UNSPECIFIED: 0, +}; + +// ======================================================================== +// Country centroids (fallback for IPs without geo data) +// ======================================================================== + +const COUNTRY_CENTROIDS: Record = { + US:[39.8,-98.6],CA:[56.1,-106.3],MX:[23.6,-102.6],BR:[-14.2,-51.9],AR:[-38.4,-63.6], + GB:[55.4,-3.4],DE:[51.2,10.5],FR:[46.2,2.2],IT:[41.9,12.6],ES:[40.5,-3.7], + NL:[52.1,5.3],BE:[50.5,4.5],SE:[60.1,18.6],NO:[60.5,8.5],FI:[61.9,25.7], + DK:[56.3,9.5],PL:[51.9,19.1],CZ:[49.8,15.5],AT:[47.5,14.6],CH:[46.8,8.2], + PT:[39.4,-8.2],IE:[53.1,-8.2],RO:[45.9,25.0],HU:[47.2,19.5],BG:[42.7,25.5], + HR:[45.1,15.2],SK:[48.7,19.7],UA:[48.4,31.2],RU:[61.5,105.3],BY:[53.7,28.0], + TR:[39.0,35.2],GR:[39.1,21.8],RS:[44.0,21.0],CN:[35.9,104.2],JP:[36.2,138.3], + KR:[35.9,127.8],IN:[20.6,79.0],PK:[30.4,69.3],BD:[23.7,90.4],ID:[-0.8,113.9], + TH:[15.9,101.0],VN:[14.1,108.3],PH:[12.9,121.8],MY:[4.2,101.9],SG:[1.4,103.8], + TW:[23.7,121.0],HK:[22.4,114.1],AU:[-25.3,133.8],NZ:[-40.9,174.9], + ZA:[-30.6,22.9],NG:[9.1,8.7],EG:[26.8,30.8],KE:[-0.02,37.9],ET:[9.1,40.5], + MA:[31.8,-7.1],DZ:[28.0,1.7],TN:[33.9,9.5],GH:[7.9,-1.0], + SA:[23.9,45.1],AE:[23.4,53.8],IL:[31.0,34.9],IR:[32.4,53.7],IQ:[33.2,43.7], + KW:[29.3,47.5],QA:[25.4,51.2],BH:[26.0,50.6],JO:[30.6,36.2],LB:[33.9,35.9], + CL:[-35.7,-71.5],CO:[4.6,-74.3],PE:[-9.2,-75.0],VE:[6.4,-66.6], + KZ:[48.0,68.0],UZ:[41.4,64.6],GE:[42.3,43.4],AZ:[40.1,47.6],AM:[40.1,45.0], + LT:[55.2,23.9],LV:[56.9,24.1],EE:[58.6,25.0], + HN:[15.2,-86.2],GT:[15.8,-90.2],PA:[8.5,-80.8],CR:[9.7,-84.0], + SN:[14.5,-14.5],CM:[7.4,12.4],CI:[7.5,-5.5],TZ:[-6.4,34.9],UG:[1.4,32.3], +}; + +function getCountryCentroid(countryCode: string): { lat: number; lon: number } | null { + if (!countryCode) return null; + const coords = COUNTRY_CENTROIDS[countryCode.toUpperCase()]; + if (!coords) return null; + const jitter = () => (Math.random() - 0.5) * 2; + return { lat: coords[0] + jitter(), lon: coords[1] + jitter() }; +} + +// ======================================================================== +// Internal threat shape (intermediate before proto mapping) +// ======================================================================== + +export interface RawThreat { + id: string; + type: string; + source: string; + indicator: string; + indicatorType: string; + lat: number | null; + lon: number | null; + country: string; + severity: string; + malwareFamily: string; + tags: string[]; + firstSeen: number; // epoch ms + lastSeen: number; // epoch ms +} + +function sanitizeRawThreat(threat: Partial & { indicator?: string }): RawThreat | null { + const indicator = cleanString(threat.indicator, 255); + if (!indicator) return null; + + const indicatorType = threat.indicatorType || 'ip'; + if (indicatorType === 'ip' && !isIpAddress(indicator)) return null; + + return { + id: cleanString(threat.id, 255) || `${threat.source || 'feodo'}:${indicatorType}:${indicator}`, + type: threat.type || 'malicious_url', + source: threat.source || 'feodo', + indicator, + indicatorType, + lat: threat.lat ?? null, + lon: threat.lon ?? null, + country: threat.country || '', + severity: threat.severity || 'medium', + malwareFamily: cleanString(threat.malwareFamily, 80), + tags: threat.tags || [], + firstSeen: threat.firstSeen || 0, + lastSeen: threat.lastSeen || 0, + }; +} + +// ======================================================================== +// GeoIP hydration (in-memory cache only -- no Redis in handler layer) +// ======================================================================== + +const GEO_CACHE_MAX_SIZE = 2048; +const geoCache = new Map(); + +function getGeoCached(ip: string): { lat: number; lon: number; country: string } | null { + const entry = geoCache.get(ip); + if (!entry) return null; + if (Date.now() - entry.ts > GEO_CACHE_TTL_MS) { + geoCache.delete(ip); + return null; + } + return entry; +} + +function setGeoCached(ip: string, geo: { lat: number; lon: number; country: string }): void { + // Evict oldest entries when cache exceeds max size (C-1 fix) + if (geoCache.size >= GEO_CACHE_MAX_SIZE) { + const keysToDelete = Array.from(geoCache.keys()).slice(0, Math.floor(GEO_CACHE_MAX_SIZE / 4)); + for (const key of keysToDelete) geoCache.delete(key); + } + geoCache.set(ip, { ...geo, ts: Date.now() }); +} + +async function fetchGeoIp( + ip: string, + signal?: AbortSignal, +): Promise<{ lat: number; lon: number; country: string } | null> { + // Primary: ipinfo.io + try { + const resp = await fetch(`https://ipinfo.io/${encodeURIComponent(ip)}/json`, { + headers: { 'User-Agent': CHROME_UA }, + signal: signal || AbortSignal.timeout(GEO_PER_IP_TIMEOUT_MS), + }); + if (resp.ok) { + const data = await resp.json() as { loc?: string; country?: string }; + const parts = (data.loc || '').split(','); + const lat = toFiniteNumber(parts[0]); + const lon = toFiniteNumber(parts[1]); + if (hasValidCoordinates(lat, lon)) { + return { lat: lat!, lon: lon!, country: normalizeCountry(data.country) }; + } + } + } catch { /* fall through */ } + + // Check if already aborted before fallback + if (signal?.aborted) return null; + + // Fallback: freeipapi.com + try { + const resp = await fetch(`https://freeipapi.com/api/json/${encodeURIComponent(ip)}`, { + headers: { 'User-Agent': CHROME_UA }, + signal: signal || AbortSignal.timeout(GEO_PER_IP_TIMEOUT_MS), + }); + if (!resp.ok) return null; + const data = await resp.json() as { latitude?: number; longitude?: number; countryCode?: string; countryName?: string }; + const lat = toFiniteNumber(data.latitude); + const lon = toFiniteNumber(data.longitude); + if (!hasValidCoordinates(lat, lon)) return null; + return { lat: lat!, lon: lon!, country: normalizeCountry(data.countryCode || data.countryName) }; + } catch { + return null; + } +} + +async function geolocateIp( + ip: string, + signal?: AbortSignal, +): Promise<{ lat: number; lon: number; country: string } | null> { + const cached = getGeoCached(ip); + if (cached) return cached; + const geo = await fetchGeoIp(ip, signal); + if (geo) setGeoCached(ip, geo); + return geo; +} + +export async function hydrateThreatCoordinates(threats: RawThreat[]): Promise { + // Collect unique IPs needing resolution + const unresolvedIps: string[] = []; + const seenIps = new Set(); + + for (const threat of threats) { + if (hasValidCoordinates(threat.lat, threat.lon)) continue; + if (threat.indicatorType !== 'ip') continue; + const ip = cleanString(threat.indicator, 80).toLowerCase(); + if (!isIpAddress(ip) || seenIps.has(ip)) continue; + seenIps.add(ip); + unresolvedIps.push(ip); + } + + const capped = unresolvedIps.slice(0, GEO_MAX_UNRESOLVED); + const resolvedByIp = new Map(); + + // AbortController cancels orphaned workers on timeout (M-16 fix) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), GEO_OVERALL_TIMEOUT_MS); + + // Concurrent workers + const queue = [...capped]; + const workerCount = Math.min(GEO_CONCURRENCY, queue.length); + const workers = Array.from({ length: workerCount }, async () => { + while (queue.length > 0 && !controller.signal.aborted) { + const ip = queue.shift(); + if (!ip) continue; + const geo = await geolocateIp(ip, controller.signal); + if (geo) resolvedByIp.set(ip, geo); + } + }); + + try { + await Promise.all(workers); + } catch { /* aborted — expected */ } + clearTimeout(timeoutId); + + return threats.map((threat) => { + if (hasValidCoordinates(threat.lat, threat.lon)) return threat; + if (threat.indicatorType !== 'ip') return threat; + + const lookup = resolvedByIp.get(cleanString(threat.indicator, 80).toLowerCase()); + if (lookup) { + return { ...threat, lat: lookup.lat, lon: lookup.lon, country: threat.country || lookup.country }; + } + + const centroid = getCountryCentroid(threat.country); + if (centroid) { + return { ...threat, lat: centroid.lat, lon: centroid.lon }; + } + + return threat; + }); +} + +// ======================================================================== +// Source result type +// ======================================================================== + +export interface SourceResult { + ok: boolean; + threats: RawThreat[]; +} + +// ======================================================================== +// Source 1: Feodo Tracker +// ======================================================================== + +function inferFeodoSeverity(record: any, malwareFamily: string): string { + if (/emotet|qakbot|trickbot|dridex|ransom/i.test(malwareFamily)) return 'critical'; + const status = cleanString(record?.status || record?.c2_status || '', 30).toLowerCase(); + if (status === 'online') return 'high'; + return 'medium'; +} + +function parseFeodoRecord(record: any, cutoffMs: number): RawThreat | null { + const ip = cleanString( + record?.ip_address || record?.dst_ip || record?.ip || record?.ioc || record?.host, + 80, + ).toLowerCase(); + if (!isIpAddress(ip)) return null; + + const statusRaw = cleanString(record?.status || record?.c2_status || '', 30).toLowerCase(); + if (statusRaw && statusRaw !== 'online' && statusRaw !== 'offline') return null; + + const firstSeen = toEpochMs(record?.first_seen || record?.first_seen_utc || record?.dateadded); + const lastSeen = toEpochMs(record?.last_online || record?.last_seen || record?.last_seen_utc || record?.first_seen || record?.first_seen_utc); + + const activityMs = lastSeen || firstSeen; + if (activityMs && activityMs < cutoffMs) return null; + + const malwareFamily = cleanString(record?.malware || record?.malware_family || record?.family, 80); + const tags = normalizeTags(record?.tags); + + return sanitizeRawThreat({ + id: `feodo:${ip}`, + type: 'c2_server', + source: 'feodo', + indicator: ip, + indicatorType: 'ip', + lat: toFiniteNumber(record?.latitude ?? record?.lat), + lon: toFiniteNumber(record?.longitude ?? record?.lon), + country: normalizeCountry(record?.country || record?.country_code), + severity: statusRaw === 'online' ? inferFeodoSeverity(record, malwareFamily) : 'medium', + malwareFamily, + tags: normalizeTags(['botnet', 'c2', ...tags]), + firstSeen, + lastSeen, + }); +} + +export async function fetchFeodoSource(limit: number, cutoffMs: number): Promise { + try { + const response = await fetch(FEODO_URL, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false, threats: [] }; + + const payload = await response.json(); + const records: any[] = Array.isArray(payload) ? payload : (Array.isArray(payload?.data) ? payload.data : []); + + const parsed = records + .map((r) => parseFeodoRecord(r, cutoffMs)) + .filter((t): t is RawThreat => t !== null) + .sort((a, b) => (b.lastSeen || b.firstSeen) - (a.lastSeen || a.firstSeen)) + .slice(0, limit); + + return { ok: true, threats: parsed }; + } catch { + return { ok: false, threats: [] }; + } +} + +// ======================================================================== +// Source 2: URLhaus +// ======================================================================== + +function inferUrlhausType(record: any, tags: string[]): string { + const threat = cleanString(record?.threat || record?.threat_type || '', 40).toLowerCase(); + const allTags = tags.join(' '); + if (threat.includes('phish') || allTags.includes('phish')) return 'phishing'; + if (threat.includes('malware') || threat.includes('payload') || allTags.includes('malware')) return 'malware_host'; + return 'malicious_url'; +} + +function inferUrlhausSeverity(type: string, tags: string[]): string { + if (type === 'phishing') return 'medium'; + if (tags.includes('ransomware') || tags.includes('botnet')) return 'critical'; + if (type === 'malware_host') return 'high'; + return 'medium'; +} + +function parseUrlhausRecord(record: any, cutoffMs: number): RawThreat | null { + const rawUrl = cleanString(record?.url || record?.ioc || '', 1024); + const statusRaw = cleanString(record?.url_status || record?.status || '', 30).toLowerCase(); + if (statusRaw && statusRaw !== 'online') return null; + + const tags = normalizeTags(record?.tags); + + let hostname = ''; + if (rawUrl) { + try { hostname = cleanString(new URL(rawUrl).hostname, 255).toLowerCase(); } catch { /* ignore */ } + } + + const recordIp = cleanString(record?.host || record?.ip_address || record?.ip, 80).toLowerCase(); + const ipCandidate = isIpAddress(recordIp) ? recordIp : (isIpAddress(hostname) ? hostname : ''); + + const indicatorType = ipCandidate ? 'ip' : (hostname ? 'domain' : 'url'); + const indicator = ipCandidate || hostname || rawUrl; + if (!indicator) return null; + + const firstSeen = toEpochMs(record?.dateadded || record?.firstseen || record?.first_seen); + const lastSeen = toEpochMs(record?.last_online || record?.last_seen || record?.dateadded); + + const activityMs = lastSeen || firstSeen; + if (activityMs && activityMs < cutoffMs) return null; + + const type = inferUrlhausType(record, tags); + + return sanitizeRawThreat({ + id: `urlhaus:${indicatorType}:${indicator}`, + type, + source: 'urlhaus', + indicator, + indicatorType, + lat: toFiniteNumber(record?.latitude ?? record?.lat), + lon: toFiniteNumber(record?.longitude ?? record?.lon), + country: normalizeCountry(record?.country || record?.country_code), + severity: inferUrlhausSeverity(type, tags), + malwareFamily: cleanString(record?.threat, 80), + tags, + firstSeen, + lastSeen, + }); +} + +export async function fetchUrlhausSource(limit: number, cutoffMs: number): Promise { + const authKey = cleanString(process.env.URLHAUS_AUTH_KEY || '', 200); + if (!authKey) return { ok: false, threats: [] }; + + try { + const response = await fetch(URLHAUS_RECENT_URL(limit), { + method: 'GET', + headers: { Accept: 'application/json', 'Auth-Key': authKey, 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false, threats: [] }; + + const payload = await response.json(); + const rows: any[] = Array.isArray(payload?.urls) ? payload.urls : (Array.isArray(payload?.data) ? payload.data : []); + + const parsed = rows + .map((r) => parseUrlhausRecord(r, cutoffMs)) + .filter((t): t is RawThreat => t !== null) + .sort((a, b) => (b.lastSeen || b.firstSeen) - (a.lastSeen || a.firstSeen)) + .slice(0, limit); + + return { ok: true, threats: parsed }; + } catch { + return { ok: false, threats: [] }; + } +} + +// ======================================================================== +// Source 3: C2IntelFeeds (CSV) +// ======================================================================== + +function parseC2IntelCsvLine(line: string): RawThreat | null { + if (!line || line.startsWith('#')) return null; + const commaIdx = line.indexOf(','); + if (commaIdx < 0) return null; + + const ip = cleanString(line.slice(0, commaIdx), 80).toLowerCase(); + if (!isIpAddress(ip)) return null; + + const description = cleanString(line.slice(commaIdx + 1), 200); + const malwareFamily = description + .replace(/^Possible\s+/i, '') + .replace(/\s+C2\s+IP$/i, '') + .trim() || 'Unknown'; + + const tags = ['c2']; + const descLower = description.toLowerCase(); + if (descLower.includes('cobaltstrike') || descLower.includes('cobalt strike')) tags.push('cobaltstrike'); + if (descLower.includes('metasploit')) tags.push('metasploit'); + if (descLower.includes('sliver')) tags.push('sliver'); + if (descLower.includes('brute ratel') || descLower.includes('bruteratel')) tags.push('bruteratel'); + + const severity = /cobaltstrike|cobalt.strike|brute.?ratel/i.test(description) ? 'high' : 'medium'; + + return sanitizeRawThreat({ + id: `c2intel:${ip}`, + type: 'c2_server', + source: 'c2intel', + indicator: ip, + indicatorType: 'ip', + lat: null, + lon: null, + country: '', + severity, + malwareFamily, + tags: normalizeTags(tags), + firstSeen: 0, + lastSeen: 0, + }); +} + +export async function fetchC2IntelSource(limit: number): Promise { + try { + const response = await fetch(C2INTEL_URL, { + headers: { Accept: 'text/plain', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false, threats: [] }; + + const text = await response.text(); + const parsed = text.split('\n') + .map((line) => parseC2IntelCsvLine(line)) + .filter((t): t is RawThreat => t !== null) + .slice(0, limit); + + return { ok: true, threats: parsed }; + } catch { + return { ok: false, threats: [] }; + } +} + +// ======================================================================== +// Source 4: AlienVault OTX +// ======================================================================== + +export async function fetchOtxSource(limit: number, days: number): Promise { + const apiKey = cleanString(process.env.OTX_API_KEY || '', 200); + if (!apiKey) return { ok: false, threats: [] }; + + try { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const response = await fetch( + `${OTX_INDICATORS_URL}${encodeURIComponent(since)}`, + { + headers: { Accept: 'application/json', 'X-OTX-API-KEY': apiKey, 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + if (!response.ok) return { ok: false, threats: [] }; + + const payload = await response.json(); + const results: any[] = Array.isArray(payload?.results) ? payload.results : (Array.isArray(payload) ? payload : []); + + const parsed: RawThreat[] = []; + for (const record of results) { + const ip = cleanString(record?.indicator || record?.ip || '', 80).toLowerCase(); + if (!isIpAddress(ip)) continue; + + const title = cleanString(record?.title || record?.description || '', 200); + const tags = normalizeTags(record?.tags || []); + const severity = tags.some((t) => /ransomware|apt|c2|botnet/.test(t)) ? 'high' : 'medium'; + + const threat = sanitizeRawThreat({ + id: `otx:${ip}`, + type: tags.some((t) => /c2|botnet/.test(t)) ? 'c2_server' : 'malware_host', + source: 'otx', + indicator: ip, + indicatorType: 'ip', + lat: null, + lon: null, + country: '', + severity, + malwareFamily: title, + tags, + firstSeen: toEpochMs(record?.created), + lastSeen: toEpochMs(record?.modified || record?.created), + }); + if (threat) parsed.push(threat); + if (parsed.length >= limit) break; + } + + return { ok: true, threats: parsed }; + } catch { + return { ok: false, threats: [] }; + } +} + +// ======================================================================== +// Source 5: AbuseIPDB +// ======================================================================== + +export async function fetchAbuseIpDbSource(limit: number): Promise { + const apiKey = cleanString(process.env.ABUSEIPDB_API_KEY || '', 200); + if (!apiKey) return { ok: false, threats: [] }; + + try { + const url = `${ABUSEIPDB_BLACKLIST_URL}?confidenceMinimum=90&limit=${Math.min(limit, 500)}`; + const response = await fetch(url, { + headers: { Accept: 'application/json', Key: apiKey, 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false, threats: [] }; + + const payload = await response.json(); + const records: any[] = Array.isArray(payload?.data) ? payload.data : []; + + const parsed: RawThreat[] = []; + for (const record of records) { + const ip = cleanString(record?.ipAddress || record?.ip || '', 80).toLowerCase(); + if (!isIpAddress(ip)) continue; + + const score = toFiniteNumber(record?.abuseConfidenceScore) ?? 0; + const severity = score >= 95 ? 'critical' : (score >= 80 ? 'high' : 'medium'); + + const threat = sanitizeRawThreat({ + id: `abuseipdb:${ip}`, + type: 'malware_host', + source: 'abuseipdb', + indicator: ip, + indicatorType: 'ip', + lat: toFiniteNumber(record?.latitude ?? record?.lat), + lon: toFiniteNumber(record?.longitude ?? record?.lon), + country: normalizeCountry(record?.countryCode || record?.country), + severity, + malwareFamily: '', + tags: normalizeTags([`score:${score}`]), + firstSeen: 0, + lastSeen: toEpochMs(record?.lastReportedAt), + }); + if (threat) parsed.push(threat); + if (parsed.length >= limit) break; + } + + return { ok: true, threats: parsed }; + } catch { + return { ok: false, threats: [] }; + } +} + +// ======================================================================== +// Deduplication +// ======================================================================== + +export function dedupeThreats(threats: RawThreat[]): RawThreat[] { + const deduped = new Map(); + for (const threat of threats) { + const key = `${threat.source}:${threat.indicatorType}:${threat.indicator}`; + const existing = deduped.get(key); + if (!existing) { + deduped.set(key, threat); + continue; + } + + const existingSeen = existing.lastSeen || existing.firstSeen; + const candidateSeen = threat.lastSeen || threat.firstSeen; + if (candidateSeen >= existingSeen) { + deduped.set(key, { + ...existing, + ...threat, + tags: normalizeTags([...existing.tags, ...threat.tags]), + }); + } + } + return Array.from(deduped.values()); +} + +// ======================================================================== +// RawThreat -> Proto CyberThreat mapping +// ======================================================================== + +export function toProtoCyberThreat(raw: RawThreat): CyberThreat { + return { + id: raw.id, + type: THREAT_TYPE_MAP[raw.type] || 'CYBER_THREAT_TYPE_UNSPECIFIED', + source: SOURCE_MAP[raw.source] || 'CYBER_THREAT_SOURCE_UNSPECIFIED', + indicator: raw.indicator, + indicatorType: INDICATOR_TYPE_MAP[raw.indicatorType] || 'CYBER_THREAT_INDICATOR_TYPE_UNSPECIFIED', + location: hasValidCoordinates(raw.lat, raw.lon) + ? { latitude: raw.lat!, longitude: raw.lon! } + : undefined, + country: raw.country, + severity: SEVERITY_MAP[raw.severity] || 'CRITICALITY_LEVEL_UNSPECIFIED', + malwareFamily: raw.malwareFamily, + tags: raw.tags, + firstSeenAt: raw.firstSeen, + lastSeenAt: raw.lastSeen, + }; +} + diff --git a/server/worldmonitor/cyber/v1/handler.ts b/server/worldmonitor/cyber/v1/handler.ts new file mode 100644 index 000000000..7b1fb47f7 --- /dev/null +++ b/server/worldmonitor/cyber/v1/handler.ts @@ -0,0 +1,7 @@ +import type { CyberServiceHandler } from '../../../../src/generated/server/worldmonitor/cyber/v1/service_server'; + +import { listCyberThreats } from './list-cyber-threats'; + +export const cyberHandler: CyberServiceHandler = { + listCyberThreats, +}; diff --git a/server/worldmonitor/cyber/v1/list-cyber-threats.ts b/server/worldmonitor/cyber/v1/list-cyber-threats.ts new file mode 100644 index 000000000..61f962720 --- /dev/null +++ b/server/worldmonitor/cyber/v1/list-cyber-threats.ts @@ -0,0 +1,114 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + ListCyberThreatsRequest, + ListCyberThreatsResponse, +} from '../../../../src/generated/server/worldmonitor/cyber/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; + +import { + DEFAULT_LIMIT, + MAX_LIMIT, + DEFAULT_DAYS, + MAX_DAYS, + clampInt, + THREAT_TYPE_MAP, + SOURCE_MAP, + SEVERITY_MAP, + SEVERITY_RANK, + fetchFeodoSource, + fetchUrlhausSource, + fetchC2IntelSource, + fetchOtxSource, + fetchAbuseIpDbSource, + dedupeThreats, + hydrateThreatCoordinates, + toProtoCyberThreat, +} from './_shared'; + +const REDIS_CACHE_KEY = 'cyber:threats:v1'; +const REDIS_CACHE_TTL = 900; // 15 min — threat feeds update infrequently + +export async function listCyberThreats( + _ctx: ServerContext, + req: ListCyberThreatsRequest, +): Promise { + try { + const now = Date.now(); + + const cacheKey = `${REDIS_CACHE_KEY}:${req.pagination?.pageSize || 0}:${req.timeRange?.start || 0}:${req.type || ''}:${req.source || ''}:${req.minSeverity || ''}`; + const pageSize = clampInt(req.pagination?.pageSize, DEFAULT_LIMIT, 1, MAX_LIMIT); + + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + // Derive days from timeRange or use default + let days = DEFAULT_DAYS; + if (req.timeRange?.start) { + days = clampInt( + Math.ceil((now - req.timeRange.start) / (24 * 60 * 60 * 1000)), + DEFAULT_DAYS, 1, MAX_DAYS, + ); + } + const cutoffMs = now - days * 24 * 60 * 60 * 1000; + + // Fetch all sources in parallel + const [feodo, urlhaus, c2intel, otx, abuseipdb] = await Promise.all([ + fetchFeodoSource(pageSize, cutoffMs), + fetchUrlhausSource(pageSize, cutoffMs), + fetchC2IntelSource(pageSize), + fetchOtxSource(pageSize, days), + fetchAbuseIpDbSource(pageSize), + ]); + + const anySucceeded = feodo.ok || urlhaus.ok || c2intel.ok || otx.ok || abuseipdb.ok; + if (!anySucceeded) return null; + + // Merge, deduplicate, hydrate coordinates + const combined = dedupeThreats([ + ...feodo.threats, + ...urlhaus.threats, + ...c2intel.threats, + ...otx.threats, + ...abuseipdb.threats, + ]); + + const hydrated = await hydrateThreatCoordinates(combined); + + // Filter to only threats with valid coordinates + let results = hydrated + .filter((t) => t.lat !== null && t.lon !== null && t.lat >= -90 && t.lat <= 90 && t.lon >= -180 && t.lon <= 180); + + // Apply optional filters BEFORE sorting + slicing (C-2 fix) + if (req.type && req.type !== 'CYBER_THREAT_TYPE_UNSPECIFIED') { + const filterType = req.type; + results = results.filter((t) => THREAT_TYPE_MAP[t.type] === filterType); + } + if (req.source && req.source !== 'CYBER_THREAT_SOURCE_UNSPECIFIED') { + const filterSource = req.source; + results = results.filter((t) => SOURCE_MAP[t.source] === filterSource); + } + if (req.minSeverity && req.minSeverity !== 'CRITICALITY_LEVEL_UNSPECIFIED') { + const minRank = SEVERITY_RANK[req.minSeverity] || 0; + results = results.filter((t) => (SEVERITY_RANK[SEVERITY_MAP[t.severity] || ''] || 0) >= minRank); + } + + // Sort by severity then recency, then apply page size limit + results = results + .sort((a, b) => { + const bySeverity = (SEVERITY_RANK[SEVERITY_MAP[b.severity] || ''] || 0) + - (SEVERITY_RANK[SEVERITY_MAP[a.severity] || ''] || 0); + if (bySeverity !== 0) return bySeverity; + return (b.lastSeen || b.firstSeen) - (a.lastSeen || a.firstSeen); + }) + .slice(0, pageSize); + + const threats = results.map(toProtoCyberThreat); + return threats.length > 0 ? { threats, pagination: undefined } : null; + }); + + return result || { threats: [], pagination: undefined }; + } catch { + return { threats: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/displacement/v1/get-displacement-summary.ts b/server/worldmonitor/displacement/v1/get-displacement-summary.ts new file mode 100644 index 000000000..49e6f1e83 --- /dev/null +++ b/server/worldmonitor/displacement/v1/get-displacement-summary.ts @@ -0,0 +1,340 @@ +/** + * GetDisplacementSummary RPC -- paginates through the UNHCR Population API, + * aggregates raw records into per-country displacement metrics from origin and + * asylum perspectives, computes refugee flow corridors, and attaches geographic + * coordinates from hardcoded centroids. + */ + +import type { + ServerContext, + GetDisplacementSummaryRequest, + GetDisplacementSummaryResponse, + GeoCoordinates, +} from '../../../../src/generated/server/worldmonitor/displacement/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'displacement:summary:v1'; +const REDIS_CACHE_TTL = 43200; // 12 hr — annual UNHCR data, very slow-moving + +// ---------- Country centroids (ISO3 -> [lat, lon]) ---------- + +const COUNTRY_CENTROIDS: Record = { + AFG: [33.9, 67.7], SYR: [35.0, 38.0], UKR: [48.4, 31.2], SDN: [15.5, 32.5], + SSD: [6.9, 31.3], SOM: [5.2, 46.2], COD: [-4.0, 21.8], MMR: [19.8, 96.7], + YEM: [15.6, 48.5], ETH: [9.1, 40.5], VEN: [6.4, -66.6], IRQ: [33.2, 43.7], + COL: [4.6, -74.1], NGA: [9.1, 7.5], PSE: [31.9, 35.2], TUR: [39.9, 32.9], + DEU: [51.2, 10.4], PAK: [30.4, 69.3], UGA: [1.4, 32.3], BGD: [23.7, 90.4], + KEN: [0.0, 38.0], TCD: [15.5, 19.0], JOR: [31.0, 36.0], LBN: [33.9, 35.5], + EGY: [26.8, 30.8], IRN: [32.4, 53.7], TZA: [-6.4, 34.9], RWA: [-1.9, 29.9], + CMR: [7.4, 12.4], MLI: [17.6, -4.0], BFA: [12.3, -1.6], NER: [17.6, 8.1], + CAF: [6.6, 20.9], MOZ: [-18.7, 35.5], USA: [37.1, -95.7], FRA: [46.2, 2.2], + GBR: [55.4, -3.4], IND: [20.6, 79.0], CHN: [35.9, 104.2], RUS: [61.5, 105.3], +}; + +// ---------- Internal UNHCR API types ---------- + +interface UnhcrRawItem { + coo_iso?: string; + coo_name?: string; + coa_iso?: string; + coa_name?: string; + refugees?: number; + asylum_seekers?: number; + idps?: number; + stateless?: number; +} + +// ---------- Helpers ---------- + +/** Paginate through all UNHCR Population API pages for a given year. */ +async function fetchUnhcrYearItems(year: number): Promise { + const limit = 10000; + const maxPageGuard = 25; + const items: UnhcrRawItem[] = []; + + for (let page = 1; page <= maxPageGuard; page++) { + const response = await fetch( + `https://api.unhcr.org/population/v1/population/?year=${year}&limit=${limit}&page=${page}`, + { headers: { Accept: 'application/json', 'User-Agent': CHROME_UA } }, + ); + + if (!response.ok) return null; + + const data = await response.json(); + const pageItems: UnhcrRawItem[] = Array.isArray(data.items) ? data.items : []; + if (pageItems.length === 0) break; + items.push(...pageItems); + + const maxPages = Number(data.maxPages); + if (Number.isFinite(maxPages) && maxPages > 0) { + if (page >= maxPages) break; + continue; + } + + if (pageItems.length < limit) break; + } + + return items; +} + +/** Look up centroid coordinates for an ISO3 country code. */ +function getCoordinates(code: string): GeoCoordinates | undefined { + const centroid = COUNTRY_CENTROIDS[code]; + if (!centroid) return undefined; + return { latitude: centroid[0], longitude: centroid[1] }; +} + +// ---------- Aggregation types ---------- + +interface OriginAgg { + name: string; + refugees: number; + asylumSeekers: number; + idps: number; + stateless: number; +} + +interface AsylumAgg { + name: string; + refugees: number; + asylumSeekers: number; +} + +interface FlowAgg { + originCode: string; + originName: string; + asylumCode: string; + asylumName: string; + refugees: number; +} + +interface MergedCountry { + code: string; + name: string; + refugees: number; + asylumSeekers: number; + idps: number; + stateless: number; + totalDisplaced: number; + hostRefugees: number; + hostAsylumSeekers: number; + hostTotal: number; +} + +// ---------- RPC handler ---------- + +export async function getDisplacementSummary( + _ctx: ServerContext, + req: GetDisplacementSummaryRequest, +): Promise { + const emptyResponse: GetDisplacementSummaryResponse = { + summary: { + year: req.year > 0 ? req.year : new Date().getFullYear(), + globalTotals: { refugees: 0, asylumSeekers: 0, idps: 0, stateless: 0, total: 0 }, + countries: [], + topFlows: [], + }, + }; + + try { + // Redis shared cache (keyed by year) + const year = req.year > 0 ? req.year : new Date().getFullYear(); + const cacheKey = `${REDIS_CACHE_KEY}:${year}:${req.countryLimit || 0}:${req.flowLimit || 0}`; + + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + // 1. Determine year with fallback + const currentYear = new Date().getFullYear(); + const requestYear = req.year > 0 ? req.year : 0; + let rawItems: UnhcrRawItem[] = []; + let dataYearUsed = currentYear; + + if (requestYear > 0) { + const items = await fetchUnhcrYearItems(requestYear); + if (items && items.length > 0) { + rawItems = items; + dataYearUsed = requestYear; + } + } else { + for (let y = currentYear; y >= currentYear - 2; y--) { + const items = await fetchUnhcrYearItems(y); + if (!items) continue; + if (items.length > 0) { + rawItems = items; + dataYearUsed = y; + break; + } + } + } + + if (rawItems.length === 0) return null; + + // 2. Aggregate by origin and asylum + const byOrigin: Record = {}; + const byAsylum: Record = {}; + const flowMap: Record = {}; + let totalRefugees = 0; + let totalAsylumSeekers = 0; + let totalIdps = 0; + let totalStateless = 0; + + for (const item of rawItems) { + const originCode = item.coo_iso || ''; + const asylumCode = item.coa_iso || ''; + const refugees = Number(item.refugees) || 0; + const asylumSeekers = Number(item.asylum_seekers) || 0; + const idps = Number(item.idps) || 0; + const stateless = Number(item.stateless) || 0; + + totalRefugees += refugees; + totalAsylumSeekers += asylumSeekers; + totalIdps += idps; + totalStateless += stateless; + + if (originCode) { + if (!byOrigin[originCode]) { + byOrigin[originCode] = { + name: item.coo_name || originCode, + refugees: 0, asylumSeekers: 0, idps: 0, stateless: 0, + }; + } + byOrigin[originCode].refugees += refugees; + byOrigin[originCode].asylumSeekers += asylumSeekers; + byOrigin[originCode].idps += idps; + byOrigin[originCode].stateless += stateless; + } + + if (asylumCode) { + if (!byAsylum[asylumCode]) { + byAsylum[asylumCode] = { + name: item.coa_name || asylumCode, + refugees: 0, asylumSeekers: 0, + }; + } + byAsylum[asylumCode].refugees += refugees; + byAsylum[asylumCode].asylumSeekers += asylumSeekers; + } + + if (originCode && asylumCode && refugees > 0) { + const flowKey = `${originCode}->${asylumCode}`; + if (!flowMap[flowKey]) { + flowMap[flowKey] = { + originCode, + originName: item.coo_name || originCode, + asylumCode, + asylumName: item.coa_name || asylumCode, + refugees: 0, + }; + } + flowMap[flowKey].refugees += refugees; + } + } + + // 3. Merge into unified country records + const countries: Record = {}; + + for (const [code, data] of Object.entries(byOrigin)) { + countries[code] = { + code, + name: data.name, + refugees: data.refugees, + asylumSeekers: data.asylumSeekers, + idps: data.idps, + stateless: data.stateless, + totalDisplaced: data.refugees + data.asylumSeekers + data.idps + data.stateless, + hostRefugees: 0, + hostAsylumSeekers: 0, + hostTotal: 0, + }; + } + + for (const [code, data] of Object.entries(byAsylum)) { + const hostRefugees = data.refugees; + const hostAsylumSeekers = data.asylumSeekers; + const hostTotal = hostRefugees + hostAsylumSeekers; + + if (!countries[code]) { + countries[code] = { + code, + name: data.name, + refugees: 0, + asylumSeekers: 0, + idps: 0, + stateless: 0, + totalDisplaced: 0, + hostRefugees, + hostAsylumSeekers, + hostTotal, + }; + } else { + countries[code].hostRefugees = hostRefugees; + countries[code].hostAsylumSeekers = hostAsylumSeekers; + countries[code].hostTotal = hostTotal; + } + } + + // 4. Sort countries by max(totalDisplaced, hostTotal) descending + const sortedCountries = Object.values(countries).sort((a, b) => { + const aSize = Math.max(a.totalDisplaced, a.hostTotal); + const bSize = Math.max(b.totalDisplaced, b.hostTotal); + return bSize - aSize; + }); + + // 5. Apply countryLimit + const limitedCountries = req.countryLimit > 0 + ? sortedCountries.slice(0, req.countryLimit) + : sortedCountries; + + // 6. Build proto-shaped countries with GeoCoordinates + const protoCountries = limitedCountries.map((d) => ({ + code: d.code, + name: d.name, + refugees: d.refugees, + asylumSeekers: d.asylumSeekers, + idps: d.idps, + stateless: d.stateless, + totalDisplaced: d.totalDisplaced, + hostRefugees: d.hostRefugees, + hostAsylumSeekers: d.hostAsylumSeekers, + hostTotal: d.hostTotal, + location: getCoordinates(d.code), + })); + + // 7. Build flows sorted by refugees descending, capped by flowLimit + const flowLimit = req.flowLimit > 0 ? req.flowLimit : 50; + const protoFlows = Object.values(flowMap) + .sort((a, b) => b.refugees - a.refugees) + .slice(0, flowLimit) + .map((f) => ({ + originCode: f.originCode, + originName: f.originName, + asylumCode: f.asylumCode, + asylumName: f.asylumName, + refugees: f.refugees, + originLocation: getCoordinates(f.originCode), + asylumLocation: getCoordinates(f.asylumCode), + })); + + return { + summary: { + year: dataYearUsed, + globalTotals: { + refugees: totalRefugees, + asylumSeekers: totalAsylumSeekers, + idps: totalIdps, + stateless: totalStateless, + total: totalRefugees + totalAsylumSeekers + totalIdps + totalStateless, + }, + countries: protoCountries, + topFlows: protoFlows, + }, + }; + }); + + return result || emptyResponse; + } catch { + // Graceful degradation: return empty summary on ANY failure + return emptyResponse; + } +} diff --git a/server/worldmonitor/displacement/v1/get-population-exposure.ts b/server/worldmonitor/displacement/v1/get-population-exposure.ts new file mode 100644 index 000000000..424cf7244 --- /dev/null +++ b/server/worldmonitor/displacement/v1/get-population-exposure.ts @@ -0,0 +1,94 @@ +/** + * GetPopulationExposure RPC -- provides population data for priority countries + * and computes population exposure estimates within a given radius of a + * geographic point using population density approximations. + */ + +import type { + ServerContext, + GetPopulationExposureRequest, + GetPopulationExposureResponse, + CountryPopulationEntry, +} from '../../../../src/generated/server/worldmonitor/displacement/v1/service_server'; + +// ---------- Population exposure data ---------- + +const PRIORITY_COUNTRIES: Record = { + UKR: { name: 'Ukraine', pop: 37000000, area: 603550 }, + RUS: { name: 'Russia', pop: 144100000, area: 17098242 }, + ISR: { name: 'Israel', pop: 9800000, area: 22072 }, + PSE: { name: 'Palestine', pop: 5400000, area: 6020 }, + SYR: { name: 'Syria', pop: 22100000, area: 185180 }, + IRN: { name: 'Iran', pop: 88600000, area: 1648195 }, + TWN: { name: 'Taiwan', pop: 23600000, area: 36193 }, + ETH: { name: 'Ethiopia', pop: 126500000, area: 1104300 }, + SDN: { name: 'Sudan', pop: 48100000, area: 1861484 }, + SSD: { name: 'South Sudan', pop: 11400000, area: 619745 }, + SOM: { name: 'Somalia', pop: 18100000, area: 637657 }, + YEM: { name: 'Yemen', pop: 34400000, area: 527968 }, + AFG: { name: 'Afghanistan', pop: 42200000, area: 652230 }, + PAK: { name: 'Pakistan', pop: 240500000, area: 881913 }, + IND: { name: 'India', pop: 1428600000, area: 3287263 }, + MMR: { name: 'Myanmar', pop: 54200000, area: 676578 }, + COD: { name: 'DR Congo', pop: 102300000, area: 2344858 }, + NGA: { name: 'Nigeria', pop: 223800000, area: 923768 }, + MLI: { name: 'Mali', pop: 22600000, area: 1240192 }, + BFA: { name: 'Burkina Faso', pop: 22700000, area: 274200 }, +}; + +const EXPOSURE_CENTROIDS: Record = { + UKR: [48.4, 31.2], RUS: [61.5, 105.3], ISR: [31.0, 34.8], PSE: [31.9, 35.2], + SYR: [35.0, 38.0], IRN: [32.4, 53.7], TWN: [23.7, 121.0], ETH: [9.1, 40.5], + SDN: [15.5, 32.5], SSD: [6.9, 31.3], SOM: [5.2, 46.2], YEM: [15.6, 48.5], + AFG: [33.9, 67.7], PAK: [30.4, 69.3], IND: [20.6, 79.0], MMR: [19.8, 96.7], + COD: [-4.0, 21.8], NGA: [9.1, 7.5], MLI: [17.6, -4.0], BFA: [12.3, -1.6], +}; + +// ---------- RPC handler ---------- + +export async function getPopulationExposure( + _ctx: ServerContext, + req: GetPopulationExposureRequest, +): Promise { + if (req.mode === 'exposure') { + const { lat, lon } = req; + const radius = req.radius || 50; + + let bestMatch: string | null = null; + let bestDist = Infinity; + + for (const [code, [cLat, cLon]] of Object.entries(EXPOSURE_CENTROIDS)) { + const dist = Math.sqrt(Math.pow(lat - cLat, 2) + Math.pow(lon - cLon, 2)); + if (dist < bestDist) { + bestDist = dist; + bestMatch = code; + } + } + + const info = bestMatch ? PRIORITY_COUNTRIES[bestMatch]! : { pop: 50000000, area: 500000 }; + const density = info.pop / info.area; + const areaKm2 = Math.PI * radius * radius; + const exposed = Math.round(density * areaKm2); + + return { + success: true, + countries: [], + exposure: { + exposedPopulation: exposed, + exposureRadiusKm: radius, + nearestCountry: bestMatch || '', + densityPerKm2: Math.round(density), + }, + }; + } + + // Default: countries mode + const countries: CountryPopulationEntry[] = Object.entries(PRIORITY_COUNTRIES).map(([code, info]) => ({ + code, + name: info.name, + population: info.pop, + densityPerKm2: Math.round(info.pop / info.area), + })); + + return { success: true, countries }; +} diff --git a/server/worldmonitor/displacement/v1/handler.ts b/server/worldmonitor/displacement/v1/handler.ts new file mode 100644 index 000000000..ed3e93d95 --- /dev/null +++ b/server/worldmonitor/displacement/v1/handler.ts @@ -0,0 +1,9 @@ +import type { DisplacementServiceHandler } from '../../../../src/generated/server/worldmonitor/displacement/v1/service_server'; + +import { getDisplacementSummary } from './get-displacement-summary'; +import { getPopulationExposure } from './get-population-exposure'; + +export const displacementHandler: DisplacementServiceHandler = { + getDisplacementSummary, + getPopulationExposure, +}; diff --git a/server/worldmonitor/economic/v1/_bis-shared.ts b/server/worldmonitor/economic/v1/_bis-shared.ts new file mode 100644 index 000000000..148eed88e --- /dev/null +++ b/server/worldmonitor/economic/v1/_bis-shared.ts @@ -0,0 +1,66 @@ +/** + * Shared BIS (Bank for International Settlements) CSV fetch + parse helpers. + * Used by all 3 BIS RPC handlers. + */ + +import { CHROME_UA } from '../../../_shared/constants'; +import Papa from 'papaparse'; + +const BIS_BASE = 'https://stats.bis.org/api/v1/data'; + +// Curated BIS country codes — aligned with CENTRAL_BANKS in finance-geo.ts +// BIS uses ISO 2-letter codes except XM for Euro Area (maps from DE in finance-geo) +export const BIS_COUNTRIES: Record = { + US: { name: 'United States', centralBank: 'Federal Reserve' }, + GB: { name: 'United Kingdom', centralBank: 'Bank of England' }, + JP: { name: 'Japan', centralBank: 'Bank of Japan' }, + XM: { name: 'Euro Area', centralBank: 'ECB' }, + CH: { name: 'Switzerland', centralBank: 'Swiss National Bank' }, + SG: { name: 'Singapore', centralBank: 'MAS' }, + IN: { name: 'India', centralBank: 'Reserve Bank of India' }, + AU: { name: 'Australia', centralBank: 'RBA' }, + CN: { name: 'China', centralBank: "People's Bank of China" }, + CA: { name: 'Canada', centralBank: 'Bank of Canada' }, + KR: { name: 'South Korea', centralBank: 'Bank of Korea' }, + BR: { name: 'Brazil', centralBank: 'Banco Central do Brasil' }, +}; + +export const BIS_COUNTRY_KEYS = Object.keys(BIS_COUNTRIES).join('+'); + +export async function fetchBisCSV(dataset: string, key: string, timeout = 12000): Promise { + const separator = key.includes('?') ? '&' : '?'; + const url = `${BIS_BASE}/${dataset}/${key}${separator}format=csv`; + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(url, { + headers: { 'User-Agent': CHROME_UA, Accept: 'text/csv' }, + signal: controller.signal, + }); + if (!res.ok) throw new Error(`BIS HTTP ${res.status}`); + return await res.text(); + } finally { + clearTimeout(id); + } +} + +// Parse BIS CSV using papaparse — robust handling of quoted fields & metadata +export function parseBisCSV(csv: string): Array> { + const result = Papa.parse>(csv, { + header: true, + skipEmptyLines: true, + dynamicTyping: false, // keep as strings, parse numbers explicitly + }); + if (result.errors.length > 0) { + console.warn('[BIS] CSV parse errors:', result.errors.slice(0, 3)); + if (result.data.length === 0) return []; + } + return result.data; +} + +// Safe numeric parse — BIS uses '.' or empty for missing values +export function parseBisNumber(val: string | undefined): number | null { + if (!val || val === '.' || val.trim() === '') return null; + const n = Number(val); + return Number.isFinite(n) ? n : null; +} diff --git a/server/worldmonitor/economic/v1/_shared.ts b/server/worldmonitor/economic/v1/_shared.ts new file mode 100644 index 000000000..6c81bf822 --- /dev/null +++ b/server/worldmonitor/economic/v1/_shared.ts @@ -0,0 +1,88 @@ +/** + * Shared helpers for the economic domain RPCs. + */ + +import { CHROME_UA, yahooGate } from '../../../_shared/constants'; + +/** + * Fetch JSON from a URL with a configurable timeout. + * Rejects on non-2xx status. + */ +export async function fetchJSON(url: string, timeout = 8000): Promise { + if (url.includes('yahoo.com')) await yahooGate(); + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(url, { headers: { 'User-Agent': CHROME_UA }, signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(id); + } +} + +/** + * Rate of change between the most recent price and the price `days` ago. + * Returns null if there is insufficient data. + */ +export function rateOfChange(prices: number[], days: number): number | null { + if (!prices || prices.length < days + 1) return null; + const recent = prices[prices.length - 1]; + const past = prices[prices.length - 1 - days]; + if (!past || past === 0) return null; + return ((recent! - past) / past) * 100; +} + +/** + * Simple moving average over the last `period` entries. + */ +export function smaCalc(prices: number[], period: number): number | null { + if (!prices || prices.length < period) return null; + const slice = prices.slice(-period); + return slice.reduce((a, b) => a + b, 0) / period; +} + +/** + * Extract closing prices from a Yahoo Finance v8 chart response. + */ +export function extractClosePrices(chart: any): number[] { + try { + const result = chart?.chart?.result?.[0]; + return result?.indicators?.quote?.[0]?.close?.filter((p: any) => p != null) || []; + } catch { + return []; + } +} + +/** + * Extract volumes from a Yahoo Finance v8 chart response. + */ +export function extractVolumes(chart: any): number[] { + try { + const result = chart?.chart?.result?.[0]; + return result?.indicators?.quote?.[0]?.volume?.filter((v: any) => v != null) || []; + } catch { + return []; + } +} + +/** + * Extract aligned price/volume pairs from a Yahoo Finance v8 chart response. + * Only includes entries where both price and volume are non-null. + */ +export function extractAlignedPriceVolume(chart: any): Array<{ price: number; volume: number }> { + try { + const result = chart?.chart?.result?.[0]; + const closes: any[] = result?.indicators?.quote?.[0]?.close || []; + const volumes: any[] = result?.indicators?.quote?.[0]?.volume || []; + const pairs: Array<{ price: number; volume: number }> = []; + for (let i = 0; i < closes.length; i++) { + if (closes[i] != null && volumes[i] != null) { + pairs.push({ price: closes[i], volume: volumes[i] }); + } + } + return pairs; + } catch { + return []; + } +} diff --git a/server/worldmonitor/economic/v1/get-bis-credit.ts b/server/worldmonitor/economic/v1/get-bis-credit.ts new file mode 100644 index 000000000..4c418ef73 --- /dev/null +++ b/server/worldmonitor/economic/v1/get-bis-credit.ts @@ -0,0 +1,72 @@ +/** + * RPC: getBisCredit -- BIS SDMX API (WS_TC) + * Total credit-to-GDP ratio for major economies. + */ + +import type { + ServerContext, + GetBisCreditRequest, + GetBisCreditResponse, + BisCreditToGdp, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { fetchBisCSV, parseBisCSV, parseBisNumber, BIS_COUNTRIES, BIS_COUNTRY_KEYS } from './_bis-shared'; + +const REDIS_CACHE_KEY = 'economic:bis:credit:v1'; +const REDIS_CACHE_TTL = 43200; // 12 hours — quarterly data + +export async function getBisCredit( + _ctx: ServerContext, + _req: GetBisCreditRequest, +): Promise { + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + // Single batched request with .770.A suffix for % of GDP ratio (adjusted) + const twoYearsAgo = new Date(); + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2); + const startPeriod = `${twoYearsAgo.getFullYear()}-Q1`; + + const csv = await fetchBisCSV('WS_TC', `Q.${BIS_COUNTRY_KEYS}.C.A.M.770.A?startPeriod=${startPeriod}&detail=dataonly`); + const rows = parseBisCSV(csv); + + // Group by country, take last 2 observations + const byCountry = new Map>(); + for (const row of rows) { + const cc = row['REF_AREA'] || row['Reference area'] || ''; + const date = row['TIME_PERIOD'] || row['Time period'] || ''; + const val = parseBisNumber(row['OBS_VALUE'] || row['Observation value']); + if (!cc || !date || val === null) continue; + if (!byCountry.has(cc)) byCountry.set(cc, []); + byCountry.get(cc)!.push({ date, value: val }); + } + + const entries: BisCreditToGdp[] = []; + for (const [cc, obs] of byCountry) { + const info = BIS_COUNTRIES[cc]; + if (!info) continue; + + // Sort chronologically and take last 2 + obs.sort((a, b) => a.date.localeCompare(b.date)); + const latest = obs[obs.length - 1]; + const previous = obs.length >= 2 ? obs[obs.length - 2] : undefined; + + if (latest) { + entries.push({ + countryCode: cc, + countryName: info.name, + creditGdpRatio: Math.round(latest.value * 10) / 10, + previousRatio: previous ? Math.round(previous.value * 10) / 10 : Math.round(latest.value * 10) / 10, + date: latest.date, + }); + } + } + + return entries.length > 0 ? { entries } : null; + }); + return result || { entries: [] }; + } catch (e) { + console.error('[BIS] Credit-to-GDP fetch failed:', e); + return { entries: [] }; + } +} diff --git a/server/worldmonitor/economic/v1/get-bis-exchange-rates.ts b/server/worldmonitor/economic/v1/get-bis-exchange-rates.ts new file mode 100644 index 000000000..05fadcbb2 --- /dev/null +++ b/server/worldmonitor/economic/v1/get-bis-exchange-rates.ts @@ -0,0 +1,76 @@ +/** + * RPC: getBisExchangeRates -- BIS SDMX API (WS_EER) + * Effective exchange rate indices (real + nominal) for major economies. + */ + +import type { + ServerContext, + GetBisExchangeRatesRequest, + GetBisExchangeRatesResponse, + BisExchangeRate, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { fetchBisCSV, parseBisCSV, parseBisNumber, BIS_COUNTRIES, BIS_COUNTRY_KEYS } from './_bis-shared'; + +const REDIS_CACHE_KEY = 'economic:bis:eer:v1'; +const REDIS_CACHE_TTL = 21600; // 6 hours — monthly data + +export async function getBisExchangeRates( + _ctx: ServerContext, + _req: GetBisExchangeRatesRequest, +): Promise { + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + // Single batched request: R=Real only (nominal is not displayed), B=Broad basket + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + const startPeriod = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`; + + const csv = await fetchBisCSV('WS_EER', `M.R.B.${BIS_COUNTRY_KEYS}?startPeriod=${startPeriod}&detail=dataonly`); + const rows = parseBisCSV(csv); + + // Group by country, take last 2 observations for change calculation + const byCountry = new Map>(); + for (const row of rows) { + const cc = row['REF_AREA'] || row['Reference area'] || ''; + const date = row['TIME_PERIOD'] || row['Time period'] || ''; + const val = parseBisNumber(row['OBS_VALUE'] || row['Observation value']); + if (!cc || !date || val === null) continue; + if (!byCountry.has(cc)) byCountry.set(cc, []); + byCountry.get(cc)!.push({ date, value: val }); + } + + const rates: BisExchangeRate[] = []; + for (const [cc, obs] of byCountry) { + const info = BIS_COUNTRIES[cc]; + if (!info) continue; + + obs.sort((a, b) => a.date.localeCompare(b.date)); + const latest = obs[obs.length - 1]; + const prev = obs.length >= 2 ? obs[obs.length - 2] : undefined; + + if (latest) { + const realChange = prev + ? Math.round(((latest.value - prev.value) / prev.value) * 1000) / 10 + : 0; + + rates.push({ + countryCode: cc, + countryName: info.name, + realEer: Math.round(latest.value * 100) / 100, + nominalEer: 0, + realChange, + date: latest.date, + }); + } + } + + return rates.length > 0 ? { rates } : null; + }); + return result || { rates: [] }; + } catch (e) { + console.error('[BIS] Exchange rates fetch failed:', e); + return { rates: [] }; + } +} diff --git a/server/worldmonitor/economic/v1/get-bis-policy-rates.ts b/server/worldmonitor/economic/v1/get-bis-policy-rates.ts new file mode 100644 index 000000000..22655efae --- /dev/null +++ b/server/worldmonitor/economic/v1/get-bis-policy-rates.ts @@ -0,0 +1,73 @@ +/** + * RPC: getBisPolicyRates -- BIS SDMX API (WS_CBPOL) + * Central bank policy rates for major economies. + */ + +import type { + ServerContext, + GetBisPolicyRatesRequest, + GetBisPolicyRatesResponse, + BisPolicyRate, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { fetchBisCSV, parseBisCSV, parseBisNumber, BIS_COUNTRIES, BIS_COUNTRY_KEYS } from './_bis-shared'; + +const REDIS_CACHE_KEY = 'economic:bis:policy:v1'; +const REDIS_CACHE_TTL = 21600; // 6 hours — monthly data + +export async function getBisPolicyRates( + _ctx: ServerContext, + _req: GetBisPolicyRatesRequest, +): Promise { + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + // Single batched request: all countries in one +-delimited key + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + const startPeriod = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`; + + const csv = await fetchBisCSV('WS_CBPOL', `M.${BIS_COUNTRY_KEYS}?startPeriod=${startPeriod}&detail=dataonly`); + const rows = parseBisCSV(csv); + + // Group rows by country, take last 2 observations + const byCountry = new Map>(); + for (const row of rows) { + const cc = row['REF_AREA'] || row['Reference area'] || ''; + const date = row['TIME_PERIOD'] || row['Time period'] || ''; + const val = parseBisNumber(row['OBS_VALUE'] || row['Observation value']); + if (!cc || !date || val === null) continue; + if (!byCountry.has(cc)) byCountry.set(cc, []); + byCountry.get(cc)!.push({ date, value: val }); + } + + const rates: BisPolicyRate[] = []; + for (const [cc, obs] of byCountry) { + const info = BIS_COUNTRIES[cc]; + if (!info) continue; + + // Sort chronologically and take last 2 + obs.sort((a, b) => a.date.localeCompare(b.date)); + const latest = obs[obs.length - 1]; + const previous = obs.length >= 2 ? obs[obs.length - 2] : undefined; + + if (latest) { + rates.push({ + countryCode: cc, + countryName: info.name, + rate: latest.value, + previousRate: previous?.value ?? latest.value, + date: latest.date, + centralBank: info.centralBank, + }); + } + } + + return rates.length > 0 ? { rates } : null; + }); + return result || { rates: [] }; + } catch (e) { + console.error('[BIS] Policy rates fetch failed:', e); + return { rates: [] }; + } +} diff --git a/server/worldmonitor/economic/v1/get-energy-capacity.ts b/server/worldmonitor/economic/v1/get-energy-capacity.ts new file mode 100644 index 000000000..ae0b90598 --- /dev/null +++ b/server/worldmonitor/economic/v1/get-energy-capacity.ts @@ -0,0 +1,181 @@ +/** + * RPC: getEnergyCapacity -- EIA Open Data API v2 + * Installed generation capacity data (solar, wind, coal) aggregated to US national totals. + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetEnergyCapacityRequest, + GetEnergyCapacityResponse, + EnergyCapacitySeries, + EnergyCapacityYear, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:capacity:v1'; +const REDIS_CACHE_TTL = 86400; // 24h — annual data barely changes +const DEFAULT_YEARS = 20; + +interface CapacitySource { + code: string; + name: string; +} + +const EIA_CAPACITY_SOURCES: CapacitySource[] = [ + { code: 'SUN', name: 'Solar' }, + { code: 'WND', name: 'Wind' }, + { code: 'COL', name: 'Coal' }, +]; + +// Coal sub-type codes used when the aggregate COL code returns no data +const COAL_SUBTYPES = ['BIT', 'SUB', 'LIG', 'RC']; + +interface EiaCapabilityRow { + period?: string; + stateid?: string; + capability?: number; + 'capability-units'?: string; +} + +/** + * Fetch installed generation capacity from EIA state electricity profiles. + * Returns a Map of year -> total US capacity in MW for the given source code. + */ +async function fetchCapacityForSource( + sourceCode: string, + apiKey: string, + startYear: number, +): Promise> { + const params = new URLSearchParams({ + api_key: apiKey, + 'data[]': 'capability', + frequency: 'annual', + 'facets[energysourceid][]': sourceCode, + 'sort[0][column]': 'period', + 'sort[0][direction]': 'desc', + length: '5000', + start: String(startYear), + }); + + const url = `https://api.eia.gov/v2/electricity/state-electricity-profiles/capability/data/?${params}`; + const response = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) return new Map(); + + const data = await response.json() as { + response?: { data?: EiaCapabilityRow[] }; + }; + + const rows = data.response?.data; + if (!rows || rows.length === 0) return new Map(); + + // Aggregate state-level data to national totals by year + const yearTotals = new Map(); + for (const row of rows) { + if (row.period == null || row.capability == null) continue; + const year = parseInt(row.period, 10); + if (isNaN(year)) continue; + const mw = typeof row.capability === 'number' ? row.capability : parseFloat(String(row.capability)); + if (!Number.isFinite(mw)) continue; + yearTotals.set(year, (yearTotals.get(year) ?? 0) + mw); + } + + return yearTotals; +} + +/** + * Fetch coal capacity with fallback to specific sub-type codes. + * EIA capability endpoint may use BIT/SUB/LIG/RC instead of aggregate COL. + */ +async function fetchCoalCapacity( + apiKey: string, + startYear: number, +): Promise> { + // Try aggregate COL first + const colResult = await fetchCapacityForSource('COL', apiKey, startYear); + if (colResult.size > 0) return colResult; + + // Fallback: fetch individual coal sub-types and merge + const subResults = await Promise.all( + COAL_SUBTYPES.map(code => fetchCapacityForSource(code, apiKey, startYear)), + ); + + const merged = new Map(); + for (const subMap of subResults) { + for (const [year, mw] of subMap) { + merged.set(year, (merged.get(year) ?? 0) + mw); + } + } + + return merged; +} + +export async function getEnergyCapacity( + _ctx: ServerContext, + req: GetEnergyCapacityRequest, +): Promise { + try { + const apiKey = process.env.EIA_API_KEY; + if (!apiKey) return { series: [] }; + + const years = req.years > 0 ? req.years : DEFAULT_YEARS; + const currentYear = new Date().getFullYear(); + const startYear = currentYear - years; + + // Determine which sources to fetch + const requestedSources = req.energySources.length > 0 + ? EIA_CAPACITY_SOURCES.filter(s => req.energySources.includes(s.code)) + : EIA_CAPACITY_SOURCES; + + if (requestedSources.length === 0) return { series: [] }; + + // Build cache key from sorted source list + years + const sourceKey = requestedSources.map(s => s.code).sort().join(','); + const cacheKey = `${REDIS_CACHE_KEY}:${sourceKey}:${years}`; + + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + // Fetch capacity for each source + const seriesResults: EnergyCapacitySeries[] = []; + + for (const source of requestedSources) { + try { + const yearTotals = source.code === 'COL' + ? await fetchCoalCapacity(apiKey, startYear) + : await fetchCapacityForSource(source.code, apiKey, startYear); + + // Convert to sorted array (oldest first) + const dataPoints: EnergyCapacityYear[] = Array.from(yearTotals.entries()) + .sort(([a], [b]) => a - b) + .map(([year, mw]) => ({ year, capacityMw: mw })); + + seriesResults.push({ + energySource: source.code, + name: source.name, + data: dataPoints, + }); + } catch { + // Individual source failure: include empty series + seriesResults.push({ + energySource: source.code, + name: source.name, + data: [], + }); + } + } + + const hasData = seriesResults.some(s => s.data.length > 0); + return hasData ? { series: seriesResults } : null; + }); + + return result || { series: [] }; + } catch { + return { series: [] }; + } +} diff --git a/server/worldmonitor/economic/v1/get-energy-prices.ts b/server/worldmonitor/economic/v1/get-energy-prices.ts new file mode 100644 index 000000000..f94251ce6 --- /dev/null +++ b/server/worldmonitor/economic/v1/get-energy-prices.ts @@ -0,0 +1,122 @@ +/** + * RPC: getEnergyPrices -- EIA Open Data API v2 + * Energy commodity price data (WTI, Brent, etc.) + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetEnergyPricesRequest, + GetEnergyPricesResponse, + EnergyPrice, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:energy:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — weekly EIA data + +interface EiaSeriesConfig { + commodity: string; + name: string; + unit: string; + apiPath: string; + seriesFacet: string; +} + +const EIA_SERIES: EiaSeriesConfig[] = [ + { + commodity: 'wti', + name: 'WTI Crude Oil', + unit: '$/barrel', + apiPath: '/v2/petroleum/pri/spt/data/', + seriesFacet: 'RWTC', + }, + { + commodity: 'brent', + name: 'Brent Crude Oil', + unit: '$/barrel', + apiPath: '/v2/petroleum/pri/spt/data/', + seriesFacet: 'RBRTE', + }, +]; + +async function fetchEiaSeries( + config: EiaSeriesConfig, + apiKey: string, +): Promise { + try { + const params = new URLSearchParams({ + api_key: apiKey, + 'data[]': 'value', + frequency: 'weekly', + 'facets[series][]': config.seriesFacet, + 'sort[0][column]': 'period', + 'sort[0][direction]': 'desc', + length: '2', + }); + + const response = await fetch(`https://api.eia.gov${config.apiPath}?${params}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return null; + + const data = await response.json() as { + response?: { data?: Array<{ period?: string; value?: number }> }; + }; + + const rows = data.response?.data; + if (!rows || rows.length === 0) return null; + + const current = rows[0]!; + const previous = rows[1]; + + const price = current.value ?? 0; + const prevPrice = previous?.value ?? price; + const change = prevPrice !== 0 ? ((price - prevPrice) / prevPrice) * 100 : 0; + const priceAt = current.period ? new Date(current.period).getTime() : Date.now(); + + return { + commodity: config.commodity, + name: config.name, + price, + unit: config.unit, + change: Math.round(change * 10) / 10, + priceAt: Number.isFinite(priceAt) ? priceAt : Date.now(), + }; + } catch { + return null; + } +} + +async function fetchEnergyPrices(commodities: string[]): Promise { + const apiKey = process.env.EIA_API_KEY; + if (!apiKey) return []; + + const series = commodities.length > 0 + ? EIA_SERIES.filter((s) => commodities.includes(s.commodity)) + : EIA_SERIES; + + const results = await Promise.all(series.map((s) => fetchEiaSeries(s, apiKey))); + return results.filter((p): p is EnergyPrice => p !== null); +} + +export async function getEnergyPrices( + _ctx: ServerContext, + req: GetEnergyPricesRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${[...req.commodities].sort().join(',') || 'all'}`; + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const prices = await fetchEnergyPrices(req.commodities); + return prices.length > 0 ? { prices } : null; + }); + return result || { prices: [] }; + } catch { + return { prices: [] }; + } +} diff --git a/server/worldmonitor/economic/v1/get-fred-series.ts b/server/worldmonitor/economic/v1/get-fred-series.ts new file mode 100644 index 000000000..268a8c68a --- /dev/null +++ b/server/worldmonitor/economic/v1/get-fred-series.ts @@ -0,0 +1,108 @@ +/** + * RPC: getFredSeries -- Federal Reserve Economic Data (FRED) time series + * Port from api/fred-data.js + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetFredSeriesRequest, + GetFredSeriesResponse, + FredSeries, + FredObservation, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; + +const FRED_API_BASE = 'https://api.stlouisfed.org/fred'; +const REDIS_CACHE_KEY = 'economic:fred:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — FRED data updates infrequently + +async function fetchFredSeries(req: GetFredSeriesRequest): Promise { + try { + const apiKey = process.env.FRED_API_KEY; + if (!apiKey) return undefined; + + const limit = req.limit > 0 ? Math.min(req.limit, 1000) : 120; + + // Fetch observations and series metadata in parallel + const obsParams = new URLSearchParams({ + series_id: req.seriesId, + api_key: apiKey, + file_type: 'json', + sort_order: 'desc', + limit: String(limit), + }); + + const metaParams = new URLSearchParams({ + series_id: req.seriesId, + api_key: apiKey, + file_type: 'json', + }); + + const [obsResponse, metaResponse] = await Promise.all([ + fetch(`${FRED_API_BASE}/series/observations?${obsParams}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }), + fetch(`${FRED_API_BASE}/series?${metaParams}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }), + ]); + + if (!obsResponse.ok) return undefined; + + const obsData = await obsResponse.json() as { observations?: Array<{ date: string; value: string }> }; + + const observations: FredObservation[] = (obsData.observations || []) + .map((obs) => { + const value = parseFloat(obs.value); + if (isNaN(value) || obs.value === '.') return null; + return { date: obs.date, value }; + }) + .filter((o): o is FredObservation => o !== null) + .reverse(); // oldest first + + let title = req.seriesId; + let units = ''; + let frequency = ''; + + if (metaResponse.ok) { + const metaData = await metaResponse.json() as { seriess?: Array<{ title?: string; units?: string; frequency?: string }> }; + const meta = metaData.seriess?.[0]; + if (meta) { + title = meta.title || req.seriesId; + units = meta.units || ''; + frequency = meta.frequency || ''; + } + } + + return { + seriesId: req.seriesId, + title, + units, + frequency, + observations, + }; + } catch { + return undefined; + } +} + +export async function getFredSeries( + _ctx: ServerContext, + req: GetFredSeriesRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.seriesId}:${req.limit || 0}`; + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const series = await fetchFredSeries(req); + return series ? { series } : null; + }); + return result || { series: undefined }; + } catch { + return { series: undefined }; + } +} diff --git a/server/worldmonitor/economic/v1/get-macro-signals.ts b/server/worldmonitor/economic/v1/get-macro-signals.ts new file mode 100644 index 000000000..1a258a56f --- /dev/null +++ b/server/worldmonitor/economic/v1/get-macro-signals.ts @@ -0,0 +1,272 @@ +/** + * RPC: getMacroSignals -- 7-signal macro dashboard + * Port from api/macro-signals.js + * Sources: Yahoo Finance, Alternative.me, Mempool + * In-memory cache with 5-minute TTL. + */ + +import type { + ServerContext, + GetMacroSignalsRequest, + GetMacroSignalsResponse, + FearGreedHistoryEntry, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { + fetchJSON, + rateOfChange, + smaCalc, + extractClosePrices, + extractAlignedPriceVolume, +} from './_shared'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:macro-signals:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — matches in-memory TTL + +const MACRO_CACHE_TTL = 300; // 5 minutes in seconds +let macroSignalsCached: GetMacroSignalsResponse | null = null; +let macroSignalsCacheTimestamp = 0; + +function buildFallbackResult(): GetMacroSignalsResponse { + return { + timestamp: new Date().toISOString(), + verdict: 'UNKNOWN', + bullishCount: 0, + totalCount: 0, + signals: { + liquidity: { status: 'UNKNOWN', sparkline: [] }, + flowStructure: { status: 'UNKNOWN' }, + macroRegime: { status: 'UNKNOWN' }, + technicalTrend: { status: 'UNKNOWN', sparkline: [] }, + hashRate: { status: 'UNKNOWN' }, + miningCost: { status: 'UNKNOWN' }, + fearGreed: { status: 'UNKNOWN', history: [] }, + }, + meta: { qqqSparkline: [] }, + unavailable: true, + }; +} + +async function computeMacroSignals(): Promise { + const yahooBase = 'https://query1.finance.yahoo.com/v8/finance/chart'; + + // Yahoo calls go through global yahooGate() in fetchJSON — sequential to avoid 429 + const jpyChart = await fetchJSON(`${yahooBase}/JPY=X?range=1y&interval=1d`).catch(() => null); + const btcChart = await fetchJSON(`${yahooBase}/BTC-USD?range=1y&interval=1d`).catch(() => null); + const qqqChart = await fetchJSON(`${yahooBase}/QQQ?range=1y&interval=1d`).catch(() => null); + const xlpChart = await fetchJSON(`${yahooBase}/XLP?range=1y&interval=1d`).catch(() => null); + // Non-Yahoo calls can go in parallel + const [fearGreed, mempoolHash] = await Promise.allSettled([ + fetchJSON('https://api.alternative.me/fng/?limit=30&format=json'), + fetchJSON('https://mempool.space/api/v1/mining/hashrate/1m'), + ]); + + const jpyPrices = jpyChart ? extractClosePrices(jpyChart) : []; + const btcPrices = btcChart ? extractClosePrices(btcChart) : []; + const btcAligned = btcChart ? extractAlignedPriceVolume(btcChart) : []; + const qqqPrices = qqqChart ? extractClosePrices(qqqChart) : []; + const xlpPrices = xlpChart ? extractClosePrices(xlpChart) : []; + + // 1. Liquidity Signal (JPY 30d ROC) + const jpyRoc30 = rateOfChange(jpyPrices, 30); + const liquidityStatus = jpyRoc30 !== null + ? (jpyRoc30 < -2 ? 'SQUEEZE' : 'NORMAL') + : 'UNKNOWN'; + + // 2. Flow Structure (BTC vs QQQ 5d return) + const btcReturn5 = rateOfChange(btcPrices, 5); + const qqqReturn5 = rateOfChange(qqqPrices, 5); + let flowStatus = 'UNKNOWN'; + if (btcReturn5 !== null && qqqReturn5 !== null) { + const gap = btcReturn5 - qqqReturn5; + flowStatus = Math.abs(gap) > 5 ? 'PASSIVE GAP' : 'ALIGNED'; + } + + // 3. Macro Regime (QQQ/XLP 20d ROC) + const qqqRoc20 = rateOfChange(qqqPrices, 20); + const xlpRoc20 = rateOfChange(xlpPrices, 20); + let regimeStatus = 'UNKNOWN'; + if (qqqRoc20 !== null && xlpRoc20 !== null) { + regimeStatus = qqqRoc20 > xlpRoc20 ? 'RISK-ON' : 'DEFENSIVE'; + } + + // 4. Technical Trend (BTC vs SMA50 + VWAP) + const btcSma50 = smaCalc(btcPrices, 50); + const btcSma200 = smaCalc(btcPrices, 200); + const btcCurrent = btcPrices.length > 0 ? btcPrices[btcPrices.length - 1] : null; + + // Compute VWAP from aligned price/volume pairs (30d) + let btcVwap: number | null = null; + if (btcAligned.length >= 30) { + const last30 = btcAligned.slice(-30); + let sumPV = 0, sumV = 0; + for (const { price, volume } of last30) { + sumPV += price * volume; + sumV += volume; + } + if (sumV > 0) btcVwap = +(sumPV / sumV).toFixed(0); + } + + let trendStatus = 'UNKNOWN'; + let mayerMultiple: number | null = null; + if (btcCurrent && btcSma50) { + const aboveSma = btcCurrent > btcSma50 * 1.02; + const belowSma = btcCurrent < btcSma50 * 0.98; + const aboveVwap = btcVwap ? btcCurrent > btcVwap : null; + if (aboveSma && aboveVwap !== false) trendStatus = 'BULLISH'; + else if (belowSma && aboveVwap !== true) trendStatus = 'BEARISH'; + else trendStatus = 'NEUTRAL'; + } + if (btcCurrent && btcSma200) { + mayerMultiple = +(btcCurrent / btcSma200).toFixed(2); + } + + // 5. Hash Rate + let hashStatus = 'UNKNOWN'; + let hashChange: number | null = null; + if (mempoolHash.status === 'fulfilled') { + const hr = mempoolHash.value?.hashrates || mempoolHash.value; + if (Array.isArray(hr) && hr.length >= 2) { + const recent = hr[hr.length - 1]?.avgHashrate || hr[hr.length - 1]; + const older = hr[0]?.avgHashrate || hr[0]; + if (recent && older && older > 0) { + hashChange = +((recent - older) / older * 100).toFixed(1); + hashStatus = hashChange > 3 ? 'GROWING' : hashChange < -3 ? 'DECLINING' : 'STABLE'; + } + } + } + + // 6. Mining Cost (hashrate-based model) + let miningStatus = 'UNKNOWN'; + if (btcCurrent && hashChange !== null) { + miningStatus = btcCurrent > 60000 ? 'PROFITABLE' : btcCurrent > 40000 ? 'TIGHT' : 'SQUEEZE'; + } + + // 7. Fear & Greed + let fgValue: number | undefined; + let fgLabel = 'UNKNOWN'; + let fgHistory: FearGreedHistoryEntry[] = []; + if (fearGreed.status === 'fulfilled' && fearGreed.value?.data) { + const data = fearGreed.value.data; + const parsed = parseInt(data[0]?.value, 10); + fgValue = Number.isFinite(parsed) ? parsed : undefined; + fgLabel = data[0]?.value_classification || 'UNKNOWN'; + fgHistory = data.slice(0, 30).map((d: any) => ({ + value: parseInt(d.value, 10), + date: new Date(parseInt(d.timestamp, 10) * 1000).toISOString().slice(0, 10), + })).reverse(); + } + + // Sparkline data + const btcSparkline = btcPrices.slice(-30); + const qqqSparkline = qqqPrices.slice(-30); + const jpySparkline = jpyPrices.slice(-30); + + // Overall Verdict + let bullishCount = 0; + let totalCount = 0; + const signalList = [ + { name: 'Liquidity', status: liquidityStatus, bullish: liquidityStatus === 'NORMAL' }, + { name: 'Flow Structure', status: flowStatus, bullish: flowStatus === 'ALIGNED' }, + { name: 'Macro Regime', status: regimeStatus, bullish: regimeStatus === 'RISK-ON' }, + { name: 'Technical Trend', status: trendStatus, bullish: trendStatus === 'BULLISH' }, + { name: 'Hash Rate', status: hashStatus, bullish: hashStatus === 'GROWING' }, + { name: 'Mining Cost', status: miningStatus, bullish: miningStatus === 'PROFITABLE' }, + { name: 'Fear & Greed', status: fgLabel, bullish: fgValue !== undefined && fgValue > 50 }, + ]; + + for (const s of signalList) { + if (s.status !== 'UNKNOWN') { + totalCount++; + if (s.bullish) bullishCount++; + } + } + + const verdict = totalCount === 0 ? 'UNKNOWN' : (bullishCount / totalCount >= 0.57 ? 'BUY' : 'CASH'); + + // Stale-while-revalidate: if Yahoo rate-limited all calls, serve cached data + if (totalCount === 0 && macroSignalsCached && !macroSignalsCached.unavailable) { + return macroSignalsCached; + } + + return { + timestamp: new Date().toISOString(), + verdict, + bullishCount, + totalCount, + signals: { + liquidity: { + status: liquidityStatus, + value: jpyRoc30 !== null ? +jpyRoc30.toFixed(2) : undefined, + sparkline: jpySparkline, + }, + flowStructure: { + status: flowStatus, + btcReturn5: btcReturn5 !== null ? +btcReturn5.toFixed(2) : undefined, + qqqReturn5: qqqReturn5 !== null ? +qqqReturn5.toFixed(2) : undefined, + }, + macroRegime: { + status: regimeStatus, + qqqRoc20: qqqRoc20 !== null ? +qqqRoc20.toFixed(2) : undefined, + xlpRoc20: xlpRoc20 !== null ? +xlpRoc20.toFixed(2) : undefined, + }, + technicalTrend: { + status: trendStatus, + btcPrice: btcCurrent ?? undefined, + sma50: btcSma50 ? +btcSma50.toFixed(0) : undefined, + sma200: btcSma200 ? +btcSma200.toFixed(0) : undefined, + vwap30d: btcVwap ?? undefined, + mayerMultiple: mayerMultiple ?? undefined, + sparkline: btcSparkline, + }, + hashRate: { + status: hashStatus, + change30d: hashChange ?? undefined, + }, + miningCost: { status: miningStatus }, + fearGreed: { + status: fgLabel, + value: fgValue, + history: fgHistory, + }, + }, + meta: { qqqSparkline }, + unavailable: false, + }; +} + +export async function getMacroSignals( + _ctx: ServerContext, + _req: GetMacroSignalsRequest, +): Promise { + const now = Date.now(); + if (macroSignalsCached && now - macroSignalsCacheTimestamp < MACRO_CACHE_TTL * 1000) { + return macroSignalsCached; + } + + try { + // Redis shared cache (cross-instance) with in-flight dedup via cachedFetchJson + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + const computed = await computeMacroSignals(); + return (!computed.unavailable && computed.totalCount > 0) ? computed : null; + }); + + if (result && !result.unavailable && result.totalCount > 0) { + macroSignalsCached = result; + macroSignalsCacheTimestamp = now; + return result; + } + + // cachedFetchJson returned null: all data unavailable, serve stale or fallback + const fallback = macroSignalsCached || buildFallbackResult(); + macroSignalsCached = fallback; + macroSignalsCacheTimestamp = now; + return fallback; + } catch { + const fallback = macroSignalsCached || buildFallbackResult(); + macroSignalsCached = fallback; + macroSignalsCacheTimestamp = now; + return fallback; + } +} diff --git a/server/worldmonitor/economic/v1/handler.ts b/server/worldmonitor/economic/v1/handler.ts new file mode 100644 index 000000000..ce70116f9 --- /dev/null +++ b/server/worldmonitor/economic/v1/handler.ts @@ -0,0 +1,21 @@ +import type { EconomicServiceHandler } from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { getFredSeries } from './get-fred-series'; +import { listWorldBankIndicators } from './list-world-bank-indicators'; +import { getEnergyPrices } from './get-energy-prices'; +import { getMacroSignals } from './get-macro-signals'; +import { getEnergyCapacity } from './get-energy-capacity'; +import { getBisPolicyRates } from './get-bis-policy-rates'; +import { getBisExchangeRates } from './get-bis-exchange-rates'; +import { getBisCredit } from './get-bis-credit'; + +export const economicHandler: EconomicServiceHandler = { + getFredSeries, + listWorldBankIndicators, + getEnergyPrices, + getMacroSignals, + getEnergyCapacity, + getBisPolicyRates, + getBisExchangeRates, + getBisCredit, +}; diff --git a/server/worldmonitor/economic/v1/list-world-bank-indicators.ts b/server/worldmonitor/economic/v1/list-world-bank-indicators.ts new file mode 100644 index 000000000..17d0ea515 --- /dev/null +++ b/server/worldmonitor/economic/v1/list-world-bank-indicators.ts @@ -0,0 +1,88 @@ +/** + * RPC: listWorldBankIndicators -- World Bank development indicator data + * Port from api/worldbank.js + */ + +import type { + ServerContext, + ListWorldBankIndicatorsRequest, + ListWorldBankIndicatorsResponse, + WorldBankCountryData, +} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:worldbank:v1'; +const REDIS_CACHE_TTL = 86400; // 24 hr — annual data + +const TECH_COUNTRIES = [ + 'USA', 'CHN', 'JPN', 'DEU', 'KOR', 'GBR', 'IND', 'ISR', 'SGP', 'TWN', + 'FRA', 'CAN', 'SWE', 'NLD', 'CHE', 'FIN', 'IRL', 'AUS', 'BRA', 'IDN', + 'ARE', 'SAU', 'QAT', 'BHR', 'EGY', 'TUR', + 'MYS', 'THA', 'VNM', 'PHL', + 'ESP', 'ITA', 'POL', 'CZE', 'DNK', 'NOR', 'AUT', 'BEL', 'PRT', 'EST', + 'MEX', 'ARG', 'CHL', 'COL', + 'ZAF', 'NGA', 'KEN', +]; + +async function fetchWorldBankIndicators( + req: ListWorldBankIndicatorsRequest, +): Promise { + try { + const indicator = req.indicatorCode; + if (!indicator) return []; + + const countryList = req.countryCode || TECH_COUNTRIES.join(';'); + const currentYear = new Date().getFullYear(); + const years = req.year > 0 ? req.year : 5; + const startYear = currentYear - years; + + const wbUrl = `https://api.worldbank.org/v2/country/${countryList}/indicator/${indicator}?format=json&date=${startYear}:${currentYear}&per_page=1000`; + + const response = await fetch(wbUrl, { + headers: { + Accept: 'application/json', + 'User-Agent': CHROME_UA, + }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) return []; + + const data = await response.json(); + if (!data || !Array.isArray(data) || data.length < 2 || !data[1]) return []; + + const records: any[] = data[1]; + const indicatorName = records[0]?.indicator?.value || indicator; + + return records + .filter((r: any) => r.countryiso3code && r.value !== null) + .map((r: any): WorldBankCountryData => ({ + countryCode: r.countryiso3code || r.country?.id || '', + countryName: r.country?.value || '', + indicatorCode: indicator, + indicatorName, + year: parseInt(r.date, 10) || 0, + value: r.value, + })); + } catch { + return []; + } +} + +export async function listWorldBankIndicators( + _ctx: ServerContext, + req: ListWorldBankIndicatorsRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.indicatorCode}:${req.countryCode || 'all'}:${req.year || 0}`; + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const data = await fetchWorldBankIndicators(req); + return data.length > 0 ? { data, pagination: undefined } : null; + }); + return result || { data: [], pagination: undefined }; + } catch { + return { data: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/giving/v1/get-giving-summary.ts b/server/worldmonitor/giving/v1/get-giving-summary.ts new file mode 100644 index 000000000..018451f99 --- /dev/null +++ b/server/worldmonitor/giving/v1/get-giving-summary.ts @@ -0,0 +1,217 @@ +/** + * GetGivingSummary RPC -- aggregates global personal giving data from multiple + * sources into a composite Global Giving Activity Index. + * + * Data sources (all use published annual report baselines): + * 1. GoFundMe -- 2024 Year in Giving report + * 2. GlobalGiving -- 2024 annual report + * 3. JustGiving -- published cumulative totals + * 4. Endaoment / crypto giving -- industry estimates + * 5. OECD ODA annual totals (institutional baseline) + */ + +import type { + ServerContext, + GetGivingSummaryRequest, + GetGivingSummaryResponse, + GivingSummary, + PlatformGiving, + CategoryBreakdown, + CryptoGivingSummary, + InstitutionalGiving, +} from '../../../../src/generated/server/worldmonitor/giving/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'giving:summary:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hour + +// ─── GoFundMe Estimate ─── +// GoFundMe's public search API (mvc.php) was removed ~2025. Their search now +// uses Algolia internally. We use published annual report data as a baseline. +// +// Published data points (GoFundMe 2024 Year in Giving report): +// - $30B+ total raised since founding +// - ~$9B raised in 2024 alone +// - 200M+ unique donors +// - ~250,000 active campaigns at any time +// - Medical & health is the largest category (~33%) + +function getGoFundMeEstimate(): PlatformGiving { + return { + platform: 'GoFundMe', + dailyVolumeUsd: 9_000_000_000 / 365, // ~$24.7M/day from 2024 annual report + activeCampaignsSampled: 0, + newCampaigns24h: 0, + donationVelocity: 0, + dataFreshness: 'annual', + lastUpdated: new Date().toISOString(), + }; +} + +// ─── GlobalGiving Estimate ─── +// GlobalGiving's public API now requires a registered API key (returns 401 +// without one). We use published data as a baseline. +// +// Published data points (GlobalGiving 2024 annual report): +// - $900M+ total raised since founding (2002) +// - ~35,000 vetted projects in 175+ countries +// - 1.2M+ donors +// - ~$100M raised in recent years annually + +function getGlobalGivingEstimate(): PlatformGiving { + return { + platform: 'GlobalGiving', + dailyVolumeUsd: 100_000_000 / 365, // ~$274K/day from annual reports + activeCampaignsSampled: 0, + newCampaigns24h: 0, + donationVelocity: 0, + dataFreshness: 'annual', + lastUpdated: new Date().toISOString(), + }; +} + +// ─── JustGiving Estimate ─── + +function getJustGivingEstimate(): PlatformGiving { + // JustGiving reports ~$7B+ total raised. Public search API is limited. + // Use published annual reports for macro signal. + return { + platform: 'JustGiving', + dailyVolumeUsd: 7_000_000_000 / 365, // ~$19.2M/day from annual reports + activeCampaignsSampled: 0, + newCampaigns24h: 0, + donationVelocity: 0, + dataFreshness: 'annual', + lastUpdated: new Date().toISOString(), + }; +} + +// ─── Crypto Giving Estimate ─── + +function getCryptoGivingEstimate(): CryptoGivingSummary { + // On-chain charity tracking -- Endaoment, The Giving Block, etc. + // Total crypto giving estimated at ~$2B/year (2024 data). + // Endaoment alone processed ~$40M in 2023. + return { + dailyInflowUsd: 2_000_000_000 / 365, // ~$5.5M/day estimate + trackedWallets: 150, + transactions24h: 0, // would require on-chain indexer + topReceivers: ['Endaoment', 'The Giving Block', 'UNICEF Crypto Fund', 'Save the Children'], + pctOfTotal: 0.8, // ~0.8% of total charitable giving + }; +} + +// ─── Institutional / ODA Baseline ─── + +function getInstitutionalBaseline(): InstitutionalGiving { + // OECD DAC ODA statistics -- 2023 data + return { + oecdOdaAnnualUsdBn: 223.7, // 2023 preliminary + oecdDataYear: 2023, + cafWorldGivingIndex: 34, // 2024 CAF World Giving Index (global avg %) + cafDataYear: 2024, + candidGrantsTracked: 18_000_000, // Candid tracks ~18M grants + dataLag: 'Annual', + }; +} + +// ─── Category Breakdown ─── + +function getDefaultCategories(): CategoryBreakdown[] { + // Based on published GoFundMe / GlobalGiving category distributions + return [ + { category: 'Medical & Health', share: 0.33, change24h: 0, activeCampaigns: 0, trending: true }, + { category: 'Disaster Relief', share: 0.15, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Education', share: 0.12, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Community', share: 0.10, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Memorials', share: 0.08, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Animals & Pets', share: 0.07, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Environment', share: 0.05, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Hunger & Food', share: 0.05, change24h: 0, activeCampaigns: 0, trending: false }, + { category: 'Other', share: 0.05, change24h: 0, activeCampaigns: 0, trending: false }, + ]; +} + +// ─── Composite Activity Index ─── + +function computeActivityIndex(platforms: PlatformGiving[], crypto: CryptoGivingSummary): number { + // Composite index (0-100) weighted by data quality and signal strength + // Higher when: more platforms reporting, higher velocity, more new campaigns + let score = 50; // baseline + + const totalDailyVolume = platforms.reduce((s, p) => s + p.dailyVolumeUsd, 0) + crypto.dailyInflowUsd; + // Expected baseline ~$50M/day across tracked platforms + const volumeRatio = totalDailyVolume / 50_000_000; + score += Math.min(20, Math.max(-20, (volumeRatio - 1) * 20)); + + // Campaign velocity bonus + const totalVelocity = platforms.reduce((s, p) => s + p.donationVelocity, 0); + if (totalVelocity > 100) score += 5; + if (totalVelocity > 500) score += 10; + + // New campaigns signal + const totalNew = platforms.reduce((s, p) => s + p.newCampaigns24h, 0); + if (totalNew > 10) score += 5; + if (totalNew > 50) score += 5; + + // Data coverage bonus + const reporting = platforms.filter(p => p.dailyVolumeUsd > 0).length; + score += reporting * 2; + + return Math.max(0, Math.min(100, Math.round(score))); +} + +function computeTrend(index: number): string { + // Without historical data, use index level as proxy + if (index >= 65) return 'rising'; + if (index <= 35) return 'falling'; + return 'stable'; +} + +// ─── Main Handler ─── + +export async function getGivingSummary( + _ctx: ServerContext, + req: GetGivingSummaryRequest, +): Promise { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + // Gather estimates from all sources + const cryptoEstimate = getCryptoGivingEstimate(); + const gofundme = getGoFundMeEstimate(); + const globalGiving = getGlobalGivingEstimate(); + const justGiving = getJustGivingEstimate(); + const institutional = getInstitutionalBaseline(); + + let platforms = [gofundme, globalGiving, justGiving]; + if (req.platformLimit > 0) { + platforms = platforms.slice(0, req.platformLimit); + } + + // Use default category breakdown (from published reports) + let categories = getDefaultCategories(); + if (req.categoryLimit > 0) { + categories = categories.slice(0, req.categoryLimit); + } + + // Composite index + const activityIndex = computeActivityIndex(platforms, cryptoEstimate); + const trend = computeTrend(activityIndex); + const estimatedDailyFlowUsd = platforms.reduce((s, p) => s + p.dailyVolumeUsd, 0) + cryptoEstimate.dailyInflowUsd; + + const summary: GivingSummary = { + generatedAt: new Date().toISOString(), + activityIndex, + trend, + estimatedDailyFlowUsd, + platforms, + categories, + crypto: cryptoEstimate, + institutional, + }; + + return { summary }; + }); + + return result || { summary: undefined as unknown as GivingSummary }; +} diff --git a/server/worldmonitor/giving/v1/handler.ts b/server/worldmonitor/giving/v1/handler.ts new file mode 100644 index 000000000..3d07052d3 --- /dev/null +++ b/server/worldmonitor/giving/v1/handler.ts @@ -0,0 +1,7 @@ +import type { GivingServiceHandler } from '../../../../src/generated/server/worldmonitor/giving/v1/service_server'; + +import { getGivingSummary } from './get-giving-summary'; + +export const givingHandler: GivingServiceHandler = { + getGivingSummary, +}; diff --git a/server/worldmonitor/infrastructure/v1/_shared.ts b/server/worldmonitor/infrastructure/v1/_shared.ts new file mode 100644 index 000000000..24e77bb1e --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/_shared.ts @@ -0,0 +1,67 @@ +declare const process: { env: Record }; + +// ======================================================================== +// Constants +// ======================================================================== + +export const UPSTREAM_TIMEOUT_MS = 10_000; + +// Temporal baseline constants +export const BASELINE_TTL = 7776000; // 90 days in seconds +export const MIN_SAMPLES = 10; +export const Z_THRESHOLD_LOW = 1.5; +export const Z_THRESHOLD_MEDIUM = 2.0; +export const Z_THRESHOLD_HIGH = 3.0; + +export const VALID_BASELINE_TYPES = [ + 'military_flights', 'vessels', 'protests', 'news', 'ais_gaps', 'satellite_fires', +]; + +// ======================================================================== +// Temporal baseline helpers +// ======================================================================== + +export interface BaselineEntry { + mean: number; + m2: number; + sampleCount: number; + lastUpdated: string; +} + +export function makeBaselineKey(type: string, region: string, weekday: number, month: number): string { + return `baseline:${type}:${region}:${weekday}:${month}`; +} + +export function getBaselineSeverity(zScore: number): string { + if (zScore >= Z_THRESHOLD_HIGH) return 'critical'; + if (zScore >= Z_THRESHOLD_MEDIUM) return 'high'; + if (zScore >= Z_THRESHOLD_LOW) return 'medium'; + return 'normal'; +} + +// ======================================================================== +// Upstash Redis MGET helper (edge-compatible) +// getCachedJson / setCachedJson are imported from ../../../_shared/redis.ts +// ======================================================================== + +export async function mgetJson(keys: string[]): Promise<(unknown | null)[]> { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return keys.map(() => null); + try { + const resp = await fetch(`${url}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(['MGET', ...keys]), + signal: AbortSignal.timeout(5_000), + }); + if (!resp.ok) return keys.map(() => null); + const data = (await resp.json()) as { result?: (string | null)[] }; + return (data.result || []).map(v => v ? JSON.parse(v) : null); + } catch { + return keys.map(() => null); + } +} diff --git a/server/worldmonitor/infrastructure/v1/get-cable-health.ts b/server/worldmonitor/infrastructure/v1/get-cable-health.ts new file mode 100644 index 000000000..0b08bf2c7 --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/get-cable-health.ts @@ -0,0 +1,397 @@ +import type { + ServerContext, + GetCableHealthRequest, + GetCableHealthResponse, + CableHealthRecord, + CableHealthEvidence, + CableHealthStatus, +} from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; + +// ======================================================================== +// Constants +// ======================================================================== + +const CACHE_KEY = 'cable-health-v1'; +const CACHE_TTL = 600; // 10 min — cable health not time-critical + +// In-memory fallback: serves stale data when both Redis and NGA are down +let fallbackCache: GetCableHealthResponse | null = null; + +// ======================================================================== +// NGA warning types +// ======================================================================== + +interface NgaWarning { + text?: string; + issueDate?: string; + navArea?: string; + msgYear?: number; + msgNumber?: number; +} + +// ======================================================================== +// Cable keywords and patterns +// ======================================================================== + +const CABLE_KEYWORDS = [ + 'CABLE', 'CABLESHIP', 'CABLE SHIP', 'CABLE LAYING', + 'CABLE OPERATIONS', 'SUBMARINE CABLE', 'UNDERSEA CABLE', + 'FIBER OPTIC', 'TELECOMMUNICATIONS CABLE', +]; + +const FAULT_KEYWORDS = /FAULT|BREAK|CUT|DAMAGE|SEVERED|RUPTURE|OUTAGE|FAILURE/i; +const SHIP_PATTERNS = [ + /CABLESHIP\s+([A-Z][A-Z0-9\s\-']+)/i, + /CABLE\s+SHIP\s+([A-Z][A-Z0-9\s\-']+)/i, + /CS\s+([A-Z][A-Z0-9\s\-']+)/i, + /M\/V\s+([A-Z][A-Z0-9\s\-']+)/i, +]; +const ON_STATION_RE = /ON STATION|OPERATIONS IN PROGRESS|LAYING|REPAIRING|WORKING|COMMENCED/i; + +// Known cable names -> cableId mapping +const CABLE_NAME_MAP: Record = { + 'MAREA': 'marea', + 'GRACE HOPPER': 'grace_hopper', + 'HAVFRUE': 'havfrue', + 'FASTER': 'faster', + 'SOUTHERN CROSS': 'southern_cross', + 'CURIE': 'curie', + 'SEA-ME-WE': 'seamewe6', + 'SEAMEWE': 'seamewe6', + 'SMW6': 'seamewe6', + 'FLAG': 'flag', + '2AFRICA': '2africa', + 'WACS': 'wacs', + 'EASSY': 'eassy', + 'SAM-1': 'sam1', + 'SAM1': 'sam1', + 'ELLALINK': 'ellalink', + 'ELLA LINK': 'ellalink', + 'APG': 'apg', + 'INDIGO': 'indigo', + 'SJC': 'sjc', + 'FARICE': 'farice', + 'FALCON': 'falcon', +}; + +// Minimal cable geometry for proximity matching (landing coords: [lat, lon]) +const CABLE_LANDINGS: Record = { + marea: [[36.85, -75.98], [43.26, -2.93]], + grace_hopper: [[40.57, -73.97], [50.83, -4.55], [43.26, -2.93]], + havfrue: [[40.22, -74.01], [58.15, 8.0], [55.56, 8.13]], + faster: [[43.37, -124.22], [34.95, 139.95], [34.32, 136.85]], + southern_cross: [[-33.87, 151.21], [-36.85, 174.76], [33.74, -118.27]], + curie: [[33.74, -118.27], [-33.05, -71.62]], + seamewe6: [[1.35, 103.82], [19.08, 72.88], [25.13, 56.34], [21.49, 39.19], [29.97, 32.55], [43.30, 5.37]], + flag: [[50.04, -5.66], [31.20, 29.92], [25.20, 55.27], [19.08, 72.88], [1.35, 103.82], [35.69, 139.69]], + '2africa': [[50.83, -4.55], [38.72, -9.14], [14.69, -17.44], [6.52, 3.38], [-33.93, 18.42], [-4.04, 39.67], [21.49, 39.19], [31.26, 32.30]], + wacs: [[-33.93, 18.42], [6.52, 3.38], [14.69, -17.44], [38.72, -9.14], [51.51, -0.13]], + eassy: [[-29.85, 31.02], [-25.97, 32.58], [-6.80, 39.28], [-4.04, 39.67], [11.59, 43.15]], + sam1: [[-22.91, -43.17], [-34.60, -58.38], [26.36, -80.08]], + ellalink: [[38.72, -9.14], [-3.72, -38.52]], + apg: [[35.69, 139.69], [25.15, 121.44], [22.29, 114.17], [1.35, 103.82]], + indigo: [[-31.95, 115.86], [1.35, 103.82], [-6.21, 106.85]], + sjc: [[35.69, 139.69], [36.07, 120.32], [1.35, 103.82], [22.29, 114.17]], + farice: [[64.13, -21.90], [62.01, -6.77], [55.95, -3.19]], + falcon: [[25.13, 56.34], [23.59, 58.38], [26.23, 50.59], [29.38, 47.98]], +}; + +// ======================================================================== +// Signal types +// ======================================================================== + +interface Signal { + cableId: string; + ts: number; // epoch ms + severity: number; + confidence: number; + ttlSeconds: number; + kind: string; + evidence: Array<{ source: string; summary: string; ts: number }>; +} + +// ======================================================================== +// NGA fetch +// ======================================================================== + +async function fetchNgaWarnings(): Promise { + try { + const res = await fetch( + 'https://msi.nga.mil/api/publications/broadcast-warn?output=json&status=A', + { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }, + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : (data as { warnings?: NgaWarning[] })?.warnings ?? []; + } catch { + return []; + } +} + +// ======================================================================== +// Text analysis helpers +// ======================================================================== + +export function isCableRelated(text: string): boolean { + const upper = text.toUpperCase(); + return CABLE_KEYWORDS.some((kw) => upper.includes(kw)); +} + +export function parseCoordinates(text: string): [number, number][] { + const coords: [number, number][] = []; + const dms = /(\d{1,3})-(\d{1,2}(?:\.\d+)?)\s*([NS])\s+(\d{1,3})-(\d{1,2}(?:\.\d+)?)\s*([EW])/gi; + let m: RegExpExecArray | null; + while ((m = dms.exec(text)) !== null) { + let lat = parseInt(m[1], 10) + parseFloat(m[2]) / 60; + let lon = parseInt(m[4], 10) + parseFloat(m[5]) / 60; + if (m[3].toUpperCase() === 'S') lat = -lat; + if (m[6].toUpperCase() === 'W') lon = -lon; + if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) coords.push([lat, lon]); + } + return coords; +} + +export function matchCableByName(text: string): string | null { + const upper = text.toUpperCase(); + for (const [name, id] of Object.entries(CABLE_NAME_MAP)) { + if (upper.includes(name)) return id; + } + return null; +} + +export function findNearestCable(lat: number, lon: number): { cableId: string; distanceKm: number } | null { + let bestId: string | null = null; + let bestDist = Infinity; + const MAX_DIST_KM = 555; // ~5 degrees at equator + + const cosLat = Math.cos(lat * Math.PI / 180); + + for (const [cableId, landings] of Object.entries(CABLE_LANDINGS)) { + for (const [lLat, lLon] of landings) { + const dLat = (lat - lLat) * 111; + const dLon = (lon - lLon) * 111 * cosLat; + const distKm = Math.sqrt(dLat ** 2 + dLon ** 2); + if (distKm < bestDist && distKm < MAX_DIST_KM) { + bestDist = distKm; + bestId = cableId; + } + } + } + + return bestId ? { cableId: bestId, distanceKm: bestDist } : null; +} + +const MONTH_MAP: Record = { + JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, + JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11, +}; + +export function parseIssueDate(dateStr: string | undefined): number { + const m = dateStr?.match(/(\d{2})(\d{4})Z\s+([A-Z]{3})\s+(\d{4})/i); + if (!m) return 0; + const d = new Date(Date.UTC( + parseInt(m[4], 10), + MONTH_MAP[m[3].toUpperCase()] ?? 0, + parseInt(m[1], 10), + parseInt(m[2].slice(0, 2), 10), + parseInt(m[2].slice(2, 4), 10), + )); + return Number.isNaN(d.getTime()) ? 0 : d.getTime(); +} + +function hasShipName(text: string): boolean { + return SHIP_PATTERNS.some((pat) => pat.test(text)); +} + +// ======================================================================== +// Signal processing +// ======================================================================== + +export function processNgaSignals(warnings: NgaWarning[]): Signal[] { + const signals: Signal[] = []; + const cableWarnings = warnings.filter((w) => isCableRelated(w.text || '')); + + for (const warning of cableWarnings) { + const text = warning.text || ''; + const ts = parseIssueDate(warning.issueDate); + const coords = parseCoordinates(text); + + let cableId = matchCableByName(text); + let joinMethod = 'name'; + let distanceKm = 0; + + if (!cableId && coords.length > 0) { + const centLat = coords.reduce((s, c) => s + c[0], 0) / coords.length; + const centLon = coords.reduce((s, c) => s + c[1], 0) / coords.length; + const nearest = findNearestCable(centLat, centLon); + if (nearest) { + cableId = nearest.cableId; + joinMethod = 'geometry'; + distanceKm = Math.round(nearest.distanceKm); + } + } + + if (!cableId) continue; + + const isFault = FAULT_KEYWORDS.test(text); + const isRepairShip = hasShipName(text); + const isOnStation = ON_STATION_RE.test(text); + + const summaryText = text.slice(0, 150) + (text.length > 150 ? '...' : ''); + + if (isFault) { + signals.push({ + cableId, + ts, + severity: 1.0, + confidence: joinMethod === 'name' ? 0.9 : Math.max(0.4, 0.8 - distanceKm / 500), + ttlSeconds: 5 * 86400, + kind: 'operator_fault', + evidence: [{ source: 'NGA', summary: `Fault/damage reported: ${summaryText}`, ts }], + }); + } else { + signals.push({ + cableId, + ts, + severity: 0.6, + confidence: joinMethod === 'name' ? 0.8 : Math.max(0.3, 0.7 - distanceKm / 500), + ttlSeconds: 3 * 86400, + kind: 'cable_advisory', + evidence: [{ source: 'NGA', summary: `Cable advisory: ${summaryText}`, ts }], + }); + } + + if (isRepairShip) { + signals.push({ + cableId, + ts, + severity: isOnStation ? 0.8 : 0.5, + confidence: isOnStation ? 0.85 : 0.6, + ttlSeconds: isOnStation ? 24 * 3600 : 12 * 3600, + kind: 'repair_activity', + evidence: [{ + source: 'NGA', + summary: isOnStation + ? `Cable repair vessel on station: ${summaryText}` + : `Cable ship in area: ${summaryText}`, + ts, + }], + }); + } + } + + return signals; +} + +// ======================================================================== +// Health computation +// ======================================================================== + +export function computeHealthMap(signals: Signal[]): Record { + const now = Date.now(); + const byCable: Record = {}; + + for (const sig of signals) { + if (!byCable[sig.cableId]) byCable[sig.cableId] = []; + byCable[sig.cableId].push(sig); + } + + const healthMap: Record = {}; + + for (const [cableId, cableSignals] of Object.entries(byCable)) { + const effectiveSignals: Array = []; + + for (const sig of cableSignals) { + const ageMs = now - sig.ts; + const ageSec = Math.max(0, ageMs / 1000); + const recencyWeight = Math.max(0, Math.min(1, 1 - ageSec / sig.ttlSeconds)); + + if (recencyWeight <= 0) continue; + + const effective = sig.severity * sig.confidence * recencyWeight; + effectiveSignals.push({ ...sig, effective, recencyWeight }); + } + + if (effectiveSignals.length === 0) continue; + + effectiveSignals.sort((a, b) => b.effective - a.effective); + + const topScore = effectiveSignals[0].effective; + const topConfidence = effectiveSignals[0].confidence * effectiveSignals[0].recencyWeight; + + const hasOperatorFault = effectiveSignals.some( + (s) => s.kind === 'operator_fault' && s.effective >= 0.50, + ); + const hasRepairActivity = effectiveSignals.some( + (s) => s.kind === 'repair_activity' && s.effective >= 0.40, + ); + + let status: CableHealthStatus; + if (topScore >= 0.80 && hasOperatorFault) { + status = 'CABLE_HEALTH_STATUS_FAULT'; + } else if (topScore >= 0.80 && hasRepairActivity) { + status = 'CABLE_HEALTH_STATUS_DEGRADED'; + } else if (topScore >= 0.50) { + status = 'CABLE_HEALTH_STATUS_DEGRADED'; + } else { + status = 'CABLE_HEALTH_STATUS_OK'; + } + + const evidence: CableHealthEvidence[] = effectiveSignals + .slice(0, 3) + .flatMap((s) => s.evidence) + .slice(0, 3); + + const lastUpdated = effectiveSignals + .map((s) => s.ts) + .sort((a, b) => b - a)[0]; + + healthMap[cableId] = { + status, + score: Math.round(topScore * 100) / 100, + confidence: Math.round(topConfidence * 100) / 100, + lastUpdated, + evidence, + }; + } + + return healthMap; +} + +// ======================================================================== +// RPC implementation +// ======================================================================== + +export async function getCableHealth( + _ctx: ServerContext, + _req: GetCableHealthRequest, +): Promise { + try { + const result = await cachedFetchJson(CACHE_KEY, CACHE_TTL, async () => { + const ngaData = await fetchNgaWarnings(); + const signals = processNgaSignals(ngaData); + const cables = computeHealthMap(signals); + + const response: GetCableHealthResponse = { + generatedAt: Date.now(), + cables, + }; + + return response; + }); + + if (result) { + fallbackCache = result; + return result; + } + + return fallbackCache || { generatedAt: Date.now(), cables: {} }; + } catch { + if (fallbackCache) return fallbackCache; + return { generatedAt: Date.now(), cables: {} }; + } +} diff --git a/server/worldmonitor/infrastructure/v1/get-temporal-baseline.ts b/server/worldmonitor/infrastructure/v1/get-temporal-baseline.ts new file mode 100644 index 000000000..e5ce6d369 --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/get-temporal-baseline.ts @@ -0,0 +1,86 @@ +import type { + ServerContext, + GetTemporalBaselineRequest, + GetTemporalBaselineResponse, +} from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; + +import { getCachedJson } from '../../../_shared/redis'; +import { + VALID_BASELINE_TYPES, + MIN_SAMPLES, + Z_THRESHOLD_LOW, + makeBaselineKey, + getBaselineSeverity, + type BaselineEntry, +} from './_shared'; + +// ======================================================================== +// RPC implementation +// ======================================================================== + +export async function getTemporalBaseline( + _ctx: ServerContext, + req: GetTemporalBaselineRequest, +): Promise { + try { + const { type, count } = req; + const region = req.region || 'global'; + + if (!type || !VALID_BASELINE_TYPES.includes(type) || typeof count !== 'number' || isNaN(count)) { + return { + learning: false, + sampleCount: 0, + samplesNeeded: 0, + error: 'Missing or invalid params: type and count required', + }; + } + + const now = new Date(); + const weekday = now.getUTCDay(); + const month = now.getUTCMonth() + 1; + const key = makeBaselineKey(type, region, weekday, month); + + const baseline = await getCachedJson(key) as BaselineEntry | null; + + if (!baseline || baseline.sampleCount < MIN_SAMPLES) { + return { + learning: true, + sampleCount: baseline?.sampleCount || 0, + samplesNeeded: MIN_SAMPLES, + error: '', + }; + } + + const variance = Math.max(0, baseline.m2 / (baseline.sampleCount - 1)); + const stdDev = Math.sqrt(variance); + const zScore = stdDev > 0 ? Math.abs((count - baseline.mean) / stdDev) : 0; + const severity = getBaselineSeverity(zScore); + const multiplier = baseline.mean > 0 + ? Math.round((count / baseline.mean) * 100) / 100 + : count > 0 ? 999 : 1; + + return { + anomaly: zScore >= Z_THRESHOLD_LOW ? { + zScore: Math.round(zScore * 100) / 100, + severity, + multiplier, + } : undefined, + baseline: { + mean: Math.round(baseline.mean * 100) / 100, + stdDev: Math.round(stdDev * 100) / 100, + sampleCount: baseline.sampleCount, + }, + learning: false, + sampleCount: baseline.sampleCount, + samplesNeeded: MIN_SAMPLES, + error: '', + }; + } catch { + return { + learning: false, + sampleCount: 0, + samplesNeeded: 0, + error: 'Internal error', + }; + } +} diff --git a/server/worldmonitor/infrastructure/v1/handler.ts b/server/worldmonitor/infrastructure/v1/handler.ts new file mode 100644 index 000000000..77e9969d9 --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/handler.ts @@ -0,0 +1,15 @@ +import type { InfrastructureServiceHandler } from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; + +import { getCableHealth } from './get-cable-health'; +import { listInternetOutages } from './list-internet-outages'; +import { listServiceStatuses } from './list-service-statuses'; +import { getTemporalBaseline } from './get-temporal-baseline'; +import { recordBaselineSnapshot } from './record-baseline-snapshot'; + +export const infrastructureHandler: InfrastructureServiceHandler = { + getCableHealth, + listInternetOutages, + listServiceStatuses, + getTemporalBaseline, + recordBaselineSnapshot, +}; diff --git a/server/worldmonitor/infrastructure/v1/list-internet-outages.ts b/server/worldmonitor/infrastructure/v1/list-internet-outages.ts new file mode 100644 index 000000000..18d61a601 --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/list-internet-outages.ts @@ -0,0 +1,195 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + ListInternetOutagesRequest, + ListInternetOutagesResponse, + InternetOutage, + OutageSeverity, +} from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; + +import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'infra:outages:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — Cloudflare Radar rate-limited + +// ======================================================================== +// Constants +// ======================================================================== + +const CLOUDFLARE_RADAR_URL = 'https://api.cloudflare.com/client/v4/radar/annotations/outages'; + +// ======================================================================== +// Cloudflare Radar types +// ======================================================================== + +interface CloudflareOutage { + id: string; + dataSource: string; + description: string; + scope: string | null; + startDate: string; + endDate: string | null; + locations: string[]; + asns: number[]; + eventType: string; + linkedUrl: string; + locationsDetails: Array<{ name: string; code: string }>; + asnsDetails: Array<{ asn: string; name: string; location: { code: string; name: string } }>; + outage: { outageCause: string; outageType: string }; +} + +interface CloudflareResponse { + configured?: boolean; + success?: boolean; + errors?: Array<{ code: number; message: string }>; + result?: { annotations: CloudflareOutage[] }; +} + +// ======================================================================== +// Country coordinates (centroid for mapping outage locations) +// ======================================================================== + +const COUNTRY_COORDS: Record = { + AF:[33.94,67.71],AL:[41.15,20.17],DZ:[28.03,1.66],AO:[-11.20,17.87], + AR:[-38.42,-63.62],AM:[40.07,45.04],AU:[-25.27,133.78],AT:[47.52,14.55], + AZ:[40.14,47.58],BH:[26.07,50.56],BD:[23.69,90.36],BY:[53.71,27.95], + BE:[50.50,4.47],BJ:[9.31,2.32],BO:[-16.29,-63.59],BA:[43.92,17.68], + BW:[-22.33,24.68],BR:[-14.24,-51.93],BG:[42.73,25.49],BF:[12.24,-1.56], + BI:[-3.37,29.92],KH:[12.57,104.99],CM:[7.37,12.35],CA:[56.13,-106.35], + CF:[6.61,20.94],TD:[15.45,18.73],CL:[-35.68,-71.54],CN:[35.86,104.20], + CO:[4.57,-74.30],CG:[-0.23,15.83],CD:[-4.04,21.76],CR:[9.75,-83.75], + HR:[45.10,15.20],CU:[21.52,-77.78],CY:[35.13,33.43],CZ:[49.82,15.47], + DK:[56.26,9.50],DJ:[11.83,42.59],EC:[-1.83,-78.18],EG:[26.82,30.80], + SV:[13.79,-88.90],ER:[15.18,39.78],EE:[58.60,25.01],ET:[9.15,40.49], + FI:[61.92,25.75],FR:[46.23,2.21],GA:[-0.80,11.61],GM:[13.44,-15.31], + GE:[42.32,43.36],DE:[51.17,10.45],GH:[7.95,-1.02],GR:[39.07,21.82], + GT:[15.78,-90.23],GN:[9.95,-9.70],HT:[18.97,-72.29],HN:[15.20,-86.24], + HK:[22.32,114.17],HU:[47.16,19.50],IN:[20.59,78.96],ID:[-0.79,113.92], + IR:[32.43,53.69],IQ:[33.22,43.68],IE:[53.14,-7.69],IL:[31.05,34.85], + IT:[41.87,12.57],CI:[7.54,-5.55],JP:[36.20,138.25],JO:[30.59,36.24], + KZ:[48.02,66.92],KE:[-0.02,37.91],KW:[29.31,47.48],KG:[41.20,74.77], + LA:[19.86,102.50],LV:[56.88,24.60],LB:[33.85,35.86],LY:[26.34,17.23], + LT:[55.17,23.88],LU:[49.82,6.13],MG:[-18.77,46.87],MW:[-13.25,34.30], + MY:[4.21,101.98],ML:[17.57,-4.00],MR:[21.01,-10.94],MX:[23.63,-102.55], + MD:[47.41,28.37],MN:[46.86,103.85],MA:[31.79,-7.09],MZ:[-18.67,35.53], + MM:[21.92,95.96],NA:[-22.96,18.49],NP:[28.39,84.12],NL:[52.13,5.29], + NZ:[-40.90,174.89],NI:[12.87,-85.21],NE:[17.61,8.08],NG:[9.08,8.68], + KP:[40.34,127.51],NO:[60.47,8.47],OM:[21.47,55.98],PK:[30.38,69.35], + PS:[31.95,35.23],PA:[8.54,-80.78],PG:[-6.32,143.96],PY:[-23.44,-58.44], + PE:[-9.19,-75.02],PH:[12.88,121.77],PL:[51.92,19.15],PT:[39.40,-8.22], + QA:[25.35,51.18],RO:[45.94,24.97],RU:[61.52,105.32],RW:[-1.94,29.87], + SA:[23.89,45.08],SN:[14.50,-14.45],RS:[44.02,21.01],SL:[8.46,-11.78], + SG:[1.35,103.82],SK:[48.67,19.70],SI:[46.15,14.99],SO:[5.15,46.20], + ZA:[-30.56,22.94],KR:[35.91,127.77],SS:[6.88,31.31],ES:[40.46,-3.75], + LK:[7.87,80.77],SD:[12.86,30.22],SE:[60.13,18.64],CH:[46.82,8.23], + SY:[34.80,38.997],TW:[23.70,120.96],TJ:[38.86,71.28],TZ:[-6.37,34.89], + TH:[15.87,100.99],TG:[8.62,0.82],TT:[10.69,-61.22],TN:[33.89,9.54], + TR:[38.96,35.24],TM:[38.97,59.56],UG:[1.37,32.29],UA:[48.38,31.17], + AE:[23.42,53.85],GB:[55.38,-3.44],US:[37.09,-95.71],UY:[-32.52,-55.77], + UZ:[41.38,64.59],VE:[6.42,-66.59],VN:[14.06,108.28],YE:[15.55,48.52], + ZM:[-13.13,27.85],ZW:[-19.02,29.15], +}; + +// ======================================================================== +// Helpers +// ======================================================================== + +function mapOutageSeverity(outageType: string | undefined): OutageSeverity { + if (outageType === 'NATIONWIDE') return 'OUTAGE_SEVERITY_TOTAL'; + if (outageType === 'REGIONAL') return 'OUTAGE_SEVERITY_MAJOR'; + return 'OUTAGE_SEVERITY_PARTIAL'; +} + +function toEpochMs(value: string | null | undefined): number { + if (!value) return 0; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? 0 : d.getTime(); +} + +// ======================================================================== +// RPC implementation +// ======================================================================== + +export async function listInternetOutages( + _ctx: ServerContext, + req: ListInternetOutagesRequest, +): Promise { + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + const token = process.env.CLOUDFLARE_API_TOKEN; + if (!token) return null; + + const response = await fetch( + `${CLOUDFLARE_RADAR_URL}?dateRange=7d&limit=50`, + { + headers: { Authorization: `Bearer ${token}`, 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + if (!response.ok) return null; + + const data: CloudflareResponse = await response.json(); + if (data.configured === false || !data.success || data.errors?.length) return null; + + const outages: InternetOutage[] = []; + + for (const raw of data.result?.annotations || []) { + if (!raw.locations?.length) continue; + const countryCode = raw.locations[0]; + if (!countryCode) continue; + + const coords = COUNTRY_COORDS[countryCode]; + if (!coords) continue; + + const countryName = raw.locationsDetails?.[0]?.name ?? countryCode; + + const categories: string[] = ['Cloudflare Radar']; + if (raw.outage?.outageCause) categories.push(raw.outage.outageCause.replace(/_/g, ' ')); + if (raw.outage?.outageType) categories.push(raw.outage.outageType); + for (const asn of raw.asnsDetails?.slice(0, 2) || []) { + if (asn.name) categories.push(asn.name); + } + + outages.push({ + id: `cf-${raw.id}`, + title: raw.scope ? `${raw.scope} outage in ${countryName}` : `Internet disruption in ${countryName}`, + link: raw.linkedUrl || 'https://radar.cloudflare.com/outage-center', + description: raw.description, + detectedAt: toEpochMs(raw.startDate), + country: countryName, + region: '', + location: { latitude: coords[0], longitude: coords[1] }, + severity: mapOutageSeverity(raw.outage?.outageType), + categories, + cause: raw.outage?.outageCause || '', + outageType: raw.outage?.outageType || '', + endedAt: toEpochMs(raw.endDate), + }); + } + + return outages.length > 0 ? { outages, pagination: undefined } : null; + }); + + const outages = result?.outages || []; + + // Always apply filters (to both cached and fresh data) + let filtered = outages; + if (req.country) { + const target = req.country.toLowerCase(); + filtered = outages.filter((o) => o.country.toLowerCase().includes(target)); + } + if (req.timeRange?.start) { + filtered = filtered.filter((o) => o.detectedAt >= req.timeRange!.start); + } + if (req.timeRange?.end) { + filtered = filtered.filter((o) => o.detectedAt <= req.timeRange!.end); + } + + return { outages: filtered, pagination: undefined }; + } catch { + return { outages: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/infrastructure/v1/list-service-statuses.ts b/server/worldmonitor/infrastructure/v1/list-service-statuses.ts new file mode 100644 index 000000000..350c2b089 --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/list-service-statuses.ts @@ -0,0 +1,321 @@ +import type { + ServerContext, + ListServiceStatusesRequest, + ListServiceStatusesResponse, + ServiceStatus, + ServiceOperationalStatus, +} from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; + +import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { cachedFetchJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; + +// ======================================================================== +// Service status page definitions and parsers +// ======================================================================== + +interface ServiceDef { + id: string; + name: string; + statusPage: string; + customParser?: string; + category: string; +} + +const SERVICES: ServiceDef[] = [ + // Cloud Providers + { id: 'aws', name: 'AWS', statusPage: 'https://health.aws.amazon.com/health/status', customParser: 'aws', category: 'cloud' }, + { id: 'azure', name: 'Azure', statusPage: 'https://azure.status.microsoft/en-us/status/feed/', customParser: 'rss', category: 'cloud' }, + { id: 'gcp', name: 'Google Cloud', statusPage: 'https://status.cloud.google.com/incidents.json', customParser: 'gcp', category: 'cloud' }, + { id: 'cloudflare', name: 'Cloudflare', statusPage: 'https://www.cloudflarestatus.com/api/v2/status.json', category: 'cloud' }, + { id: 'vercel', name: 'Vercel', statusPage: 'https://www.vercel-status.com/api/v2/status.json', category: 'cloud' }, + { id: 'netlify', name: 'Netlify', statusPage: 'https://www.netlifystatus.com/api/v2/status.json', category: 'cloud' }, + { id: 'digitalocean', name: 'DigitalOcean', statusPage: 'https://status.digitalocean.com/api/v2/status.json', category: 'cloud' }, + { id: 'render', name: 'Render', statusPage: 'https://status.render.com/api/v2/status.json', category: 'cloud' }, + { id: 'railway', name: 'Railway', statusPage: 'https://railway.instatus.com/summary.json', customParser: 'instatus', category: 'cloud' }, + // Developer Tools + { id: 'github', name: 'GitHub', statusPage: 'https://www.githubstatus.com/api/v2/status.json', category: 'dev' }, + { id: 'gitlab', name: 'GitLab', statusPage: 'https://status.gitlab.com/1.0/status/5b36dc6502d06804c08349f7', customParser: 'statusio', category: 'dev' }, + { id: 'npm', name: 'npm', statusPage: 'https://status.npmjs.org/api/v2/status.json', category: 'dev' }, + { id: 'docker', name: 'Docker Hub', statusPage: 'https://www.dockerstatus.com/1.0/status/533c6539221ae15e3f000031', customParser: 'statusio', category: 'dev' }, + { id: 'bitbucket', name: 'Bitbucket', statusPage: 'https://bitbucket.status.atlassian.com/api/v2/status.json', category: 'dev' }, + { id: 'circleci', name: 'CircleCI', statusPage: 'https://status.circleci.com/api/v2/status.json', category: 'dev' }, + { id: 'jira', name: 'Jira', statusPage: 'https://jira-software.status.atlassian.com/api/v2/status.json', category: 'dev' }, + { id: 'confluence', name: 'Confluence', statusPage: 'https://confluence.status.atlassian.com/api/v2/status.json', category: 'dev' }, + { id: 'linear', name: 'Linear', statusPage: 'https://linearstatus.com/api/v2/status.json', customParser: 'incidentio', category: 'dev' }, + // Communication + { id: 'slack', name: 'Slack', statusPage: 'https://slack-status.com/api/v2.0.0/current', customParser: 'slack', category: 'comm' }, + { id: 'discord', name: 'Discord', statusPage: 'https://discordstatus.com/api/v2/status.json', category: 'comm' }, + { id: 'zoom', name: 'Zoom', statusPage: 'https://www.zoomstatus.com/api/v2/status.json', category: 'comm' }, + { id: 'notion', name: 'Notion', statusPage: 'https://www.notion-status.com/api/v2/status.json', category: 'comm' }, + // AI Services + { id: 'openai', name: 'OpenAI', statusPage: 'https://status.openai.com/api/v2/status.json', customParser: 'incidentio', category: 'ai' }, + { id: 'anthropic', name: 'Anthropic', statusPage: 'https://status.claude.com/api/v2/status.json', customParser: 'incidentio', category: 'ai' }, + { id: 'replicate', name: 'Replicate', statusPage: 'https://www.replicatestatus.com/api/v2/status.json', customParser: 'incidentio', category: 'ai' }, + // SaaS + { id: 'stripe', name: 'Stripe', statusPage: 'https://status.stripe.com/current', customParser: 'stripe', category: 'saas' }, + { id: 'twilio', name: 'Twilio', statusPage: 'https://status.twilio.com/api/v2/status.json', category: 'saas' }, + { id: 'datadog', name: 'Datadog', statusPage: 'https://status.datadoghq.com/api/v2/status.json', category: 'saas' }, + { id: 'sentry', name: 'Sentry', statusPage: 'https://status.sentry.io/api/v2/status.json', category: 'saas' }, + { id: 'supabase', name: 'Supabase', statusPage: 'https://status.supabase.com/api/v2/status.json', category: 'saas' }, +]; + +// ======================================================================== +// Status normalization +// ======================================================================== + +function normalizeToProtoStatus(raw: string): ServiceOperationalStatus { + if (!raw) return 'SERVICE_OPERATIONAL_STATUS_UNSPECIFIED'; + const val = raw.toLowerCase(); + if (val === 'none' || val === 'operational' || val.includes('all systems operational')) { + return 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL'; + } + if (val === 'minor' || val === 'degraded_performance' || val.includes('degraded')) { + return 'SERVICE_OPERATIONAL_STATUS_DEGRADED'; + } + if (val === 'partial_outage') { + return 'SERVICE_OPERATIONAL_STATUS_PARTIAL_OUTAGE'; + } + if (val === 'major' || val === 'major_outage' || val === 'critical' || val.includes('outage')) { + return 'SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE'; + } + if (val === 'maintenance' || val.includes('maintenance')) { + return 'SERVICE_OPERATIONAL_STATUS_MAINTENANCE'; + } + return 'SERVICE_OPERATIONAL_STATUS_UNSPECIFIED'; +} + +// ======================================================================== +// Service status page checker +// ======================================================================== + +async function checkServiceStatus(service: ServiceDef): Promise { + const now = Date.now(); + const base: Pick = { + id: service.id, + name: service.name, + url: service.statusPage, + }; + const unknown = (desc: string): ServiceStatus => ({ + ...base, + status: 'SERVICE_OPERATIONAL_STATUS_UNSPECIFIED', + description: desc, + checkedAt: now, + latencyMs: 0, + }); + + try { + const headers: Record = { + Accept: service.customParser === 'rss' ? 'application/xml, text/xml' : 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache', + }; + if (service.customParser !== 'incidentio') { + headers['User-Agent'] = CHROME_UA; + } + + const start = Date.now(); + const response = await fetch(service.statusPage, { + headers, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + const latencyMs = Date.now() - start; + + if (!response.ok) { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_UNSPECIFIED', description: `HTTP ${response.status}`, checkedAt: now, latencyMs }; + } + + // Custom parsers + if (service.customParser === 'gcp') { + const data = await response.json() as any[]; + const active = Array.isArray(data) ? data.filter((i: any) => i.end === undefined || new Date(i.end) > new Date()) : []; + if (active.length === 0) { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', description: 'All services operational', checkedAt: now, latencyMs }; + } + const hasHigh = active.some((i: any) => i.severity === 'high'); + return { + ...base, + status: hasHigh ? 'SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE' : 'SERVICE_OPERATIONAL_STATUS_DEGRADED', + description: `${active.length} active incident(s)`, + checkedAt: now, latencyMs, + }; + } + + if (service.customParser === 'aws') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', description: 'Status page reachable', checkedAt: now, latencyMs }; + } + + if (service.customParser === 'rss') { + const text = await response.text(); + const hasIncident = text.includes('') && (text.includes('degradation') || text.includes('outage') || text.includes('incident')); + return { + ...base, + status: hasIncident ? 'SERVICE_OPERATIONAL_STATUS_DEGRADED' : 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', + description: hasIncident ? 'Recent incidents reported' : 'No recent incidents', + checkedAt: now, latencyMs, + }; + } + + if (service.customParser === 'instatus') { + const data = await response.json() as any; + const pageStatus = data.page?.status; + if (pageStatus === 'UP') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', description: 'All systems operational', checkedAt: now, latencyMs }; + } + if (pageStatus === 'HASISSUES') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_DEGRADED', description: 'Some issues reported', checkedAt: now, latencyMs }; + } + return unknown(pageStatus || 'Unknown'); + } + + if (service.customParser === 'statusio') { + const data = await response.json() as any; + const overall = data.result?.status_overall; + const code = overall?.status_code; + if (code === 100) { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', description: overall.status || 'All systems operational', checkedAt: now, latencyMs }; + } + if (code >= 300 && code < 500) { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_DEGRADED', description: overall.status || 'Degraded performance', checkedAt: now, latencyMs }; + } + if (code >= 500) { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE', description: overall.status || 'Service disruption', checkedAt: now, latencyMs }; + } + return unknown(overall?.status || 'Unknown status'); + } + + if (service.customParser === 'slack') { + const data = await response.json() as any; + if (data.status === 'ok') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', description: 'All systems operational', checkedAt: now, latencyMs }; + } + if (data.status === 'active' || data.active_incidents?.length > 0) { + const count = data.active_incidents?.length || 1; + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_DEGRADED', description: `${count} active incident(s)`, checkedAt: now, latencyMs }; + } + return unknown(data.status || 'Unknown'); + } + + if (service.customParser === 'stripe') { + const data = await response.json() as any; + if (data.largestatus === 'up') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_OPERATIONAL', description: data.message || 'All systems operational', checkedAt: now, latencyMs }; + } + if (data.largestatus === 'degraded') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_DEGRADED', description: data.message || 'Degraded performance', checkedAt: now, latencyMs }; + } + if (data.largestatus === 'down') { + return { ...base, status: 'SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE', description: data.message || 'Service disruption', checkedAt: now, latencyMs }; + } + return unknown(data.message || 'Unknown'); + } + + if (service.customParser === 'incidentio') { + const text = await response.text(); + if (text.startsWith(' { + try { + const results = await cachedFetchJson(INFRA_CACHE_KEY, INFRA_CACHE_TTL, async () => { + const fresh = await Promise.all(SERVICES.map(checkServiceStatus)); + return fresh.length > 0 ? fresh : null; + }) || []; + + // Apply optional status filter + let filtered = results; + if (req.status && req.status !== 'SERVICE_OPERATIONAL_STATUS_UNSPECIFIED') { + filtered = results.filter((s) => s.status === req.status); + } + + // Sort: outages first, then degraded, then operational + const statusOrder: Record = { + SERVICE_OPERATIONAL_STATUS_MAJOR_OUTAGE: 0, + SERVICE_OPERATIONAL_STATUS_PARTIAL_OUTAGE: 1, + SERVICE_OPERATIONAL_STATUS_DEGRADED: 2, + SERVICE_OPERATIONAL_STATUS_MAINTENANCE: 3, + SERVICE_OPERATIONAL_STATUS_UNSPECIFIED: 4, + SERVICE_OPERATIONAL_STATUS_OPERATIONAL: 5, + }; + filtered.sort((a, b) => (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4)); + + return { statuses: filtered }; + } catch { + return { statuses: [] }; + } +} diff --git a/server/worldmonitor/infrastructure/v1/record-baseline-snapshot.ts b/server/worldmonitor/infrastructure/v1/record-baseline-snapshot.ts new file mode 100644 index 000000000..875f86490 --- /dev/null +++ b/server/worldmonitor/infrastructure/v1/record-baseline-snapshot.ts @@ -0,0 +1,70 @@ +import type { + ServerContext, + RecordBaselineSnapshotRequest, + RecordBaselineSnapshotResponse, +} from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; + +import { setCachedJson } from '../../../_shared/redis'; +import { + VALID_BASELINE_TYPES, + BASELINE_TTL, + makeBaselineKey, + mgetJson, + type BaselineEntry, +} from './_shared'; + +// ======================================================================== +// RPC implementation +// ======================================================================== + +export async function recordBaselineSnapshot( + _ctx: ServerContext, + req: RecordBaselineSnapshotRequest, +): Promise { + try { + const updates = req.updates; + + if (!Array.isArray(updates) || updates.length === 0) { + return { updated: 0, error: 'Body must have updates array' }; + } + + const batch = updates.slice(0, 20); + const now = new Date(); + const weekday = now.getUTCDay(); + const month = now.getUTCMonth() + 1; + + const keys = batch.map(u => makeBaselineKey(u.type, u.region || 'global', weekday, month)); + const existing = await mgetJson(keys) as (BaselineEntry | null)[]; + + const writes: Promise[] = []; + + for (let i = 0; i < batch.length; i++) { + const { type, count } = batch[i]!; + if (!VALID_BASELINE_TYPES.includes(type) || typeof count !== 'number' || isNaN(count)) continue; + + const prev: BaselineEntry = existing[i] as BaselineEntry || { mean: 0, m2: 0, sampleCount: 0, lastUpdated: '' }; + + // Welford's online algorithm + const n = prev.sampleCount + 1; + const delta = count - prev.mean; + const newMean = prev.mean + delta / n; + const delta2 = count - newMean; + const newM2 = prev.m2 + delta * delta2; + + writes.push(setCachedJson(keys[i]!, { + mean: newMean, + m2: newM2, + sampleCount: n, + lastUpdated: now.toISOString(), + }, BASELINE_TTL)); + } + + if (writes.length > 0) { + await Promise.all(writes); + } + + return { updated: writes.length, error: '' }; + } catch { + return { updated: 0, error: 'Internal error' }; + } +} diff --git a/server/worldmonitor/intelligence/v1/_shared.ts b/server/worldmonitor/intelligence/v1/_shared.ts new file mode 100644 index 000000000..515eb234e --- /dev/null +++ b/server/worldmonitor/intelligence/v1/_shared.ts @@ -0,0 +1,28 @@ +/** + * Shared constants, types, and helpers used by multiple intelligence RPCs. + */ + +// ======================================================================== +// Constants +// ======================================================================== + +export const UPSTREAM_TIMEOUT_MS = 30_000; +export const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; +export const GROQ_MODEL = 'llama-3.1-8b-instant'; + +// ======================================================================== +// Tier-1 country definitions (used by risk-scores + country-intel-brief) +// ======================================================================== + +export const TIER1_COUNTRIES: Record = { + US: 'United States', RU: 'Russia', CN: 'China', UA: 'Ukraine', IR: 'Iran', + IL: 'Israel', TW: 'Taiwan', KP: 'North Korea', SA: 'Saudi Arabia', TR: 'Turkey', + PL: 'Poland', DE: 'Germany', FR: 'France', GB: 'United Kingdom', IN: 'India', + PK: 'Pakistan', SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela', +}; + +// ======================================================================== +// Helpers +// ======================================================================== + +export { hashString } from '../../../_shared/hash'; diff --git a/server/worldmonitor/intelligence/v1/classify-event.ts b/server/worldmonitor/intelligence/v1/classify-event.ts new file mode 100644 index 000000000..ab1484ee7 --- /dev/null +++ b/server/worldmonitor/intelligence/v1/classify-event.ts @@ -0,0 +1,118 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + ClassifyEventRequest, + ClassifyEventResponse, + SeverityLevel, +} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { UPSTREAM_TIMEOUT_MS, GROQ_API_URL, GROQ_MODEL, hashString } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; + +// ======================================================================== +// Constants +// ======================================================================== + +const CLASSIFY_CACHE_TTL = 86400; +const VALID_LEVELS = ['critical', 'high', 'medium', 'low', 'info']; +const VALID_CATEGORIES = [ + 'conflict', 'protest', 'disaster', 'diplomatic', 'economic', + 'terrorism', 'cyber', 'health', 'environmental', 'military', + 'crime', 'infrastructure', 'tech', 'general', +]; + +// ======================================================================== +// Helpers +// ======================================================================== + +function mapLevelToSeverity(level: string): SeverityLevel { + if (level === 'critical' || level === 'high') return 'SEVERITY_LEVEL_HIGH'; + if (level === 'medium') return 'SEVERITY_LEVEL_MEDIUM'; + return 'SEVERITY_LEVEL_LOW'; +} + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function classifyEvent( + _ctx: ServerContext, + req: ClassifyEventRequest, +): Promise { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) return { classification: undefined }; + + // Input sanitization (M-14 fix): limit title length + const MAX_TITLE_LEN = 500; + const title = typeof req.title === 'string' ? req.title.slice(0, MAX_TITLE_LEN) : ''; + if (!title) return { classification: undefined }; + + const cacheKey = `classify:sebuf:v1:${hashString(title.toLowerCase())}`; + + const cached = await cachedFetchJson<{ level: string; category: string; timestamp: number }>( + cacheKey, + CLASSIFY_CACHE_TTL, + async () => { + try { + const systemPrompt = `You classify news headlines into threat level and category. Return ONLY valid JSON, no other text. + +Levels: critical, high, medium, low, info +Categories: conflict, protest, disaster, diplomatic, economic, terrorism, cyber, health, environmental, military, crime, infrastructure, tech, general + +Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact. + +Return: {"level":"...","category":"..."}`; + + const resp = await fetch(GROQ_API_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': CHROME_UA }, + body: JSON.stringify({ + model: GROQ_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: title }, + ], + temperature: 0, + max_tokens: 50, + }), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + + if (!resp.ok) return null; + const data = (await resp.json()) as { choices?: Array<{ message?: { content?: string } }> }; + const raw = data.choices?.[0]?.message?.content?.trim(); + if (!raw) return null; + + let parsed: { level?: string; category?: string }; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + const level = VALID_LEVELS.includes(parsed.level ?? '') ? parsed.level! : null; + const category = VALID_CATEGORIES.includes(parsed.category ?? '') ? parsed.category! : null; + if (!level || !category) return null; + + return { level, category, timestamp: Date.now() }; + } catch { + return null; + } + }, + ); + + if (!cached?.level || !cached?.category) return { classification: undefined }; + + return { + classification: { + category: cached.category, + subcategory: cached.level, + severity: mapLevelToSeverity(cached.level), + confidence: 0.9, + analysis: '', + entities: [], + }, + }; +} diff --git a/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts b/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts new file mode 100644 index 000000000..d687c9a91 --- /dev/null +++ b/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts @@ -0,0 +1,92 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + GetCountryIntelBriefRequest, + GetCountryIntelBriefResponse, +} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { UPSTREAM_TIMEOUT_MS, GROQ_API_URL, GROQ_MODEL, TIER1_COUNTRIES } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; + +// ======================================================================== +// Constants +// ======================================================================== + +const INTEL_CACHE_TTL = 7200; + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function getCountryIntelBrief( + _ctx: ServerContext, + req: GetCountryIntelBriefRequest, +): Promise { + const empty: GetCountryIntelBriefResponse = { + countryCode: req.countryCode, + countryName: '', + brief: '', + model: GROQ_MODEL, + generatedAt: Date.now(), + }; + + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) return empty; + + const cacheKey = `ci-sebuf:v1:${req.countryCode}`; + const countryName = TIER1_COUNTRIES[req.countryCode] || req.countryCode; + const dateStr = new Date().toISOString().split('T')[0]; + + const systemPrompt = `You are a senior intelligence analyst providing comprehensive country situation briefs. Current date: ${dateStr}. Provide geopolitical context appropriate for the current date. + +Write a concise intelligence brief for the requested country covering: +1. Current Situation - what is happening right now +2. Military & Security Posture +3. Key Risk Factors +4. Regional Context +5. Outlook & Watch Items + +Rules: +- Be specific and analytical +- 4-5 paragraphs, 250-350 words +- No speculation beyond what data supports +- Use plain language, not jargon`; + + const result = await cachedFetchJson(cacheKey, INTEL_CACHE_TTL, async () => { + try { + const resp = await fetch(GROQ_API_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': CHROME_UA }, + body: JSON.stringify({ + model: GROQ_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `Country: ${countryName} (${req.countryCode})` }, + ], + temperature: 0.4, + max_tokens: 900, + }), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + + if (!resp.ok) return null; + const data = (await resp.json()) as { choices?: Array<{ message?: { content?: string } }> }; + const brief = data.choices?.[0]?.message?.content?.trim() || ''; + if (!brief) return null; + + return { + countryCode: req.countryCode, + countryName, + brief, + model: GROQ_MODEL, + generatedAt: Date.now(), + }; + } catch { + return null; + } + }); + + return result || empty; +} diff --git a/server/worldmonitor/intelligence/v1/get-pizzint-status.ts b/server/worldmonitor/intelligence/v1/get-pizzint-status.ts new file mode 100644 index 000000000..8ab95ec74 --- /dev/null +++ b/server/worldmonitor/intelligence/v1/get-pizzint-status.ts @@ -0,0 +1,156 @@ +import type { + ServerContext, + GetPizzintStatusRequest, + GetPizzintStatusResponse, + PizzintStatus, + PizzintLocation, + GdeltTensionPair, + TrendDirection, + DataFreshness, +} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'intel:pizzint:v1'; +const REDIS_CACHE_TTL = 600; // 10 min + +// ======================================================================== +// Constants +// ======================================================================== + +const PIZZINT_API = 'https://www.pizzint.watch/api/dashboard-data'; +const GDELT_BATCH_API = 'https://www.pizzint.watch/api/gdelt/batch'; +const DEFAULT_GDELT_PAIRS = 'usa_russia,russia_ukraine,usa_china,china_taiwan,usa_iran,usa_venezuela'; + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function getPizzintStatus( + _ctx: ServerContext, + req: GetPizzintStatusRequest, +): Promise { + const cacheKey = `${REDIS_CACHE_KEY}:${req.includeGdelt ? 'gdelt' : 'base'}`; + + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + let pizzint: PizzintStatus | undefined; + try { + const resp = await fetch(PIZZINT_API, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`PizzINT API returned ${resp.status}`); + + const raw = (await resp.json()) as { + success?: boolean; + data?: Array<{ + place_id: string; + name: string; + address: string; + current_popularity: number; + percentage_of_usual: number | null; + is_spike: boolean; + spike_magnitude: number | null; + data_source: string; + recorded_at: string; + data_freshness: string; + is_closed_now?: boolean; + lat?: number; + lng?: number; + }>; + }; + if (raw.success && raw.data) { + const locations: PizzintLocation[] = raw.data.map((d) => ({ + placeId: d.place_id, + name: d.name, + address: d.address, + currentPopularity: d.current_popularity, + percentageOfUsual: d.percentage_of_usual ?? 0, + isSpike: d.is_spike, + spikeMagnitude: d.spike_magnitude ?? 0, + dataSource: d.data_source, + recordedAt: d.recorded_at, + dataFreshness: (d.data_freshness === 'fresh' ? 'DATA_FRESHNESS_FRESH' : 'DATA_FRESHNESS_STALE') as DataFreshness, + isClosedNow: d.is_closed_now ?? false, + lat: d.lat ?? 0, + lng: d.lng ?? 0, + })); + + const openLocations = locations.filter((l) => !l.isClosedNow); + const activeSpikes = locations.filter((l) => l.isSpike).length; + const avgPop = openLocations.length > 0 + ? openLocations.reduce((s, l) => s + l.currentPopularity, 0) / openLocations.length + : 0; + + // DEFCON calculation + let adjusted = avgPop; + if (activeSpikes > 0) adjusted += activeSpikes * 10; + adjusted = Math.min(100, adjusted); + let defconLevel = 5; + let defconLabel = 'Normal Activity'; + if (adjusted >= 85) { defconLevel = 1; defconLabel = 'Maximum Activity'; } + else if (adjusted >= 70) { defconLevel = 2; defconLabel = 'High Activity'; } + else if (adjusted >= 50) { defconLevel = 3; defconLabel = 'Elevated Activity'; } + else if (adjusted >= 25) { defconLevel = 4; defconLabel = 'Above Normal'; } + + const hasFresh = locations.some((l) => l.dataFreshness === 'DATA_FRESHNESS_FRESH'); + + pizzint = { + defconLevel, + defconLabel, + aggregateActivity: Math.round(avgPop), + activeSpikes, + locationsMonitored: locations.length, + locationsOpen: openLocations.length, + updatedAt: Date.now(), + dataFreshness: (hasFresh ? 'DATA_FRESHNESS_FRESH' : 'DATA_FRESHNESS_STALE') as DataFreshness, + locations, + }; + } + } catch (_) { /* PizzINT unavailable — continue to GDELT */ } + + // Fetch GDELT tension pairs + let tensionPairs: GdeltTensionPair[] = []; + if (req.includeGdelt) { + try { + const url = `${GDELT_BATCH_API}?pairs=${encodeURIComponent(DEFAULT_GDELT_PAIRS)}&method=gpr`; + const resp = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (resp.ok) { + const raw = (await resp.json()) as Record>; + tensionPairs = Object.entries(raw).map(([pairKey, dataPoints]) => { + const countries = pairKey.split('_'); + const latest = dataPoints[dataPoints.length - 1]!; + const prev = dataPoints.length > 1 ? dataPoints[dataPoints.length - 2]! : latest; + const change = prev.v > 0 ? ((latest.v - prev.v) / prev.v) * 100 : 0; + const trend: TrendDirection = change > 5 + ? 'TREND_DIRECTION_RISING' + : change < -5 + ? 'TREND_DIRECTION_FALLING' + : 'TREND_DIRECTION_STABLE'; + + return { + id: pairKey, + countries, + label: countries.map((c) => c.toUpperCase()).join(' - '), + score: latest?.v ?? 0, + trend, + changePercent: Math.round(change * 10) / 10, + region: 'global', + }; + }); + } + } catch { /* gdelt unavailable */ } + } + + // Only cache if PizzINT data was retrieved + if (!pizzint) return null; + return { pizzint, tensionPairs }; + }); + + return result || { pizzint: undefined, tensionPairs: [] }; +} diff --git a/server/worldmonitor/intelligence/v1/get-risk-scores.ts b/server/worldmonitor/intelligence/v1/get-risk-scores.ts new file mode 100644 index 000000000..ea5ef62c5 --- /dev/null +++ b/server/worldmonitor/intelligence/v1/get-risk-scores.ts @@ -0,0 +1,183 @@ +import type { + ServerContext, + GetRiskScoresRequest, + GetRiskScoresResponse, + CiiScore, + StrategicRisk, + TrendDirection, + SeverityLevel, +} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { getCachedJson, setCachedJson, cachedFetchJson } from '../../../_shared/redis'; +import { TIER1_COUNTRIES } from './_shared'; +import { fetchAcledCached } from '../../../_shared/acled'; + +// ======================================================================== +// Country risk baselines and multipliers +// ======================================================================== + +const BASELINE_RISK: Record = { + US: 5, RU: 35, CN: 25, UA: 50, IR: 40, IL: 45, TW: 30, KP: 45, + SA: 20, TR: 25, PL: 10, DE: 5, FR: 10, GB: 5, IN: 20, PK: 35, + SY: 50, YE: 50, MM: 45, VE: 40, +}; + +const EVENT_MULTIPLIER: Record = { + US: 0.3, RU: 2.0, CN: 2.5, UA: 0.8, IR: 2.0, IL: 0.7, TW: 1.5, KP: 3.0, + SA: 2.0, TR: 1.2, PL: 0.8, DE: 0.5, FR: 0.6, GB: 0.5, IN: 0.8, PK: 1.5, + SY: 0.7, YE: 0.7, MM: 1.8, VE: 1.8, +}; + +const COUNTRY_KEYWORDS: Record = { + US: ['united states', 'usa', 'america', 'washington', 'biden', 'trump', 'pentagon'], + RU: ['russia', 'moscow', 'kremlin', 'putin'], + CN: ['china', 'beijing', 'xi jinping', 'prc'], + UA: ['ukraine', 'kyiv', 'zelensky', 'donbas'], + IR: ['iran', 'tehran', 'khamenei', 'irgc'], + IL: ['israel', 'tel aviv', 'netanyahu', 'idf', 'gaza'], + TW: ['taiwan', 'taipei'], + KP: ['north korea', 'pyongyang', 'kim jong'], + SA: ['saudi arabia', 'riyadh'], + TR: ['turkey', 'ankara', 'erdogan'], + PL: ['poland', 'warsaw'], + DE: ['germany', 'berlin'], + FR: ['france', 'paris', 'macron'], + GB: ['britain', 'uk', 'london'], + IN: ['india', 'delhi', 'modi'], + PK: ['pakistan', 'islamabad'], + SY: ['syria', 'damascus'], + YE: ['yemen', 'sanaa', 'houthi'], + MM: ['myanmar', 'burma'], + VE: ['venezuela', 'caracas', 'maduro'], +}; + +// ======================================================================== +// Internal helpers +// ======================================================================== + +function normalizeCountryName(text: string): string | null { + const lower = text.toLowerCase(); + for (const [code, keywords] of Object.entries(COUNTRY_KEYWORDS)) { + if (keywords.some((kw) => lower.includes(kw))) return code; + } + return null; +} + +interface AcledEvent { + country: string; + event_type: string; +} + +async function fetchACLEDProtests(): Promise { + const endDate = new Date().toISOString().split('T')[0]!; + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!; + const raw = await fetchAcledCached({ + eventTypes: 'Protests|Riots', + startDate, + endDate, + }); + return raw.map((e) => ({ country: e.country || '', event_type: e.event_type || '' })); +} + +function computeCIIScores(protests: AcledEvent[]): CiiScore[] { + const countryEvents = new Map(); + for (const event of protests) { + const code = normalizeCountryName(event.country); + if (code && TIER1_COUNTRIES[code]) { + const count = countryEvents.get(code) || { protests: 0, riots: 0 }; + if (event.event_type === 'Riots') count.riots++; + else count.protests++; + countryEvents.set(code, count); + } + } + + const scores: CiiScore[] = []; + for (const [code, _name] of Object.entries(TIER1_COUNTRIES)) { + const events = countryEvents.get(code) || { protests: 0, riots: 0 }; + const baseline = BASELINE_RISK[code] || 20; + const multiplier = EVENT_MULTIPLIER[code] || 1.0; + const unrest = Math.min(100, Math.round((events.protests + events.riots * 2) * multiplier * 2)); + const security = Math.min(100, baseline + events.riots * multiplier * 5); + const information = Math.min(100, (events.protests + events.riots) * multiplier * 3); + const composite = Math.min(100, Math.round(baseline + (unrest * 0.4 + security * 0.35 + information * 0.25) * 0.5)); + + scores.push({ + region: code, + staticBaseline: baseline, + dynamicScore: composite - baseline, + combinedScore: composite, + trend: 'TREND_DIRECTION_STABLE' as TrendDirection, + components: { + newsActivity: information, + ciiContribution: unrest, + geoConvergence: 0, + militaryActivity: 0, + }, + computedAt: Date.now(), + }); + } + + scores.sort((a, b) => b.combinedScore - a.combinedScore); + return scores; +} + +function computeStrategicRisks(ciiScores: CiiScore[]): StrategicRisk[] { + const top5 = ciiScores.slice(0, 5); + const weights = top5.map((_, i) => 1 - i * 0.15); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + const weightedSum = top5.reduce((sum, s, i) => sum + s.combinedScore * weights[i]!, 0); + const overallScore = Math.min(100, Math.round((weightedSum / totalWeight) * 0.7 + 15)); + + return [ + { + region: 'global', + level: (overallScore >= 70 + ? 'SEVERITY_LEVEL_HIGH' + : overallScore >= 40 + ? 'SEVERITY_LEVEL_MEDIUM' + : 'SEVERITY_LEVEL_LOW') as SeverityLevel, + score: overallScore, + factors: top5.map((s) => s.region), + trend: 'TREND_DIRECTION_STABLE' as TrendDirection, + }, + ]; +} + +// ======================================================================== +// Cache keys +// ======================================================================== + +const RISK_CACHE_KEY = 'risk:scores:sebuf:v1'; +const RISK_STALE_CACHE_KEY = 'risk:scores:sebuf:stale:v1'; +const RISK_CACHE_TTL = 600; +const RISK_STALE_TTL = 3600; + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function getRiskScores( + _ctx: ServerContext, + _req: GetRiskScoresRequest, +): Promise { + try { + const result = await cachedFetchJson( + RISK_CACHE_KEY, + RISK_CACHE_TTL, + async () => { + const protests = await fetchACLEDProtests(); + const ciiScores = computeCIIScores(protests); + const strategicRisks = computeStrategicRisks(ciiScores); + const r: GetRiskScoresResponse = { ciiScores, strategicRisks }; + await setCachedJson(RISK_STALE_CACHE_KEY, r, RISK_STALE_TTL).catch(() => {}); + return r; + }, + ); + if (result) return result; + } catch { /* upstream failed — fall through to stale */ } + + const stale = (await getCachedJson(RISK_STALE_CACHE_KEY)) as GetRiskScoresResponse | null; + if (stale) return stale; + const ciiScores = computeCIIScores([]); + return { ciiScores, strategicRisks: computeStrategicRisks(ciiScores) }; +} diff --git a/server/worldmonitor/intelligence/v1/handler.ts b/server/worldmonitor/intelligence/v1/handler.ts new file mode 100644 index 000000000..391599f77 --- /dev/null +++ b/server/worldmonitor/intelligence/v1/handler.ts @@ -0,0 +1,15 @@ +import type { IntelligenceServiceHandler } from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { getRiskScores } from './get-risk-scores'; +import { getPizzintStatus } from './get-pizzint-status'; +import { classifyEvent } from './classify-event'; +import { getCountryIntelBrief } from './get-country-intel-brief'; +import { searchGdeltDocuments } from './search-gdelt-documents'; + +export const intelligenceHandler: IntelligenceServiceHandler = { + getRiskScores, + getPizzintStatus, + classifyEvent, + getCountryIntelBrief, + searchGdeltDocuments, +}; diff --git a/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts b/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts new file mode 100644 index 000000000..cabb89a32 --- /dev/null +++ b/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts @@ -0,0 +1,105 @@ +import type { + ServerContext, + SearchGdeltDocumentsRequest, + SearchGdeltDocumentsResponse, + GdeltArticle, +} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'intel:gdelt-docs:v1'; +const REDIS_CACHE_TTL = 600; // 10 min + +// ======================================================================== +// Constants +// ======================================================================== + +const GDELT_MAX_RECORDS = 20; +const GDELT_DEFAULT_RECORDS = 10; +const GDELT_DOC_API = 'https://api.gdeltproject.org/api/v2/doc/doc'; + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function searchGdeltDocuments( + _ctx: ServerContext, + req: SearchGdeltDocumentsRequest, +): Promise { + let query = req.query; + if (!query || query.length < 2) { + return { articles: [], query: query || '', error: 'Query parameter required (min 2 characters)' }; + } + + // Append tone filter to query if provided (e.g., "tone>5" for positive articles) + if (req.toneFilter) { + query = `${query} ${req.toneFilter}`; + } + + const maxRecords = Math.min( + req.maxRecords > 0 ? req.maxRecords : GDELT_DEFAULT_RECORDS, + GDELT_MAX_RECORDS, + ); + const timespan = req.timespan || '72h'; + + try { + const cacheKey = `${REDIS_CACHE_KEY}:${query}:${timespan}:${maxRecords}`; + const result = await cachedFetchJson( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const gdeltUrl = new URL(GDELT_DOC_API); + gdeltUrl.searchParams.set('query', query); + gdeltUrl.searchParams.set('mode', 'artlist'); + gdeltUrl.searchParams.set('maxrecords', maxRecords.toString()); + gdeltUrl.searchParams.set('format', 'json'); + gdeltUrl.searchParams.set('sort', req.sort || 'date'); + gdeltUrl.searchParams.set('timespan', timespan); + + const response = await fetch(gdeltUrl.toString(), { + headers: { 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`GDELT returned ${response.status}`); + } + + const data = (await response.json()) as { + articles?: Array<{ + title?: string; + url?: string; + domain?: string; + source?: { domain?: string }; + seendate?: string; + socialimage?: string; + language?: string; + tone?: number; + }>; + }; + + const articles: GdeltArticle[] = (data.articles || []).map((article) => ({ + title: article.title || '', + url: article.url || '', + source: article.domain || article.source?.domain || '', + date: article.seendate || '', + image: article.socialimage || '', + language: article.language || '', + tone: typeof article.tone === 'number' ? article.tone : 0, + })); + + if (articles.length === 0) return null; + return { articles, query, error: '' } as SearchGdeltDocumentsResponse; + }, + ); + return result || { articles: [], query, error: '' }; + } catch (error) { + return { + articles: [], + query, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts new file mode 100644 index 000000000..06d383f28 --- /dev/null +++ b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts @@ -0,0 +1,159 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + GetVesselSnapshotRequest, + GetVesselSnapshotResponse, + VesselSnapshot, + AisDensityZone, + AisDisruption, + AisDisruptionType, + AisDisruptionSeverity, +} from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; + +// ======================================================================== +// Helpers +// ======================================================================== + +function getRelayBaseUrl(): string | null { + const relayUrl = process.env.WS_RELAY_URL; + if (!relayUrl) return null; + return relayUrl + .replace('wss://', 'https://') + .replace('ws://', 'http://') + .replace(/\/$/, ''); +} + +function getRelayRequestHeaders(): Record { + const headers: Record = { + Accept: 'application/json', + 'User-Agent': CHROME_UA, + }; + const relaySecret = process.env.RELAY_SHARED_SECRET; + if (relaySecret) { + const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase(); + headers[relayHeader] = relaySecret; + headers.Authorization = `Bearer ${relaySecret}`; + } + return headers; +} + +const DISRUPTION_TYPE_MAP: Record = { + gap_spike: 'AIS_DISRUPTION_TYPE_GAP_SPIKE', + chokepoint_congestion: 'AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION', +}; + +const SEVERITY_MAP: Record = { + low: 'AIS_DISRUPTION_SEVERITY_LOW', + elevated: 'AIS_DISRUPTION_SEVERITY_ELEVATED', + high: 'AIS_DISRUPTION_SEVERITY_HIGH', +}; + +// In-memory cache (matches old /api/ais-snapshot behavior) +const SNAPSHOT_CACHE_TTL_MS = 10_000; // 10 seconds -- matches client poll interval +let cachedSnapshot: VesselSnapshot | undefined; +let cacheTimestamp = 0; +let inFlightRequest: Promise | null = null; + +async function fetchVesselSnapshot(): Promise { + // Return cached if fresh + const now = Date.now(); + if (cachedSnapshot && (now - cacheTimestamp) < SNAPSHOT_CACHE_TTL_MS) { + return cachedSnapshot; + } + + // In-flight dedup: if a request is already running, await it + if (inFlightRequest) { + return inFlightRequest; + } + + inFlightRequest = fetchVesselSnapshotFromRelay(); + try { + const result = await inFlightRequest; + if (result) { + cachedSnapshot = result; + cacheTimestamp = Date.now(); + } + return result ?? cachedSnapshot; // serve stale on relay failure + } finally { + inFlightRequest = null; + } +} + +async function fetchVesselSnapshotFromRelay(): Promise { + try { + const relayBaseUrl = getRelayBaseUrl(); + if (!relayBaseUrl) return undefined; + + const response = await fetch( + `${relayBaseUrl}/ais/snapshot?candidates=false`, + { + headers: getRelayRequestHeaders(), + signal: AbortSignal.timeout(10000), + }, + ); + + if (!response.ok) return undefined; + + const data = await response.json(); + if (!data || !Array.isArray(data.disruptions) || !Array.isArray(data.density)) { + return undefined; + } + + const densityZones: AisDensityZone[] = data.density.map((z: any): AisDensityZone => ({ + id: String(z.id || ''), + name: String(z.name || ''), + location: { + latitude: Number(z.lat) || 0, + longitude: Number(z.lon) || 0, + }, + intensity: Number(z.intensity) || 0, + deltaPct: Number(z.deltaPct) || 0, + shipsPerDay: Number(z.shipsPerDay) || 0, + note: String(z.note || ''), + })); + + const disruptions: AisDisruption[] = data.disruptions.map((d: any): AisDisruption => ({ + id: String(d.id || ''), + name: String(d.name || ''), + type: DISRUPTION_TYPE_MAP[d.type] || 'AIS_DISRUPTION_TYPE_UNSPECIFIED', + location: { + latitude: Number(d.lat) || 0, + longitude: Number(d.lon) || 0, + }, + severity: SEVERITY_MAP[d.severity] || 'AIS_DISRUPTION_SEVERITY_UNSPECIFIED', + changePct: Number(d.changePct) || 0, + windowHours: Number(d.windowHours) || 0, + darkShips: Number(d.darkShips) || 0, + vesselCount: Number(d.vesselCount) || 0, + region: String(d.region || ''), + description: String(d.description || ''), + })); + + return { + snapshotAt: Date.now(), + densityZones, + disruptions, + }; + } catch { + return undefined; + } +} + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function getVesselSnapshot( + _ctx: ServerContext, + _req: GetVesselSnapshotRequest, +): Promise { + try { + const snapshot = await fetchVesselSnapshot(); + return { snapshot }; + } catch { + return { snapshot: undefined }; + } +} diff --git a/server/worldmonitor/maritime/v1/handler.ts b/server/worldmonitor/maritime/v1/handler.ts new file mode 100644 index 000000000..026b7346c --- /dev/null +++ b/server/worldmonitor/maritime/v1/handler.ts @@ -0,0 +1,9 @@ +import type { MaritimeServiceHandler } from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server'; + +import { getVesselSnapshot } from './get-vessel-snapshot'; +import { listNavigationalWarnings } from './list-navigational-warnings'; + +export const maritimeHandler: MaritimeServiceHandler = { + getVesselSnapshot, + listNavigationalWarnings, +}; diff --git a/server/worldmonitor/maritime/v1/list-navigational-warnings.ts b/server/worldmonitor/maritime/v1/list-navigational-warnings.ts new file mode 100644 index 000000000..c474ae10a --- /dev/null +++ b/server/worldmonitor/maritime/v1/list-navigational-warnings.ts @@ -0,0 +1,93 @@ +import type { + ServerContext, + ListNavigationalWarningsRequest, + ListNavigationalWarningsResponse, + NavigationalWarning, +} from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'maritime:navwarnings:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — NGA broadcasts update daily + +// ======================================================================== +// Helpers +// ======================================================================== + +const NGA_WARNINGS_URL = 'https://msi.nga.mil/api/publications/broadcast-warn?output=json&status=A'; + +function parseNgaDate(dateStr: unknown): number { + if (!dateStr || typeof dateStr !== 'string') return 0; + // Format: "081653Z MAY 2024" + const match = dateStr.match(/(\d{2})(\d{4})Z\s+([A-Z]{3})\s+(\d{4})/i); + if (!match) return Date.parse(dateStr) || 0; + const months: Record = { + JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, + JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11, + }; + const day = parseInt(match[1]!, 10); + const hours = parseInt(match[2]!.slice(0, 2), 10); + const minutes = parseInt(match[2]!.slice(2, 4), 10); + const month = months[match[3]!.toUpperCase()] ?? 0; + const year = parseInt(match[4]!, 10); + return Date.UTC(year, month, day, hours, minutes); +} + +async function fetchNgaWarnings(area?: string): Promise { + try { + const response = await fetch(NGA_WARNINGS_URL, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) return []; + + const data = await response.json(); + const rawWarnings: any[] = Array.isArray(data) ? data : (data?.broadcast_warn ?? []); + + let warnings: NavigationalWarning[] = rawWarnings.map((w: any): NavigationalWarning => ({ + id: `${w.navArea || ''}-${w.msgYear || ''}-${w.msgNumber || ''}`, + title: `NAVAREA ${w.navArea || ''} ${w.msgNumber || ''}/${w.msgYear || ''}`, + text: w.text || '', + area: `${w.navArea || ''}${w.subregion ? ' ' + w.subregion : ''}`, + location: undefined, + issuedAt: parseNgaDate(w.issueDate), + expiresAt: 0, + authority: w.authority || '', + })); + + if (area) { + const areaLower = area.toLowerCase(); + warnings = warnings.filter( + (w) => + w.area.toLowerCase().includes(areaLower) || + w.text.toLowerCase().includes(areaLower), + ); + } + + return warnings; + } catch { + return []; + } +} + +// ======================================================================== +// RPC handler +// ======================================================================== + +export async function listNavigationalWarnings( + _ctx: ServerContext, + req: ListNavigationalWarningsRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.area || 'all'}`; + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const warnings = await fetchNgaWarnings(req.area); + return warnings.length > 0 ? { warnings, pagination: undefined } : null; + }); + return result || { warnings: [], pagination: undefined }; + } catch { + return { warnings: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/market/v1/_shared.ts b/server/worldmonitor/market/v1/_shared.ts new file mode 100644 index 000000000..736c8045a --- /dev/null +++ b/server/worldmonitor/market/v1/_shared.ts @@ -0,0 +1,185 @@ +/** + * Shared helpers, types, and constants for the market service handler RPCs. + */ + +declare const process: { env: Record }; + +import { CHROME_UA, yahooGate } from '../../../_shared/constants'; + +// ======================================================================== +// Constants +// ======================================================================== + +export const UPSTREAM_TIMEOUT_MS = 10_000; + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +export async function fetchYahooQuotesBatch( + symbols: string[], +): Promise<{ results: Map; rateLimited: boolean }> { + const results = new Map(); + let rateLimitHits = 0; + for (let i = 0; i < symbols.length; i++) { + const q = await fetchYahooQuote(symbols[i]!); + if (q) { + results.set(symbols[i]!, q); + } else { + rateLimitHits++; + } + if (rateLimitHits >= 3 && results.size === 0) break; + } + return { results, rateLimited: rateLimitHits >= 3 && results.size === 0 }; +} + +// Yahoo-only symbols: indices and futures not on Finnhub free tier +export const YAHOO_ONLY_SYMBOLS = new Set([ + '^GSPC', '^DJI', '^IXIC', '^VIX', + 'GC=F', 'CL=F', 'NG=F', 'SI=F', 'HG=F', +]); + +// Known crypto IDs and their metadata +export const CRYPTO_META: Record = { + bitcoin: { name: 'Bitcoin', symbol: 'BTC' }, + ethereum: { name: 'Ethereum', symbol: 'ETH' }, + solana: { name: 'Solana', symbol: 'SOL' }, + ripple: { name: 'XRP', symbol: 'XRP' }, +}; + +// ======================================================================== +// Types +// ======================================================================== + +export interface YahooChartResponse { + chart: { + result: Array<{ + meta: { + regularMarketPrice: number; + chartPreviousClose?: number; + previousClose?: number; + }; + indicators?: { + quote?: Array<{ close?: (number | null)[] }>; + }; + }>; + }; +} + +export interface CoinGeckoMarketItem { + id: string; + current_price: number; + price_change_percentage_24h: number; + sparkline_in_7d?: { price: number[] }; +} + +// ======================================================================== +// Finnhub quote fetcher +// ======================================================================== + +export async function fetchFinnhubQuote( + symbol: string, + apiKey: string, +): Promise<{ symbol: string; price: number; changePercent: number } | null> { + try { + const url = `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(symbol)}&token=${apiKey}`; + const resp = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!resp.ok) return null; + + const data = await resp.json() as { c: number; d: number; dp: number; h: number; l: number; o: number; pc: number; t: number }; + if (data.c === 0 && data.h === 0 && data.l === 0) return null; + + return { symbol, price: data.c, changePercent: data.dp }; + } catch { + return null; + } +} + +// ======================================================================== +// Yahoo Finance quote fetcher +// ======================================================================== +// TODO: Add Financial Modeling Prep (FMP) as Yahoo Finance fallback. +// +// FMP API docs: https://site.financialmodelingprep.com/developer/docs +// Auth: API key required — env var FMP_API_KEY +// Free tier: 250 requests/day (paid tiers for higher volume) +// +// Endpoint mapping (Yahoo → FMP): +// Quote: /stable/quote?symbol=AAPL (batch: comma-separated) +// Indices: /stable/quote?symbol=^GSPC (^GSPC, ^DJI, ^IXIC supported) +// Commodities:/stable/quote?symbol=GCUSD (gold=GCUSD, oil=CLUSD, etc.) +// Forex: /stable/batch-forex-quotes (JPY/USD pairs) +// Crypto: /stable/batch-crypto-quotes (BTC, ETH, etc.) +// Sparkline: /stable/historical-price-eod/light?symbol=AAPL (daily close) +// Intraday: /stable/historical-chart/1min?symbol=AAPL +// +// Symbol mapping needed: +// ^GSPC → ^GSPC (same), ^VIX → ^VIX (same) +// GC=F → GCUSD, CL=F → CLUSD, NG=F → NGUSD, SI=F → SIUSD, HG=F → HGUSD +// JPY=X → JPYUSD (forex pair format differs) +// BTC-USD → BTCUSD +// +// Implementation plan: +// 1. Add FMP_API_KEY to SUPPORTED_SECRET_KEYS in main.rs + settings UI +// 2. Create fetchFMPQuote() here returning same shape as fetchYahooQuote() +// 3. fetchYahooQuote() tries Yahoo first → on 429/failure, tries FMP if key exists +// 4. economic/_shared.ts fetchJSON() same fallback for Yahoo chart URLs +// 5. get-macro-signals.ts needs chart data (1y range) — use /stable/historical-price-eod/light +// ======================================================================== + +export async function fetchYahooQuote( + symbol: string, +): Promise<{ price: number; change: number; sparkline: number[] } | null> { + try { + await yahooGate(); + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`; + const resp = await fetch(url, { + headers: { + 'User-Agent': CHROME_UA, + }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!resp.ok) return null; + + const data: YahooChartResponse = await resp.json(); + const result = data.chart.result[0]; + const meta = result?.meta; + if (!meta) return null; + + const price = meta.regularMarketPrice; + const prevClose = meta.chartPreviousClose || meta.previousClose || price; + const change = ((price - prevClose) / prevClose) * 100; + + const closes = result.indicators?.quote?.[0]?.close; + const sparkline = closes?.filter((v): v is number => v != null) || []; + + return { price, change, sparkline }; + } catch { + return null; + } +} + +// ======================================================================== +// CoinGecko fetcher +// ======================================================================== + +export async function fetchCoinGeckoMarkets( + ids: string[], +): Promise { + const url = `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${ids.join(',')}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`; + const resp = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`CoinGecko HTTP ${resp.status}: ${body.slice(0, 200)}`); + } + + const data = await resp.json(); + if (!Array.isArray(data)) { + throw new Error(`CoinGecko returned non-array: ${JSON.stringify(data).slice(0, 200)}`); + } + return data; +} diff --git a/server/worldmonitor/market/v1/get-country-stock-index.ts b/server/worldmonitor/market/v1/get-country-stock-index.ts new file mode 100644 index 000000000..fd6cbab12 --- /dev/null +++ b/server/worldmonitor/market/v1/get-country-stock-index.ts @@ -0,0 +1,145 @@ +/** + * RPC: GetCountryStockIndex + * Fetches national stock market index data from Yahoo Finance. + */ + +import type { + ServerContext, + GetCountryStockIndexRequest, + GetCountryStockIndexResponse, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { UPSTREAM_TIMEOUT_MS, type YahooChartResponse } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +// ======================================================================== +// Country-to-index mapping +// ======================================================================== + +const COUNTRY_INDEX: Record = { + US: { symbol: '^GSPC', name: 'S&P 500' }, + GB: { symbol: '^FTSE', name: 'FTSE 100' }, + DE: { symbol: '^GDAXI', name: 'DAX' }, + FR: { symbol: '^FCHI', name: 'CAC 40' }, + JP: { symbol: '^N225', name: 'Nikkei 225' }, + CN: { symbol: '000001.SS', name: 'SSE Composite' }, + HK: { symbol: '^HSI', name: 'Hang Seng' }, + IN: { symbol: '^BSESN', name: 'BSE Sensex' }, + KR: { symbol: '^KS11', name: 'KOSPI' }, + TW: { symbol: '^TWII', name: 'TAIEX' }, + AU: { symbol: '^AXJO', name: 'ASX 200' }, + BR: { symbol: '^BVSP', name: 'Bovespa' }, + CA: { symbol: '^GSPTSE', name: 'TSX Composite' }, + MX: { symbol: '^MXX', name: 'IPC Mexico' }, + AR: { symbol: '^MERV', name: 'MERVAL' }, + RU: { symbol: 'IMOEX.ME', name: 'MOEX' }, + ZA: { symbol: '^J203.JO', name: 'JSE All Share' }, + SA: { symbol: '^TASI.SR', name: 'Tadawul' }, + AE: { symbol: 'DFMGI.AE', name: 'DFM General' }, + IL: { symbol: '^TA125.TA', name: 'TA-125' }, + TR: { symbol: 'XU100.IS', name: 'BIST 100' }, + PL: { symbol: '^WIG20', name: 'WIG 20' }, + NL: { symbol: '^AEX', name: 'AEX' }, + CH: { symbol: '^SSMI', name: 'SMI' }, + ES: { symbol: '^IBEX', name: 'IBEX 35' }, + IT: { symbol: 'FTSEMIB.MI', name: 'FTSE MIB' }, + SE: { symbol: '^OMX', name: 'OMX Stockholm 30' }, + NO: { symbol: '^OSEAX', name: 'Oslo All Share' }, + SG: { symbol: '^STI', name: 'STI' }, + TH: { symbol: '^SET.BK', name: 'SET' }, + MY: { symbol: '^KLSE', name: 'KLCI' }, + ID: { symbol: '^JKSE', name: 'Jakarta Composite' }, + PH: { symbol: 'PSEI.PS', name: 'PSEi' }, + NZ: { symbol: '^NZ50', name: 'NZX 50' }, + EG: { symbol: '^EGX30.CA', name: 'EGX 30' }, + CL: { symbol: '^IPSA', name: 'IPSA' }, + PE: { symbol: '^SPBLPGPT', name: 'S&P Lima' }, + AT: { symbol: '^ATX', name: 'ATX' }, + BE: { symbol: '^BFX', name: 'BEL 20' }, + FI: { symbol: '^OMXH25', name: 'OMX Helsinki 25' }, + DK: { symbol: '^OMXC25', name: 'OMX Copenhagen 25' }, + IE: { symbol: '^ISEQ', name: 'ISEQ Overall' }, + PT: { symbol: '^PSI20', name: 'PSI 20' }, + CZ: { symbol: '^PX', name: 'PX Prague' }, + HU: { symbol: '^BUX', name: 'BUX' }, +}; + +// ======================================================================== +// Cache +// ======================================================================== + +const REDIS_CACHE_KEY = 'market:stock-index:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min — weekly data, slow-moving + +let stockIndexCache: Record = {}; +const STOCK_INDEX_CACHE_TTL = 3_600_000; // 1 hour (in-memory fallback) + +// ======================================================================== +// Handler +// ======================================================================== + +export async function getCountryStockIndex( + _ctx: ServerContext, + req: GetCountryStockIndexRequest, +): Promise { + const code = (req.countryCode || '').toUpperCase(); + const notAvailable: GetCountryStockIndexResponse = { + available: false, code, symbol: '', indexName: '', price: 0, weekChangePercent: 0, currency: '', fetchedAt: '', + }; + + if (!code) return notAvailable; + + const index = COUNTRY_INDEX[code]; + if (!index) return notAvailable; + + const cached = stockIndexCache[code]; + if (cached && Date.now() - cached.ts < STOCK_INDEX_CACHE_TTL) return cached.data; + + const redisKey = `${REDIS_CACHE_KEY}:${code}`; + + try { + const result = await cachedFetchJson(redisKey, REDIS_CACHE_TTL, async () => { + const encodedSymbol = encodeURIComponent(index.symbol); + const yahooUrl = `https://query1.finance.yahoo.com/v8/finance/chart/${encodedSymbol}?range=1mo&interval=1d`; + + const res = await fetch(yahooUrl, { + headers: { 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + + if (!res.ok) return null; + + const data: YahooChartResponse = await res.json(); + const chartResult = data?.chart?.result?.[0]; + if (!chartResult) return null; + + const allCloses = chartResult.indicators?.quote?.[0]?.close?.filter((v): v is number => v != null); + if (!allCloses || allCloses.length < 2) return null; + + const closes = allCloses.slice(-6); + const latest = closes[closes.length - 1]!; + const oldest = closes[0]!; + const weekChange = ((latest - oldest) / oldest) * 100; + const meta = chartResult.meta || {}; + + return { + available: true, + code, + symbol: index.symbol, + indexName: index.name, + price: +latest.toFixed(2), + weekChangePercent: +weekChange.toFixed(2), + currency: (meta as { currency?: string }).currency || 'USD', + fetchedAt: new Date().toISOString(), + }; + }); + + if (result?.available) { + stockIndexCache[code] = { data: result, ts: Date.now() }; + } + + return result || notAvailable; + } catch { + return notAvailable; + } +} diff --git a/server/worldmonitor/market/v1/get-sector-summary.ts b/server/worldmonitor/market/v1/get-sector-summary.ts new file mode 100644 index 000000000..669e3a33e --- /dev/null +++ b/server/worldmonitor/market/v1/get-sector-summary.ts @@ -0,0 +1,56 @@ +/** + * RPC: GetSectorSummary + * Fetches sector ETF performance from Finnhub. + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetSectorSummaryRequest, + GetSectorSummaryResponse, + SectorPerformance, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { fetchFinnhubQuote, fetchYahooQuotesBatch } from './_shared'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:sectors:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — Finnhub rate-limited + +export async function getSectorSummary( + _ctx: ServerContext, + _req: GetSectorSummaryRequest, +): Promise { + const apiKey = process.env.FINNHUB_API_KEY; + + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + const sectorSymbols = ['XLK', 'XLF', 'XLE', 'XLV', 'XLY', 'XLI', 'XLP', 'XLU', 'XLB', 'XLRE', 'XLC', 'SMH']; + const sectors: SectorPerformance[] = []; + + if (apiKey) { + const results = await Promise.all( + sectorSymbols.map((s) => fetchFinnhubQuote(s, apiKey)), + ); + for (const r of results) { + if (r) sectors.push({ symbol: r.symbol, name: r.symbol, change: r.changePercent }); + } + } + + // Fallback to Yahoo Finance when Finnhub key is missing or returned nothing + if (sectors.length === 0) { + const batch = await fetchYahooQuotesBatch(sectorSymbols); + for (const s of sectorSymbols) { + const yahoo = batch.results.get(s); + if (yahoo) sectors.push({ symbol: s, name: s, change: yahoo.change }); + } + } + + return sectors.length > 0 ? { sectors } : null; + }); + + return result || { sectors: [] }; + } catch { + return { sectors: [] }; + } +} diff --git a/server/worldmonitor/market/v1/handler.ts b/server/worldmonitor/market/v1/handler.ts new file mode 100644 index 000000000..381d939dd --- /dev/null +++ b/server/worldmonitor/market/v1/handler.ts @@ -0,0 +1,31 @@ +/** + * Market service handler -- thin composition of per-RPC modules. + * + * RPCs: + * - ListMarketQuotes (Finnhub + Yahoo Finance for stocks/indices) + * - ListCryptoQuotes (CoinGecko markets API) + * - ListCommodityQuotes (Yahoo Finance for commodity futures) + * - GetSectorSummary (Finnhub for sector ETFs) + * - ListStablecoinMarkets (CoinGecko stablecoin peg health) + * - ListEtfFlows (Yahoo Finance BTC spot ETF flow estimates) + * - GetCountryStockIndex (Yahoo Finance national stock indices) + */ + +import type { MarketServiceHandler } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { listMarketQuotes } from './list-market-quotes'; +import { listCryptoQuotes } from './list-crypto-quotes'; +import { listCommodityQuotes } from './list-commodity-quotes'; +import { getSectorSummary } from './get-sector-summary'; +import { listStablecoinMarkets } from './list-stablecoin-markets'; +import { listEtfFlows } from './list-etf-flows'; +import { getCountryStockIndex } from './get-country-stock-index'; + +export const marketHandler: MarketServiceHandler = { + listMarketQuotes, + listCryptoQuotes, + listCommodityQuotes, + getSectorSummary, + listStablecoinMarkets, + listEtfFlows, + getCountryStockIndex, +}; diff --git a/server/worldmonitor/market/v1/list-commodity-quotes.ts b/server/worldmonitor/market/v1/list-commodity-quotes.ts new file mode 100644 index 000000000..4836dde82 --- /dev/null +++ b/server/worldmonitor/market/v1/list-commodity-quotes.ts @@ -0,0 +1,48 @@ +/** + * RPC: ListCommodityQuotes + * Fetches commodity futures quotes from Yahoo Finance. + */ + +import type { + ServerContext, + ListCommodityQuotesRequest, + ListCommodityQuotesResponse, + CommodityQuote, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { fetchYahooQuotesBatch } from './_shared'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:commodities:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — commodities move slower than indices + +function redisCacheKey(symbols: string[]): string { + return `${REDIS_CACHE_KEY}:${[...symbols].sort().join(',')}`; +} + +export async function listCommodityQuotes( + _ctx: ServerContext, + req: ListCommodityQuotesRequest, +): Promise { + const symbols = req.symbols; + if (!symbols.length) return { quotes: [] }; + + const redisKey = redisCacheKey(symbols); + + try { + const result = await cachedFetchJson(redisKey, REDIS_CACHE_TTL, async () => { + const batch = await fetchYahooQuotesBatch(symbols); + const quotes: CommodityQuote[] = []; + for (const s of symbols) { + const yahoo = batch.results.get(s); + if (yahoo) { + quotes.push({ symbol: s, name: s, display: s, price: yahoo.price, change: yahoo.change, sparkline: yahoo.sparkline }); + } + } + return quotes.length > 0 ? { quotes } : null; + }); + + return result || { quotes: [] }; + } catch { + return { quotes: [] }; + } +} diff --git a/server/worldmonitor/market/v1/list-crypto-quotes.ts b/server/worldmonitor/market/v1/list-crypto-quotes.ts new file mode 100644 index 000000000..04159e9ab --- /dev/null +++ b/server/worldmonitor/market/v1/list-crypto-quotes.ts @@ -0,0 +1,64 @@ +/** + * RPC: ListCryptoQuotes + * Fetches cryptocurrency quotes from CoinGecko markets API. + */ + +import type { + ServerContext, + ListCryptoQuotesRequest, + ListCryptoQuotesResponse, + CryptoQuote, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { CRYPTO_META, fetchCoinGeckoMarkets } from './_shared'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:crypto:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — CoinGecko rate-limited + +export async function listCryptoQuotes( + _ctx: ServerContext, + req: ListCryptoQuotesRequest, +): Promise { + const ids = req.ids.length > 0 ? req.ids : Object.keys(CRYPTO_META); + + const cacheKey = `${REDIS_CACHE_KEY}:${[...ids].sort().join(',')}`; + + try { + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const items = await fetchCoinGeckoMarkets(ids); + + if (items.length === 0) { + throw new Error('CoinGecko returned no data'); + } + + const byId = new Map(items.map((c) => [c.id, c])); + const quotes: CryptoQuote[] = []; + + for (const id of ids) { + const coin = byId.get(id); + if (!coin) continue; + const meta = CRYPTO_META[id]; + const prices = coin.sparkline_in_7d?.price; + const sparkline = prices && prices.length > 24 ? prices.slice(-48) : (prices || []); + + quotes.push({ + name: meta?.name || id, + symbol: meta?.symbol || id.toUpperCase(), + price: coin.current_price ?? 0, + change: coin.price_change_percentage_24h ?? 0, + sparkline, + }); + } + + if (quotes.every(q => q.price === 0)) { + throw new Error('CoinGecko returned all-zero prices'); + } + + return quotes.length > 0 ? { quotes } : null; + }); + + return result || { quotes: [] }; + } catch { + return { quotes: [] }; + } +} diff --git a/server/worldmonitor/market/v1/list-etf-flows.ts b/server/worldmonitor/market/v1/list-etf-flows.ts new file mode 100644 index 000000000..c78ec6afc --- /dev/null +++ b/server/worldmonitor/market/v1/list-etf-flows.ts @@ -0,0 +1,196 @@ +/** + * RPC: ListEtfFlows + * Estimates BTC spot ETF flow direction from Yahoo Finance volume/price data. + */ + +import type { + ServerContext, + ListEtfFlowsRequest, + ListEtfFlowsResponse, + EtfFlow, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { UPSTREAM_TIMEOUT_MS, type YahooChartResponse } from './_shared'; +import { CHROME_UA, yahooGate } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +// ======================================================================== +// Constants and cache +// ======================================================================== + +const REDIS_CACHE_KEY = 'market:etf-flows:v1'; +const REDIS_CACHE_TTL = 600; // 10 min — daily volume data, slow-moving + +const ETF_LIST = [ + { ticker: 'IBIT', issuer: 'BlackRock' }, + { ticker: 'FBTC', issuer: 'Fidelity' }, + { ticker: 'ARKB', issuer: 'ARK/21Shares' }, + { ticker: 'BITB', issuer: 'Bitwise' }, + { ticker: 'GBTC', issuer: 'Grayscale' }, + { ticker: 'HODL', issuer: 'VanEck' }, + { ticker: 'BRRR', issuer: 'Valkyrie' }, + { ticker: 'EZBC', issuer: 'Franklin' }, + { ticker: 'BTCO', issuer: 'Invesco' }, + { ticker: 'BTCW', issuer: 'WisdomTree' }, +]; + +let etfCache: ListEtfFlowsResponse | null = null; +let etfCacheTimestamp = 0; +const ETF_CACHE_TTL = 900_000; // 15 minutes (in-memory fallback) + +// ======================================================================== +// Helpers +// ======================================================================== + +async function fetchEtfChart(ticker: string): Promise { + try { + await yahooGate(); + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=5d&interval=1d`; + const resp = await fetch(url, { + headers: { + 'User-Agent': CHROME_UA, + }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!resp.ok) return null; + return (await resp.json()) as YahooChartResponse; + } catch { + return null; + } +} + +function parseEtfChartData(chart: YahooChartResponse, ticker: string, issuer: string): EtfFlow | null { + try { + const result = chart?.chart?.result?.[0]; + if (!result) return null; + + const quote = result.indicators?.quote?.[0]; + const closes = (quote as { close?: (number | null)[] })?.close || []; + const volumes = (quote as { volume?: (number | null)[] })?.volume || []; + + const validCloses = closes.filter((p): p is number => p != null); + const validVolumes = volumes.filter((v): v is number => v != null); + + if (validCloses.length < 2) return null; + + const latestPrice = validCloses[validCloses.length - 1]!; + const prevPrice = validCloses[validCloses.length - 2]!; + const priceChange = prevPrice ? ((latestPrice - prevPrice) / prevPrice * 100) : 0; + + const latestVolume = validVolumes.length > 0 ? validVolumes[validVolumes.length - 1]! : 0; + const avgVolume = validVolumes.length > 1 + ? validVolumes.slice(0, -1).reduce((a, b) => a + b, 0) / (validVolumes.length - 1) + : latestVolume; + + const volumeRatio = avgVolume > 0 ? latestVolume / avgVolume : 1; + const direction = priceChange > 0.1 ? 'inflow' : priceChange < -0.1 ? 'outflow' : 'neutral'; + const estFlowMagnitude = latestVolume * latestPrice * (priceChange > 0 ? 1 : -1) * 0.1; + + return { + ticker, + issuer, + price: +latestPrice.toFixed(2), + priceChange: +priceChange.toFixed(2), + volume: latestVolume, + avgVolume: Math.round(avgVolume), + volumeRatio: +volumeRatio.toFixed(2), + direction, + estFlow: Math.round(estFlowMagnitude), + }; + } catch { + return null; + } +} + +// ======================================================================== +// Handler +// ======================================================================== + +export async function listEtfFlows( + _ctx: ServerContext, + _req: ListEtfFlowsRequest, +): Promise { + const now = Date.now(); + if (etfCache && now - etfCacheTimestamp < ETF_CACHE_TTL) { + return etfCache; + } + + try { + const result = await cachedFetchJson(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => { + const etfs: EtfFlow[] = []; + let misses = 0; + for (const etf of ETF_LIST) { + const chart = await fetchEtfChart(etf.ticker); + if (chart) { + const parsed = parseEtfChartData(chart, etf.ticker, etf.issuer); + if (parsed) etfs.push(parsed); else misses++; + } else { + misses++; + } + if (misses >= 3 && etfs.length === 0) break; + } + + // Stale-while-revalidate: if Yahoo rate-limited all calls, serve cached data + if (etfs.length === 0 && etfCache) { + return etfCache; + } + + if (etfs.length === 0) { + const rateLimited = misses >= 3; + return rateLimited + ? { timestamp: new Date().toISOString(), etfs: [], rateLimited: true } + : null; + } + + const totalVolume = etfs.reduce((sum, e) => sum + e.volume, 0); + const totalEstFlow = etfs.reduce((sum, e) => sum + e.estFlow, 0); + const inflowCount = etfs.filter(e => e.direction === 'inflow').length; + const outflowCount = etfs.filter(e => e.direction === 'outflow').length; + + etfs.sort((a, b) => b.volume - a.volume); + + return { + timestamp: new Date().toISOString(), + summary: { + etfCount: etfs.length, + totalVolume, + totalEstFlow, + netDirection: totalEstFlow > 0 ? 'NET INFLOW' : totalEstFlow < 0 ? 'NET OUTFLOW' : 'NEUTRAL', + inflowCount, + outflowCount, + }, + etfs, + }; + }); + + if (result) { + etfCache = result; + etfCacheTimestamp = now; + } + + return result || etfCache || { + timestamp: new Date().toISOString(), + summary: { + etfCount: 0, + totalVolume: 0, + totalEstFlow: 0, + netDirection: 'UNAVAILABLE', + inflowCount: 0, + outflowCount: 0, + }, + etfs: [], + }; + } catch { + return etfCache || { + timestamp: new Date().toISOString(), + summary: { + etfCount: 0, + totalVolume: 0, + totalEstFlow: 0, + netDirection: 'UNAVAILABLE', + inflowCount: 0, + outflowCount: 0, + }, + etfs: [], + }; + } +} diff --git a/server/worldmonitor/market/v1/list-market-quotes.ts b/server/worldmonitor/market/v1/list-market-quotes.ts new file mode 100644 index 000000000..c97ff071d --- /dev/null +++ b/server/worldmonitor/market/v1/list-market-quotes.ts @@ -0,0 +1,128 @@ +/** + * RPC: ListMarketQuotes + * Fetches stock/index quotes from Finnhub (stocks) and Yahoo Finance (indices/futures). + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + ListMarketQuotesRequest, + ListMarketQuotesResponse, + MarketQuote, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { YAHOO_ONLY_SYMBOLS, fetchFinnhubQuote, fetchYahooQuotesBatch } from './_shared'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:quotes:v1'; +const REDIS_CACHE_TTL = 120; // 2 min — shared across all Vercel instances + +const quotesCache = new Map(); +const QUOTES_CACHE_TTL = 120_000; // 2 minutes (in-memory fallback) + +function cacheKey(symbols: string[]): string { + return [...symbols].sort().join(','); +} + +function redisCacheKey(symbols: string[]): string { + return `${REDIS_CACHE_KEY}:${[...symbols].sort().join(',')}`; +} + +export async function listMarketQuotes( + _ctx: ServerContext, + req: ListMarketQuotesRequest, +): Promise { + const now = Date.now(); + const key = cacheKey(req.symbols); + + // Layer 1: in-memory cache (same instance) + const memCached = quotesCache.get(key); + if (memCached && now - memCached.timestamp < QUOTES_CACHE_TTL) { + return memCached.data; + } + + const redisKey = redisCacheKey(req.symbols); + + try { + const result = await cachedFetchJson(redisKey, REDIS_CACHE_TTL, async () => { + const apiKey = process.env.FINNHUB_API_KEY; + const symbols = req.symbols; + if (!symbols.length) return { quotes: [], finnhubSkipped: !apiKey, skipReason: !apiKey ? 'FINNHUB_API_KEY not configured' : '' }; + + const finnhubSymbols = symbols.filter((s) => !YAHOO_ONLY_SYMBOLS.has(s)); + const yahooSymbols = symbols.filter((s) => YAHOO_ONLY_SYMBOLS.has(s)); + + const quotes: MarketQuote[] = []; + + // Fetch Finnhub quotes (only if API key is set) + if (finnhubSymbols.length > 0 && apiKey) { + const results = await Promise.all( + finnhubSymbols.map((s) => fetchFinnhubQuote(s, apiKey)), + ); + for (const r of results) { + if (r) { + quotes.push({ + symbol: r.symbol, + name: r.symbol, + display: r.symbol, + price: r.price, + change: r.changePercent, + sparkline: [], + }); + } + } + } + + // Fallback: route Finnhub symbols through Yahoo when key is missing + const missedFinnhub = apiKey + ? finnhubSymbols.filter((s) => !quotes.some((q) => q.symbol === s)) + : finnhubSymbols; + const allYahoo = [...yahooSymbols, ...missedFinnhub]; + + // Fetch Yahoo Finance quotes (staggered to avoid 429) + let yahooRateLimited = false; + if (allYahoo.length > 0) { + const batch = await fetchYahooQuotesBatch(allYahoo); + yahooRateLimited = batch.rateLimited; + for (const s of allYahoo) { + if (quotes.some((q) => q.symbol === s)) continue; + const yahoo = batch.results.get(s); + if (yahoo) { + quotes.push({ + symbol: s, + name: s, + display: s, + price: yahoo.price, + change: yahoo.change, + sparkline: yahoo.sparkline, + }); + } + } + } + + // Stale-while-revalidate: if Yahoo rate-limited and no fresh data, serve cached + if (quotes.length === 0 && memCached) { + return memCached.data; + } + + if (quotes.length === 0) { + return yahooRateLimited + ? { quotes: [], finnhubSkipped: false, skipReason: '', rateLimited: true } + : null; + } + + // Only report skipped if Finnhub key missing AND Yahoo fallback didn't cover the gap + const coveredByYahoo = finnhubSymbols.every((s) => quotes.some((q) => q.symbol === s)); + const skipped = !apiKey && !coveredByYahoo; + return { quotes, finnhubSkipped: skipped, skipReason: skipped ? 'FINNHUB_API_KEY not configured' : '' }; + }); + + if (result?.quotes?.length) { + quotesCache.set(key, { data: result, timestamp: now }); + } + + return result || memCached?.data || { quotes: [], finnhubSkipped: false, skipReason: '' }; + } catch { + return memCached?.data || { quotes: [], finnhubSkipped: false, skipReason: '' }; + } +} diff --git a/server/worldmonitor/market/v1/list-stablecoin-markets.ts b/server/worldmonitor/market/v1/list-stablecoin-markets.ts new file mode 100644 index 000000000..a4ce3a1d3 --- /dev/null +++ b/server/worldmonitor/market/v1/list-stablecoin-markets.ts @@ -0,0 +1,148 @@ +/** + * RPC: ListStablecoinMarkets + * Fetches stablecoin peg health data from CoinGecko. + */ + +import type { + ServerContext, + ListStablecoinMarketsRequest, + ListStablecoinMarketsResponse, + Stablecoin, +} from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; +import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:stablecoins:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — CoinGecko rate-limited + +// ======================================================================== +// Constants and cache +// ======================================================================== + +const DEFAULT_STABLECOIN_IDS = 'tether,usd-coin,dai,first-digital-usd,ethena-usde'; + +let stablecoinCache: ListStablecoinMarketsResponse | null = null; +let stablecoinCacheTimestamp = 0; +const STABLECOIN_CACHE_TTL = 120_000; // 2 minutes + +// ======================================================================== +// Types +// ======================================================================== + +interface CoinGeckoStablecoinItem { + id: string; + symbol: string; + name: string; + current_price: number; + market_cap: number; + total_volume: number; + price_change_percentage_24h: number; + price_change_percentage_7d_in_currency?: number; + image: string; +} + +// ======================================================================== +// Handler +// ======================================================================== + +export async function listStablecoinMarkets( + _ctx: ServerContext, + req: ListStablecoinMarketsRequest, +): Promise { + const now = Date.now(); + if (stablecoinCache && now - stablecoinCacheTimestamp < STABLECOIN_CACHE_TTL) { + return stablecoinCache; + } + + const coins = req.coins.length > 0 + ? req.coins.filter(c => /^[a-z0-9-]+$/.test(c)).join(',') + : DEFAULT_STABLECOIN_IDS; + + const redisKey = `${REDIS_CACHE_KEY}:${coins}`; + + try { + const result = await cachedFetchJson(redisKey, REDIS_CACHE_TTL, async () => { + const url = `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${coins}&order=market_cap_desc&sparkline=false&price_change_percentage=7d`; + const resp = await fetch(url, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + + if (resp.status === 429 && stablecoinCache) return stablecoinCache; + if (!resp.ok) throw new Error(`CoinGecko HTTP ${resp.status}`); + + const data = (await resp.json()) as CoinGeckoStablecoinItem[]; + + const stablecoins: Stablecoin[] = data.map(coin => { + const price = coin.current_price || 0; + const deviation = Math.abs(price - 1.0); + let pegStatus: string; + if (deviation <= 0.005) pegStatus = 'ON PEG'; + else if (deviation <= 0.01) pegStatus = 'SLIGHT DEPEG'; + else pegStatus = 'DEPEGGED'; + + return { + id: coin.id, + symbol: (coin.symbol || '').toUpperCase(), + name: coin.name, + price, + deviation: +(deviation * 100).toFixed(3), + pegStatus, + marketCap: coin.market_cap || 0, + volume24h: coin.total_volume || 0, + change24h: coin.price_change_percentage_24h || 0, + change7d: coin.price_change_percentage_7d_in_currency || 0, + image: coin.image || '', + }; + }); + + if (stablecoins.length === 0) return null; + + const totalMarketCap = stablecoins.reduce((sum, c) => sum + c.marketCap, 0); + const totalVolume24h = stablecoins.reduce((sum, c) => sum + c.volume24h, 0); + const depeggedCount = stablecoins.filter(c => c.pegStatus === 'DEPEGGED').length; + + return { + timestamp: new Date().toISOString(), + summary: { + totalMarketCap, + totalVolume24h, + coinCount: stablecoins.length, + depeggedCount, + healthStatus: depeggedCount === 0 ? 'HEALTHY' : depeggedCount === 1 ? 'CAUTION' : 'WARNING', + }, + stablecoins, + }; + }); + + if (result) { + stablecoinCache = result; + stablecoinCacheTimestamp = now; + } + + return result || stablecoinCache || { + timestamp: new Date().toISOString(), + summary: { + totalMarketCap: 0, + totalVolume24h: 0, + coinCount: 0, + depeggedCount: 0, + healthStatus: 'UNAVAILABLE', + }, + stablecoins: [], + }; + } catch { + return stablecoinCache || { + timestamp: new Date().toISOString(), + summary: { + totalMarketCap: 0, + totalVolume24h: 0, + coinCount: 0, + depeggedCount: 0, + healthStatus: 'UNAVAILABLE', + }, + stablecoins: [], + }; + } +} diff --git a/server/worldmonitor/military/v1/_shared.ts b/server/worldmonitor/military/v1/_shared.ts new file mode 100644 index 000000000..363693501 --- /dev/null +++ b/server/worldmonitor/military/v1/_shared.ts @@ -0,0 +1,145 @@ +declare const process: { env: Record }; + +import type { + AircraftDetails, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + + + +// ======================================================================== +// Military identification +// ======================================================================== + +const _HEX_PACKED = "473c0b800e89507fa07434c6704f803b769b0c404b87c8443b768648d8a4505f6743c91b49849187c8493b768387c8483b77ceae292b3b766d7a42a84a3613738bf0507f897a42a73b75ae152af587c8457585d43b75e987c825152bebe8065fae69d4468103497c9548088048da433b766f3b75bf4a35ceae6ce73b9bdf146634e80699152bdf44ed82505f784a35ad3ac4214850a7afca1e3b77d63b7f9f4682ef731bcdaf4057683251e400d1ae6bd4e49560ae81a54060e5ae5d1fae5d264b7fb87a44107a4940ae27a50101a90101970101aa0200a30200c4ae1860ae52e5ae52ff04c03004c1a67585d18014758015104681577cf8877cf8d87cf8dd7cf8ef7cf9a77cf9d07cf9e27cfa0b7cfa117cfa3f7cfa437cfa62ae6db4ae73e8ae744dae747bae748fae749fae74b8ae74c3ae74e2ae87ed4a35ea4a360cae525ee499243b7b6e3b9bf6457c32457c3300601506439806a25f06a2930a400a0a403d0ac0ac0ac8f60d05d90d81a5140530151d0c152886152894152afe152b68152c041533da155376194e9732001c32003633fde133fe7833ffc73422d63430833474163530563532c53553153571cd3aaad23aab053aab0f3aab443aab5f3aabbe3aabc53aac1a3b76483b76573b76763b77843b77f93b7b663b9bd63e9daf3ea6533eaed63f45203f765a3f841b3fb113400ee0400ee443c15343c2ae43c31743c32643c36443c39543c45843c55443c55c43c5df43c67143c70b43c79c43c8b743c8ea43c8f643c90843c933447d1d447d3b447d50447d52447d5344f18344f66845f42445f43145f43545f439467890477fca477fcc477fd447812848042f48047c48047f48080448080d48085848085d48086748087a48088b48d8ac48d920497c10497c2a497c484984324a82f84a83174b7f5b4b7f634b7f734b7fab4b7fbb4b7fd04b7fd44b7fd74b82024b82174c550f4ca330505f765080665111217061f671028871028c71f01271f902738a007434cb76e31376e72276e72779c23c7a42197a425c7a49557a49df7b70127cf3da7cf83d7cf8567cf85a7cf8687cf86a7cf886800279800325800e14800e4187c40387c40f87c83687cc3f87cc408804068804098805a3881405881b6a881b81881ba2881bc68840068880bc8953f28967dd896c21896c22896c2d8a01338a05e58a073c8a073e8a08438a09338a4501a82420abe1e9adfc60adfc63adfcc1adfcd6adfce2adfd01adfd89adfdc1adfdd6adfe72adfe78adfea3adfea9adff06adffbaadffc4adfff1ae000cae001eae0022ae0035ae0044ae0054ae0062ae006dae007eae0089ae009dae00a7ae0159ae018aae01c0ae01f3ae01f4ae022dae0259ae0266ae02c6ae02efae0374ae037cae0381ae03d3ae03d7ae0404ae0408ae040fae0449ae0451ae045cae046dae0479ae04d8ae04feae056eae057cae0581ae0583ae05d1ae0610ae0629ae07a6ae07a9ae07c0ae07c3ae07f4ae07f7ae080fae0859ae0889ae08f1ae093dae095aae0966ae09b0ae0a24ae0a3dae0a54ae0aa6ae0ac0ae0b36ae0b79ae0b9dae0c02ae0c4aae0c81ae0c82ae0cccae0cdeae0ce8ae0d08ae0d1eae0d24ae0d53ae0d98ae0e0dae0e28ae0e3fae0e67ae0e93ae0e97ae0ea9ae10e6ae10fcae1106ae1116ae1141ae1152ae1159ae116aae1198ae1389ae1393ae13b2ae13ffae1462ae146fae14c0ae14cbae1520ae1710ae172aae1739ae17acae17c0ae17fbae1924ae1975ae19b9ae19ddae1b91ae1beeae1c15ae1c1fae1ccbae1d6bae1d8aae1dabae1e44ae1e47ae1e67ae1e68ae1e90ae1eaaae1ec2ae1ed1ae1f24ae1f42ae1f4cae1fffae2005ae2014ae2018ae2038ae2039ae20c6ae20caae211eae214aae21cdae2670ae2695ae2696ae26b8ae26c7ae27a6ae27f9ae27fcae2ee9ae2eeeae2f56ae3406ae4793ae4798ae4799ae47b8ae47e2ae4887ae4888ae49c7ae49f7ae4a1aae4a60ae4b7eae4ba2ae4ba3ae4baaae4be0ae4be8ae4cf4ae4efeae4f13ae4f3fae4f64ae4f6aae4fb7ae4ff0ae50b8ae50ebae515dae5198ae51b3ae51d1ae51deae5209ae5243ae5245ae52b6ae52c5ae535dae54b4ae564fae568bae56b5ae579dae57d9ae5879ae589cae598dae599fae59daae59dbae59e7ae5a1bae5a4eae5a60ae5a65ae5a7cae5a8fae5ab5ae5ac5ae5ac9ae5ad5ae5b35ae5b6eae5c64ae5cb8ae5d3cae5d49ae5d4aae5d5aae5d6eae5d77ae5dedae5dfbae5e59ae5e87ae5ea1ae5ea2ae5ed1ae5ee4ae6024ae6027ae6072ae60f7ae618eae61fdae6211ae623bae6251ae6341ae6373ae6389ae649eae64b3ae660aae6953ae695cae6a4fae6a95ae6aa5ae6b7cae6c1cae6c1dae6c57ae6cbfae6d4dae6d50ae6d78c2b0c1c2bcfbc2bde1c87f04e48c8ae49182e49378e494a7e847d0e847fae84a270100820200b80200fc02ba6406428b0642fb08af3f0a40190a40240a40540a40580a40610ac0e70ac3ee0ac7e30ac7e60ac88f0ac9060ac9370aca0a0b427f0d05e70d082d0d09430d0bf1149c77151cf6152b041533fd1533ff154065154db21d334432002a32003e33fcab33fcae33fd0a33fd3e33fd6e33fd8833fd9233fd9833fda833fe7d33ffd033ffd233ffd93571ce3aaaae3aaac33aab413aab433aab853aab8d3aabcc3aabcf3aabd23aabde3aabe03b75393b763c3b76563b77b93e8b353e8b413f52db3f751b3f85803f85d2400ee343c1b643c1c643c2af43c32043c39b43c49443c4ad43c60a43c60f43c6bd43c6c043c6d043c72d43c79f43c7a843c7aa43c7e143c87c43c8c543c8c8447d3c44f12244f12744f64444f67244f67944f829451e92456789457c2346f483477fcf47811447812147812548040748040d48041e48047348084248088148104948d84448d895497c49497cb14984304984a44984c74a34d64a34e24a35e34a82f04a82f74aecbc4b7f6f4b7fdc4b80154b822b4b82a44b82d44b82e84b83444c2c3a501f8f502fa5505f6c505f72506e6d506f5f507f8e50807250ffc07102597102a87102e371030871030d71038071f90871fa01738a4a738b4975023b75867476103f76e72e781d697a427e7a428c7a43ea7a44507b006c7b09497cf8487cf8747cf87d7cf8d07cf9a97cf9df7cf9ef7cfaa48000d68002158002398002488002b08002d680031d80032f80033c8004fd800791800e2380151187cc0887cc1088140a883801896c13896c26896c3b896c498a09808a2901901010a1af83adfcd3adfc75adfcd7adfce8adfcf2adfcfaadfdc8adfe5eadfe89adff00adff43adff88adffb3adffcdae0004ae0010ae004cae0068ae0073ae0083ae017aae01ccae01d0ae01e7ae01e9ae01feae023dae0250ae02d1ae02edae035eae0363ae040aae047fae0486ae04f3ae04f7ae051aae0577ae0589ae066eae06e4ae07edae07ffae0803ae0868ae08baae0964ae0995ae09eeae0a0fae0a39ae0a47ae0a48ae0a88ae0b69ae0b6dae0b72ae0bfbae0bfcae0c11ae0c1fae0c40ae0c62ae0c74ae0d44ae0e3dae0e66ae0ebdae0f03ae10bcae10e9ae112cae1132ae1135ae117aae11baae11c6ae11e0ae1217ae1283ae12aeae135fae13b9ae13eeae1408ae143fae1467ae151aae151fae1733ae1743ae1790ae17f7ae1aaeae1bfcae1da9ae1db8ae1e4fae1e55ae1e8aae1eb3ae1f23ae1f32ae1f5aae1f61ae1f84ae1fa5ae1fc6ae1fc9ae202cae202dae20e4ae20f5ae215bae21c1ae223fae23bdae2606ae2708ae270aae272cae2760ae2762ae2863ae2f67ae3408ae4788ae47dbae4a0aae4a25ae4a7cae4b4bae4b85ae4bb0ae4bddae4cfeae4d2cae4ebdae4ec1ae4eebae4f5fae4f69ae4f76ae4fabae4fd9ae4fdcae4fe1ae4ff8ae50c3ae50d4ae510cae5110ae511bae517eae51b5ae51dfae525fae52dfae5313ae5641ae564bae5663ae5667ae5668ae566fae567bae5699ae56acae56b3ae574fae5788ae57c8ae57cdae5882ae58a2ae58a8ae593fae59dcae59f0ae5a7fae5b14ae5bc8ae5c1fae5c6bae5c9dae5ca0ae5cc4ae5d3dae5d4cae5d52ae5de5ae5e4aae5e5cae5ed4ae5ed7ae5ed8ae5ef3ae5f16ae5f18ae5f41ae5f96ae5f9dae5f9eae603bae6052ae6207ae6217ae622eae634cae6352ae6374ae638bae63b8ae6478ae648dae6490ae6494ae64a9ae65faae660eae690cae6914ae6998ae6a98ae6aadae6b96ae6baaae6bd3ae6bf7ae6c06ae6c33ae6c53ae6c60ae6c64ae6cc7ae6ce8ae6dc1ae73dcae7485ae7489ae74adae74b1ae77a0af04fcc2b085c2bcc9c87f23e4008ce400e9e483afe49413e4992fe49936e806420101a102003506a26406a28d06a28fc87f3908af3d0a40140a40390ac0ea0ac3170ac33f0ac3f50ac7da0ac8210ac92b0ac93d0ac9ed0acaf20ba0380c21430d05460d07dc0d08a7142b1d142e9b142f53142f6114f127150095151ced151cf2152b00152b03154069154e52156ee61f704032001f32002132003033fcc133fccc33fd2533fd2c33fd5533fd6233fd9333fdc333fdfc34308b3435cd34738d3474d43571c93571d13571d239a84039a8483aaaba3aaaca3aaacc3aab1d3aab203aab353aab4e3aab4f3aab663aab773aabb63aabb73aabe33aac1e3aac223aac2c3b76353b764a3b764d3b7b673b7b7c3b9bd03b9bf43e88193eb6b33f4f0f3f60053f6e703f8249400ea8400ee5447d1743c0b443c25443c2b343c31143c44043c4bf43c4dc43c5e243c69143c69743c6c643c6cc43c6f643c76643c76743c76a43c7a143c7d243c85343c8d843c8de43c8f843c91d43c92743c94e447d5144f04144f04244f16644f60244f61a44f806457c0e45f43846789b4681524682a648048348048a48048c48084548d88e48da604984ad4a36154a82fc4a8312506f65507f8c50ff3c60180068306a70626370c08070c0907102827102dd7102fe71034d7103ae71f9c471fe507433957586237586c97600e976e30576e30a76e7237a42527a42537a427f7a429e7bd03d7cf88a7cf9247cf9907cf99e7cf9d17cf9db7cf9e07cf9e57cf9ed7cfaaa800079800221800274800795800e8a87c40a87c82e883ac18953fd896c678a06e68a0a01ae26bbae277a0b4151adfca8adfcafadfdbeadfe1badfe4aadfe76adfea1adfed5adff77adff8aadff90adffa1adffd3adffd7adffe1adffffae000fae0055ae0072ae00b7ae00b9ae00baae00bdae00d1ae00e3ae0102ae013fae01fbae0230ae0243ae02cbae02d6ae0362ae0370ae0372ae03d6ae03ecae0413ae0419ae041fae0423ae0489ae049eae04a7ae04eaae04eeae055bae0582ae0597ae05a9ae0665ae068dae06d8ae07deae088aae0899ae08a3ae08aaae08aeae09deae0a31ae0a75ae0a79ae0a80ae0a85ae0ab9ae0b03ae0b84ae0b89ae0ba2ae0c00ae0c2fae0c67ae0cbfae0cf1ae0d00ae0d88ae0dc3ae0dc5ae0e03ae0e12ae0e17ae0e50ae10a7ae10b9ae10f6ae1113ae1121ae1150ae11b0ae11c8ae11d7ae123cae12aaae13b0ae13c5ae13c7ae1465ae1466ae1480ae14fcae1524ae1530ae1727ae1752ae178eae17e9ae183dae1859ae1897ae18bcae18daae19aaae1bffae1c1eae1c31ae1c35ae1d89ae1dbfae1e79ae1eafae1f3aae1f43ae1f50ae1f55ae1f94ae1fcbae201bae20bbae2107ae2141ae2148ae21beae24ecae2663ae268bae268dae269dae26b9ae2914ae2938ae2bebae2beeae2ee3ae2fa0ae34bfae47f7ae481fae49caae4a26ae4b66ae4b76ae4b94ae4b9bae4ba0ae4bf2ae4caeae4d37ae4e03ae4e15ae4ea9ae4eb8ae4ec5ae4edcae4fa3ae4fbfae4fc2ae4fcfae4fe2ae50b5ae50bdae50cfae50d0ae519bae51adae51b8ae51e9ae51edae5244ae5248ae5273ae52a9ae52b5ae52c4ae52e1ae52e8ae5300ae5317ae531fae54cbae54e9ae54efae54f1ae54f6ae5639ae566dae56a7ae56dfae5737ae574dae5787ae59a3ae59c3ae59c6ae59e8ae59efae59fcae5a18ae5a31ae5a37ae5a4bae5a53ae5a9dae5ad0ae5ae4ae5aebae5b38ae5b42ae5b70ae5b84ae5baaae5c68ae5c71ae5ca3ae5cdaae5cedae5d0cae5d30ae5d36ae5d3eae5d3fae5d76ae5d93ae5dbfae5e76ae5ec8ae5ef1ae5f95ae5fa3ae604dae61deae6234ae6255ae627dae63f8ae647cae649bae64a7ae64baae68b7ae6963ae698cae6a31ae6a34ae6a39ae6a8aae6a97ae6ac0ae6ba9ae6bd1ae6befae6c54ae6c72ae6cbeae6cdeae6d75ae6da5ae6e7aae73e3ae73eeae7473ae747eae74b5ae74c5ae77c5ae7811af38bec2afd1c2b1d9c87f0ee400a3e4012de4015be4955ce4993ce80675e8cfbae8cfc601018b0101920101980200630200b00200bc0200f002010902b261034443038f4304403f04c03f06a20906a21f06a25109012c0ac3c50ac8f30ba03914b3b714fbf81501c6152890152b091533e215535d32000232002632004432005533fca433fcee33fd0c33fd2233fd5133fdd633fdfe33fe2b33ff143571c13571c33571d03591813aaacf3aab023aab253aab2b3aab363aab883aabbb3aabd33b762d3b77753b7b863b7f6d3b9bb63b9bde3b9be03f43683f476b3f717c3fa50c3fa9333fa93b400eec43c22643c2a643c46143c4a543c4bb43c4c843c56343c5ec43c5f443c69f43c6c743c6d843c6f943c70d43c76443c7e543c7e843c8c143c91a43c92044f10744f16444f60344f67844f67f45f42245f4404678a3468111473c0c477f8e477f9e4781294804044804194804b64804b948080a480c2a480c2c48104de4992348d82348d82d48d8af48d91048da4c497c02497c26497ca6497cb649843349848b4a35e14a35e24a81144b7f7b4b7fa54b7fad4b7fb24b7fc94b7fcc4b7fd64b82994b82d34c2c3b4d03c6506f22506f44506f5d506f6950ff3d50ff3f683000683248702c0470428071012c71025d71039871fa14728132738a4c738a55738a9275050975862a7a42487a424e7a42a37a44117a44457a49437a496f7bc0127bd0327be0367cf8457cf84c7cf8557cf8577cf8cc7cf9d97cf9ea7cfa478002ad8002e780078e800e1f800f328014768016e887cc1987cd05881403881bf48835d2884017896c05896c47896c58896c66a11cf8ae5af0adfca4adfcaaadfcb7adfcc4adfce1adfd0dadfdbfadfe51adfe97adfeabadfebdadff09adff0cadff52adff5aadff83adff8dadffd9ae0030ae0038ae004fae0052ae008bae0093ae00b1ae00c6ae00d4ae00eeae00ffae0104ae0116ae016dae01caae01edae0221ae0222ae022eae0240ae02c8ae02ecae02f5ae0308ae035dae0385ae0386ae038dae0420ae042bae045eae0482ae04beae04dbae04e8ae04f6ae055fae05a8ae05e3ae0657ae07e1ae0823ae086fae0880ae089dae08f9ae0968ae099eae09a0ae09baae2698ae09dbae0a0cae0a3aae0a3bae0afdae0b70ae0b75ae0c29ae0c4cae0c6aae0c77ae0d28ae0d91ae0dbdae0dd3ae0e16ae0e53ae0e59ae0e5eae0f00ae10bdae10ecae1105ae110eae110fae111cae114dae1151ae1164ae119bae12a5ae12a6ae12c5ae130fae13acae13c3ae13f0ae1407ae1418ae142bae1437ae143eae14b3ae14baae14bbae1568ae1734ae1798ae1802ae1828ae184aae18c7ae18d9ae18e6ae18e9ae18eeae18faae1900ae1af9ae1c0eae1d61ae1dd0ae1dd3ae1e6aae1ecdae1f3fae1f49ae1f73ae1f81ae1f83ae1f97ae1fb1ae1fc5ae1fecae2010ae2016ae2036ae203cae2041ae2049ae204eae205cae212fae2132ae2146ae21c9ae220bae223dae23feae2651ae267cae26b1ae26bcae2918ae2bffae2ed6ae2ee6ae2efcae2f61ae2fa2ae2fb0ae2fd3ae478aae47a9ae47d8ae47f4ae4826ae482bae4835ae4859ae4880ae488cae49f0ae4a24ae4a51ae4ae5ae4b54ae4b75ae4b89ae4bf1ae4c04ae4d38ae4d66ae4d6aae4e12ae4e1cae4e9cae4ee1ae4f2fae4f4fae4ffdae50bfae50d1ae50d3ae5137ae5199ae51bdae51c2ae51d5ae51e6ae52bbae52d4ae52d6ae5302ae535fae5375ae54caae54e8ae54edae5643ae56c9ae5738ae5766ae577eae578cae57b4ae5872ae588aae58f1ae5966ae598eae59a6ae59cbae59dfae59ecae59f7ae5a29ae5a6bae5a7bae5abbae5ae5ae5bd4ae5bdaae5ccbae5d27ae5d2fae5d43ae5d63ae5d8bae5dbcae5dc6ae5e69ae5e90ae5ed3ae5f9aae6015ae601eae60c9ae60eaae60fcae6195ae61ccae61dbae621bae6257ae6284ae63beae63dcae63dfae63f0ae6496ae64a0ae64b8ae64c2ae6600ae660dae6966ae6990ae6999ae6a2cae6a32ae6a38ae6aa3ae6aaaae6aaeae6b8cae6b90ae6ba5ae6bafae6bf5ae6c10ae6c19ae6c25ae6cadae6cb6ae6cc2ae6d7fae73d7ae73daae74bfae74d8ae74e8ae77baae77c0aeaaafaed8b5aed9b7af3c5fc2c04dc87f08c87f22e20049e400c3e400cde47dfee48444e48750e48f43e48ff4e494a2e49932e49945e80674e8cfc7e8cfcbe8cfdb01008b0101640200c504c1a70940150a40150a404d0ac7ab0ac7d00ac7e10ac88c0ac9410acaf80c215b0d08ab0d0e1c33fd4c14247014247114343314345a151cf91526c8152bda1533c5154c3132001932002e33fca333fce733fd7033fd7733ff513431133543c33571cb35919439a8473aaab93aaace3aaaf13aab0e3b762f3b779c3b7b3b3b7b5c3b9bac3e826f3f4e273f5aac3f61aa3f6c263f80063f86ad3f88443f8d2e3fa1be3fbefc505f7743c07d43c20843c21943c39743c39a43c40a43c44443c44b43c45b43c47e43c60343c61843c67043c6bc43c75e43c78343c7cf43c8e243c92c447d05447d1f447d2c44f18444f68244f6834682d946f48446f592473c054781174804114804824804b348084948084e480c04480c4348d84248d89148da62497ca2497cbd4984934984964b7f654b7fa04b8209507f8350ffcb613133702c2570626770c08b70c08c71012d71025c71030271030671039c71f90171f90c71fa0271fa0871fc047386c07500d475837b76e30d76e7287a42847a42857a428d7a44037a49427a49de7cf87b7cf8987cf89d7cf8ec7cf9227cf9dd7cf9e67cfa087cfa8780026f8002758002be80079380079780171587c82b87cc1f87cc32881b72881ba58967da896c1f8a097f8a098eae0c42a3ad35ae08b2ae0c52ae0d0aae0d10adfc7badfc8dadfcacadfccbadfcceadfd11adfd7eadfd86adfdd4adfe4cadfe6dadfe74adfe7fadfe83adfea5adfecdadff50adff54adff5fadff66adff9dadffa6adffaaadffadae0080ae010dae015eae015fae0160ae0191ae01deae01ebae0236ae0265ae0269ae02d0ae02dcae035fae03f4ae0401ae0460ae048dae049aae04a8ae04c9ae04e1ae04fcae04ffae052cae05b8ae0627ae06ddae07e0ae0800ae0804ae0863ae08a4ae08b6ae08ebae094eae0997ae0a11ae0a6bae0b07ae0b0aae0becae0bf9ae0c2cae0c72ae0ccaae0cd5ae0cf2ae0cf4ae0cfdae0d29ae0da5ae0dc0ae0ddfae0df5ae0e92ae0e9dae0eb7ae0edbae0ee6ae10b3ae10baae10f7ae10fbae1118ae1128ae116fae1177ae117bae11adae120aae120bae1242ae1255ae128fae1290ae13baae13f5ae140dae1413ae143aae144dae144fae1458ae146eae1723ae1814ae182aae18f7ae1908ae1946ae19c2ae19e7ae19f7ae1b70ae1c06ae1c1bae1d52ae1d5dae1dc9ae1de2ae1e62ae1e63ae1e65ae1ebeae1fa4ae1fa6ae1fd6ae1fe6ae1fe9ae1ffbae2003ae2055ae2064ae2067ae20c0ae20c8ae20e3ae212bae215cae215eae21c8ae21d8ae23a0ae24c7ae268cae269aae26aaae26aeae2767ae2783ae2906ae2913ae2917ae29fcae2bf1ae2edcae2edeae2f63ae2f66ae2f6fae3403ae45c3ae478fae47efae483dae4878ae488bae4a09ae4a0cae4a18ae4b5aae4be5ae4cecae4cfaae4cfdae4e8dae4eb5ae4ed4ae4f78ae4f8bae4fc9ae4fceae4feaae4feeae50baae50d8ae5113ae5115ae51beae526aae526fae527bae5283ae5293ae5296ae52e0ae52eaae52efae52f6ae5378ae538dae540dae54d0ae54d2ae54d7ae54deae5644ae565fae568fae5696ae56b7ae56bcae56dbae56f2ae5729ae5735ae5751ae577aae57bcae58b0ae58efae5960ae596dae5970ae59a7ae59acae59d6ae5a20ae5a21ae5a6eae5a72ae5a7aae5ae2ae5b1fae5bcdae5bddae5c20ae5c7bae5c91ae5cd6ae5d10ae5d1dae5d2eae5d68ae5d6fae5d70ae5d73ae5e0fae5ea9ae5ebbae5edaae5eeaae5eebae5efcae5f3aae5f42ae5f98ae6011ae6037ae61b7ae61daae61e0ae6232ae627aae6340ae6358ae637bae6382ae6395ae63dbae6489ae6495ae64abae64b5ae690eae695bae6a70ae6a77ae6b95ae6b98ae6ba4ae6be8ae6c47ae6c4aae6c76ae6cddae6d3cae6d4aae6d5bae6dadae6e7fae7477ae749eae74b3ae74daae74dfae779aae77a4c2b049c87f29e2005de40050e400e5e40139e40489e483ade4993ae806a6e8cfc200b89b0101ae0101f40180a7038f4e06405106a21306a25406a27806a29106a29c06a39207007a0700dd0900f90940110a40100a402a0a403b0a40530ac7aa0ac8930acadc0d06bc142bfc142c17142ca114b57a14f1201501c71533d61533ef15535b32002932004032005333fca533fcc333fd2033fd2733fd2f33fd4533fd5633fd5d33fd6f33fdd433fe0333fe3733fe7b33fec633ff9933ffb433ffbb33ffbf33ffc233ffcd33ffce3415893421993535413aaaaf3aaae13aaae83aab1c3aab423aaba13b76343b7bf63b9b2c3b9bdc3b9bff3eb1a63f64f643c07143c1e343c43543c4de43c69043c6e243c6f343c74143c78443c7d943c7e443c8d643c92343c93843c93b43c93e43cae3447d27447d33447d3d44c1e944f02544f1a644f63644f68444f6a144f82745f42d45f432468196473c02477f85480440480c2848d8a648d96148d98048da82497c2d497c30497ca7497cc049843749848249848a4984b28a08674a34d94a35c14a35cf4a82fd4a82ff4b7f704b7f774b82d04ca3324ca41a506f425082af7061f770621670626f70c07870c07a70c07c71025a71fa0e738a9176410476e72f7a42827a494f7cf8407cf8467cf85b7cf99f7cf9b77cf9f17cfa107cfa517cfa5780020580024d800337800e3287c40587cc0b87cc1a87cc1c87cc1e87cd0488040b88040f881b85882248896c10896c728a08ce8a08d18a100e901016ae12b9ae5a07adfc77adfc7eadfcadadfcbaadfcc6adfd09adfd75adfd80adfdb6adfdc3adfe52adfe5cadfec0adfecfadff5badff5eae002aae0058ae006aae008cae0105ae0117ae0136ae0140ae0178ae0189ae01aaae01aeae0226ae0248ae024dae0307ae03f6ae046bae04a2ae04e5ae04faae0580ae0593ae05dbae060aae0624ae0674ae068bae068fae07cdae07d0ae07e4ae085aae08bdae0967ae09c1ae09dcae09ebae0a0aae0a7dae0ab5ae0ae4ae0bc1ae0b28ae0b77ae0baeae0bb9ae0c2eae0c44ae0c5eae0c70ae0cc5ae0d1dae0d25ae0d32ae0d36ae0d66ae0deaae0df3ae0e06ae0e1aae0e1dae0e88ae0e9bae109aae1179ae6cc1ae1193ae11f0ae1209ae1238ae138eae13aeae13beae140bae1441ae14a0ae14b4ae14c9ae14d7ae1526ae161dae178bae1795ae17bbae17cbae1838ae1841ae1872ae196dae1becae1c17ae1c26ae1d7cae5a13ae1db3ae1e61ae1e7cae1e85ae1e8bae1e9bae1ea4ae1eb8ae1ebfae1ecbae1f41ae1f7cae200aae200dae2031ae203fae2050ae2108ae21c6ae2208ae24e8ae2667ae266aae2684ae2694ae26d9ae26e0ae2909ae29dcae2be0ae2bf8ae2bfbae2ef9ae2facae47f0ae4815ae4816ae4823ae485eae4879ae498dae49dcae49dfae4a2cae4afeae4b4aae4b4eae4b69ae4b8bae4be1ae4cf8ae4d98ae4e0cae4e13ae4e9aae4ea4ae4ec4ae4eceae4ed9ae4ee3ae4f99ae4f9eae4fa6ae4fc6ae4fc7ae4fe8ae50d5ae51c9ae51dcae51ddae526eae527fae52adae530cae5311ae531aae5361ae5370ae539eae54c7ae54f3ae565aae56b8ae56baae56c4ae571bae5777ae5784ae57c7ae5997ae59c1ae59f9ae59feae5a14ae5a3fae5a61ae5ac1ae5ad8ae5b86ae5c16ae5c6dae5c9aae5caaae5ce0ae5d47ae5d5bae5d72ae5d7eae5d8aae5eaaae5ec3ae5edcae5ee7ae5f46ae5f50ae5fafae6007ae6020ae60ffae61c1ae61ceae620fae6222ae6236ae632aae6347ae6388ae63ebae6408ae647eae6487ae648fae6497ae64a1ae64adae64b0ae68bfae68f0ae6910ae698eae6a23ae6a30ae6a71ae6a8dae6aafae6ba1ae6beeae6c3bae6cabae6d3dae6d7cae6d7eae6d80ae6daeae73d8ae73deae7448ae7484ae749bae74cfae74deae74e0ae77a3ae77ddafa827c2b053c87f2ce485e8e4891fe48c89e80647e84801e8480306a2560100750101b00101d006a24f06a2520741fa0a40250a40260ac3f40ac82a0c40540d836e14633d15279c1528851528921533f815405d1540761d33f533fc9e33fca733fdb633fe1033fe2333fe6f33fe7033fe7333ff1733ffc933ffda3474d53474d835350d3aaab03aab6c3aab9a3b76493b76883b76b43b7b3f3b7f723b9bb13b9bcd3b9bd83eac693f431a3f532c3f65333f727c3f881c3f89353f927c3fa82a3fa9353fab7643c06e43c07743c31443c48143c4b043c56843c61543c67443c68243c6a343c6e343c6ea43c74743c7a343c7e943c88543c8ce43c8d343c8d443c8e843c8f743c92243c92443c93243c94243cb0b447d09447d1944f10246788d46789d46815046830c47813048040848041048042048048e48048f48081148d82c48d88148d8e048d90c48d9cd497c054a34d74a35fa4a82f24a830a4b7f4c4b7f8b4b7fa64b7fcb4b822c4ca1ea4ca331505f68507f845082565110eb702c2070622e70625d70626b70c07b70c08f7103057103b071dd2371f904738b42738b437434c57434d4ae4f2076e30c76e73378cf957a428f7a44007a44167bb1537bd0697cf8367cf9cd7cfa157cfa7f80027b8002a08002ae8002c3800e1e800e26800e69800e79800e8c87c84187cc0287cc27881b63881c0c884005884008896c04896c17896c68ae4f65adfc6eadfc7fadfc92adfcf9adfcfcadfd72adfdb4adfdf9adfe1aadfe20adfe53adfe55adfe64adfe7aadfe7cadfe8dadfecaadff64adff72adff9fadffa7ae0016ae002fae0031ae0036ae0111ae0137ae0167ae01cdae01d5ae01f0ae01f7ae0272ae0394ae03faae0462ae050aae0572ae0598ae063fae0650ae0664ae067aae06dbae074fae079fae07d5ae0882ae0884ae09acae09bcae09f9ae0a1dae0a25ae0a30ae0a8cae0aafae0b73ae0b7aae0b99ae0ba0ae0bc4ae0bdbae0c18ae0c1dae0c3bae0c47ae0c60ae0c86ae0cb5ae0cf9ae0d16ae0d83ae0dd1ae0de3ae0e02ae0e13ae0e23ae0e4aae0e61ae0e6bae0e74ae0efeae10f4ae10faae1108ae1134ae11afae11b8ae11bbae11ceae11e1ae1292ae12a9ae13a8ae13fbae1414ae145dae1470ae14a4ae14c2ae14d5ae1730ae1747ae1755ae17a2ae17abae1805ae1856ae185dae18edae1972ae1bf1ae1c16ae1c25ae1d13ae1d78ae1dbeae1dd6ae1ddeae1e49ae1e56ae1ec6ae1f4dae1f6fae1f74ae1f89ae1f9eae1fbeae1fc0ae1fc8ae2001ae2021ae203eae2119ae2129ae2138ae220aae2238ae223cae2697ae26b7ae26edae26f7ae2741ae275eae2901ae2915ae2f72ae2f73ae2faeae478bae4790ae4791ae47e9ae47eaae481aae481cae4827ae482aae4843ae4870ae4992ae4a52ae4af9ae4b46ae4b71ae4bbaae4bffae4c5eae4d67ae4e95ae4ea1ae4ee4ae4f9fae4fbbae4fefae503dae50aeae510bae5116ae5181ae51baae51e5ae523cae5250ae528cae5298ae52ddae52e7ae52f5ae531eae5369ae537eae53a2ae5647ae5653ae5662ae567fae56a8ae56adae5724ae5739ae574aae577dae5793ae57a6ae57a8ae57b7ae57d1ae5877ae58acae58e3ae58e9ae58faae597cae59adae59b3ae5a11ae5a30ae5a48ae5a80ae5a89ae5a96ae5a9eae5ab9ae5ae8ae5b41ae5bc1ae5bc3ae5cb4ae5cfeae5d2aae5d64ae5d71ae5d99ae5dd6ae5de4ae5e10ae5e8aae5e92ae5ee6ae5f09ae5fa2ae606eae60d2ae60d6ae6101ae61b3ae61b4ae6200ae620bae6210ae621cae63baae6a3cae6aa1ae6abcae6ba0ae6ba2ae6bb2ae6bb9ae6bd2ae6cdbae6cf9ae6d5fae6da1ae73e7ae74b9ae74ecaedb4faef94baf8601af9ba5afa82bc2bd05e20004e4007be4010be80643e8064d010083038f4404c20c06a20e06a24706a25806a27a06a27c0ac88b0ac8dc0ac9fe0aca240acadd0d06150d07c70d09450d0a20142d8514f11614f124151cd7151cdd151cea1526eb15405a15520a32007333fcad33fd3233fd5233fda333fda433fdd233fe0033fe3633fe7533febc33ffb733ffd533ffe933fffb3425d63532c63563433aaaec3aab163aab193aab323aab3a3aab793aabb93aabd43aabe93aabf63aac123aac173b766b3b76893b77833b9bd23b9bf13b9bf83e80c03e865f3e8b133f65213fa9383fb0e63fb2b243c25143c2a143c33f43c4ab43c4d343c4d643c54b43c56143c5e343c6b943c6d543c73843c77043c77d43c78243c79743c7af43c8b543c8f343c90b43cae848104a447d07447d28447d2e44f0e244f42944f42e44f65344f66344f66a457c03457c1845f42e46788e46788f4678a74682e548049548080948086048086c8016ee48c45e48d960497c04497c28497c2e497c2f497c35497c3e497c3f497c84497cab497fa24984434a35a24a35c84a81994a83014aecbf4b7f5e4b7f764b7f9b4b7fb34b7ff94d03cd501fbb505f6a505f6b6831f2702c49702c8770626270626a70c09671037e71039771f9c1730903738a5a738a5d758628758659762bf4766048ae6c1a7a44187a443f7a48937a491a7a49347bb1697be0487cf8527cf87a7cf8ed7cf98f7cf9b07cf9c87cf9da7cf9e77cfa0f7cfa147cfaab8002918002988002cb8003248003e58003e687c81e87c82887c82987cc22881b6d881ba48967db896c06896c1a896c57896c5f8a07078a09e2ae6c5dae0468ae2793ae04acadfca6adfccdadfcdbadfd12adfdf8adfe81adff05adffa3adffa4adffa5adffdcae0000ae0046ae00d6ae0115ae013aae0162ae01efae025cae02daae0368ae0436ae0457ae045aae045dae04d7ae04f4ae0505ae0570ae057dae059bae0681ae06dfae07aeae07b4ae07ddae0818ae0885ae089fae08deae08e8ae0996ae09aaae09c5ae0a04ae0a05ae0a13ae0a46ae0aa0ae0ac7ae0b65ae0bfeae0c3fae0c45ae0c58ae0c92ae0c9cae0cedae0d1fae0dcbae0dcdae0de0ae0dfbae0e56ae0e5fae0e6cae0e94ae0e95ae0ea6ae10f3ae10f5ae10f8ae1100ae113cae119aae119dae11f8ae1234ae127cae1346ae13a2ae13a6ae13bcae140cae1449ae1452ae1453ae14bfae16f1ae170cae179aae179bae189aae18c1ae18f1ae18fbae1921ae19fbae1bf0ae1d8cae1d97ae1e45ae1e50ae1eaeae1f1bae1f26ae1f2fae1f47ae1f79ae1f7aae1f9cae1fbaae1fc2ae1ff3ae2015ae206dae6bc9ae20c9ae2136ae21e8ae23e3ae264fae26a7ae26a9ae2796ae27faae2beaae2c39ae47aaae47b3ae47bcae483eae485fae4860ae4872ae4883ae4a0eae4a4eae4b27ae4b41ae4b60ae4b9dae4cfbae4d27ae4d63ae4e8eae4ea7ae4ebeae4eddae4fdbae5118ae516fae5262ae526cae52dbae5371ae5382ae538eae53caae5645ae568eae56cfae56d5ae5775ae5785ae57a0ae58f6ae596fae59a2ae59f2ae5a15ae5a66ae5a78ae5a79ae5acdae5ad6ae5aedae5b17ae5b75ae5be0ae5c90ae5cb3ae5ce5ae5d5eae5dcaae5deaae5e51ae5e9eae5edeae5f0cae5f11ae6008ae601bae6034ae60ebae6191ae61c9ae633cae634eae637aae638dae6392ae63f4ae641bae6477ae6483ae6485ae6488ae64a4ae64a6ae64bfae6911ae69c5ae6a1dae6ab8ae6c66ae6c7aae6c83ae6cfdae6d59ae6d79ae6d82ae6dbfae6ed5ae7402ae7488ae74bcae74edaf5149af61caaf8458af847ac2b0ade47f5be49181e494a5e49947e80673e847ff0101b10201b206a20a06a27506a28c06a2970a402d0a403a0ac89d0ac9280acb5f0ba0040d07cf0d088c0d09460d0a3114f11d1501771501cd15288a152bc41533d91533fc1f333f31ff3c32004832005233fcb033fcb333fd3a33fd3d33fd6a33fd9c33fdd033ff1633ff1933ffa133ffbe33ffeb34308734308834738b34738c3594053aaaaa3aaaab3aaab23aaae23aaae33aab3e3aac253b75c83b75cc3b75cf3b75e03b763e3b76413b77783b7b743b7b763b9bdb3b9be13e801e3e81823e8a4a3e8c6b3eade845f44d3f4b1f3f6cc93f81e33f984f3f9ae83fa9403fb96c400eea43c13643c15543c1e543c26a43c2a343c32343c48743c4cc43c51e43c55143c55a43c5fc43c61d43c6d643c70c43c79643c7cd43c88943c8be43c8ca43c8cd43c8e343c8f443c92a43c92b43c92f43c963447d2f447d6844f16844f64344f65044f66b44f67444f68646789346789446789a468100477f9d477ff547811647812b47812c48041348042348044848d84148d84348d85348d903497c99ae5f174a35a84a35d04a36104a81854a82f94b7f614b7f954b7f974b82064b820d4b82104b82c64ca1584ca3364ca41b4ca41e4d03c84d03c9702c3270626970626e7102617103b50ac9ea71d87071f90671f9c371faf371fc017434c17585d57585d775862276e31476e72176e73079193a7a43ff7a44027bc0377cf87f7cf89e7cf8c87cf8d97cf8db7cf9a87cf9d57cf9f48002aa800e2187c81a87c837881402881404881b6f881b848836d3883b84884007884b038853328967d1896c1d8990018a01318a2908901011a6fa0ba9a198adfc9badfcabadfccaadfcdfadfce0adfd8aadfe0fadfeceadfed1adfedcadfef1adff75adffacadffefae0001ae003aae0045ae005fae0065ae007fae008fae00edae00f3ae0139ae01c8ae0212ae0227ae0242ae02d2ae02dfae02e0ae0313ae0364ae0366ae0375ae0384ae03f7ae03f8ae0403ae0409ae0441ae04ecae055dae058aae05abae05adae05deae0614ae0661ae0685ae07b3ae07daae07efae088dae088fae0893ae0897ae093fae0945ae094cae09b1ae09bfae09c8ae09fcae0a15ae0a1aae0a3fae0a41ae0a83ae0b00ae0b40ae0b68ae0b83ae0bbaae0c4bae0ca4ae0cd4ae0cebae0d4fae0dbbae0dd4ae0dedae0e25ae0e44ae0ea8ae0f04ae115dae11c9ae11e9ae12b7ae13b7ae13cbae13ceae1404ae144eae145aae147eae14d0ae14d4ae15efae1689ae178cae17b9ae17efae1849ae186dae1898ae18f8ae1c34ae1daaae1e46ae1e59ae1e5bae1e74ae1ea8ae1f1dae1f33ae1f3bae1f86ae1f8fae1fa7ae1fabae2047ae2053ae205eae205fae2066ae2071ae20bfae20c7ae2130ae214cae215dae216eae21efae2230ae266dae26e2ae2794ae2797ae2907ae290aae291aae2c3aae2f23ae2f9eae4796ae479cae47e8ae4834ae4867ae4871ae487eae4885ae49c6ae49ecae49fbae4a0fae4a15ae4a20ae4b2dae4cadae4cefae4d3eae4e94ae4ebfae4ed0ae4ed2ae4ed7ae4edaae4f68ae4f8aae4f95ae4fd3ae4fd6ae51a7ae523eae52e6ae5367ae537aae5392ae543eae54eeae5684ae56a1ae56b2ae56c0ae56f0ae578eae57c2ae57d4ae5964ae596cae5995ae59e0ae59e9ae59f4ae59fdae5a0fae5a28ae5ab2ae5ac7ae5adfae5b0fae5b3fae5b49ae5b6fae5bf5ae5c67ae5ca8ae5cafae5cbbae5ceaae5d0bae5d13ae5d18ae5d4dae5d74ae5d94ae5dc2ae5df7ae5e12ae5e57ae5e9aae5eafae5ee3ae5eedae5f37ae601cae6021ae6039ae6041ae6106ae618cae6209ae623aae624cae625bae625cae636dae637eae63e6ae63f1ae63f6ae6404ae6405ae649aae64b6ae6997ae6aabae6b9bae6c15ae6c16ae6c44ae6c63ae6c84ae6ca7ae6cbaae6cc4ae6e75ae6e77ae73ddae7479ae74b2ae74e3ae77d3ae77feaedb53af3c88c87f0bc87f38c87f3fe14c32e200c7e4007de4010ce40110e41aa3e49258e49593e4992601007e0200330200380200c706428a06a21806a21906a27e0a404f0ac0710ac0e60ac8970ac9300acaf60d05680d07c50d0e6b142c1a142c9414fc0315287e152afa1533ee15508215537c32001532004a32005e33fcaa33fcdb33fd2833fd4033fd7533fdb833fddf33fdeb33fe6c33ffee34151734738e3532c935464b39a84f3aaabf3aaac43aaacd3aaad83aaae93aab123aab133aab173aab373aab493aab6f3aab743aac1c3b76393b7b3d3b7b753b9bfa43c43643c49e3f551d3f60073f85693f89b03f8a643f9c843fa9963fb3163fb34d400ee143c67c43c4a243c67f43c09843c0db43c19843c21843c29043c2a943c31943c56543c5e143c5ed43c6d743c70643c73e43c8ef43c93543c96743caed447d1644b2ad44f10444f10644f14144f18944f1a344f66e44f67344f68744f68844f6a344f8464678a646807946807a46815146815b477f9248040c48041648041d48044648050848050e48080c480854480861480882480c22480c2448104b48d82548d8e2497c2c4984294984b64a36014a830b71f8814b7f6e4b7f814b7fb04b7fb94b7fdf4b82074b82084b82214b82d14ca1ec4d03c54d23b3503fd5505f69506f68507f8150813150ffcc702c2a70c0927102e271030c7103847103d574358b7500c57503f87504357586b976e30976e30e76e72c7836057a42187a427c7a427d7a42a27a44017a493d7a49d27b0fcd7bb1527bcaac7be0247be0457cf8327cf8517cf89b7cf9a67cf9b67cf9c47cf9d87cf9de7cf9f77cfa487cfa848002288002b58002fa8002fb87cc30881b96881bb1881bc8884016896c25896c4c8a05808a0846adfc6cadfc91adfcb3adfcdaadfdcbadfe5badfe68adfe80adfea0adfeb4adfed6adfed7adff45adff56adff86adffb5adffb8adffdbae000bae0027ae002bae007aae0092ae0095ae00d3ae00f6ae00fcae00fdae0197ae01c9ae01d9ae01e6ae02c7ae02f2ae02f3ae0383ae0387ae03d5ae03fbae041aae0426ae04a1ae04aeae04c4ae0500ae0564ae0573ae059fae05aaae05baae0607ae0611ae061cae0658ae065fae0668ae0673ae0752ae07afae07d7ae0810ae088eae08bcae08e3ae095cae0961ae0965ae09a1ae0a16ae0a28ae0a38ae0a44ae0aabae0ae6ae0b19ae0b7bae0b7eae0b80ae0bacae0bb4ae0be1ae0c0aae0c32ae0c36ae0c5aae0c64ae0cbbae0d31ae0d39ae0d3aae0d61ae0d80ae0e1bae0e42ae0e4eae0e7eae0eb4ae10b8ae10e7ae1149ae1155ae1160ae1196ae11e3ae11f2ae1205ae139bae13a0ae1401ae1406ae1443ae1450ae1477ae14b5ae14cdae14d6ae14f8ae1514ae15c9ae16f4ae16fbae1728ae179eae17a1ae17ffae184eae59a4ae18f3ae1948ae194eae1951ae19eeae19f6ae1b77ae1bf3ae1d3eae1e53ae1e70ae1e8dae1e93ae1eb2ae1f72ae1f92ae1fb8ae1fd2ae1ff0ae201933fd4eae2030ae203bae2046ae20deae2178ae21a1ae21f0ae223bae24f1ae2680ae26baae26f5ae276fae27a4ae27f7ae2900ae293aae2bf3ae2bf6ae2c00ae2ed5ae2ed9ae2fabae479bae4817ae483bae487fae4882ae4a19ae4a2aae4b35ae4b5bae4bb9ae4beeae4cb1ae4cf0ae4d56ae4ddeae4e17ae4e18ae4e97ae4ecdae4eeaae4fa8ae4fb0ae50beae50ddae51d3ae51e3ae5251ae5272ae528aae52b3ae5362ae536bae5657ae5676ae567eae5680ae56ceae5723ae5750ae577bae578dae579eae57a1ae5894ae589aae58a0ae58a6ae5940ae595eae596eae59b6ae59ccae59f1ae5a02ae5a03ae5a04ae5a75ae5a8bae5a8cae5a91ae5abeae5b26ae5b2cae5b2fae5b74ae5bb0ae5bbdae5c6eae5c98ae5ca7ae5ce1ae5d2bae5d31ae5d4bae5d86ae5de6ae5decae5e04ae5e8eae5e9dae5ea7ae5eaeae5ebdae5f03ae5f08ae5f1aae5f40ae5f85ae600eae602cae60f4ae61c2ae6215ae62f8ae6337ae6344ae6359ae6370ae63b1ae63ccae63f2ae63f5ae64aeae6601ae690aae6a16ae6a2bae6a2fae6b86ae6c41ae6c70ae6c88ae6cfaae6dbcae748eae77c6c2b0b7c2b39bc87f0dc87f26c87f2ac87f32e2002ae4009de400a1e49286e9406000eafd01007b0100870100890100d301012c0101ac02003b0200f3038ff006a24906a25506a299480c050a404c0ac33b0ac9430acbb20c20da0c40520d0ab0142e22142e46146849151cd415271515533732000a32001732002032003132003432004332005c32006333fd3733fe0b33fe0c33fe1833fe1b33ffb233ffcc3aaad63aaaf43aaaf93aab463aab6d3aaba23aabd93aabf33b76013b76253b76263b76523b9bfe3df5bf3ea1cc3eb86643c27b3f47fa3f62fc3f9a923fb1793fba0643c28b43c39443c0cc43c1be43c26543c4af43c68543c6b843c73d43c76343c7a043c87b43c8c743c8e043c8e543c93c43c94f44f0a644f16044f16944f66544f8014678a14682eb477fa148044a48080748085748d88748d892497c24497c32497c814984264984574984874aecba4b7f784b7f9d4b7fbd4b7feb4b82c54d03c1505f6f506f61506f66516204516205702c6a7061f570621770626671021d7102ae7102c471031171df0171f29b74358775012e7502a876172576410276e3067a42467a44197a48967a495d7a49ee7b005f7b0eab7b70277bc0107be02a7cf8307cf84e7cf8807cf8927cf99c7cf9a37cfa017cfa047cfa0d7cfaa57cfaa67cfaa980028f8002b88002c58002cd800796800e42801478896c5687c41387c41687c41a87c83187cc1b87cc4988140188140c884361896c09896c59896c648a01328a08bc901012ae0b7ca2172eae0bbdae0bbeae0c20ae606aadfc64adfc93adfc94adfc9fadfcbeadfcf1adfcfdadfdc2adfdcdadfe77adfe7eadfe84adfe87adfec9adfed3adffa9ae001dae0050ae005bae0077ae0085ae008aae009eae00c9ae00dbae00f9ae00faae0163ae0179ae01c2ae024fae02d5ae038cae03cfae03f2ae0415ae0425ae0456ae04cdae04dfae04e9ae0592ae059cae05a0ae05e1ae060eae061bae0620ae066bae0694ae07f1ae0805ae0816ae0888ae089bae08ecae094dae09a2ae09d1ae0a29ae0a7bae0a87ae0a9eae0abfae0ae2ae0af9ae0b09ae0b30ae0b32ae0b3fae0c53ae0c6dae0cb9ae0dacae0db6ae0dbaae0e04ae0e0cae0e0eae0e1fae0e24ae0e2bae0e36ae0e5bae0e5cae0e68ae0e6aae0e96ae0e98ae0e9aae0ed0ae0edeae110cae113bae114cae115aae116cae1174ae1175ae11b1ae11c2ae11ebae11f6ae123eae1245ae1294ae13a4ae13adae13f9ae1400ae140aae1415ae1417ae141dae1479ae1482ae14b8ae16f5ae1700ae1702ae171fae174cae17a8ae17b1ae1852ae1866ae189cae18aeae1b86ae1c0cae1d7bae1db5ae1e7bae1e81ae1e83ae1e88ae1ebcae1ecfae1f87ae1fb9ae1fd1ae1fddae1fe0ae204cae2058ae2134ae2142ae2151ae220dae2660ae269cae26f1ae2774ae27fdae29d5ae2ef4ae2f13ae2f5fae2f6cae2f71ae2f97ae2f98ae2fa6ae2fafae35ccae479aae47acae47adae47b9ae47bdae47e6ae47ffae4820ae4821ae4849ae487aae488aae498bae49daae49deae4a1cae4a7aae4ae2ae4af1ae4af8ae4afcae4b31ae4b49ae4bb8ae4cf7ae4d60ae4d9bae4e10ae4e90ae4ee2ae4ee8ae4f29ae4f7bae4f88ae50e8ae51c4ae51cfae51d9ae51e4ae5247ae524eae525bae52daae536dae53feae54cfae54eaae5646ae5672ae56e9ae5720ae5768ae579aae57bdae57c1ae57c3ae57ceae5875ae58aeae58b8ae58d3ae58ebae595dae59d5ae59eeae5a70ae5ae9ae5b36ae5b8cae5bd0ae5c55ae5c63ae5d5dae5d66ae5d88ae5d91ae5d96ae5dbdae5e0bae5e6fae5e84ae5ea0ae5ea8ae5edbae5eddae5ee2ae5f3eae5f47ae5f93ae5f9bae600bae600cae603eae60ecae6197ae61bfae6221ae623eae624dae6383ae63b7ae63e0ae6402ae6407ae6471ae660cae690dae698aae6a75ae6a89ae6a8bae6a92ae6a9aae6abfae6bffae6c0bae6cccae6d3eae6d54ae6d8eae6da7ae6dbaae6dbeae6dc0ae6dc2ae6dcaae6dcdae7449ae746dae747fae7483ae748cae74c0ae74d6ae74e6ae77caae7801af3c49c2bfc1c87f10c87f2fc87f40e20018e40093e483d2e49287e4992be8065ee8068ee8cfd8e8cfda01007702003a02005f0200b502011a06a25c06a27d0900fc09cd450ab0420ac3f20ac9320ac93c0acb180d82130d8216142c4f142e12150011152b37152bb2154068154071154eb632001432004532005d32007433fc9233fcb233fce033fd3133fd4233fdd733fdf333ffdb34158b3426953571ca3f9b113aaab43aaac03aaad93aab153aab453aab4c3aab9e3aaba43aabb43aabc13aabc83aabe83b7b5e3b9bcf3ea12c3f81fa3fa9393fb4aa43c07c43c17543c17c43c1b343c1cc43c1ff43c29e43c2b543c31043c39943c4e043c5db43c5e943c5fb43c66f43c6e043c6e443c70043c70743c73a43c74943c75b43c78143c79843c8b443c95043c9b343caef43cf30447d21447d54447d5844ed8344f14744f67a4680784681764682bd477fcd478131480414480478480499480810480859480c2d48104748104f48d82448d82848d845497cc7497cdd4984484984974984a74a82fb4a830e4a83154b7f434b7fda4b7ff54b7ffe4b82c04b82db4d206a505f75506f4350ffee704f8171027d7102fa7102fb71df03738a03738b4a7500bd7502a57503f77a429d7a429f7a492e7bb15c7be0167cf83b7cf88b7cf8977cf9b57cf9ce7cf9d37cf9f37cf9f87cfa007cfa097cfa3280020480020e8002ab8002cc80033387c40187c41087c826880029881ba38967d3896c15896c24896c2e896c41896c63896c6c8a00028a02dc8a0328adfc73adfc7aadfc9cadfc9eadfce6adfcf8adfdb7adfe59adfe6cadfe7badfeaaadfed9adff4eadff76adff8fadffe5adffecae0006ae0008ae000dae0057ae0067ae0069ae0076ae00e4ae0143ae014fae01fcae0234ae0252ae0253ae0310ae0314ae0355ae036aae03eaae03fdae0484ae04e7ae0560ae057aae0595ae05a7ae065aae0750ae07a5ae07c6ae080dae0815ae0819ae094bae09c9ae0a0bae0a3eae0a74ae0a9cae0a9dae0afaae0afeae0b3dae0be3ae0c2aae0c7dae0cdbae0d38ae0d3bae0d64ae0dc9ae0e14ae0e3bae0e8cae10bfae10fdae1114ae1154ae11b5ae11c1ae11faae13b1ae13b4ae13e6ae13f4ae1410ae1456ae1459ae147fae14a2ae14ccae1532ae1724ae1731ae1738ae186aae18acae18ecae18f4ae194fae19c1ae1bebae1bf8ae1bfdae1dafae1db7ae1dc2ae1e96ae1e99ae1ed9ae1f39ae1f51ae1f56ae1f57ae1f78ae1fa3ae1fafae1fb3ae1fd4ae1ff8ae2043ae2057ae20bcae20c1ae2114ae213fae222eae2482ae24e4ae2650ae2674ae268eae26a3ae26acae26d8ae275bae290dae290fae29d4ae2be5ae2ed2ae2ed7ae2ef6ae47a4ae47b7ae47d9ae47dfae47e7ae47edae49c8ae4a13ae4a1fae4a7dae4b50ae4b74ae4bd9ae4bdfae4cb0ae4cf1ae4d2dae4d5bae4e93ae4ea0ae4ea6ae4eb2ae4f27ae4fadae4faeae5026ae509fae50b0ae50e9ae5112ae51a2ae5208ae5240ae5269ae528bae5292ae52ecae52f1ae530bae536cae5395ae5664ae566aae5673ae5679ae569bae56ccae57a7ae57d0ae595cae596bae5996ae59abae59d2ae5a23ae5a5aae5a64ae5aa1ae5ad4ae5ae6ae5aefae5b8dae5bb1ae5be1ae5be3ae5c54ae5c76ae5c8dae5c93ae5ce2ae5d3aae5d3bae5d7bae5d7dae5d80ae5d90ae5d97ae5dfcae5e00ae5e05ae5e9cae5eb7ae5f04ae5f4dae5fa5ae5fa9ae6010ae6036ae6047ae604fae61dfae620eae622bae623dae6298ae633fae634aae634fae6350ae63ceae63d9ae6403ae647bae648aae64b4ae68b9ae6987ae6a2eae6a78ae6a7fae6abeae6b94ae6ba6ae6ba8ae6c0eae6c48ae6c6eae6c75ae6ccbae6ccdae6cfbae6d4cae6dc3ae7470ae747aae7481ae74d5ae74d7ae74eaaf61c6c2bcf1c2b07bc2bd19c2bd23c87f00c87f06c87f2be14d73e20028e200efe40109e485e9e487d4e4916de494a63aab04e84919e88022e8cfc10101a302003e02004006a21606a25006a25d06a29407007b0ac77b0ac78e0ac8300ac93e0d06d90d08963aab1b0d821814fbc614fc071526be1526db152897152b0a1533de15340215405c15508532000e32004b32007c33fcc633fd0b33fd3433fd4333fd4933fd5033fd5733fd9933fdc133fe1a33fe2c33ffad3546413571cf3aaabd3aaac23aab283aab733aab823aab8e3aab933aab953aaba93aabb53aac133aac1d3b76403b77793b7b433b9bab3b9baf3ea5563f49e63f4dca3f89763f8b8e3fa9343fa93c43c11743c14843c16843c1e443c29543c29943c32b43c49643c4bc43c4cf43c4d043c4d543c4d743c54f43c5f943c67e43c69943c6d143c72e43c75743c76e43c79243c79e43c7e743c87943c87d43c8b343c8cc43c8e143c93644982544ed8144f10944f64244f6a044f82146789148043248044548080548b15e48d82e48d89848d8a548d90948d98149842e4984424984884984b14a35a54a36144a81824a81884b7f724b7fae4b7fb54b821f4b829a4b82a706a26b4ca41d503fd0503fdb506e1c507f826831f1683243702c3b70626571026f71030a06a27771f90b738a8f7503277610527a42037a42077a42237a42437a42837a44757a448f7a48957be04c7cf8677cf8797cf8f07cf98d7cf9f07cf9fb8002768002c480079c800e16800e20800e7080150d80151c80151e87c41587cc0087cc2187cc3187cd0387cd23881b68881bee881c02894087896028896c07896c2c896c3d896c40896c46896c7406a2988a0a00ae07c8a6fa32a7cae8ae6330ae636fadfc83adfc89adfc8aadfcaeadfcccadfdb5adfdbaadfdd1adfdecadfe63adfe82adfeaeadfebcadff55adff6fadff79adffdeadffe4ae0047ae005dae008dae01ceae01d7ae01e3ae0203ae0237ae0264ae02ddae0325ae0356ae0376ae03efae0407ae0487ae04b2ae04c1ae04deae055cae0662ae0663ae0679ae0689ae07abae07bdae63bdae07e9ae0883ae08b8ae09aeae09b4ae09d8ae09f5ae0a17ae0a43ae0abaae0affae0b01ae0b6bae0bc8ae0be8ae0c22ae0c7fae0c83ae0d23ae0d63ae0d6fae0da7ae0dcfae0decae0e0bae0e10ae0e32ae0e40ae0e55ae0e5dae0e73ae0eb6ae10ffae1109ae1138ae1139ae113dae1147ae1153ae1162ae12b6ae1396ae13f3ae1403ae1405ae1425ae149eae14a6ae14a9ae14c8ae1521ae173aae1758ae179fae17b8ae17f3ae183bae1873ae18e5ae18fdae197dae19bbae19e3ae1bf4ae1d00ae1d5bae1e4dae1e54ae1e5cae1e8fae1e98ae1ebbae1eceae1f31ae1f45ae1f4aae1f4fae1f65ae1f95ae1fb5ae1fdaae2008ae200bae2040ae206fae215fae2209ae2237ae23e1ae24e6ae24ebae267bae63c1ae26f3ae2709ae27f3ae27f8ae2becae2ee7ae2f19ae2fd7ae47beae47f6ae47fcae485bae49c5ae49e6ae4a4dae4a81ae4b3bae4b57ae4b58ae4caaae4e01ae4e11ae4e1fae4edeae4ef1ae4f36ae4f83ae4f96ae4f9cae4fa0ae4fc3ae4fdfae4fe0ae4febae513bae51b1ae5268ae528fae52a0ae52abae52f2ae5305ae5315ae5316ae5321ae5374ae5381ae55a0ae567cae5681ae5683ae56a0ae56d1ae56e4ae571aae5790ae5796ae57b8ae57ccae58b2ae598fae5998ae59f5ae5a57ae5a8aae5aadae5ab0ae5ac8ae5adbae5b45ae5b69ae5b89ae5bdeae5be6ae5be7ae5cb9ae5d37ae5d55ae5d57ae5d7aae5d7fae5d8dae5dc5ae5dc7ae5dc8ae5e14ae5e50ae614aae5e91ae5eb0ae5ebfae5ec1ae5ec7ae5ee0ae5f51ae6194ae61c5ae61ffae6201ae6237ae63ddae6473ae65f6ae6902ae6915ae695eae6960ae6965ae6a28ae6a29ae6a35ae6a81ae6a93ae6aa8ae6b9aae6b9eae6babae6c6aae6c7cae6cb5ae6cc0ae6cd2ae6d40ae6d76ae6d86ae6da6ae6da8ae6dafae6db8ae6dc9ae73efae7427ae746fae7482ae74aeae74e4ae74eeaf551baf9babc2bcabe2001ce200aee4007ce4008be400c2e40142e48c6ce49174e49af8e84930e84931e8800401007902005e0200610200ee0344450640f206a3930901380940130ac1520ac3c40ac7c90ac7db0ac8280ac88e0ac8960ac9260ac9400acaf70b603e0b603f0c215a0d08a80d0bf4142da0142f6414a127150099154c831553411d334532000f32003b32005833fd1033fd3b33fd4f33fd6d33fd8033fdc233ff1a3532cb3532cc35350c3571c53591c63593cc3aaabc3aaae63aab243aab263aab333aab4b3aab603aabf23b76003b762e3b7b5d3b7b6a3b9bae3b9be33e857e3e8ef23e8f0f3f43e63f62193f86dc3f89753fbba93fbc8343c17443c1e243c25843c2aa43c32743c39343c39c43c40343c42143c4c543c5e443c5f543c66d43c68a43c6da43c72943c79d43c7ea43c80943c8b943c8c043c8c343c94843cf2e43cf3a447d2644c1e444f66d44f67644f67b457c2045f446467801477f7147812448041c48051248080248083748083a480c4548d84648d88648da61497c03497c0649842c4984614984d24a34e14a35b64aecb94b7f8f4b7f964b7fc34b7ff24b82254b822a4b82c44b82d54ca13e4d03d04d20de507f9d50ff3e6008816831f77102a171030471030e71fe5c743591763b2c76e31076e72b7a424d7a42547a431a7a438b7a443a7a49cf7b059f7b704f7be04e7cf8357cf83e7cf85d7cf8897cf8ee7cf9957cf9ab7cf9b38002448002658002688002c98002f7800332800e3e800e8b800f338016ec87c00287c40c87cc1888140b881b668880bd896c2f896c628991828a041b8a042b8a042c8a055d8a07088a0935a7cabea7ce16adfc6aadfc8cadfcbcadfcbfadfcd5adfcecadfcf3adfcf4adfcf7adfde6adfe10adfe15adfe65adfe6fadfe92adfebbadff08adff58adff7dadffa0adffe0adffe6ae0013ae006bae007dae00a1ae00b3ae00c4ae00daae0164ae01a4ae01f9ae023bae023eae0244ae025aae02caae03c2ae03ebae0406ae0418ae044dae0488ae048aae04bcae04c7ae04cbae04f8ae0504ae0509ae0559ae0563ae0565ae0635ae066fae0690ae0691ae0692ae06d9ae07adae07cbae07e5ae07eeae088bae089cae08eaae094fae0950ae095bae098eae0993ae09a8ae09d4ae0a09ae0a12ae0a1fae0a8dae0aadae0b08ae0b1eae0b20ae0ba9ae6c02ae0baaae0bc2ae0c0fae0c39ae0c43ae0c4fae0c71ae0ca5ae0ccdae0d1cae0d90ae0dc6ae0dc8ae0de6ae0e29ae0e34ae0e35ae0e4bae0e62ae0e64ae0ebaae1119ae111dae1146ae11d5ae11d6ae11e2ae123dae130dae139fae13c4ae140fae14c5ae172cae1732ae173fae1789ae17a4ae17aeae18f6ae1976ae19e8ae1c07ae1c08ae1c32ae1d19ae1dc4ae1dc8ae1e60ae1e77ae1e86ae1eabae1f1fae1f27ae1f60ae1f76ae1f9bae1fa8ae1fc1ae2024ae2054ae20c5ae20fdae210fae21d6ae24c5ae26abae26caae272dae2742ae2770ae2771ae2772ae2937ae293bae29cfae29daae29fdae2ef2ae2ef3ae2f9fae479eae47e4ae4829ae487bae487dae488dae49c2ae4a0dae4a1eae4a4cae4a53ae4a94ae6c58ae4b80ae4bd7ae4d32ae4dfeae4e14ae4e92ae4eefae4f3cae4fc5ae50b2ae51a3ae51a8ae51b9ae51bbae51ceae5282ae5289ae52ccae5373ae5383ae5393ae539bae5421ae54e3ae5648ae5661ae566eae5694ae56c2ae56eeae5718ae571cae5726ae5783ae57cbae5870ae587aae5899ae58b5ae58b6ae58edae58f4ae5941ae599aae59c7ae59cfae5a0aae5a42ae5a4dae5ac6ae5acaae5b16ae5b80ae5bd5ae5c77ae5d53ae5dccae5e16ae5e97ae5eabae5ed5ae5f0eae5f4eae5f92ae5f97ae5f9cae5fa1ae6042ae60b0ae60cbae6203ae623cae6334ae638aae63deae63edae6413ae6475ae647dae64b7ae64c3ae65f5ae68bdae6989ae698dae6a14ae6a2dae6a3eae6a7cae6a80ae6aa9ae6b9dae6bcaae6bf3ae6d3fae6d7bae6da4ae73ebae748dae74b4ae74b7ae74c2aed61daf09c4afc66ac2b08fc87f2dc87f36e14d34e400b0e847f8e8cfb9e8cfbfe8cfce0101780101910200b70200c102010706a26306a2920a40050a400b0a40230a405e0ac3180ac7e20ac89e0ac8d90ac9860acb170d09140d0b280d8212142280142d8614b67014b67314b8ee14f11b1533ba154c8f1d333d32001332002432006133fccb33fd7633fdb433fdbc33fde533fe1433fe2e33fe9d33ff1833ff1c33ff8c33ffb53426933474d23532c73532ce3565953571d43593cb39a85539a8573aab033aab643aab7d3aab813aabca3aabcb3aabd83b769c3b77903b77aa3b9baa3b9bb93e854f3e95dc3f6eea3f6f023f793f3f88563f892a3f96663f97fd3f9e5b3fa1963fa9363facfe400ee243c06a43c14443c1d543c1fa43c20c43c22543c31843c38c43c39643c40843c4db43c55343c73b43c76043c76143c79343c8bf43c90f43c93443c972447d0044c1e844f64944f662457c0d46789c46f801473c07477f7b47819d4804124804444804914804b548080848084f480c2148d82148d84948d8e3497c4a497cb34984a54984a64984c84a35a44a81f84b7f5f4b7f8a4b7f9a4b7fc14b7fdd4b7fe04b82a14b82b14b82e14b82ea4ca31a505f6d506e1d506f45507f8650ff39702c3170c08d71002871025e71026d71034e71039d7103b671dd1871f88075032875032976209a76e7327a42227a42897a43fa7a49c47be0517cf8377cf8437cf86b7cf8937cf9a57cf9aa7cfa0c7cfa3380026a80027e80029f8002a78002ca8002da8002ea80041480079480079b800e15800e2f800f3087cc2687cd018805b088140688140e881bb8881bb9881bc78967d08967dc896c658a02d88a09378a0a36ae4fdeaa94bcadfc6fadfc86adfc87adfc99adfca2adfcb5adfcbbadfcc7adfceeadfe1cadfe95adfeccadfed4adff4aadff53adffb9adffc8ae0017ae0021ae0040ae00d5ae00e9ae0149ae015dae01cfae01d8ae01e4ae0229ae0256ae0271ae02f9ae030cae0319ae0320ae0389ae03caae03d0ae046cae047eae0483ae04b1ae04b3ae04b8ae04d9ae04daae04e2ae04f9ae0599ae062cae0659ae065eae0666ae068cae06e9ae07acae07feae0809ae080aae0817ae0875ae08b3ae08b5ae08fbae0944ae09d9ae09fdae0af5ae0b94ae0bbcae0bc3ae0bffae0c5dae0cd0ae0cd7ae0d6bae0d71ae0d8bae0d94ae0dd0ae0de9ae0e19ae0e22ae0e4dae0e4fae0ebeae1163ae116eae1170ae11b7ae11dcae11ecae11eeae1233ae1275ae1397ae13afae143cae1481ae149cae149fae14afae14c3ae14c7ae1525ae1577ae15a4ae171eae17b4ae17bfae1869ae1901ae1920ae1926ae1997ae19a6ae19c4ae19d3ae1b6dae1c11ae1d81ae1e4bae1e66ae1e71ae1e73ae1ea9ae1eb7ae1ec7ae1f1eae1f28ae1f4eae1f5eae1f82ae1f9aae1fa1ae1fe8ae1ff9ae2013ae2023ae2026ae2027ae20fbae223aae24e2ae24f7ae266fae2679ae267eae268fae26a2ae275fae2784ae27a2ae27a7ae27f4ae27ffae2902ae2919ae29d8ae29d9ae2f10ae2f18ae2f25ae2f5bae2fadae47a1ae47b6ae47e3ae4814ae4a7fae4b56ae4ceeae4d50ae4d64ae4d68ae4e1bae4ec9ae4f5aae4f77ae4fc4ae4ffeae50d9ae5114ae511eae51b4ae523bae5299ae529cae52c0ae52c7ae537dae539cae539dae540cae54e6ae54ecae54f5ae54f9ae5623ae5678ae56a2ae56b4ae56bfae56d4ae56deae5722ae5780ae5782ae578bae5795ae5797ae57a9ae57aaae5889ae5897ae5973ae59b9ae59e4ae5a2eae5a4cae5ab1ae5aecae5b3bae5b40ae5b6bae5b8bae5bbeae5bc5ae5bd2ae5c62ae5c6aae5c96ae5d17ae5d48ae5e7eae5ebcae5ec6ae5ee9ae5eefae5efdae5f3cae5fadae6018ae6028ae60c8ae60d3ae622dae68b2ae634bae6371ae6375ae63bfae647fae64a5ae65f9ae68aeae68bcae6918ae699aae6a1cae6a33ae6b9fae6c46ae6c5fae6c6dae6ccaae6cf3ae6cfcae6d7aae74b0ae74d0ae74e9ae77faaf6c50af8486c2b003c2bd2d33fdd5c87f11e4009be400f7e8066be847f4e8483401007c01008d0101900200bb05cc10062f0f06a2480940140a40120a401e0ac3c30ac7450ac7d90ac9330ac93a0d082e0d094414b9331501c0151cd8151cfa1526da152882152bc71533f31553491f2bca1f33951f6fa932001e32003332003c33fcd133fcfd33fd0333fd5a33fd6c33fde233fe1233fe9e33ff1f33ffcf33ffd43425993430853571cc3591d03591d939a84d3aaaa83aab0b3aab5b3aab7f3aabdb3aabdd3aabf03aabf73b769d3b77743b7b643b9bb243c7093e9cd643c70e3f43d43f4d0c3f66823f79f93fb7973fb88a43c00943c04143c17d43c1fb43c1fc43c22d43c24a43c31c43c31d43c32243c49a43c4bd43c4da43c55b43c56643c5dd43c5e843c5ee43c6a643c6e743c8bd43c8cf43c8f543c943447d04447d24447d4544c1e144f12844f68145f42c4680754682f046fbe947819f4804094804154804224804ad48086d480c01480c03480c26480c27480c2948104648312348d8a948d8ee497c9a497ca1497ca3497cc8497ce449842d4984394984474984ae4a35b74a35d14a81874a82f34a83094aebd34b7f5a4b7f5d4b7f674b7f714b7f804b7f8e4b7f914b7fa24b7faa4b7fc04b7ff44b82054b82154b82a34b82a54b82ad4b82df4b82e94c01554ca1e84d03cb501f9d506f675100e76831f6702c3070626170626871013771035071f9c2738a907434ce79141c7a42217a42587a429b7a44557a49377cf86f7cf8917cf8f17cf9e480022980024f80025e80026980029a8002a28002b68002b7800f0587c84087cd0287cd2e881b86896c3e8a04278a0a4cae0ddbac17c9adfc96adfc9dadfca5adfdb8adfdd3adfdeaadfe49adfe67adfe91adfeadadff62adff7aadff87adffafadffb0adffc6adffd1adffe2adffe9adfffbae0039ae003bae0061ae0086ae009bae00a5ae00b4ae00b6ae00ceae00d9ae0100ae0146ae01abae01b0ae01cbae01d1ae01f2ae021bae025fae0263ae02fbae0369ae03e2ae0424ae045fae0466ae046aae047dae0485ae048cae049bae04aaae04ceae04f1ae04fdae0586ae058cae05acae07eaae08acae08f4ae095fae09bdae0a8bae0a93ae0acbae0b3eae0b76ae0bafae0c0cae0c0eae0c23ae0c49ae0c5bae0cbeae0cd3ae0d0bae0d33ae0d4eae0db4ae0dccae0debae10b5ae5290ae10efae111bae1137ae1140ae1194ae11e5ae1258ae12b0ae13c0ae13f1ae141fae1423ae1445ae1451ae16e3ae172dae1740ae1749ae17a6ae1829ae1911ae1b61ae1b6eae1c2dae1d71ae1d7aae1e43ae1e57ae1e58ae1e7dae1ea5ae1eb0ae1f38ae1f54ae1f63ae1f70ae1fa9ae1fb7ae1fccae1fd5ae1ff6ae2009ae205bae20c4ae20eeae214bae2174ae21e0ae21f1ae24a8ae24e3ae24f0ae24f2ae266bae2672ae2686ae2693ae26afae274bae2ee2ae2ee4ae2ef7ae2ef8ae2f5aae47b4ae47bbae47faae4825ae4832ae4842ae4847ae4863ae4876ae4a7eae4ae6ae4b33ae4bacae4be7ae4d33ae4d69ae4e99ae4ec7ae4ee6ae4f54ae4facae4fecae4ff6ae5156ae519533fcb6ae51d8ae528dae52f4ae5310ae5377ae5396ae539aae53d2ae54cdae54dcae54e4ae55d5ae5660ae5685ae56d7ae56e1ae5721ae5772ae578aae57a3ae57d7ae5885ae58a7ae5967ae5990ae59b8ae59c8ae59d0ae59d3ae59eaae5a63ae5a6fae5a94ae5a9fae5aa4ae5accae5adeae5b1bae5b3dae5b68ae5b81ae5baeae5be2ae5bedae5c5eae5ca6ae5cadae5cc9ae5ccfae5cd7ae5d1cae5d50ae5d54ae5d85ae5ec9ae5ef7ae5f80ae5faeae6004ae6026ae6029ae6035ae61faae620dae621aae621dae622fae634dae637dae63b9ae63bbae63c0ae63e3ae63ecae6479ae6486ae6959ae695fae699bae69c7ae6a1bae6a86ae6c22ae6c61ae6c67ae6c79ae6cbcae6cbdae6d81ae6da9ae6dc5ae6e80ae73d5ae7475ae749cae74c1ae74c6ae77d4ae77f333fd19c2b3a5c87f2ec87f3be20008e48443e48c6ee4916ae49217e4992ee8068ae88d54e8cfc30060370100710100800200390200b202013106800206a20c08af3e0a403c0ac3410ac7d30ac7d80ac9340ac9c70acafa0b41160d05e60d07db0d08aa0d08b50d0e26142c81142ec114f91f1500c5152884152895152be8154f531d333c32000732004132004f32005733fdde33fde033fded33fe0e33fe1e33fec83426943430d83442c43571c43aaab73aaad43aab083aab213aab2a3aab403aab473aab613aabdf3aabe43aac273b75c03b76503b779f3b7b6b3b9bb73b9bce3b9bf73e83d43e8e963ebd413f69313f8fbe434ca943c0e243c10f43c14b43c17343c25643c28f43c42443c45243c4c043c56243c6a043c6bb43c6cb43c6f543c6f843c70843c71043c7e043c8f143c92e43c93743c94a43c95544c1ea44f0a745f44846789746800746807b4680fb46815e477fc9477ff048040e48043e48048048048448080648083c480868480c2b48d84b48d88d48d89648d8ad48d90648d90b497024497ca5497cb2497cb4497cb74984584984894a34d84a82204b7f8c4b7f904b7fc64b7fce4b7fe54b82134ca1e94d03c34d232d503fd9516203600b5d68324c702c0270625f7102a97102de7102e07102fc7102fd71fa0671fa11738a53738a6374359475012d75015676103076105176e72678044878ccaf7a426c7a445f7b700a7bb17e7cf8477cf8347cf84d7cf8737cf9257cf9917cf9a07cf9ad7cf9b47cf9d67cf9fe7cfa167cfa4f7cfa8380027380027c80027d80033180033687c84e881407884b01896c12896c438a032a8a06578a0842ae6bb5adfc8fadfcb1adfcc5adfcddadfdc0adfddcadfe19adfe75adfea8adff42adff5dadff63adff6eadff71adff7eadffc1adffc7adfff7ae0029ae0033ae0042ae0043ae00aaae00c7ae00cbae0206ae0262ae0267ae02d4ae0329ae037aae03e5ae03edae0402ae046eae0472ae049fae04bbae0501ae0566ae056aae05ddae0654ae0667ae07f5ae0874ae08a8ae08e2ae08edae093aae09abae0a32ae0a4bae0a78ae0a86ae0a8fae6b92ae0ab2ae0af4ae0bceae0bebae0c0bae0cc4ae0cc7ae0cc8ae0d35ae0d8dae0db5ae0dc2ae0ddcae0df9ae0e2aae0e2dae0e30ae0e41ae0e86ae0ec5ae0efcae10beae1107ae110bae1112ae1123ae1130ae113aae11daae1201ae1235ae1240ae1254ae138fae13a9ae13c2ae1439ae144aae1455ae1469ae149aae14ffae1736ae1742ae17b6ae1839ae184fae1850ae1864ae186bae1871ae18ccae191fae1925ae1b8eae1bf5ae1c38ae1dbdae1e5fae1e89ae1ec3ae1ed5ae1ed8ae1f35ae1f53ae1f5fae1f6aae1fa0ae1faaae1fcfae1fd9ae1ffdae2000ae200cae2137ae222dae2604ae267fae2edbae2eddae2eeaae2ef1ae2f57ae2f58ae2fd6ae478eae47feae4819ae4862ae4866ae499dae49c1ae4a4bae4aeaae4af2ae4af3ae4b6cae4b6fae4babae4d54ae4e00ae4e0dae4e1dae4eb3ae4ebaae4fb2ae4fd5ae4ff1ae50b1ae5193ae5196ae52aaae52baae52caae52f8ae53c1ae54e7ae54f4ae54f8ae564cae567dae5686ae56b0ae56c8ae56dcae56e5ae572cae5778ae577cae57d5ae57d8ae58e4ae595fae5965ae59afae59bdae59c4ae59c5ae59deae5a01ae5a1cae5a2fae5a47ae5a54ae5a59ae5a68ae5a8eae5abfae5ad2ae5b6aae5c5cae5c60ae5ca4ae5cabae5d1eae5d4eae5eb1ae5eb5ae5eb9ae5effae5f14ae5f8fae6019ae601fae60b2ae60f0ae6332ae6346ae6353ae636eae63bcae63d8ae63daae63e1ae63e8ae63eeae6484ae6961ae698bae698fae699cae6a13ae6a1eae6a36ae6a73ae6a7eae6aa6ae6be7ae6bf9ae6c45ae6cb7ae6cf1ae7458ae7476ae7478ae74bdae74ceae77a7aed1cdaf3c55af8602afca18c2b03fc2b071c2bdc3c2c07fe4009ee49254e49929e84035e847f7e8c00702006003e32506a21206a24e06a26506a26c0a40110a40350a40550a40570ac38e0ac3c20ac7440ac7460ac82f0ac89c0b44530c4053142dc7142e3b142f6914f126151cd3151d0b152bc91533111533be1533fa15405b1b4f5f1d33431d334632000b32001132002c32003232004c32005633fc8233fc9833fceb33fd0833fd4633fda033fda533fde733fe0133ff9233ffc533ffe53592133aaaa23aaaa43aaad73aaada3aabe23b76243b77403b777a3b77b73f722e3e89983e8b023f4ba93f55723fa9ff3fb37b3fbdfc43c22243c27343c27643c29143c30443c38e43c45143c55643c55d43c56443c61143c68f43c69c43c6dc43c6e943c76543c76b43c78043c79443c79943c87f43c8eb43c8fa447d1844f67e44f69244f6a4457c1746788b46789e46789f4682c046ec4b473c08477f73477f9148042948044348044b48083b48084b48d8ae497c014984274984594984b54a35ef4a822a4a83144b7fd14b7fef4b801f4b820b4b82a84b82b04b82bf4b82d24b82da4b82e7505f74506f24506f41506f64506f6b507fa970c09171026771029171029d7102a27102d571030f7103b271041e7281317434cf74368c75032a76410376e30b76e725777e0d7913697a42017a42877a42a67a49c37a49da7bb1557be01f7cf8397cf8587cf8647cf8657cf8787cf8827cf8837cf8847cf8f27cf9dc7cf9fa80024b8002778003c180041580147780150f87cd20881b6b884015896c2b8a08cfe20007ae04fbae058dae0590adfc6dadfc84adfc97adfca1adfca7adfca9adfcf0adfcfeadfd08adfd6fadfdc6adfe1fadfe8fadfea2adfeacadfebfadfef2adff57adffd2adffebadffedae004aae00b0ae00d8ae00fbae0101ae012eae0131ae0132ae0134ae0145ae0148ae014bae014cae014dae0153ae01b3ae01c7ae01d2ae0204ae021fae0232ae0246ae0260ae026bae02d3ae031bae032bae035cae037fae038aae03feae0410ae04adae04c5ae04e3ae05b9ae05d4ae05e4ae0608ae0623ae0626ae0672ae0687ae0798ae07a8ae07b7ae07bcae07d2ae0813ae0832ae0840ae0842ae085bae0892ae08beae0940ae09adae09d2ae0a10ae0a55ae0a92ae0aaaae0bb5ae0bedae0c25ae0c37ae0d12ae0d34ae0d51ae0d68ae0de1ae0de5ae0de7ae0df0ae0e38ae0e46ae10f2ae111aae1165ae1171ae11b3ae11beae11caae1293ae138dae13f8ae13fcae145fae1464ae14acae14b0ae14bcae14beae1597ae16edae16fdae1746ae174bae174eae17a9ae17b7ae183aae18e7ae197cae19caae19f0ae1c2bae1c30ae1d03ae1dc1ae1df0ae1e94ae1eb4ae1f4bae1f91ae1ff4ae201eae2025ae202aae21ceae21e7ae23d4ae23ecae2657ae2690ae269fae26bfae2791ae29d0ae29d1ae29d2ae2eedae2efaae2f5dae2f5eae2fd4ae35cdae47baae47ddae47ecae47f9ae4812ae483aae4848ae4861ae4889ae49f2ae4abfae4af0ae4b34ae4ba9ae4beaae4cf9ae4eaeae4eafae4ed8ae4eecae4fafae50c4ae50ccae51cdae5263ae5267ae528eae5291ae529aae52a7ae52bcae52cdae52f0ae5306ae5363ae5379ae5387ae5391ae53d8ae54e5ae564dae5675ae5690ae5698ae56a3ae5792ae5794ae57a2ae57acae57bbae5887ae58e2ae58e6ae5985ae59a8ae59e5ae59fbae5a58ae5a81ae5ab6ae5addae5ae7ae5b28ae5b67ae5b76ae5bc4ae5bd8ae5cb2ae5cd1ae5cfaae5d0eae5d4fae5d67ae5dd4ae5df2ae5e9fae5ecbae5eceae5f13ae5f44ae5f56ae5fa7ae602fae60b1ae60f1ae60f6ae6169ae6193ae61fbae6213ae6214ae6219ae6226ae6227ae622cae626bae628bae632bae633dae6349ae6385ae63e5ae6401ae6406ae6474ae6493ae68b8ae68bbae6a18ae6a19ae6a3bae6a6dae6a99ae6ac3ae6bebae6c13ae6c14ae6c17ae6c6cae6c73ae6cacae6d53ae6d56ae6d58ae6d5aae6daaae74a7ae74abae74baae74cbaedae5c2bdffe483d1e48f41e84800e84802e8490d01008c01016602010a038f4f06409806a24d06a25906a27606a29a07007e0982250ac0f60ac7510b43000c404e0d05470d065f0d08a90d09420d09610d8032142f5c14f1191500941501c815287f152888152bcb1533c8154c5c155335155342155353155356197cd032001b32005032007b33fc9633fd0e33fd4a33fd4b33fd6533fdab33fdcf33fdd333fe1333fe2233ffb333ffc33424533530413530553532c33aaaa73aaaa93aab653aab9b3aaba03aabd63aabe13aabe73b761c3b76313b764c3b76cb3b77853b7b6f3b7b703b7b723b9bef3ea74d3ead633f54073f67323f6e573f7b3c3f7dc13f7dec3f8c413fa9413fbd82405f354060d643c04c43c08143c0ae43c16343c27443c27543c39243c46e43c49d43c4c243c4d143c55e43c5ef43c61443c68043c69243c75943c7ac43c7ad43c7ce43c7e243c87e43c8d543c8e943c91e43c93a43c94b43c951447d23447d6944c1e544f64544f64c44f66f45f4374678954680fa46ec4c4781a048041a48050d480513480c4848104148c15e48d82b48da6348da83497c09497c2b497c334984214984954984b04a34e94a360e4a81f44a821f4b7f9f4b7fc54b7fd34b82144b82184b82d9506f23506f6250ff3a516206600bb76831ce683307702c4670c0817102807103007103ad71d87171dd1771f90371fa10738a49738a62738a647586247610477a43f27a49487bb0e27bc7b77bd0367bd04c7be0297cf8317cf8717cf8757cf88d7cf8d77cf9ae7cf9d47cf9f27cf9ff7cfa1b7cfa4680027a8002958002ce800323800dbb80150e87c41187c835880404881bba89602a896c0f8a00248a090cae4d65ae779eae0a77adfc88adfcedadfd0fadfdceadfe60adfe71adfeb3adff6cadff8badff99adffc9adfffcadfffeae0007ae0015ae0032ae0041ae008eae00a0ae00a9ae0133ae013cae014eae0152ae0192ae01acae01f1ae0201ae0202ae0224ae026dae02d9ae0358ae0371ae038eae03ccae03e6ae03e7ae03e8ae0411ae0417ae041cae0440ae0446ae044bae0455ae0473ae04baae04e4ae04edae0506ae057eae0591ae060dae061dae0622ae066cae0676ae07ceae07d4ae07f2ae0801ae080bae080cae088cae0977ae099fae0a18ae0a2bae0a89ae0af3ae0b04ae0ba6ae0bb6ae0c08ae0c98ae0ca8ae0cbdae0cc9ae0d03ae0d93ae0e15ae0e71ae0e85ae0ea7ae10c1ae110aae112bae112fae1142ae1143ae1144ae11bfae11d3ae1204ae1239ae1241ae1274ae12a1ae12a8ae12acae13bbae1419ae1442ae1533ae1536ae1595ae15c4ae16f3ae1753ae1793ae1870ae1905ae1927ae193cae198aae199cae1ae8ae1befae1bf6ae1d1cae1db0ae1e4aae1e6dae1e78ae1e82ae1ea6ae1ec0ae1f37ae1f5bae1f7eae1f8aae1f8dae1f90ae1fd3ae1fe2ae1ffaae202eae203dae205aae20b6ae21e9ae223eae24e9ae2656ae266cae26d4ae5140ae2f1aae2fa5ae47a0ae47b0ae47b5ae47e1ae47f2ae4831ae4840ae486aae4881ae4d2bae4d30ae4dddae4e8fae4e98ae4ea5ae4ea8ae4ebcae4ec8ae4f16ae4fb8ae4fd4ae4fe6ae4ff4ae4ffcae50c5ae50e1ae5183ae519aae51a9ae51acae51b0ae51bcae51c7ae5254ae5261ae5274ae5288ae52d2ae52f7ae54b6ae5665ae568dae5871ae5880ae588eae58c0ae58c7ae5961ae59aaae59c0ae59d7ae59f3ae5a08ae5a0dae5a1fae5a7dae5a82ae5a92ae5ae0ae5b2eae5c73ae5d25ae5dfaae5dffae5e01ae5e08ae5e11ae5ec4ae5ee1ae5f10ae5f4fae6012ae6014ae6017ae602aae61b8ae6205ae6231ae6240ae6328ae632dae633eae6372ae6381ae63b2ae63c2ae63e7ae63eaae63fdae63feae63ffae64b1ae64bbae6901ae6903ae6996ae6a3fae6a7aae6bc6ae6c0cae6c0fae6c4cae6cf2ae6d41ae6e7bae73d6ae7412ae744cae747dae74aaae74c7ae74c8ae74e73f8686e20017e2005ee4009fe4015ae48e5de494a3e4955de8cfbee940500101a00200a20200fd0640f106a21406a21706a21e09012b0940100a40180ac3150ac7a40ac9a00ac9e90ba0090d01e03f9359142deb14f90d1528891533e11533f6154ee715534f15537e1d334932000632001232001d32002232002732003832007e33fcbd33fd6433fd7433fd9b33fdb533fddd33fde633fe1933fe7933fe7a33feca33ff4b33ffc634308634738a3aaac93aaaf03aaaff3aab013aab593aab723aabae3b75b53b76373b76463b76663b76683b76743b767f3b77733b777f3b77803b7b853b7bf93b9be73b9bfb3b9bfc3e90c83ebd9a3f43463f56e63f66b03f853b3fbdc443c06d43c07043c0bf43c17243c1a243c25043c26e43c27743c2b043c30843c32843c42d43c4d943c60e43c61a43c68143c6df43c6eb43c6f743c6fa43c73f43c7da43c81043c88043c8c443c8f943c92643c94943c96243c96543c99f447d3a44f02844f16744f1a544f42b44f845451c4245f42646812e477fd348040648041f48042b48042c48048148048d48049b48083648083948084a48104348105448d88548d894497c27497c73497c784984534984544984564984994984b34a81e64b7f7f4b82044b820a4b820c4b82974b82dc4b82e04b82e24b82e34b83a94c2c2a501fbc506e21506e22506f5e6831f4702c66702c67702c8871f88371fa0f71fc02738a0474358d76105d777e0b7a426b7a42a07a44407a495a7a495b7cf83c7cf8427cf8857cf8907cf9967cf9e97cfa027cfa317cfa3780020a80022580026d800270800790800e13800e54800e63800e71800f3480171687c40e87c82287c82487c84d87cd1b881409881b61881ba68880d1896c0d896c4889900289910f8a06eb8a08668a08918a08948a08dc8a2907a1b361ae1120ae5284ae068eadfcd8adfcf6adfd0badfdc4adfdd5adfe1eadfe4dadfe54adfe6eadfeddadff07adff40adff59adffcaadfff9ae0005ae001aae003dae0056ae0074ae0079ae0099ae00e7ae010eae0113ae0147ae01faae0238ae0261ae036fae038bae03deae03f9ae040eae0422ae0428ae044eae04c0ae04caae04f2ae059dae05a5ae0618ae061eae0655ae0669ae07c1ae07c2ae07e6ae0806ae08adae60e9ae08dcae08feae09a4ae09b5ae09c4ae0a35ae0a49ae0a53ae0ab6ae0abeae0acaae0aedae0b35ae0b3cae0b71ae0b8aae0b92ae0b9aae0bd7ae0c14ae0c33ae0c9aae0cf5ae0d37ae0d6aae0d6cae0dbfae0de8ae0e2cae10e2ae111eae1158ae11b6ae11c0ae123fae1276ae138aae13cdae13faae1416ae1446ae144bae1460ae149dae14adae14c1ae1517ae1534ae1729ae178dae185eae198bae198dae1a05ae1ba9ae1cc8ae1cf9ae1d8fae1d91ae1dc7ae1e4cae1e4eae1e5aae1e6eae1f48ae1f6bae1f6cae1f71ae1f8bae1f93ae1f96ae1fbdae1ffeae200eae2044ae2052ae2060ae20bdae20c2ae2131ae216fae2486ae265eae2675ae2681ae26b2ae2730ae277dae29ceae2bf9ae2bfaae2ed8ae2eefae2fa4ae3409ae449bae47a5ae47e0ae4822ae484bae499fae49e1ae4a16ae4a21ae4ae0ae4af6ae4b83ae4e16ae4e1eae4f23ae4f26ae4f42ae4f8fae4f94ae4f97ae4fe5ae50dbae5119ae514eae51b7ae51bfae51dbae5275ae5312ae531dae5360ae54dbae565bae565cae5670ae56b1ae56c6ae56e0ae577fae5781ae57abae57b5ae587dae588fae58e5ae58eaae598bae59b0ae59bfae59d8ae59e2ae59e3ae5a19ae5a77ae5a7eae5a85ae5aabae5b2dae5b3eae5b47ae5b71ae5b7fae5bc2ae5c17ae5c99ae5c9bae5c9cae5ca2ae5d15ae5d38ae5d84ae5d9bae5dfdae5e03ae5e4cae5e65ae5e9bae5eb3ae5f39ae5f3fae5f45ae5f94ae5f9fae5fe7ae6006ae602bae605fae6060ae60d1ae61b6ae6229ae6329ae6354ae6376ae6384ae63f9ae6499ae64a8ae65f8ae68afae6904ae6a12ae6a7dae6ab9ae6bacae6c18ae6c34ae6c68ae6c6bae6cb1ae6cc8ae6cd7ae6d74ae6db2ae6e76ae6e7eae73e5ae7457ae74caae74e1ae77c4af4fb3afb23cc2b067c87f09c87f24e20022e20094e4014de402d0e47e40e48bd9e4916ce49273e49d16e80688e8068be8800ae940fae940fb0200ff02b26d04c20d06a21106a26906a27b0a40060a40090a401a0a401c0aca230b41170b42270b42900d05c20d06da0d08240d09aa15008f152be215406415534032005132007733fcfe33fd0633fd2433fd4d33fd6833fd8f33fdf533fe0833fe0d33fe1d33fe1f33ffae33ffec34264f34269734314c3432ce3434d634360334738f3532c43aaabe3aaaf63aab233aab633b75463b765a3b766a3b773f3b7b6c3b9bb83b9bd73f52cd3f89053f8c623f99c63fbe24400ee843c40743c42b43c00043c09943c13e43c27243c31f43c36b43c39843c39f43c3a143c42f43c46343c49b43c4a943c5eb43c5f743c67743c67843c69443c6bf43c6fb43c70a43c76943c76c43c7a243c7a543c7d343c7d743c93943c964447d1a447d31447d3f44f14644f18144f1a144f42144f60144f60544f67c457c21457c3145f42b45f44246810246815a46831346ec4d473c06477fd047812747812e48044148050948051448080148084d48c0de48d8a848d982497c714a34e34a35d54b7f454b7f6d4b7f7d4b7f994b7fa94b7fbe4b7fc24b7fc74b82034b820e4b82cc4ca41c4d03c0501f9c501fba505f71506e20506e6c71027c7102a77102dc7102e171030771037f71039b71f01171f90571fa09744a6275002676104b76e31276e7247830707a42267a428a7a42b27a486d7a49317a49397aa30b7cf8547cf88e7cf8f47cf98e7cf99a7cfa737cfad280022680027180027f800290800318800602800792800dc0800dc187c81b87c83487cc0487cc098805a5881b6e881b83881b8988401a884b02888005888006894011896c02896c6b8a04208a042d8a08bd8a09e7a2de5cae1ae0adfc66adfcb0adfcc2adfcdeadfd04adfdc7adfdd7adfdddae6a74adfe13adfe4badfe5aadfe5fadfed8adff8cadff98adffd5adffeeadfff2ae001bae0026ae003eae0090ae009fae00cdae00d0ae00f5ae0200ae0239ae023cae025dae025eae02deae0359ae038fae03c4ae03e9ae03eeae040dae0414ae041eae0467ae0477ae04ccae04d0ae04ebae0576ae05a4ae0628ae066aae06e0ae06e6ae07b0ae07d1ae07e7ae0847ae0865ae086aae0881ae0894ae08cfae08e9ae08f8ae093bae096aae098cae09c0ae09f7ae0a19ae0a33ae0a42ae0a76ae0a7aae0a8eae0afcae0b48ae0b52ae0b95ae0c05ae0c13ae0c54ae0cd8ae0ce1ae0d0dae0d2eae0d62ae0d7cae0d9dae0db9ae0defae0e01ae0e3aae0e54ae0edfae0eedae0f02ae10bbae10e1ae10e3ae110dae116bae11bcae11efae135dae1390ae1394ae139eae13a5ae13abae13bdae13c1ae13cfae1402ae140eae1431ae1461ae14a1ae14aaae151dae152cae161cae16e4ae171dae1788ae178aae17a3ae17e8ae1857ae185fae1923ae1959ae1b4eae1baaae1c02ae1d2aae1d2bae1d85ae1db2ae1e6cae1e84ae1ea1ae1eb5ae1ec1ae1ec9ae1f3dae1f8cae1fb2ae1fcdae2011ae212cae21ebae222fae24eeae2673ae2678ae2685ae2687ae26a5ae2764ae277eae2798ae2911ae2939ae2bd7ae2c3bae2eebae2f2aae2f9cae2fa9ae47a6ae47a8ae47aeae47bfae4845ae485aae4864ae486fae499cae49c3ae4b00ae4b53ae4b77ae4b8cae4b8eae4b91ae4ba5ae4bb1ae4c62ae4d2aae4d5aae4e9bae4e9fae4eaaae4ebbae4ec6ae4ee5ae4ef0ae4f1eae4f7fae4fa1ae4fb6ae4fb9ae4ff2ae4ff5ae50b9ae5187ae519dae51b2ae51e2ae5238ae5287ae52a8ae52bfae52c8ae52d1ae5304ae530fae5376ae5394ae564aae566bae5671ae567aae5695ae569aae56e2ae5776ae5786ae57beae5878ae587cae587eae587fae5895ae58a5ae58f3ae5962ae5963ae59aeae59cdae59edae59f6ae5a46ae5a84ae5a88ae5a97ae5ab7ae5acfae5b27ae5b37ae5b72ae5b73ae5b77ae5b87ae5b8aae5badae5c5aae5c94ae5cacae5cb0ae5cb1ae5cd0ae5cf1ae5d0fae5d39ae5d62ae5d6aae5d75ae5dceae5df6ae5dfeae5e0dae5e13ae5eb4ae5ee8ae5ef9ae5f0dae5f19ae5f31ae6043ae61dcae6223ae633aae63b6ae63c3ae6603ae68baae68beae68c0ae6a20ae6a88ae6a8eae6a96ae6b87ae6baeae6c5cae6ca9ae6caeae6cafae6cdfae6da2ae6db5ae73e9ae7447ae744aae7466ae74afae74beae77daaf0a4daf351faf4fabaff009c2b02bc2bb7fc2bd41c2bd4bc87f02c87f28c87f3de2002fe40410e49592e49934e8066ce8cfc4e8cfcc01007a06a26206a2950941090a401b0ac37e0ac3ef0ac79d0ac9020ac9ec0c404914247e142f4f1432a814f1231501ad151cd2152b9e152ba4152bb01540661540731551e015522132000432001832002f32005933fc8c33fc9133fcd233fd8d33fdc633fe2433fe3a33fea933ff4733ff4f33ffd33426143474143532ca35350f3571c83571d73aaacb3aaad53aaae03aaaeb3aaaed3aab003aab3c3aab5d3aab6e3aab7e3aabf13aac163aac343b76723b77ab3b9bf93b9bfd3ea5413f80193f814b3f85ca3f8bd0400eed43c04e43c20743c21c43c25743c48e43c4ac43c5e043c61643c61e43c6a743c6db43c8d743c91f43c94743c97a43c98c447d1b447d20447d3444f02744f18744f1a244f64144f67745f436467892468158473c0a47811a47812f4781a14781a348040548044c4804b248050a48050b48083d48085c480883480c4148104248105148d82748d84048d84c48d8e4497c21497c42497c4b497c724984944a81f94a83064b7f444b7f664b7f744b7f984b7fa84b7fb64b7fec4b82164b82cf501d13502faf505f875100c2702c2670626c7102df71034f71039f71f80171f90971fa07738a8d7435897503f97585db764101791cad79c1047a427b7a493c7a49597b70267cf86c7cf9a27cf9ac7cf9eb7cfa348002eb800dc380145087cc3e881b67881bab883b86896c5c8a063d8a08d28a2902ae5e8cadfc7dadfc85adfc9aadfcc0adfce5adfce7adfd0cadfdccadfe86adfe8aadfe8eadfea4adfeb6adfedeadff3fadff4badff4fadff78adff80adffa2adfff5ae0053ae005eae0075ae0084ae0097ae00a3ae00f0ae0150ae0156ae0181ae0193ae01c4ae01e8ae01eaae0247ae037eae040bae0474ae0478ae04a6ae04c2ae04dcae053eae0569ae0571ae0588ae05afae05b0ae05e2ae0649ae0651ae066dae07a7ae07bbae07dbae08a2ae08a5ae08afae08faae0943ae0992ae0999ae09beae0b0dae0b15ae0b39ae0b9cae0b9eae0ba8ae0bc0ae0bc5ae0c50ae0c59ae0c5cae0c73ae0cbaae0db2ae0e39ae0e57ae0e5aae0e8aae0ea4ae0eabae10f9ae113eae1d5aae1199ae11abae11cbae13f7ae1454ae1463ae14abae14faae1547ae173eae1744ae1750ae1797ae17adae17f5ae1803ae186eae1936ae1943ae1949ae1962ae1980ae1b92ae1b9aae1c10ae1c22ae1c27ae1d5eae1d5fae1d88ae1db9ae1e48ae1e52ae1e64ae1e76ae1e8eae1e92ae1eb9ae1f3cae1f62ae202fae2063ae2140ae21cfae21d7ae2676ae2677ae2683ae26c9ae27f6ae290eae2923ae29dbae2ee0ae2f69ae2f6dae4789ae478cae47b2ae47f3ae4830ae4839ae499bae4a1bae4b3eae4b95ae4cedae4cf5ae4d5fae4de1ae4e9eae4f2dae4f5cae4f93ae4fd1ae4fd2ae50adae50e3ae51c3ae51d6ae5258ae527cae529dae529fae52b8ae52c1ae54b5ae54d4ae54e0ae564eae5650ae5688ae5693ae569dae569fae56d2ae56d9ae5744ae5799ae57c6ae58dfae596aae5981ae59b7ae59faae5a2dae5a3cae5a56ae5a6cae5a93ae5b44ae5b88ae5bb2ae5c5bae5cdcae5cfbae5d08ae5d45ae5d5cae5d65ae5d89ae5d92ae5dc0ae5dcfae5debae5df9ae5e15ae5ea6ae5ed9ae5ef2ae5f4cae5f53ae5fa8ae600fae6023ae605aae61f6ae61f8ae6216ae6335ae6356ae6378ae63f3ae6481ae6498ae649fae68b3ae6916ae6919ae6a1aae6a26ae6a3dae6a84ae6a85ae6aa2ae6c12ae6c1bae6c1eae6c82ae6cbbae6cc9ae6d72ae6dacae6ed7ae73edae74bbae74d1ae74ddae7798ae77d0ae77d5ae77fcae8953aed618aef550af4fa7af7831af85fbaf8603af9ba1c2b035c2b05dc87f0fc87f25e14c70e40550e48932e48e51e849c402003702003c02004c0200b60200bf0200e202012f03e3e604403e06a21c06a25e0ac8940acad80b41130c217e142e6714667214757e14a12c1500981501b815287c15288d152b051533e0154e6532003f32007933fcbe33fd1b33fece33ffcb3424903530423563413571d33aaae73aab073aab143aab223aab3d3aab913aab923aab9d3aab9f3aabb33aabc03aabc63aabda3e91ee3ea5bc3f447a3f44883f56853f619c3f6ee13f8d1f3fa2573fa5ea43c21643c22743c25a43c29f43c30c43c32a43c36c43c3a443c4e143c60b43c60d43c69b43c6c543c6d343c75f43c77b43c7db43c87a43c8ba43c8bb43c8d043c8d243c8f043c91c447d5644f14244f666457c0f457c3045f44345f4474780ee47811548083f480853480864480c0648d8567cf89c48d8a748d908497c11497c4049843149848f4984a24984a34a35a74a35c74a83024aecc94b7f624b7f754b7f944b7fc44b82124b82984c2c1b4ca13f50807f4d03cc503fdc505f6e505fa0505fa17cf8eb507f8750fd8a702c6870626d7103147103a8738a597434c374358274358a74359374359575050a76603876e72d78059d7a42497a429c7a42a47a49467aa03a7be9037cf86e7cf8707cf8967cf89a7cf9b17cf9c57cf9cf7cfa1780021e8002788002c78002f8800440800c3d800e1987c83f87c84c87cc0187cc2487cc4a88040188140d896c0b896c1e8a05608a05a58a0845a1b22eadfc74adfd00adfdcaadfdcfadfde8adfe14adfe61adfe85adfe9aadfeb7adfebaadff46adff4dadff74adffa8adffe7adfff8ae000aae000eae004bae007bae0081ae00c3ae00d7ae00feae013eae01c6ae01e0ae01f6ae01f8ae0219ae021aae0228ae0257ae02d8ae03d4ae03e0ae044fae0458ae047bae049cae04e0ae0503ae056cae0574ae0584ae058bae05b1ae05bcae06daae07dcae07f3ae07fbae0895ae089eae08a1ae08bbae0953ae09b3ae09c7ae0a2aae0a3cae0ac9ae0b6eae0b6fae0beeae0c27ae0c9fae0caaae0d41ae0d82ae0e52ae0e8bae0e9eae0eb8ae0ed4ae0ef9ae10b6ae115eae1172ae1197ae11bdae11dbae1259ae139aae13b5ae13efae1409ae145cae1478ae1704ae1708ae1718ae1792ae179dae17baae183cae18ebae1932ae1945ae1974ae1afbae1b8fae1d6cae1d92ae1d98ae1dbaae1dc3ae1ddbae1e91ae1eadae1ed6ae1f30ae1f34ae1f3eae1f46ae1fa2ae1fbcae1fcaae1fceae1fd8ae2007ae2012ae2061ae2068ae20beae20f9ae212dae21bdae21f8ae265cae26b0ae26d6ae26dbae291eae2befae2edaae2f6bae47e5ae47fdae4818ae4846ae488eae4a0bae4b30ae4be9ae4d49ae4d52ae4dffae4eb4ae4eb6ae4eb7ae4f12ae4f25ae4f92ae4fc1ae4fe3ae4ffbae50e0ae50e5ae510fae5121ae5148ae51afae51c5ae52a4ae52aeae52b9ae5320ae5365ae5366ae5372ae537fae5422ae54ccae54ceae54d6ae54f7ae56c5ae5752ae578fae57a4ae57b9ae57baae57d2ae57d3ae5890ae58f2ae599bae599dae59c9ae59d1ae59d9ae5a12ae5a17ae5a1aae5a1eae5a3eae5a5cae5a5dae5b3aae5bcfae5be4ae5c74ae5c8fae5c9fae5d51ae5d61ae5d69ae5d6bae5d7cae5de2ae5e18ae5e94ae5e99ae5ebeae5ec2ae5f15ae5f38ae5feaae6025ae6033ae6044ae6045ae60e8ae6224ae6235ae624bae633bae6391ae6393ae63fbae6400ae6482ae6491ae64c5ae65f7ae65feae6a79ae6bd6ae6bdcae6becae6c0dae6c59ae6cb3ae6ce4ae6d4eae6db1ae6e78ae73eaae73f1ae740cae74a6ae74cdae74d3ae77d2ae77f8ae77f9ae86acaecb47aef4c4aefcdfafa18bc87f03e400ece40135e483aae483abe48920e49210e49925e8064be8cfc9e8cfcf00cbac0200d5062f0106a21a06a26806a2720900fa0a400d0a400e0a40170ac82b0ac8910ac89a0ac8f40ac9270ac9e80b20140d04c20d08e4142c5e142d51142e96142ea915002515287b152887152b5815508332000532000d32001632001a32002533fc8d33fce933fd2133fd3333fd3c33fd4833fdbe33fe2633fea333ff1e33ff7c33ffbc33ffd83426163433923553163aaab83aaac63aaad33aaaef3aab0d3aab3b3aab833aab903aabc73aabd03aac373b76283b76473b76543b9bd13b9bdd3e920f3f402a3f57273f57e93f59f33f66313f80b13f81a53f84e83f9dac3fa48343c94543c02443c17643c22a43c25b43c26843c2a543c31543c43943c4c443c56043c68443c6c243c7e643c8b643c8dc43c8f243c94643c960447d06447d3244f63544f63a45f42845f44546788c473c03473c09477f72477fcb48042d48043d48080e48083848085f480c1b480c4448b0de48d85548da4248da84497fa149842b49844149847449848d4984924a34e64a35a64a35ac4a82ee4a830c4b7f894b7fa14b7faf4b7fb14b7fed4b7ff84b82014c5c00503fd2505fbb7062647103097103157103a471f00f71fc03738a617434c2750415758736762af37a420c7a424f7a425e7a426d7a427a7a43fe7a44447a444f7a44657a49147a495c7cf84b7cf8957cf9c97cf9ee7cfa307cfa448000788000e38002a580031e80033587c808881408881baa881bb3885337896c308991818a055e8a055f8a09328a093633fea833fec433ff1133ff75adfc98adfca0adfcb4adfd88adfdebadfdeeadfe0dadfe17adfe18adfe73adfed0adfedaadff10adff3cadff5cadff7cadff7fadff93adff9eadffcbadffd0adffeaadfffdae0012ae0025ae0063ae0078ae007cae00deae00f2ae00f4ae01fdae026cae02f0ae0324ae0391ae03d1ae03dbae03f0ae0421ae0453ae0469ae0470ae04a9ae04abae04bfae04f5ae057bae059eae05b7ae062dae0677ae0686ae06e5ae07bfae07d8ae07d9ae07dfae07e2ae07f8ae0807ae08d5ae093cae0952ae0978ae09c2ae09ffae0a7fae0aa8ae0ae3ae0b85ae0b86ae0b93ae0b9bae0bbfae0c21ae0c63ae0c65ae0c6cae0cafae0cbcae0cc0ae0cf6ae0d15ae0d26ae0d57ae0d6dae0d7dae0e51ae0e8fae10deae1129ae112aae1133ae1145ae114eae11e4ae127bae127dae145bae145eae14a7ae14feae151eae1531ae1535ae16e5ae1725ae172bae173dae17b2ae17beae182bae1836ae18e0ae18f5ae1991ae19c5ae1ba8ae1c0aae1c19ae1d46ae1d56ae1d94ae1de0ae1e6fae1e9dae1f6dae1f9dae1f9fae1faeae1fc3ae1fe1ae2004ae2022ae203aae2048ae212eae24e7ae2662ae2665ae266eae26daae2746ae2761ae2779ae2912ae29feae2ee1ae2f65ae47daae47f8ae481bae481eae4844ae484eae485cae4a2233ffd1ae4afaae4b47ae4b88ae4cf3ae4cf6ae4d36ae4d3aae4d48ae4de2ae4e0bae4e58ae4e91ae4ec2ae4ee9ae4eedae4f14ae4f31ae4f4cae4f80ae4f8cae4f91ae4fb5ae4fddae5004ae50b6ae50c1ae50c2ae50ceae50d6ae50e6ae5117ae511aae5120ae519eae51d0ae51e1ae525cae526dae52e4ae530eae53a5ae53d4ae5407ae541aae54c9ae54daae5655ae568aae56ddae5725ae576bae5789ae57b6ae5873ae589dae58adae58f7ae58f8ae5968ae59b5ae59f8ae5a71ae5a86ae5ab4ae5ae3ae5b39ae5bffae5c66ae5ca9ae5d14ae5d24ae5d28ae5d6dae5dc1ae5dd0ae5df8ae5e02ae5e09ae5e0aae5e0cae5e95ae5e98ae5eb2ae5ebaae5ecaae5ef8ae5f00ae5f06ae5f0fae5facae6005ae6016ae6022ae60edae60f3ae6212ae6218ae6220ae6249ae6336ae6345ae6380ae6394ae63c7ae63f7ae6492ae68b4ae690fae6917ae6a8fae6aa7ae6ac2ae6bb0ae6bc7ae6c0aae6c1fae6c5aae6cb4ae6d7dae743cae746eae748aae74ccae7800ae7813c2b09933ffd6c87f07e400a0e400b4e494a4e806b9e847f100c7f601007201007d0101d30200310200e50680b006a26006a26a06a27407007f0a401f0ab0280ac3f10ac3f30ac82e0ac8950ac8980b43560d08e50d0bf014311e14b5761500c1151cef1526e8152b0815407815520b15535215535731ff0a32000832002832003a32003d32005433fca233fcda33fd0533fd7e33fdc433fdd833fde833fe1733fe2833fe3033fe923422cc3533443aab293aab5a3aab753aab783aab8c3aabe53aac243b76553b7b693b7b713b7f753b9bd33b9bd93b9bf34a36073e87643f55ad3f5d913f60013f75883fafb63fb3a43fbebf43c76d43c7d8406f5043c07343c07543c22443c28743c29243c30743c31343c38d43c40f43c49743c6e643c74843c75d43c8db43c8ee43c96843cb58447d2244c1e344f0c944f1a444f652457c0545f42945f430467898477f8f48040248051048051148083e480843480879480c4048105348d88448d89048d8ab497cdc497cde49842249846f4984764984b44a35ae4b7f9c4b7fbc4b7fd54b7fde4b82ac4ca1e74ca1eb4ca2044d03ce506f6060003c702c0b7101ae71025b7102837102e471dd2171f28771fa13738a01738b4c738b4d7434c875837876604776e301777e0f7a424b7a426f7a42807a429a7a44177a44317a444d7a493a7a49527a49dc7cf85c7cf8767cf8777cf8947cf8f57cf91d7cf9f97cfa6c7cfa717cfad48002378002a4800338800dbd800f2fae0ef587c40487c84287c84a87cc3887cc3987cc3b87cd2287cde4881bc48840048967d2896c03896c14896c20896c23896c3189900c8a08448a08d48a290943c309ae0ddaae0e1cae60d4ae0e3cadfc70adfcefadfd02adfdb9adfde0adfe47adfe5dadfe66adfe93adfebeadff04adff4cadff60adff9aadffaeadffc3adffc5adffd8ae0018ae002eae005cae009aae0151ae0157ae01d4ae01d6ae0225ae022cae0241ae0268ae02c9ae02dbae02ebae02faae0377ae0388ae0393ae03e1ae0412ae0429ae042dae044aae047cae04b4ae04b9ae04c8ae04d1ae051dae0567ae0575ae0596ae05a3ae05e5ae05ecae061fae06e1ae07b9ae07beae07c4ae07e8ae0812ae0814ae08b0ae08e6ae0998ae099aae099cae0a23ae0a70ae0aa9ae0b0bae0b21ae0b88ae0bb2ae0bfdae0c03ae0c15ae0cb3ae0dd8ae10eaae10ebae10f0ae10feae117cae11c5ae11cfae11d1ae11d9ae1200ae123aae13ccae1438ae1447ae146aae146dae14b1ae14b2ae14b6ae1537ae16f2ae170aae1737ae173bae1748ae1796ae17bdae1868ae18d6ae1916ae1c2aae1c33ae1e97ae1e9eae1eacae1ebaae1f40ae1f52ae1f64ae1f7bae1f88ae1fd7ae1fedae2037ae2042ae20b7ae20baae20d0ae21f3ae220eae2600ae265dae26a0ae26cdae2756ae2768ae2f11ae2fa1ae478dae47abae47b1ae4828ae4836ae4a2bae4ae1ae4afdae4b52ae4be3ae4d58ae4e0eae4e96ae4eb9ae4ee7ae4eeeae4f6cae4f85ae4fbeae5003ae5122ae5153ae5242ae5256ae5260ae52cbae52cfae52d7ae52ebae531cae535eae5397ae53c3ae54d1ae569cae56a4ae56a9ae56daae571dae58a3ae58d9ae595bae5993ae59e6ae5a2aae5a3bae5a4fae5a62ae5a69ae5a73ae5aa6ae5ab3ae5adcae5b43ae5b6dae5bceae5c5fae5c95ae5d1aae5d35ae5d60ae5d81ae5d83ae5d87ae5d8cae5dbeae5dd2ae5e06ae5e07ae5e54ae5eadae5ed2ae5f0aae5f52ae6038ae603cae61cfae61f9ae6202ae620cae6225ae624eae6338ae6339ae6357ae63caae63e2ae641aae6472ae6476ae648cae64bdae6605ae695aae6a91ae6b99ae6bfcae6c01ae6c43ae6c5bae6c5eae6c80ae6c86ae6cb8ae6d9cae6dc4ae73dfae73ecae744fae7487ae77d7ae77f2af4dd8c87f01c87f05e200f0e40168e49376e847f60200c902b26203e326062f0506a20f06a2670a40210a40220a403e0a40600ac0f50ac3da0ac7dd0ac93b0aca140ba0000c4050152bb40d08790d0958142f9a14681114f11c15287d152b3c1540751553711b6c0330012632000932003932005f33fc9f33fd8133fdb333fdff33fe0f33fe7233fe9533ff1533ff4033ff4d33ffe434245534308934338a3453053474d73532c83532cd3aaaac3aaac73aaac83aaafb3aab943b76383b76993b7b393b9bb53e953a3ea0483eb57b3ebe1243c2713f90bc3f9e9b3fa93d3fbbc543c2b240535843c07443c16143c18643c20a43c23743c49843c4c743c4dd43c5da43c5e643c61b43c6dd43c76843c78543c79a43c7ab43c88743c8cb43c8e443c8ec43c90c43c91843c92143c93d43c953447d15447d4244c1e744f0e044f12544f14944f67d44f6a64678a5473c0e47819e480431480442480c23480c25480c4748104548d82248d82648d88b48f3a67a4230497c07497c23497cd1497cd64a35aa4a81844b7f794b7f9e4b7fca4b7fee4b82ae4b82af4ca3354d03c24d03c44d03ca503fd3506f3f7062607101317101357101a771020a71030b71038871f00e738a4b7434c47435887435977502a675050b76398776e7297a42567a425b7a425f7a426e7a431c7a48de7bba297cf8337cf87e7cf8887cf9e17cf9ec7cf9fc7cfa127cfa727cfaa77cfaac7cfad37cfad58002b38002e980030b800321800e1280152187c82387c84687c84787c84b87cc2087cc2587cd1987cd24880db688140f881bb5881be8881c06884019896c27896c3f8a06e98a09438a0961a1926aaaaef7ae74d4ae11c4adfc80adfc8eadfc95adfcd4adfce4adfd06adfd0aadfeb5adff0dadff41adff51adff81adff9cadffb6adffbfadffcfae0014ae0023ae0049ae006cae006eae0096ae00a6ae00b2ae00ccae013bae013dae01b9ae021dae0251ae0258ae030aae03a0ae03beae03c9ae03d2ae03dcae03ffae0416ae042eae0443ae0463ae0475ae0476ae0481ae049dae04bdae0561ae0579ae0587ae058eae060cae0631ae07cfae08fdae09a3ae09e8ae0a08ae0a20ae0a2eae0a84ae0a90ae0aacae0ab8ae0b16ae0b59ae0b96ae0babae0c3cae0c61ae0c69ae0cb7ae0ccfae0d20ae0d4bae0e0fae0e81ae0eddae10c2ae10dfae1104ae1124ae1125ae1161ae11deae11edae1256ae1279ae127aae127eae129aae12bdae1386ae139cae13c8ae13f6ae13feae1422ae1440ae146bae14cfae1579ae1589ae16e9ae1719ae174fae17afae17b3ae1842ae186fae1899ae189eae18b6ae1928ae1935ae1987ae1ab7ae1bf2ae1c23ae1c2fae1d95ae1dbbae1e7eae1e8cae1ed2ae1ed7ae1f0cae1f1cae1f29ae1f2dae1f67ae1f7dae1fc7ae1fdbae1feeae1ff7ae202bae2045ae204fae2076ae220fae222cae24efae2659ae2669ae269eae26a4ae26a6ae26d1ae26e3ae29d6ae29d7ae2bf0ae2bf2ae2bfeae2ed3ae2eecae2f99ae4499ae4795ae4865ae49e9ae4b67ae4b6bae4bb7ae4bdaae4ec3ae4ed6ae4f32ae4f41ae4f4bae50bcae50c9ae50daae50e7ae51e0ae5294ae52c6ae52e9ae5364ae536fae53a0ae54c8ae54d3ae5654ae5656ae5689ae5691ae5697ae56cbae5749ae57aeae57bfae5891ae5898ae58a1ae58f9ae593eae5975ae59ebae5a00ae5a0cae5a22ae5a2bae5a83ae5a87ae5b46ae5abcae5ae1ae5b7cae5bafae5bc9ae5bd1ae5bd9ae5bdcae5c9eae5ca5ae5cbcae5cbdae5cf0ae5d41ae5d42ae5d8eae5e83ae5e8fae5eb6ae5ecdae5eeeae5ef0ae5ef4ae5efaae5f43ae5fabae601dae603dae60cdae60efae61b9ae61d0ae61e6ae6245ae632cae638eae6409ae64bcae6602ae6909ae6913ae6a2aae6a37ae6a4dae6a72ae6a90ae6b81ae6b91ae6b9cae6c11ae6c7bae6db9ae6dbdae6e74ae6e79ae73dbae748bae74dbae77beae77cdaedab9aedb4eaf0a4aaf8023afaba9c2b0cbc87f13c87f21c87f3ee200abe400e0e40106e41aa2e485e7e48de1e49060e49927e49931e49948e8065ce80672e8c23701007801008a0200c60200fe06a27f0ac7500ac7dc0ac82c0ac82d0ac9380d077f0d0bf5142baa142d9414a11614f12515009115009a152b5e15406c154c32154ed015521f32000332001032002332004932006033fcea33fcec33fd4133fd5e33fd6b33fd9033fda633fdc733fe0933fe3233fe7733ffba3456073535423563423571d53571d63aaaa63aaaea3aaaf53aab103aab2e3aab703aaba53b75aa3b76713b76a23b77b63b7b3a3b7b3c3b7b3e3b7b6d3e97eb3e9bf73ea34a3f4efe3f6e1f3f8fed3f9c813fa6573fb65b3fbe34400eee43c17143c1f143c23943c2ad43c47a43c4f443c55743c61043c61243c68c43c79543c79b43c7a943c7dd43c88643c91743c94443caf043cf31447d01447d1044f10344f63844f65444f66c44f67144f685457c08457c0c4678a9477ff1477ff448044948047e4804b748050f480869480c4648104848105248d84748d8524984344984864a35d24aecbb4b7f834b7f854b7f924b7f934b7fc84b7fd24b82aa4ca1ed501fb9502fa7505f88506f4050ff4070625c70c07970c0d67102097103137103857103a971fa03738a4d74358c7435987583797586297587377610d878022478059e78c84d7a42627a426a7a438c7a444e7a483a7a49237a493f7b0b317cbc677cf84f7cf8667cf88c7cf88f7cf8f37cf9b27cf9d27cfa2f7cfa4980021f80023180026b8002d98002db8002f28002f680033080079d800e3080147487c41b87c84387cc3787cd21880405880416881b64881b65881b69881bb28960268960be896c08896c32896c3a896c44896c4a8a01008a07158a10108a5182a1b798ae0c0932007aadfc61adfc78adfc8badfc90adfcc3adfcc9adfcfbadfd03adfd05adfd10adfd6cadfe48adfe62adfe70adfe90adfed2adfedbadff44adff47adff82adff94ae001fae0070ae0082ae0087ae0098ae00cfae00dcae00ecae0154ae0158ae01dbae0215ae022bae022fae02ccae0304ae0321ae0323ae0326ae032dae03cbae03d8ae03daae03f1ae042cae0465ae0594ae05beae0632ae065dae0683ae06deae07d3ae0808ae0878ae0896ae08b9ae08e7ae09daae09faae0a0eae0a40ae0a45ae0ab3ae0af8ae0ba7ae0bb0ae0bd2ae0bfaae0c3eae0c55ae0c5fae0c66ae0cb6ae0dd5ae0e09ae0e47ae0e80ae0eb5ae0ee5ae10e0ae10e8ae1102ae1117ae113fae114aae114bae1156ae1176ae117dae1192ae11b2ae11b4ae11e633fd3fae127fae129eae1392ae139dae13a3ae13ecae144cae14a3ae14c4ae151cae159aae1613ae1726ae172fae1830ae19c8ae19ccae19d1ae1b9cae1bedae1c18ae1cfeae1d47ae1d80ae1dadae1db4ae1ec8ae1f36ae1fbbae1fe5ae201dae2028ae2033ae20d9ae2144ae21eeae21f2ae220cae2661ae267aae2682ae26b6ae275aae2775ae278bae27fbae2817ae2be6ae2efbae2f70ae2f9bae2fa8ae340aae47dcae47f5ae4824ae4837ae4869ae4886ae49e5ae4a23ae4a27ae4b5fae4b6eae4bdcae4cabae4ceaae4cfcae4e0aae4e20ae4e9dae4ea3ae4eb1ae4f44ae4fb1ae4fbcae50b3ae50d7ae511dae51c0ae51cbae51daae51eaae523aae527eae52c9ae52d5ae536aae538fae53bdae53ffae540aae54c6ae54ddae54ebae5666ae56e3ae5728ae579cae57adae57c0ae586dae58e0ae58e1ae58ecae599eae59a1ae59a5ae59ffae5a5bae5a74ae5a98ae5aa0ae5aacae5ac4ae5acbae5b85ae5ba9ae5bcaae5c57ae5c8eae5c97ae5d44ae5d95ae5dcdae5ea4ae5eb8ae5ec0ae5eccae5ed6ae5f3dae5f79ae5faaae5fe8ae6013ae602eae6046ae61d3ae629fae632eae63b4ae63e4ae64b9ae65ffae6a1fae6abaae6bcdae6c55ae6d43ae6d84ae6dabae6dbbae73f3ae74d9ae7797ae77a1aee57aaf3c51afa187afa18cafa191c2b00dc87f14c87f33e40095e40138e49288e8068de806b8e84901e84a5ae880e9e8c2350100d20101a20201b002b26e06a21b06a25a06a26f06a29b0a40280a404a0a40560ac9000ac92d0acafc0b41250b41960b603b0c2178142c0f142e9114f121151d05152756152b06152bdd15405e15407732000132004d33fd6033fd6333fd6633fd8a33fe2133fe2f33fe7433fe9433ffb933ffbd33ffc43426963432cd3435cc3453043532c13532c23592093593c83aaaf73aab0a3aab0c3aab763aab7a3aab893aabd13aac1b3b76593b76673b77913b7b633b7b733b9bb03b9bd443c8bc3e80df3e85c43e875e3e9fd23f568c3f80863f88483f91383fa9373fac0e3fb95043c07643c17043c30d43c31643c46543c4c343c4c643c4d243c55243c5dc43c5de43c5f843c60c43c61743c6a143c6ba43c6e543c73943c76f43c77c43c7a443c7ae43c7d143c7d443c88243c8b843c8c643c8c943c8d143c8da43c8e643c91943c93f43c94043cf2f447d02447d3644c1e244f04444f18644f42444f661457c0945f42f4678964680d14682f548043548044748047948047a48049348049748050c480862480866480c4248d82948d82f48d9c5497c08497c25497c4349842849843a4a36044a82f14b7f684b7f874b7fa74b7fb44b7fba4b7fcf4b820f4ca41f4ca44b505f66506f21507fab6831f368324a7062707062717101ab7102087102a371030371035d7103a071041c71041f71d87371f90771f90a738a41738a44738a60738a65738b4874359675865a76e30876e72a7836067a42477a449c7a49d87bb1817cf8537cf87c7cf91a7cf99d7cf9cb7cf9f67cfa067cfa807cfa8580029e8002b9800dbc800e1787c40287c81887c83287cc23881b71881b7a881b88881c018853368953fc896c288990078a02d98a02db8a2904901015a92aedadfc72adfcc8adfce9adfdd0adfddfadfde1adfe94adfee0adff48adff89adffabadffdaadffddadfff0ae0020ae0034ae0037ae0088ae0094ae00a2ae00b8ae00e1ae00f1ae0112ae0155ae015aae015bae01a1ae01c3ae01ffae0255ae0360ae036dae0382ae03c0ae03e4ae03f3ae041bae041dae0427ae0454ae0464ae04b0ae058fae05a6ae05b6ae05d3ae05e7ae0656ae0660ae06e8ae07aaae07b1ae07b6ae07ebae07faae0898ae08a9ae08b1ae08b4ae0949ae098bae09a7ae09f8ae0a1bae0a7cae0a8aae0afbae0b60ae0b82ae0b91ae0bb3ae0c1bae0c56ae0c6eae0c99ae0caeae0cb4ae0cc3ae0d42ae0d47ae0d5cae0d84ae0d92ae0db7ae0df4ae0e18ae0e49ae0e99ae0eb2ae0ebfae1127ae118aae11c3ae11d8ae11ddae1253ae13a1ae13b6ae13caae13d1ae13e5ae13fdae1412ae1421ae1457ae1472ae16e6ae1721ae174dae1791ae179cae17a5ae1804ae1862ae18feae192dae1c09ae1c12ae1c20ae1c2cae1cfaae1de4ae1e6bae1e72ae1e7fae1e87ae1e9cae1ea0ae1ec4ae1eccae1f2c152baeae1f99ae1facae1fb4ae1fbfae1fc4ae1feaae1ff5ae2002ae201aae2034ae204dae20e6ae2160ae21bcae21ccae21daae2210ae24e5ae2688ae2689ae2699ae269bae26a8ae26b4ae26d2ae2776ae278cae2903ae2904ae2908ae290cae2916ae2936ae2bdcae2bedae2bf4ae2bfcae2ee5ae2ef5ae2f59ae2f64ae2f9aae2fa3ae4792ae47a3ae47deae481dae4841ae498aae4999ae4a14ae4adfae4ae3ae4b5cae4b62ae4b86ae4b8aae4b90ae4d34ae4e21ae4eabae4ed3ae4edfae4f2cae4f40ae4f90ae4fdaae50b4ae50cbae510dae511fae51a4ae51abae51b6ae51d4ae51e8ae5278ae52e3ae5301ae530dae531bae5368ae53b8ae54dfae54e1ae54e2ae55ddae5631ae5652ae56b9ae56caae56d6ae5791ae58b3152be3ae58eeae598cae59ceae5a2cae5a4aae5ad9ae5b19ae5b6cae5bc0ae5bccae5cc6ae5cefae5d34ae5d5fae5d79ae5d98ae5e4dae5e55ae5e56ae5e6aae5ed0ae5f12ae5f29ae5f48ae5f99ae600aae600dae6030ae61feae621eae6228ae6230ae6247ae6348ae637fae68b1ae6988ae6a15ae6a17ae6a47ae6a82ae6abdae6ac1ae6b84ae6b97ae6bb1ae6c65ae6c7eae6caaae6cc3ae6d42ae6d85ae6db3ae6dceae73e2ae73e6ae73fdae77d1ae77ffaf2136af4a7bafc664c2b82dc87f0ae4010de48c6fe4992ae8068fe8c2340100860101f50200b1046000058f0006439706a27006a28a06a2960ac76e0ac7e00ac8220ac8900ac9390ac93f0ac9450ba0330d087f0d089514247d142c9c142d84142f6514f9e714fc0d151cf1151d04152b7c15535a31fed732000c32004e32005b32007233fcb933fd3033fd3633fd5f33fd8233fd9433fdac33ffb634158a34261534308c3aaaa33aaac13aaae53aab5e3aab993aaba33aabbc3aabbd3aabc23aac143b76273b76303b76923b769a3b769e3b76a13b77203b77763b7f6843c7433ea7643f43a03f88cc3f9e543fa3423fa93f43c15f43c1ae43c22f43c25243c26743c26f43c3a243c4be43c4c143c4ca43c55043c55543c61943c69843c69d43c6be43c6c443c6e843c6f443c77f43c7d643c88343c90e43c94d43c96643c971447d0e447d5744f02444f10144f42344f64e44f6a744f80244f82345f4444678a046801d4680ff46815c477fd2477ff648d85448d8e148d90e497c12497ca8497cac497ccd497cd249842f49843549844f4a35eb4a360f4b7f694b7fcd4b7fe24b82ab4b82ce4b82e54ca31e501fa050800f50821350ffd8702c6e70625b70c0767102f971f01071fa0b71fa0d738a5f744a637500d27a42157a42447a42867a428e7a42a17a442b7a44607a49577a49657bb1727bd0467bd0667cf8387cf83a7cf99b7cf9e87cf9f57cfa197cfa3b7cfa4e80021080022c8002558002668002a18002af8002b480031a800326800e228a059687c41c87c82187cc0387cc1d8880048967d48a041e8a05a78a06e08a07098a08d08a0a35adfc62adfc68adfc79adfc7cadfcb2adfcb6adfcd0adfcd2adfcd9adfcf5adfd83adfdc9adfdd8adfeb8adfeb9adfedfadff91ae0024ae002cae003cae0059ae0060ae0066ae006fae009cae00bfae00c0ae00e6ae00e8ae01e2ae01e5ae01f5ae0254ae026fae0270ae02f6ae036cae03c5ae0433ae047aae048bae0499ae04a3ae04afae04b5ae04b7ae04e6ae0532ae05bdae0612ae062aae062fae065cae0671ae0693ae0696ae06dcae06e7ae0751ae075cae07a3ae07ecae07f0ae07f6ae07f9ae0802ae0811ae0891ae08b7ae08fcae094aae0957ae09fbae0a4aae0a7eae0a97ae0af2ae0b02ae0b18ae0b1bae0b1dae0b1fae0b2bae0b3bae0b7dae0b87ae0c68ae0c84ae0cc2ae0d69ae0d76ae0dceae0dd2ae0e43ae0e4cae0e69ae0e84ae0e8eae0e91ae0eacae1167ae117eae11aeae11b9ae11ccae11cdae1232ae1236ae123bae12c4ae141aae1471ae14aeae14c6ae1703ae1722ae172eae1741ae1799ae17b0ae1851ae192bae1babae1c24ae1d64ae1dd2ae1e51ae1e75ae1e7aae1ea2ae1eb6ae1ec5ae1ecaae1f59ae1f5cae1f66ae1f6eae1f7fae1f80ae1f98ae1fb6ae1fe4ae1fefae2032ae2035ae20b9ae216aae2173ae21baae21e1ae23deae24f4ae2605ae2652ae2664ae26cfae2905ae2910ae29ddae2be7ae2be8ae2be9ae2ed4ae2ee8ae2f5cae4794ae47f1ae4810ae4838ae484aae486bae486dae4873ae4884ae4a17ae4af4ae4afbae4b25ae4b29ae4b9aae4be6ae4befae4d2fae4e0fae4ea2ae4eb0ae4f15ae4f33ae4fbaae4fe4ae50b7ae50cdae50e2ae50eaae5180ae518bae519cae51aaae51d2ae51ebae5295ae5297ae52a2ae52fcae537bae5384ae5406ae5420ae54f0ae5658ae565dae5677ae5719ae5773ae5774ae57c5ae589fae58f5ae5969ae59bbae59bcae59ddae5a3aae5a52ae5a67ae5a90ae5ac2ae5b48ae5bc7ae5bdfae5be5ae5c5dae5c69ae5cbaae5cc5ae5ccaae5d16ae5d46ae5d59ae5d82ae5d8fae5dc4ae5defae5e53ae5ea5ae5eacae5efbae5f54ae603aae603fae6051ae605dae61f7ae61fcae6204ae6206ae620aae6275ae6278ae6351ae636cae63c8ae63e9ae63efae63faae648bae649cae64aaae65f4ae65fcae68adae69c4ae6a25ae6a27ae6a76ae6a83ae6a8cae6a94ae6ab7ae6badae6bf2ae6c08ae6c20ae6c26ae6c77ae6c7fae6c81ae6d60ae6da3ae6e7dae7480ae7486ae74a9ae74c4ae77d9ae7812ae9ff8aed15baef94eaf53b8afb238c2bedbe40137e40141e4015fe48ff5e4916be49928e80648e806b4e847f20100740200b4058f0106a20406a21006a24c06a26606a273084fe00ac0050ac7470ac7df0ac92f0ac9360ac9420acab30acada0b42800ba0360d0931142dde142ed81526d8152b611533c015406715406e1551df15522c32003732007833fd5433fd5933fdbf33fddc33fdfd33fe0233fe1133feab33ff1333ff1b33fffe3571c23aaaa53aaaad3aaab53aaad13aab683aac183b76073b76293b765c3b77773b7f933e95ca3ea3833eb6f83f46db3f87433f88033f9d553fa0163fad9d3fb27e400ee640642143c0f743c18443c22e43c23543c25543c25c43c28343c2b443c30643c30b43c30e43c39143c39d43c49f43c4cb43c4df43c55843c5f643c60943c69643c6c143c6c343c74643c7d043c7d543c8c243c8df43c8e743c8ed43c93143cf33447d29447d4444f0e744f6a5467899468159473c0d477fd148040348040f48047b48084c48086548d88f48da8548f3a5497c31497c82497c9849842a4984af4b7f6b4b7fac4b7fb74b821a4c5c1a4ca1ee4d2117501f9e501f9f505f70506f46506f6a5080de50ffdf702c6570c07e710310728130738a027435927502a975862075870376e3077801587a443e7a449b7a49587bca5d7be0597cf8447cf84a7cf8697cf8817cf9e37cfa037cfa057cfa0e8003a0800f31881b82881ba1896c0e896c3c8990098a041f8a06ea8a0a27a3652bae63c5adfc67adfc6badfc82adfcb8adfce3adfceaadfcebadfd0eadfd6dadfdbbadfe0eadfe88adff65adff92adff96adff97adffb4adffd4adffd6adffe8adfff4adfffaae0009ae002dae00a4ae00c2ae00eaae0103ae0110ae0114ae0141ae0144ae014aae0207ae021eae022aae0245ae0361ae0365ae0367ae0378ae0380ae0392ae03c3ae0452ae0459ae045bae04c3ae04ddae0507ae056bae056fae0578ae05c6ae05cfae05e8ae064eae07a1ae07b5ae07d6ae07fcae080eae087fae089aae08bfae0955ae095eae0976ae09d6ae0a03ae0a71ae0abbae0b0cae0b22ae0b6cae0bb7ae0c01ae0c07ae0c4eae0c6bae0cc6ae0cf3ae0d4dae0da8ae0e08ae0e6dae1111ae1122ae112dae112eae1131ae1136ae1148ae114fae1191ae11acae11d2ae11dfae1206ae1207ae120cae1211ae1252ae12adae1395ae13aaae13b3ae1411ae1424ae143dae1444ae1468ae146cae14b7ae14b9ae170dae173cae1794ae1827ae182cae1837ae184dae1854ae191cae1c0bae1c21ae1cfbae1d41ae1d53ae1d58ae1d90ae1e5dae1e95ae1ea3ae1eb1ae1ed3ae1ed4ae1f2aae1f58ae1fadae1ff2ae200fae2017ae201cae2029ae204bae211bae213eae21c5ae21c7ae21caae21cbae21fcae2207ae26cbae26ccae2778ae2bd3ae2bfdae2ef0ae2f54ae2f62ae2faaae479dae47a7ae47eeae4833ae483fae49edae4a10ae4a28ae4a8fae4ae8ae4b3dae4b6dae4b78ae4be2ae4bebae4becae4cacae4d9aae4e4fae4eadae4ec0ae4ed1ae4ed5ae4f21ae4f53ae4f55ae4fb3ae4fe9ae4ff3ae502cae50c8ae510eae5111ae5197ae51a5ae51caae51d7ae51e7ae523dae5265ae5280ae52d0ae52d9ae5314ae5318ae5319ae5386ae53d3ae5659ae566cae56a6ae56abae56c7ae573bae57a5ae57c4ae5876ae5892ae58a9ae58deae58e7ae58f0ae59b4ae5a06ae5a1dae5a6aae5a6dae5a8dae5a95ae5aa8ae5ab8ae5ac0ae5ad1ae5aeaae5b7eae5bcbae5bd3ae5bdbae5bf4ae5bfbae5c58ae5c61ae5c65ae5cdfae5d33ae5d58ae5dcbae5e0eae5e93ae5e96ae5ea3ae5eecae5f01ae5f05ae5f3bae5f49ae5fa0ae606fae60f2ae61beae6239ae6333ae63fcae65fdae6604ae695dae6a55ae6a7bae6aa4ae6c56ae6c69ae6c74ae6c78ae6c87ae6cb9ae6cd6ae6d57ae6db0ae6e7cae6ed6ae6edaae73e4ae73f0ae73f5ae746cae74acae74c9ae74d2ae77bfae77c1ae77cfae77fbae77fdae7e91c87f3ac87f3ce400a2e400f8e48f4243c7a6e49063e49949e80645e80676e8068ce8493fe88102e8cfc50101650101770101d10101d20200b30200e40200f102019704c1a80640f00680b706a21506a24a06a26e0ac3220ac3650ac88d0ac8920ac89b0ac92a0b41140b42910ba0030c204e0c20d00c404c0d06120d07da0d08720d0bf8142d8c142d9a14f11e1526fc152a261533cc15406f15407215508415537a1577661d33411d334a32003532007633fc8b33fd3533fd3933fd4433fd8333fd9133fe2533fec733fff834158134245734338f3543c53552903571c643c7a739d62c3aab6a3aabc93aac0f3aac193b761a3b779e3b77e83b7b653eb52243c7b03f70013f8b7d7cf85945f42a43c07243c0ee43c12a43c1bb43c24b43c27943c45c43c45f43c47b43c49343c4ce43c55943c55f43c5e743c5ea43c5fa43c61343c67a43c68743c6e143c75a43c79143c88443c92d43c94c43c98f43cf3243cf34447d1c447d437cf99244f14344f14444f84445f42545f43446fbea477ff2477ff3477ff747812a4781324804894804a8480884497c22497cb54a36114a81864b7f7e4b7f824b7f884b7f8d4b7fa44b7fe14b82284b82954c5c1b4ca28b4ca420505f65506e1b506e1e506f63507f9c50800c50ff9b70625e7101aa71038e7103d471bc6571be4371f00d71f882738b4b74359975872576e3117802447a42457a424c7a425a7a425d7a42887a492c7a493e7bb0e17cf8417cf9a17cfa077cfa0a7cfa137cfa7e7cfaa380023580026e80029780031b80150c87c40d87cc0a87cd1a881b6c881b70881bc5881bf5884018896c738a0329adff8eaaecf3aaf4e7adff9bae77c2adfc65adfc69adfc71adfc76adfca3adfcffadfd13adfd70adfda3adfe31adfe69adfe7dadfe98adfe99adfea7adfec1adff61adff68adffb2adffbbadffbcadffc0adffccadffdfae0003ae0011ae003fae004eae0064ae0091ae00c8ae012fae01ecae020eae0231ae02e1ae02e4ae0316ae031dae0379ae03e3ae044cae0480ae0568ae056dae05bbae05e0ae060fae0630ae064dae0698ae074eae07baae07e3ae07fdae08a6ae08abae08e0ae08e1ae0959ae0994ae09bbae09f3ae0a07ae0a14ae0a21ae0a2fae0a67ae0a72ae0ab7ae0b8fae0ba3ae0badae0be2ae0c17ae0c28ae0c41ae0cc1ae0d2fae0d6eae0d70ae0d8fae0deeae0df1ae0dfaae0e37ae0e45ae0e7fae0e8dae0eb9ae0ed5ae0efaae10e5ae1110ae1115ae111fae1126ae1178ae1180ae1195ae119cae11c7ae11d0ae11e7ae1237ae1251ae12b8ae130cae1388ae13c9ae141bae141eae142fae147aae14bdae14ceae1528ae152fae15d0ae16faae1720ae174aae1754ae178fae17a0ae17aaae18c8ae1995ae19a5ae1bfbae1c05ae1c0fae1c13ae1c36ae1d60ae1d79ae1db1ae1dd5ae1e69ae1f5dae1f68ae1fb0ae1fd0ae1fdeae201fae204aae2051ae2116ae2139ae21b9ae21e4ae265bae2691ae2692ae26a1ae26adae26c4ae2707ae27feae29d3ae2bf7ae2c3dae2edfae2f68ae2f9dae47a2ae47ebae4811ae483cae49c4ae49e7ae4af5ae4af7ae4b45ae4b4dae4b59ae4b92ae4b93ae4bb6ae4bdeae4c63ae4e08ae4e09ae4e1aae4eacae4eccae4edbae4ef3ae4f98ae4f9aae4fcdae4fe7ae4fedae4ffaae511cae51c8ae51ccae524fae5266ae526bae5276ae5285ae52a5ae5303ae536eae5398ae5399ae53a1ae54d8ae54d9ae54f2ae54faae5649ae5651ae565eae569eae56a5ae56eaae5798ae587bae5883ae58a4ae598aae599cae59b1ae59beae5a0bae5a50ae5a51ae5abaae5abdae5aceae5ad3ae5aeeae5bc6ae5bd7ae5cceae5cddae5cebae5d09ae5d32ae5d56ae5dd9ae5deeae5df1ae5e17ae5ec5ae5edfae5f4bae6009ae6032ae6040ae6049ae60afae60eeae6331ae6342ae6355ae63b3ae649dae64acae64beae68b6ae68e0ae6962ae6a22ae6a3aae6a9bae6bb6ae6bd7ae6c2dae6c6fae6c7dae6c85ae6ca8ae6cc5ae6cc6ae6d4bae6db6ae6ed8ae77c7ae77d6c2b017c2b021c87f27c87f41e2002ee47d79e8068902004106a20d06a21d06a25706a25b06a26d06a27106a28e0a405f0ac8240acafe0acb160b60360d806c142dd414b92f151d071526d6152b6a154ca315532915533b1b4f6032002b32002d32004732005a32007132007d33fd6933fe2033fe3833fe3933fe9933ff1233ff4933ffb83425cb34308a3571c738203b3aaab33aaac53aaaf33aab483aab5c3aab983aabb83b762c3b76703b778f3b7b5a3b9b1a3b9b603b9bcc3b9bf03e82ea3f40df3f4ddf3f77aa3f80323fab93400ed0400ee943c06f43c09a43c17e43c1fd43c28e43c34243c34443c38f43c39043c4d843c50043c56743c5e543c6d443c74043c75c43c76243c93043c94143c96143c99d447d0d447d0f447d1e44f04044f04344f0c844f12444f42c44f5a144f6a244f825457c1545f4334678a44678b1477f9047812d48040a48041b48049648105048d82048d96348da8048da81497c29497caa4984364984554984624a35ab4a36187cf9cc4b7f864b7fa34b82114b82194b82204b822d4b830450815f50ff3b50ffb65110d86832497062727101a971020471031271fa0a738a69738b4e7434c77436ac7586cc76030376e7317cf83f781a867a42657a42817a431b7a432d7a48f97bc0177cf8497cf86d7cf8997cf9a47cf9af7cf9fd7cfa407cfa8280021380028780029c8002ac8002dc8002e080031c80033a80078f80079e87c00387c40087c41287c41488002c88040c881c0b888001894088896c16896c4b8a00298a01348a06588a08578a0872ae56d3ae1169ae1173aa94a3ae11eaadfcbdadfccfadfcd1adfcdcadfd07adfddeadfde7adfdedadfe12adfe79adfea6adfecbadff3eadff49adffb7adffe3ae0028ae0051ae0071ae00d2ae00e5ae0171ae0175ae0195ae01c5ae01eeae0208ae024eae026eae035bae036eae03ceae03f5ae0405ae0471ae04c6ae04cfae0502ae055eae0562ae057fae0585ae05aeae062bae07ccae0890ae08a7ae08c0ae08d6ae08daae08eeae08f0ae0954ae0990ae09b6ae09b9ae09d3ae0a6eae0aa7ae0abcae0b0eae0b6aae0b7fae0b98ae0c12ae0c26ae0c38ae0cadae0d2bae0d65ae0d67ae0d7eae0d81ae0d89ae0ddeae0df7ae0df8ae0e00ae0e1eae0e20ae0e26ae0ea5ae0eaaae10b7ae10c0ae1157ae1202ae1273ae138bae13a7ae13b8ae13bfae13c6ae13f2ae141cae1420ae1448ae14a5ae14a8ae14caae1527ae1735ae1751ae17edae184bae184cae1903ae19cdae1c2eae1d75ae1dacae1dcbae1e5eae1e9fae1ed0ae1f25ae1f2bae1f2eae1f85ae1f8eae1fe3ae1febae2006ae2059ae2062ae2065ae20b8ae20c3ae213cae213dae2666ae2668ae268aae26b3ae26b5ae26beae2731ae2763ae27f2ae2c3cae2f60ae2f6aae2fa7ae484dae4998ae4a08ae4a11ae4a12ae4a1dae4a29ae4b37ae4b55ae4b8fae4bb5ae4bbbae4d4eae4d5cae4e19ae4f28ae4f82ae4fa9ae4fb4ae50c0ae50dcae50dfae50e4ae51a6ae51aeae51c6ae524cae5277ae53a3ae54d5ae5642ae5674ae5682ae56bbae56c1ae56cdae56d0ae56edae571eae571fae5741ae5779ae579bae579fae57caae5874ae5881ae588cae589eae58b7ae58e8ae5991ae5999ae59caae59d4ae59e1ae5a05ae5a49ae5a5eae5a5fae5ac3ae5b79ae5b7bae5bd6ae5be8ae5c8cae5cccae5cdeae5ceeae5d40ae5d6cae5d9aae5f02ae5f0bae5f55ae601aae602dae6031ae60d5ae60f5ae61adae61b5ae61c0ae61c3ae6238ae625dae62fcae63b5ae63c6ae647aae648eae64afae6609ae68b5ae6912ae6a21ae6a24ae6a87ae6ba3ae6ba7ae6bfeae6c62ae6c71ae6cb0ae6d51ae6d63ae6ed9ae7474ae74a8ae74b6ae74dcae77c8ae77ceaed610af848cafa82ac2b0a3c87f0cc87f12c87f31c87f42e40096e400b3e497d7e49933e49da4e84902e88101e909fb7c79a97c79aaa37718a39a9fa651e9ab333ca3a9e1a7f1d0ac71ba0acb7d0ac7720ac7730ac7a30ac3940ac7a533fff93aac063b77d24b7fd90ac3eb0ac75c0ac1aa0ac7a23aac383b753c3b75503b76da0200a80ac3920ac3d70ac7600aca033b76a43b76a53b76ca3b7b800ac3350ac7880ac79e0ac8630ac9043aac293b761048104e0ac3360ac3ea0ac75d0ac7a60ac9053aac093aac233aac303b75523b76be3b771f7103940ac75a0ac7670ac79a0ac7e40ac99c0acba73b75a93b76d20ac1640ac7790acaca0ac7bb0ac8ad0ac8c40acad63aac323b76130200cc0ac8670ac8f83b754e0ac7893542c53aac023aac0e3b76b33b76c83b76d53b77e43b7b413b9b1f0ac3380ac7a70ac7ba0ac9160201250ac74e0ac74f0ac7803542c33b75253b770d71010a3b75373b760d3b76ba0ac76f0ac7810aca790acb7c3b76a03b76c93b77e53b7b568940163aac043b75c64c80927103870ac75b0ac7740ac8270ac9443b76bb3b7b203b9b2102008e0ac3f00ac7780ac7b70ac8af0ac8bf0ac8f70ac91d0ac3780ac77a0ac8a13aac2a3b75380ac7630ac9010aca4c3b76c20ac0000ac3dc0ac74c0ac7a00ac8f90ac9130acb943b76b60ac9080ac99b0aca470aca530acb553b75233b75b60ac0390ac3430ac3d90ac7770ac9710ac9900acaa73413903b75310ac3850ac4050ac9310ac9d10acb533b76c00ac1980ac7550ac7750ac9193aac117101360ac0cb0ac7b10ac7b90ac8b00ac9150ac9490aca523b75403b76c63b76d40ac74d0ac8b80acad90acb364b839c7103930ac8530ac8f50ac92c0ac9720acafd3aac2d3b76c10900ae0ac1570ac7590ac8c53542c47103860200ac0ac9177101a80ac3a80ac9c83b7b230ac7573b76b53b7b400ac37d0ac94b0aca870acacf3aac053b75350ac35a0ac3ed0ac76b3aac033b76d90ac7700ac9243b75490ac3ec0ac9030acac73b76d80ac9140aca6e0acb543b76113b76d03b7b220200590ac7a10ac7c20ac9eb33ffc03aac0b3b75543b77d30ac3930ac3a90ac7850ac7bc0ac9923b76d60ac3d40ac94a0acb3433ffc13aac073aac313b75343b76c50ac8a00d088b3413913aac083b7556e498420ac37f0ac7bd0ac8bc0ac9183b76b73b76b83b76dd3b7b5900aec90ac7680ac91c0acb9533ffaf3b76123b76c73b9b1e7103910ac3960ac3d60aca490aca4b3b753f3b76143b9b1d0ac3370ac7b60ac8be0ac9070acadb3b76cc3b76d13aac0c3b76c33b7b213b9b220ac3340ac7b80ac8b90ac8c00ac9223b75ba3b76d73b7b583b9b290ac0280ac3dd0ac8b30ac99e0acb353b76ae0ac7490ac7540ac75f0ac7690ac9470ac9993aac0a0ac33d0ac76d0ac8650ac91b0aca4a0acb933b75333b76db0ac2040ac3950ac7710ac77f0ac8b10aca780aca883aac280ac7650ac7860ac37c0ac8c20aca300aca483b75263b753a0ac37b0ac7980ac8bd3aac2f3b75b83b7b5771039602012d0ac33c0ac7610ac76a0ac91a3b76bf3b9b200ac7960201360ac99a0ac9c63aac153b75323b76220ac8c3e880e80200ab0201300ac7760ac7a83b753d3b76c43b771e0ac9480ac3880ac7de0ac8b50ac96f0aca500ac7e70ac9230ac9fc3b75b70ac33a0ac35d0ac74a0ac7830ac7910ac8b60ac8b70ac8bb35455648c65e0ac1a30ac9700ac78a3545553b769f3b76bd0ac8c10aca4e0acb693b75e54cc468766021ae513f3b77cb3b77ca3b77c83b77cf3b77c53b77c73b77c6ae660771f4ef71f4f071f4f271f4f471f4f671f4f771f4f8c2c363c2b3ffae626fae6262ae6263ae626eae62a2ae62a3ae62a4ae62a6ae62a7ae62a8ae62a9ae62b2ae62b3ae62b73b77d83b77d73b76ad3b76acae62bb3b76ab3b77d53b76aaae78023b76a93b76a83b76a7ae62c5c2b107ae62d245f45bae5130ae4d61ae04a0c2b111c2b11bc2b125c2b12f3b76a6c2b139c2b143c2b14dc2b157ae6d83c2b161c2b16bae5919c2b17fc2b1897cfaa17cfaa07cfa9fae64cca7e84eae56f4084f00881bc97103a3155bcf51005e51002b03e01503e004c2b1754b840808cbab35951648da8f1577113b77d07a4447af4f34683037af04676831ed70c0b470c04d70c05770c0b770c0ba70c0200180190180218990087bc0063b7ba77103a73591ca3b75ed3b7530ae2930ae62c6ae3988505c063b77e7ae74263b77103b771c3b771b3b771a3b77193b77183b77173b77163b77153b77143b77133b77123b7711ae7803ae4f1aae59833b77e6ae5980ae24daae24e03b75ec0201a7ae2744ae24c63b77cd3b77cc3b77c33b77df3b77de3b77d93b77dd3b77dc3b77db3b77daae4f19ae5984ae1e103b75653b75433b75443b77c9ae62c7ae5f8d3b7ba53b77ee3b77f13b77f03b77ef3b77ed3aabfb3b77eb3b77fd3aabfc3aabfa3aabfd3aabf83aabff3aabf93aaa983b77fa3b775d3b7ba63b7baa3b7ba43b7ba33b7ba23b7ba13b7ba03b7b9f3b7b9e3b7b9d3b7b9c3b7b9b3b7b9a3b7b983b7b973b7b963b7b953b7b943b7b933b7b923b7b913b7b903b780f3b7b8e3b7b8d3b7b8c3b7b8b3b7b523b7b513b7b503b7b4b3b7b4a3b7b49ae6ca43b77f83b77f63b77f53b77f33b77f23b77ec3b75273aabfe3b77f73b773c3b773b3b77393b77373b77293b76583b772c3b772d3b772e3b772f3b77303b77323b77333b77353b773e3b7731ae586cc2b1a7c2b2e7c2b2ddc2b2d3c2b3693b9bb43b9bb3c87f1ac87f16c87f1745104cae5736c2c1f1c87f19c87f18c2b0fdae5982c2af81c2af8bc2af27c2afbdc2afb3c2af95c2c223c2b517c2b413c2b41dc2b43bc2b427c2b431c2b445c2b44fc2b459c2b463c2b46d3b77223b77233b77243b77253b77263b7728c2b373c2af4f4984bac2af59c2af63c2af6dc2b477c2b481c2b48bc2b49fc2b4a9c2b4b3c2b4bdc2b4c7c2b4dbc2b4e5c2b4efc2b50dc2b4f9c2b495c2b215c2b229c2b23dc2b251c2b355c2b3b9c2b37dc2b535ae1e16c2b549c2b553c2b1edc2b3c3c2b585c2b5714b83a3c2b5adc2b5b7c2b5c1c2b21fc2bb25c2bb2fc2bb39c2b20bc2b247c2b233c2c1fbc2b567c2b55dc2b1cfc2b1c5c2b1bbae2926ae2929c2b52bae4a04c2b35fc2bb43c2bb4dc2bb57c2bb61c2bb6bc2bb75c2bb89c2bb93c2bb9dc2bba7c2bbb1ae62cbaea045497c9fae62ccae1b4dae62d5c2c237c2b3f5c2b3ebc2afc7c2b3d7ae2927ae292ac2c313ae4f56ae62e0ae21dcae3c3cae2f47c2bffdae61d4c2c093ae61d6ae5002aed615ae5a41ae21f7ae214dae5b18ae62d4ae538cae1716ae2759ae4fcbae73fcae73faae62b5ae62b4ae626dae5d21ae61e2ae62ceae62d6ae62dbc2bce7ae62e5ae62e7ae62f4ae6316ae631aae631bae631eae5253adff0bae6bd0ae501eae63d2ae2788ae205dae4d55ae2755ae61bdae60d7ae4b9cae4f9bae68a6ae689bae0e0aae0019ae5893ae1a5dae62cfae62ddae4ef4ae1ea7ae6190ae142eae4ba1ae00c5ae292c48104cae09623b7475affd5eaedb55ae4bb2ae62f5738bebae7816ae1826ae0c3dae61c4ae62e8ae4f87ae5ba8ae6307ae275cae1b8cc2c309ae62d0ae62d8ae108dae592dae2be1ae292d45f469ae6174ae086bae4e7271f0e2ae5e25ae6d77ae6a41ae2740ae18dbae6d87ae525aae58b1ae5d2caed1d0ae5a76ae1ab5ae5385ae7450ae62d143c30fae62daae62de7cfa1f7cfa2cae4d5eae52fdae4d4bae4f9dae62caae2056ae4ddfae62d9ae2751ae5888ae5635ae612bae613eae0bb1ae62b0ae62b8ae276eaf213caef553ae2799ae62c8ae62ebae1dd7ae1844a60ba5ae57c9ae4f5eae4f70ae4d57ae631fae4d31ae6ca1ae1dcfae4fd7af8504ae276dae183fae62cdaeeb9aae52a1ae57ddae6ce9ae4d9948d962ae62a5a71706ae010fae62b6ae58aaae6261ae60e2ae273bae62f3ae5e85ae52feae277bae2fd2ae2fd1ae6317ae740aae2541ae626cae6264ae615fae524bae62b1ae6140ae4f1fae62d7aed9b8ae2748ae4f8eae412bae62beae62bcae62bdae62bfae62c0ae62c3ae62c2ae64c4ae62c4ae62d343c9bbae62dcae62e6ae62eaae6308ae6318ae6319ae09caae631cae275dae4d29adfff6af9ba83571d8ae62baaeb3b5ae61e7ae0cd1ae4d40ae0fc4ae62df702c0aae586eafabadae2787ae5bb7ae68a4ae2bd4ae63c9af6c56ae68a0ae4f52ae3fdfae27a0ae73feae00caae63c4ae62acae73f2ae00eb71f26dae097aae1dc6ae4f60ae73f7ae5637a74e67ae1e3471ff72ae5638a9add7ae2925ae1b5bae0e65ae0ceeae74ebae74e5ae7451ae744eae744bae7411ae7408ae7409ae7407ae7406ae7405ae7404ae7403ae7401ae7400ae73ffae73fbae73f9ae73f8ae73f6ae73f4ae73e1ae73e0ae73d9ae6db7e80685ae5632ae6c42ae6d8bae6d8cae6d8dae6d8fae6d9dae6d55ae6d52ae6d4fae6d49ae6d48ae6d47ae6d46ae6d45ae6d44ae6cf8ae6cf7ae6cf6ae6cf5ae6cf4ae6c4bae6c49ae6c40ae6c3fae6c3eae6c3dae6c3cae6c3aae6c39ae6c09ae6c07ae6d892e1719ae6c05ae6c00ae6bfdae6bfaae6bfbae6bf8ae6bf6ae6bf4ae6bf0ae6bf1ae6bedae6bd8ae6bb3ae6bb4ae6abbafc663aed61aae189daef953ae5633e49218ae6a4eae6a4cae6a48ae6a49ae6a4aae6a4bae6a50ae6a51ae6a53ae6a52ae6a54ae690bae68a5ae68acae68abae68aaae68a9ae68a8ae68a7ae68a3ae68a2ae68a1ae689fae689eae689dae689cae689aae5884ae5dd8ae62f7ae2928ae58beae5286ae60e4ae4b97ae277cae6050c2c31dae4d3d7cfa1dae4f387585d98a08408a08418a083fae779fae16f8ae5d22ae4f84ae63cbae1ab6ae1def3593c93b76cf8a02daae5a09ae4797ae479fae47afae1ddcae0161ae5977ae5d29ae64c0ae4d41ae68f2ae6156ae4d42ae4d43ae4d44ae4d45ae4d46ae4d47ae4d4a758625ae4f5bae6260ae629eae6294ae1436ae61c8ae6159507fa8ae4985ae591d43c45943c40daedb51ae4d2eaea0f97cfa27ae2602aeeba1ae4d350d09a8ae5c0baebbcbaeeb9c3b75284b84183b762badffbeae7413e4008a71f104353607afcdadae6208ae6be1ae26f43b7608ae4b403aac10ae5634aec22f48da4580030dae4f3bae604ce49930ae5c56ae56363b7529ae4b4cadff7bae6322ae5630ae11f706a20bae4b26ae4b28ae4b2aae4b2bae4b2eae4b2fae4b32ae4b36ae4b39ae4b38ae4b3a4b836cae4b3c710399af84873593ceae292eae56bd43c96eae60deae5aaac2b31943c9bd7cfa1e43c9adae1aa4ae18e8ae4f453b752c4b841180030ea8b6beae747c094020ae53f0ae138caef9508a10114b8417ae4f79ae1dceae2bdaae2bdeae292f3b752aae5e88ae293180030fae604eae4f2e3b752bae09583aaabbae116643c9aa3b752dae2932ae5d19ae6279ae5d2d3b770fae4bb47cfaa23b752eae4f49ae4f4aae4f47ae4f48800310ae68f10d09990c217f4683b4ae7414adfd74adfd73adfd7f702c09ae4f46ae56f5ae520f3b752fadfd79ae4fd843c9940d540880031143c96f43c970adfd7dadfd7badfd76adfd713b755dae54bfae6289ae6280ae2933ae74523b7681ae60ddae60e3ae60e7ae60e5ae60e6ae60e1ae60e0ae60dfae60daae60dbae60d8ae60d9ae60dc3b9b04ae60ceae60cfae60d0ae60ccae60caae5e8dae60c7ae5e8bae5e89ae5e86ae5e7fae5e80ae5e81ae5e82ae5dd7ae5dd5ae5dd3ae5dd1ae5aa9ae5aa7ae5aa5ae5aa2ae5aa3ae5a9cae5a9bae5a9aae5a99ae5727ae53f5ae53f8ae4e70ae4e71ae5929ae29343b7697ae13e8ae0048ae6b6dae53f1aee372ae5978ae4fffae5000ae5001ae4ff9ae4fcaae4fc8ae4fa2ae4f8dae4f89ae4f86ae4f7cae4f7dae4f7eae4f7aae4b5eadff7375862baf38c4359403ae5979af3bbc8a042243c9b83aab3fae4b87ae7415ae74163b775cae597a7103177103533b77feae597b8016f8ae74423b77ff4b8415884802ae61e3ae61e1ae61e4ae62908016f9ae2777a99660a7fdeca8055aa99a17a34723a0f56ba0f922a0fcd9a10090a10447a2b320a2ec0cae6b10a9e9f7a67024abcac9abf0fdaa7e20a66b3ba8b2fca033a9a0979da09aacae034dad3226a4b786a20bae4678afaabad3a0a530a0b34fa1fa1ea2a399a33d5ca3c6663b9bf23533c3a57edfa5e6c8a628caa62cc7a630e7a68fb2a6d883a6da81a6dff4a71981a71b04a71b1da74181a7855ba966b1a966b2aa690eaa7e89aa7eacaa7ecfaa7ef2aaaa07aaaa2aaaacecaaad32aaad55aab0e9aaad78aab198aabab0aabba54b8392a7a451a52994a69a4bae6500a39981a412baab17ddac544aac5801acc1c5acfbb7ada4d5adc44faddd7a87cd2fae5cf6ae59184b833aa87889adc21eadd70aadedbeae53884b83a74b839b4b8414ab05f200fe014b83344b833b4b83554b83474b83974b83384b834a4b83464b83494b83544b833d3591997585d8ae2757ae743bae0d27ae0d2a4b83394b833c3f4994ae2752ae2753ae2754ae275871f664ae188ba3685da4207fa89bb0a8b80aa8bbc1adce1aac96dfa9844fae77dbae5a24a1a131a1a4e8a335eea339a5a34113a34adaa3c2afa68426a63df9a641b0a64567a3ca1da6692fa66ce6a693d2a801a370c07d70c08eae5a26ae4f1dae5bb4ae0d5bae52b0aa8bbea93240a9f290a77ae7ae0106ae6272ae6273ae6274ae189bae4ddc43c9b943c9c7ae21ea8a042a8a055bae7417ae52b4ae5a25ae52b1ae52b2ae52b7ae52bdae52beae6d6dae5a27ae1939ae1b498a04214b800f7cf9997cfa25ae0c1aae0c19896c11896c18896c1b896c197cfa2b80026caf3c5bae572eae2fd08a0428ae2786ae2780ae2781ae2782ae2785ae2789ae278aae278dae278eae278fae2790ae2792ae279bae279aae279cae279dae27a1ae27a3ae0cd2ae0cd6af54c4ae69d17cfa28ae21ddae21deae21dfae0b4c35919aaf9e24ae43ddaf384c3b75e43b75caae238c3591d34b82ff4b82e63dd2ce02003402003202003602003dae6bbaae61cbae61cdae69daae6192ae618fae618dae618bae618aae6189ae6188ae6187ae6186ae6185ae6184ae61c7ae61d2ae61d5ae61d7ae61d8ae61d9ae61ddae630eae36f6ae4b68af665aae6ced0d05d70d05ae0d05af0d05d88a1012af1644ae69dcae5279ae5270ae5271ae527aae527dae5281ae529eae52a3ae52a6ae52afae52c2ae52c3ae52d3ae52d8ae52dcae52deae52e2ae52edae52eeae52f9ae52faae52fbaae4efae196b4b83aeae52ac0a80214b835f4b83ac4b83adae52394b83354b83364b83374b83aa7585da7586217586267586274b83d64b83d54b83934b83944b83954b83964b83984b83994b839a7586b7ae5241ae5246ae5249ae524aae524d7cf9937cf9943b7f6fae472f3df5c17103607cfa2a7cfa267cfa297cfa2d7cfa2e7cfa247cfa237cfa227cfa217cfa207cfa187cfa1a7cfa1caf843908cbbb0b14403543c2ae5e4b71035a710359ae5bbaae4f6dae60c6ae60c5ae60c4ae60c3ae60c2ae60c1ae60c0ae60bfae60beae60bdae60bcae60b9ae60baae60bbae60b8ae60b7ae606b71035bae5e3b710358ae606cae606dae6061ae6062ae6063ae6064ae6065ae6066ae6067ae6068ae6069aedab7ae2bd8ae2bd5ae2bd6ae2bd9ae2bdbae2bddae2bdfae2be2ae2be3ae2be4ae17cdae5bbbae4f71ae4f72ae4f73ae4f74ae4f75ae4f61ae4f62ae4f63ae4f66ae4f67ae4f6bae4f6eae4f6fae6b4d359413881c66ae7418af85ffae00ddae5e4eae5e4fae5e52ae5e58ae5e49ae5e48ae1887af77c9aed80bae5bb9ae5bb5ae5bb6ae5bb8ae5bbcae2fd5ae2fd9ae5fb1ae5fb2ae5fb3ae5fb4ae5fb5ae5fb6ae5c72ae5c75ae17c2ae1dddae1dd4ae1dd1ae1dd8ae1dd9ae1ddaae1ddfae1de1ae1de3ae19db7a4299ae6b6eae1b16ae6bb7ae1b6920103f3594047102ffae7441ae6cceae6ccfafb674e4955fae5858c2b2f1ae181fae11f9ae1168ae637775861fae6cd07585d6758718ae5a0e80021bc2bcb5c2bcd3c2bcdd48088a48088c48088e45f4664984bdae5fb7ae5fb8ae5fb9ae5fbaae5fbbae5fbcae5fbdae5fbeae5fbfae5fc0ae5fc1ae5fc2ae5fc3ae5fc4ae5fc5ae5fc6ae5fc7ae5fc8ae5fc9ae5fcaae5fcbae5fccae5fcdae5fceae5fcfae58fbae1762ae1763ae176eae1764ae1765ae1766ae1767ae1768ae5c29ae5c2bae5c34ae5c35ae5c3aae5c3cae5c47ae176f4b83a5ae502b8a095f4b83abae1b18ae77b8aab2faa3ad38a57b3502b26c3b7f67a6692ba3e624a8649ba8574180030c800312800313ae5006ae5005ae5007ae5008ae5009ae500aae500bae500cae500dae500eae500fae5010ae5011ae5012aae4f6aeb3023aabef3aabeec2bf3f3aabec3aabebae1b19ae5c07ae5c08ae5c09ae5c0aae5c03ae5c04ae5c06ae5c05ae5c0cae5c0dae5c0eae5c0f704282881c08ae16f0ae4f17ae4d3fae4d39ae4d3bae4d3c3e9808ae50bb71039a4984b8ae1b1a4984bbae6d67ae4e634984a171031e71031a43c9d88991803dd2cb3b757b710355710357af3a67af84344984b9ae64c1ae538aae5389ae538bae0a0043c9a5a6fd49ae6ab14b84864b8413ae132ba75993ae6ca3e4992ce49938ae5bbf3b9b5e3b9b5a3b9b5b43c988ae4b65ae09d0af551eaf7ffe48d9237103814984bc46781f3dd2cf447d6587cdffae23f0af2bc5ae6ab0ae2101ae741971f8eb3b7becaf3c02ae062eae08efae5a388a070aae56be71f814ae5aaeae5aafae26ecae5931ae77b9ae5754ae5753497c9e3aab1aae6ca5ae1845ae18434a35f94984beae592e3b7be8c2c269ae5f89780328ae1b1bae4e61ae91d2ae616eae2126ae2125ae4e3baf259c4984c0ae61968848048880d3ae63cd881bafae412cae5855b1e8967cfa94ae62ab4984c14984c3e4922dae2c074984ea88480770c05548d8804844bb4844acae00bb884806884808ae4e5bae4e55ae4e56881c05aeeba2af20c1881c04ae6cfeae4e57ae4e51ae4e52ae4e5fae16ebae175bae77dc884805ae740bae740dae740eae740f884809ae2773ae277faf08a2ae1c1dae50f73df5c0ae5c6c3aab6bae52f3ae5bacaee828ae6809ae0437ae0442ae0434ae0430ae6820adff6aae4b6a06a29dae6b590a4048353606ae1b51ae1b56ae1b52ae1b53ae1b54ae1b5548088548d88348088948088848088d48088faec784ae749dae6905ae6906ae6907ae6908ae00acae2391ae1b1cae63064b829bae1953af6c00ae4e48ae4e44ae11fb738bf4702c22497ce3ae7453aecb4aae6d88ae62690d0980ae4baeb1e87dae5936ae4f50ae4f51ae5202ae1c04ae53efae00beae5e78ae5e77ae6d68ae5e79ae1e00aeeb98ae7410ae4f3dae4f3eae22113b77fbaed92202d483aed12aae67f4aeeba3ae4cf2ae0cb8ae69f8afb23aae4e59ae741aaf2172ae0c8aae6783ae1dc03aaadcae6421ae5ba4ae741bae741cae5ba7ae5ba6ae5ba5ae5ba3ae5ba2ae5babae541bae77b5ae4e64b1e796ae49e3710363ae5e428014813aabed3b7521ae779933fcb43b75f03b75f43b75f7ae62e4adfec4ae58abae0bc6ae4903ae0a34a755dcae4b82ae4bfb7103a1ae498971031f3aaaddae0941ae4cebaecaa3ae520cae2bc6ae5e7aae26f2ae2765ae0c2d4b8296ae1902af1cb702d060adff03ae4f1bae0942ae743dae53ceae4e4c0d84c133fcbc68330568330606a2794b7fbf3b7bee35360b4b82cdae4e46ae626a33fcbfae54b880029b4aecbeae77cc502fadae8372ae6056ae1fdfae5fa4c87f377103657103a535360171035ee40088ae5bfeae4e5aae4e60af0df6e40089ae6d01359517ae4ecaae0e77ae741dae741e683d90c2c255c2c20fc2c219c2c22dc2c241c2c24bc2c25fc2c273c2c27dc2c287c2c291c2c29bc2c2a5c2c2af896c5a447d4fc2bf2b48d91148d91448d91548d91348d91648da86c2bfd5af3bc9ae4f22ae5b21aaf011ae2bc4ae592aae4a88ae64b2aecef7ae2f150d08770d000f0d0878ae6cebaed60dae4c0dae2bc8154e3d3aabceae58d2ae7454ae5b23ae58d1881bccae64a371f76f7080203b7520ae60530a40043474d3ae5b1aae5b1cae5b1dae5b1eae4a89ae5b20ae5b22ae5b24ae5b25ae5b29ae5b2aaf4bb4ae593aae1b4cae589ba9d660ae57b1ae4bfaae5e3fae4dd5ae532cae4daeae5343ae5b65ae5e44ae5fdfae4da2ae4de643c81b7cf929ae69be43c812ae5921e20100ae753d43c811ae5923ae5927ae592bae592cae592fae414fae412eae4980ae13e9ae0e2eadfdafae6d2fae19b8ae13eaae6da0ae070fae18eaaff733ae62aeae62adae4e3eae593cae1b50ae412aae21b3ae2170aeeba0ae1b15a10635ae1b4fae5a55ae7499ae6a6eae6813ae2bccae6d33ae69c1ae2f4d010137ae1b57ae6a6fae627eae5fe9ae628eae62673533c2ae18abe0b2d8aed55fae49dbae1b17ae627cae1b99010148ae2f1dae2f12ae2f14c2bdcdae5b58af8437af47dfae0867ae628cae1b1dae62f2ae195d4b8362ae1b1e4bd24bae69bfae4ef2ae1b9fae7420ae58b4ae6bb8ae1b1f43c957ae098dae1103ae6c210101d7aedb4c010179ae62f0ae6304ae409370c094e80677ae77b7ae77b6ae4f57600039ae11e8ae0d0fafe1a0ae27f5ae741fae59a0ae18e1ae1b0fae62afae8955ae1b0048d922e88888e88887ae4d62ae4e4a43c956ae5de3ae54c2af8482ae54c3b3e2c2ae54c4ae54c5ae59330d095dae115cae6268ae2143ae76a6ae4d513b7721ae632143c958ae9d8743c81443c816ae4f4d3aabf4a279c5ae4e04ae85fe3b7617af8420ae62faae62f9e2005bae10f1ae4f4eae279eae34bdae69c043c80643c81575837775862d76e302ae7421ae7423ae518daf3bc23536043591c98016e5507f8aae44c98016e64aec62457c04477f953b7660ae6d8aae7815ae7814ae77a571ff4248da46ae4513ae1b0248d90434165406a24b4a34e8ae4e4bae0dc1aed33487c40bae6b6c76602aae4de043c81707037ee40086702c6dae63cfae1b03aec6ac43c818aed56b0d09e8a79b150d0998ae1b0da9add6407d90ae4e4dae1b04ae1b0e4b822933fff2af875aad237935360a45f45d43c88a46815646815dae4bf843c80743c98a43c8193b75f23b75ee3b75f13b75f53b75f83b75fa3b75fb3b75fd46f80315771015771515771714fa3914fbfa14fc0414fc0515fc0c14fc0814fc0a14fc0b14fc0e14fc0f14fc10a7e839af3bdd8a04263b9b0643c81aae18d3af8b3a43c88b43c98bae5e408016ebae7804ae4f5d7586a87586a7af878bae7425af84214b8371348089738a88af848343c81c511122ae4f2bae604b43c98943c81d400edaae6283ae6302ae5c14400eb5ae6266881c10ae625eae6281ae62a1ae62ff400ea2afc662400edc43c6cf71035fae630071038214fc0c14fc110d003fae574eae743480152202016b447d39400ed3ae4e3fae6a43af9ba443c81f400eb4400ebc43c6d23f8c79783132ac6bd0ae591b8a0a2943c81343c82043c82106a29e06a23006a23106a23b738a86e483bbe200fd400eb7e40144881bcf7586b68a06598a065a497fa306a23275835cae1b01ae1b05ae1b0c8a065b3b75ef3b75f33b75f63b75f93b75fc3b75fe710409ae5c3615771215770e15770c15771315771415771615771814fa3b800e3b783162ae6a440700bbaeeb9e7103a28a067cae1b06ae1b0b447d4eae1b58ae57cfaeeba5ae00f74b836135918e447d3545f4688a067d06a2344b83a4ae5e6d06a23eae540471031902016615771914fc09ae77bdaf9904447d598a067e06a23506a23f881c09ae51713ea427ae5770ae5767ae1b07ae5769ae576fae2f35ae2f3143c95bae77bb06a236710366710318881bb7ae77bc06a23789900506a23cae576c71f5d07832037a442cae1b08ae6a45ae5e7daf0a4c15cd2806a23806a23dae45c6ae1b0aae1b5aae1b9bae1ba080150b683d00ae576d7586f5ae5e6e0ac31e06a2393b75b933fcb1ae69cfae4c1906a233ae1b0906a29fae1afa06a290ae5e6bae5e6cae1b80ae1b8aae576eae1b60ae1b6cae1b72aedaacae1b7aae1b7bb1e7ac06a23a7a442dae62edae576a800e38ae5e67b1e877e200f906a22906a22c080010ae2bc5ae5e6806a22f06a22be847fcae62fbae630a3b7f740428daae5e6606a22eae630906a22a706047ae6ceeae51f2ae2795ae4ef5ae043fae0abdad5caa06a22dae6a46aeebabae588dae521f02c540af53a4ae63d1ae62b98a0a47ac5ba3ae60f9881bd1881bb47103abafa162aee731ae62507a4298ae54c0ae05a28a0a483b7bd28006b23b7fbcae1afcae1b62ae1b6fae1b73ae1b7cae1b81ae6292ae62964682e6ae630dae630caef04739913bae627baec2f2ae20a0ae57640acb790acb770acb7887cdf4ae69ccae6176ae5992aeffc47cfa74ae509eae509daf2ec0af3378af1a94aef8b333fcb8ae1688ae597e096025ad5af4a9a196ae751f8016e7ae4f243aab8baba78cac0a69ae195eadff6dae00e2ae4c09ae2bc2ae77b2ae77b1ae77b0ae77adae77abae77acae77a83b7f8b3b7fde3aaafaae17ce43c878ae64c8ae77b4ae77afae77aaaebe960d5430702c6cae1ae2ae4ef80d0a220d6118adffb1ae77b3ae77aeae5255ae77a9ae19a9af8026ae23aea08cfcae5127a1b9fcae1ae4881bd4881bd2ae520dc2c377ae1ae5010057ae621fae7424ae7422af4fa800856b0d064f7103acae1ae6a053fdafe1be71ff7baf9e53ae6293ae6297aef94d4b8412adfe8cae1ae7ae5212ae6aacae743e505c07ae1ae180062a71031d71031baf4fa13acbb1881bd3505c08505c0971031633ffef33fff333ffe333ffe1ae58074a34ea010024896c4f4b85cbae1aff80058508001f0d064e0d064d0d06507a43f075026d48ad0748ad0633fff1ae5c7e717c9f33ffe071f4f1010230ae4d5d68324f04200a06a03806a05a4984c233fff033ffed0d087aae4caf33ffeaae4bfe0c404fae4e6c71f4f3ae20fa3593d98a0a2cae4b444810f633ffe88a0429ae6edb3b7ba871033d0ac7c833ffe78a0424ae9a98e8cfc8ae5938aeede98a041daf84de33ffe2ae4bf98a070671f4f93b75a70be2b07a43ef78c5ac71f005af8db748da96ae81bfaf4f60a24e31aee8a1c2b323ae5803ae1b8450042f407d8faf8447ae1b65ae1b764b8c2e4bd2b20641e16008018a0a3f0983a1ae61bbae540eaf4f364ba674407dfc044037a0e824a134a60900f5ae61bcae61baa3bbabae60faae540f881bd5468101710320a6d58b14fc1714fc16b1e7a1038f42a967be0386133b7b8a71f007ae6105ae1b66ae1b78ae1b85c2b201c2c36dab87c170c056038ffe038f47038f45ae6244ae60fd038f46038fffae1b67ae1b79ae1b87a9aa6bae5c44ae5c21ae5c22ae5c2dae5c2eae5c2fae5c30ae5c31ae5c32ae5c33ae5c37ae5c38ae5c39ae5c3bae5c3dae5c40ae5c48038615038f4aae5c23ae5c3eae5c41ae5c49ae5c4caae8e0ae5e647103923b7766ae77c33b77653aab8fae53da8002d8ae239fafab96adff3badff3aae5c24ae5c3fae5c42ae5c4aae5c5071f0003b9bd5af9b75ae5c53adff3dae5c25ae5c43ae5c4bae5c51896c1cae1b6871f4edae1b88ae407371f00102c5443f992bae501dae5c26e4955571f5ea71f4c971f4c171f4ee71f4cf71f65c71f4cb71f4ca71f4c771f4c271f4c371f4c571f4c671f4c471f4c871f4cc71f4cd71f4ce71f4d071f4d471f4d571f4d671f4d771f64d71f4d871f4d971f4db71f4dc71f64a71fc0571f4dd71f4de71f4df71f4e171f4e271f4e071f4e371f4e571f4e671f4e771f4e871f4e971f4ea71f4eb71f4ec71f4d171f4da71f4e4728520ae5c27ae5c45ae5c4dae5c52717cbcae60feaf653171f4d271f4f571f4fa71f40071f40171f40271f403ae5c46ae5c4eae0e82728521ae1b6aae1b89ae60fbae24eaaf6ec3ae6100ae6102ae6104ae274fae4aecae62463aab84ae5c4fafbfee71f4d3ae6bdaae2383ae6057ae62efae63d7ae1afdae1b5dae1b5eae1b5fae1b63ae1b6bae1b7eae1b82ae1b83af3f84ae5f88af05d9ae53e1ae6233ae5616ae1c03ae5b5fae6243ae5228ae5bf671f267ae6d0668324daee660aeea98ae6103adfee1ae4d28ae5e5aae5e5baf841caf4faaae470e0d0bf2ae62aaae62fdae62fe7806fdae6311ae44f7ae6313717ca6ae6412af0930ae3341afa189ae6277ae1aecaf5356ae6dcbaaec2445f462ae6edcae23ab780039ae5e617a43f1ae5706ae23acae56f6ae23aaae6606ae517bae6055ae6bcbae23adae4e69ae4e62ae4e653aab97ae4e67ae742eae568c14fc1214fc1314fc1414fc15151d41151d42ae4e66155bd0155bea155beb14f113147991148000152a8d152c29152c2d152baf152ba97324e57324e17324e37324e47324e67500d5467807ae4130ae2769ae1b31ae6291ae6295ae6301af266f46782d46782eae651e80069baf231333ffe6ae6c2370002f46784746781246781346781d46783744f0e148d882ae26eeae6000ae7428ae49ad43c81eae4f8104c1e8ae5f1bae6993084003ae5f1c084004ae5f1dae4ae4be09c0728135ae779bae03fc06804eae779c76603571f006ae6242ae6241ae1b32467808e40123ae0cb0ae570746784446781446782046782f46783844f12104c1e9ae1b3300fe05ae624f467809467843ae694aae6994ae5f1e46781546782146783087c8570703fa71f251881b798a042380152004c20e3591853593d4447d2d3b7f76447d5571f88846783a04c1eaafb23e3b76a346780a46784246781646782246783171f253447d66ae5f24ae5f1fae6790ae1b3446780c46784146781746782346783246783baece5dae604aae77f64b83b5ae77f5ae591cafc65fafb89cae9f44800314800e0c800dbe800315aff006ae591eaec91f46780d467818ae1b71ae74554678244678333d9340800e0e447d0b728523ae17c5ae1b3546783cae591f0d07d571035ce4993d477fb6881bd771f2d3ae5606ae591aae5916e9019171f003ae5915ae53edae53f9ae1b3609008846781946782646783446783d71f008ae1b3746780f884803e200dde0164546781a467827467835ae6b7dae63d0ae742aaf662d89630246783e71f00271f009ae59170a404bae4ecb0180003aaae4884009896c45ae63d4ae641446781146781b46782946783646783f45f461ae5a16ae5a10738bf5ae6410ae6415ae6416ae53b7ae53b0ae53c0ae53c2ae53a9ae1afe46780bae63d546782aae6411467840ae0f9245f463ae6417ae53b1ae53baae53c4ae53a8ae53aaae1b39ae1b64ae1b75ae1b7fae1b8bae1b9dae1ba10e1838ae53bbae53c5ae617fae53abae17f80360b2ae1b2dae1b203aaaee3aaadf3aaadb467839ae5bf7ae63d6ae6418ae5bf8ae53b20800ccae5bfcae1b25afb721ae1b2bae1b2eae1b10ae1b12af3df3ae1b21ae1b26ae1b2cae1b1171f14eae5bfaae5bfdae6419ae53b3ae53bcaa0cf5ae53c6ae53acaea9f6ae53b4881bb6adff95ae0de4ae53beae440bae53c7ae53adae0a5fae4409ae53a4ae1b59ae4f0aae640bae0f0cae1b27aeeba4ae77cbac817fac810fac738fa4d9aaae6320af6bcbae1b23aefc4dae53b5ae53bfae53c8aedaa643c5c5ae5f20ae53ae485920ae640c710002ae1b28ae1b2fae1b13ae5de0ae5de1ae2480ae24817cfb0bae2483ae53c9ae53afae640dae55a2a444d6af2a7aae5f21084017ae55a1c2b59971f09eae5e6371f14571f142ae4e4971f09dae629bae2484ae2485e483baae5930afffbeae2f45ae2f2cae2080ae159eae15e3030006ae6a57ae53cbae5f22ae53a7ae640e8a0855396084ae56aaa6d0a8ae53ccae640fae7e67ae1b29ae1b1406a3cf894082ae1b24ae6b1b02b26b02b26a89602ba71feca7478eaf8606a6f84aaf8489ae53cdae0956ae742cac9cfeaee737e404094984bf3b7faf33fc973b7771ae6285ae5f23ae53b6fedd2f71f013af50e9af564ba0ec9806a2c3717c73ae53d5ae7817ae5de7ae5de83b9b5cae62f1ae5d23ae62e3ae6303601142ae53dbae62eea9aa23afe21dae1b2aa9adf8896c42aae8d0afb234afb673ae11f4ae4e42ae5bb3b1e7d9ae53e9ae40bcafdfe2afdcd3ae77efae5befae073cae20e8ae20e9ae20f0ae20f1ae20fcae20ffae2100ae6470ae2110ae2117ae211aae211d71f112ae2120515001ae2122ae20e7ae5bebae573071f0d6ae5be9aeffd8a6b8b3ae699f3b776cae20eaae50a8adfec7018047ae20f2ae20feae2102ae2112ae2118ae2121ae20e5ae5bec71fa6caf4f43ae562faf3bde80151fae4bc2a010efae20ebaef51fae62e9ae68d3ae5c15ae2123adff27ae6be5aedaa5ae743fae7440ae23a2ae20ec601861ae20f4ae2104ae20e1ae5133e40113ae5beeae77edae20edae20f3ae2105ae20e0ae59c2c2c007ae1b7d8964c90ac7ea3aac00ae09a9506e18af3c4eae2106ae20e202016a02016735940e3b7fb47a43ed880417ae20efae2109ae211c06a0a2ae5fa6ae210aae6288ae742bae1b30ae1b8dae1b9eaf9ba9ae210bae211fae62c9ae58bcae77eeae629aae210caeda3eae5028ae077168325070604a60181a89629f09cd55ae77f0601819ae210d4713bfae65f30201653b7772ae1b3aae1b3bae1b40af76c5af3d52ae210e6018033b7564ae21033b77c400a272aeeba7ae5937ae2bc0ae0541ae6b54ae1b3cae1b41ae1b93ae1b96ae6b360d83733b77fc5002cbae4a79ae4c07c2b58f0ac8c6ae6d03af3fe93b7fd7ae1b3dae1b42ae1b94ae1b97ae36ea43caee7cfafeafea5f3b7559ae780771f171ae5986ae6bbdadff6bc2b503ae5b3171f106ae6bbcae1b3eae1b43ae1b98896572ae5191c2b409ae4f0bae693eae6248ae5beaae622aae623fae5dbbae4b99ae624a7cfafc7cfaff7cfb00ae5dbaae5db90201a671f2b7ae6171ae0a37ae5df0ae6451ae09afae248c480850ae0ba1ae0e11c032e3af85fcae4a80ae440cae19fcae840faf7645ae6177afd871480886ae51758016edae1b3fae68eaae63d3c2c09d480887ae1b45e847fdae4bafc032e5ae610aae18f970604cc032ecae6d02ae6cffae6d003b75633b7761ae49f6ae4e45ae6d07ae6d0407c001ae6d0571f004afb79f800218800e1b800208c032edae5913ae5920ae5922ae5925ae5926ae1b46ae5928ae6324ae6325c2bf71ae5813ae4c02ae4c03ae4bfcae4bfdae4c00afca17a7e796ae2f40ae5172c032eeae5924ae2f41ae6326ae7498ae6970ae18cbc032f2ae632706a00fae1b47a62a05a97166ab334349844cc032f9ae5201ae4c10ae2353afa161ae5165881bcd738a484810f870c12eaf2567ae2655ae0c4d314c65af3bceaf843ca33d33ae5174ae5176ae517fae5182ae5188ae518aae518eae5190ae5192ae5194ae6bdfaeecf1ae51664b8681ae5173ae5177ae51843b756aae5189ae518cae518fae52204b8268aed554aa0fffae5ddd7cfa97afa55371f020ae5167af0811ae5178ae518571f2e6ae5224ae522baeeaf0aeeba6ae5932ae4e6aae4e6dae4e6e45f467ae6b82ae5e7bae4e3a7cfafdae4e4eae0dd6ae0eeeaf1dccaf1c26ae6ce2ae5f7fae5f7dae5ddbae4e6ba5c52dae512aae5f7ea88e57aaaa7006a01c7cfa93af20a2af1cdbadb8c0ae5168ae5179a1f5f1ae5186ae5ddcae5169ae517aae1b48af83a7afa16ce489baadfdabae5ddec0532cae516aae516bae4e5cae5ddfadff35ae4e53ba4e53e4014087cc05ae1a26ae516c507c4f896c53ae5939ae5934ae9d45aea33caf9a2eae6cecae516dae1c014b9c300e07183b7760ae6386ae516eafa8223aab96a88e473b7763ae4e5d46f593aaecfbc041fec02044c041f5c041fcc041fbae5f84aae7fd485694c041fac0204bc0204cc0204ac02043ae2bcac041f7c02047c032f3ae64deafe89aaf973d7101e203001271010bc041f6c020457101e9b1e50c45f464affa2dae0b17ae5974ae5976aec97e71f0fac041f3af54bbc2b57b894081aeb7f27101953b7769ae8c2402016804200306430c71f01fc2b5a3348088881c684b80173b776eae6a597101f0ae24f3ae24f5ae5e62ae24fc3b7b89afc735afead17a433ac2bf8fae24f6084002af6beeaf3bcfaf78fda3ed57e400d9aa228d738a89881bea7a43eeafb22cae6d6cafa7e9ae24f9ae24fbaf4f46738be8ae6286afdbcc710362ae24faae24fdae6287afa80fafef5fafdaa4896c75ae563aafd81eafe62fae4e50ae4e5eae5886ae4e54ae0778ae50deafb66c71f0dfafdfacae2115ae09b8afa5d371151d71f10caf85c6e49532ae8915ad7069ae4d4dae7b14ae851eae5f87ae5f864aec63ae2111ae2113ae6bbba9ad72aaec6e738bf2ab5ab9ae616dae610cae78054856f3ae7806717cde3b776bae7fffae2399ae2398ae5ba0ae637cae6bc871019bae5bf9aee81fae88a8af3bc6ae6153ae6b1faf987bae22c6ae6183ae617dae617bae617aae614bae6147ae6173ae6175ae6168ae6163ae6155ae613cae613bae613aae5765ae5204e20026afca98afde2cafbab843c575e20027e20025ae61800d81ecb9c2cfae2bc7062f2eaf9e29ae6114ae6113adff85aed556ae2658ae33f73b7620aed558ae53eee2001f6830ed08a081ae6167ae5de9ae1484ae274ec02829ae50feaf9fdfaebb0b1e3c8ac032fac02835c0282cae53d9ae53d7ae5f81ae5f8aae5f8bae56f3c06ccbae6a58ae6a56ae53eba1e9530100d1af9b683b7770c032e4018098ae585dae26f6ae2f2eae92bd3b7b88728522ae4b70ae5211ae4c0ac0282bae53d6ae5f82ae099dae5f8cc02825c02833ae58d73b7b87ae58d4ae58daae58dbc02834c032f8ae5f83ae5170ae517c01008fae517dae58d5ae58dcae58d6ae58ddae1904afc669af6063ae6d6eae58d8af382188436f87c851c2be9587c8523f9640ae16cb87c84fae5e7c87c853ae57dcae3549ae276671ffadae74a0a3293e87c85071ffaf87c854459901ae2f21ae2f20ae2f22ae2f16ae2f1bae2f1cae2f0fae2f0aae2f0cae43e787c855ae53e6ae6dc7ae53e2ae53e887c819ae0c51c2b53fae6dc80ae044ae6d09ae2f173b7f66aed90c87c856ae53e3ae2f1e87c81cae2f0daf85b8af3beaafb21bae2f1fae2f0e87c85887c81dae2f0387c85987c81f3b7764ae53e4ae53ea3b776287c82087c82787c82a87c82c87c82d87c82f87c8304243fd87c8338960b4459902ae53e56830a53b776daf3294ae53e7ae04efc2af77aeac73ae7de7497cd5ae53f4ae53f6ae5e60ae53f74b83684b83644b8365c2af9fae0cceae23afae23a3c02828ae6d6ac032f4c0282aaed0950acc67ae4b7c497fa487cdf3afca20ae23a4c02820afb9daae5f90a68b92ae01d371032cae586aae6d08ae23a5af486aae23a6a60693aee4e4ae23a73b7767ae18fcae59144b8b32ae6d69ae6cefae4bc0ae4bc1ae4bc3af532a7cfb0c89636eae23a8ae2f34ae4bc4ae23a9ae4bc57586faab009dae50f0aaf443ae4bc6ae6b23ae77f1ae0d3fa0299ea1bbe571f01aaada4cae086c71f01bae00efaafaaaae0862ae0864ae0870adff20ae4e3cae0400ae10e4ae291fae60f8ae7433af080eae0cfcae4bc7ae0866ae086dae0871adff19ae4e3dae6dccae6b8dae53deae53dfae6314ae631543c6a9aed8afae49d7ae086eae0872adff1eaf6379706049ae4bc8ae0873ae4bd8af0fd0ae4bc9aff00eae53f33b7768ae0876ae53dd4b90300900b5ae53dc717ca706a0013b775faf3bbeae2739ae5f4aae5669c032efc2c2ebae1bfeaf476eae0c30c041efc0282dc06ce0c082f060182589404d3b77d43b776aa73d39ae40717cfaa8ae4c0cae4c0baefb00ae55c9ae55ccae5526ae55ffae5704ae572dae572aae572fae6a00ae6d70ae6d6f042088ae4e8bae2387ae69c6ae69d0ae5705ae572bae4e8c71154aaf63f57101e3717c7989407faab59571f2a1ae2bd0c2be27ae169faf9b733b9b19ae66083b77ac0201c80201c9ae74560200ba0c405c0c405aaf7f780200bd0200c002018e0200c30200c20201a90201aa02014dae77f4af0a53afca1f02018f020192020194020161020181ae7431ae23640b4354af0db60b4150af4f2faef1c78962647060243b775e600be9ae69d2a808e7a7f654a983a3a97feca97c35a81055a30beca23027ae53f2aff785af6164ae08c30201900201930200be0201ad02014f020195ae1985a80c9e3b775ba7fa0b717cb53b76b0896636710334a7f29d0201910201ae02015eaaf024ae52374b8410020074ae4c01ae5935ae593bae61540c405b600bc0894049a022e54b8c4b600be8600bc43b7522a2fad10201520201534678ac3534480d082c07037dae5b9dc053257586f6881762881764881766800f21800f2b800f25800f24800f2a800f13800f10800e3d80028280028e80028880028980028a800280800283800dc4800dc280148280148380148a800e648007a08007a2507f9e4b838280034a80035080035a80075880078880075a80075b400ebd7586f47586f7881761881763881765881767800f28800f2d800f1480028480028102014ca7a2be3b7566800285801484800e658007a180034980034b80035180035b80075980078980028d80028b80075c80078a80075da43bea800f29800f15800286af486f801485800e6680034c80035280035c801486800e6780034d80035380035d80078b80075e60183e71022bc2b5cb0c21d0e4a32e801487800e6880034e80035480078c80075fa56a0060185a8a042580148880035580078d3b75694246a970604b7101905003eb3b75dd80034f80035671152a48448c6008078003578940148003588960b1ae182d8a0705aff79eae182faff005ae1aafaf6b67ae198eae7808af65cc800359aaf34cae182eae1ab0ae1ab2ae1ab371f0f7afb8a2ae6cf0ae4a01711516896571aed0d7ae4c084b83b04b83a6ae630bae630fe200fe71f027ae6cb2a7c0f23b762106a0213b7567ae1ab1ae1ab43b776f3b75a802000f3b75453b756b3b75420420015160018940c07101e73b7568502fab502fac4b8331507c537103334246b14599034599048940c8a21be11d3340"; +export const MILITARY_HEX_SET = new Set( + Array.from({ length: _HEX_PACKED.length / 6 }, (_, i) => _HEX_PACKED.slice(i * 6, i * 6 + 6)), +); + +export function isMilitaryHex(hexId: string | null | undefined): boolean { + if (!hexId) return false; + return MILITARY_HEX_SET.has(String(hexId).replace(/^~/, '').toLowerCase()); +} + +export const MILITARY_PREFIXES = [ + 'RCH', 'REACH', 'MOOSE', 'EVAC', 'DUSTOFF', 'PEDRO', + 'DUKE', 'HAVOC', 'KNIFE', 'WARHAWK', 'VIPER', 'RAGE', 'FURY', + 'SHELL', 'TEXACO', 'ARCO', 'ESSO', 'PETRO', + 'SENTRY', 'AWACS', 'MAGIC', 'DISCO', 'DARKSTAR', + 'COBRA', 'PYTHON', 'RAPTOR', 'EAGLE', 'HAWK', 'TALON', + 'BOXER', 'OMNI', 'TOPCAT', 'SKULL', 'REAPER', 'HUNTER', + 'ARMY', 'NAVY', 'USAF', 'USMC', 'USCG', + 'AE', 'CNV', 'PAT', 'SAM', 'EXEC', + 'OPS', 'CTF', 'TF', + 'NATO', 'GAF', 'RRF', 'RAF', 'FAF', 'IAF', 'RNLAF', 'BAF', 'DAF', 'HAF', 'PAF', + 'SWORD', 'LANCE', 'ARROW', 'SPARTAN', + 'RSAF', 'EMIRI', 'UAEAF', 'KAF', 'QAF', 'BAHAF', 'OMAAF', + 'IRIAF', 'IRG', 'IRGC', + 'TAF', 'TUAF', + 'RSD', 'RF', 'RFF', 'VKS', + 'CHN', 'PLAAF', 'PLA', +]; + +export const AIRLINE_CODES = new Set([ + 'SVA', 'QTR', 'THY', 'UAE', 'ETD', 'GFA', 'MEA', 'RJA', 'KAC', 'ELY', + 'IAW', 'IRA', 'MSR', 'SYR', 'PGT', 'AXB', 'FDB', 'KNE', 'FAD', 'ADY', 'OMA', + 'ABQ', 'ABY', 'NIA', 'FJA', 'SWR', 'HZA', 'OMS', 'EGF', 'NOS', 'SXD', + 'BAW', 'AFR', 'DLH', 'KLM', 'AUA', 'SAS', 'FIN', 'LOT', 'AZA', 'TAP', 'IBE', + 'VLG', 'RYR', 'EZY', 'WZZ', 'NOZ', 'BEL', 'AEE', 'ROT', + 'AIC', 'CPA', 'SIA', 'MAS', 'THA', 'VNM', 'JAL', 'ANA', 'KAL', 'AAR', 'EVA', + 'CAL', 'CCA', 'CES', 'CSN', 'HDA', 'CHH', 'CXA', 'GIA', 'PAL', 'SLK', + 'AAL', 'DAL', 'UAL', 'SWA', 'JBU', 'FFT', 'ASA', 'NKS', 'WJA', 'ACA', + 'FDX', 'UPS', 'GTI', 'ABW', 'CLX', 'MPH', + 'AIR', 'SKY', 'JET', +]); + +export function isMilitaryCallsign(callsign: string | null | undefined): boolean { + if (!callsign) return false; + const cs = callsign.toUpperCase().trim(); + for (const prefix of MILITARY_PREFIXES) { + if (cs.startsWith(prefix)) return true; + } + if (/^[A-Z]{4,}\d{1,3}$/.test(cs)) return true; + if (/^[A-Z]{3}\d{1,2}$/.test(cs)) { + const prefix = cs.slice(0, 3); + if (!AIRLINE_CODES.has(prefix)) return true; + } + return false; +} + +export function detectAircraftType(callsign: string | null | undefined): string { + if (!callsign) return 'unknown'; + const cs = callsign.toUpperCase().trim(); + if (/^(SHELL|TEXACO|ARCO|ESSO|PETRO|KC|STRAT)/.test(cs)) return 'tanker'; + if (/^(SENTRY|AWACS|MAGIC|DISCO|DARKSTAR|E3|E8|E6)/.test(cs)) return 'awacs'; + if (/^(RCH|REACH|MOOSE|EVAC|DUSTOFF|C17|C5|C130|C40)/.test(cs)) return 'transport'; + if (/^(HOMER|OLIVE|JAKE|PSEUDO|GORDO|RC|U2|SR)/.test(cs)) return 'reconnaissance'; + if (/^(RQ|MQ|REAPER|PREDATOR|GLOBAL)/.test(cs)) return 'drone'; + if (/^(DEATH|BONE|DOOM|B52|B1|B2)/.test(cs)) return 'bomber'; + return 'unknown'; +} + +// ======================================================================== +// Theater definitions +// ======================================================================== + +export interface TheaterDef { + id: string; + name: string; + bounds: { north: number; south: number; east: number; west: number }; + thresholds: { elevated: number; critical: number }; + strikeIndicators: { minTankers: number; minAwacs: number; minFighters: number }; +} + +export const POSTURE_THEATERS: TheaterDef[] = [ + { id: 'iran-theater', name: 'Iran Theater', bounds: { north: 42, south: 20, east: 65, west: 30 }, thresholds: { elevated: 8, critical: 20 }, strikeIndicators: { minTankers: 2, minAwacs: 1, minFighters: 5 } }, + { id: 'taiwan-theater', name: 'Taiwan Strait', bounds: { north: 30, south: 18, east: 130, west: 115 }, thresholds: { elevated: 6, critical: 15 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 4 } }, + { id: 'baltic-theater', name: 'Baltic Theater', bounds: { north: 65, south: 52, east: 32, west: 10 }, thresholds: { elevated: 5, critical: 12 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 } }, + { id: 'blacksea-theater', name: 'Black Sea', bounds: { north: 48, south: 40, east: 42, west: 26 }, thresholds: { elevated: 4, critical: 10 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 } }, + { id: 'korea-theater', name: 'Korean Peninsula', bounds: { north: 43, south: 33, east: 132, west: 124 }, thresholds: { elevated: 5, critical: 12 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 } }, + { id: 'south-china-sea', name: 'South China Sea', bounds: { north: 25, south: 5, east: 121, west: 105 }, thresholds: { elevated: 6, critical: 15 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 4 } }, + { id: 'east-med-theater', name: 'Eastern Mediterranean', bounds: { north: 37, south: 33, east: 37, west: 25 }, thresholds: { elevated: 4, critical: 10 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 } }, + { id: 'israel-gaza-theater', name: 'Israel/Gaza', bounds: { north: 33, south: 29, east: 36, west: 33 }, thresholds: { elevated: 3, critical: 8 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 } }, + { id: 'yemen-redsea-theater', name: 'Yemen/Red Sea', bounds: { north: 22, south: 11, east: 54, west: 32 }, thresholds: { elevated: 4, critical: 10 }, strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 } }, +]; + +// ======================================================================== +// Raw flight type (used by theater posture) +// ======================================================================== + +export interface RawFlight { + id: string; + callsign: string; + lat: number; + lon: number; + altitude: number; + heading: number; + speed: number; + aircraftType: string; +} + +export const UPSTREAM_TIMEOUT_MS = 20_000; + +// ======================================================================== +// Wingbits response mapper (shared by single + batch RPCs) +// ======================================================================== + +export function mapWingbitsDetails(icao24: string, data: Record): AircraftDetails { + return { + icao24, + registration: String(data.registration ?? ''), + manufacturerIcao: String(data.manufacturerIcao ?? ''), + manufacturerName: String(data.manufacturerName ?? ''), + model: String(data.model ?? ''), + typecode: String(data.typecode ?? ''), + serialNumber: String(data.serialNumber ?? ''), + icaoAircraftType: String(data.icaoAircraftType ?? ''), + operator: String(data.operator ?? ''), + operatorCallsign: String(data.operatorCallsign ?? ''), + operatorIcao: String(data.operatorIcao ?? ''), + owner: String(data.owner ?? ''), + built: String(data.built ?? ''), + engines: String(data.engines ?? ''), + categoryDescription: String(data.categoryDescription ?? ''), + }; +} + diff --git a/server/worldmonitor/military/v1/get-aircraft-details-batch.ts b/server/worldmonitor/military/v1/get-aircraft-details-batch.ts new file mode 100644 index 000000000..d180f1f10 --- /dev/null +++ b/server/worldmonitor/military/v1/get-aircraft-details-batch.ts @@ -0,0 +1,90 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + GetAircraftDetailsBatchRequest, + GetAircraftDetailsBatchResponse, + AircraftDetails, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +import { mapWingbitsDetails } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJsonBatch, cachedFetchJson } from '../../../_shared/redis'; + +interface CachedAircraftDetails { + details: AircraftDetails | null; + configured: boolean; +} + +export async function getAircraftDetailsBatch( + _ctx: ServerContext, + req: GetAircraftDetailsBatchRequest, +): Promise { + const apiKey = process.env.WINGBITS_API_KEY; + if (!apiKey) return { results: {}, fetched: 0, requested: 0, configured: false }; + + const normalized = req.icao24s + .map((id) => id.trim().toLowerCase()) + .filter((id) => id.length > 0); + const uniqueSorted = Array.from(new Set(normalized)).sort(); + const limitedList = uniqueSorted.slice(0, 10); + + // Redis shared cache — batch GET all keys in a single pipeline round-trip + const SINGLE_KEY = 'military:aircraft:v1'; + const SINGLE_TTL = 24 * 60 * 60; + const results: Record = {}; + const toFetch: string[] = []; + + const cacheKeys = limitedList.map((icao24) => `${SINGLE_KEY}:${icao24}`); + const cachedMap = await getCachedJsonBatch(cacheKeys); + + for (let i = 0; i < limitedList.length; i++) { + const icao24 = limitedList[i]!; + const cached = cachedMap.get(cacheKeys[i]!); + if (cached && typeof cached === 'object' && 'details' in cached) { + const details = (cached as { details?: AircraftDetails | null }).details; + if (details) { + results[icao24] = details; + } + // details === null means cached negative lookup; skip refetch. + } else { + toFetch.push(icao24); + } + } + + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + for (let i = 0; i < toFetch.length; i++) { + const icao24 = toFetch[i]!; + const cacheResult = await cachedFetchJson( + `${SINGLE_KEY}:${icao24}`, + SINGLE_TTL, + async () => { + try { + const resp = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, { + headers: { 'x-api-key': apiKey, Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10_000), + }); + if (resp.status === 404) { + return { details: null, configured: true }; + } + if (resp.ok) { + const data = (await resp.json()) as Record; + const details = mapWingbitsDetails(icao24, data); + return { details, configured: true }; + } + } catch { /* skip failed lookups */ } + return null; + }, + ); + if (cacheResult?.details) results[icao24] = cacheResult.details; + if (i < toFetch.length - 1) await delay(100); + } + + return { + results, + fetched: Object.keys(results).length, + requested: limitedList.length, + configured: true, + }; +} diff --git a/server/worldmonitor/military/v1/get-aircraft-details.ts b/server/worldmonitor/military/v1/get-aircraft-details.ts new file mode 100644 index 000000000..bcd57604d --- /dev/null +++ b/server/worldmonitor/military/v1/get-aircraft-details.ts @@ -0,0 +1,63 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + AircraftDetails, + GetAircraftDetailsRequest, + GetAircraftDetailsResponse, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +import { mapWingbitsDetails } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'military:aircraft:v1'; +const REDIS_CACHE_TTL = 24 * 60 * 60; // 24 hours — aircraft metadata is mostly static + +interface CachedAircraftDetails { + details: AircraftDetails | null; + configured: boolean; +} + +export async function getAircraftDetails( + _ctx: ServerContext, + req: GetAircraftDetailsRequest, +): Promise { + const apiKey = process.env.WINGBITS_API_KEY; + if (!apiKey) return { details: undefined, configured: false }; + + const icao24 = req.icao24.toLowerCase(); + const cacheKey = `${REDIS_CACHE_KEY}:${icao24}`; + + try { + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const resp = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, { + headers: { 'x-api-key': apiKey, Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10_000), + }); + + // Cache not-found responses to avoid repeated misses for the same aircraft. + if (resp.status === 404) { + return { details: null, configured: true }; + } + if (!resp.ok) return null; + + const data = (await resp.json()) as Record; + return { + details: mapWingbitsDetails(icao24, data), + configured: true, + }; + }); + + if (!result || !result.details) { + return { details: undefined, configured: true }; + } + + return { + details: result.details, + configured: true, + }; + } catch { + return { details: undefined, configured: true }; + } +} diff --git a/server/worldmonitor/military/v1/get-theater-posture.ts b/server/worldmonitor/military/v1/get-theater-posture.ts new file mode 100644 index 000000000..5f229dd2f --- /dev/null +++ b/server/worldmonitor/military/v1/get-theater-posture.ts @@ -0,0 +1,258 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + GetTheaterPostureRequest, + GetTheaterPostureResponse, + TheaterPosture, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +import { getCachedJson, setCachedJson, cachedFetchJson } from '../../../_shared/redis'; +import { + isMilitaryCallsign, + isMilitaryHex, + detectAircraftType, + POSTURE_THEATERS, + UPSTREAM_TIMEOUT_MS, + type RawFlight, +} from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; + +const CACHE_KEY = 'theater-posture:sebuf:v1'; +const STALE_CACHE_KEY = 'theater-posture:sebuf:stale:v1'; +const BACKUP_CACHE_KEY = 'theater-posture:sebuf:backup:v1'; +const CACHE_TTL = 900; // 15 minutes +const STALE_TTL = 86400; +const BACKUP_TTL = 604800; + +// ======================================================================== +// Flight fetching (OpenSky + Wingbits fallback) +// ======================================================================== + +function getRelayRequestHeaders(): Record { + const headers: Record = { + Accept: 'application/json', + 'User-Agent': CHROME_UA, + }; + const relaySecret = process.env.RELAY_SHARED_SECRET; + if (relaySecret) { + const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase(); + headers[relayHeader] = relaySecret; + headers.Authorization = `Bearer ${relaySecret}`; + } + return headers; +} + +// Two bounding boxes covering all 9 POSTURE_THEATERS instead of fetching every +// aircraft globally. Returns ~hundreds of relevant states instead of ~10,000+. +const THEATER_QUERY_REGIONS = [ + { name: 'WESTERN', lamin: 10, lamax: 66, lomin: 9, lomax: 66 }, // Baltic→Yemen, Baltic→Iran + { name: 'PACIFIC', lamin: 4, lamax: 44, lomin: 104, lomax: 133 }, // SCS→Korea +]; + +function parseOpenSkyStates( + data: { states?: Array<[string, string, ...unknown[]]> }, +): RawFlight[] { + if (!data.states) return []; + const flights: RawFlight[] = []; + for (const state of data.states) { + const [icao24, callsign, , , , lon, lat, altitude, onGround, velocity, heading] = state as [ + string, string, unknown, unknown, unknown, number | null, number | null, number | null, boolean, number | null, number | null, + ]; + if (lat == null || lon == null || onGround) continue; + if (!isMilitaryCallsign(callsign) && !isMilitaryHex(icao24)) continue; + flights.push({ + id: icao24, + callsign: callsign?.trim() || '', + lat, lon, + altitude: altitude ?? 0, + heading: heading ?? 0, + speed: (velocity as number) ?? 0, + aircraftType: detectAircraftType(callsign), + }); + } + return flights; +} + +async function fetchMilitaryFlightsFromOpenSky(): Promise { + const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar'); + const baseUrl = isSidecar + ? 'https://opensky-network.org/api/states/all' + : process.env.WS_RELAY_URL ? process.env.WS_RELAY_URL + '/opensky' : null; + + if (!baseUrl) return []; + + const seenIds = new Set(); + const allFlights: RawFlight[] = []; + + for (const region of THEATER_QUERY_REGIONS) { + const params = `lamin=${region.lamin}&lamax=${region.lamax}&lomin=${region.lomin}&lomax=${region.lomax}`; + const resp = await fetch(`${baseUrl}?${params}`, { + headers: getRelayRequestHeaders(), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`OpenSky API error: ${resp.status} for ${region.name}`); + + const data = (await resp.json()) as { states?: Array<[string, string, ...unknown[]]> }; + for (const flight of parseOpenSkyStates(data)) { + if (!seenIds.has(flight.id)) { + seenIds.add(flight.id); + allFlights.push(flight); + } + } + } + + return allFlights; +} + +async function fetchMilitaryFlightsFromWingbits(): Promise { + const apiKey = process.env.WINGBITS_API_KEY; + if (!apiKey) return null; + + const areas = POSTURE_THEATERS.map((t) => ({ + alias: t.id, + by: 'box', + la: (t.bounds.north + t.bounds.south) / 2, + lo: (t.bounds.east + t.bounds.west) / 2, + w: Math.abs(t.bounds.east - t.bounds.west) * 60, + h: Math.abs(t.bounds.north - t.bounds.south) * 60, + unit: 'nm', + })); + + try { + const resp = await fetch('https://customer-api.wingbits.com/v1/flights', { + method: 'POST', + headers: { 'x-api-key': apiKey, Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': CHROME_UA }, + body: JSON.stringify(areas), + signal: AbortSignal.timeout(15_000), + }); + if (!resp.ok) return null; + + const data = (await resp.json()) as Array<{ flights?: Array> }>; + const flights: RawFlight[] = []; + const seenIds = new Set(); + + for (const areaResult of data) { + const flightList = Array.isArray(areaResult.flights || areaResult) ? (areaResult.flights || areaResult) as Array> : []; + for (const f of flightList) { + const icao24 = (f.h || f.icao24 || f.id) as string; + if (!icao24 || seenIds.has(icao24)) continue; + seenIds.add(icao24); + const callsign = ((f.f || f.callsign || f.flight || '') as string).trim(); + if (!isMilitaryCallsign(callsign) && !isMilitaryHex(icao24)) continue; + flights.push({ + id: icao24, + callsign, + lat: (f.la || f.latitude || f.lat) as number, + lon: (f.lo || f.longitude || f.lon || f.lng) as number, + altitude: (f.ab || f.altitude || f.alt || 0) as number, + heading: (f.th || f.heading || f.track || 0) as number, + speed: (f.gs || f.groundSpeed || f.speed || f.velocity || 0) as number, + aircraftType: detectAircraftType(callsign), + }); + } + } + return flights; + } catch { + return null; + } +} + +// ======================================================================== +// Theater posture calculation +// ======================================================================== + +function calculatePostures(flights: RawFlight[]): TheaterPosture[] { + return POSTURE_THEATERS.map((theater) => { + const theaterFlights = flights.filter( + (f) => f.lat >= theater.bounds.south && f.lat <= theater.bounds.north && + f.lon >= theater.bounds.west && f.lon <= theater.bounds.east, + ); + + const total = theaterFlights.length; + const byType = { + tankers: theaterFlights.filter((f) => f.aircraftType === 'tanker').length, + awacs: theaterFlights.filter((f) => f.aircraftType === 'awacs').length, + fighters: theaterFlights.filter((f) => f.aircraftType === 'fighter').length, + }; + + const postureLevel = total >= theater.thresholds.critical + ? 'critical' + : total >= theater.thresholds.elevated + ? 'elevated' + : 'normal'; + + const strikeCapable = + byType.tankers >= theater.strikeIndicators.minTankers && + byType.awacs >= theater.strikeIndicators.minAwacs && + byType.fighters >= theater.strikeIndicators.minFighters; + + const ops: string[] = []; + if (strikeCapable) ops.push('strike_capable'); + if (byType.tankers > 0) ops.push('aerial_refueling'); + if (byType.awacs > 0) ops.push('airborne_early_warning'); + + return { + theater: theater.id, + postureLevel, + activeFlights: total, + trackedVessels: 0, + activeOperations: ops, + assessedAt: Date.now(), + }; + }); +} + +// ======================================================================== +// RPC handler +// ======================================================================== + +async function fetchTheaterPostureFresh(): Promise { + let flights: RawFlight[] = []; + + try { + flights = await fetchMilitaryFlightsFromOpenSky(); + } catch { + flights = []; + } + + // Wingbits is a fallback only when OpenSky is unavailable/empty. + if (flights.length === 0) { + const wingbitsFlights = await fetchMilitaryFlightsFromWingbits(); + if (wingbitsFlights && wingbitsFlights.length > 0) { + flights = wingbitsFlights; + } else { + throw new Error('Both OpenSky and Wingbits unavailable'); + } + } + + const theaters = calculatePostures(flights); + const result: GetTheaterPostureResponse = { theaters }; + + await Promise.all([ + setCachedJson(STALE_CACHE_KEY, result, STALE_TTL), + setCachedJson(BACKUP_CACHE_KEY, result, BACKUP_TTL), + ]).catch(() => {}); + + return result; +} + +export async function getTheaterPosture( + _ctx: ServerContext, + _req: GetTheaterPostureRequest, +): Promise { + try { + const result = await cachedFetchJson( + CACHE_KEY, + CACHE_TTL, + fetchTheaterPostureFresh, + ); + if (result) return result; + } catch { /* upstream failed — fall through to stale/backup */ } + + const stale = (await getCachedJson(STALE_CACHE_KEY)) as GetTheaterPostureResponse | null; + if (stale) return stale; + const backup = (await getCachedJson(BACKUP_CACHE_KEY)) as GetTheaterPostureResponse | null; + if (backup) return backup; + return { theaters: [] }; +} diff --git a/server/worldmonitor/military/v1/get-usni-fleet-report.ts b/server/worldmonitor/military/v1/get-usni-fleet-report.ts new file mode 100644 index 000000000..ef795606c --- /dev/null +++ b/server/worldmonitor/military/v1/get-usni-fleet-report.ts @@ -0,0 +1,443 @@ +import type { + ServerContext, + GetUSNIFleetReportRequest, + GetUSNIFleetReportResponse, + USNIVessel, + USNIStrikeGroup, + BattleForceSummary, + USNIFleetReport, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +import { getCachedJson, setCachedJson, cachedFetchJsonWithMeta } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; + +const USNI_CACHE_KEY = 'usni-fleet:sebuf:v1'; +const USNI_STALE_CACHE_KEY = 'usni-fleet:sebuf:stale:v1'; +const USNI_CACHE_TTL = 21600; // 6 hours +const USNI_STALE_TTL = 604800; // 7 days + +// ======================================================================== +// USNI parsing helpers +// ======================================================================== + +const HULL_TYPE_MAP: Record = { + CVN: 'carrier', CV: 'carrier', + DDG: 'destroyer', CG: 'destroyer', + LHD: 'amphibious', LHA: 'amphibious', LPD: 'amphibious', LSD: 'amphibious', LCC: 'amphibious', + SSN: 'submarine', SSBN: 'submarine', SSGN: 'submarine', + FFG: 'frigate', LCS: 'frigate', + MCM: 'patrol', PC: 'patrol', + AS: 'auxiliary', ESB: 'auxiliary', ESD: 'auxiliary', + 'T-AO': 'auxiliary', 'T-AKE': 'auxiliary', 'T-AOE': 'auxiliary', + 'T-ARS': 'auxiliary', 'T-ESB': 'auxiliary', 'T-EPF': 'auxiliary', + 'T-AGOS': 'research', 'T-AGS': 'research', 'T-AGM': 'research', AGOS: 'research', +}; + +function hullToVesselType(hull: string): string { + if (!hull) return 'unknown'; + for (const [prefix, type] of Object.entries(HULL_TYPE_MAP)) { + if (hull.startsWith(prefix)) return type; + } + return 'unknown'; +} + +function detectDeploymentStatus(text: string): string { + if (!text) return 'unknown'; + const lower = text.toLowerCase(); + if (lower.includes('deployed') || lower.includes('deployment')) return 'deployed'; + if (lower.includes('underway') || lower.includes('transiting') || lower.includes('transit')) return 'underway'; + if (lower.includes('homeport') || lower.includes('in port') || lower.includes('pierside') || lower.includes('returned')) return 'in-port'; + return 'unknown'; +} + +function extractHomePort(text: string): string | undefined { + const match = text.match(/homeported (?:at|in) ([^.,]+)/i) || text.match(/home[ -]?ported (?:at|in) ([^.,]+)/i); + return match ? match[1]!.trim() : undefined; +} + +function stripHtml(html: string): string { + return html + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/’/g, "'") + .replace(/“/g, '"') + .replace(/”/g, '"') + .replace(/–/g, '\u2013') + .replace(/\s+/g, ' ') + .trim(); +} + +const REGION_COORDS: Record = { + 'Philippine Sea': { lat: 18.0, lon: 130.0 }, + 'South China Sea': { lat: 14.0, lon: 115.0 }, + 'East China Sea': { lat: 28.0, lon: 125.0 }, + 'Sea of Japan': { lat: 40.0, lon: 135.0 }, + 'Arabian Sea': { lat: 18.0, lon: 63.0 }, + 'Red Sea': { lat: 20.0, lon: 38.0 }, + 'Mediterranean Sea': { lat: 35.0, lon: 18.0 }, + 'Eastern Mediterranean': { lat: 34.5, lon: 33.0 }, + 'Western Mediterranean': { lat: 37.0, lon: 3.0 }, + 'Persian Gulf': { lat: 26.5, lon: 52.0 }, + 'Gulf of Oman': { lat: 24.5, lon: 58.5 }, + 'Gulf of Aden': { lat: 12.0, lon: 47.0 }, + 'Caribbean Sea': { lat: 15.0, lon: -73.0 }, + 'North Atlantic': { lat: 45.0, lon: -30.0 }, + 'Atlantic Ocean': { lat: 30.0, lon: -40.0 }, + 'Western Atlantic': { lat: 30.0, lon: -60.0 }, + 'Pacific Ocean': { lat: 20.0, lon: -150.0 }, + 'Eastern Pacific': { lat: 18.0, lon: -125.0 }, + 'Western Pacific': { lat: 20.0, lon: 140.0 }, + 'Indian Ocean': { lat: -5.0, lon: 75.0 }, + Antarctic: { lat: -70.0, lon: 20.0 }, + 'Baltic Sea': { lat: 58.0, lon: 20.0 }, + 'Black Sea': { lat: 43.5, lon: 34.0 }, + 'Bay of Bengal': { lat: 14.0, lon: 87.0 }, + Yokosuka: { lat: 35.29, lon: 139.67 }, + Japan: { lat: 35.29, lon: 139.67 }, + Sasebo: { lat: 33.16, lon: 129.72 }, + Guam: { lat: 13.45, lon: 144.79 }, + 'Pearl Harbor': { lat: 21.35, lon: -157.95 }, + 'San Diego': { lat: 32.68, lon: -117.15 }, + Norfolk: { lat: 36.95, lon: -76.30 }, + Mayport: { lat: 30.39, lon: -81.40 }, + Bahrain: { lat: 26.23, lon: 50.55 }, + Rota: { lat: 36.63, lon: -6.35 }, + 'Diego Garcia': { lat: -7.32, lon: 72.42 }, + Djibouti: { lat: 11.55, lon: 43.15 }, + Singapore: { lat: 1.35, lon: 103.82 }, + 'Souda Bay': { lat: 35.49, lon: 24.08 }, + Naples: { lat: 40.84, lon: 14.25 }, +}; + +function getRegionCoords(regionText: string): { lat: number; lon: number } | null { + const normalized = regionText + .replace(/^(In the|In|The)\s+/i, '') + .replace(/\s+/g, ' ') + .trim(); + if (REGION_COORDS[normalized]) return REGION_COORDS[normalized]; + const lower = normalized.toLowerCase(); + for (const [key, coords] of Object.entries(REGION_COORDS)) { + if (key.toLowerCase() === lower || lower.includes(key.toLowerCase()) || key.toLowerCase().includes(lower)) { + return coords; + } + } + return null; +} + +function parseLeadingInteger(text: string): number | undefined { + const match = text.match(/\d{1,3}(?:,\d{3})*/); + if (!match) return undefined; + return parseInt(match[0].replace(/,/g, ''), 10); +} + +function extractBattleForceSummary(tableHtml: string): BattleForceSummary | undefined { + const rows = Array.from(tableHtml.matchAll(/]*>([\s\S]*?)<\/tr>/gi)); + if (rows.length < 2) return undefined; + + const headerCells = Array.from(rows[0]![1]!.matchAll(/]*>([\s\S]*?)<\/t[dh]>/gi)) + .map((m) => stripHtml(m[1]!).toLowerCase()); + const valueCells = Array.from(rows[1]![1]!.matchAll(/]*>([\s\S]*?)<\/t[dh]>/gi)) + .map((m) => parseLeadingInteger(stripHtml(m[1]!))); + + const summary: BattleForceSummary = { totalShips: 0, deployed: 0, underway: 0 }; + let matched = false; + + for (let idx = 0; idx < headerCells.length; idx++) { + const label = headerCells[idx] || ''; + const value = valueCells[idx]; + if (!Number.isFinite(value)) continue; + + if (label.includes('battle force') || label.includes('total') || label.includes('ships')) { + summary.totalShips = value!; + matched = true; + } else if (label.includes('deployed')) { + summary.deployed = value!; + matched = true; + } else if (label.includes('underway')) { + summary.underway = value!; + matched = true; + } + } + + if (matched) return summary; + + const tableText = stripHtml(tableHtml); + const totalMatch = tableText.match(/(?:battle[- ]?force|ships?|total)[^0-9]{0,40}(\d{1,3}(?:,\d{3})*)/i) + || tableText.match(/(\d{1,3}(?:,\d{3})*)\s*(?:battle[- ]?force|ships?|total)/i); + const deployedMatch = tableText.match(/deployed[^0-9]{0,40}(\d{1,3}(?:,\d{3})*)/i) + || tableText.match(/(\d{1,3}(?:,\d{3})*)\s*deployed/i); + const underwayMatch = tableText.match(/underway[^0-9]{0,40}(\d{1,3}(?:,\d{3})*)/i) + || tableText.match(/(\d{1,3}(?:,\d{3})*)\s*underway/i); + + if (!totalMatch && !deployedMatch && !underwayMatch) return undefined; + return { + totalShips: totalMatch ? parseInt(totalMatch[1]!.replace(/,/g, ''), 10) : 0, + deployed: deployedMatch ? parseInt(deployedMatch[1]!.replace(/,/g, ''), 10) : 0, + underway: underwayMatch ? parseInt(underwayMatch[1]!.replace(/,/g, ''), 10) : 0, + }; +} + +interface ParsedStrikeGroup { + name: string; + carrier?: string; + airWing?: string; + destroyerSquadron?: string; + escorts: string[]; +} + +function parseUSNIArticle( + html: string, + articleUrl: string, + articleDate: string, + articleTitle: string, +): USNIFleetReport { + const warnings: string[] = []; + const vessels: USNIVessel[] = []; + const vesselByRegionHull = new Map(); + const strikeGroups: ParsedStrikeGroup[] = []; + const regionsSet = new Set(); + + let battleForceSummary: BattleForceSummary | undefined; + const tableMatch = html.match(/]*>([\s\S]*?)<\/table>/i); + if (tableMatch) { + battleForceSummary = extractBattleForceSummary(tableMatch[1]!); + } + + const h2Parts = html.split(/]*>/i); + + for (let i = 1; i < h2Parts.length; i++) { + const part = h2Parts[i]!; + const h2EndIdx = part.indexOf(''); + if (h2EndIdx === -1) continue; + const regionRaw = stripHtml(part.substring(0, h2EndIdx)); + const regionContent = part.substring(h2EndIdx + 5); + + const regionName = regionRaw + .replace(/^(In the|In|The)\s+/i, '') + .replace(/\s+/g, ' ') + .trim(); + + if (!regionName) continue; + regionsSet.add(regionName); + + const coords = getRegionCoords(regionName); + if (!coords) { + warnings.push(`Unknown region: "${regionName}"`); + } + const regionLat = coords?.lat ?? 0; + const regionLon = coords?.lon ?? 0; + + const h3Parts = regionContent.split(/]*>/i); + + let currentStrikeGroup: ParsedStrikeGroup | null = null; + + for (let j = 0; j < h3Parts.length; j++) { + const section = h3Parts[j]!; + + if (j > 0) { + const h3EndIdx = section.indexOf(''); + if (h3EndIdx !== -1) { + const sgName = stripHtml(section.substring(0, h3EndIdx)); + if (sgName) { + currentStrikeGroup = { + name: sgName, + carrier: undefined, + airWing: undefined, + destroyerSquadron: undefined, + escorts: [], + }; + strikeGroups.push(currentStrikeGroup); + } + } + } + + const shipRegex = /USS\s+<(?:em|i)>([^<]+)<\/(?:em|i)>\s*\(([^)]+)\)/gi; + let match: RegExpExecArray | null; + const sectionText = stripHtml(section); + const deploymentStatus = detectDeploymentStatus(sectionText); + const homePort = extractHomePort(sectionText); + const activityDesc = sectionText.length > 10 ? sectionText.substring(0, 200).trim() : ''; + + const upsertVessel = (entry: USNIVessel) => { + const key = `${entry.region}|${entry.hullNumber.toUpperCase()}`; + const existing = vesselByRegionHull.get(key); + if (existing) { + if (!existing.strikeGroup && entry.strikeGroup) existing.strikeGroup = entry.strikeGroup; + if (existing.deploymentStatus === 'unknown' && entry.deploymentStatus !== 'unknown') { + existing.deploymentStatus = entry.deploymentStatus; + } + if (!existing.homePort && entry.homePort) existing.homePort = entry.homePort; + if ((!existing.activityDescription || existing.activityDescription.length < (entry.activityDescription || '').length) && entry.activityDescription) { + existing.activityDescription = entry.activityDescription; + } + return; + } + vessels.push(entry); + vesselByRegionHull.set(key, entry); + }; + + while ((match = shipRegex.exec(section)) !== null) { + const shipName = match[1]!.trim(); + const hullNumber = match[2]!.trim(); + const vesselType = hullToVesselType(hullNumber); + + if (vesselType === 'carrier' && currentStrikeGroup) { + currentStrikeGroup.carrier = `USS ${shipName} (${hullNumber})`; + } + if (currentStrikeGroup) { + currentStrikeGroup.escorts.push(`USS ${shipName} (${hullNumber})`); + } + + upsertVessel({ + name: `USS ${shipName}`, + hullNumber, + vesselType, + region: regionName, + regionLat, + regionLon, + deploymentStatus, + homePort: homePort || '', + strikeGroup: currentStrikeGroup?.name || '', + activityDescription: activityDesc, + articleUrl, + articleDate, + }); + } + + const usnsRegex = /USNS\s+<(?:em|i)>([^<]+)<\/(?:em|i)>\s*\(([^)]+)\)/gi; + while ((match = usnsRegex.exec(section)) !== null) { + const shipName = match[1]!.trim(); + const hullNumber = match[2]!.trim(); + upsertVessel({ + name: `USNS ${shipName}`, + hullNumber, + vesselType: hullToVesselType(hullNumber), + region: regionName, + regionLat, + regionLon, + deploymentStatus, + homePort: homePort || '', + strikeGroup: currentStrikeGroup?.name || '', + activityDescription: activityDesc, + articleUrl, + articleDate, + }); + } + } + } + + for (const sg of strikeGroups) { + const wingMatch = html.match(new RegExp(sg.name + '[\\s\\S]{0,500}Carrier Air Wing\\s*(\\w+)', 'i')); + if (wingMatch) sg.airWing = `Carrier Air Wing ${wingMatch[1]}`; + const desronMatch = html.match(new RegExp(sg.name + '[\\s\\S]{0,500}Destroyer Squadron\\s*(\\w+)', 'i')); + if (desronMatch) sg.destroyerSquadron = `Destroyer Squadron ${desronMatch[1]}`; + sg.escorts = Array.from(new Set(sg.escorts)); + } + + const protoStrikeGroups: USNIStrikeGroup[] = strikeGroups.map((sg) => ({ + name: sg.name, + carrier: sg.carrier || '', + airWing: sg.airWing || '', + destroyerSquadron: sg.destroyerSquadron || '', + escorts: sg.escorts, + })); + + return { + articleUrl, + articleDate, + articleTitle, + battleForceSummary, + vessels, + strikeGroups: protoStrikeGroups, + regions: Array.from(regionsSet), + parsingWarnings: warnings, + timestamp: Date.now(), + }; +} + +// ======================================================================== +// RPC handler +// ======================================================================== + +async function fetchUSNIReport(): Promise { + console.log('[USNI Fleet] Fetching from WordPress API...'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + let wpData: Array>; + try { + const response = await fetch( + 'https://news.usni.org/wp-json/wp/v2/posts?categories=4137&per_page=1', + { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: controller.signal, + }, + ); + if (!response.ok) throw new Error(`USNI API error: ${response.status}`); + wpData = (await response.json()) as Array>; + } finally { + clearTimeout(timeoutId); + } + + if (!wpData || !wpData.length) return null; + + const post = wpData[0]!; + const articleUrl = (post.link as string) || `https://news.usni.org/?p=${post.id}`; + const articleDate = (post.date as string) || new Date().toISOString(); + const articleTitle = stripHtml(((post.title as Record)?.rendered) || 'USNI Fleet Tracker'); + const htmlContent = ((post.content as Record)?.rendered) || ''; + + if (!htmlContent) return null; + + const report = parseUSNIArticle(htmlContent, articleUrl, articleDate, articleTitle); + console.log(`[USNI Fleet] Parsed: ${report.vessels.length} vessels, ${report.strikeGroups.length} CSGs, ${report.regions.length} regions`); + + if (report.parsingWarnings.length > 0) { + console.warn('[USNI Fleet] Warnings:', report.parsingWarnings.join('; ')); + } + + // Also write to stale backup cache + setCachedJson(USNI_STALE_CACHE_KEY, report, USNI_STALE_TTL).catch(() => {}); + + return report; +} + +export async function getUSNIFleetReport( + _ctx: ServerContext, + req: GetUSNIFleetReportRequest, +): Promise { + try { + if (req.forceRefresh) { + // Bypass cachedFetchJson — fetch fresh and write both caches + const report = await fetchUSNIReport(); + if (!report) return { report: undefined, cached: false, stale: false, error: 'No USNI fleet tracker articles found' }; + await setCachedJson(USNI_CACHE_KEY, report, USNI_CACHE_TTL); + return { report, cached: false, stale: false, error: '' }; + } + + // Single atomic call — source tracking inside cachedFetchJsonWithMeta eliminates TOCTOU race + const { data: report, source } = await cachedFetchJsonWithMeta( + USNI_CACHE_KEY, USNI_CACHE_TTL, fetchUSNIReport, + ); + if (report) { + if (source === 'cache') console.log('[USNI Fleet] Cache hit'); + return { report, cached: source === 'cache', stale: false, error: '' }; + } + + return { report: undefined, cached: false, stale: false, error: 'No USNI fleet tracker articles found' }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[USNI Fleet] Error:', message); + + const stale = (await getCachedJson(USNI_STALE_CACHE_KEY)) as USNIFleetReport | null; + if (stale) { + console.log('[USNI Fleet] Returning stale cached data'); + return { report: stale, cached: true, stale: true, error: 'Using cached data' }; + } + + return { report: undefined, cached: false, stale: false, error: message }; + } +} diff --git a/server/worldmonitor/military/v1/get-wingbits-status.ts b/server/worldmonitor/military/v1/get-wingbits-status.ts new file mode 100644 index 000000000..828088955 --- /dev/null +++ b/server/worldmonitor/military/v1/get-wingbits-status.ts @@ -0,0 +1,15 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + GetWingbitsStatusRequest, + GetWingbitsStatusResponse, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +export async function getWingbitsStatus( + _ctx: ServerContext, + _req: GetWingbitsStatusRequest, +): Promise { + const apiKey = process.env.WINGBITS_API_KEY; + return { configured: !!apiKey }; +} diff --git a/server/worldmonitor/military/v1/handler.ts b/server/worldmonitor/military/v1/handler.ts new file mode 100644 index 000000000..b665e0678 --- /dev/null +++ b/server/worldmonitor/military/v1/handler.ts @@ -0,0 +1,17 @@ +import type { MilitaryServiceHandler } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +import { listMilitaryFlights } from './list-military-flights'; +import { getTheaterPosture } from './get-theater-posture'; +import { getAircraftDetails } from './get-aircraft-details'; +import { getAircraftDetailsBatch } from './get-aircraft-details-batch'; +import { getWingbitsStatus } from './get-wingbits-status'; +import { getUSNIFleetReport } from './get-usni-fleet-report'; + +export const militaryHandler: MilitaryServiceHandler = { + listMilitaryFlights, + getTheaterPosture, + getAircraftDetails, + getAircraftDetailsBatch, + getWingbitsStatus, + getUSNIFleetReport, +}; diff --git a/server/worldmonitor/military/v1/list-military-flights.ts b/server/worldmonitor/military/v1/list-military-flights.ts new file mode 100644 index 000000000..512f835dc --- /dev/null +++ b/server/worldmonitor/military/v1/list-military-flights.ts @@ -0,0 +1,171 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + ListMilitaryFlightsRequest, + ListMilitaryFlightsResponse, + MilitaryAircraftType, +} from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; + +import { isMilitaryCallsign, isMilitaryHex, detectAircraftType, UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'military:flights:v1'; +const REDIS_CACHE_TTL = 600; // 10 min — reduce upstream API pressure + +/** Snap a coordinate to a grid step so nearby bbox values share cache entries. */ +const quantize = (v: number, step: number) => Math.round(v / step) * step; +const BBOX_GRID_STEP = 1; // 1-degree grid (~111 km at equator) + +interface RequestBounds { + south: number; + north: number; + west: number; + east: number; +} + +function getRelayRequestHeaders(): Record { + const headers: Record = { + Accept: 'application/json', + 'User-Agent': CHROME_UA, + }; + const relaySecret = process.env.RELAY_SHARED_SECRET; + if (relaySecret) { + const relayHeader = (process.env.RELAY_AUTH_HEADER || 'x-relay-key').toLowerCase(); + headers[relayHeader] = relaySecret; + headers.Authorization = `Bearer ${relaySecret}`; + } + return headers; +} + +function normalizeBounds(bb: NonNullable): RequestBounds { + return { + south: Math.min(bb.southWest!.latitude, bb.northEast!.latitude), + north: Math.max(bb.southWest!.latitude, bb.northEast!.latitude), + west: Math.min(bb.southWest!.longitude, bb.northEast!.longitude), + east: Math.max(bb.southWest!.longitude, bb.northEast!.longitude), + }; +} + +function filterFlightsToBounds( + flights: ListMilitaryFlightsResponse['flights'], + bounds: RequestBounds, +): ListMilitaryFlightsResponse['flights'] { + return flights.filter((flight) => { + const lat = flight.location?.latitude; + const lon = flight.location?.longitude; + if (lat == null || lon == null) return false; + return lat >= bounds.south && lat <= bounds.north && lon >= bounds.west && lon <= bounds.east; + }); +} + +const AIRCRAFT_TYPE_MAP: Record = { + tanker: 'MILITARY_AIRCRAFT_TYPE_TANKER', + awacs: 'MILITARY_AIRCRAFT_TYPE_AWACS', + transport: 'MILITARY_AIRCRAFT_TYPE_TRANSPORT', + reconnaissance: 'MILITARY_AIRCRAFT_TYPE_RECONNAISSANCE', + drone: 'MILITARY_AIRCRAFT_TYPE_DRONE', + bomber: 'MILITARY_AIRCRAFT_TYPE_BOMBER', +}; + +export async function listMilitaryFlights( + _ctx: ServerContext, + req: ListMilitaryFlightsRequest, +): Promise { + try { + const bb = req.boundingBox; + if (!bb?.southWest || !bb?.northEast) return { flights: [], clusters: [], pagination: undefined }; + const requestBounds = normalizeBounds(bb); + + // Quantize bbox to a 1° grid so nearby map views share cache entries. + // Precise coordinates caused near-zero hit rate since every pan/zoom created a unique key. + const quantizedBB = [ + quantize(bb.southWest.latitude, BBOX_GRID_STEP), + quantize(bb.southWest.longitude, BBOX_GRID_STEP), + quantize(bb.northEast.latitude, BBOX_GRID_STEP), + quantize(bb.northEast.longitude, BBOX_GRID_STEP), + ].join(':'); + const cacheKey = `${REDIS_CACHE_KEY}:${quantizedBB}:${req.operator || ''}:${req.aircraftType || ''}:${req.pagination?.pageSize || 0}`; + + const fullResult = await cachedFetchJson( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar'); + const baseUrl = isSidecar + ? 'https://opensky-network.org/api/states/all' + : process.env.WS_RELAY_URL ? process.env.WS_RELAY_URL + '/opensky' : null; + + if (!baseUrl) return null; + + const fetchBB = { + lamin: quantize(bb.southWest.latitude, BBOX_GRID_STEP) - BBOX_GRID_STEP / 2, + lamax: quantize(bb.northEast.latitude, BBOX_GRID_STEP) + BBOX_GRID_STEP / 2, + lomin: quantize(bb.southWest.longitude, BBOX_GRID_STEP) - BBOX_GRID_STEP / 2, + lomax: quantize(bb.northEast.longitude, BBOX_GRID_STEP) + BBOX_GRID_STEP / 2, + }; + const params = new URLSearchParams(); + params.set('lamin', String(fetchBB.lamin)); + params.set('lamax', String(fetchBB.lamax)); + params.set('lomin', String(fetchBB.lomin)); + params.set('lomax', String(fetchBB.lomax)); + + const url = `${baseUrl!}${params.toString() ? '?' + params.toString() : ''}`; + const resp = await fetch(url, { + headers: getRelayRequestHeaders(), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + + if (!resp.ok) return null; + + const data = (await resp.json()) as { states?: Array<[string, string, ...unknown[]]> }; + if (!data.states) return null; + + const flights: ListMilitaryFlightsResponse['flights'] = []; + for (const state of data.states) { + const [icao24, callsign, , , , lon, lat, altitude, onGround, velocity, heading] = state as [ + string, string, unknown, unknown, unknown, number | null, number | null, number | null, boolean, number | null, number | null, + ]; + if (lat == null || lon == null || onGround) continue; + if (!isMilitaryCallsign(callsign) && !isMilitaryHex(icao24)) continue; + + const aircraftType = detectAircraftType(callsign); + + flights.push({ + id: icao24, + callsign: (callsign || '').trim(), + hexCode: icao24, + registration: '', + aircraftType: (AIRCRAFT_TYPE_MAP[aircraftType] || 'MILITARY_AIRCRAFT_TYPE_UNKNOWN') as MilitaryAircraftType, + aircraftModel: '', + operator: 'MILITARY_OPERATOR_OTHER', + operatorCountry: '', + location: { latitude: lat, longitude: lon }, + altitude: altitude ?? 0, + heading: heading ?? 0, + speed: (velocity as number) ?? 0, + verticalRate: 0, + onGround: false, + squawk: '', + origin: '', + destination: '', + lastSeenAt: Date.now(), + firstSeenAt: 0, + confidence: 'MILITARY_CONFIDENCE_LOW', + isInteresting: false, + note: '', + enrichment: undefined, + }); + } + + return flights.length > 0 ? { flights, clusters: [], pagination: undefined } : null; + }, + ); + + if (!fullResult) return { flights: [], clusters: [], pagination: undefined }; + return { ...fullResult, flights: filterFlightsToBounds(fullResult.flights, requestBounds) }; + } catch { + return { flights: [], clusters: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/news/v1/_shared.ts b/server/worldmonitor/news/v1/_shared.ts new file mode 100644 index 000000000..6ed4ae1d0 --- /dev/null +++ b/server/worldmonitor/news/v1/_shared.ts @@ -0,0 +1,199 @@ +declare const process: { env: Record }; + +// ======================================================================== +// Constants +// ======================================================================== + +export const CACHE_TTL_SECONDS = 86400; // 24 hours +export const CACHE_VERSION = 'v5'; + +// ======================================================================== +// Hash utility (unified FNV-1a 52-bit -- H-7 fix) +// ======================================================================== + +import { hashString } from '../../../_shared/hash'; +export { hashString }; + +// ======================================================================== +// Cache key builder (ported from _summarize-handler.js) +// ======================================================================== + +export function getCacheKey( + headlines: string[], + mode: string, + geoContext: string = '', + variant: string = 'full', + lang: string = 'en', +): string { + const sorted = headlines.slice(0, 5).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}`; +} + +// ======================================================================== +// Headline deduplication (used by SummarizeArticle) +// ======================================================================== + +// @ts-ignore -- plain JS module, no .d.mts needed for this pure function +export { deduplicateHeadlines } from './dedup.mjs'; + +// ======================================================================== +// SummarizeArticle: Full prompt builder (ported from _summarize-handler.js) +// ======================================================================== + +export function buildArticlePrompts( + headlines: string[], + uniqueHeadlines: string[], + opts: { mode: string; geoContext: string; variant: string; lang: string }, +): { systemPrompt: string; userPrompt: string } { + const headlineText = uniqueHeadlines.map((h, i) => `${i + 1}. ${h}`).join('\n'); + const intelSection = opts.geoContext ? `\n\n${opts.geoContext}` : ''; + const isTechVariant = opts.variant === 'tech'; + const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Provide geopolitical context appropriate for the current date.'}`; + const langInstruction = opts.lang && opts.lang !== 'en' ? `\nIMPORTANT: Output the summary in ${opts.lang.toUpperCase()} language.` : ''; + + let systemPrompt: string; + let userPrompt: string; + + if (opts.mode === 'brief') { + if (isTechVariant) { + systemPrompt = `${dateContext} + +Summarize the single most important tech/startup headline in 2 concise sentences MAX (under 60 words total). +Rules: +- Each numbered headline below is a SEPARATE, UNRELATED story +- Pick the ONE most significant headline and summarize ONLY that story +- NEVER combine or merge facts, names, or details from different headlines +- Focus ONLY on technology, startups, AI, funding, product launches, or developer news +- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation +- Lead with the company/product/technology name +- No bullet points, no meta-commentary, no elaboration beyond the core facts${langInstruction}`; + } else { + systemPrompt = `${dateContext} + +Summarize the single most important headline in 2 concise sentences MAX (under 60 words total). +Rules: +- Each numbered headline below is a SEPARATE, UNRELATED story +- Pick the ONE most significant headline and summarize ONLY that story +- NEVER combine or merge people, places, or facts from different headlines into one sentence +- Lead with WHAT happened and WHERE - be specific +- NEVER start with "Breaking news", "Good evening", "Tonight", or TV-style openings +- Start directly with the subject of the chosen headline +- If intelligence context is provided, use it only if it relates to your chosen headline +- No bullet points, no meta-commentary, no elaboration beyond the core facts${langInstruction}`; + } + userPrompt = `Each headline below is a separate story. Pick the most important ONE and summarize only that story:\n${headlineText}${intelSection}`; + } else if (opts.mode === 'analysis') { + if (isTechVariant) { + systemPrompt = `${dateContext} + +Analyze the most significant tech/startup development in 2 concise sentences MAX (under 60 words total). +Rules: +- Each numbered headline below is a SEPARATE, UNRELATED story +- Pick the ONE most significant story and analyze ONLY that +- NEVER combine facts from different headlines +- Focus ONLY on technology implications: funding trends, AI developments, market shifts, product strategy +- IGNORE political implications, trade wars, government unless directly about tech policy +- Lead with the insight, no filler or elaboration`; + } else { + systemPrompt = `${dateContext} + +Analyze the most significant development in 2 concise sentences MAX (under 60 words total). Be direct and specific. +Rules: +- Each numbered headline below is a SEPARATE, UNRELATED story +- Pick the ONE most significant story and analyze ONLY that +- NEVER combine or merge people, places, or facts from different headlines +- Lead with the insight - what's significant and why +- NEVER start with "Breaking news", "Tonight", "The key/dominant narrative is" +- Start with substance, no filler or elaboration +- If intelligence context is provided, use it only if it relates to your chosen headline`; + } + userPrompt = isTechVariant + ? `Each headline is a separate story. What's the key tech trend?\n${headlineText}${intelSection}` + : `Each headline is a separate story. What's the key pattern or risk?\n${headlineText}${intelSection}`; + } else if (opts.mode === 'translate') { + const targetLang = opts.variant; + 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\nPick the most important tech headline and summarize it in 2 concise sentences (under 60 words). Each headline is a separate story - NEVER merge facts from different headlines. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}` + : `${dateContext}\n\nPick the most important headline and summarize it in 2 concise sentences (under 60 words). Each headline is a separate, unrelated story - NEVER merge people or facts from different headlines. Lead with substance. NEVER start with "Breaking news" or "Tonight".${langInstruction}`; + userPrompt = `Each headline is a separate story. Key takeaway from the most important one:\n${headlineText}${intelSection}`; + } + + return { systemPrompt, userPrompt }; +} + +// ======================================================================== +// SummarizeArticle: Provider credential resolution +// ======================================================================== + +export interface ProviderCredentials { + apiUrl: string; + model: string; + headers: Record; + extraBody?: Record; +} + +export function getProviderCredentials(provider: string): ProviderCredentials | null { + if (provider === 'ollama') { + const baseUrl = process.env.OLLAMA_API_URL; + if (!baseUrl) return null; + const headers: Record = { 'Content-Type': 'application/json' }; + const apiKey = process.env.OLLAMA_API_KEY; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + return { + apiUrl: new URL('/v1/chat/completions', baseUrl).toString(), + model: process.env.OLLAMA_MODEL || 'llama3.1:8b', + headers, + extraBody: { think: false }, + }; + } + + if (provider === 'groq') { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) return null; + return { + apiUrl: 'https://api.groq.com/openai/v1/chat/completions', + model: 'llama-3.1-8b-instant', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }; + } + + if (provider === 'openrouter') { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) return null; + return { + apiUrl: 'https://openrouter.ai/api/v1/chat/completions', + model: 'openrouter/free', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://worldmonitor.app', + 'X-Title': 'WorldMonitor', + }, + }; + } + + return null; +} diff --git a/server/worldmonitor/news/v1/dedup.mjs b/server/worldmonitor/news/v1/dedup.mjs new file mode 100644 index 000000000..cea72a6c5 --- /dev/null +++ b/server/worldmonitor/news/v1/dedup.mjs @@ -0,0 +1,29 @@ +/** + * Headline deduplication using word-level similarity. + * Plain JS module so it can be imported from both TS source and .mjs tests. + */ + +/** @param {string[]} headlines */ +export function deduplicateHeadlines(headlines) { + const seen = []; + const unique = []; + + for (const headline of headlines) { + const normalized = headline.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); + const words = new Set(normalized.split(' ').filter((w) => w.length >= 4)); + + let isDuplicate = false; + for (const seenWords of seen) { + const intersection = [...words].filter((w) => seenWords.has(w)); + const similarity = intersection.length / Math.min(words.size, seenWords.size); + if (similarity > 0.6) { isDuplicate = true; break; } + } + + if (!isDuplicate) { + seen.push(words); + unique.push(headline); + } + } + + return unique; +} diff --git a/server/worldmonitor/news/v1/handler.ts b/server/worldmonitor/news/v1/handler.ts new file mode 100644 index 000000000..3fb5966ff --- /dev/null +++ b/server/worldmonitor/news/v1/handler.ts @@ -0,0 +1,7 @@ +import type { NewsServiceHandler } from '../../../../src/generated/server/worldmonitor/news/v1/service_server'; + +import { summarizeArticle } from './summarize-article'; + +export const newsHandler: NewsServiceHandler = { + summarizeArticle, +}; diff --git a/server/worldmonitor/news/v1/summarize-article.ts b/server/worldmonitor/news/v1/summarize-article.ts new file mode 100644 index 000000000..27d3fd5e0 --- /dev/null +++ b/server/worldmonitor/news/v1/summarize-article.ts @@ -0,0 +1,203 @@ +import type { + ServerContext, + SummarizeArticleRequest, + SummarizeArticleResponse, +} from '../../../../src/generated/server/worldmonitor/news/v1/service_server'; + +import { cachedFetchJsonWithMeta } from '../../../_shared/redis'; +import { + CACHE_TTL_SECONDS, + deduplicateHeadlines, + buildArticlePrompts, + getProviderCredentials, + getCacheKey, +} from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; + +// ====================================================================== +// Reasoning preamble detection +// ====================================================================== + +export const TASK_NARRATION = /^(we need to|i need to|let me|i'll |i should|i will |the task is|the instructions|according to the rules|so we need to|okay[,.]\s*(i'll|let me|so|we need|the task|i should|i will)|sure[,.]\s*(i'll|let me|so|we need|the task|i should|i will|here))/i; +export const PROMPT_ECHO = /^(summarize the top story|summarize the key|rules:|here are the rules|the top story is likely)/i; + +export function hasReasoningPreamble(text: string): boolean { + const trimmed = text.trim(); + return TASK_NARRATION.test(trimmed) || PROMPT_ECHO.test(trimmed); +} + +// ====================================================================== +// SummarizeArticle: Multi-provider LLM summarization with Redis caching +// Ported from api/_summarize-handler.js +// ====================================================================== + +export async function summarizeArticle( + _ctx: ServerContext, + req: SummarizeArticleRequest, +): Promise { + const { provider, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = req; + + // Input sanitization (M-14 fix): limit headline count and length + const MAX_HEADLINES = 10; + const MAX_HEADLINE_LEN = 500; + const MAX_GEO_CONTEXT_LEN = 2000; + const headlines = (req.headlines || []) + .slice(0, MAX_HEADLINES) + .map(h => typeof h === 'string' ? h.slice(0, MAX_HEADLINE_LEN) : ''); + const sanitizedGeoContext = typeof geoContext === 'string' ? geoContext.slice(0, MAX_GEO_CONTEXT_LEN) : ''; + + // Provider credential check + const skipReasons: Record = { + ollama: 'OLLAMA_API_URL not configured', + groq: 'GROQ_API_KEY not configured', + openrouter: 'OPENROUTER_API_KEY not configured', + }; + + const credentials = getProviderCredentials(provider); + if (!credentials) { + return { + summary: '', + model: '', + provider: provider, + cached: false, + tokens: 0, + fallback: true, + skipped: true, + reason: skipReasons[provider] || `Unknown provider: ${provider}`, + error: '', + errorType: '', + }; + } + + const { apiUrl, model, headers: providerHeaders, extraBody } = credentials; + + // Request validation + if (!headlines || !Array.isArray(headlines) || headlines.length === 0) { + return { + summary: '', + model: '', + provider: provider, + cached: false, + tokens: 0, + fallback: false, + skipped: false, + reason: '', + error: 'Headlines array required', + errorType: 'ValidationError', + }; + } + + try { + const cacheKey = getCacheKey(headlines, mode, sanitizedGeoContext, variant, lang); + + // Single atomic call — source tracking happens inside cachedFetchJsonWithMeta, + // eliminating the TOCTOU race between a separate getCachedJson and cachedFetchJson. + const { data: result, source } = await cachedFetchJsonWithMeta<{ summary: string; model: string; tokens: number }>( + cacheKey, + CACHE_TTL_SECONDS, + async () => { + const uniqueHeadlines = deduplicateHeadlines(headlines.slice(0, 5)); + const { systemPrompt, userPrompt } = buildArticlePrompts(headlines, uniqueHeadlines, { + mode, + geoContext: sanitizedGeoContext, + variant, + lang, + }); + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { ...providerHeaders, 'User-Agent': CHROME_UA }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.3, + max_tokens: 100, + top_p: 0.9, + ...extraBody, + }), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[SummarizeArticle:${provider}] API error:`, response.status, errorText); + throw new Error(response.status === 429 ? 'Rate limited' : `${provider} API error`); + } + + const data = await response.json() as any; + const tokens = (data.usage?.total_tokens as number) || 0; + const message = data.choices?.[0]?.message; + let rawContent = typeof message?.content === 'string' ? message.content.trim() : ''; + + rawContent = rawContent + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/<\|thinking\|>[\s\S]*?<\|\/thinking\|>/gi, '') + .replace(/[\s\S]*?<\/reasoning>/gi, '') + .replace(/[\s\S]*?<\/reflection>/gi, '') + .trim(); + + // Strip unterminated thinking blocks (no closing tag) + rawContent = rawContent + .replace(/[\s\S]*/gi, '') + .replace(/<\|thinking\|>[\s\S]*/gi, '') + .replace(/[\s\S]*/gi, '') + .replace(/[\s\S]*/gi, '') + .trim(); + + if (['brief', 'analysis'].includes(mode) && hasReasoningPreamble(rawContent)) { + console.warn(`[SummarizeArticle:${provider}] Reasoning preamble detected, rejecting`); + return null; + } + + return rawContent ? { summary: rawContent, model, tokens } : null; + }, + ); + + if (result?.summary) { + return { + summary: result.summary, + model: result.model || model, + provider: source === 'cache' ? 'cache' : provider, + cached: source === 'cache', + tokens: source === 'cache' ? 0 : (result.tokens || 0), + fallback: false, + skipped: false, + reason: '', + error: '', + errorType: '', + }; + } + + return { + summary: '', + model: '', + provider: provider, + cached: false, + tokens: 0, + fallback: true, + skipped: false, + reason: '', + error: 'Empty response', + errorType: '', + }; + + } catch (err: unknown) { + const error = err instanceof Error ? err : new Error(String(err)); + console.error(`[SummarizeArticle:${provider}] Error:`, error.name, error.message); + return { + summary: '', + model: '', + provider: provider, + cached: false, + tokens: 0, + fallback: true, + skipped: false, + reason: '', + error: error.message, + errorType: error.name, + }; + } +} diff --git a/server/worldmonitor/positive-events/v1/handler.ts b/server/worldmonitor/positive-events/v1/handler.ts new file mode 100644 index 000000000..30a5d2f02 --- /dev/null +++ b/server/worldmonitor/positive-events/v1/handler.ts @@ -0,0 +1,6 @@ +import type { PositiveEventsServiceHandler } from '../../../../src/generated/server/worldmonitor/positive_events/v1/service_server'; +import { listPositiveGeoEvents } from './list-positive-geo-events'; + +export const positiveEventsHandler: PositiveEventsServiceHandler = { + listPositiveGeoEvents, +}; diff --git a/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts b/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts new file mode 100644 index 000000000..ffef85ba3 --- /dev/null +++ b/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts @@ -0,0 +1,114 @@ +/** + * ListPositiveGeoEvents RPC -- fetches geocoded positive news events + * from GDELT GEO API using positive topic queries. + */ + +import type { + ServerContext, + ListPositiveGeoEventsRequest, + ListPositiveGeoEventsResponse, + PositiveGeoEvent, +} from '../../../../src/generated/server/worldmonitor/positive_events/v1/service_server'; + +import { classifyNewsItem } from '../../../../src/services/positive-classifier'; + +const GDELT_GEO_URL = 'https://api.gdeltproject.org/api/v2/geo/geo'; + +// Compound positive queries combining topics from POSITIVE_GDELT_TOPICS pattern +const POSITIVE_QUERIES = [ + '(breakthrough OR discovery OR "renewable energy")', + '(conservation OR "poverty decline" OR "humanitarian aid")', + '("good news" OR volunteer OR donation OR charity)', +]; + +async function fetchGdeltGeoPositive(query: string): Promise { + const params = new URLSearchParams({ + query, + format: 'geojson', + timespan: '24h', + maxrecords: '75', + }); + + const response = await fetch(`${GDELT_GEO_URL}?${params}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return []; + + const data = await response.json(); + const features: unknown[] = data?.features || []; + const seenLocations = new Set(); + const events: PositiveGeoEvent[] = []; + + for (const feature of features as any[]) { + const name: string = feature.properties?.name || ''; + if (!name || seenLocations.has(name)) continue; + // GDELT returns error messages as fake features — skip them + if (name.startsWith('ERROR:') || name.includes('unknown error')) continue; + + const count: number = feature.properties?.count || 1; + if (count < 3) continue; // Noise filter + + const coords = feature.geometry?.coordinates; + if (!Array.isArray(coords) || coords.length < 2) continue; + + const [lon, lat] = coords; // GeoJSON order: [lon, lat] + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) continue; + + seenLocations.add(name); + + const category = classifyNewsItem('GDELT', name); + + events.push({ + latitude: lat, + longitude: lon, + name, + category, + count, + timestamp: Date.now(), + }); + } + + return events; +} + +export async function listPositiveGeoEvents( + _ctx: ServerContext, + _req: ListPositiveGeoEventsRequest, +): Promise { + try { + const allEvents: PositiveGeoEvent[] = []; + const seenNames = new Set(); + + for (let i = 0; i < POSITIVE_QUERIES.length; i++) { + if (i > 0) { + // Rate-limit delay between queries + await new Promise(r => setTimeout(r, 500)); + } + + try { + const events = await fetchGdeltGeoPositive(POSITIVE_QUERIES[i]); + for (const event of events) { + if (!seenNames.has(event.name)) { + seenNames.add(event.name); + allEvents.push(event); + } + } + } catch { + // Individual query failure is non-fatal + } + } + + return { events: allEvents }; + } catch { + return { events: [] }; + } +} diff --git a/server/worldmonitor/prediction/v1/handler.ts b/server/worldmonitor/prediction/v1/handler.ts new file mode 100644 index 000000000..6372baca1 --- /dev/null +++ b/server/worldmonitor/prediction/v1/handler.ts @@ -0,0 +1,7 @@ +import type { PredictionServiceHandler } from '../../../../src/generated/server/worldmonitor/prediction/v1/service_server'; + +import { listPredictionMarkets } from './list-prediction-markets'; + +export const predictionHandler: PredictionServiceHandler = { + listPredictionMarkets, +}; diff --git a/server/worldmonitor/prediction/v1/list-prediction-markets.ts b/server/worldmonitor/prediction/v1/list-prediction-markets.ts new file mode 100644 index 000000000..543b40d73 --- /dev/null +++ b/server/worldmonitor/prediction/v1/list-prediction-markets.ts @@ -0,0 +1,150 @@ +/** + * ListPredictionMarkets RPC -- proxies the Gamma API for Polymarket prediction markets. + * + * Critical constraint: Gamma API is behind Cloudflare JA3 fingerprint detection + * that blocks server-side TLS connections. The handler tries the fetch and + * gracefully returns empty on failure -- identical to the existing api/polymarket.js + * behavior. This is expected, not an error. + */ + +import type { + PredictionServiceHandler, + ServerContext, + ListPredictionMarketsRequest, + ListPredictionMarketsResponse, + PredictionMarket, +} from '../../../../src/generated/server/worldmonitor/prediction/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'prediction:markets:v1'; +const REDIS_CACHE_TTL = 300; // 5 min + +const GAMMA_BASE = 'https://gamma-api.polymarket.com'; +const FETCH_TIMEOUT = 8000; + +// ---------- Internal Gamma API types ---------- + +interface GammaMarket { + question: string; + outcomes?: string; + outcomePrices?: string; + volume?: string; + volumeNum?: number; + closed?: boolean; + slug?: string; +} + +interface GammaEvent { + id: string; + title: string; + slug: string; + volume?: number; + markets?: GammaMarket[]; + closed?: boolean; +} + +// ---------- Helpers ---------- + +/** Parse the yes-side price from a Gamma market's outcomePrices JSON string (0-1 scale). */ +function parseYesPrice(market: GammaMarket): number { + try { + const pricesStr = market.outcomePrices; + if (pricesStr) { + const prices: string[] = JSON.parse(pricesStr); + if (prices.length >= 1) { + const parsed = parseFloat(prices[0]!); + if (!isNaN(parsed)) return parsed; // 0-1 scale for proto + } + } + } catch { + /* keep default */ + } + return 0.5; +} + +/** Map a GammaEvent to a proto PredictionMarket (picks top market by volume). */ +function mapEvent(event: GammaEvent, category: string): PredictionMarket { + // Pick the top market from the event (first one is typically highest volume) + const topMarket = event.markets?.[0]; + + return { + id: event.id || '', + title: topMarket?.question || event.title, + yesPrice: topMarket ? parseYesPrice(topMarket) : 0.5, + volume: event.volume ?? 0, + url: `https://polymarket.com/event/${event.slug}`, + closesAt: 0, + category: category || '', + }; +} + +/** Map a GammaMarket to a proto PredictionMarket. */ +function mapMarket(market: GammaMarket): PredictionMarket { + return { + id: market.slug || '', + title: market.question, + yesPrice: parseYesPrice(market), + volume: (market.volumeNum ?? (market.volume ? parseFloat(market.volume) : 0)) || 0, + url: `https://polymarket.com/market/${market.slug}`, + closesAt: 0, + category: '', + }; +} + +// ---------- RPC ---------- + +export const listPredictionMarkets: PredictionServiceHandler['listPredictionMarkets'] = async ( + _ctx: ServerContext, + req: ListPredictionMarketsRequest, +): Promise => { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.category || 'all'}:${req.query || ''}:${req.pagination?.pageSize || 50}`; + const result = await cachedFetchJson( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const useEvents = !!req.category; + const endpoint = useEvents ? 'events' : 'markets'; + const limit = Math.max(1, Math.min(100, req.pagination?.pageSize || 50)); + const params = new URLSearchParams({ + closed: 'false', + order: 'volume', + ascending: 'false', + limit: String(limit), + }); + if (useEvents) { + params.set('tag_slug', req.category); + } + + const response = await fetch( + `${GAMMA_BASE}/${endpoint}?${params}`, + { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }, + ); + if (!response.ok) return null; + + const data: unknown = await response.json(); + let markets: PredictionMarket[]; + if (useEvents) { + markets = (data as GammaEvent[]).map((e) => mapEvent(e, req.category)); + } else { + markets = (data as GammaMarket[]).map(mapMarket); + } + + if (req.query) { + const q = req.query.toLowerCase(); + markets = markets.filter((m) => m.title.toLowerCase().includes(q)); + } + + return markets.length > 0 ? { markets, pagination: undefined } : null; + }, + ); + return result || { markets: [], pagination: undefined }; + } catch { + return { markets: [], pagination: undefined }; + } +}; diff --git a/server/worldmonitor/research/v1/handler.ts b/server/worldmonitor/research/v1/handler.ts new file mode 100644 index 000000000..30619152e --- /dev/null +++ b/server/worldmonitor/research/v1/handler.ts @@ -0,0 +1,22 @@ +/** + * Research service handler -- thin composition file. + * + * Each RPC is implemented in its own file: + * - list-arxiv-papers.ts (arXiv Atom XML API) + * - list-trending-repos.ts (GitHub trending JSON APIs) + * - list-hackernews-items.ts (HN Firebase JSON API) + * - list-tech-events.ts (Techmeme ICS + dev.events RSS + curated) + */ + +import type { ResearchServiceHandler } from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; +import { listArxivPapers } from './list-arxiv-papers'; +import { listTrendingRepos } from './list-trending-repos'; +import { listHackernewsItems } from './list-hackernews-items'; +import { listTechEvents } from './list-tech-events'; + +export const researchHandler: ResearchServiceHandler = { + listArxivPapers, + listTrendingRepos, + listHackernewsItems, + listTechEvents, +}; diff --git a/server/worldmonitor/research/v1/list-arxiv-papers.ts b/server/worldmonitor/research/v1/list-arxiv-papers.ts new file mode 100644 index 000000000..abeb57475 --- /dev/null +++ b/server/worldmonitor/research/v1/list-arxiv-papers.ts @@ -0,0 +1,108 @@ +/** + * RPC: listArxivPapers + * + * Fetches papers from the arXiv Atom XML API, parsed via fast-xml-parser. + * Returns empty array on any failure (graceful degradation). + */ + +import { XMLParser } from 'fast-xml-parser'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:arxiv:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — daily arXiv updates +import type { + ServerContext, + ListArxivPapersRequest, + ListArxivPapersResponse, + ArxivPaper, +} from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; + +// ---------- XML Parser ---------- + +const xmlParser = new XMLParser({ + ignoreAttributes: false, // CRITICAL: arXiv uses attributes for category term, link href/rel + attributeNamePrefix: '@_', + isArray: (_name: string, jpath: string) => + /\.(entry|author|category|link)$/.test(jpath), +}); + +// ---------- Fetch ---------- + +async function fetchArxivPapers(req: ListArxivPapersRequest): Promise { + const category = req.category || 'cs.AI'; + const pageSize = req.pagination?.pageSize || 50; + + let searchQuery: string; + if (req.query) { + searchQuery = `all:${req.query}+AND+cat:${category}`; + } else { + searchQuery = `cat:${category}`; + } + + const url = `https://export.arxiv.org/api/query?search_query=${searchQuery}&start=0&max_results=${pageSize}`; + + const response = await fetch(url, { + headers: { Accept: 'application/xml', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) return []; + + const xml = await response.text(); + const parsed = xmlParser.parse(xml); + const feed = parsed?.feed; + if (!feed) return []; + + const entries: any[] = Array.isArray(feed.entry) ? feed.entry : feed.entry ? [feed.entry] : []; + + return entries.map((entry: any): ArxivPaper => { + // Extract ID: last segment after last '/' + const rawId = String(entry.id || ''); + const id = rawId.split('/').pop() || rawId; + + // Clean title (arXiv titles can have internal newlines) + const title = (entry.title || '').trim().replace(/\s+/g, ' '); + + // Clean summary + const summary = (entry.summary || '').trim().replace(/\s+/g, ' '); + + // Authors + const authors = (entry.author ?? []).map((a: any) => a.name || ''); + + // Categories (from attributes) + const categories = (entry.category ?? []).map((c: any) => c['@_term'] || ''); + + // Published time (Unix epoch ms) + const publishedAt = entry.published ? new Date(entry.published).getTime() : 0; + + // URL: find link with rel="alternate", fallback to entry.id + const links: any[] = Array.isArray(entry.link) ? entry.link : entry.link ? [entry.link] : []; + const alternateLink = links.find((l: any) => l['@_rel'] === 'alternate'); + const url = alternateLink?.['@_href'] || rawId; + + return { id, title, summary, authors, categories, publishedAt, url }; + }); +} + +// ---------- Handler ---------- + +export async function listArxivPapers( + _ctx: ServerContext, + req: ListArxivPapersRequest, +): Promise { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.category || 'cs.AI'}:${req.query || ''}:${req.pagination?.pageSize || 50}`; + const result = await cachedFetchJson( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const papers = await fetchArxivPapers(req); + return papers.length > 0 ? { papers, pagination: undefined } : null; + }, + ); + return result || { papers: [], pagination: undefined }; + } catch { + return { papers: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/research/v1/list-hackernews-items.ts b/server/worldmonitor/research/v1/list-hackernews-items.ts new file mode 100644 index 000000000..c42c3af41 --- /dev/null +++ b/server/worldmonitor/research/v1/list-hackernews-items.ts @@ -0,0 +1,99 @@ +/** + * RPC: listHackernewsItems + * + * Fetches Hacker News stories via the Firebase JSON API with a 2-step fetch + * (IDs then items) and bounded concurrency. Returns empty array on any failure. + */ + +import type { + ServerContext, + ListHackernewsItemsRequest, + ListHackernewsItemsResponse, + HackernewsItem, +} from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:hackernews:v1'; +const REDIS_CACHE_TTL = 600; // 10 min + +// ---------- Constants ---------- + +const ALLOWED_HN_FEEDS = new Set(['top', 'new', 'best', 'ask', 'show', 'job']); +const HN_MAX_CONCURRENCY = 10; + +// ---------- Fetch ---------- + +async function fetchHackernewsItems(req: ListHackernewsItemsRequest): Promise { + const feedType = ALLOWED_HN_FEEDS.has(req.feedType) ? req.feedType : 'top'; + const pageSize = req.pagination?.pageSize || 30; + + // Step 1: Fetch story IDs + const idsUrl = `https://hacker-news.firebaseio.com/v0/${feedType}stories.json`; + const idsResponse = await fetch(idsUrl, { + headers: { 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10000), + }); + + if (!idsResponse.ok) return []; + + const allIds: unknown = await idsResponse.json(); + if (!Array.isArray(allIds)) return []; + + // Step 2: Batch-fetch individual items with bounded concurrency + const ids = allIds.slice(0, pageSize) as number[]; + const items: HackernewsItem[] = []; + + for (let i = 0; i < ids.length; i += HN_MAX_CONCURRENCY) { + const batch = ids.slice(i, i + HN_MAX_CONCURRENCY); + const results = await Promise.all( + batch.map(async (id): Promise => { + try { + const res = await fetch( + `https://hacker-news.firebaseio.com/v0/item/${id}.json`, + { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(5000) }, + ); + if (!res.ok) return null; + const raw: any = await res.json(); + if (!raw || raw.type !== 'story') return null; + return { + id: raw.id || 0, + title: raw.title || '', + url: raw.url || '', + score: raw.score || 0, + commentCount: raw.descendants || 0, + by: raw.by || '', + submittedAt: (raw.time || 0) * 1000, // HN uses Unix seconds, proto uses ms + }; + } catch { + return null; + } + }), + ); + for (const item of results) { + if (item) items.push(item); + } + } + + return items; +} + +// ---------- Handler ---------- + +export async function listHackernewsItems( + _ctx: ServerContext, + req: ListHackernewsItemsRequest, +): Promise { + try { + const feedType = ALLOWED_HN_FEEDS.has(req.feedType) ? req.feedType : 'top'; + const cacheKey = `${REDIS_CACHE_KEY}:${feedType}:${req.pagination?.pageSize || 30}`; + const result = await cachedFetchJson(cacheKey, REDIS_CACHE_TTL, async () => { + const items = await fetchHackernewsItems(req); + return items.length > 0 ? { items, pagination: undefined } : null; + }); + return result || { items: [], pagination: undefined }; + } catch { + return { items: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/research/v1/list-tech-events.ts b/server/worldmonitor/research/v1/list-tech-events.ts new file mode 100644 index 000000000..c2c2fee2f --- /dev/null +++ b/server/worldmonitor/research/v1/list-tech-events.ts @@ -0,0 +1,386 @@ +/** + * RPC: listTechEvents + * + * Aggregates tech events from three sources: + * - Techmeme ICS calendar + * - dev.events RSS feed + * - Curated major conferences + * + * Supports filtering by type, mappability, time range, and limit. + * Includes geocoding via 500-city coordinate lookup. + * Returns graceful error response on failure. + */ + +import type { + ServerContext, + ListTechEventsRequest, + ListTechEventsResponse, + TechEvent, + TechEventCoords, +} from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; +import { CITY_COORDS } from '../../../../api/data/city-coords'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:tech-events:v1'; +const REDIS_CACHE_TTL = 21600; // 6 hr — weekly event data + +// ---------- Constants ---------- + +const ICS_URL = 'https://www.techmeme.com/newsy_events.ics'; +const DEV_EVENTS_RSS = 'https://dev.events/rss.xml'; + +// Curated major tech events that may fall off limited RSS feeds +const CURATED_EVENTS: TechEvent[] = [ + { + id: 'step-dubai-2026', + title: 'STEP Dubai 2026', + type: 'conference', + location: 'Dubai Internet City, Dubai', + coords: { lat: 25.0956, lng: 55.1548, country: 'UAE', original: 'Dubai Internet City, Dubai', virtual: false }, + startDate: '2026-02-11', + endDate: '2026-02-12', + url: 'https://dubai.stepconference.com', + source: 'curated', + description: 'Intelligence Everywhere: The AI Economy - 8,000+ attendees, 400+ startups', + }, + { + id: 'gitex-global-2026', + title: 'GITEX Global 2026', + type: 'conference', + location: 'Dubai World Trade Centre, Dubai', + coords: { lat: 25.2285, lng: 55.2867, country: 'UAE', original: 'Dubai World Trade Centre, Dubai', virtual: false }, + startDate: '2026-12-07', + endDate: '2026-12-11', + url: 'https://www.gitex.com', + source: 'curated', + description: 'World\'s largest tech & startup show', + }, + { + id: 'token2049-dubai-2026', + title: 'TOKEN2049 Dubai 2026', + type: 'conference', + location: 'Dubai, UAE', + coords: { lat: 25.2048, lng: 55.2708, country: 'UAE', original: 'Dubai, UAE', virtual: false }, + startDate: '2026-04-29', + endDate: '2026-04-30', + url: 'https://www.token2049.com', + source: 'curated', + description: 'Premier crypto event in Dubai', + }, + { + id: 'collision-2026', + title: 'Collision 2026', + type: 'conference', + location: 'Toronto, Canada', + coords: { lat: 43.6532, lng: -79.3832, country: 'Canada', original: 'Toronto, Canada', virtual: false }, + startDate: '2026-06-22', + endDate: '2026-06-25', + url: 'https://collisionconf.com', + source: 'curated', + description: 'North America\'s fastest growing tech conference', + }, + { + id: 'web-summit-2026', + title: 'Web Summit 2026', + type: 'conference', + location: 'Lisbon, Portugal', + coords: { lat: 38.7223, lng: -9.1393, country: 'Portugal', original: 'Lisbon, Portugal', virtual: false }, + startDate: '2026-11-02', + endDate: '2026-11-05', + url: 'https://websummit.com', + source: 'curated', + description: 'The world\'s premier tech conference', + }, +]; + +// ---------- Geocoding ---------- + +function normalizeLocation(location: string | null): (TechEventCoords) | null { + if (!location) return null; + + // Clean up the location string + let normalized = location.toLowerCase().trim(); + + // Remove common suffixes/prefixes + normalized = normalized.replace(/^hybrid:\s*/i, ''); + normalized = normalized.replace(/,\s*(usa|us|uk|canada)$/i, ''); + + // Direct lookup + if (CITY_COORDS[normalized]) { + const c = CITY_COORDS[normalized]; + return { lat: c!.lat, lng: c!.lng, country: c!.country, original: location, virtual: c!.virtual ?? false }; + } + + // Try removing state/country suffix + const parts = normalized.split(','); + if (parts.length > 1) { + const city = parts[0]!.trim(); + if (CITY_COORDS[city]) { + const c = CITY_COORDS[city]!; + return { lat: c.lat, lng: c.lng, country: c.country, original: location, virtual: c.virtual ?? false }; + } + } + + // Try fuzzy match (contains) + for (const [key, coords] of Object.entries(CITY_COORDS)) { + if (normalized.includes(key) || key.includes(normalized)) { + return { lat: coords.lat, lng: coords.lng, country: coords.country, original: location, virtual: coords.virtual ?? false }; + } + } + + return null; +} + +// ---------- ICS Parser ---------- + +function parseICS(icsText: string): TechEvent[] { + const events: TechEvent[] = []; + const eventBlocks = icsText.split('BEGIN:VEVENT').slice(1); + + for (const block of eventBlocks) { + const summaryMatch = block.match(/SUMMARY:(.+)/); + const locationMatch = block.match(/LOCATION:(.+)/); + const dtstartMatch = block.match(/DTSTART;VALUE=DATE:(\d+)/); + const dtendMatch = block.match(/DTEND;VALUE=DATE:(\d+)/); + const urlMatch = block.match(/URL:(.+)/); + const uidMatch = block.match(/UID:(.+)/); + + if (summaryMatch && dtstartMatch) { + const summary = summaryMatch[1]!.trim(); + const location = locationMatch ? locationMatch[1]!.trim() : ''; + const startDate = dtstartMatch[1]!; + const endDate = dtendMatch ? dtendMatch[1]! : startDate; + const url = urlMatch ? urlMatch[1]!.trim() : ''; + const uid = uidMatch ? uidMatch[1]!.trim() : ''; + + // Determine event type + let type = 'other'; + if (summary.startsWith('Earnings:')) type = 'earnings'; + else if (summary.startsWith('IPO')) type = 'ipo'; + else if (location) type = 'conference'; + + // Parse coordinates if location exists + const coords = normalizeLocation(location || null); + + events.push({ + id: uid, + title: summary, + type, + location: location, + coords: coords ?? undefined, + startDate: `${startDate.slice(0, 4)}-${startDate.slice(4, 6)}-${startDate.slice(6, 8)}`, + endDate: `${endDate.slice(0, 4)}-${endDate.slice(4, 6)}-${endDate.slice(6, 8)}`, + url: url, + source: 'techmeme', + description: '', + }); + } + } + + return events.sort((a, b) => a.startDate.localeCompare(b.startDate)); +} + +// ---------- RSS Parser ---------- + +function parseDevEventsRSS(rssText: string): TechEvent[] { + const events: TechEvent[] = []; + + // Simple regex-based RSS parsing for edge runtime + const itemMatches = rssText.matchAll(/([\s\S]*?)<\/item>/g); + + for (const match of itemMatches) { + const item = match[1]!; + + const titleMatch = item.match(/<!\[CDATA\[(.*?)\]\]><\/title>|<title>(.*?)<\/title>/); + const linkMatch = item.match(/<link>(.*?)<\/link>/); + const descMatch = item.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>|<description>(.*?)<\/description>/s); + const guidMatch = item.match(/<guid[^>]*>(.*?)<\/guid>/); + + const title = titleMatch ? (titleMatch[1] ?? titleMatch[2]) : null; + const link = linkMatch ? linkMatch[1] ?? '' : ''; + const description = descMatch ? (descMatch[1] ?? descMatch[2] ?? '') : ''; + const guid = guidMatch ? guidMatch[1] ?? '' : ''; + + if (!title) continue; + + // Parse date from description: "EventName is happening on Month Day, Year" + const dateMatch = description.match(/on\s+(\w+\s+\d{1,2},?\s+\d{4})/i); + let startDate: string | null = null; + if (dateMatch) { + const parsed = new Date(dateMatch[1]!); + if (!isNaN(parsed.getTime())) { + startDate = parsed.toISOString().split('T')[0]!; + } + } + + // Parse location from description: various formats + let location: string | null = null; + const locationMatch = description.match(/(?:in|at)\s+([A-Za-z\s]+,\s*[A-Za-z\s]+)(?:\.|$)/i) || + description.match(/Location:\s*([^<\n]+)/i); + if (locationMatch) { + location = locationMatch[1]!.trim(); + } + // Check for "Online" events + if (description.toLowerCase().includes('online')) { + location = 'Online'; + } + + // Skip events without valid dates or in the past + if (!startDate) continue; + const eventDate = new Date(startDate); + const now = new Date(); + now.setHours(0, 0, 0, 0); + if (eventDate < now) continue; + + const coords = location && location !== 'Online' ? normalizeLocation(location) : null; + + events.push({ + id: guid || `dev-events-${title.slice(0, 20)}`, + title: title, + type: 'conference', + location: location || '', + coords: coords ?? (location === 'Online' ? { lat: 0, lng: 0, country: 'Virtual', original: 'Online', virtual: true } : undefined), + startDate: startDate, + endDate: startDate, // RSS doesn't have end date + url: link, + source: 'dev.events', + description: '', + }); + } + + return events; +} + +// ---------- Fetch ---------- + +async function fetchTechEvents(req: ListTechEventsRequest): Promise<ListTechEventsResponse> { + const { type, mappable, limit, days } = req; + + // Fetch both sources in parallel + const [icsResponse, rssResponse] = await Promise.allSettled([ + fetch(ICS_URL, { + headers: { 'User-Agent': CHROME_UA }, + }), + fetch(DEV_EVENTS_RSS, { + headers: { 'User-Agent': CHROME_UA }, + }), + ]); + + let events: TechEvent[] = []; + + // Parse Techmeme ICS + if (icsResponse.status === 'fulfilled' && icsResponse.value.ok) { + const icsText = await icsResponse.value.text(); + events.push(...parseICS(icsText)); + } else { + console.warn('Failed to fetch Techmeme ICS'); + } + + // Parse dev.events RSS + if (rssResponse.status === 'fulfilled' && rssResponse.value.ok) { + const rssText = await rssResponse.value.text(); + const devEvents = parseDevEventsRSS(rssText); + events.push(...devEvents); + } else { + console.warn('Failed to fetch dev.events RSS'); + } + + // Add curated events (major conferences that may fall off limited RSS feeds) + const now = new Date(); + now.setHours(0, 0, 0, 0); + for (const curated of CURATED_EVENTS) { + const eventDate = new Date(curated.startDate); + if (eventDate >= now) { + events.push(curated); + } + } + + // Deduplicate by title similarity (rough match) + const seen = new Set<string>(); + events = events.filter(e => { + const year = e.startDate.slice(0, 4); + const key = e.title.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 30) + year; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + // Sort by date + events.sort((a, b) => a.startDate.localeCompare(b.startDate)); + + // Filter by type if specified + if (type && type !== 'all') { + events = events.filter(e => e.type === type); + } + + // Filter to only mappable events if requested + if (mappable) { + events = events.filter(e => e.coords && !e.coords.virtual); + } + + // Filter by time range if specified + if (days > 0) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() + days); + events = events.filter(e => new Date(e.startDate) <= cutoff); + } + + // Apply limit if specified + if (limit > 0) { + events = events.slice(0, limit); + } + + // Add metadata + const conferences = events.filter(e => e.type === 'conference'); + const mappableCount = conferences.filter(e => e.coords && !e.coords.virtual).length; + + return { + success: true, + count: events.length, + conferenceCount: conferences.length, + mappableCount, + lastUpdated: new Date().toISOString(), + events, + error: '', + }; +} + +function applyLimit(res: ListTechEventsResponse, limit: number): ListTechEventsResponse { + const events = res.events.slice(0, limit); + const conferences = events.filter(e => e.type === 'conference'); + const mappableCount = conferences.filter(e => e.coords && !e.coords.virtual).length; + return { ...res, events, count: events.length, conferenceCount: conferences.length, mappableCount }; +} + +// ---------- Handler ---------- + +export async function listTechEvents( + _ctx: ServerContext, + req: ListTechEventsRequest, +): Promise<ListTechEventsResponse> { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.type || 'all'}:${req.mappable ? 1 : 0}:${req.days || 0}`; + const result = await cachedFetchJson<ListTechEventsResponse>(cacheKey, REDIS_CACHE_TTL, async () => { + const fetched = await fetchTechEvents({ ...req, limit: 0 }); + return fetched.events.length > 0 ? fetched : null; + }); + if (!result) { + return { success: true, count: 0, conferenceCount: 0, mappableCount: 0, lastUpdated: new Date().toISOString(), events: [], error: '' }; + } + if (req.limit > 0 && result.events.length > req.limit) { + return applyLimit(result, req.limit); + } + return result; + } catch (error) { + return { + success: false, + count: 0, + conferenceCount: 0, + mappableCount: 0, + lastUpdated: new Date().toISOString(), + events: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/server/worldmonitor/research/v1/list-trending-repos.ts b/server/worldmonitor/research/v1/list-trending-repos.ts new file mode 100644 index 000000000..7f76ae172 --- /dev/null +++ b/server/worldmonitor/research/v1/list-trending-repos.ts @@ -0,0 +1,85 @@ +/** + * RPC: listTrendingRepos + * + * Fetches trending GitHub repos from gitterapp JSON API with + * herokuapp fallback. Returns empty array on any failure. + */ + +import type { + ServerContext, + ListTrendingReposRequest, + ListTrendingReposResponse, + GithubRepo, +} from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:trending:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — daily trending data + +// ---------- Fetch ---------- + +async function fetchTrendingRepos(req: ListTrendingReposRequest): Promise<GithubRepo[]> { + const language = req.language || 'python'; + const period = req.period || 'daily'; + const pageSize = req.pagination?.pageSize || 50; + + // Primary API + const primaryUrl = `https://api.gitterapp.com/repositories?language=${language}&since=${period}`; + let data: any[]; + + try { + const response = await fetch(primaryUrl, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) throw new Error('Primary API failed'); + data = await response.json() as any[]; + } catch { + // Fallback API + try { + const fallbackUrl = `https://gh-trending-api.herokuapp.com/repositories/${language}?since=${period}`; + const fallbackResponse = await fetch(fallbackUrl, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10000), + }); + + if (!fallbackResponse.ok) return []; + data = await fallbackResponse.json() as any[]; + } catch { + return []; + } + } + + if (!Array.isArray(data)) return []; + + return data.slice(0, pageSize).map((raw: any): GithubRepo => ({ + fullName: `${raw.author}/${raw.name}`, + description: raw.description || '', + language: raw.language || '', + stars: raw.stars || 0, + starsToday: raw.currentPeriodStars || 0, + forks: raw.forks || 0, + url: raw.url || `https://github.com/${raw.author}/${raw.name}`, + })); +} + +// ---------- Handler ---------- + +export async function listTrendingRepos( + _ctx: ServerContext, + req: ListTrendingReposRequest, +): Promise<ListTrendingReposResponse> { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.language || 'python'}:${req.period || 'daily'}:${req.pagination?.pageSize || 50}`; + const result = await cachedFetchJson<ListTrendingReposResponse>(cacheKey, REDIS_CACHE_TTL, async () => { + const repos = await fetchTrendingRepos(req); + return repos.length > 0 ? { repos, pagination: undefined } : null; + }); + return result || { repos: [], pagination: undefined }; + } catch { + return { repos: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/seismology/v1/handler.ts b/server/worldmonitor/seismology/v1/handler.ts new file mode 100644 index 000000000..8498a996b --- /dev/null +++ b/server/worldmonitor/seismology/v1/handler.ts @@ -0,0 +1,7 @@ +import type { SeismologyServiceHandler } from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; + +import { listEarthquakes } from './list-earthquakes'; + +export const seismologyHandler: SeismologyServiceHandler = { + listEarthquakes, +}; diff --git a/server/worldmonitor/seismology/v1/list-earthquakes.ts b/server/worldmonitor/seismology/v1/list-earthquakes.ts new file mode 100644 index 000000000..3e8f12b22 --- /dev/null +++ b/server/worldmonitor/seismology/v1/list-earthquakes.ts @@ -0,0 +1,68 @@ +/** + * ListEarthquakes RPC -- proxies the USGS earthquake GeoJSON API. + * + * Fetches M4.5+ earthquakes from the last 24 hours and transforms the USGS + * GeoJSON features into proto-shaped Earthquake objects. + */ + +import type { + SeismologyServiceHandler, + ServerContext, + ListEarthquakesRequest, + ListEarthquakesResponse, +} from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; + +const USGS_FEED_URL = + 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson'; +const CACHE_KEY = 'seismology:earthquakes:v1'; +const CACHE_TTL = 300; // 5 minutes + +export const listEarthquakes: SeismologyServiceHandler['listEarthquakes'] = async ( + _ctx: ServerContext, + req: ListEarthquakesRequest, +): Promise<ListEarthquakesResponse> => { + const pageSize = req.pagination?.pageSize || 500; + + try { + // Cache stores full set, slice on read — avoids polluting cache with partial results + const cached = await cachedFetchJson<{ earthquakes: ListEarthquakesResponse['earthquakes'] }>(CACHE_KEY, CACHE_TTL, async () => { + const response = await fetch(USGS_FEED_URL, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + throw new Error(`USGS API error: ${response.status}`); + } + + const geojson: any = await response.json(); + const features: any[] = geojson.features || []; + + // Null-safe property access + const earthquakes = features + .filter((f: any) => f?.properties && f?.geometry?.coordinates) + .map((f: any) => ({ + id: (f.id as string) || '', + place: (f.properties?.place as string) || '', + magnitude: (f.properties?.mag as number) ?? 0, + depthKm: (f.geometry?.coordinates?.[2] as number) ?? 0, + location: { + latitude: (f.geometry?.coordinates?.[1] as number) ?? 0, + longitude: (f.geometry?.coordinates?.[0] as number) ?? 0, + }, + occurredAt: f.properties?.time ?? 0, + sourceUrl: (f.properties?.url as string) || '', + })); + + return earthquakes.length > 0 ? { earthquakes } : null; + }); + + const earthquakes = cached?.earthquakes || []; + return { earthquakes: earthquakes.slice(0, pageSize), pagination: undefined }; + } catch { + return { earthquakes: [], pagination: undefined }; + } +}; diff --git a/server/worldmonitor/supply-chain/v1/_minerals-data.ts b/server/worldmonitor/supply-chain/v1/_minerals-data.ts new file mode 100644 index 000000000..eae197d61 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/_minerals-data.ts @@ -0,0 +1,51 @@ +export interface MineralProductionEntry { + mineral: string; + country: string; + countryCode: string; + productionTonnes: number; + unit: string; +} + +export const MINERAL_PRODUCTION_2024: MineralProductionEntry[] = [ + // Lithium (tonnes LCE) + { mineral: 'Lithium', country: 'Australia', countryCode: 'AU', productionTonnes: 86000, unit: 'tonnes LCE' }, + { mineral: 'Lithium', country: 'Chile', countryCode: 'CL', productionTonnes: 44000, unit: 'tonnes LCE' }, + { mineral: 'Lithium', country: 'China', countryCode: 'CN', productionTonnes: 33000, unit: 'tonnes LCE' }, + { mineral: 'Lithium', country: 'Argentina', countryCode: 'AR', productionTonnes: 9600, unit: 'tonnes LCE' }, + + // Cobalt (tonnes) + { mineral: 'Cobalt', country: 'DRC', countryCode: 'CD', productionTonnes: 130000, unit: 'tonnes' }, + { mineral: 'Cobalt', country: 'Indonesia', countryCode: 'ID', productionTonnes: 17000, unit: 'tonnes' }, + { mineral: 'Cobalt', country: 'Russia', countryCode: 'RU', productionTonnes: 8900, unit: 'tonnes' }, + { mineral: 'Cobalt', country: 'Australia', countryCode: 'AU', productionTonnes: 5600, unit: 'tonnes' }, + + // Rare Earths (tonnes REO) + { mineral: 'Rare Earths', country: 'China', countryCode: 'CN', productionTonnes: 240000, unit: 'tonnes REO' }, + { mineral: 'Rare Earths', country: 'Myanmar', countryCode: 'MM', productionTonnes: 38000, unit: 'tonnes REO' }, + { mineral: 'Rare Earths', country: 'USA', countryCode: 'US', productionTonnes: 43000, unit: 'tonnes REO' }, + { mineral: 'Rare Earths', country: 'Australia', countryCode: 'AU', productionTonnes: 18000, unit: 'tonnes REO' }, + + // Nickel (tonnes) + { mineral: 'Nickel', country: 'Indonesia', countryCode: 'ID', productionTonnes: 1800000, unit: 'tonnes' }, + { mineral: 'Nickel', country: 'Philippines', countryCode: 'PH', productionTonnes: 330000, unit: 'tonnes' }, + { mineral: 'Nickel', country: 'Russia', countryCode: 'RU', productionTonnes: 220000, unit: 'tonnes' }, + { mineral: 'Nickel', country: 'New Caledonia', countryCode: 'NC', productionTonnes: 190000, unit: 'tonnes' }, + + // Copper (tonnes) + { mineral: 'Copper', country: 'Chile', countryCode: 'CL', productionTonnes: 5300000, unit: 'tonnes' }, + { mineral: 'Copper', country: 'DRC', countryCode: 'CD', productionTonnes: 2500000, unit: 'tonnes' }, + { mineral: 'Copper', country: 'Peru', countryCode: 'PE', productionTonnes: 2200000, unit: 'tonnes' }, + { mineral: 'Copper', country: 'China', countryCode: 'CN', productionTonnes: 1900000, unit: 'tonnes' }, + + // Gallium (tonnes) + { mineral: 'Gallium', country: 'China', countryCode: 'CN', productionTonnes: 600, unit: 'tonnes' }, + { mineral: 'Gallium', country: 'Japan', countryCode: 'JP', productionTonnes: 10, unit: 'tonnes' }, + { mineral: 'Gallium', country: 'South Korea', countryCode: 'KR', productionTonnes: 8, unit: 'tonnes' }, + { mineral: 'Gallium', country: 'Russia', countryCode: 'RU', productionTonnes: 5, unit: 'tonnes' }, + + // Germanium (tonnes) + { mineral: 'Germanium', country: 'China', countryCode: 'CN', productionTonnes: 95, unit: 'tonnes' }, + { mineral: 'Germanium', country: 'Belgium', countryCode: 'BE', productionTonnes: 15, unit: 'tonnes' }, + { mineral: 'Germanium', country: 'Canada', countryCode: 'CA', productionTonnes: 9, unit: 'tonnes' }, + { mineral: 'Germanium', country: 'Russia', countryCode: 'RU', productionTonnes: 5, unit: 'tonnes' }, +]; diff --git a/server/worldmonitor/supply-chain/v1/_scoring.mjs b/server/worldmonitor/supply-chain/v1/_scoring.mjs new file mode 100644 index 000000000..8b2518389 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/_scoring.mjs @@ -0,0 +1,39 @@ +export const SEVERITY_SCORE = { + 'AIS_DISRUPTION_SEVERITY_LOW': 1, + 'AIS_DISRUPTION_SEVERITY_ELEVATED': 2, + 'AIS_DISRUPTION_SEVERITY_HIGH': 3, +}; + +export function computeDisruptionScore(warningCount, congestionSeverity) { + return Math.min(100, warningCount * 15 + congestionSeverity * 30); +} + +export function scoreToStatus(score) { + if (score < 20) return 'green'; + if (score < 50) return 'yellow'; + return 'red'; +} + +export function computeHHI(shares) { + if (!shares || shares.length === 0) return 0; + return shares.reduce((sum, s) => sum + s * s, 0); +} + +export function riskRating(hhi) { + if (hhi >= 5000) return 'critical'; + if (hhi >= 2500) return 'high'; + if (hhi >= 1500) return 'moderate'; + return 'low'; +} + +export function detectSpike(history) { + if (!history || history.length < 3) return false; + const values = history.map(h => typeof h === 'number' ? h : h.value).filter(v => Number.isFinite(v)); + if (values.length < 3) return false; + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length; + const stdDev = Math.sqrt(variance); + if (stdDev === 0) return false; + const latest = values[values.length - 1]; + return latest > mean + 2 * stdDev; +} diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts new file mode 100644 index 000000000..425d9907e --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts @@ -0,0 +1,125 @@ +import type { + ServerContext, + GetChokepointStatusRequest, + GetChokepointStatusResponse, + ChokepointInfo, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import type { + ListNavigationalWarningsResponse, + GetVesselSnapshotResponse, + AisDisruption, +} from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { listNavigationalWarnings } from '../../maritime/v1/list-navigational-warnings'; +import { getVesselSnapshot } from '../../maritime/v1/get-vessel-snapshot'; +// @ts-expect-error — .mjs module, no declaration file +import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE } from './_scoring.mjs'; + +const REDIS_CACHE_KEY = 'supply_chain:chokepoints:v1'; +const REDIS_CACHE_TTL = 300; + +interface ChokepointConfig { + id: string; + name: string; + lat: number; + lon: number; + areaKeywords: string[]; + routes: string[]; +} + +const CHOKEPOINTS: ChokepointConfig[] = [ + { id: 'suez', name: 'Suez Canal', lat: 30.45, lon: 32.35, areaKeywords: ['suez', 'red sea'], routes: ['China-Europe (Suez)', 'Gulf-Europe Oil', 'Qatar LNG-Europe'] }, + { id: 'malacca', name: 'Malacca Strait', lat: 1.43, lon: 103.5, areaKeywords: ['malacca', 'singapore strait'], routes: ['China-Middle East Oil', 'China-Europe (via Suez)', 'Japan-Middle East Oil'] }, + { id: 'hormuz', name: 'Strait of Hormuz', lat: 26.56, lon: 56.25, areaKeywords: ['hormuz', 'persian gulf', 'arabian gulf'], routes: ['Gulf Oil Exports', 'Qatar LNG', 'Iran Exports'] }, + { id: 'bab_el_mandeb', name: 'Bab el-Mandeb', lat: 12.58, lon: 43.33, areaKeywords: ['bab el-mandeb', 'bab al-mandab', 'mandeb', 'aden'], routes: ['Suez-Indian Ocean', 'Gulf-Europe Oil', 'Red Sea Transit'] }, + { id: 'panama', name: 'Panama Canal', lat: 9.08, lon: -79.68, areaKeywords: ['panama'], routes: ['US East Coast-Asia', 'US East Coast-South America', 'Atlantic-Pacific Bulk'] }, + { id: 'taiwan', name: 'Taiwan Strait', lat: 24.0, lon: 119.5, areaKeywords: ['taiwan strait', 'formosa'], routes: ['China-Japan Trade', 'Korea-Southeast Asia', 'Pacific Semiconductor'] }, +]; + +function makeInternalCtx(): { request: Request; pathParams: Record<string, string>; headers: Record<string, string> } { + return { request: new Request('http://internal'), pathParams: {}, headers: {} }; +} + +interface ChokepointFetchResult { + chokepoints: ChokepointInfo[]; + upstreamUnavailable: boolean; +} + +async function fetchChokepointData(): Promise<ChokepointFetchResult> { + const ctx = makeInternalCtx(); + + let navFailed = false; + let vesselFailed = false; + + const [navResult, vesselResult] = await Promise.all([ + listNavigationalWarnings(ctx, { area: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }), + getVesselSnapshot(ctx, {}).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }), + ]); + + const warnings = navResult.warnings || []; + const disruptions: AisDisruption[] = vesselResult.snapshot?.disruptions || []; + const upstreamUnavailable = (navFailed && vesselFailed) || (navFailed && disruptions.length === 0) || (vesselFailed && warnings.length === 0); + + const chokepoints = CHOKEPOINTS.map((cp): ChokepointInfo => { + const matchedWarnings = warnings.filter(w => + cp.areaKeywords.some(kw => w.text.toLowerCase().includes(kw) || w.area.toLowerCase().includes(kw)) + ); + + const matchedDisruptions = disruptions.filter(d => + d.type === 'AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION' && + cp.areaKeywords.some(kw => d.region.toLowerCase().includes(kw) || d.name.toLowerCase().includes(kw)) + ); + + const maxSeverity = matchedDisruptions.reduce((max, d) => { + const score = (SEVERITY_SCORE as Record<string, number>)[d.severity] ?? 0; + return Math.max(max, score); + }, 0); + + const disruptionScore = computeDisruptionScore(matchedWarnings.length, maxSeverity); + const status = scoreToStatus(disruptionScore); + + const congestionLevel = maxSeverity >= 3 ? 'high' : maxSeverity >= 2 ? 'elevated' : maxSeverity >= 1 ? 'low' : 'normal'; + + const descriptions: string[] = []; + if (matchedWarnings.length > 0) descriptions.push(`${matchedWarnings.length} active navigational warning(s)`); + if (matchedDisruptions.length > 0) descriptions.push(`AIS congestion detected`); + if (descriptions.length === 0) descriptions.push('No active disruptions'); + + return { + id: cp.id, + name: cp.name, + lat: cp.lat, + lon: cp.lon, + disruptionScore, + status, + activeWarnings: matchedWarnings.length, + congestionLevel, + affectedRoutes: cp.routes, + description: descriptions.join('; '), + }; + }); + + return { chokepoints, upstreamUnavailable }; +} + +export async function getChokepointStatus( + _ctx: ServerContext, + _req: GetChokepointStatusRequest, +): Promise<GetChokepointStatusResponse> { + try { + const result = await cachedFetchJson<GetChokepointStatusResponse>( + REDIS_CACHE_KEY, + REDIS_CACHE_TTL, + async () => { + const { chokepoints, upstreamUnavailable } = await fetchChokepointData(); + return { chokepoints, fetchedAt: new Date().toISOString(), upstreamUnavailable }; + }, + ); + + return result ?? { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } +} diff --git a/server/worldmonitor/supply-chain/v1/get-critical-minerals.ts b/server/worldmonitor/supply-chain/v1/get-critical-minerals.ts new file mode 100644 index 000000000..e87ad24c0 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-critical-minerals.ts @@ -0,0 +1,75 @@ +import type { + ServerContext, + GetCriticalMineralsRequest, + GetCriticalMineralsResponse, + CriticalMineral, + MineralProducer, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { MINERAL_PRODUCTION_2024 } from './_minerals-data'; +// @ts-expect-error — .mjs module, no declaration file +import { computeHHI, riskRating } from './_scoring.mjs'; + +const REDIS_CACHE_KEY = 'supply_chain:minerals:v1'; +const REDIS_CACHE_TTL = 86400; + +function buildMineralsData(): CriticalMineral[] { + const byMineral = new Map<string, typeof MINERAL_PRODUCTION_2024>(); + for (const entry of MINERAL_PRODUCTION_2024) { + const existing = byMineral.get(entry.mineral) || []; + existing.push(entry); + byMineral.set(entry.mineral, existing); + } + + const minerals: CriticalMineral[] = []; + + for (const [mineral, entries] of byMineral) { + const globalProduction = entries.reduce((sum, e) => sum + e.productionTonnes, 0); + const unit = entries[0]?.unit || 'tonnes'; + + const producers: MineralProducer[] = entries + .sort((a, b) => b.productionTonnes - a.productionTonnes) + .slice(0, 5) + .map(e => ({ + country: e.country, + countryCode: e.countryCode, + productionTonnes: e.productionTonnes, + sharePct: globalProduction > 0 ? (e.productionTonnes / globalProduction) * 100 : 0, + })); + + const shares = entries.map(e => globalProduction > 0 ? (e.productionTonnes / globalProduction) * 100 : 0); + const hhi = computeHHI(shares); + + minerals.push({ + mineral, + topProducers: producers, + hhi, + riskRating: riskRating(hhi), + globalProduction, + unit, + }); + } + + return minerals.sort((a, b) => b.hhi - a.hhi); +} + +export async function getCriticalMinerals( + _ctx: ServerContext, + _req: GetCriticalMineralsRequest, +): Promise<GetCriticalMineralsResponse> { + try { + const result = await cachedFetchJson<GetCriticalMineralsResponse>( + REDIS_CACHE_KEY, + REDIS_CACHE_TTL, + async () => { + const minerals = buildMineralsData(); + return { minerals, fetchedAt: new Date().toISOString(), upstreamUnavailable: false }; + }, + ); + + return result ?? { minerals: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { minerals: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } +} diff --git a/server/worldmonitor/supply-chain/v1/get-shipping-rates.ts b/server/worldmonitor/supply-chain/v1/get-shipping-rates.ts new file mode 100644 index 000000000..7fd288b38 --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/get-shipping-rates.ts @@ -0,0 +1,106 @@ +declare const process: { env: Record<string, string | undefined> }; + +import type { + ServerContext, + GetShippingRatesRequest, + GetShippingRatesResponse, + ShippingIndex, + ShippingRatePoint, +} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; +// @ts-expect-error — .mjs module, no declaration file +import { detectSpike } from './_scoring.mjs'; + +const FRED_API_BASE = 'https://api.stlouisfed.org/fred'; +const REDIS_CACHE_KEY = 'supply_chain:shipping:v2'; +const REDIS_CACHE_TTL = 3600; + +interface FredSeriesConfig { + seriesId: string; + name: string; + unit: string; + frequency: string; +} + +const SHIPPING_SERIES: FredSeriesConfig[] = [ + { seriesId: 'PCU483111483111', name: 'Deep Sea Freight PPI', unit: 'index', frequency: 'm' }, + { seriesId: 'TSIFRGHT', name: 'Freight Transportation Index', unit: 'index', frequency: 'm' }, +]; + +async function fetchFredSeries(cfg: FredSeriesConfig): Promise<ShippingIndex | null> { + const apiKey = process.env.FRED_API_KEY; + if (!apiKey) return null; + + try { + const params = new URLSearchParams({ + series_id: cfg.seriesId, + api_key: apiKey, + file_type: 'json', + frequency: cfg.frequency, + sort_order: 'desc', + limit: '24', + }); + + const response = await fetch(`${FRED_API_BASE}/series/observations?${params}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return null; + + const data = await response.json() as { observations?: Array<{ date: string; value: string }> }; + const observations = (data.observations || []) + .map((obs): ShippingRatePoint | null => { + const value = parseFloat(obs.value); + if (isNaN(value) || obs.value === '.') return null; + return { date: obs.date, value }; + }) + .filter((o): o is ShippingRatePoint => o !== null) + .reverse(); + + if (observations.length === 0) return null; + + const currentValue = observations[observations.length - 1]!.value; + const previousValue = observations.length > 1 ? observations[observations.length - 2]!.value : currentValue; + const changePct = previousValue !== 0 ? ((currentValue - previousValue) / previousValue) * 100 : 0; + + const spikeAlert = detectSpike(observations); + + return { + indexId: cfg.seriesId, + name: cfg.name, + currentValue, + previousValue, + changePct, + unit: cfg.unit, + history: observations, + spikeAlert, + }; + } catch { + return null; + } +} + +export async function getShippingRates( + _ctx: ServerContext, + _req: GetShippingRatesRequest, +): Promise<GetShippingRatesResponse> { + try { + const result = await cachedFetchJson<GetShippingRatesResponse>( + REDIS_CACHE_KEY, + REDIS_CACHE_TTL, + async () => { + const results = await Promise.all(SHIPPING_SERIES.map(fetchFredSeries)); + const indices = results.filter((r): r is ShippingIndex => r !== null); + if (indices.length === 0) return null; + return { indices, fetchedAt: new Date().toISOString(), upstreamUnavailable: false }; + }, + ); + + return result ?? { indices: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { indices: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } +} diff --git a/server/worldmonitor/supply-chain/v1/handler.ts b/server/worldmonitor/supply-chain/v1/handler.ts new file mode 100644 index 000000000..86f89baad --- /dev/null +++ b/server/worldmonitor/supply-chain/v1/handler.ts @@ -0,0 +1,11 @@ +import type { SupplyChainServiceHandler } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + +import { getShippingRates } from './get-shipping-rates'; +import { getChokepointStatus } from './get-chokepoint-status'; +import { getCriticalMinerals } from './get-critical-minerals'; + +export const supplyChainHandler: SupplyChainServiceHandler = { + getShippingRates, + getChokepointStatus, + getCriticalMinerals, +}; diff --git a/server/worldmonitor/trade/v1/_shared.ts b/server/worldmonitor/trade/v1/_shared.ts new file mode 100644 index 000000000..24fd3997e --- /dev/null +++ b/server/worldmonitor/trade/v1/_shared.ts @@ -0,0 +1,86 @@ +/** + * Shared helpers for the trade domain RPCs. + * WTO Timeseries API integration. + */ + +declare const process: { env: Record<string, string | undefined> }; + +import { CHROME_UA } from '../../../_shared/constants'; + +/** WTO Timeseries API base URL. */ +export const WTO_API_BASE = 'https://api.wto.org/timeseries/v1'; + +/** Merchandise exports (total) — annual. */ +export const ITS_MTV_AX = 'ITS_MTV_AX'; +/** Merchandise imports (total) — annual. */ +export const ITS_MTV_AM = 'ITS_MTV_AM'; +/** Simple average MFN applied tariff — all products. */ +export const TP_A_0010 = 'TP_A_0010'; + +/** + * WTO member numeric codes → human-readable names. + */ +export const WTO_MEMBER_CODES: Record<string, string> = { + '840': 'United States', + '156': 'China', + '276': 'Germany', + '392': 'Japan', + '826': 'United Kingdom', + '250': 'France', + '356': 'India', + '643': 'Russia', + '076': 'Brazil', + '410': 'South Korea', + '036': 'Australia', + '124': 'Canada', + '484': 'Mexico', + '380': 'Italy', + '528': 'Netherlands', + '000': 'World', +}; + +/** + * Fetch JSON from the WTO Timeseries API. + * Returns parsed JSON on success, or null if the API key is missing or the request fails. + * + * IMPORTANT: The WTO API does NOT support comma-separated indicator codes in the `i` param. + * Each indicator must be queried separately. + */ +export async function wtoFetch( + path: string, + params?: Record<string, string>, +): Promise<any | null> { + const apiKey = process.env.WTO_API_KEY; + if (!apiKey) { + console.warn('[WTO] WTO_API_KEY not set in process.env'); + return null; + } + + try { + const url = new URL(`${WTO_API_BASE}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + + const res = await fetch(url.toString(), { + headers: { + 'Ocp-Apim-Subscription-Key': apiKey, + 'User-Agent': CHROME_UA, + }, + signal: AbortSignal.timeout(15000), + }); + + // 204 = No Content (valid query, no matching data) + if (res.status === 204) return { Dataset: [] }; + if (!res.ok) { + console.warn(`[WTO] HTTP ${res.status} for ${path}`); + return null; + } + return await res.json(); + } catch (e) { + console.error('[WTO] Fetch error:', e instanceof Error ? e.message : e); + return null; + } +} diff --git a/server/worldmonitor/trade/v1/get-tariff-trends.ts b/server/worldmonitor/trade/v1/get-tariff-trends.ts new file mode 100644 index 000000000..a3a043e4a --- /dev/null +++ b/server/worldmonitor/trade/v1/get-tariff-trends.ts @@ -0,0 +1,113 @@ +/** + * RPC: getTariffTrends -- WTO applied tariff trend data + * Fetches MFN simple average applied tariff rates over time. + * + * NOTE: Tariff indicators (TP_A_*) do NOT have a partner dimension. + * The `partnerCountry` request field is accepted but not sent to the API. + */ + +declare const process: { env: Record<string, string | undefined> }; + +import type { + ServerContext, + GetTariffTrendsRequest, + GetTariffTrendsResponse, + TariffDataPoint, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES, TP_A_0010 } from './_shared'; + +const REDIS_CACHE_TTL = 21600; // 6h — WTO data is annual, rarely changes + +/** + * Validate a country/sector code string — alphanumeric, max 10 chars. + */ +function isValidCode(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +/** + * Transform a raw WTO data row into a TariffDataPoint. + */ +function toDataPoint(row: any, reporter: string, partner: string): TariffDataPoint | null { + if (!row) return null; + const year = parseInt(row.Year ?? row.year ?? row.Period ?? '', 10); + const tariffRate = parseFloat(row.Value ?? row.value ?? ''); + if (isNaN(year) || isNaN(tariffRate)) return null; + + return { + reportingCountry: + WTO_MEMBER_CODES[reporter] ?? String(row.ReportingEconomy ?? row.reportingEconomy ?? reporter), + partnerCountry: + WTO_MEMBER_CODES[partner] ?? String(row.PartnerEconomy ?? row.partnerEconomy ?? (partner || 'World')), + productSector: String(row.ProductOrSector ?? row.productOrSector ?? 'All products'), + year, + tariffRate: Math.round(tariffRate * 100) / 100, + boundRate: parseFloat(row.BoundRate ?? row.boundRate ?? '0') || 0, + indicatorCode: String(row.IndicatorCode ?? row.indicatorCode ?? TP_A_0010), + }; +} + +async function fetchTariffTrends( + reporter: string, + partner: string, + _productSector: string, + years: number, +): Promise<{ datapoints: TariffDataPoint[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + const startYear = currentYear - years; + + // Tariff indicators do NOT support the partner (p) parameter. + const params: Record<string, string> = { + i: TP_A_0010, + r: reporter, + ps: `${startYear}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }; + + const data = await wtoFetch('/data', params); + if (!data) return { datapoints: [], ok: false }; + + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const datapoints = dataset + .map((row) => toDataPoint(row, reporter, partner || '000')) + .filter((d): d is TariffDataPoint => d !== null) + .sort((a, b) => a.year - b.year); + + return { datapoints, ok: true }; +} + +export async function getTariffTrends( + _ctx: ServerContext, + req: GetTariffTrendsRequest, +): Promise<GetTariffTrendsResponse> { + try { + // Input validation + const reporter = isValidCode(req.reportingCountry) ? req.reportingCountry : '840'; + const partner = isValidCode(req.partnerCountry) ? req.partnerCountry : '000'; + const productSector = isValidCode(req.productSector) ? req.productSector : ''; + const years = Math.max(1, Math.min(req.years > 0 ? req.years : 10, 30)); + + const cacheKey = `trade:tariffs:v1:${reporter}:${productSector || 'all'}:${years}`; + const result = await cachedFetchJson<GetTariffTrendsResponse>( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const { datapoints, ok } = await fetchTariffTrends(reporter, partner, productSector, years); + if (!ok || datapoints.length === 0) return null; + return { datapoints, fetchedAt: new Date().toISOString(), upstreamUnavailable: false }; + }, + ); + + return result ?? { datapoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { + datapoints: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/get-trade-barriers.ts b/server/worldmonitor/trade/v1/get-trade-barriers.ts new file mode 100644 index 000000000..b9d42b601 --- /dev/null +++ b/server/worldmonitor/trade/v1/get-trade-barriers.ts @@ -0,0 +1,179 @@ +/** + * RPC: getTradeBarriers -- WTO tariff barrier analysis + * + * Shows agricultural vs non-agricultural tariff gap and maximum duty rates + * as indicators of sector-specific trade barriers. + * + * NOTE: The WTO ePing API (SPS/TBT notifications) is a separate subscription product. + * This handler uses Timeseries API tariff data to surface sector-level trade barriers. + */ + +declare const process: { env: Record<string, string | undefined> }; + +import type { + ServerContext, + GetTradeBarriersRequest, + GetTradeBarriersResponse, + TradeBarrier, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES } from './_shared'; + +const REDIS_CACHE_TTL = 21600; // 6h — WTO data is annual, rarely changes + +/** Major economies to query. */ +const MAJOR_REPORTERS = ['840', '156', '276', '392', '826', '356', '076', '643', '410', '036', '124', '484', '250', '380', '528']; + +/** + * Validate a country code string — alphanumeric, max 10 chars. + */ +function isValidCountry(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +interface TariffRow { + country: string; + countryCode: string; + indicator: string; + year: number; + value: number; +} + +function parseRows(data: any): TariffRow[] { + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const rows: TariffRow[] = []; + + for (const row of dataset) { + const year = parseInt(row.Year ?? row.year ?? '0', 10); + const value = parseFloat(row.Value ?? row.value ?? ''); + if (isNaN(year) || isNaN(value)) continue; + + const countryCode = String(row.ReportingEconomyCode ?? ''); + rows.push({ + country: WTO_MEMBER_CODES[countryCode] ?? String(row.ReportingEconomy ?? ''), + countryCode, + indicator: String(row.IndicatorCode ?? ''), + year, + value, + }); + } + + return rows; +} + +async function fetchBarriers( + _countries: string[], + limit: number, +): Promise<{ barriers: TradeBarrier[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + const reporters = MAJOR_REPORTERS.join(','); + + // Fetch agricultural and non-agricultural tariffs in parallel + const [agriData, nonAgriData] = await Promise.all([ + wtoFetch('/data', { + i: 'TP_A_0160', + r: reporters, + ps: `${currentYear - 3}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }), + wtoFetch('/data', { + i: 'TP_A_0430', + r: reporters, + ps: `${currentYear - 3}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }), + ]); + + if (!agriData && !nonAgriData) return { barriers: [], ok: false }; + + const agriRows = agriData ? parseRows(agriData) : []; + const nonAgriRows = nonAgriData ? parseRows(nonAgriData) : []; + + // Get most recent year per country for each indicator + const latestAgri = new Map<string, TariffRow>(); + for (const row of agriRows) { + const existing = latestAgri.get(row.countryCode); + if (!existing || row.year > existing.year) { + latestAgri.set(row.countryCode, row); + } + } + + const latestNonAgri = new Map<string, TariffRow>(); + for (const row of nonAgriRows) { + const existing = latestNonAgri.get(row.countryCode); + if (!existing || row.year > existing.year) { + latestNonAgri.set(row.countryCode, row); + } + } + + // Build barriers: show countries where agricultural tariffs significantly exceed non-agricultural + const barriers: TradeBarrier[] = []; + const allCodes = new Set([...latestAgri.keys(), ...latestNonAgri.keys()]); + + for (const code of allCodes) { + const agri = latestAgri.get(code); + const nonAgri = latestNonAgri.get(code); + if (!agri && !nonAgri) continue; + + const agriRate = agri?.value ?? 0; + const nonAgriRate = nonAgri?.value ?? 0; + const gap = agriRate - nonAgriRate; + const country = agri?.country ?? nonAgri?.country ?? code; + const year = String(agri?.year ?? nonAgri?.year ?? ''); + + barriers.push({ + id: `${code}-tariff-gap-${year}`, + notifyingCountry: country, + title: `Agricultural tariff: ${agriRate.toFixed(1)}% vs Non-agricultural: ${nonAgriRate.toFixed(1)}% (gap: ${gap > 0 ? '+' : ''}${gap.toFixed(1)}pp)`, + measureType: gap > 10 ? 'High agricultural protection' : gap > 5 ? 'Moderate agricultural protection' : 'Low tariff gap', + productDescription: 'Agricultural vs Non-agricultural products', + objective: gap > 0 ? 'Agricultural sector protection' : 'Uniform tariff structure', + status: gap > 10 ? 'high' : gap > 5 ? 'moderate' : 'low', + dateDistributed: year, + sourceUrl: 'https://stats.wto.org', + }); + } + + // Sort by gap (highest agricultural protection first) + barriers.sort((a, b) => { + const gapA = parseFloat(a.title.match(/gap: ([+-]?\d+\.?\d*)/)?.[1] ?? '0'); + const gapB = parseFloat(b.title.match(/gap: ([+-]?\d+\.?\d*)/)?.[1] ?? '0'); + return gapB - gapA; + }); + + return { barriers: barriers.slice(0, limit), ok: true }; +} + +export async function getTradeBarriers( + _ctx: ServerContext, + req: GetTradeBarriersRequest, +): Promise<GetTradeBarriersResponse> { + try { + const countries = (req.countries ?? []).filter(isValidCountry); + const limit = Math.max(1, Math.min(req.limit > 0 ? req.limit : 50, 100)); + + const cacheKey = `trade:barriers:v1:tariff-gap:${limit}`; + const result = await cachedFetchJson<GetTradeBarriersResponse>( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const { barriers, ok } = await fetchBarriers(countries, limit); + if (!ok || barriers.length === 0) return null; + return { barriers, fetchedAt: new Date().toISOString(), upstreamUnavailable: false }; + }, + ); + + return result ?? { barriers: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { + barriers: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/get-trade-flows.ts b/server/worldmonitor/trade/v1/get-trade-flows.ts new file mode 100644 index 000000000..72190ef7e --- /dev/null +++ b/server/worldmonitor/trade/v1/get-trade-flows.ts @@ -0,0 +1,176 @@ +/** + * RPC: getTradeFlows -- WTO merchandise trade flow data + * Fetches bilateral export/import values and computes YoY changes. + * + * NOTE: The WTO API does NOT support comma-separated indicator codes. + * Exports and imports must be fetched in separate requests. + */ + +declare const process: { env: Record<string, string | undefined> }; + +import type { + ServerContext, + GetTradeFlowsRequest, + GetTradeFlowsResponse, + TradeFlowRecord, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES, ITS_MTV_AX, ITS_MTV_AM } from './_shared'; + +const REDIS_CACHE_TTL = 21600; // 6h — WTO data is annual, rarely changes + +/** + * Validate a country code string — alphanumeric, max 10 chars. + */ +function isValidCode(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +interface RawFlowRow { + year: number; + indicator: string; + value: number; +} + +/** + * Parse raw WTO rows into a flat list of { year, indicator, value }. + */ +function parseRows(data: any, indicator: string): RawFlowRow[] { + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const rows: RawFlowRow[] = []; + + for (const row of dataset) { + const year = parseInt(row.Year ?? row.year ?? row.Period ?? '', 10); + const value = parseFloat(row.Value ?? row.value ?? ''); + if (!isNaN(year) && !isNaN(value)) { + rows.push({ year, indicator, value }); + } + } + + return rows; +} + +/** + * Build trade flow records from export + import rows, computing YoY changes. + */ +function buildFlowRecords( + rows: RawFlowRow[], + reporter: string, + partner: string, +): TradeFlowRecord[] { + // Group by year + const byYear = new Map<number, { exports: number; imports: number }>(); + + for (const row of rows) { + if (!byYear.has(row.year)) { + byYear.set(row.year, { exports: 0, imports: 0 }); + } + const entry = byYear.get(row.year)!; + if (row.indicator === ITS_MTV_AX) { + entry.exports = row.value; + } else if (row.indicator === ITS_MTV_AM) { + entry.imports = row.value; + } + } + + // Sort by year ascending + const sortedYears = Array.from(byYear.keys()).sort((a, b) => a - b); + + const records: TradeFlowRecord[] = []; + for (let i = 0; i < sortedYears.length; i++) { + const year = sortedYears[i]!; + const current = byYear.get(year)!; + const prev = i > 0 ? byYear.get(sortedYears[i - 1]!) : null; + + let yoyExportChange = 0; + let yoyImportChange = 0; + + if (prev && prev.exports > 0) { + yoyExportChange = Math.round(((current.exports - prev.exports) / prev.exports) * 10000) / 100; + } + if (prev && prev.imports > 0) { + yoyImportChange = Math.round(((current.imports - prev.imports) / prev.imports) * 10000) / 100; + } + + records.push({ + reportingCountry: WTO_MEMBER_CODES[reporter] ?? reporter, + partnerCountry: WTO_MEMBER_CODES[partner] ?? partner, + year, + exportValueUsd: current.exports, + importValueUsd: current.imports, + yoyExportChange, + yoyImportChange, + productSector: 'Total merchandise', + }); + } + + return records; +} + +async function fetchTradeFlows( + reporter: string, + partner: string, + years: number, +): Promise<{ flows: TradeFlowRecord[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + const startYear = currentYear - years; + + const baseParams: Record<string, string> = { + r: reporter, + p: partner || '000', + ps: `${startYear}-${currentYear}`, + pc: 'TO', + fmt: 'json', + mode: 'full', + max: '500', + }; + + // Fetch exports and imports in parallel (separate requests — WTO API doesn't support comma-separated indicators) + const [exportsData, importsData] = await Promise.all([ + wtoFetch('/data', { ...baseParams, i: ITS_MTV_AX }), + wtoFetch('/data', { ...baseParams, i: ITS_MTV_AM }), + ]); + + if (!exportsData && !importsData) return { flows: [], ok: false }; + + const rows: RawFlowRow[] = [ + ...(exportsData ? parseRows(exportsData, ITS_MTV_AX) : []), + ...(importsData ? parseRows(importsData, ITS_MTV_AM) : []), + ]; + + const flows = buildFlowRecords(rows, reporter, partner || '000'); + + return { flows, ok: true }; +} + +export async function getTradeFlows( + _ctx: ServerContext, + req: GetTradeFlowsRequest, +): Promise<GetTradeFlowsResponse> { + try { + // Input validation + const reporter = isValidCode(req.reportingCountry) ? req.reportingCountry : '840'; + const partner = isValidCode(req.partnerCountry) ? req.partnerCountry : '000'; + const years = Math.max(1, Math.min(req.years > 0 ? req.years : 10, 30)); + + const cacheKey = `trade:flows:v1:${reporter}:${partner}:${years}`; + const result = await cachedFetchJson<GetTradeFlowsResponse>( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const { flows, ok } = await fetchTradeFlows(reporter, partner, years); + if (!ok || flows.length === 0) return null; + return { flows, fetchedAt: new Date().toISOString(), upstreamUnavailable: false }; + }, + ); + + return result ?? { flows: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { + flows: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/get-trade-restrictions.ts b/server/worldmonitor/trade/v1/get-trade-restrictions.ts new file mode 100644 index 000000000..11ca6bd7b --- /dev/null +++ b/server/worldmonitor/trade/v1/get-trade-restrictions.ts @@ -0,0 +1,128 @@ +/** + * RPC: getTradeRestrictions -- WTO tariff-based trade restriction overview + * + * Shows countries with highest applied tariff rates as a proxy for trade restrictiveness. + * Uses MFN simple average tariffs across all products, agricultural, and non-agricultural sectors. + * + * NOTE: The WTO Quantitative Restrictions (QR) API is a separate subscription product. + * This handler uses the Timeseries API tariff data as an available proxy for trade barriers. + */ + +declare const process: { env: Record<string, string | undefined> }; + +import type { + ServerContext, + GetTradeRestrictionsRequest, + GetTradeRestrictionsResponse, + TradeRestriction, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES } from './_shared'; + +const REDIS_CACHE_KEY = 'trade:restrictions:v1'; +const REDIS_CACHE_TTL = 21600; // 6h — WTO data is annual, rarely changes + +/** Major economies to query for tariff data. */ +const MAJOR_REPORTERS = ['840', '156', '276', '392', '826', '356', '076', '643', '410', '036', '124', '484', '250', '380', '528']; + +/** + * Transform a raw WTO tariff row into a TradeRestriction (tariff-as-barrier view). + */ +function toRestriction(row: any): TradeRestriction | null { + if (!row) return null; + const value = parseFloat(row.Value ?? row.value ?? ''); + if (isNaN(value)) return null; + + const reporterCode = String(row.ReportingEconomyCode ?? row.reportingEconomyCode ?? ''); + const year = String(row.Year ?? row.year ?? row.Period ?? ''); + const indicator = String(row.Indicator ?? row.indicator ?? row.IndicatorCode ?? ''); + + return { + id: `${reporterCode}-${year}-${row.IndicatorCode ?? ''}`, + reportingCountry: WTO_MEMBER_CODES[reporterCode] ?? String(row.ReportingEconomy ?? row.reportingEconomy ?? ''), + affectedCountry: 'All trading partners', + productSector: indicator.includes('agricultural') + ? (indicator.includes('non-') ? 'Non-agricultural products' : 'Agricultural products') + : 'All products', + measureType: 'MFN Applied Tariff', + description: `Average tariff rate: ${value.toFixed(1)}%`, + status: value > 10 ? 'high' : value > 5 ? 'moderate' : 'low', + notifiedAt: year, + sourceUrl: 'https://stats.wto.org', + }; +} + +async function fetchRestrictions( + _countries: string[], + limit: number, +): Promise<{ restrictions: TradeRestriction[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + + // Fetch all-products tariff for major economies (most recent years) + const params: Record<string, string> = { + i: 'TP_A_0010', + r: MAJOR_REPORTERS.join(','), + ps: `${currentYear - 3}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: String(limit * 3), + }; + + const data = await wtoFetch('/data', params); + if (!data) return { restrictions: [], ok: false }; + + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + + // Keep only the most recent year per country + const latestByCountry = new Map<string, any>(); + for (const row of dataset) { + const code = String(row.ReportingEconomyCode ?? ''); + const year = parseInt(row.Year ?? row.year ?? '0', 10); + const existing = latestByCountry.get(code); + if (!existing || year > parseInt(existing.Year ?? existing.year ?? '0', 10)) { + latestByCountry.set(code, row); + } + } + + const restrictions = Array.from(latestByCountry.values()) + .map(toRestriction) + .filter((r): r is TradeRestriction => r !== null) + .sort((a, b) => { + // Sort by tariff rate descending (extract from description) + const rateA = parseFloat(a.description.match(/[\d.]+/)?.[0] ?? '0'); + const rateB = parseFloat(b.description.match(/[\d.]+/)?.[0] ?? '0'); + return rateB - rateA; + }) + .slice(0, limit); + + return { restrictions, ok: true }; +} + +export async function getTradeRestrictions( + _ctx: ServerContext, + req: GetTradeRestrictionsRequest, +): Promise<GetTradeRestrictionsResponse> { + try { + const limit = Math.max(1, Math.min(req.limit > 0 ? req.limit : 50, 100)); + + const cacheKey = `${REDIS_CACHE_KEY}:tariff-overview:${limit}`; + const result = await cachedFetchJson<GetTradeRestrictionsResponse>( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const { restrictions, ok } = await fetchRestrictions([], limit); + if (!ok || restrictions.length === 0) return null; + return { restrictions, fetchedAt: new Date().toISOString(), upstreamUnavailable: false }; + }, + ); + + return result ?? { restrictions: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true }; + } catch { + return { + restrictions: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/handler.ts b/server/worldmonitor/trade/v1/handler.ts new file mode 100644 index 000000000..81a5b65af --- /dev/null +++ b/server/worldmonitor/trade/v1/handler.ts @@ -0,0 +1,13 @@ +import type { TradeServiceHandler } from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { getTradeRestrictions } from './get-trade-restrictions'; +import { getTariffTrends } from './get-tariff-trends'; +import { getTradeFlows } from './get-trade-flows'; +import { getTradeBarriers } from './get-trade-barriers'; + +export const tradeHandler: TradeServiceHandler = { + getTradeRestrictions, + getTariffTrends, + getTradeFlows, + getTradeBarriers, +}; diff --git a/server/worldmonitor/unrest/v1/_shared.ts b/server/worldmonitor/unrest/v1/_shared.ts new file mode 100644 index 000000000..6641385d3 --- /dev/null +++ b/server/worldmonitor/unrest/v1/_shared.ts @@ -0,0 +1,125 @@ +declare const process: { env: Record<string, string | undefined> }; + +import type { + UnrestEvent, + UnrestEventType, + SeverityLevel, +} from '../../../../src/generated/server/worldmonitor/unrest/v1/service_server'; + +// ======================================================================== +// API URLs +// ======================================================================== + +export const GDELT_GEO_URL = 'https://api.gdeltproject.org/api/v2/geo/geo'; + +// ======================================================================== +// ACLED Event Type Mapping (ported from src/services/protests.ts lines 39-46) +// ======================================================================== + +export function mapAcledEventType(eventType: string, subEventType: string): UnrestEventType { + const lower = (eventType + ' ' + subEventType).toLowerCase(); + if (lower.includes('riot') || lower.includes('mob violence')) + return 'UNREST_EVENT_TYPE_RIOT'; + if (lower.includes('strike')) + return 'UNREST_EVENT_TYPE_STRIKE'; + if (lower.includes('demonstration')) + return 'UNREST_EVENT_TYPE_DEMONSTRATION'; + if (lower.includes('protest')) + return 'UNREST_EVENT_TYPE_PROTEST'; + return 'UNREST_EVENT_TYPE_CIVIL_UNREST'; +} + +// ======================================================================== +// Severity Classification (ported from src/services/protests.ts lines 49-53) +// ======================================================================== + +export function classifySeverity(fatalities: number, eventType: string): SeverityLevel { + if (fatalities > 0 || eventType.toLowerCase().includes('riot')) + return 'SEVERITY_LEVEL_HIGH'; + if (eventType.toLowerCase().includes('protest')) + return 'SEVERITY_LEVEL_MEDIUM'; + return 'SEVERITY_LEVEL_LOW'; +} + +// ======================================================================== +// GDELT Classifiers +// ======================================================================== + +export function classifyGdeltSeverity(count: number, name: string): SeverityLevel { + const lowerName = name.toLowerCase(); + if (count > 100 || lowerName.includes('riot') || lowerName.includes('clash')) + return 'SEVERITY_LEVEL_HIGH'; + if (count < 25) + return 'SEVERITY_LEVEL_LOW'; + return 'SEVERITY_LEVEL_MEDIUM'; +} + +export function classifyGdeltEventType(name: string): UnrestEventType { + const lowerName = name.toLowerCase(); + if (lowerName.includes('riot')) return 'UNREST_EVENT_TYPE_RIOT'; + if (lowerName.includes('strike')) return 'UNREST_EVENT_TYPE_STRIKE'; + if (lowerName.includes('demonstration')) return 'UNREST_EVENT_TYPE_DEMONSTRATION'; + return 'UNREST_EVENT_TYPE_PROTEST'; +} + +// ======================================================================== +// Deduplication (ported from src/services/protests.ts lines 226-258) +// ======================================================================== + +export function deduplicateEvents(events: UnrestEvent[]): UnrestEvent[] { + const unique = new Map<string, UnrestEvent>(); + + for (const event of events) { + const lat = event.location?.latitude ?? 0; + const lon = event.location?.longitude ?? 0; + const latKey = Math.round(lat * 10) / 10; + const lonKey = Math.round(lon * 10) / 10; + const dateKey = new Date(event.occurredAt).toISOString().split('T')[0]; + const key = `${latKey}:${lonKey}:${dateKey}`; + + const existing = unique.get(key); + if (!existing) { + unique.set(key, event); + } else { + // Merge: prefer ACLED (higher confidence), combine sources + if ( + event.sourceType === 'UNREST_SOURCE_TYPE_ACLED' && + existing.sourceType !== 'UNREST_SOURCE_TYPE_ACLED' + ) { + event.sources = [...new Set([...event.sources, ...existing.sources])]; + unique.set(key, event); + } else if (existing.sourceType === 'UNREST_SOURCE_TYPE_ACLED') { + existing.sources = [...new Set([...existing.sources, ...event.sources])]; + } else { + // Both GDELT: combine sources, upgrade confidence if 2+ sources + existing.sources = [...new Set([...existing.sources, ...event.sources])]; + if (existing.sources.length >= 2) { + existing.confidence = 'CONFIDENCE_LEVEL_HIGH'; + } + } + } + } + + return Array.from(unique.values()); +} + +// ======================================================================== +// Sort (ported from src/services/protests.ts lines 262-273) +// ======================================================================== + +export function sortBySeverityAndRecency(events: UnrestEvent[]): UnrestEvent[] { + const severityOrder: Record<string, number> = { + SEVERITY_LEVEL_HIGH: 0, + SEVERITY_LEVEL_MEDIUM: 1, + SEVERITY_LEVEL_LOW: 2, + SEVERITY_LEVEL_UNSPECIFIED: 3, + }; + + return events.sort((a, b) => { + const sevDiff = + (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3); + if (sevDiff !== 0) return sevDiff; + return b.occurredAt - a.occurredAt; + }); +} + diff --git a/server/worldmonitor/unrest/v1/handler.ts b/server/worldmonitor/unrest/v1/handler.ts new file mode 100644 index 000000000..936e70ea1 --- /dev/null +++ b/server/worldmonitor/unrest/v1/handler.ts @@ -0,0 +1,7 @@ +import type { UnrestServiceHandler } from '../../../../src/generated/server/worldmonitor/unrest/v1/service_server'; + +import { listUnrestEvents } from './list-unrest-events'; + +export const unrestHandler: UnrestServiceHandler = { + listUnrestEvents, +}; diff --git a/server/worldmonitor/unrest/v1/list-unrest-events.ts b/server/worldmonitor/unrest/v1/list-unrest-events.ts new file mode 100644 index 000000000..8632103d9 --- /dev/null +++ b/server/worldmonitor/unrest/v1/list-unrest-events.ts @@ -0,0 +1,183 @@ +/** + * ListUnrestEvents RPC -- merges ACLED and GDELT data into deduplicated, + * severity-classified, sorted unrest events. + */ + +import type { + ServerContext, + ListUnrestEventsRequest, + ListUnrestEventsResponse, + UnrestEvent, + UnrestSourceType, + ConfidenceLevel, +} from '../../../../src/generated/server/worldmonitor/unrest/v1/service_server'; + +import { + GDELT_GEO_URL, + mapAcledEventType, + classifySeverity, + classifyGdeltSeverity, + classifyGdeltEventType, + deduplicateEvents, + sortBySeverityAndRecency, +} from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; +import { fetchAcledCached } from '../../../_shared/acled'; + +const REDIS_CACHE_KEY = 'unrest:events:v1'; +const REDIS_CACHE_TTL = 900; // 15 min — ACLED + GDELT merge + +// ---------- ACLED Fetch (ported from api/acled.js + src/services/protests.ts) ---------- + +async function fetchAcledProtests(req: ListUnrestEventsRequest): Promise<UnrestEvent[]> { + try { + const now = Date.now(); + const startMs = req.timeRange?.start ?? (now - 30 * 24 * 60 * 60 * 1000); + const endMs = req.timeRange?.end ?? now; + const startDate = new Date(startMs).toISOString().split('T')[0]!; + const endDate = new Date(endMs).toISOString().split('T')[0]!; + + const rawEvents = await fetchAcledCached({ + eventTypes: 'Protests', + startDate, + endDate, + country: req.country || undefined, + }); + + return rawEvents + .filter((e) => { + const lat = parseFloat(e.latitude || ''); + const lon = parseFloat(e.longitude || ''); + return Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; + }) + .map((e): UnrestEvent => { + const fatalities = parseInt(e.fatalities || '', 10) || 0; + return { + id: `acled-${e.event_id_cnty}`, + title: e.notes?.slice(0, 200) || `${e.sub_event_type} in ${e.location}`, + summary: typeof e.notes === 'string' ? e.notes.substring(0, 500) : '', + eventType: mapAcledEventType(e.event_type || '', e.sub_event_type || ''), + city: e.location || '', + country: e.country || '', + region: e.admin1 || '', + location: { + latitude: parseFloat(e.latitude || '0'), + longitude: parseFloat(e.longitude || '0'), + }, + occurredAt: new Date(e.event_date || '').getTime(), + severity: classifySeverity(fatalities, e.event_type || ''), + fatalities, + sources: [e.source].filter(Boolean) as string[], + sourceType: 'UNREST_SOURCE_TYPE_ACLED' as UnrestSourceType, + tags: e.tags?.split(';').map((t: string) => t.trim()).filter(Boolean) ?? [], + actors: [e.actor1, e.actor2].filter(Boolean) as string[], + confidence: 'CONFIDENCE_LEVEL_HIGH' as ConfidenceLevel, + }; + }); + } catch { + return []; + } +} + +// ---------- GDELT Fetch (ported from api/gdelt-geo.js + src/services/protests.ts) ---------- + +async function fetchGdeltEvents(): Promise<UnrestEvent[]> { + try { + const params = new URLSearchParams({ + query: 'protest', + format: 'geojson', + maxrecords: '250', + timespan: '7d', + }); + + const response = await fetch(`${GDELT_GEO_URL}?${params}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return []; + + const data = await response.json(); + const features: unknown[] = data?.features || []; + const seenLocations = new Set<string>(); + const events: UnrestEvent[] = []; + + for (const feature of features as any[]) { + const name: string = feature.properties?.name || ''; + if (!name || seenLocations.has(name)) continue; + + const count: number = feature.properties?.count || 1; + if (count < 5) continue; // Filter noise + + const coords = feature.geometry?.coordinates; + if (!Array.isArray(coords) || coords.length < 2) continue; + + const [lon, lat] = coords; // GeoJSON order: [lon, lat] + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) + continue; + + seenLocations.add(name); + const country = name.split(',').pop()?.trim() || name; + + events.push({ + id: `gdelt-${lat.toFixed(2)}-${lon.toFixed(2)}-${Date.now()}`, + title: `${name} (${count} reports)`, + summary: '', + eventType: classifyGdeltEventType(name), + city: name.split(',')[0]?.trim() || '', + country, + region: '', + location: { latitude: lat, longitude: lon }, + occurredAt: Date.now(), + severity: classifyGdeltSeverity(count, name), + fatalities: 0, + sources: ['GDELT'], + sourceType: 'UNREST_SOURCE_TYPE_GDELT' as UnrestSourceType, + tags: [], + actors: [], + confidence: (count > 20 + ? 'CONFIDENCE_LEVEL_HIGH' + : 'CONFIDENCE_LEVEL_MEDIUM') as ConfidenceLevel, + }); + } + + return events; + } catch { + return []; + } +} + +// ---------- RPC Implementation ---------- + +export async function listUnrestEvents( + _ctx: ServerContext, + req: ListUnrestEventsRequest, +): Promise<ListUnrestEventsResponse> { + try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.country || 'all'}:${req.timeRange?.start || 0}:${req.timeRange?.end || 0}`; + const result = await cachedFetchJson<ListUnrestEventsResponse>( + cacheKey, + REDIS_CACHE_TTL, + async () => { + const [acledEvents, gdeltEvents] = await Promise.all([ + fetchAcledProtests(req), + fetchGdeltEvents(), + ]); + const merged = deduplicateEvents([...acledEvents, ...gdeltEvents]); + const sorted = sortBySeverityAndRecency(merged); + return sorted.length > 0 ? { events: sorted, clusters: [], pagination: undefined } : null; + }, + ); + return result || { events: [], clusters: [], pagination: undefined }; + } catch { + return { events: [], clusters: [], pagination: undefined }; + } +} diff --git a/server/worldmonitor/wildfire/v1/handler.ts b/server/worldmonitor/wildfire/v1/handler.ts new file mode 100644 index 000000000..f257d916c --- /dev/null +++ b/server/worldmonitor/wildfire/v1/handler.ts @@ -0,0 +1,7 @@ +import type { WildfireServiceHandler } from '../../../../src/generated/server/worldmonitor/wildfire/v1/service_server'; + +import { listFireDetections } from './list-fire-detections'; + +export const wildfireHandler: WildfireServiceHandler = { + listFireDetections, +}; diff --git a/server/worldmonitor/wildfire/v1/list-fire-detections.ts b/server/worldmonitor/wildfire/v1/list-fire-detections.ts new file mode 100644 index 000000000..94439d7c4 --- /dev/null +++ b/server/worldmonitor/wildfire/v1/list-fire-detections.ts @@ -0,0 +1,151 @@ +/** + * ListFireDetections RPC -- proxies the NASA FIRMS CSV API. + * + * Fetches active fire detections from all 9 monitored regions in parallel + * and transforms the FIRMS CSV rows into proto-shaped FireDetection objects. + * + * Gracefully degrades to empty results when NASA_FIRMS_API_KEY is not set. + */ + +declare const process: { env: Record<string, string | undefined> }; + +import type { + WildfireServiceHandler, + ServerContext, + ListFireDetectionsRequest, + ListFireDetectionsResponse, + FireConfidence, +} from '../../../../src/generated/server/worldmonitor/wildfire/v1/service_server'; + +import { CHROME_UA } from '../../../_shared/constants'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'wildfire:fires:v1'; +const REDIS_CACHE_TTL = 3600; // 1h — NASA FIRMS VIIRS NRT updates every ~3 hours + +const FIRMS_SOURCE = 'VIIRS_SNPP_NRT'; + +/** Bounding boxes as west,south,east,north */ +const MONITORED_REGIONS: Record<string, string> = { + 'Ukraine': '22,44,40,53', + 'Russia': '20,50,180,82', + 'Iran': '44,25,63,40', + 'Israel/Gaza': '34,29,36,34', + 'Syria': '35,32,42,37', + 'Taiwan': '119,21,123,26', + 'North Korea': '124,37,131,43', + 'Saudi Arabia': '34,16,56,32', + 'Turkey': '26,36,45,42', +}; + +/** Map VIIRS confidence letters to proto enum values. */ +function mapConfidence(c: string): FireConfidence { + switch (c.toLowerCase()) { + case 'h': + return 'FIRE_CONFIDENCE_HIGH'; + case 'n': + return 'FIRE_CONFIDENCE_NOMINAL'; + case 'l': + return 'FIRE_CONFIDENCE_LOW'; + default: + return 'FIRE_CONFIDENCE_UNSPECIFIED'; + } +} + +/** Parse a FIRMS CSV response into an array of row objects keyed by header name. */ +function parseCSV(csv: string): Record<string, string>[] { + const lines = csv.trim().split('\n'); + if (lines.length < 2) return []; + + const headers = lines[0]!.split(',').map((h) => h.trim()); + const results: Record<string, string>[] = []; + + for (let i = 1; i < lines.length; i++) { + const vals = lines[i]!.split(',').map((v) => v.trim()); + if (vals.length < headers.length) continue; + + const row: Record<string, string> = {}; + headers.forEach((h, idx) => { + row[h] = vals[idx]!; + }); + results.push(row); + } + + return results; +} + +/** + * Parse FIRMS acq_date (YYYY-MM-DD) + acq_time (HHMM) into Unix epoch + * milliseconds. + */ +function parseDetectedAt(acqDate: string, acqTime: string): number { + const padded = acqTime.padStart(4, '0'); + const hours = padded.slice(0, 2); + const minutes = padded.slice(2); + return new Date(`${acqDate}T${hours}:${minutes}:00Z`).getTime(); +} + +export const listFireDetections: WildfireServiceHandler['listFireDetections'] = async ( + _ctx: ServerContext, + _req: ListFireDetectionsRequest, +): Promise<ListFireDetectionsResponse> => { + const apiKey = + process.env.NASA_FIRMS_API_KEY || process.env.FIRMS_API_KEY || ''; + + if (!apiKey) { + return { fireDetections: [], pagination: undefined }; + } + + const result = await cachedFetchJson<ListFireDetectionsResponse>( + REDIS_CACHE_KEY, + REDIS_CACHE_TTL, + async () => { + const entries = Object.entries(MONITORED_REGIONS); + const results = await Promise.allSettled( + entries.map(async ([regionName, bbox]) => { + const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${apiKey}/${FIRMS_SOURCE}/${bbox}/1`; + const res = await fetch(url, { + headers: { Accept: 'text/csv', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) { + throw new Error(`FIRMS ${res.status} for ${regionName}`); + } + const csv = await res.text(); + const rows = parseCSV(csv); + return { regionName, rows }; + }), + ); + + const fireDetections: ListFireDetectionsResponse['fireDetections'] = []; + + for (const r of results) { + if (r.status === 'fulfilled') { + const { regionName, rows } = r.value; + for (const row of rows) { + const detectedAt = parseDetectedAt(row.acq_date || '', row.acq_time || ''); + fireDetections.push({ + id: `${row.latitude ?? ''}-${row.longitude ?? ''}-${row.acq_date ?? ''}-${row.acq_time ?? ''}`, + location: { + latitude: parseFloat(row.latitude ?? '0') || 0, + longitude: parseFloat(row.longitude ?? '0') || 0, + }, + brightness: parseFloat(row.bright_ti4 ?? '0') || 0, + frp: parseFloat(row.frp ?? '0') || 0, + confidence: mapConfidence(row.confidence || ''), + satellite: row.satellite || '', + detectedAt, + region: regionName, + dayNight: row.daynight || '', + }); + } + } else { + console.error('[FIRMS]', r.reason?.message); + } + } + + return fireDetections.length > 0 ? { fireDetections, pagination: undefined } : null; + }, + ); + return result || { fireDetections: [], pagination: undefined }; +}; diff --git a/settings.html b/settings.html new file mode 100644 index 000000000..ba5c29eaf --- /dev/null +++ b/settings.html @@ -0,0 +1,62 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>World Monitor Settings + + + + +
+
+ + + + +
+

+
+
+
+
+
+
Loading...
+
+
+
Loading...
+
+
+
+ + +
+
+
+

Diagnostics

+
+ + +
+
+
+

API Traffic

+
+ + + +
+
+
+
+
+
+
+ + +
+
+ + + + diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..8244cbade --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,150 @@ +{ + "version": 1, + "skills": { + "ab-test-setup": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "ba045a5195bb6b2a084b0daa10fa364f942f6121ffc568e4ab5ca01da6a09c30" + }, + "ad-creative": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "1df12a926edaaf35751828d43f36e036d8519c4af0cc06221baebf1aebee0228" + }, + "ai-seo": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "33af140df6f3040b5f89e352c9f49a623f5049abb80ae3195cd47d578616e139" + }, + "analytics-tracking": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "96c10fd21185cf403b34f9a3af87c9e778708c25db8c6e006ed896afdf2cc8d5" + }, + "churn-prevention": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "b3d61eac3dcb6553c5fdb1f379c7d6864f37d4c0217f9dd4aea478eb172bf329" + }, + "cold-email": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "f406fe65455983eb6446c6dd5572a2235d1099f92a5cac38293e71267c84141f" + }, + "competitor-alternatives": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "f29cc163c4ac65d4b412529d82459ea5128278043aacd4881e5339ddbe902a99" + }, + "content-strategy": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "256d326fbdfbb234c2e61bb9fbbed923c474a7e08b666747e590e3cf84f8742a" + }, + "copy-editing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "1027f9ec618a1f02ddb41c89fa08512e1979985f8914c6237732654cbf238737" + }, + "copywriting": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "39b7a636c7e254a2f08dee43db6b9f6faf6723de999a6bbce2eb5ac95a130207" + }, + "email-sequence": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "8a5a64f0f64c14e026d59e74aeb06722ea49ef48b3a70679163ed2e660d1ac25" + }, + "form-cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "7f412794d281f26faae0d79161f8287e7be97fb6c88b74e118165903936fb77a" + }, + "free-tool-strategy": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "371e1b44d0060bd1780246dd3e8d18811a8d6fcb23d48aa8437017a7bcba614a" + }, + "launch-strategy": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "c7254a1e9288c1adb6897c0c8ef0b0f82759894fe21c7971f9d5b62362f416d5" + }, + "marketing-ideas": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "06ad6731a3bb329218ad88ee364551db6cce8835d0646b60def8432c5f45e804" + }, + "marketing-psychology": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "1f9527f4b69799e8acd05966ea1f57798890948a1c8446016593c9b485e3e86d" + }, + "onboarding-cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "ac591b22280ebc4bf893eddb4655355337b15cf0d38c8940c6449faf55b78602" + }, + "page-cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "ac4bc45f538c8804ddf5ffe9757ef746197f33d9fde198bbb49885a98e03100a" + }, + "paid-ads": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "e212dd0b3aa01fd5d42092767cf818938c3ed78d12bd5023cd7ec6a1719d9c03" + }, + "paywall-upgrade-cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "fe2dee2b0d171f93c3c574130de1b699530911b8397e0982881bc95bd0bd90d0" + }, + "popup-cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "8047da67ca79acbb8767167428fe8caa8f00922b514c9fa20ac3a5d70eda1bcd" + }, + "pricing-strategy": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "9316806c2d2479b0006189432b32cf749cab0879d04237e8b72164431ce648d9" + }, + "product-marketing-context": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "dc38de01e0af8422124cebe45ec1043ff83d2ce7f61d9a99575a415526861652" + }, + "programmatic-seo": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "aae6ed1dda8bacf4980860e3ee2cc3c181f31e31d6b524cfd452015930d16a1f" + }, + "referral-program": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "82b3aa762c7cee814eb864978e7ef67911b078abfaeb690cbe3fc034754d6f2b" + }, + "schema-markup": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "691aca0ac4d419c806a1d13a3ce8ca565a7e6ed791bd14cd4d79e91d713e82de" + }, + "seo-audit": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "6bd4cde58bb701b5dc90a0fae2f0ea3efc7c3e6fd2ae754083fa659930e7d39f" + }, + "signup-flow-cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "6439d733383da6307724ac5fc49311449ffbe18e4891ad874cdd5be0fd2e1cfa" + }, + "social-content": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "computedHash": "aaffbb6e301984ac94feb67f4eae29ad1936ecd1992e97db59dd03528ef20fd8" + } + } +} diff --git a/src-tauri/.cargo/config.local.toml.example b/src-tauri/.cargo/config.local.toml.example new file mode 100644 index 000000000..4e3a71736 --- /dev/null +++ b/src-tauri/.cargo/config.local.toml.example @@ -0,0 +1,12 @@ +# Local optional override for restricted-network/offline Rust builds. +# +# Usage: +# cp src-tauri/.cargo/config.local.toml.example src-tauri/.cargo/config.local.toml +# (keep config.local.toml untracked) +# +# Then run from src-tauri/: +# cargo generate-lockfile --offline +# cargo tauri build --offline --config tauri.conf.json + +[source.crates-io] +replace-with = "vendored-sources" diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml new file mode 100644 index 000000000..0d679dd4d --- /dev/null +++ b/src-tauri/.cargo/config.toml @@ -0,0 +1,16 @@ +# Rust dependency source configuration. +# +# Default behavior remains online (crates.io). +# +# Optional restricted-network mode is available in two ways: +# 1) per command: +# cargo --offline --config 'source.crates-io.replace-with="vendored-sources"' +# 2) local override file (recommended for CI/offline jobs): +# cp .cargo/config.local.toml.example .cargo/config.local.toml +# # config.local.toml is intentionally gitignored +# +# To (re)populate vendor/ for CI artifacts or an internal mirror handoff: +# cargo vendor --manifest-path src-tauri/Cargo.toml src-tauri/vendor + +[source.vendored-sources] +directory = "vendor" diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 000000000..aa58b9f93 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,3 @@ +/target +/.cargo/config.local.toml +/gen diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 000000000..391bea8c9 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5733 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 2.13.0", + "selectors", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "world-monitor" +version = "2.5.15" +dependencies = [ + "getrandom 0.2.17", + "keyring", + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 000000000..95971863f --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "world-monitor" +version = "2.5.16" +description = "World Monitor desktop application" +authors = ["World Monitor"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] } +reqwest = { version = "0.12", default-features = false, features = ["native-tls", "json"] } +getrandom = "0.2" + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] +devtools = ["tauri/devtools"] diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 000000000..d860e1e6a --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 000000000..608b89cfa --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capabilities for World Monitor trusted app windows", + "windows": ["main", "settings", "live-channels"], + "permissions": ["core:default"] +} diff --git a/src-tauri/capabilities/youtube-login.json b/src-tauri/capabilities/youtube-login.json new file mode 100644 index 000000000..0a25e8b68 --- /dev/null +++ b/src-tauri/capabilities/youtube-login.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "youtube-login", + "description": "Restricted capabilities for the external-origin YouTube login window", + "windows": ["youtube-login"], + "permissions": ["core:window:default"] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 000000000..621d194a6 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 000000000..0dc37bb31 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 000000000..e0f0a7c56 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 000000000..604de3c29 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 000000000..7a476d417 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 000000000..2ec93fbb1 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 000000000..d00204a47 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 000000000..b6eb94d8e Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 000000000..0881966d6 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 000000000..2b446f0c1 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 000000000..1a661ddc3 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 000000000..05f06144d Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 000000000..b5bb7f0b7 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..2e8bee168 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..e50b9e904 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..2e8bee168 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..cbbdfe0d8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..d924b9bcd Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..cbbdfe0d8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..03b917269 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..85cb7d4c3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..03b917269 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..72e28049b Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..3fcc05214 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..72e28049b Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..9e514dfe8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..ac8d7a071 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..9e514dfe8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 000000000..d7ca9f579 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 000000000..718fcb750 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 000000000..052029464 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 000000000..236748cef Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 000000000..30e4b876f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 000000000..30e4b876f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 000000000..920c79e17 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 000000000..50b65f565 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 000000000..336a81b24 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 000000000..336a81b24 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 000000000..054ae74cf Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 000000000..30e4b876f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 000000000..0d849c0ed Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 000000000..0d849c0ed Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 000000000..72e1a8c5b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 000000000..ac7b65190 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 000000000..72e1a8c5b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 000000000..8cd714d9e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 000000000..8a64da5b5 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 000000000..299bcdd36 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 000000000..2e40a2b96 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs new file mode 100644 index 000000000..a46af203f --- /dev/null +++ b/src-tauri/sidecar/local-api-server.mjs @@ -0,0 +1,1276 @@ +#!/usr/bin/env node +import http, { createServer } from 'node:http'; +import https from 'node:https'; +import dns from 'node:dns/promises'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { brotliCompress, gzipSync } from 'node:zlib'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const brotliCompressAsync = promisify(brotliCompress); + +// Monkey-patch globalThis.fetch to force IPv4 for HTTPS requests. +// Node.js built-in fetch (undici) tries IPv6 first via Happy Eyeballs. +// Government APIs (EIA, NASA FIRMS, FRED) publish AAAA records but their +// IPv6 endpoints time out, causing ETIMEDOUT. This override ensures ALL +// fetch() calls in dynamically-loaded handler modules (api/*.js) use IPv4. +const _originalFetch = globalThis.fetch; + +function normalizeRequestBody(body) { + if (body == null) return null; + if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) return body; + if (body instanceof URLSearchParams) return body.toString(); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + return body; +} + +async function resolveRequestBody(input, init, method, isRequest) { + if (method === 'GET' || method === 'HEAD') return null; + + if (init?.body != null) { + return normalizeRequestBody(init.body); + } + + if (isRequest && input?.body) { + const clone = typeof input.clone === 'function' ? input.clone() : input; + const buffer = await clone.arrayBuffer(); + return normalizeRequestBody(buffer); + } + + return null; +} + +function buildSafeResponse(statusCode, statusText, headers, bodyBuffer) { + const status = Number.isInteger(statusCode) ? statusCode : 500; + const body = (status === 204 || status === 205 || status === 304) ? null : bodyBuffer; + return new Response(body, { status, statusText, headers }); +} + +function isTransientVerificationError(error) { + if (!(error instanceof Error)) return false; + const code = typeof error.code === 'string' ? error.code : ''; + if (code && ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_CONNECT_TIMEOUT'].includes(code)) { + return true; + } + if (error.name === 'AbortError') return true; + return /timed out|timeout|network|fetch failed|failed to fetch|socket hang up/i.test(error.message); +} + +globalThis.fetch = async function ipv4Fetch(input, init) { + const isRequest = input && typeof input === 'object' && 'url' in input; + let url; + try { url = new URL(typeof input === 'string' ? input : input.url); } catch { return _originalFetch(input, init); } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return _originalFetch(input, init); + const mod = url.protocol === 'https:' ? https : http; + const method = init?.method || (isRequest ? input.method : 'GET'); + const body = await resolveRequestBody(input, init, method, isRequest); + const headers = {}; + const rawHeaders = init?.headers || (isRequest ? input.headers : null); + if (rawHeaders) { + const h = rawHeaders instanceof Headers ? Object.fromEntries(rawHeaders.entries()) + : Array.isArray(rawHeaders) ? Object.fromEntries(rawHeaders) : rawHeaders; + Object.assign(headers, h); + } + return new Promise((resolve, reject) => { + const req = mod.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method, headers, family: 4 }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const responseHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers)) { + if (v) responseHeaders.set(k, Array.isArray(v) ? v.join(', ') : v); + } + try { + resolve(buildSafeResponse(res.statusCode, res.statusMessage, responseHeaders, buf)); + } catch (error) { + reject(error); + } + }); + }); + req.on('error', reject); + if (init?.signal) { init.signal.addEventListener('abort', () => req.destroy()); } + if (body != null) req.write(body); + req.end(); + }); +}; + +const ALLOWED_ENV_KEYS = new Set([ + 'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'FRED_API_KEY', 'EIA_API_KEY', + 'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'URLHAUS_AUTH_KEY', + 'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL', + 'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET', + 'AISSTREAM_API_KEY', 'VITE_WS_RELAY_URL', 'FINNHUB_API_KEY', 'NASA_FIRMS_API_KEY', + 'OLLAMA_API_URL', 'OLLAMA_MODEL', 'WORLDMONITOR_API_KEY', 'WTO_API_KEY', +]); + +const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +// ── SSRF protection ────────────────────────────────────────────────────── +// Block requests to private/reserved IP ranges to prevent the RSS proxy +// from being used as a localhost pivot or internal network scanner. + +function isPrivateIP(ip) { + // IPv4-mapped IPv6 — extract the v4 portion + const v4Mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + const addr = v4Mapped ? v4Mapped[1] : ip; + + // IPv6 loopback + if (addr === '::1' || addr === '::') return true; + + // IPv6 link-local / unique-local + if (/^f[cd][0-9a-f]{2}:/i.test(addr)) return true; // fc00::/7 (ULA) + if (/^fe[89ab][0-9a-f]:/i.test(addr)) return true; // fe80::/10 (link-local) + + const parts = addr.split('.').map(Number); + if (parts.length !== 4 || parts.some(p => isNaN(p))) return false; // not an IPv4 + + const [a, b] = parts; + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 private + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private + if (a === 192 && b === 168) return true; // 192.168.0.0/16 private + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 0) return true; // 0.0.0.0/8 + if (a >= 224) return true; // 224.0.0.0+ multicast/reserved + return false; +} + +async function isSafeUrl(urlString) { + let parsed; + try { + parsed = new URL(urlString); + } catch { + return { safe: false, reason: 'Invalid URL' }; + } + + // Only allow http(s) protocols + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { safe: false, reason: 'Only http and https protocols are allowed' }; + } + + // Block URLs with credentials + if (parsed.username || parsed.password) { + return { safe: false, reason: 'URLs with credentials are not allowed' }; + } + + const hostname = parsed.hostname; + + // Quick-reject obvious private hostnames before DNS resolution + if (hostname === 'localhost' || hostname === '[::1]') { + return { safe: false, reason: 'Requests to localhost are not allowed' }; + } + + // Check if the hostname is already an IP literal + const ipLiteral = hostname.replace(/^\[|\]$/g, ''); + if (isPrivateIP(ipLiteral)) { + return { safe: false, reason: 'Requests to private/reserved IP addresses are not allowed' }; + } + + // DNS resolution check — resolve the hostname and verify all resolved IPs + // are public. This prevents DNS rebinding attacks where a public domain + // resolves to a private IP. + let addresses = []; + try { + try { + const v4 = await dns.resolve4(hostname); + addresses = addresses.concat(v4); + } catch { /* no A records — try AAAA */ } + try { + const v6 = await dns.resolve6(hostname); + addresses = addresses.concat(v6); + } catch { /* no AAAA records */ } + + if (addresses.length === 0) { + return { safe: false, reason: 'Could not resolve hostname' }; + } + + for (const addr of addresses) { + if (isPrivateIP(addr)) { + return { safe: false, reason: 'Hostname resolves to a private/reserved IP address' }; + } + } + } catch { + return { safe: false, reason: 'DNS resolution failed' }; + } + + return { safe: true, resolvedAddresses: addresses }; +} + +function json(data, status = 200, extraHeaders = {}) { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json', ...extraHeaders }, + }); +} + +function canCompress(headers, body) { + return body.length > 1024 && !headers['content-encoding']; +} + +function appendVary(existing, token) { + const value = typeof existing === 'string' ? existing : ''; + const parts = value.split(',').map((p) => p.trim()).filter(Boolean); + if (!parts.some((p) => p.toLowerCase() === token.toLowerCase())) { + parts.push(token); + } + return parts.join(', '); +} + +async function maybeCompressResponseBody(body, headers, acceptEncoding = '') { + if (!canCompress(headers, body)) return body; + headers['vary'] = appendVary(headers['vary'], 'Accept-Encoding'); + + if (acceptEncoding.includes('br')) { + headers['content-encoding'] = 'br'; + return brotliCompressAsync(body); + } + + if (acceptEncoding.includes('gzip')) { + headers['content-encoding'] = 'gzip'; + return gzipSync(body); + } + + return body; +} + +function isBracketSegment(segment) { + return segment.startsWith('[') && segment.endsWith(']'); +} + +function splitRoutePath(routePath) { + return routePath.split('/').filter(Boolean); +} + +function routePriority(routePath) { + const parts = splitRoutePath(routePath); + return parts.reduce((score, part) => { + if (part.startsWith('[[...') && part.endsWith(']]')) return score + 0; + if (part.startsWith('[...') && part.endsWith(']')) return score + 1; + if (isBracketSegment(part)) return score + 2; + return score + 10; + }, 0); +} + +function matchRoute(routePath, pathname) { + const routeParts = splitRoutePath(routePath); + const pathParts = splitRoutePath(pathname.replace(/^\/api/, '')); + + let i = 0; + let j = 0; + + while (i < routeParts.length && j < pathParts.length) { + const routePart = routeParts[i]; + const pathPart = pathParts[j]; + + if (routePart.startsWith('[[...') && routePart.endsWith(']]')) { + return true; + } + + if (routePart.startsWith('[...') && routePart.endsWith(']')) { + return true; + } + + if (isBracketSegment(routePart)) { + i += 1; + j += 1; + continue; + } + + if (routePart !== pathPart) { + return false; + } + + i += 1; + j += 1; + } + + if (i === routeParts.length && j === pathParts.length) return true; + + if (i === routeParts.length - 1) { + const tail = routeParts[i]; + if (tail?.startsWith('[[...') && tail.endsWith(']]')) { + return true; + } + if (tail?.startsWith('[...') && tail.endsWith(']')) { + return j < pathParts.length; + } + } + + return false; +} + +async function buildRouteTable(root) { + if (!existsSync(root)) return []; + + const files = []; + + async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(absolute); + continue; + } + if (!entry.name.endsWith('.js')) continue; + if (entry.name.startsWith('_')) continue; + + const relative = path.relative(root, absolute).replace(/\\/g, '/'); + const routePath = relative.replace(/\.js$/, '').replace(/\/index$/, ''); + files.push({ routePath, modulePath: absolute }); + } + } + + await walk(root); + + files.sort((a, b) => routePriority(b.routePath) - routePriority(a.routePath)); + return files; +} + +const REQUEST_BODY_CACHE = Symbol('requestBodyCache'); + +async function readBody(req) { + if (Object.prototype.hasOwnProperty.call(req, REQUEST_BODY_CACHE)) { + return req[REQUEST_BODY_CACHE]; + } + + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? Buffer.concat(chunks) : undefined; + req[REQUEST_BODY_CACHE] = body; + return body; +} + +function toHeaders(nodeHeaders, options = {}) { + const stripOrigin = options.stripOrigin === true; + const headers = new Headers(); + Object.entries(nodeHeaders).forEach(([key, value]) => { + const lowerKey = key.toLowerCase(); + if (lowerKey === 'host') return; + if (stripOrigin && (lowerKey === 'origin' || lowerKey === 'referer' || lowerKey.startsWith('sec-fetch-'))) { + return; + } + if (Array.isArray(value)) { + value.forEach(v => headers.append(key, v)); + } else if (typeof value === 'string') { + headers.set(key, value); + } + }); + return headers; +} + +async function proxyToCloud(requestUrl, req, remoteBase) { + const target = `${remoteBase}${requestUrl.pathname}${requestUrl.search}`; + const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req); + return fetch(target, { + method: req.method, + // Strip browser-origin headers for server-to-server parity. + headers: toHeaders(req.headers, { stripOrigin: true }), + body, + }); +} + +function pickModule(pathname, routes) { + const apiPath = pathname.startsWith('/api') ? pathname.slice(4) || '/' : pathname; + + for (const candidate of routes) { + if (matchRoute(candidate.routePath, apiPath)) { + return candidate.modulePath; + } + } + + return null; +} + +const moduleCache = new Map(); +const failedImports = new Set(); +const fallbackCounts = new Map(); +const cloudPreferred = new Set(); + +const TRAFFIC_LOG_MAX = 200; +const trafficLog = []; +let verboseMode = false; +let _verboseStatePath = null; + +function loadVerboseState(dataDir) { + _verboseStatePath = path.join(dataDir, 'verbose-mode.json'); + try { + const data = JSON.parse(readFileSync(_verboseStatePath, 'utf-8')); + verboseMode = !!data.verboseMode; + } catch { /* file missing or invalid — keep default false */ } +} + +function saveVerboseState() { + if (!_verboseStatePath) return; + try { writeFileSync(_verboseStatePath, JSON.stringify({ verboseMode })); } catch { /* ignore */ } +} + +function recordTraffic(entry) { + trafficLog.push(entry); + if (trafficLog.length > TRAFFIC_LOG_MAX) trafficLog.shift(); + if (verboseMode) { + const ts = entry.timestamp.split('T')[1].replace('Z', ''); + console.log(`[traffic] ${ts} ${entry.method} ${entry.path} → ${entry.status} ${entry.durationMs}ms`); + } +} + +function logOnce(logger, route, message) { + const key = `${route}:${message}`; + const count = (fallbackCounts.get(key) || 0) + 1; + fallbackCounts.set(key, count); + if (count === 1) { + logger.warn(`[local-api] ${route} → ${message}`); + } else if (count === 5 || count % 100 === 0) { + logger.warn(`[local-api] ${route} → ${message} (x${count})`); + } +} + +async function importHandler(modulePath) { + if (failedImports.has(modulePath)) { + throw new Error(`cached-failure:${path.basename(modulePath)}`); + } + + const cached = moduleCache.get(modulePath); + if (cached) return cached; + + try { + const mod = await import(pathToFileURL(modulePath).href); + moduleCache.set(modulePath, mod); + return mod; + } catch (error) { + if (error.code === 'ERR_MODULE_NOT_FOUND') { + failedImports.add(modulePath); + } + throw error; + } +} + +function resolveConfig(options = {}) { + const port = Number(options.port ?? process.env.LOCAL_API_PORT ?? 46123); + const remoteBase = String(options.remoteBase ?? process.env.LOCAL_API_REMOTE_BASE ?? 'https://worldmonitor.app').replace(/\/$/, ''); + const resourceDir = String(options.resourceDir ?? process.env.LOCAL_API_RESOURCE_DIR ?? process.cwd()); + const apiDir = options.apiDir + ? String(options.apiDir) + : [ + path.join(resourceDir, 'api'), + path.join(resourceDir, '_up_', 'api'), + ].find((candidate) => existsSync(candidate)) ?? path.join(resourceDir, 'api'); + const dataDir = String(options.dataDir ?? process.env.LOCAL_API_DATA_DIR ?? resourceDir); + const mode = String(options.mode ?? process.env.LOCAL_API_MODE ?? 'desktop-sidecar'); + const cloudFallback = String(options.cloudFallback ?? process.env.LOCAL_API_CLOUD_FALLBACK ?? '') === 'true'; + const logger = options.logger ?? console; + + return { + port, + remoteBase, + resourceDir, + dataDir, + apiDir, + mode, + cloudFallback, + logger, + }; +} + +function isMainModule() { + if (!process.argv[1]) return false; + return pathToFileURL(process.argv[1]).href === import.meta.url; +} + +async function handleLocalServiceStatus(context) { + return json({ + success: true, + timestamp: new Date().toISOString(), + summary: { operational: 2, degraded: 0, outage: 0, unknown: 0 }, + services: [ + { id: 'local-api', name: 'Local Desktop API', category: 'dev', status: 'operational', description: `Running on 127.0.0.1:${context.port}` }, + { id: 'cloud-pass-through', name: 'Cloud pass-through', category: 'cloud', status: 'operational', description: `Fallback target ${context.remoteBase}` }, + ], + local: { enabled: true, mode: context.mode, port: context.port, remoteBase: context.remoteBase }, + }); +} + +async function tryCloudFallback(requestUrl, req, context, reason) { + if (reason) { + const route = requestUrl.pathname; + const count = (fallbackCounts.get(route) || 0) + 1; + fallbackCounts.set(route, count); + if (count === 1) { + const brief = reason instanceof Error + ? (reason.code === 'ERR_MODULE_NOT_FOUND' ? 'missing npm dependency' : reason.message) + : reason; + context.logger.warn(`[local-api] ${route} → cloud (${brief})`); + } else if (count === 5 || count % 100 === 0) { + context.logger.warn(`[local-api] ${route} → cloud x${count}`); + } + } + try { + return await proxyToCloud(requestUrl, req, context.remoteBase); + } catch (error) { + context.logger.error('[local-api] cloud fallback failed', requestUrl.pathname, error); + return null; + } +} + +const SIDECAR_ALLOWED_ORIGINS = [ + /^tauri:\/\/localhost$/, + /^https?:\/\/localhost(:\d+)?$/, + /^https?:\/\/127\.0\.0\.1(:\d+)?$/, + /^https?:\/\/tauri\.localhost(:\d+)?$/, + // Only allow exact domain or single-level subdomains (e.g. preview-xyz.worldmonitor.app). + // The previous (.*\.)? pattern was overly broad. Anchored to prevent spoofing + // via domains like worldmonitorEVIL.vercel.app. + /^https:\/\/([a-z0-9-]+\.)?worldmonitor\.app$/, +]; + +function getSidecarCorsOrigin(req) { + const origin = req.headers?.origin || req.headers?.get?.('origin') || ''; + if (origin && SIDECAR_ALLOWED_ORIGINS.some(p => p.test(origin))) return origin; + return 'tauri://localhost'; +} + +function makeCorsHeaders(req) { + return { + 'Access-Control-Allow-Origin': getSidecarCorsOrigin(req), + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + 'Vary': 'Origin', + }; +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = 12000) { + // Use node:https with IPv4 forced — Node.js built-in fetch (undici) tries IPv6 + // first and some servers (EIA, NASA FIRMS) have broken IPv6 causing ETIMEDOUT. + const u = new URL(url); + if (u.protocol === 'https:') { + return new Promise((resolve, reject) => { + const reqOpts = { + hostname: u.hostname, + port: u.port || 443, + path: u.pathname + u.search, + method: options.method || 'GET', + headers: options.headers || {}, + family: 4, + }; + // Pin to a pre-resolved IP to prevent TOCTOU DNS rebinding. + // The hostname is kept for SNI / TLS certificate validation. + if (options.resolvedAddress) { + reqOpts.lookup = (_hostname, _opts, cb) => cb(null, options.resolvedAddress, 4); + } + const req = https.request(reqOpts, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString(); + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + headers: { get: (k) => res.headers[k.toLowerCase()] || null }, + text: () => Promise.resolve(body), + json: () => Promise.resolve(JSON.parse(body)), + }); + }); + }); + req.on('error', reject); + req.setTimeout(timeoutMs, () => { req.destroy(new Error('Request timed out')); }); + if (options.body) { + const body = normalizeRequestBody(options.body); + if (body != null) req.write(body); + } + req.end(); + }); + } + // HTTP fallback (localhost sidecar, etc.) + // For pinned addresses on plain HTTP, rewrite the URL to connect to the + // validated IP and set the Host header so virtual-host routing still works. + let fetchUrl = url; + const fetchHeaders = { ...(options.headers || {}) }; + if (options.resolvedAddress && u.protocol === 'http:') { + const pinned = new URL(url); + fetchHeaders['Host'] = pinned.host; + pinned.hostname = options.resolvedAddress; + fetchUrl = pinned.toString(); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(fetchUrl, { ...options, headers: fetchHeaders, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function relayToHttpUrl(rawUrl) { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol === 'ws:') parsed.protocol = 'http:'; + if (parsed.protocol === 'wss:') parsed.protocol = 'https:'; + return parsed.toString().replace(/\/$/, ''); + } catch { + return null; + } +} + +function isAuthFailure(status, text = '') { + // Intentionally broad for provider auth responses. + // Callers MUST check isCloudflareChallenge403() first or CF challenge pages + // may be misclassified as credential failures. + if (status === 401 || status === 403) return true; + return /unauthori[sz]ed|forbidden|invalid api key|invalid token|bad credentials/i.test(text); +} + +function isCloudflareChallenge403(response, text = '') { + if (response.status !== 403 || !response.headers.get('cf-ray')) return false; + const contentType = String(response.headers.get('content-type') || '').toLowerCase(); + const body = String(text || '').toLowerCase(); + const looksLikeHtml = contentType.includes('text/html') || body.includes(' body.includes(marker)).length; + return matches >= 2; +} + +async function validateSecretAgainstProvider(key, rawValue, context = {}) { + const value = String(rawValue || '').trim(); + if (!value) return { valid: false, message: 'Value is required' }; + + const fail = (message) => ({ valid: false, message }); + const ok = (message) => ({ valid: true, message }); + + try { + switch (key) { + case 'GROQ_API_KEY': { + const response = await fetchWithTimeout('https://api.groq.com/openai/v1/models', { + headers: { Authorization: `Bearer ${value}`, 'User-Agent': CHROME_UA }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('Groq key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('Groq rejected this key'); + if (!response.ok) return fail(`Groq probe failed (${response.status})`); + return ok('Groq key verified'); + } + + case 'OPENROUTER_API_KEY': { + const response = await fetchWithTimeout('https://openrouter.ai/api/v1/models', { + headers: { Authorization: `Bearer ${value}`, 'User-Agent': CHROME_UA }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('OpenRouter key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('OpenRouter rejected this key'); + if (!response.ok) return fail(`OpenRouter probe failed (${response.status})`); + return ok('OpenRouter key verified'); + } + + case 'FRED_API_KEY': { + const response = await fetchWithTimeout( + `https://api.stlouisfed.org/fred/series?series_id=GDP&api_key=${encodeURIComponent(value)}&file_type=json`, + { headers: { Accept: 'application/json', 'User-Agent': CHROME_UA } } + ); + const text = await response.text(); + if (!response.ok) return fail(`FRED probe failed (${response.status})`); + let payload = null; + try { payload = JSON.parse(text); } catch { /* ignore */ } + if (payload?.error_code || payload?.error_message) return fail('FRED rejected this key'); + if (!Array.isArray(payload?.seriess)) return fail('Unexpected FRED response'); + return ok('FRED key verified'); + } + + case 'EIA_API_KEY': { + const response = await fetchWithTimeout( + `https://api.eia.gov/v2/?api_key=${encodeURIComponent(value)}`, + { headers: { Accept: 'application/json', 'User-Agent': CHROME_UA } } + ); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('EIA key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('EIA rejected this key'); + if (!response.ok) return fail(`EIA probe failed (${response.status})`); + let payload = null; + try { payload = JSON.parse(text); } catch { /* ignore */ } + if (payload?.response?.id === undefined && !payload?.response?.routes) return fail('Unexpected EIA response'); + return ok('EIA key verified'); + } + + case 'CLOUDFLARE_API_TOKEN': { + const response = await fetchWithTimeout( + 'https://api.cloudflare.com/client/v4/radar/annotations/outages?dateRange=1d&limit=1', + { headers: { Authorization: `Bearer ${value}`, 'User-Agent': CHROME_UA } } + ); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('Cloudflare token stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('Cloudflare rejected this token'); + if (!response.ok) return fail(`Cloudflare probe failed (${response.status})`); + let payload = null; + try { payload = JSON.parse(text); } catch { /* ignore */ } + if (payload?.success !== true) return fail('Cloudflare Radar API did not return success'); + return ok('Cloudflare token verified'); + } + + case 'ACLED_ACCESS_TOKEN': { + const response = await fetchWithTimeout('https://acleddata.com/api/acled/read?_format=json&limit=1', { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${value}`, + 'User-Agent': CHROME_UA, + }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('ACLED token stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('ACLED rejected this token'); + if (!response.ok) return fail(`ACLED probe failed (${response.status})`); + return ok('ACLED token verified'); + } + + case 'URLHAUS_AUTH_KEY': { + const response = await fetchWithTimeout('https://urlhaus-api.abuse.ch/v1/urls/recent/limit/1/', { + headers: { + Accept: 'application/json', + 'Auth-Key': value, + 'User-Agent': CHROME_UA, + }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('URLhaus key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('URLhaus rejected this key'); + if (!response.ok) return fail(`URLhaus probe failed (${response.status})`); + return ok('URLhaus key verified'); + } + + case 'OTX_API_KEY': { + const response = await fetchWithTimeout('https://otx.alienvault.com/api/v1/user/me', { + headers: { + Accept: 'application/json', + 'X-OTX-API-KEY': value, + 'User-Agent': CHROME_UA, + }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('OTX key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('OTX rejected this key'); + if (!response.ok) return fail(`OTX probe failed (${response.status})`); + return ok('OTX key verified'); + } + + case 'ABUSEIPDB_API_KEY': { + const response = await fetchWithTimeout('https://api.abuseipdb.com/api/v2/check?ipAddress=8.8.8.8&maxAgeInDays=90', { + headers: { + Accept: 'application/json', + Key: value, + 'User-Agent': CHROME_UA, + }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('AbuseIPDB key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('AbuseIPDB rejected this key'); + if (!response.ok) return fail(`AbuseIPDB probe failed (${response.status})`); + return ok('AbuseIPDB key verified'); + } + + case 'WINGBITS_API_KEY': { + const response = await fetchWithTimeout('https://customer-api.wingbits.com/v1/flights/details/3c6444', { + headers: { + Accept: 'application/json', + 'x-api-key': value, + 'User-Agent': CHROME_UA, + }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('Wingbits key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('Wingbits rejected this key'); + if (response.status >= 500) return fail(`Wingbits probe failed (${response.status})`); + return ok('Wingbits key accepted'); + } + + case 'FINNHUB_API_KEY': { + const response = await fetchWithTimeout(`https://finnhub.io/api/v1/quote?symbol=AAPL&token=${encodeURIComponent(value)}`, { + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + }); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('Finnhub key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('Finnhub rejected this key'); + if (response.status === 429) return ok('Finnhub key accepted (rate limited)'); + if (!response.ok) return fail(`Finnhub probe failed (${response.status})`); + let payload = null; + try { payload = JSON.parse(text); } catch { /* ignore */ } + if (typeof payload?.error === 'string' && payload.error.toLowerCase().includes('invalid')) { + return fail('Finnhub rejected this key'); + } + if (typeof payload?.c !== 'number') return fail('Unexpected Finnhub response'); + return ok('Finnhub key verified'); + } + + case 'NASA_FIRMS_API_KEY': { + const response = await fetchWithTimeout( + `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(value)}/VIIRS_SNPP_NRT/22,44,40,53/1`, + { headers: { Accept: 'text/csv', 'User-Agent': CHROME_UA } } + ); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('NASA FIRMS key stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('NASA FIRMS rejected this key'); + if (!response.ok) return fail(`NASA FIRMS probe failed (${response.status})`); + if (/invalid api key|not authorized|forbidden/i.test(text)) return fail('NASA FIRMS rejected this key'); + return ok('NASA FIRMS key verified'); + } + + case 'OLLAMA_API_URL': { + let probeUrl; + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) return fail('Must be an http(s) URL'); + // Probe the OpenAI-compatible models endpoint + probeUrl = new URL('/v1/models', value).toString(); + } catch { + return fail('Invalid URL'); + } + const response = await fetchWithTimeout(probeUrl, { method: 'GET' }, 8000); + if (!response.ok) { + // Fall back to native Ollama /api/tags endpoint + try { + const tagsUrl = new URL('/api/tags', value).toString(); + const tagsResponse = await fetchWithTimeout(tagsUrl, { method: 'GET' }, 8000); + if (!tagsResponse.ok) return fail(`Ollama probe failed (${tagsResponse.status})`); + return ok('Ollama endpoint verified (native API)'); + } catch { + return fail(`Ollama probe failed (${response.status})`); + } + } + return ok('Ollama endpoint verified'); + } + + case 'OLLAMA_MODEL': + return ok('Model name stored'); + + case 'WS_RELAY_URL': + case 'VITE_WS_RELAY_URL': + case 'VITE_OPENSKY_RELAY_URL': { + const probeUrl = relayToHttpUrl(value); + if (!probeUrl) return fail('Relay URL is invalid'); + const response = await fetchWithTimeout(probeUrl, { method: 'GET' }); + if (response.status >= 500) return fail(`Relay probe failed (${response.status})`); + return ok('Relay URL is reachable'); + } + + case 'OPENSKY_CLIENT_ID': + case 'OPENSKY_CLIENT_SECRET': { + const contextClientId = typeof context.OPENSKY_CLIENT_ID === 'string' ? context.OPENSKY_CLIENT_ID.trim() : ''; + const contextClientSecret = typeof context.OPENSKY_CLIENT_SECRET === 'string' ? context.OPENSKY_CLIENT_SECRET.trim() : ''; + const clientId = key === 'OPENSKY_CLIENT_ID' + ? value + : (contextClientId || String(process.env.OPENSKY_CLIENT_ID || '').trim()); + const clientSecret = key === 'OPENSKY_CLIENT_SECRET' + ? value + : (contextClientSecret || String(process.env.OPENSKY_CLIENT_SECRET || '').trim()); + if (!clientId || !clientSecret) { + return fail('Set both OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET before verification'); + } + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }); + const response = await fetchWithTimeout( + 'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': CHROME_UA }, + body, + } + ); + const text = await response.text(); + if (isCloudflareChallenge403(response, text)) return ok('OpenSky credentials stored (Cloudflare blocked verification)'); + if (isAuthFailure(response.status, text)) return fail('OpenSky rejected these credentials'); + if (!response.ok) return fail(`OpenSky auth probe failed (${response.status})`); + let payload = null; + try { payload = JSON.parse(text); } catch { /* ignore */ } + if (!payload?.access_token) return fail('OpenSky auth response did not include an access token'); + return ok('OpenSky credentials verified'); + } + + case 'AISSTREAM_API_KEY': + return ok('AISSTREAM key stored (live verification not available in sidecar)'); + + case 'WTO_API_KEY': + return ok('WTO API key stored (live verification not available in sidecar)'); + + default: + return ok('Key stored'); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'provider probe failed'; + if (isTransientVerificationError(error)) { + return { valid: true, message: `Saved (could not verify: ${message})` }; + } + return fail(`Verification request failed: ${message}`); + } +} + +async function dispatch(requestUrl, req, routes, context) { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: makeCorsHeaders(req) }); + } + + // Health check — exempt from auth to support external monitoring tools + if (requestUrl.pathname === '/api/service-status') { + return handleLocalServiceStatus(context); + } + + // ── Global auth gate ──────────────────────────────────────────────────── + // Every endpoint below requires a valid LOCAL_API_TOKEN. This prevents + // other local processes, malicious browser scripts, and rogue extensions + // from accessing the sidecar API without the per-session token. + const expectedToken = process.env.LOCAL_API_TOKEN; + if (expectedToken) { + const authHeader = req.headers.authorization || ''; + if (authHeader !== `Bearer ${expectedToken}`) { + context.logger.warn(`[local-api] unauthorized request to ${requestUrl.pathname}`); + return json({ error: 'Unauthorized' }, 401); + } + } + + if (requestUrl.pathname === '/api/local-status') { + return json({ + success: true, + mode: context.mode, + port: context.port, + apiDir: context.apiDir, + remoteBase: context.remoteBase, + cloudFallback: context.cloudFallback, + routes: routes.length, + }); + } + if (requestUrl.pathname === '/api/local-traffic-log') { + if (req.method === 'DELETE') { + trafficLog.length = 0; + return json({ cleared: true }); + } + // Strip query strings from logged paths to avoid leaking feed URLs and + // user research patterns to anyone who can read the traffic log. + const sanitized = trafficLog.map(entry => ({ + ...entry, + path: entry.path?.split('?')[0] ?? entry.path, + })); + return json({ entries: sanitized, verboseMode, maxEntries: TRAFFIC_LOG_MAX }); + } + if (requestUrl.pathname === '/api/local-debug-toggle') { + if (req.method === 'POST') { + verboseMode = !verboseMode; + saveVerboseState(); + context.logger.log(`[local-api] verbose logging ${verboseMode ? 'ON' : 'OFF'}`); + } + return json({ verboseMode }); + } + // Registration — call Convex directly (desktop frontend bypasses sidecar for this endpoint; + // this handler only runs when CONVEX_URL is available, e.g. self-hosted deployments) + if (requestUrl.pathname === '/api/register-interest' && req.method === 'POST') { + const convexUrl = process.env.CONVEX_URL; + if (!convexUrl) { + return json({ error: 'Registration service not configured — use cloud endpoint directly' }, 503); + } + try { + const body = await new Promise((resolve, reject) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + req.on('error', reject); + }); + const parsed = JSON.parse(body); + const email = parsed.email; + if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return json({ error: 'Invalid email address' }, 400); + } + const response = await fetchWithTimeout(`${convexUrl}/api/mutation`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: 'registerInterest:register', + args: { email, source: parsed.source || 'desktop', appVersion: parsed.appVersion || 'unknown' }, + format: 'json', + }), + }, 15000); + const responseBody = await response.text(); + let result; + try { result = JSON.parse(responseBody); } catch { result = { status: 'registered' }; } + if (result.status === 'error') { + return json({ error: result.errorMessage || 'Registration failed' }, 500); + } + return json(result.value || result); + } catch (e) { + context.logger.error(`[register-interest] error: ${e.message}`); + return json({ error: 'Registration service unreachable' }, 502); + } + } + + // RSS proxy — fetch public feeds with SSRF protection + if (requestUrl.pathname === '/api/rss-proxy') { + const feedUrl = requestUrl.searchParams.get('url'); + if (!feedUrl) return json({ error: 'Missing url parameter' }, 400); + + // SSRF protection: block private IPs, reserved ranges, and DNS rebinding + const safety = await isSafeUrl(feedUrl); + if (!safety.safe) { + context.logger.warn(`[local-api] rss-proxy SSRF blocked: ${safety.reason} (url=${feedUrl})`); + return json({ error: safety.reason }, 403); + } + + try { + const parsed = new URL(feedUrl); + // Pin to the first IPv4 address validated by isSafeUrl() so the + // actual TCP connection goes to the same IP we checked, closing + // the TOCTOU DNS-rebinding window. + const pinnedV4 = safety.resolvedAddresses?.find(a => a.includes('.')); + const response = await fetchWithTimeout(feedUrl, { + headers: { + 'User-Agent': CHROME_UA, + 'Accept': 'application/rss+xml, application/xml, text/xml, */*', + 'Accept-Language': 'en-US,en;q=0.9', + }, + ...(pinnedV4 ? { resolvedAddress: pinnedV4 } : {}), + }, parsed.hostname.includes('news.google.com') ? 20000 : 12000); + const contentType = response.headers?.get?.('content-type') || 'application/xml'; + const rssBody = await response.text(); + return new Response(rssBody || '', { + status: response.status, + headers: { 'content-type': contentType }, + }); + } catch (e) { + const isTimeout = e.name === 'AbortError' || e.message?.includes('timeout'); + return json({ error: isTimeout ? 'Feed timeout' : 'Failed to fetch feed', url: feedUrl }, isTimeout ? 504 : 502); + } + } + + if (requestUrl.pathname === '/api/local-env-update') { + if (req.method === 'POST') { + const body = await readBody(req); + if (body) { + try { + const { key, value } = JSON.parse(body.toString()); + if (typeof key === 'string' && key.length > 0 && ALLOWED_ENV_KEYS.has(key)) { + if (value == null || value === '') { + delete process.env[key]; + context.logger.log(`[local-api] env unset: ${key}`); + } else { + process.env[key] = String(value); + context.logger.log(`[local-api] env set: ${key}`); + } + moduleCache.clear(); + failedImports.clear(); + cloudPreferred.clear(); + return json({ ok: true, key }); + } + return json({ error: 'key not in allowlist' }, 403); + } catch { /* bad JSON */ } + } + return json({ error: 'expected { key, value }' }, 400); + } + return json({ error: 'POST required' }, 405); + } + + if (requestUrl.pathname === '/api/local-validate-secret') { + if (req.method !== 'POST') { + return json({ error: 'POST required' }, 405); + } + const body = await readBody(req); + if (!body) return json({ error: 'expected { key, value }' }, 400); + try { + const { key, value, context } = JSON.parse(body.toString()); + if (typeof key !== 'string' || !ALLOWED_ENV_KEYS.has(key)) { + return json({ error: 'key not in allowlist' }, 403); + } + const safeContext = (context && typeof context === 'object') ? context : {}; + const result = await validateSecretAgainstProvider(key, value, safeContext); + return json(result, result.valid ? 200 : 422); + } catch { + return json({ error: 'expected { key, value }' }, 400); + } + } + + if (context.cloudFallback && cloudPreferred.has(requestUrl.pathname)) { + const cloudResponse = await tryCloudFallback(requestUrl, req, context); + if (cloudResponse) return cloudResponse; + } + + const modulePath = pickModule(requestUrl.pathname, routes); + if (!modulePath || !existsSync(modulePath)) { + if (context.cloudFallback) { + const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'handler missing'); + if (cloudResponse) return cloudResponse; + } + logOnce(context.logger, requestUrl.pathname, 'no local handler'); + return json({ error: 'No local handler for this endpoint', endpoint: requestUrl.pathname }, 404); + } + + try { + const mod = await importHandler(modulePath); + if (typeof mod.default !== 'function') { + logOnce(context.logger, requestUrl.pathname, 'invalid handler module'); + if (context.cloudFallback) { + const cloudResponse = await tryCloudFallback(requestUrl, req, context, `invalid handler module`); + if (cloudResponse) return cloudResponse; + } + return json({ error: 'Invalid handler module', endpoint: requestUrl.pathname }, 500); + } + + const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req); + const request = new Request(requestUrl.toString(), { + method: req.method, + headers: toHeaders(req.headers, { stripOrigin: true }), + body, + }); + + const response = await mod.default(request); + if (!(response instanceof Response)) { + logOnce(context.logger, requestUrl.pathname, 'handler returned non-Response'); + if (context.cloudFallback) { + const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'handler returned non-Response'); + if (cloudResponse) return cloudResponse; + } + return json({ error: 'Handler returned invalid response', endpoint: requestUrl.pathname }, 500); + } + + if (!response.ok && context.cloudFallback) { + const cloudResponse = await tryCloudFallback(requestUrl, req, context, `local status ${response.status}`); + if (cloudResponse) { cloudPreferred.add(requestUrl.pathname); return cloudResponse; } + } + + return response; + } catch (error) { + const reason = error.code === 'ERR_MODULE_NOT_FOUND' ? 'missing dependency' : error.message; + context.logger.error(`[local-api] ${requestUrl.pathname} → ${reason}`); + if (context.cloudFallback) { + const cloudResponse = await tryCloudFallback(requestUrl, req, context, error); + if (cloudResponse) { cloudPreferred.add(requestUrl.pathname); return cloudResponse; } + } + return json({ error: 'Local handler error', reason, endpoint: requestUrl.pathname }, 502); + } +} + +export async function createLocalApiServer(options = {}) { + const context = resolveConfig(options); + loadVerboseState(context.dataDir); + const routes = await buildRouteTable(context.apiDir); + + const server = createServer(async (req, res) => { + const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${context.port}`); + + if (!requestUrl.pathname.startsWith('/api/')) { + res.writeHead(404, { 'content-type': 'application/json', ...makeCorsHeaders(req) }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + const start = Date.now(); + const skipRecord = req.method === 'OPTIONS' + || requestUrl.pathname === '/api/local-traffic-log' + || requestUrl.pathname === '/api/local-debug-toggle' + || requestUrl.pathname === '/api/local-env-update' + || requestUrl.pathname === '/api/local-validate-secret'; + + try { + const response = await dispatch(requestUrl, req, routes, context); + const durationMs = Date.now() - start; + let body = Buffer.from(await response.arrayBuffer()); + const headers = Object.fromEntries(response.headers.entries()); + const corsOrigin = getSidecarCorsOrigin(req); + headers['access-control-allow-origin'] = corsOrigin; + headers['vary'] = appendVary(headers['vary'], 'Origin'); + + if (!skipRecord) { + recordTraffic({ + timestamp: new Date().toISOString(), + method: req.method, + path: requestUrl.pathname + (requestUrl.search || ''), + status: response.status, + durationMs, + }); + } + + const acceptEncoding = req.headers['accept-encoding'] || ''; + body = await maybeCompressResponseBody(body, headers, acceptEncoding); + + if (headers['content-encoding']) { + delete headers['content-length']; + } + + res.writeHead(response.status, headers); + res.end(body); + } catch (error) { + const durationMs = Date.now() - start; + context.logger.error('[local-api] fatal', error); + + if (!skipRecord) { + recordTraffic({ + timestamp: new Date().toISOString(), + method: req.method, + path: requestUrl.pathname + (requestUrl.search || ''), + status: 500, + durationMs, + error: error.message, + }); + } + + res.writeHead(500, { 'content-type': 'application/json', ...makeCorsHeaders(req) }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + }); + + return { + context, + routes, + server, + async start() { + const tryListen = (port) => new Promise((resolve, reject) => { + const onListening = () => { server.off('error', onError); resolve(); }; + const onError = (error) => { server.off('listening', onListening); reject(error); }; + server.once('listening', onListening); + server.once('error', onError); + server.listen(port, '127.0.0.1'); + }); + + try { + await tryListen(context.port); + } catch (err) { + if (err?.code === 'EADDRINUSE') { + context.logger.log(`[local-api] port ${context.port} busy, falling back to OS-assigned port`); + await tryListen(0); + } else { + throw err; + } + } + + const address = server.address(); + const boundPort = typeof address === 'object' && address?.port ? address.port : context.port; + context.port = boundPort; + + const portFile = process.env.LOCAL_API_PORT_FILE; + if (portFile) { + try { writeFileSync(portFile, String(boundPort)); } catch {} + } + + context.logger.log(`[local-api] listening on http://127.0.0.1:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback})`); + return { port: boundPort }; + }, + async close() { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +if (isMainModule()) { + try { + const app = await createLocalApiServer(); + await app.start(); + } catch (error) { + console.error('[local-api] startup failed', error); + process.exit(1); + } +} diff --git a/src-tauri/sidecar/local-api-server.test.mjs b/src-tauri/sidecar/local-api-server.test.mjs new file mode 100644 index 000000000..cdcea5312 --- /dev/null +++ b/src-tauri/sidecar/local-api-server.test.mjs @@ -0,0 +1,1383 @@ +import { strict as assert } from 'node:assert'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { createServer, request as httpRequest } from 'node:http'; +import https from 'node:https'; +import { EventEmitter } from 'node:events'; +import { brotliDecompressSync, gunzipSync } from 'node:zlib'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createLocalApiServer } from './local-api-server.mjs'; + +async function listen(server, host = '127.0.0.1', port = 0) { + await new Promise((resolve, reject) => { + const onListening = () => { + server.off('error', onError); + resolve(); + }; + const onError = (error) => { + server.off('listening', onListening); + reject(error); + }; + server.once('listening', onListening); + server.once('error', onError); + server.listen(port, host); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server address'); + } + return address.port; +} + +async function postJsonViaHttp(url, payload) { + const target = new URL(url); + const body = JSON.stringify(payload); + return new Promise((resolve, reject) => { + const req = httpRequest({ + hostname: target.hostname, + port: Number(target.port || 80), + path: `${target.pathname}${target.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(body)), + }, + }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let json = null; + try { json = JSON.parse(text); } catch { /* non-json response */ } + resolve({ status: res.statusCode || 0, text, json }); + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function mockHttpsRequestOnce({ statusCode, headers, body }) { + const original = https.request; + https.request = (_options, onResponse) => { + const req = new EventEmitter(); + req.setTimeout = () => {}; + req.write = () => {}; + req.destroy = (error) => { + if (error) req.emit('error', error); + }; + req.end = () => { + queueMicrotask(() => { + const res = new EventEmitter(); + res.statusCode = statusCode; + res.statusMessage = ''; + res.headers = headers; + onResponse(res); + if (body) res.emit('data', Buffer.from(body)); + res.emit('end'); + }); + }; + return req; + }; + return () => { + https.request = original; + }; +} + +async function setupRemoteServer() { + const hits = []; + const origins = []; + const server = createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + hits.push(url.pathname); + origins.push(req.headers.origin || null); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + source: 'remote', + path: url.pathname, + origin: req.headers.origin || null, + })); + }); + + const port = await listen(server); + return { + hits, + origins, + remoteBase: `http://127.0.0.1:${port}`, + async close() { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +async function setupApiDir(files) { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'wm-sidecar-test-')); + const apiDir = path.join(tempRoot, 'api'); + await mkdir(apiDir, { recursive: true }); + + await Promise.all( + Object.entries(files).map(async ([relativePath, source]) => { + const absolute = path.join(apiDir, relativePath); + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, source, 'utf8'); + }) + ); + + return { + apiDir, + async cleanup() { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +async function setupResourceDirWithUpApi(files) { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'wm-sidecar-resource-test-')); + const apiDir = path.join(tempRoot, '_up_', 'api'); + await mkdir(apiDir, { recursive: true }); + + await Promise.all( + Object.entries(files).map(async ([relativePath, source]) => { + const absolute = path.join(apiDir, relativePath); + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, source, 'utf8'); + }) + ); + + return { + resourceDir: tempRoot, + apiDir, + async cleanup() { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +test('returns local error directly when cloudFallback is off (default)', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'fred-data.js': ` + export default async function handler() { + return new Response(JSON.stringify({ source: 'local-error' }), { + status: 500, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/fred-data`); + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body.source, 'local-error'); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('falls back to cloud when cloudFallback is enabled and local handler returns 500', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'fred-data.js': ` + export default async function handler() { + return new Response(JSON.stringify({ source: 'local-error' }), { + status: 500, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + cloudFallback: 'true', + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/fred-data`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, 'remote'); + assert.equal(remote.hits.includes('/api/fred-data'), true); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('preserves POST body when cloud fallback is triggered after local non-OK response', async () => { + const remoteBodies = []; + const remote = createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + remoteBodies.push(body); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ source: 'remote', body })); + }); + }); + const remotePort = await listen(remote); + + const localApi = await setupApiDir({ + 'post-fail.js': ` + export default async function handler(req) { + await req.text(); + return new Response(JSON.stringify({ source: 'local-error' }), { + status: 500, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: `http://127.0.0.1:${remotePort}`, + cloudFallback: 'true', + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const payload = JSON.stringify({ secret: 'keep-body' }); + const response = await fetch(`http://127.0.0.1:${port}/api/post-fail`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: payload, + }); + assert.equal(response.status, 200); + + const body = await response.json(); + assert.equal(body.source, 'remote'); + assert.equal(body.body, payload); + assert.equal(remoteBodies[0], payload); + } finally { + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + remote.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test('uses local handler response when local handler succeeds', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'live.js': ` + export default async function handler() { + return new Response(JSON.stringify({ source: 'local-ok' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/live`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, 'local-ok'); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('returns 404 when local route does not exist and cloudFallback is off', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/not-found`); + assert.equal(response.status, 404); + const body = await response.json(); + assert.equal(body.error, 'No local handler for this endpoint'); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('strips browser origin headers before invoking local handlers', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'origin-check.js': ` + export default async function handler(req) { + const origin = req.headers.get('origin'); + return new Response(JSON.stringify({ + source: 'local', + originPresent: Boolean(origin), + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/origin-check`, { + headers: { Origin: 'https://tauri.localhost' }, + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, 'local'); + assert.equal(body.originPresent, false); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('preserves Request body when handler uses fetch(Request)', async () => { + let receivedBody = ''; + + const upstream = createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + receivedBody = Buffer.concat(chunks).toString('utf8'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ receivedBody })); + }); + }); + const upstreamPort = await listen(upstream); + process.env.WM_TEST_UPSTREAM = `http://127.0.0.1:${upstreamPort}`; + + const localApi = await setupApiDir({ + 'request-proxy.js': ` + export default async function handler() { + const request = new Request(\`\${process.env.WM_TEST_UPSTREAM}/echo\`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ secret: 'keep-body' }), + }); + const upstream = await fetch(request); + const payload = await upstream.text(); + return new Response(payload, { + status: upstream.status, + headers: { 'content-type': 'application/json' }, + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/request-proxy`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.receivedBody.includes('"secret":"keep-body"'), true); + assert.equal(receivedBody.includes('"secret":"keep-body"'), true); + } finally { + delete process.env.WM_TEST_UPSTREAM; + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + upstream.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test('returns local handler error when fetch(Request) uses a consumed body', async () => { + let upstreamHits = 0; + + const upstream = createServer((req, res) => { + upstreamHits += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + const upstreamPort = await listen(upstream); + process.env.WM_TEST_UPSTREAM = `http://127.0.0.1:${upstreamPort}`; + + const localApi = await setupApiDir({ + 'request-consumed.js': ` + export default async function handler() { + const request = new Request(\`\${process.env.WM_TEST_UPSTREAM}/echo\`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ secret: 'used-body' }), + }); + await request.text(); + await fetch(request); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/request-consumed`); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error, 'Local handler error'); + assert.equal(typeof body.reason, 'string'); + assert.equal(body.reason.length > 0, true); + assert.equal(upstreamHits, 0); + } finally { + delete process.env.WM_TEST_UPSTREAM; + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + upstream.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test('strips browser origin headers when proxying to cloud fallback (cloudFallback enabled)', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + cloudFallback: 'true', + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/no-local-handler`, { + headers: { Origin: 'https://tauri.localhost' }, + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, 'remote'); + assert.equal(body.origin, null); + assert.equal(remote.origins[0], null); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('responds to OPTIONS preflight with CORS headers', async () => { + const localApi = await setupApiDir({ + 'data.js': ` + export default async function handler() { + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/data`, { method: 'OPTIONS' }); + assert.equal(response.status, 204); + assert.equal(response.headers.get('access-control-allow-methods'), 'GET, POST, PUT, DELETE, OPTIONS'); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('preserves Origin in Vary when gzip compression is applied', async () => { + const localApi = await setupApiDir({ + 'large.js': ` + export default async function handler() { + return new Response(JSON.stringify({ payload: 'x'.repeat(4096) }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/large`, { + headers: { + Origin: 'https://tauri.localhost', + 'Accept-Encoding': 'gzip', + }, + }); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), 'https://tauri.localhost'); + assert.equal(response.headers.get('content-encoding'), 'gzip'); + + const vary = (response.headers.get('vary') || '') + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + + assert.equal(vary.includes('origin'), true); + assert.equal(vary.includes('accept-encoding'), true); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('resolves packaged tauri resource layout under _up_/api', async () => { + const remote = await setupRemoteServer(); + const localResource = await setupResourceDirWithUpApi({ + 'live.js': ` + export default async function handler() { + return new Response(JSON.stringify({ source: 'local-up' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + resourceDir: localResource.resourceDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + assert.equal(app.context.apiDir, localResource.apiDir); + assert.equal(app.routes.length, 1); + + const response = await fetch(`http://127.0.0.1:${port}/api/live`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, 'local-up'); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localResource.cleanup(); + await remote.close(); + } +}); + +// ── Ollama env key allowlist + validation tests ── + +test('accepts OLLAMA_API_URL via /api/local-env-update', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'http://127.0.0.1:11434' }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.ok, true); + assert.equal(body.key, 'OLLAMA_API_URL'); + assert.equal(process.env.OLLAMA_API_URL, 'http://127.0.0.1:11434'); + } finally { + delete process.env.OLLAMA_API_URL; + await app.close(); + await localApi.cleanup(); + } +}); + +test('accepts OLLAMA_MODEL via /api/local-env-update', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_MODEL', value: 'llama3.1:8b' }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.ok, true); + assert.equal(body.key, 'OLLAMA_MODEL'); + assert.equal(process.env.OLLAMA_MODEL, 'llama3.1:8b'); + } finally { + delete process.env.OLLAMA_MODEL; + await app.close(); + await localApi.cleanup(); + } +}); + +test('rejects unknown key via /api/local-env-update', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'NOT_ALLOWED_KEY', value: 'some-value' }), + }); + assert.equal(response.status, 403); + const body = await response.json(); + assert.equal(body.error, 'key not in allowlist'); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('validates OLLAMA_API_URL via /api/local-validate-secret (reachable endpoint)', async () => { + // Stand up a mock Ollama server that responds to /v1/models + const mockOllama = createServer((req, res) => { + if (req.url === '/v1/models') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ data: [{ id: 'llama3.1:8b' }] })); + } else { + res.writeHead(404); + res.end('not found'); + } + }); + const ollamaPort = await listen(mockOllama); + + const localApi = await setupApiDir({}); + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: `http://127.0.0.1:${ollamaPort}` }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.valid, true); + assert.equal(body.message, 'Ollama endpoint verified'); + } finally { + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + mockOllama.close((err) => (err ? reject(err) : resolve())); + }); + } +}); + +test('validates LM Studio style /v1 base URL via /api/local-validate-secret', async () => { + const mockOpenAiCompatible = createServer((req, res) => { + if (req.url === '/v1/models') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ data: [{ id: 'qwen2.5-7b-instruct' }] })); + } else { + res.writeHead(404); + res.end('not found'); + } + }); + const providerPort = await listen(mockOpenAiCompatible); + + const localApi = await setupApiDir({}); + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: `http://127.0.0.1:${providerPort}/v1` }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.valid, true); + assert.equal(body.message, 'Ollama endpoint verified'); + } finally { + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + mockOpenAiCompatible.close((err) => (err ? reject(err) : resolve())); + }); + } +}); + +test('validates OLLAMA_API_URL via native /api/tags fallback', async () => { + // Mock server that only responds to /api/tags (not /v1/models) + const mockOllama = createServer((req, res) => { + if (req.url === '/api/tags') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ models: [{ name: 'llama3.1:8b' }] })); + } else { + res.writeHead(404); + res.end('not found'); + } + }); + const ollamaPort = await listen(mockOllama); + + const localApi = await setupApiDir({}); + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: `http://127.0.0.1:${ollamaPort}` }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.valid, true); + assert.equal(body.message, 'Ollama endpoint verified (native API)'); + } finally { + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + mockOllama.close((err) => (err ? reject(err) : resolve())); + }); + } +}); + +test('validates OLLAMA_MODEL stores model name', async () => { + const localApi = await setupApiDir({}); + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_MODEL', value: 'mistral:7b' }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.valid, true); + assert.equal(body.message, 'Model name stored'); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('rejects OLLAMA_API_URL with non-http protocol', async () => { + const localApi = await setupApiDir({}); + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'ftp://127.0.0.1:11434' }), + }); + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.valid, false); + assert.equal(body.message, 'Must be an http(s) URL'); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('treats Cloudflare challenge 403 as soft-pass during secret validation', async () => { + const localApi = await setupApiDir({}); + const restoreHttps = mockHttpsRequestOnce({ + statusCode: 403, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cf-ray': 'abc123', + }, + body: 'Attention RequiredCloudflare Ray ID: 123', + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await postJsonViaHttp(`http://127.0.0.1:${port}/api/local-validate-secret`, { + key: 'GROQ_API_KEY', + value: 'dummy-key', + }); + assert.equal(response.status, 200); + assert.equal(response.json?.valid, true); + assert.equal(response.json?.message, 'Groq key stored (Cloudflare blocked verification)'); + } finally { + restoreHttps(); + await app.close(); + await localApi.cleanup(); + } +}); + +test('does not soft-pass provider auth 403 JSON responses even with cf-ray header', async () => { + const localApi = await setupApiDir({}); + const restoreHttps = mockHttpsRequestOnce({ + statusCode: 403, + headers: { + 'content-type': 'application/json', + 'cf-ray': 'abc123', + }, + body: JSON.stringify({ error: 'invalid api key' }), + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await postJsonViaHttp(`http://127.0.0.1:${port}/api/local-validate-secret`, { + key: 'GROQ_API_KEY', + value: 'invalid-key', + }); + assert.equal(response.status, 422); + assert.equal(response.json?.valid, false); + assert.equal(response.json?.message, 'Groq rejected this key'); + } finally { + restoreHttps(); + await app.close(); + await localApi.cleanup(); + } +}); + +test('auth-required behavior unchanged — rejects unauthenticated requests when token is set', async () => { + const localApi = await setupApiDir({}); + const originalToken = process.env.LOCAL_API_TOKEN; + process.env.LOCAL_API_TOKEN = 'secret-token-123'; + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + // Request without auth header should be rejected + const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'http://127.0.0.1:11434' }), + }); + assert.equal(response.status, 401); + const body = await response.json(); + assert.equal(body.error, 'Unauthorized'); + + // Request with correct auth header should succeed + const authedResponse = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer secret-token-123', + }, + body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'http://127.0.0.1:11434' }), + }); + assert.equal(authedResponse.status, 200); + } finally { + if (originalToken !== undefined) { + process.env.LOCAL_API_TOKEN = originalToken; + } else { + delete process.env.LOCAL_API_TOKEN; + } + delete process.env.OLLAMA_API_URL; + await app.close(); + await localApi.cleanup(); + } +}); + + +test('prefers Brotli compression for payloads larger than 1KB when supported by the client', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'compression-check.js': ` + export default async function handler() { + const payload = { value: 'x'.repeat(3000) }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/compression-check`, { + headers: { 'Accept-Encoding': 'gzip, br' }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-encoding'), 'br'); + + const compressed = Buffer.from(await response.arrayBuffer()); + const decompressed = brotliDecompressSync(compressed).toString('utf8'); + const body = JSON.parse(decompressed); + assert.equal(body.value.length, 3000); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +test('uses gzip compression when Brotli is unavailable but gzip is accepted', async () => { + const remote = await setupRemoteServer(); + const localApi = await setupApiDir({ + 'compression-check.js': ` + export default async function handler() { + const payload = { value: 'x'.repeat(3000) }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + remoteBase: remote.remoteBase, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/compression-check`, { + headers: { 'Accept-Encoding': 'gzip' }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-encoding'), 'gzip'); + + const compressed = Buffer.from(await response.arrayBuffer()); + const decompressed = gunzipSync(compressed).toString('utf8'); + const body = JSON.parse(decompressed); + assert.equal(body.value.length, 3000); + assert.equal(remote.hits.length, 0); + } finally { + await app.close(); + await localApi.cleanup(); + await remote.close(); + } +}); + +// ── Security hardening tests ──────────────────────────────────────────── + +test('rejects unauthenticated requests to /api/local-status when token is set', async () => { + const localApi = await setupApiDir({}); + const originalToken = process.env.LOCAL_API_TOKEN; + process.env.LOCAL_API_TOKEN = 'security-test-token'; + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-status`); + assert.equal(response.status, 401); + const body = await response.json(); + assert.equal(body.error, 'Unauthorized'); + + // With token should succeed + const authed = await fetch(`http://127.0.0.1:${port}/api/local-status`, { + headers: { 'Authorization': 'Bearer security-test-token' }, + }); + assert.equal(authed.status, 200); + } finally { + if (originalToken !== undefined) { + process.env.LOCAL_API_TOKEN = originalToken; + } else { + delete process.env.LOCAL_API_TOKEN; + } + await app.close(); + await localApi.cleanup(); + } +}); + +test('rejects unauthenticated requests to /api/local-traffic-log when token is set', async () => { + const localApi = await setupApiDir({}); + const originalToken = process.env.LOCAL_API_TOKEN; + process.env.LOCAL_API_TOKEN = 'security-test-token'; + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-traffic-log`); + assert.equal(response.status, 401); + } finally { + if (originalToken !== undefined) { + process.env.LOCAL_API_TOKEN = originalToken; + } else { + delete process.env.LOCAL_API_TOKEN; + } + await app.close(); + await localApi.cleanup(); + } +}); + +test('rejects unauthenticated requests to /api/local-debug-toggle when token is set', async () => { + const localApi = await setupApiDir({}); + const originalToken = process.env.LOCAL_API_TOKEN; + process.env.LOCAL_API_TOKEN = 'security-test-token'; + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/local-debug-toggle`); + assert.equal(response.status, 401); + } finally { + if (originalToken !== undefined) { + process.env.LOCAL_API_TOKEN = originalToken; + } else { + delete process.env.LOCAL_API_TOKEN; + } + await app.close(); + await localApi.cleanup(); + } +}); + +test('rejects unauthenticated requests to /api/rss-proxy when token is set', async () => { + const localApi = await setupApiDir({}); + const originalToken = process.env.LOCAL_API_TOKEN; + process.env.LOCAL_API_TOKEN = 'security-test-token'; + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=https://example.com/rss`); + assert.equal(response.status, 401); + } finally { + if (originalToken !== undefined) { + process.env.LOCAL_API_TOKEN = originalToken; + } else { + delete process.env.LOCAL_API_TOKEN; + } + await app.close(); + await localApi.cleanup(); + } +}); + +test('allows unauthenticated requests to /api/service-status (health check exempt)', async () => { + const localApi = await setupApiDir({}); + const originalToken = process.env.LOCAL_API_TOKEN; + process.env.LOCAL_API_TOKEN = 'security-test-token'; + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/service-status`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.success, true); + } finally { + if (originalToken !== undefined) { + process.env.LOCAL_API_TOKEN = originalToken; + } else { + delete process.env.LOCAL_API_TOKEN; + } + await app.close(); + await localApi.cleanup(); + } +}); + +test('rss-proxy blocks requests to localhost (SSRF protection)', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://127.0.0.1:3000`); + assert.equal(response.status, 403); + const body = await response.json(); + assert.ok(body.error.includes('private') || body.error.includes('localhost')); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('rss-proxy blocks requests to private IP ranges (SSRF protection)', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + // Test 192.168.x.x range + const response1 = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://192.168.1.1/`); + assert.equal(response1.status, 403); + + // Test 10.x.x.x range + const response2 = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://10.0.0.1/`); + assert.equal(response2.status, 403); + + // Test 172.16-31.x.x range + const response3 = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://172.16.0.1/`); + assert.equal(response3.status, 403); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('rss-proxy blocks non-http protocols (SSRF protection)', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=file:///etc/passwd`); + assert.equal(response.status, 403); + const body = await response.json(); + assert.ok(body.error.includes('http')); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('rss-proxy blocks URLs with credentials (SSRF protection)', async () => { + const localApi = await setupApiDir({}); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://user:pass@example.com/rss`); + assert.equal(response.status, 403); + const body = await response.json(); + assert.ok(body.error.includes('credentials')); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('traffic log strips query strings from entries to protect privacy', async () => { + const localApi = await setupApiDir({ + 'test-endpoint.js': ` + export default async function handler() { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + `, + }); + + const app = await createLocalApiServer({ + port: 0, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + // Make a request that will be recorded in the traffic log + await fetch(`http://127.0.0.1:${port}/api/test-endpoint?secret=value&key=data`); + + // Retrieve the traffic log + const logResponse = await fetch(`http://127.0.0.1:${port}/api/local-traffic-log`); + assert.equal(logResponse.status, 200); + const logBody = await logResponse.json(); + + // Verify query strings are stripped + const entry = logBody.entries.find(e => e.path.includes('test-endpoint')); + assert.ok(entry, 'Traffic log should contain the test-endpoint entry'); + assert.equal(entry.path, '/api/test-endpoint'); + assert.ok(!entry.path.includes('secret='), 'Query string should be stripped from traffic log'); + } finally { + await app.close(); + await localApi.cleanup(); + } +}); + +test('service-status reports bound fallback port after EADDRINUSE recovery', async () => { + const blocker = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('occupied'); + }); + await listen(blocker, '127.0.0.1', 46123); + + const localApi = await setupApiDir({}); + const app = await createLocalApiServer({ + port: 46123, + apiDir: localApi.apiDir, + logger: { log() {}, warn() {}, error() {} }, + }); + const { port } = await app.start(); + + try { + assert.notEqual(port, 46123); + + const response = await fetch(`http://127.0.0.1:${port}/api/service-status`); + assert.equal(response.status, 200); + const body = await response.json(); + + assert.equal(body.local.port, port); + const localService = body.services.find((service) => service.id === 'local-api'); + assert.equal(localService.description, `Running on 127.0.0.1:${port}`); + } finally { + await app.close(); + await localApi.cleanup(); + await new Promise((resolve, reject) => { + blocker.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/src-tauri/sidecar/node/.gitkeep b/src-tauri/sidecar/node/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src-tauri/sidecar/package.json b/src-tauri/sidecar/package.json new file mode 100644 index 000000000..5ffd9800b --- /dev/null +++ b/src-tauri/sidecar/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 000000000..15b804ba9 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,1308 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use std::collections::HashMap; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use keyring::Entry; +use reqwest::Url; +use serde::Serialize; +use serde_json::{Map, Value}; +use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::{AppHandle, Manager, RunEvent, Webview, WebviewUrl, WebviewWindowBuilder, WindowEvent}; + +const DEFAULT_LOCAL_API_PORT: u16 = 46123; +const KEYRING_SERVICE: &str = "world-monitor"; +const LOCAL_API_LOG_FILE: &str = "local-api.log"; +const DESKTOP_LOG_FILE: &str = "desktop.log"; +const MENU_FILE_SETTINGS_ID: &str = "file.settings"; +const MENU_HELP_GITHUB_ID: &str = "help.github"; +#[cfg(feature = "devtools")] +const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools"; +const TRUSTED_WINDOWS: [&str; 3] = ["main", "settings", "live-channels"]; +const SUPPORTED_SECRET_KEYS: [&str; 22] = [ + "GROQ_API_KEY", + "OPENROUTER_API_KEY", + "FRED_API_KEY", + "EIA_API_KEY", + "CLOUDFLARE_API_TOKEN", + "ACLED_ACCESS_TOKEN", + "URLHAUS_AUTH_KEY", + "OTX_API_KEY", + "ABUSEIPDB_API_KEY", + "WINGBITS_API_KEY", + "WS_RELAY_URL", + "VITE_OPENSKY_RELAY_URL", + "OPENSKY_CLIENT_ID", + "OPENSKY_CLIENT_SECRET", + "AISSTREAM_API_KEY", + "VITE_WS_RELAY_URL", + "FINNHUB_API_KEY", + "NASA_FIRMS_API_KEY", + "OLLAMA_API_URL", + "OLLAMA_MODEL", + "WORLDMONITOR_API_KEY", + "WTO_API_KEY", +]; + +#[derive(Default)] +struct LocalApiState { + child: Mutex>, + token: Mutex>, + port: Mutex>, +} + +/// In-memory cache for keychain secrets. Populated once at startup to avoid +/// repeated macOS Keychain prompts (each `Entry::get_password()` triggers one). +struct SecretsCache { + secrets: Mutex>, +} + +/// In-memory mirror of persistent-cache.json. The file can grow to 10+ MB, +/// so reading/parsing/writing it on every IPC call blocks the main thread. +/// Instead, load once into RAM and serialize writes to preserve ordering. +struct PersistentCache { + data: Mutex>, + dirty: Mutex, + write_lock: Mutex<()>, +} + +impl SecretsCache { + fn load_from_keychain() -> Self { + // Try consolidated vault first — single keychain prompt + if let Ok(entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") { + if let Ok(json) = entry.get_password() { + if let Ok(map) = serde_json::from_str::>(&json) { + let secrets: HashMap = map + .into_iter() + .filter(|(k, v)| { + SUPPORTED_SECRET_KEYS.contains(&k.as_str()) && !v.trim().is_empty() + }) + .map(|(k, v)| (k, v.trim().to_string())) + .collect(); + return SecretsCache { + secrets: Mutex::new(secrets), + }; + } + } + } + + // Migration: read individual keys (old format), consolidate into vault. + // This triggers one keychain prompt per key — happens only once. + let mut secrets = HashMap::new(); + for key in SUPPORTED_SECRET_KEYS.iter() { + if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) { + if let Ok(value) = entry.get_password() { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + secrets.insert((*key).to_string(), trimmed); + } + } + } + } + + // Write consolidated vault and clean up individual entries + if !secrets.is_empty() { + if let Ok(json) = serde_json::to_string(&secrets) { + if let Ok(vault_entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") { + if vault_entry.set_password(&json).is_ok() { + for key in SUPPORTED_SECRET_KEYS.iter() { + if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) { + let _ = entry.delete_credential(); + } + } + } + } + } + } + + SecretsCache { + secrets: Mutex::new(secrets), + } + } +} + +impl PersistentCache { + fn load(path: &Path) -> Self { + let data = if path.exists() { + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() + } else { + Map::new() + }; + PersistentCache { + data: Mutex::new(data), + dirty: Mutex::new(false), + write_lock: Mutex::new(()), + } + } + + fn get(&self, key: &str) -> Option { + let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); + data.get(key).cloned() + } + + /// Flush to disk only if dirty. Returns Ok(true) if written. + fn flush(&self, path: &Path) -> Result { + let _write_guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); + + let is_dirty = { + let dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty + }; + if !is_dirty { + return Ok(false); + } + + let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); + let serialized = serde_json::to_string(&Value::Object(data.clone())) + .map_err(|e| format!("Failed to serialize cache: {e}"))?; + drop(data); + std::fs::write(path, serialized) + .map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; + let mut dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = false; + Ok(true) + } +} + +#[derive(Serialize)] +struct DesktopRuntimeInfo { + os: String, + arch: String, + local_api_port: Option, +} + +fn save_vault(cache: &HashMap) -> Result<(), String> { + let json = + serde_json::to_string(cache).map_err(|e| format!("Failed to serialize vault: {e}"))?; + let entry = Entry::new(KEYRING_SERVICE, "secrets-vault") + .map_err(|e| format!("Keyring init failed: {e}"))?; + entry + .set_password(&json) + .map_err(|e| format!("Failed to write vault: {e}"))?; + Ok(()) +} + +fn generate_local_token() -> String { + let mut buf = [0u8; 32]; + getrandom::getrandom(&mut buf).expect("OS CSPRNG unavailable"); + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +fn require_trusted_window(label: &str) -> Result<(), String> { + if TRUSTED_WINDOWS.contains(&label) { + Ok(()) + } else { + Err(format!("Command not allowed from window '{label}'")) + } +} + +#[tauri::command] +fn get_local_api_token(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { + require_trusted_window(webview.label())?; + let token = state + .token + .lock() + .map_err(|_| "Failed to lock local API token".to_string())?; + token + .clone() + .ok_or_else(|| "Token not generated".to_string()) +} + +#[tauri::command] +fn get_desktop_runtime_info(state: tauri::State<'_, LocalApiState>) -> DesktopRuntimeInfo { + let port = state.port.lock().ok().and_then(|g| *g); + DesktopRuntimeInfo { + os: env::consts::OS.to_string(), + arch: env::consts::ARCH.to_string(), + local_api_port: port, + } +} + +#[tauri::command] +fn get_local_api_port(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { + require_trusted_window(webview.label())?; + state.port.lock() + .map_err(|_| "Failed to lock port state".to_string())? + .ok_or_else(|| "Port not yet assigned".to_string()) +} + +#[tauri::command] +fn list_supported_secret_keys() -> Vec { + SUPPORTED_SECRET_KEYS + .iter() + .map(|key| (*key).to_string()) + .collect() +} + +#[tauri::command] +fn get_secret( + webview: Webview, + key: String, + cache: tauri::State<'_, SecretsCache>, +) -> Result, String> { + require_trusted_window(webview.label())?; + if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) { + return Err(format!("Unsupported secret key: {key}")); + } + let secrets = cache + .secrets + .lock() + .map_err(|_| "Lock poisoned".to_string())?; + Ok(secrets.get(&key).cloned()) +} + +#[tauri::command] +fn get_all_secrets(webview: Webview, cache: tauri::State<'_, SecretsCache>) -> Result, String> { + require_trusted_window(webview.label())?; + Ok(cache + .secrets + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()) +} + +#[tauri::command] +fn set_secret( + webview: Webview, + key: String, + value: String, + cache: tauri::State<'_, SecretsCache>, +) -> Result<(), String> { + require_trusted_window(webview.label())?; + if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) { + return Err(format!("Unsupported secret key: {key}")); + } + let mut secrets = cache + .secrets + .lock() + .map_err(|_| "Lock poisoned".to_string())?; + let trimmed = value.trim().to_string(); + // Build proposed state, persist first, then commit to cache + let mut proposed = secrets.clone(); + if trimmed.is_empty() { + proposed.remove(&key); + } else { + proposed.insert(key, trimmed); + } + save_vault(&proposed)?; + *secrets = proposed; + Ok(()) +} + +#[tauri::command] +fn delete_secret(webview: Webview, key: String, cache: tauri::State<'_, SecretsCache>) -> Result<(), String> { + require_trusted_window(webview.label())?; + if !SUPPORTED_SECRET_KEYS.contains(&key.as_str()) { + return Err(format!("Unsupported secret key: {key}")); + } + let mut secrets = cache + .secrets + .lock() + .map_err(|_| "Lock poisoned".to_string())?; + let mut proposed = secrets.clone(); + proposed.remove(&key); + save_vault(&proposed)?; + *secrets = proposed; + Ok(()) +} + +fn cache_file_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create app data directory {}: {e}", dir.display()))?; + Ok(dir.join("persistent-cache.json")) +} + +#[tauri::command] +fn read_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result, String> { + require_trusted_window(webview.label())?; + Ok(cache.get(&key)) +} + +#[tauri::command] +fn delete_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result<(), String> { + require_trusted_window(webview.label())?; + { + let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); + data.remove(&key); + } + { + let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = true; + } + // Disk flush deferred to exit handler (cache.flush) — avoids blocking main thread + Ok(()) +} + +#[tauri::command] +fn write_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String, value: String) -> Result<(), String> { + require_trusted_window(webview.label())?; + let parsed_value: Value = serde_json::from_str(&value) + .map_err(|e| format!("Invalid cache payload JSON: {e}"))?; + let _write_guard = cache.write_lock.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); + data.insert(key, parsed_value); + } + { + let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = true; + } + + // Flush synchronously under write lock so concurrent writes cannot reorder. + let path = cache_file_path(&app)?; + let data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); + let serialized = serde_json::to_string(&Value::Object(data.clone())) + .map_err(|e| format!("Failed to serialize cache: {e}"))?; + drop(data); + std::fs::write(&path, &serialized) + .map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; + { + let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = false; + } + Ok(()) +} + +fn logs_dir_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_log_dir() + .map_err(|e| format!("Failed to resolve app log dir: {e}"))?; + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create app log dir {}: {e}", dir.display()))?; + Ok(dir) +} + +fn sidecar_log_path(app: &AppHandle) -> Result { + Ok(logs_dir_path(app)?.join(LOCAL_API_LOG_FILE)) +} + +fn desktop_log_path(app: &AppHandle) -> Result { + Ok(logs_dir_path(app)?.join(DESKTOP_LOG_FILE)) +} + +fn append_desktop_log(app: &AppHandle, level: &str, message: &str) { + let Ok(path) = desktop_log_path(app) else { + return; + }; + + let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else { + return; + }; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let _ = writeln!(file, "[{timestamp}][{level}] {message}"); +} + +fn open_in_shell(arg: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut command = { + let mut cmd = Command::new("open"); + cmd.arg(arg); + cmd + }; + + #[cfg(target_os = "windows")] + let mut command = { + let mut cmd = Command::new("explorer"); + cmd.arg(arg); + cmd + }; + + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let mut cmd = Command::new("xdg-open"); + cmd.arg(arg); + cmd + }; + + command + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open {}: {e}", arg)) +} + +fn open_path_in_shell(path: &Path) -> Result<(), String> { + open_in_shell(&path.to_string_lossy()) +} + +#[tauri::command] +fn open_url(url: String) -> Result<(), String> { + let parsed = Url::parse(&url).map_err(|_| "Invalid URL".to_string())?; + + match parsed.scheme() { + "https" => open_in_shell(parsed.as_str()), + "http" => match parsed.host_str() { + Some("localhost") | Some("127.0.0.1") => open_in_shell(parsed.as_str()), + _ => Err("Only https:// URLs are allowed (http:// only for localhost)".to_string()), + }, + _ => Err("Only https:// URLs are allowed (http:// only for localhost)".to_string()), + } +} + +fn open_logs_folder_impl(app: &AppHandle) -> Result { + let dir = logs_dir_path(app)?; + open_path_in_shell(&dir)?; + Ok(dir) +} + +fn open_sidecar_log_impl(app: &AppHandle) -> Result { + let log_path = sidecar_log_path(app)?; + if !log_path.exists() { + File::create(&log_path) + .map_err(|e| format!("Failed to create sidecar log {}: {e}", log_path.display()))?; + } + open_path_in_shell(&log_path)?; + Ok(log_path) +} + +#[tauri::command] +fn open_logs_folder(app: AppHandle) -> Result { + open_logs_folder_impl(&app).map(|path| path.display().to_string()) +} + +#[tauri::command] +fn open_sidecar_log_file(app: AppHandle) -> Result { + open_sidecar_log_impl(&app).map(|path| path.display().to_string()) +} + +#[tauri::command] +async fn open_settings_window_command(app: AppHandle) -> Result<(), String> { + open_settings_window(&app) +} + +#[tauri::command] +fn close_settings_window(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("settings") { + window + .close() + .map_err(|e| format!("Failed to close settings window: {e}"))?; + } + Ok(()) +} + +#[tauri::command] +async fn open_live_channels_window_command( + app: AppHandle, + base_url: Option, +) -> Result<(), String> { + open_live_channels_window(&app, base_url) +} + +#[tauri::command] +fn close_live_channels_window(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("live-channels") { + window + .close() + .map_err(|e| format!("Failed to close live channels window: {e}"))?; + } + Ok(()) +} + +/// Fetch JSON from Polymarket Gamma API using native TLS (bypasses Cloudflare JA3 blocking). +/// Called from frontend when browser CORS and sidecar Node.js TLS both fail. +#[tauri::command] +async fn fetch_polymarket(webview: Webview, path: String, params: String) -> Result { + require_trusted_window(webview.label())?; + let allowed = ["events", "markets", "tags"]; + let segment = path.trim_start_matches('/'); + if !allowed.iter().any(|a| segment.starts_with(a)) { + return Err("Invalid Polymarket path".into()); + } + let url = format!("https://gamma-api.polymarket.com/{}?{}", segment, params); + let client = reqwest::Client::builder() + .use_native_tls() + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + let resp = client + .get(&url) + .header("Accept", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Polymarket fetch failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("Polymarket HTTP {}", resp.status())); + } + resp.text() + .await + .map_err(|e| format!("Read body failed: {e}")) +} + +fn open_settings_window(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("settings") { + let _ = window.show(); + window + .set_focus() + .map_err(|e| format!("Failed to focus settings window: {e}"))?; + return Ok(()); + } + + let _settings_window = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into())) + .title("World Monitor Settings") + .inner_size(980.0, 760.0) + .min_inner_size(820.0, 620.0) + .resizable(true) + .background_color(tauri::webview::Color(26, 28, 30, 255)) + .build() + .map_err(|e| format!("Failed to create settings window: {e}"))?; + + // On Windows/Linux, menus are per-window. Remove the inherited app menu + // from the settings window (macOS uses a shared app-wide menu bar instead). + #[cfg(not(target_os = "macos"))] + let _ = _settings_window.remove_menu(); + + Ok(()) +} + +fn open_live_channels_window(app: &AppHandle, base_url: Option) -> Result<(), String> { + if let Some(window) = app.get_webview_window("live-channels") { + let _ = window.show(); + window + .set_focus() + .map_err(|e| format!("Failed to focus live channels window: {e}"))?; + return Ok(()); + } + + // In dev, use the same origin as the main window (e.g. http://localhost:3001) so we don't + // get "connection refused" when Vite runs on a different port than devUrl. + let url = match base_url { + Some(ref origin) if !origin.is_empty() => { + let path = origin.trim_end_matches('/'); + let full_url = format!("{}/live-channels.html", path); + WebviewUrl::External(Url::parse(&full_url).map_err(|_| "Invalid base URL".to_string())?) + } + _ => WebviewUrl::App("live-channels.html".into()), + }; + + let _live_channels_window = WebviewWindowBuilder::new(app, "live-channels", url) + .title("Channel management - World Monitor") + .inner_size(680.0, 760.0) + .min_inner_size(520.0, 600.0) + .resizable(true) + .background_color(tauri::webview::Color(26, 28, 30, 255)) + .build() + .map_err(|e| format!("Failed to create live channels window: {e}"))?; + + #[cfg(not(target_os = "macos"))] + let _ = _live_channels_window.remove_menu(); + + Ok(()) +} + +fn open_youtube_login_window(app: &AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("youtube-login") { + let _ = window.show(); + window + .set_focus() + .map_err(|e| format!("Failed to focus YouTube login window: {e}"))?; + return Ok(()); + } + + let url = WebviewUrl::External( + Url::parse("https://accounts.google.com/ServiceLogin?service=youtube&continue=https://www.youtube.com/") + .map_err(|e| format!("Invalid URL: {e}"))? + ); + + let _yt_window = WebviewWindowBuilder::new(app, "youtube-login", url) + .title("Sign in to YouTube") + .inner_size(500.0, 700.0) + .resizable(true) + .build() + .map_err(|e| format!("Failed to create YouTube login window: {e}"))?; + + #[cfg(not(target_os = "macos"))] + let _ = _yt_window.remove_menu(); + + Ok(()) +} + +#[tauri::command] +async fn open_youtube_login(app: AppHandle) -> Result<(), String> { + open_youtube_login_window(&app) +} + +fn build_app_menu(handle: &AppHandle) -> tauri::Result> { + let settings_item = MenuItem::with_id( + handle, + MENU_FILE_SETTINGS_ID, + "Settings...", + true, + Some("CmdOrCtrl+,"), + )?; + let separator = PredefinedMenuItem::separator(handle)?; + let quit_item = PredefinedMenuItem::quit(handle, Some("Quit"))?; + let file_menu = Submenu::with_items( + handle, + "File", + true, + &[&settings_item, &separator, &quit_item], + )?; + + let about_metadata = AboutMetadata { + name: Some("World Monitor".into()), + version: Some(env!("CARGO_PKG_VERSION").into()), + copyright: Some("\u{00a9} 2025 Elie Habib".into()), + website: Some("https://worldmonitor.app".into()), + website_label: Some("worldmonitor.app".into()), + ..Default::default() + }; + let about_item = + PredefinedMenuItem::about(handle, Some("About World Monitor"), Some(about_metadata))?; + let github_item = MenuItem::with_id( + handle, + MENU_HELP_GITHUB_ID, + "GitHub Repository", + true, + None::<&str>, + )?; + let help_separator = PredefinedMenuItem::separator(handle)?; + + #[cfg(feature = "devtools")] + let help_menu = { + let devtools_item = MenuItem::with_id( + handle, + MENU_HELP_DEVTOOLS_ID, + "Toggle Developer Tools", + true, + Some("CmdOrCtrl+Alt+I"), + )?; + Submenu::with_items( + handle, + "Help", + true, + &[&about_item, &help_separator, &github_item, &devtools_item], + )? + }; + + #[cfg(not(feature = "devtools"))] + let help_menu = Submenu::with_items( + handle, + "Help", + true, + &[&about_item, &help_separator, &github_item], + )?; + + let edit_menu = { + let undo = PredefinedMenuItem::undo(handle, None)?; + let redo = PredefinedMenuItem::redo(handle, None)?; + let sep1 = PredefinedMenuItem::separator(handle)?; + let cut = PredefinedMenuItem::cut(handle, None)?; + let copy = PredefinedMenuItem::copy(handle, None)?; + let paste = PredefinedMenuItem::paste(handle, None)?; + let select_all = PredefinedMenuItem::select_all(handle, None)?; + Submenu::with_items( + handle, + "Edit", + true, + &[&undo, &redo, &sep1, &cut, ©, &paste, &select_all], + )? + }; + + Menu::with_items(handle, &[&file_menu, &edit_menu, &help_menu]) +} + +fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { + match event.id().as_ref() { + MENU_FILE_SETTINGS_ID => { + if let Err(err) = open_settings_window(app) { + append_desktop_log(app, "ERROR", &format!("settings menu failed: {err}")); + eprintln!("[tauri] settings menu failed: {err}"); + } + } + MENU_HELP_GITHUB_ID => { + let _ = open_in_shell("https://github.com/koala73/worldmonitor"); + } + #[cfg(feature = "devtools")] + MENU_HELP_DEVTOOLS_ID => { + if let Some(window) = app.get_webview_window("main") { + if window.is_devtools_open() { + window.close_devtools(); + } else { + window.open_devtools(); + } + } + } + _ => {} + } +} + +/// Strip Windows extended-length path prefixes that `canonicalize()` adds. +/// Preserve UNC semantics: `\\?\UNC\server\share\...` must become +/// `\\server\share\...` (not `UNC\server\share\...`). +fn sanitize_path_for_node(p: &Path) -> String { + let s = p.to_string_lossy(); + if let Some(stripped_unc) = s.strip_prefix("\\\\?\\UNC\\") { + format!("\\\\{stripped_unc}") + } else if let Some(stripped) = s.strip_prefix("\\\\?\\") { + stripped.to_string() + } else { + s.into_owned() + } +} + +#[cfg(test)] +mod sanitize_path_tests { + use super::sanitize_path_for_node; + use std::path::Path; + + #[test] + fn strips_extended_drive_prefix() { + let raw = Path::new(r"\\?\C:\Program Files\nodejs\node.exe"); + assert_eq!( + sanitize_path_for_node(raw), + r"C:\Program Files\nodejs\node.exe".to_string() + ); + } + + #[test] + fn strips_extended_unc_prefix_and_preserves_unc_root() { + let raw = Path::new(r"\\?\UNC\server\share\sidecar\local-api-server.mjs"); + assert_eq!( + sanitize_path_for_node(raw), + r"\\server\share\sidecar\local-api-server.mjs".to_string() + ); + } + + #[test] + fn leaves_standard_paths_unchanged() { + let raw = Path::new(r"C:\Users\alice\sidecar\local-api-server.mjs"); + assert_eq!( + sanitize_path_for_node(raw), + r"C:\Users\alice\sidecar\local-api-server.mjs".to_string() + ); + } +} + +fn local_api_paths(app: &AppHandle) -> (PathBuf, PathBuf) { + let resource_dir = app + .path() + .resource_dir() + .unwrap_or_else(|_| PathBuf::from(".")); + + let sidecar_script = if cfg!(debug_assertions) { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("sidecar/local-api-server.mjs") + } else { + resource_dir.join("sidecar/local-api-server.mjs") + }; + + let api_dir_root = if cfg!(debug_assertions) { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + } else { + let direct_api = resource_dir.join("api"); + let lifted_root = resource_dir.join("_up_"); + let lifted_api = lifted_root.join("api"); + if direct_api.exists() { + resource_dir + } else if lifted_api.exists() { + lifted_root + } else { + resource_dir + } + }; + + (sidecar_script, api_dir_root) +} + +fn resolve_node_binary(app: &AppHandle) -> Option { + if let Ok(explicit) = env::var("LOCAL_API_NODE_BIN") { + let explicit_path = PathBuf::from(explicit); + if explicit_path.is_file() { + return Some(explicit_path); + } + append_desktop_log( + app, + "WARN", + &format!( + "LOCAL_API_NODE_BIN is set but not a valid file: {}", + explicit_path.display() + ), + ); + } + + if !cfg!(debug_assertions) { + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + if let Ok(resource_dir) = app.path().resource_dir() { + let bundled = resource_dir.join("sidecar").join("node").join(node_name); + if bundled.is_file() { + return Some(bundled); + } + } + } + + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + if let Some(path_var) = env::var_os("PATH") { + for dir in env::split_paths(&path_var) { + let candidate = dir.join(node_name); + if candidate.is_file() { + return Some(candidate); + } + } + } + + let common_locations = if cfg!(windows) { + vec![ + PathBuf::from(r"C:\Program Files\nodejs\node.exe"), + PathBuf::from(r"C:\Program Files (x86)\nodejs\node.exe"), + ] + } else { + vec![ + PathBuf::from("/opt/homebrew/bin/node"), + PathBuf::from("/usr/local/bin/node"), + PathBuf::from("/usr/bin/node"), + PathBuf::from("/opt/local/bin/node"), + ] + }; + + common_locations.into_iter().find(|path| path.is_file()) +} + +fn read_port_file(path: &Path, timeout_ms: u64) -> Option { + let start = std::time::Instant::now(); + let interval = std::time::Duration::from_millis(100); + let timeout = std::time::Duration::from_millis(timeout_ms); + while start.elapsed() < timeout { + if let Ok(contents) = fs::read_to_string(path) { + if let Ok(port) = contents.trim().parse::() { + if port > 0 { + return Some(port); + } + } + } + std::thread::sleep(interval); + } + None +} + +fn start_local_api(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut slot = state + .child + .lock() + .map_err(|_| "Failed to lock local API state".to_string())?; + if slot.is_some() { + return Ok(()); + } + + // Clear port state for fresh start + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = None; + } + + let (script, resource_root) = local_api_paths(app); + if !script.exists() { + return Err(format!( + "Local API sidecar script missing at {}", + script.display() + )); + } + let node_binary = resolve_node_binary(app).ok_or_else(|| { + "Node.js executable not found. Install Node 18+ or set LOCAL_API_NODE_BIN".to_string() + })?; + + let port_file = logs_dir_path(app)?.join("sidecar.port"); + let _ = fs::remove_file(&port_file); + + let log_path = sidecar_log_path(app)?; + let log_file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .map_err(|e| format!("Failed to open local API log {}: {e}", log_path.display()))?; + let log_file_err = log_file + .try_clone() + .map_err(|e| format!("Failed to clone local API log handle: {e}"))?; + + append_desktop_log( + app, + "INFO", + &format!( + "starting local API sidecar script={} resource_root={} log={}", + script.display(), + resource_root.display(), + log_path.display() + ), + ); + append_desktop_log( + app, + "INFO", + &format!("resolved node binary={}", node_binary.display()), + ); + append_desktop_log( + app, + "INFO", + &format!( + "local API sidecar preferred port={} port_file={}", + DEFAULT_LOCAL_API_PORT, + port_file.display() + ), + ); + + // Generate a unique token for local API auth (prevents other local processes from accessing sidecar) + let mut token_slot = state + .token + .lock() + .map_err(|_| "Failed to lock token slot")?; + if token_slot.is_none() { + *token_slot = Some(generate_local_token()); + } + let local_api_token = token_slot.clone().unwrap(); + drop(token_slot); + + let mut cmd = Command::new(&node_binary); + #[cfg(windows)] + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW — hide the node.exe console + // Sanitize paths for Node.js on Windows: strip \\?\ UNC prefix and set + // explicit working directory to avoid bare drive-letter CWD issues that + // cause EISDIR errors in Node.js module resolution. + let script_for_node = sanitize_path_for_node(&script); + let resource_for_node = sanitize_path_for_node(&resource_root); + append_desktop_log( + app, + "INFO", + &format!("node args: script={script_for_node} resource_dir={resource_for_node}"), + ); + let data_dir = logs_dir_path(app) + .map(|p| sanitize_path_for_node(&p)) + .unwrap_or_else(|_| resource_for_node.clone()); + cmd.arg(&script_for_node) + .env("LOCAL_API_PORT", DEFAULT_LOCAL_API_PORT.to_string()) + .env("LOCAL_API_PORT_FILE", &port_file) + .env("LOCAL_API_RESOURCE_DIR", &resource_for_node) + .env("LOCAL_API_DATA_DIR", &data_dir) + .env("LOCAL_API_MODE", "tauri-sidecar") + .env("LOCAL_API_TOKEN", &local_api_token) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_file_err)); + if let Some(parent) = script.parent() { + cmd.current_dir(parent); + } + + // Pass cached keychain secrets to sidecar as env vars (no keychain re-read) + let mut secret_count = 0u32; + let secrets_cache = app.state::(); + if let Ok(secrets) = secrets_cache.secrets.lock() { + for (key, value) in secrets.iter() { + cmd.env(key, value); + secret_count += 1; + } + } + append_desktop_log( + app, + "INFO", + &format!("injected {secret_count} keychain secrets into sidecar env"), + ); + + // Inject build-time secrets (CI) with runtime env fallback (dev) + if let Some(url) = option_env!("CONVEX_URL") { + cmd.env("CONVEX_URL", url); + } else if let Ok(url) = std::env::var("CONVEX_URL") { + cmd.env("CONVEX_URL", url); + } + + let child = cmd + .spawn() + .map_err(|e| format!("Failed to launch local API: {e}"))?; + append_desktop_log( + app, + "INFO", + &format!("local API sidecar started pid={}", child.id()), + ); + *slot = Some(child); + drop(slot); + + // Wait for sidecar to write confirmed port (up to 5s) + if let Some(confirmed_port) = read_port_file(&port_file, 5000) { + append_desktop_log( + app, + "INFO", + &format!("sidecar confirmed port={confirmed_port}"), + ); + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = Some(confirmed_port); + } + } else { + append_desktop_log( + app, + "WARN", + "sidecar port file not found within timeout, using default", + ); + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = Some(DEFAULT_LOCAL_API_PORT); + } + } + + Ok(()) +} + +fn stop_local_api(app: &AppHandle) { + if let Ok(state) = app.try_state::().ok_or(()) { + if let Ok(mut slot) = state.child.lock() { + if let Some(mut child) = slot.take() { + let _ = child.kill(); + append_desktop_log(app, "INFO", "local API sidecar stopped"); + } + } + if let Ok(mut port_slot) = state.port.lock() { + *port_slot = None; + } + if let Ok(log_dir) = logs_dir_path(app) { + let _ = fs::remove_file(log_dir.join("sidecar.port")); + } + } +} + +#[cfg(target_os = "linux")] +fn resolve_appimage_gio_module_dir() -> Option { + let appdir = env::var_os("APPDIR")?; + let appdir = PathBuf::from(appdir); + + // Common layouts produced by AppImage/linuxdeploy on Debian and RPM families. + let preferred = [ + "usr/lib/gio/modules", + "usr/lib64/gio/modules", + "usr/lib/x86_64-linux-gnu/gio/modules", + "usr/lib/aarch64-linux-gnu/gio/modules", + "usr/lib/arm-linux-gnueabihf/gio/modules", + "lib/gio/modules", + "lib64/gio/modules", + ]; + + for relative in preferred { + let candidate = appdir.join(relative); + if candidate.is_dir() { + return Some(candidate); + } + } + + // Fallback: probe one level of arch-specific directories, e.g. usr/lib//gio/modules. + for lib_root in ["usr/lib", "usr/lib64", "lib", "lib64"] { + let root = appdir.join(lib_root); + if !root.is_dir() { + continue; + } + let entries = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let candidate = entry.path().join("gio/modules"); + if candidate.is_dir() { + return Some(candidate); + } + } + } + + None +} + +fn main() { + // Work around WebKitGTK rendering issues on Linux that can cause blank white + // screens. DMA-BUF renderer failures are common with NVIDIA drivers and on + // immutable distros (e.g. Bazzite/Fedora Atomic). Setting the env var before + // WebKit initialises forces a software fallback path. Only set when the user + // hasn't explicitly configured the variable. + #[cfg(target_os = "linux")] + { + if env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + // SAFETY: called before any threads are spawned (Tauri hasn't started yet). + unsafe { env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") }; + } + + // On Wayland-only compositors (e.g. niri, river, sway without XWayland), + // GTK3 may fail to initialise if it defaults to X11 backend first and no + // DISPLAY is set. Explicitly prefer the Wayland backend when a Wayland + // display is available. Falls back to X11 if Wayland init fails. + if env::var_os("WAYLAND_DISPLAY").is_some() && env::var_os("GDK_BACKEND").is_none() { + unsafe { env::set_var("GDK_BACKEND", "wayland,x11") }; + } + + // Work around GLib version mismatch when running as an AppImage on newer + // distros. The AppImage bundles GLib from the CI build system (Ubuntu + // 24.04, GLib 2.80). Host GIO modules (e.g. GVFS's libgvfsdbus.so) may + // link against newer GLib symbols absent in the bundled copy, producing: + // "undefined symbol: g_task_set_static_name" + // Point GIO_MODULE_DIR at the AppImage's bundled modules to isolate from + // host libraries. Also disable the WebKit bubblewrap sandbox which fails + // inside AppImage's FUSE mount (causes blank screen on many distros). + if env::var_os("APPIMAGE").is_some() && env::var_os("GIO_MODULE_DIR").is_none() { + if let Some(module_dir) = resolve_appimage_gio_module_dir() { + unsafe { env::set_var("GIO_MODULE_DIR", &module_dir) }; + } else if env::var_os("GIO_USE_VFS").is_none() { + // Last-resort fallback: prefer local VFS backend if module path + // discovery fails, which reduces GVFS dependency surface. + unsafe { env::set_var("GIO_USE_VFS", "local") }; + eprintln!( + "[tauri] APPIMAGE detected but bundled gio/modules not found; using GIO_USE_VFS=local fallback" + ); + } + } + + // WebKit2GTK's bubblewrap sandbox can fail inside an AppImage FUSE + // mount, causing blank white screens. Disable it when running as + // AppImage — the AppImage itself already provides isolation. + if env::var_os("APPIMAGE").is_some() { + // WebKitGTK 2.39.3+ deprecated WEBKIT_FORCE_SANDBOX and now expects + // WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 instead. + if env::var_os("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS").is_none() { + unsafe { env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") }; + } + // Keep the legacy var for older WebKitGTK releases that still use it. + if env::var_os("WEBKIT_FORCE_SANDBOX").is_none() { + unsafe { env::set_var("WEBKIT_FORCE_SANDBOX", "0") }; + } + // Prevent GTK from loading host input-method modules that may + // link against incompatible library versions. + if env::var_os("GTK_IM_MODULE").is_none() { + unsafe { env::set_var("GTK_IM_MODULE", "gtk-im-context-simple") }; + } + + // The linuxdeploy GStreamer hook force-overrides GST_PLUGIN_SYSTEM_PATH_1_0 + // and GST_PLUGIN_PATH_1_0 to only contain bundled plugins. The AppImage + // bundles GStreamer from the CI build system (Ubuntu 24.04, GStreamer 1.24) + // but does NOT bundle codec plugins (gst-libav, fakevideosink from + // gst-plugins-bad). On hosts with newer GStreamer (e.g. Arch with 1.28), + // the bundled-only path means host plugins are invisible → WebKit can't + // play video. Append host plugin directories as fallback so the system's + // codec plugins are discoverable. + let host_gst_dirs = [ + "/usr/lib/x86_64-linux-gnu/gstreamer-1.0", + "/usr/lib/gstreamer-1.0", + "/usr/lib64/gstreamer-1.0", + "/usr/lib/aarch64-linux-gnu/gstreamer-1.0", + ]; + let existing: Vec = host_gst_dirs + .iter() + .filter(|d| std::path::Path::new(d).is_dir()) + .map(|d| d.to_string()) + .collect(); + if !existing.is_empty() { + let suffix = existing.join(":"); + for var in ["GST_PLUGIN_PATH_1_0", "GST_PLUGIN_SYSTEM_PATH_1_0"] { + let current = env::var(var).unwrap_or_default(); + if !current.is_empty() { + unsafe { env::set_var(var, format!("{current}:{suffix}")) }; + } else { + unsafe { env::set_var(var, &suffix) }; + } + } + } + } + } + + tauri::Builder::default() + .menu(build_app_menu) + .on_menu_event(handle_menu_event) + .manage(LocalApiState::default()) + .manage(SecretsCache::load_from_keychain()) + .invoke_handler(tauri::generate_handler![ + list_supported_secret_keys, + get_secret, + get_all_secrets, + set_secret, + delete_secret, + get_local_api_token, + get_local_api_port, + get_desktop_runtime_info, + read_cache_entry, + write_cache_entry, + delete_cache_entry, + open_logs_folder, + open_sidecar_log_file, + open_settings_window_command, + close_settings_window, + open_live_channels_window_command, + close_live_channels_window, + open_url, + open_youtube_login, + fetch_polymarket + ]) + .setup(|app| { + // Load persistent cache into memory (avoids 14MB file I/O on every IPC call) + let cache_path = cache_file_path(&app.handle()).unwrap_or_default(); + app.manage(PersistentCache::load(&cache_path)); + + if let Err(err) = start_local_api(&app.handle()) { + append_desktop_log( + &app.handle(), + "ERROR", + &format!("local API sidecar failed to start: {err}"), + ); + eprintln!("[tauri] local API sidecar failed to start: {err}"); + } + + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while running world-monitor tauri application") + .run(|app, event| { + match &event { + // macOS: hide window on close instead of quitting (standard behavior) + #[cfg(target_os = "macos")] + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if label == "main" => { + api.prevent_close(); + if let Some(w) = app.get_webview_window("main") { + let _ = w.hide(); + } + } + // macOS: reshow window when dock icon is clicked + #[cfg(target_os = "macos")] + RunEvent::Reopen { .. } => { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + // Only macOS needs explicit re-raising to keep settings above the main window. + // On Windows, focusing the settings window here can trigger rapid focus churn + // between windows and present as a UI hang. + #[cfg(target_os = "macos")] + RunEvent::WindowEvent { + label, + event: WindowEvent::Focused(true), + .. + } if label == "main" => { + if let Some(sw) = app.get_webview_window("settings") { + let _ = sw.show(); + let _ = sw.set_focus(); + } + } + RunEvent::ExitRequested { .. } | RunEvent::Exit => { + // Flush in-memory cache to disk before quitting + if let Ok(path) = cache_file_path(app) { + if let Some(cache) = app.try_state::() { + let _ = cache.flush(&path); + } + } + stop_local_api(app); + } + _ => {} + } + }); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 000000000..8aea6cba9 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "World Monitor", + "mainBinaryName": "world-monitor", + "version": "2.5.16", + "identifier": "app.worldmonitor.desktop", + "build": { + "beforeDevCommand": "npm run build:sidecar-sebuf && npm run dev", + "beforeBuildCommand": "npm run build:desktop", + "frontendDist": "../dist", + "devUrl": "http://localhost:3000" + }, + "app": { + "windows": [ + { + "title": "World Monitor", + "width": 1440, + "height": 900, + "minWidth": 1200, + "minHeight": 720, + "resizable": true, + "fullscreen": false, + "backgroundColor": [ + 26, + 28, + 30, + 255 + ] + } + ], + "security": { + "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" + } + }, + "bundle": { + "active": true, + "targets": [ + "app", + "dmg", + "nsis", + "msi", + "appimage" + ], + "category": "Productivity", + "shortDescription": "World Monitor desktop app (supports World and Tech variants)", + "longDescription": "World Monitor desktop app for real-time global intelligence. Build with VITE_VARIANT=tech to package Tech Monitor branding and dataset defaults.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "resources": [ + "../api", + "sidecar/local-api-server.mjs", + "sidecar/package.json", + "sidecar/node", + "../data", + "../src/config" + ], + "windows": { + "digestAlgorithm": "sha256", + "timestampUrl": "https://timestamp.digicert.com" + }, + "macOS": { + "hardenedRuntime": true + }, + "linux": { + "appimage": { + "bundleMediaFramework": true + } + } + } +} diff --git a/src-tauri/tauri.finance.conf.json b/src-tauri/tauri.finance.conf.json new file mode 100644 index 000000000..f386729a4 --- /dev/null +++ b/src-tauri/tauri.finance.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Finance Monitor", + "mainBinaryName": "finance-monitor", + "identifier": "app.worldmonitor.finance.desktop", + "app": { + "windows": [ + { + "title": "Finance Monitor" + } + ] + }, + "bundle": { + "shortDescription": "Finance Monitor desktop app", + "longDescription": "Finance Monitor desktop app for real-time markets and trading intelligence.", + "targets": [ + "app", + "dmg", + "nsis", + "msi", + "appimage" + ], + "macOS": { + "hardenedRuntime": true + }, + "linux": { + "appimage": { + "bundleMediaFramework": true + } + }, + "windows": { + "digestAlgorithm": "sha256", + "timestampUrl": "https://timestamp.digicert.com" + } + } +} diff --git a/src-tauri/tauri.tech.conf.json b/src-tauri/tauri.tech.conf.json new file mode 100644 index 000000000..8a858fb68 --- /dev/null +++ b/src-tauri/tauri.tech.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Tech Monitor", + "mainBinaryName": "tech-monitor", + "identifier": "app.worldmonitor.tech.desktop", + "app": { + "windows": [ + { + "title": "Tech Monitor" + } + ] + }, + "bundle": { + "shortDescription": "Tech Monitor desktop app", + "longDescription": "Tech Monitor desktop app for real-time AI and technology intelligence.", + "targets": [ + "app", + "dmg", + "nsis", + "msi", + "appimage" + ], + "macOS": { + "hardenedRuntime": true + }, + "linux": { + "appimage": { + "bundleMediaFramework": true + } + }, + "windows": { + "digestAlgorithm": "sha256", + "timestampUrl": "https://timestamp.digicert.com" + } + } +} diff --git a/src/App.ts b/src/App.ts index c67a81595..e5541e1fc 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,1259 +1,518 @@ -import type { NewsItem, Monitor, PanelConfig, MapLayers, RelatedAsset } from '@/types'; +import type { Monitor, PanelConfig, MapLayers } from '@/types'; +import type { AppContext } from '@/app/app-context'; import { - FEEDS, - INTEL_SOURCES, - SECTORS, - COMMODITIES, - MARKET_SYMBOLS, REFRESH_INTERVALS, DEFAULT_PANELS, DEFAULT_MAP_LAYERS, + MOBILE_DEFAULT_MAP_LAYERS, STORAGE_KEYS, + SITE_VARIANT, } from '@/config'; -import { fetchCategoryFeeds, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, fetchAisSignals, initAisStream, getAisStatus, fetchCableActivity, fetchProtestEvents, getProtestStatus, initDB, updateBaseline, calculateDeviation, analyzeCorrelations, clusterNews, addToSignalHistory, saveSnapshot, cleanOldSnapshots } from '@/services'; -import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage, ExportPanel } from '@/utils'; +import { initDB, cleanOldSnapshots, isAisConfigured, initAisStream, isOutagesConfigured, disconnectAisStream } from '@/services'; +import { mlWorker } from '@/services/ml-worker'; +import { getAiFlowSettings, subscribeAiFlowChange } from '@/services/ai-flow-settings'; +import { startLearning } from '@/services/country-instability'; +import { dataFreshness } from '@/services/data-freshness'; +import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice } from '@/utils'; import type { ParsedMapUrlState } from '@/utils'; -import { - MapComponent, - NewsPanel, - MarketPanel, - HeatmapPanel, - CommoditiesPanel, - CryptoPanel, - PredictionPanel, - MonitorPanel, - Panel, - SignalModal, - PlaybackControl, - StatusPanel, - EconomicPanel, - SearchModal, -} from '@/components'; -import type { SearchResult } from '@/components/SearchModal'; -import { INTEL_HOTSPOTS, CONFLICT_ZONES, MILITARY_BASES, UNDERSEA_CABLES, NUCLEAR_FACILITIES } from '@/config/geo'; -import { PIPELINES } from '@/config/pipelines'; -import { AI_DATA_CENTERS } from '@/config/ai-datacenters'; -import { GAMMA_IRRADIATORS } from '@/config/irradiators'; -import type { PredictionMarket, MarketData, ClusteredEvent } from '@/types'; +import { SignalModal, IntelligenceGapBadge } from '@/components'; +import { isDesktopRuntime } from '@/services/runtime'; +import { trackEvent, trackDeeplinkOpened } from '@/services/analytics'; +import { preloadCountryGeometry, getCountryNameByCode } from '@/services/country-geometry'; +import { initI18n } from '@/services/i18n'; -export class App { - private container: HTMLElement; - private map: MapComponent | null = null; - private panels: Record = {}; - private newsPanels: Record = {}; - private allNews: NewsItem[] = []; - private monitors: Monitor[]; - private panelSettings: Record; - private mapLayers: MapLayers; - private signalModal: SignalModal | null = null; - private playbackControl: PlaybackControl | null = null; - private statusPanel: StatusPanel | null = null; - private exportPanel: ExportPanel | null = null; - private economicPanel: EconomicPanel | null = null; - private searchModal: SearchModal | null = null; - private latestPredictions: PredictionMarket[] = []; - private latestMarkets: MarketData[] = []; - private latestClusters: ClusteredEvent[] = []; - private isPlaybackMode = false; - private initialUrlState: ParsedMapUrlState | null = null; +import { DesktopUpdater } from '@/app/desktop-updater'; +import { CountryIntelManager } from '@/app/country-intel'; +import { SearchManager } from '@/app/search-manager'; +import { RefreshScheduler } from '@/app/refresh-scheduler'; +import { PanelLayoutManager } from '@/app/panel-layout'; +import { DataLoaderManager } from '@/app/data-loader'; +import { EventHandlerManager } from '@/app/event-handlers'; - constructor(containerId: string) { - const el = document.getElementById(containerId); - if (!el) throw new Error(`Container ${containerId} not found`); - this.container = el; +const CYBER_LAYER_ENABLED = import.meta.env.VITE_ENABLE_CYBER_LAYER === 'true'; - this.monitors = loadFromStorage(STORAGE_KEYS.monitors, []); - this.panelSettings = loadFromStorage>( - STORAGE_KEYS.panels, - DEFAULT_PANELS - ); - this.mapLayers = loadFromStorage(STORAGE_KEYS.mapLayers, DEFAULT_MAP_LAYERS); - this.initialUrlState = parseMapUrlState(window.location.search, this.mapLayers); - if (this.initialUrlState.layers) { - this.mapLayers = this.initialUrlState.layers; - } - } +export type { CountryBriefSignals } from '@/app/app-context'; - public async init(): Promise { - await initDB(); - initAisStream(); // Connect to aisstream.io for live vessel tracking - this.renderLayout(); - this.signalModal = new SignalModal(); - this.setupPlaybackControl(); - this.setupStatusPanel(); - this.setupExportPanel(); - this.setupEconomicPanel(); - this.setupSearchModal(); - this.setupEventListeners(); - this.setupUrlStateSync(); - await this.loadAllData(); - this.setupRefreshIntervals(); - this.setupSnapshotSaving(); - cleanOldSnapshots(); - } - - private setupStatusPanel(): void { - this.statusPanel = new StatusPanel(); - const headerLeft = this.container.querySelector('.header-left'); - if (headerLeft) { - headerLeft.appendChild(this.statusPanel.getElement()); - } - } - - private setupExportPanel(): void { - this.exportPanel = new ExportPanel(() => ({ - news: this.latestClusters.length > 0 ? this.latestClusters : this.allNews, - markets: this.latestMarkets, - predictions: this.latestPredictions, - timestamp: Date.now(), - })); - - const headerRight = this.container.querySelector('.header-right'); - if (headerRight) { - headerRight.insertBefore(this.exportPanel.getElement(), headerRight.firstChild); - } - } +export class App { + private state: AppContext; + private pendingDeepLinkCountry: string | null = null; - private setupEconomicPanel(): void { - const economicContainer = document.createElement('div'); - economicContainer.className = 'economic-panel-container'; - economicContainer.id = 'economicPanel'; - this.economicPanel = new EconomicPanel(economicContainer); + private panelLayout: PanelLayoutManager; + private dataLoader: DataLoaderManager; + private eventHandlers: EventHandlerManager; + private searchManager: SearchManager; + private countryIntel: CountryIntelManager; + private refreshScheduler: RefreshScheduler; + private desktopUpdater: DesktopUpdater; - // Apply initial visibility from layer settings - if (!this.mapLayers.economic) { - economicContainer.classList.add('hidden'); - } + private modules: { destroy(): void }[] = []; + private unsubAiFlow: (() => void) | null = null; - const main = this.container.querySelector('.main'); - if (main) { - main.appendChild(economicContainer); - } + constructor(containerId: string) { + const el = document.getElementById(containerId); + if (!el) throw new Error(`Container ${containerId} not found`); - // Listen for layer toggle changes - this.map?.setOnLayerChange((layer, enabled) => { - if (layer === 'economic') { - economicContainer.classList.toggle('hidden', !enabled); + const PANEL_ORDER_KEY = 'panel-order'; + const PANEL_SPANS_KEY = 'worldmonitor-panel-spans'; + + const isMobile = isMobileDevice(); + const isDesktopApp = isDesktopRuntime(); + const monitors = loadFromStorage(STORAGE_KEYS.monitors, []); + + // Use mobile-specific defaults on first load (no saved layers) + const defaultLayers = isMobile ? MOBILE_DEFAULT_MAP_LAYERS : DEFAULT_MAP_LAYERS; + + let mapLayers: MapLayers; + let panelSettings: Record; + + // Check if variant changed - reset all settings to variant defaults + const storedVariant = localStorage.getItem('worldmonitor-variant'); + const currentVariant = SITE_VARIANT; + console.log(`[App] Variant check: stored="${storedVariant}", current="${currentVariant}"`); + if (storedVariant !== currentVariant) { + // Variant changed - use defaults for new variant, clear old settings + console.log('[App] Variant changed - resetting to defaults'); + localStorage.setItem('worldmonitor-variant', currentVariant); + localStorage.removeItem(STORAGE_KEYS.mapLayers); + localStorage.removeItem(STORAGE_KEYS.panels); + localStorage.removeItem(PANEL_ORDER_KEY); + localStorage.removeItem(PANEL_SPANS_KEY); + mapLayers = { ...defaultLayers }; + panelSettings = { ...DEFAULT_PANELS }; + } else { + mapLayers = loadFromStorage(STORAGE_KEYS.mapLayers, defaultLayers); + // Happy variant: force non-happy layers off even if localStorage has stale true values + if (currentVariant === 'happy') { + const unhappyLayers: (keyof MapLayers)[] = ['conflicts', 'bases', 'hotspots', 'nuclear', 'irradiators', 'sanctions', 'military', 'protests', 'pipelines', 'waterways', 'ais', 'flights', 'spaceports', 'minerals', 'natural', 'fires', 'outages', 'cyberThreats', 'weather', 'economic', 'cables', 'datacenters', 'ucdpEvents', 'displacement', 'climate']; + unhappyLayers.forEach(layer => { mapLayers[layer] = false; }); } - // Save layer settings - this.mapLayers[layer] = enabled; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - }); - } - - private setupSearchModal(): void { - this.searchModal = new SearchModal(this.container); - - // Register static sources (hotspots, conflicts, bases) - // Include keywords in subtitle for better searchability - this.searchModal.registerSource('hotspot', INTEL_HOTSPOTS.map(h => ({ - id: h.id, - title: h.name, - subtitle: `${h.subtext || ''} ${h.keywords?.join(' ') || ''} ${h.description || ''}`.trim(), - data: h, - }))); - - this.searchModal.registerSource('conflict', CONFLICT_ZONES.map(c => ({ - id: c.id, - title: c.name, - subtitle: `${c.parties?.join(' ') || ''} ${c.keywords?.join(' ') || ''} ${c.description || ''}`.trim(), - data: c, - }))); - - this.searchModal.registerSource('base', MILITARY_BASES.map(b => ({ - id: b.id, - title: b.name, - subtitle: `${b.type} ${b.description || ''}`.trim(), - data: b, - }))); - - // Register pipelines - this.searchModal.registerSource('pipeline', PIPELINES.map(p => ({ - id: p.id, - title: p.name, - subtitle: `${p.type} ${p.operator || ''} ${p.countries?.join(' ') || ''}`.trim(), - data: p, - }))); - - // Register undersea cables - this.searchModal.registerSource('cable', UNDERSEA_CABLES.map(c => ({ - id: c.id, - title: c.name, - subtitle: c.major ? 'Major cable' : '', - data: c, - }))); - - // Register AI datacenters - this.searchModal.registerSource('datacenter', AI_DATA_CENTERS.map(d => ({ - id: d.id, - title: d.name, - subtitle: `${d.owner} ${d.chipType || ''}`.trim(), - data: d, - }))); - - // Register nuclear facilities - this.searchModal.registerSource('nuclear', NUCLEAR_FACILITIES.map(n => ({ - id: n.id, - title: n.name, - subtitle: `${n.type} ${n.operator || ''}`.trim(), - data: n, - }))); - - // Register gamma irradiators - this.searchModal.registerSource('irradiator', GAMMA_IRRADIATORS.map(g => ({ - id: g.id, - title: `${g.city}, ${g.country}`, - subtitle: g.organization || '', - data: g, - }))); - - // Handle result selection - this.searchModal.setOnSelect((result) => this.handleSearchResult(result)); - - // Global keyboard shortcut - document.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - if (this.searchModal?.isOpen()) { - this.searchModal.close(); - } else { - // Update search index with latest data before opening - this.updateSearchIndex(); - this.searchModal?.open(); + panelSettings = loadFromStorage>( + STORAGE_KEYS.panels, + DEFAULT_PANELS + ); + // Merge in any new panels that didn't exist when settings were saved + for (const [key, config] of Object.entries(DEFAULT_PANELS)) { + if (!(key in panelSettings)) { + panelSettings[key] = { ...config }; } } - }); - } - - private handleSearchResult(result: SearchResult): void { - switch (result.type) { - case 'news': { - // Find and scroll to the news panel containing this item - const item = result.data as NewsItem; - this.scrollToPanel('politics'); - this.highlightNewsItem(item.link); - break; - } - case 'hotspot': { - // Trigger map popup for hotspot - const hotspot = result.data as typeof INTEL_HOTSPOTS[0]; - this.map?.setView('global'); - setTimeout(() => { - this.map?.triggerHotspotClick(hotspot.id); - }, 300); - break; - } - case 'conflict': { - const conflict = result.data as typeof CONFLICT_ZONES[0]; - this.map?.setView('global'); - setTimeout(() => { - this.map?.triggerConflictClick(conflict.id); - }, 300); - break; - } - case 'market': { - this.scrollToPanel('markets'); - break; - } - case 'prediction': { - this.scrollToPanel('polymarket'); - break; - } - case 'base': { - const base = result.data as typeof MILITARY_BASES[0]; - this.map?.setView('global'); - setTimeout(() => { - this.map?.triggerBaseClick(base.id); - }, 300); - break; - } - case 'pipeline': { - const pipeline = result.data as typeof PIPELINES[0]; - this.map?.setView('global'); - this.map?.enableLayer('pipelines'); - this.mapLayers.pipelines = true; - setTimeout(() => { - this.map?.triggerPipelineClick(pipeline.id); - }, 300); - break; - } - case 'cable': { - const cable = result.data as typeof UNDERSEA_CABLES[0]; - this.map?.setView('global'); - this.map?.enableLayer('cables'); - this.mapLayers.cables = true; - setTimeout(() => { - this.map?.triggerCableClick(cable.id); - }, 300); - break; - } - case 'datacenter': { - const dc = result.data as typeof AI_DATA_CENTERS[0]; - this.map?.setView('global'); - this.map?.enableLayer('datacenters'); - this.mapLayers.datacenters = true; - setTimeout(() => { - this.map?.triggerDatacenterClick(dc.id); - }, 300); - break; - } - case 'nuclear': { - const nuc = result.data as typeof NUCLEAR_FACILITIES[0]; - this.map?.setView('global'); - this.map?.enableLayer('nuclear'); - this.mapLayers.nuclear = true; - setTimeout(() => { - this.map?.triggerNuclearClick(nuc.id); - }, 300); - break; - } - case 'irradiator': { - const irr = result.data as typeof GAMMA_IRRADIATORS[0]; - this.map?.setView('global'); - this.map?.enableLayer('irradiators'); - this.mapLayers.irradiators = true; - setTimeout(() => { - this.map?.triggerIrradiatorClick(irr.id); - }, 300); - break; - } - case 'earthquake': - case 'outage': - // These are dynamic, just switch to map view - this.map?.setView('global'); - break; - } - } - - private scrollToPanel(panelId: string): void { - const panel = document.querySelector(`[data-panel="${panelId}"]`); - if (panel) { - panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); - panel.classList.add('flash-highlight'); - setTimeout(() => panel.classList.remove('flash-highlight'), 1500); - } - } - - private highlightNewsItem(itemId: string): void { - setTimeout(() => { - const item = document.querySelector(`[data-news-id="${itemId}"]`); - if (item) { - item.scrollIntoView({ behavior: 'smooth', block: 'center' }); - item.classList.add('flash-highlight'); - setTimeout(() => item.classList.remove('flash-highlight'), 1500); + console.log('[App] Loaded panel settings from storage:', Object.entries(panelSettings).filter(([_, v]) => !v.enabled).map(([k]) => k)); + + // One-time migration: reorder panels for existing users (v1.9 panel layout) + const PANEL_ORDER_MIGRATION_KEY = 'worldmonitor-panel-order-v1.9'; + if (!localStorage.getItem(PANEL_ORDER_MIGRATION_KEY)) { + const savedOrder = localStorage.getItem(PANEL_ORDER_KEY); + if (savedOrder) { + try { + const order: string[] = JSON.parse(savedOrder); + const priorityPanels = ['insights', 'strategic-posture', 'cii', 'strategic-risk']; + const filtered = order.filter(k => !priorityPanels.includes(k) && k !== 'live-news'); + const liveNewsIdx = order.indexOf('live-news'); + const newOrder = liveNewsIdx !== -1 ? ['live-news'] : []; + newOrder.push(...priorityPanels.filter(p => order.includes(p))); + newOrder.push(...filtered); + localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder)); + console.log('[App] Migrated panel order to v1.8 layout'); + } catch { + // Invalid saved order, will use defaults + } + } + localStorage.setItem(PANEL_ORDER_MIGRATION_KEY, 'done'); } - }, 100); - } - - private updateSearchIndex(): void { - if (!this.searchModal) return; - - // Update news sources (use link as unique id) - this.searchModal.registerSource('news', this.allNews.slice(0, 200).map(n => ({ - id: n.link, - title: n.title, - subtitle: n.source, - data: n, - }))); - - // Update predictions if available - if (this.latestPredictions.length > 0) { - this.searchModal.registerSource('prediction', this.latestPredictions.map(p => ({ - id: p.title, - title: p.title, - subtitle: `${(p.yesPrice * 100).toFixed(0)}% probability`, - data: p, - }))); - } - // Update markets if available - if (this.latestMarkets.length > 0) { - this.searchModal.registerSource('market', this.latestMarkets.map(m => ({ - id: m.symbol, - title: `${m.symbol} - ${m.name}`, - subtitle: `$${m.price?.toFixed(2) || 'N/A'}`, - data: m, - }))); - } - } - - private setupPlaybackControl(): void { - this.playbackControl = new PlaybackControl(); - this.playbackControl.onSnapshot((snapshot) => { - if (snapshot) { - this.isPlaybackMode = true; - this.restoreSnapshot(snapshot); - } else { - this.isPlaybackMode = false; - this.loadAllData(); + // Tech variant migration: move insights to top (after live-news) + if (currentVariant === 'tech') { + const TECH_INSIGHTS_MIGRATION_KEY = 'worldmonitor-tech-insights-top-v1'; + if (!localStorage.getItem(TECH_INSIGHTS_MIGRATION_KEY)) { + const savedOrder = localStorage.getItem(PANEL_ORDER_KEY); + if (savedOrder) { + try { + const order: string[] = JSON.parse(savedOrder); + const filtered = order.filter(k => k !== 'insights' && k !== 'live-news'); + const newOrder: string[] = []; + if (order.includes('live-news')) newOrder.push('live-news'); + if (order.includes('insights')) newOrder.push('insights'); + newOrder.push(...filtered); + localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder)); + console.log('[App] Tech variant: Migrated insights panel to top'); + } catch { + // Invalid saved order, will use defaults + } + } + localStorage.setItem(TECH_INSIGHTS_MIGRATION_KEY, 'done'); + } } - }); - - const headerRight = this.container.querySelector('.header-right'); - if (headerRight) { - headerRight.insertBefore(this.playbackControl.getElement(), headerRight.firstChild); } - } - - private setupSnapshotSaving(): void { - const saveCurrentSnapshot = async () => { - if (this.isPlaybackMode) return; - - const marketPrices: Record = {}; - this.latestMarkets.forEach(m => { - if (m.price !== null) marketPrices[m.symbol] = m.price; - }); - - await saveSnapshot({ - timestamp: Date.now(), - events: this.latestClusters, - marketPrices, - predictions: this.latestPredictions.map(p => ({ - title: p.title, - yesPrice: p.yesPrice - })), - hotspotLevels: this.map?.getHotspotLevels() ?? {} - }); - }; - - saveCurrentSnapshot(); - setInterval(saveCurrentSnapshot, 15 * 60 * 1000); - } - - private restoreSnapshot(snapshot: import('@/services/storage').DashboardSnapshot): void { - for (const panel of Object.values(this.newsPanels)) { - panel.showLoading(); - } - - const events = snapshot.events as ClusteredEvent[]; - this.latestClusters = events; - - const predictions = snapshot.predictions.map((p, i) => ({ - id: `snap-${i}`, - title: p.title, - yesPrice: p.yesPrice, - noPrice: 1 - p.yesPrice, - volume24h: 0, - liquidity: 0, - })); - this.latestPredictions = predictions; - (this.panels['polymarket'] as PredictionPanel).renderPredictions(predictions); - - this.map?.setHotspotLevels(snapshot.hotspotLevels); - } - - private renderLayout(): void { - this.container.innerHTML = ` -
-
- - by Elie Habib - - - -
- - LIVE -
-
-
- - - -
-
- - - --:--:-- UTC - -
-
-
-
-
-
- Global Situation -
-
-
-
-
-
-
- - `; - - this.createPanels(); - this.renderPanelToggles(); - this.updateTime(); - setInterval(() => this.updateTime(), 1000); - } - - private createPanels(): void { - const panelsGrid = document.getElementById('panelsGrid')!; - - // Initialize map in the map section - const mapContainer = document.getElementById('mapContainer') as HTMLElement; - this.map = new MapComponent(mapContainer, { - zoom: 1.5, - pan: { x: 0, y: 0 }, - view: 'global', - layers: this.mapLayers, - timeRange: '7d', - }); - - // Create all panels - const politicsPanel = new NewsPanel('politics', 'World / Geopolitical'); - this.attachRelatedAssetHandlers(politicsPanel); - this.newsPanels['politics'] = politicsPanel; - this.panels['politics'] = politicsPanel; - - const techPanel = new NewsPanel('tech', 'Technology / AI'); - this.attachRelatedAssetHandlers(techPanel); - this.newsPanels['tech'] = techPanel; - this.panels['tech'] = techPanel; - - const financePanel = new NewsPanel('finance', 'Financial News'); - this.attachRelatedAssetHandlers(financePanel); - this.newsPanels['finance'] = financePanel; - this.panels['finance'] = financePanel; - - const heatmapPanel = new HeatmapPanel(); - this.panels['heatmap'] = heatmapPanel; - - const marketsPanel = new MarketPanel(); - this.panels['markets'] = marketsPanel; - const monitorPanel = new MonitorPanel(this.monitors); - this.panels['monitors'] = monitorPanel; - monitorPanel.onChanged((monitors) => { - this.monitors = monitors; - saveToStorage(STORAGE_KEYS.monitors, monitors); - this.updateMonitorResults(); - }); - - const commoditiesPanel = new CommoditiesPanel(); - this.panels['commodities'] = commoditiesPanel; - - const predictionPanel = new PredictionPanel(); - this.panels['polymarket'] = predictionPanel; - - const govPanel = new NewsPanel('gov', 'Government / Policy'); - this.attachRelatedAssetHandlers(govPanel); - this.newsPanels['gov'] = govPanel; - this.panels['gov'] = govPanel; - - const intelPanel = new NewsPanel('intel', 'Intel Feed'); - this.attachRelatedAssetHandlers(intelPanel); - this.newsPanels['intel'] = intelPanel; - this.panels['intel'] = intelPanel; - - const cryptoPanel = new CryptoPanel(); - this.panels['crypto'] = cryptoPanel; - - const middleeastPanel = new NewsPanel('middleeast', 'Middle East / MENA'); - this.attachRelatedAssetHandlers(middleeastPanel); - this.newsPanels['middleeast'] = middleeastPanel; - this.panels['middleeast'] = middleeastPanel; - - const layoffsPanel = new NewsPanel('layoffs', 'Layoffs Tracker'); - this.attachRelatedAssetHandlers(layoffsPanel); - this.newsPanels['layoffs'] = layoffsPanel; - this.panels['layoffs'] = layoffsPanel; - - const congressPanel = new NewsPanel('congress', 'Congress Trades'); - this.attachRelatedAssetHandlers(congressPanel); - this.newsPanels['congress'] = congressPanel; - this.panels['congress'] = congressPanel; - - const aiPanel = new NewsPanel('ai', 'AI / ML'); - this.attachRelatedAssetHandlers(aiPanel); - this.newsPanels['ai'] = aiPanel; - this.panels['ai'] = aiPanel; - - const thinktanksPanel = new NewsPanel('thinktanks', 'Think Tanks'); - this.attachRelatedAssetHandlers(thinktanksPanel); - this.newsPanels['thinktanks'] = thinktanksPanel; - this.panels['thinktanks'] = thinktanksPanel; - - // Add panels to grid in saved order - const defaultOrder = ['politics', 'middleeast', 'tech', 'ai', 'finance', 'layoffs', 'congress', 'heatmap', 'markets', 'commodities', 'crypto', 'polymarket', 'gov', 'thinktanks', 'intel', 'monitors']; - const savedOrder = this.getSavedPanelOrder(); - // Merge saved order with default to include new panels - let panelOrder = defaultOrder; - if (savedOrder.length > 0) { - // Add any missing panels from default that aren't in saved order - const missing = defaultOrder.filter(k => !savedOrder.includes(k)); - // Remove any saved panels that no longer exist - const valid = savedOrder.filter(k => defaultOrder.includes(k)); - // Insert missing panels after 'politics' (except monitors which goes at end) - const monitorsIdx = valid.indexOf('monitors'); - if (monitorsIdx !== -1) valid.splice(monitorsIdx, 1); // Remove monitors temporarily - const insertIdx = valid.indexOf('politics') + 1 || 0; - const newPanels = missing.filter(k => k !== 'monitors'); - valid.splice(insertIdx, 0, ...newPanels); - valid.push('monitors'); // Always put monitors last - panelOrder = valid; - } - - panelOrder.forEach((key: string) => { - const panel = this.panels[key]; - if (panel) { - const el = panel.getElement(); - this.makeDraggable(el, key); - panelsGrid.appendChild(el); + // One-time migration: clear stale panel ordering and sizing state + const LAYOUT_RESET_MIGRATION_KEY = 'worldmonitor-layout-reset-v2.5'; + if (!localStorage.getItem(LAYOUT_RESET_MIGRATION_KEY)) { + const hadSavedOrder = !!localStorage.getItem(PANEL_ORDER_KEY); + const hadSavedSpans = !!localStorage.getItem(PANEL_SPANS_KEY); + if (hadSavedOrder || hadSavedSpans) { + localStorage.removeItem(PANEL_ORDER_KEY); + localStorage.removeItem(PANEL_SPANS_KEY); + console.log('[App] Applied layout reset migration (v2.5): cleared panel order/spans'); } - }); - - this.applyPanelSettings(); - this.applyInitialUrlState(); - } - - private applyInitialUrlState(): void { - if (!this.initialUrlState || !this.map) return; - - const { view, zoom, lat, lon, timeRange, layers } = this.initialUrlState; - - if (view) { - this.map.setView(view); - this.setActiveViewButton(view); - } - - if (timeRange) { - this.map.setTimeRange(timeRange); - } - - if (layers) { - this.mapLayers = layers; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - this.map.setLayers(layers); - } - - if (zoom !== undefined) { - this.map.setZoom(zoom); - } - - if (lat !== undefined && lon !== undefined) { - this.map.setCenter(lat, lon); - } - } - - private getSavedPanelOrder(): string[] { - try { - const saved = localStorage.getItem('panel-order'); - return saved ? JSON.parse(saved) : []; - } catch { - return []; - } - } - - private savePanelOrder(): void { - const grid = document.getElementById('panelsGrid'); - if (!grid) return; - const order = Array.from(grid.children) - .map((el) => (el as HTMLElement).dataset.panel) - .filter((key): key is string => !!key); - localStorage.setItem('panel-order', JSON.stringify(order)); - } - - private attachRelatedAssetHandlers(panel: NewsPanel): void { - panel.setRelatedAssetHandlers({ - onRelatedAssetClick: (asset) => this.handleRelatedAssetClick(asset), - onRelatedAssetsFocus: (assets) => this.map?.highlightAssets(assets), - onRelatedAssetsClear: () => this.map?.highlightAssets(null), - }); - } - - private handleRelatedAssetClick(asset: RelatedAsset): void { - if (!this.map) return; - - switch (asset.type) { - case 'pipeline': - this.map.enableLayer('pipelines'); - this.mapLayers.pipelines = true; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - this.map.triggerPipelineClick(asset.id); - break; - case 'cable': - this.map.enableLayer('cables'); - this.mapLayers.cables = true; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - this.map.triggerCableClick(asset.id); - break; - case 'datacenter': - this.map.enableLayer('datacenters'); - this.mapLayers.datacenters = true; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - this.map.triggerDatacenterClick(asset.id); - break; - case 'base': - this.map.enableLayer('bases'); - this.mapLayers.bases = true; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - this.map.triggerBaseClick(asset.id); - break; - case 'nuclear': - this.map.enableLayer('nuclear'); - this.mapLayers.nuclear = true; - saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); - this.map.triggerNuclearClick(asset.id); - break; - } - } - - private makeDraggable(el: HTMLElement, key: string): void { - el.draggable = true; - el.dataset.panel = key; - - el.addEventListener('dragstart', (e) => { - el.classList.add('dragging'); - e.dataTransfer?.setData('text/plain', key); - }); - - el.addEventListener('dragend', () => { - el.classList.remove('dragging'); - this.savePanelOrder(); - }); - - el.addEventListener('dragover', (e) => { - e.preventDefault(); - const dragging = document.querySelector('.dragging'); - if (!dragging || dragging === el) return; - - const grid = document.getElementById('panelsGrid'); - if (!grid) return; - - const siblings = Array.from(grid.children).filter((c) => c !== dragging); - const nextSibling = siblings.find((sibling) => { - const rect = sibling.getBoundingClientRect(); - return e.clientY < rect.top + rect.height / 2; - }); - - if (nextSibling) { - grid.insertBefore(dragging, nextSibling); - } else { - grid.appendChild(dragging); + localStorage.setItem(LAYOUT_RESET_MIGRATION_KEY, 'done'); + } + + // Desktop key management panel must always remain accessible in Tauri. + if (isDesktopApp) { + const runtimePanel = panelSettings['runtime-config'] ?? { + name: 'Desktop Configuration', + enabled: true, + priority: 2, + }; + runtimePanel.enabled = true; + panelSettings['runtime-config'] = runtimePanel; + saveToStorage(STORAGE_KEYS.panels, panelSettings); + } + + let initialUrlState: ParsedMapUrlState | null = parseMapUrlState(window.location.search, mapLayers); + if (initialUrlState.layers) { + if (currentVariant === 'tech') { + const geoLayers: (keyof MapLayers)[] = ['conflicts', 'bases', 'hotspots', 'nuclear', 'irradiators', 'sanctions', 'military', 'protests', 'pipelines', 'waterways', 'ais', 'flights', 'spaceports', 'minerals']; + const urlLayers = initialUrlState.layers; + geoLayers.forEach(layer => { + urlLayers[layer] = false; + }); } - }); - } - - private setupEventListeners(): void { - // View buttons - document.querySelectorAll('.view-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const view = (btn as HTMLElement).dataset.view as 'global' | 'us' | 'mena'; - this.setActiveViewButton(view); - this.map?.setView(view); - }); - }); - - // Search button - document.getElementById('searchBtn')?.addEventListener('click', () => { - this.updateSearchIndex(); - this.searchModal?.open(); - }); - - // Copy link button - document.getElementById('copyLinkBtn')?.addEventListener('click', async () => { - const shareUrl = this.getShareUrl(); - if (!shareUrl) return; - const button = document.getElementById('copyLinkBtn'); - try { - await this.copyToClipboard(shareUrl); - this.setCopyLinkFeedback(button, 'Copied!'); - } catch (error) { - console.warn('Failed to copy share link:', error); - this.setCopyLinkFeedback(button, 'Copy failed'); + // For happy variant, force off all non-happy layers (including natural events) + if (currentVariant === 'happy') { + const unhappyLayers: (keyof MapLayers)[] = ['conflicts', 'bases', 'hotspots', 'nuclear', 'irradiators', 'sanctions', 'military', 'protests', 'pipelines', 'waterways', 'ais', 'flights', 'spaceports', 'minerals', 'natural', 'fires', 'outages', 'cyberThreats', 'weather', 'economic', 'cables', 'datacenters', 'ucdpEvents', 'displacement', 'climate']; + const urlLayers = initialUrlState.layers; + unhappyLayers.forEach(layer => { + urlLayers[layer] = false; + }); } - }); + mapLayers = initialUrlState.layers; + } + if (!CYBER_LAYER_ENABLED) { + mapLayers.cyberThreats = false; + } + const disabledSources = new Set(loadFromStorage(STORAGE_KEYS.disabledFeeds, [])); + + // Build shared state object + this.state = { + map: null, + isMobile, + isDesktopApp, + container: el, + panels: {}, + newsPanels: {}, + panelSettings, + mapLayers, + allNews: [], + newsByCategory: {}, + latestMarkets: [], + latestPredictions: [], + latestClusters: [], + intelligenceCache: {}, + cyberThreatsCache: null, + disabledSources, + currentTimeRange: '7d', + inFlight: new Set(), + seenGeoAlerts: new Set(), + monitors, + signalModal: null, + statusPanel: null, + searchModal: null, + findingsBadge: null, + playbackControl: null, + exportPanel: null, + unifiedSettings: null, + mobileWarningModal: null, + pizzintIndicator: null, + countryBriefPage: null, + countryTimeline: null, + positivePanel: null, + countersPanel: null, + progressPanel: null, + breakthroughsPanel: null, + heroPanel: null, + digestPanel: null, + speciesPanel: null, + renewablePanel: null, + tvMode: null, + happyAllItems: [], + isDestroyed: false, + isPlaybackMode: false, + isIdle: false, + initialLoadComplete: false, + initialUrlState, + PANEL_ORDER_KEY, + PANEL_SPANS_KEY, + }; - // Settings modal - document.getElementById('settingsBtn')?.addEventListener('click', () => { - document.getElementById('settingsModal')?.classList.add('active'); - }); + // Instantiate modules (callbacks wired after all modules exist) + this.refreshScheduler = new RefreshScheduler(this.state); + this.countryIntel = new CountryIntelManager(this.state); + this.desktopUpdater = new DesktopUpdater(this.state); - document.getElementById('modalClose')?.addEventListener('click', () => { - document.getElementById('settingsModal')?.classList.remove('active'); + this.dataLoader = new DataLoaderManager(this.state, { + renderCriticalBanner: (postures) => this.panelLayout.renderCriticalBanner(postures), }); - document.getElementById('settingsModal')?.addEventListener('click', (e) => { - if ((e.target as HTMLElement).classList.contains('modal-overlay')) { - (e.target as HTMLElement).classList.remove('active'); - } + this.searchManager = new SearchManager(this.state, { + openCountryBriefByCode: (code, country) => this.countryIntel.openCountryBriefByCode(code, country), }); - // Window resize - window.addEventListener('resize', () => { - this.map?.render(); + this.panelLayout = new PanelLayoutManager(this.state, { + openCountryStory: (code, name) => this.countryIntel.openCountryStory(code, name), + loadAllData: () => this.dataLoader.loadAllData(), + updateMonitorResults: () => this.dataLoader.updateMonitorResults(), }); - // Map section resize handle - this.setupMapResize(); - } - - private setupUrlStateSync(): void { - if (!this.map) return; - const update = debounce(() => { - const shareUrl = this.getShareUrl(); - if (!shareUrl) return; - history.replaceState(null, '', shareUrl); - }, 250); - - this.map.onStateChanged(() => update()); - update(); - } - - private getShareUrl(): string | null { - if (!this.map) return null; - const state = this.map.getState(); - const center = this.map.getCenter(); - const baseUrl = `${window.location.origin}${window.location.pathname}`; - return buildMapUrl(baseUrl, { - view: state.view, - zoom: state.zoom, - center, - timeRange: state.timeRange, - layers: state.layers, + this.eventHandlers = new EventHandlerManager(this.state, { + updateSearchIndex: () => this.searchManager.updateSearchIndex(), + loadAllData: () => this.dataLoader.loadAllData(), + flushStaleRefreshes: () => this.refreshScheduler.flushStaleRefreshes(), + setHiddenSince: (ts) => this.refreshScheduler.setHiddenSince(ts), + loadDataForLayer: (layer) => { void this.dataLoader.loadDataForLayer(layer as keyof MapLayers); }, + waitForAisData: () => this.dataLoader.waitForAisData(), + syncDataFreshnessWithLayers: () => this.dataLoader.syncDataFreshnessWithLayers(), }); - } - - private async copyToClipboard(text: string): Promise { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } - - private setCopyLinkFeedback(button: HTMLElement | null, message: string): void { - if (!button) return; - const originalText = button.textContent ?? ''; - button.textContent = message; - button.classList.add('copied'); - window.setTimeout(() => { - button.textContent = originalText; - button.classList.remove('copied'); - }, 1500); - } - private setActiveViewButton(view: 'global' | 'us' | 'mena'): void { - document.querySelectorAll('.view-btn').forEach((btn) => { - const isActive = (btn as HTMLElement).dataset.view === view; - btn.classList.toggle('active', isActive); - }); + // Wire cross-module callback: DataLoader → SearchManager + this.dataLoader.updateSearchIndex = () => this.searchManager.updateSearchIndex(); + + // Track destroy order (reverse of init) + this.modules = [ + this.desktopUpdater, + this.panelLayout, + this.countryIntel, + this.searchManager, + this.dataLoader, + this.refreshScheduler, + this.eventHandlers, + ]; } - private setupMapResize(): void { - const mapSection = document.getElementById('mapSection'); - const resizeHandle = document.getElementById('mapResizeHandle'); - if (!mapSection || !resizeHandle) return; - - // Load saved height - const savedHeight = localStorage.getItem('map-height'); - if (savedHeight) { - mapSection.style.height = savedHeight; + public async init(): Promise { + const initStart = performance.now(); + await initDB(); + await initI18n(); + const aiFlow = getAiFlowSettings(); + if (aiFlow.browserModel || isDesktopRuntime()) { + await mlWorker.init(); } - let isResizing = false; - let startY = 0; - let startHeight = 0; - - resizeHandle.addEventListener('mousedown', (e) => { - isResizing = true; - startY = e.clientY; - startHeight = mapSection.offsetHeight; - mapSection.classList.add('resizing'); - document.body.style.cursor = 'ns-resize'; - e.preventDefault(); - }); - - document.addEventListener('mousemove', (e) => { - if (!isResizing) return; - const deltaY = e.clientY - startY; - const newHeight = Math.max(400, Math.min(startHeight + deltaY, window.innerHeight * 0.85)); - mapSection.style.height = `${newHeight}px`; - this.map?.render(); - }); - - document.addEventListener('mouseup', () => { - if (!isResizing) return; - isResizing = false; - mapSection.classList.remove('resizing'); - document.body.style.cursor = ''; - // Save height preference - localStorage.setItem('map-height', mapSection.style.height); - this.map?.render(); - }); - } - - private renderPanelToggles(): void { - const container = document.getElementById('panelToggles')!; - container.innerHTML = Object.entries(this.panelSettings) - .map( - ([key, panel]) => ` -
-
${panel.enabled ? '✓' : ''}
- ${panel.name} -
- ` - ) - .join(''); - - container.querySelectorAll('.panel-toggle-item').forEach((item) => { - item.addEventListener('click', () => { - const panelKey = (item as HTMLElement).dataset.panel!; - const config = this.panelSettings[panelKey]; - if (config) { - config.enabled = !config.enabled; - saveToStorage(STORAGE_KEYS.panels, this.panelSettings); - this.renderPanelToggles(); - this.applyPanelSettings(); - } - }); - }); - } - - private applyPanelSettings(): void { - Object.entries(this.panelSettings).forEach(([key, config]) => { - if (key === 'map') { - const mapSection = document.getElementById('mapSection'); - if (mapSection) { - mapSection.classList.toggle('hidden', !config.enabled); + this.unsubAiFlow = subscribeAiFlowChange((key) => { + if (key === 'browserModel') { + const s = getAiFlowSettings(); + if (s.browserModel) { + mlWorker.init(); + } else { + mlWorker.terminate(); } - return; } - const panel = this.panels[key]; - panel?.toggle(config.enabled); }); - } - private updateTime(): void { - const now = new Date(); - const el = document.getElementById('timeDisplay'); - if (el) { - el.textContent = now.toUTCString().split(' ')[4] + ' UTC'; + // Check AIS configuration before init + if (!isAisConfigured()) { + this.state.mapLayers.ais = false; + } else if (this.state.mapLayers.ais) { + initAisStream(); } - } - - private async loadAllData(): Promise { - await Promise.all([ - this.loadNews(), - this.loadMarkets(), - this.loadPredictions(), - this.loadEarthquakes(), - this.loadWeatherAlerts(), - this.loadFredData(), - this.loadOutages(), - this.loadAisSignals(), - this.loadCableActivity(), - this.loadProtests(), - ]); - - // Update search index after all data loads - this.updateSearchIndex(); - } - - private async loadNewsCategory(category: string, feeds: typeof FEEDS.politics): Promise { - try { - const panel = this.newsPanels[category]; - const items = await fetchCategoryFeeds(feeds ?? [], { - onBatch: (partialItems) => { - if (panel) { - panel.renderNews(partialItems); - } - }, - }); - if (panel) { - panel.renderNews(items); + // Phase 1: Layout (creates map + panels) + this.panelLayout.init(); - const baseline = await updateBaseline(`news:${category}`, items.length); - const deviation = calculateDeviation(items.length, baseline); - panel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); - } + // Happy variant: pre-populate panels from persistent cache for instant render + if (SITE_VARIANT === 'happy') { + await this.dataLoader.hydrateHappyPanelsFromCache(); + } - this.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { - status: 'ok', - itemCount: items.length, + // Phase 2: Shared UI components + this.state.signalModal = new SignalModal(); + this.state.signalModal.setLocationClickHandler((lat, lon) => { + this.state.map?.setCenter(lat, lon, 4); + }); + if (!this.state.isMobile) { + this.state.findingsBadge = new IntelligenceGapBadge(); + this.state.findingsBadge.setOnSignalClick((signal) => { + if (this.state.countryBriefPage?.isVisible()) return; + if (localStorage.getItem('wm-settings-open') === '1') return; + this.state.signalModal?.showSignal(signal); }); - this.statusPanel?.updateApi('RSS2JSON', { status: 'ok' }); - - return items; - } catch (error) { - this.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { - status: 'error', - errorMessage: String(error), + this.state.findingsBadge.setOnAlertClick((alert) => { + if (this.state.countryBriefPage?.isVisible()) return; + if (localStorage.getItem('wm-settings-open') === '1') return; + this.state.signalModal?.showAlert(alert); }); - this.statusPanel?.updateApi('RSS2JSON', { status: 'error' }); - return []; } - } - private async loadNews(): Promise { - this.allNews = []; + // Phase 3: UI setup methods + this.eventHandlers.startHeaderClock(); + this.eventHandlers.setupMobileWarning(); + this.eventHandlers.setupPlaybackControl(); + this.eventHandlers.setupStatusPanel(); + this.eventHandlers.setupPizzIntIndicator(); + this.eventHandlers.setupExportPanel(); + this.eventHandlers.setupUnifiedSettings(); - const categories = [ - { key: 'politics', feeds: FEEDS.politics }, - { key: 'tech', feeds: FEEDS.tech }, - { key: 'finance', feeds: FEEDS.finance }, - { key: 'gov', feeds: FEEDS.gov }, - { key: 'middleeast', feeds: FEEDS.middleeast }, - { key: 'layoffs', feeds: FEEDS.layoffs }, - { key: 'congress', feeds: FEEDS.congress }, - { key: 'ai', feeds: FEEDS.ai }, - { key: 'thinktanks', feeds: FEEDS.thinktanks }, - ]; + // Phase 4: SearchManager, MapLayerHandlers, CountryIntel + this.searchManager.init(); + this.eventHandlers.setupMapLayerHandlers(); + this.countryIntel.init(); - for (const { key, feeds } of categories) { - const items = await this.loadNewsCategory(key, feeds); - this.allNews.push(...items); - } + // Phase 5: Event listeners + URL sync + this.eventHandlers.init(); + // Capture ?country= BEFORE URL sync overwrites it + const initState = parseMapUrlState(window.location.search, this.state.mapLayers); + this.pendingDeepLinkCountry = initState.country ?? null; + this.eventHandlers.setupUrlStateSync(); - // Intel (uses different source) - const intel = await fetchCategoryFeeds(INTEL_SOURCES); - const intelPanel = this.newsPanels['intel']; - if (intelPanel) { - intelPanel.renderNews(intel); - const baseline = await updateBaseline('news:intel', intel.length); - const deviation = calculateDeviation(intel.length, baseline); - intelPanel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); - } - this.allNews.push(...intel); + // Phase 6: Data loading + this.dataLoader.syncDataFreshnessWithLayers(); + await preloadCountryGeometry(); + await this.dataLoader.loadAllData(); - // Update map hotspots - this.map?.updateHotspotActivity(this.allNews); + startLearning(); - // Update monitors - this.updateMonitorResults(); - - // Update clusters for correlation analysis - this.latestClusters = clusterNews(this.allNews); - } - - private async loadMarkets(): Promise { - try { - // Stocks - const stocks = await fetchMultipleStocks(MARKET_SYMBOLS, { - onBatch: (partialStocks) => { - this.latestMarkets = partialStocks; - (this.panels['markets'] as MarketPanel).renderMarkets(partialStocks); - }, - }); - this.latestMarkets = stocks; - (this.panels['markets'] as MarketPanel).renderMarkets(stocks); - this.statusPanel?.updateApi('Alpha Vantage', { status: 'ok' }); - - // Sectors - const sectors = await fetchMultipleStocks( - SECTORS.map((s) => ({ ...s, display: s.name })), - { - onBatch: (partialSectors) => { - (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( - partialSectors.map((s) => ({ name: s.name, change: s.change })) - ); - }, - } - ); - (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( - sectors.map((s) => ({ name: s.name, change: s.change })) - ); - - // Commodities - const commodities = await fetchMultipleStocks(COMMODITIES, { - onBatch: (partialCommodities) => { - (this.panels['commodities'] as CommoditiesPanel).renderCommodities( - partialCommodities.map((c) => ({ - display: c.display, - price: c.price, - change: c.change, - })) - ); - }, - }); - (this.panels['commodities'] as CommoditiesPanel).renderCommodities( - commodities.map((c) => ({ display: c.display, price: c.price, change: c.change })) - ); - } catch { - this.statusPanel?.updateApi('Alpha Vantage', { status: 'error' }); + // Hide unconfigured layers after first data load + if (!isAisConfigured()) { + this.state.map?.hideLayerToggle('ais'); } - - try { - // Crypto - const crypto = await fetchCrypto(); - (this.panels['crypto'] as CryptoPanel).renderCrypto(crypto); - this.statusPanel?.updateApi('CoinGecko', { status: 'ok' }); - } catch { - this.statusPanel?.updateApi('CoinGecko', { status: 'error' }); + if (isOutagesConfigured() === false) { + this.state.map?.hideLayerToggle('outages'); } - } - - private async loadPredictions(): Promise { - try { - const predictions = await fetchPredictions(); - this.latestPredictions = predictions; - (this.panels['polymarket'] as PredictionPanel).renderPredictions(predictions); - - this.statusPanel?.updateFeed('Polymarket', { status: 'ok', itemCount: predictions.length }); - this.statusPanel?.updateApi('Polymarket', { status: 'ok' }); - - this.runCorrelationAnalysis(); - } catch (error) { - this.statusPanel?.updateFeed('Polymarket', { status: 'error', errorMessage: String(error) }); - this.statusPanel?.updateApi('Polymarket', { status: 'error' }); + if (!CYBER_LAYER_ENABLED) { + this.state.map?.hideLayerToggle('cyberThreats'); } - } - private async loadEarthquakes(): Promise { - try { - const earthquakes = await fetchEarthquakes(); - this.map?.setEarthquakes(earthquakes); - this.statusPanel?.updateApi('USGS', { status: 'ok' }); - } catch { - this.statusPanel?.updateApi('USGS', { status: 'error' }); - } - } + // Phase 7: Refresh scheduling + this.setupRefreshIntervals(); + this.eventHandlers.setupSnapshotSaving(); + cleanOldSnapshots().catch((e) => console.warn('[Storage] Snapshot cleanup failed:', e)); - private async loadWeatherAlerts(): Promise { - try { - const alerts = await fetchWeatherAlerts(); - this.map?.setWeatherAlerts(alerts); - this.statusPanel?.updateFeed('Weather', { status: 'ok', itemCount: alerts.length }); - } catch { - this.statusPanel?.updateFeed('Weather', { status: 'error' }); - } - } + // Phase 8: Deep links + update checks + this.handleDeepLinks(); + this.desktopUpdater.init(); - private async loadOutages(): Promise { - try { - const outages = await fetchInternetOutages(); - this.map?.setOutages(outages); - this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); - } catch { - this.statusPanel?.updateFeed('NetBlocks', { status: 'error' }); - } + // Analytics + trackEvent('wm_app_loaded', { + load_time_ms: Math.round(performance.now() - initStart), + panel_count: Object.keys(this.state.panels).length, + }); + this.eventHandlers.setupPanelViewTracking(); } - private async loadAisSignals(): Promise { - try { - const { disruptions, density } = await fetchAisSignals(); - const aisStatus = getAisStatus(); - this.map?.setAisData(disruptions, density); + public destroy(): void { + this.state.isDestroyed = true; - if (aisStatus.connected) { - this.statusPanel?.updateFeed('AIS', { - status: 'ok', - itemCount: disruptions.length + density.length, - }); - this.statusPanel?.updateApi('AISStream', { status: 'ok' }); - } else { - this.statusPanel?.updateFeed('AIS', { - status: aisStatus.vessels > 0 ? 'ok' : 'error', - itemCount: disruptions.length + density.length, - errorMessage: aisStatus.vessels === 0 ? 'No API key - set VITE_AISSTREAM_API_KEY' : undefined, - }); - this.statusPanel?.updateApi('AISStream', { - status: aisStatus.vessels > 0 ? 'ok' : 'error', - }); - } - } catch (error) { - this.statusPanel?.updateFeed('AIS', { status: 'error', errorMessage: String(error) }); - this.statusPanel?.updateApi('AISStream', { status: 'error' }); + // Destroy all modules in reverse order + for (let i = this.modules.length - 1; i >= 0; i--) { + this.modules[i]!.destroy(); } - } - private async loadCableActivity(): Promise { - try { - const activity = await fetchCableActivity(); - this.map?.setCableActivity(activity.advisories, activity.repairShips); - const itemCount = activity.advisories.length + activity.repairShips.length; - this.statusPanel?.updateFeed('CableOps', { status: 'ok', itemCount }); - } catch { - this.statusPanel?.updateFeed('CableOps', { status: 'error' }); - } + // Clean up subscriptions, map, and AIS + this.unsubAiFlow?.(); + this.state.map?.destroy(); + disconnectAisStream(); } - private async loadProtests(): Promise { - try { - const protestData = await fetchProtestEvents(); - this.map?.setProtests(protestData.events); - const status = getProtestStatus(); - - this.statusPanel?.updateFeed('Protests', { - status: 'ok', - itemCount: protestData.events.length, - errorMessage: !status.acledConfigured ? 'ACLED not configured - using GDELT only' : undefined, - }); - - if (status.acledConfigured) { - this.statusPanel?.updateApi('ACLED', { status: 'ok' }); - } - this.statusPanel?.updateApi('GDELT', { status: 'ok' }); - } catch (error) { - this.statusPanel?.updateFeed('Protests', { status: 'error', errorMessage: String(error) }); - this.statusPanel?.updateApi('ACLED', { status: 'error' }); - this.statusPanel?.updateApi('GDELT', { status: 'error' }); - } - } + private handleDeepLinks(): void { + const url = new URL(window.location.href); + const MAX_DEEP_LINK_RETRIES = 60; + const DEEP_LINK_RETRY_INTERVAL_MS = 500; + const DEEP_LINK_INITIAL_DELAY_MS = 2000; - private async loadFredData(): Promise { - try { - this.economicPanel?.setLoading(true); - const data = await fetchFredData(); - this.economicPanel?.update(data); - this.statusPanel?.updateApi('FRED', { status: 'ok' }); - } catch { - this.statusPanel?.updateApi('FRED', { status: 'error' }); - this.economicPanel?.setLoading(false); - } - } + // Check for story deep link: /story?c=UA&t=ciianalysis + if (url.pathname === '/story' || url.searchParams.has('c')) { + const countryCode = url.searchParams.get('c'); + if (countryCode) { + trackDeeplinkOpened('story', countryCode); + const countryName = getCountryNameByCode(countryCode.toUpperCase()) || countryCode; - private updateMonitorResults(): void { - const monitorPanel = this.panels['monitors'] as MonitorPanel; - monitorPanel.renderResults(this.allNews); - } + let attempts = 0; + const checkAndOpen = () => { + if (dataFreshness.hasSufficientData() && this.state.latestClusters.length > 0) { + this.countryIntel.openCountryStory(countryCode.toUpperCase(), countryName); + return; + } + attempts += 1; + if (attempts >= MAX_DEEP_LINK_RETRIES) { + this.eventHandlers.showToast('Data not available'); + return; + } else { + setTimeout(checkAndOpen, DEEP_LINK_RETRY_INTERVAL_MS); + } + }; + setTimeout(checkAndOpen, DEEP_LINK_INITIAL_DELAY_MS); - private runCorrelationAnalysis(): void { - if (this.latestClusters.length === 0) { - this.latestClusters = clusterNews(this.allNews); + history.replaceState(null, '', '/'); + return; + } } - const signals = analyzeCorrelations( - this.latestClusters, - this.latestPredictions, - this.latestMarkets - ); - - if (signals.length > 0) { - addToSignalHistory(signals); - this.signalModal?.show(signals); + // Check for country brief deep link: ?country=UA + const deepLinkCountry = this.pendingDeepLinkCountry; + this.pendingDeepLinkCountry = null; + if (deepLinkCountry) { + trackDeeplinkOpened('country', deepLinkCountry); + const cName = CountryIntelManager.resolveCountryName(deepLinkCountry); + let attempts = 0; + const checkAndOpenBrief = () => { + if (dataFreshness.hasSufficientData()) { + this.countryIntel.openCountryBriefByCode(deepLinkCountry, cName); + return; + } + attempts += 1; + if (attempts >= MAX_DEEP_LINK_RETRIES) { + this.eventHandlers.showToast('Data not available'); + return; + } else { + setTimeout(checkAndOpenBrief, DEEP_LINK_RETRY_INTERVAL_MS); + } + }; + setTimeout(checkAndOpenBrief, DEEP_LINK_INITIAL_DELAY_MS); } } private setupRefreshIntervals(): void { - setInterval(() => this.loadNews(), REFRESH_INTERVALS.feeds); - setInterval(() => this.loadMarkets(), REFRESH_INTERVALS.markets); - setInterval(() => this.loadPredictions(), REFRESH_INTERVALS.predictions); - setInterval(() => this.loadEarthquakes(), 5 * 60 * 1000); - setInterval(() => this.loadWeatherAlerts(), 10 * 60 * 1000); - setInterval(() => this.loadFredData(), 30 * 60 * 1000); - setInterval(() => this.loadOutages(), 60 * 60 * 1000); // 1 hour - Cloudflare rate limit - setInterval(() => this.loadAisSignals(), REFRESH_INTERVALS.ais); - setInterval(() => this.loadCableActivity(), 30 * 60 * 1000); - setInterval(() => this.loadProtests(), 15 * 60 * 1000); // 15 min - GDELT updates frequently + // Always refresh news for all variants + this.refreshScheduler.scheduleRefresh('news', () => this.dataLoader.loadNews(), REFRESH_INTERVALS.feeds); + + // Happy variant only refreshes news -- skip all geopolitical/financial/military refreshes + if (SITE_VARIANT !== 'happy') { + this.refreshScheduler.registerAll([ + { name: 'markets', fn: () => this.dataLoader.loadMarkets(), intervalMs: REFRESH_INTERVALS.markets }, + { name: 'predictions', fn: () => this.dataLoader.loadPredictions(), intervalMs: REFRESH_INTERVALS.predictions }, + { name: 'pizzint', fn: () => this.dataLoader.loadPizzInt(), intervalMs: 10 * 60 * 1000 }, + { name: 'natural', fn: () => this.dataLoader.loadNatural(), intervalMs: 5 * 60 * 1000, condition: () => this.state.mapLayers.natural }, + { name: 'weather', fn: () => this.dataLoader.loadWeatherAlerts(), intervalMs: 10 * 60 * 1000, condition: () => this.state.mapLayers.weather }, + { name: 'fred', fn: () => this.dataLoader.loadFredData(), intervalMs: 30 * 60 * 1000 }, + { name: 'oil', fn: () => this.dataLoader.loadOilAnalytics(), intervalMs: 30 * 60 * 1000 }, + { name: 'spending', fn: () => this.dataLoader.loadGovernmentSpending(), intervalMs: 60 * 60 * 1000 }, + { name: 'bis', fn: () => this.dataLoader.loadBisData(), intervalMs: 60 * 60 * 1000 }, + { name: 'firms', fn: () => this.dataLoader.loadFirmsData(), intervalMs: 30 * 60 * 1000 }, + { name: 'ais', fn: () => this.dataLoader.loadAisSignals(), intervalMs: REFRESH_INTERVALS.ais, condition: () => this.state.mapLayers.ais }, + { name: 'cables', fn: () => this.dataLoader.loadCableActivity(), intervalMs: 30 * 60 * 1000, condition: () => this.state.mapLayers.cables }, + { name: 'cableHealth', fn: () => this.dataLoader.loadCableHealth(), intervalMs: 5 * 60 * 1000, condition: () => this.state.mapLayers.cables }, + { name: 'flights', fn: () => this.dataLoader.loadFlightDelays(), intervalMs: 10 * 60 * 1000, condition: () => this.state.mapLayers.flights }, + { name: 'cyberThreats', fn: () => { + this.state.cyberThreatsCache = null; + return this.dataLoader.loadCyberThreats(); + }, intervalMs: 10 * 60 * 1000, condition: () => CYBER_LAYER_ENABLED && this.state.mapLayers.cyberThreats }, + ]); + } + + // WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream + if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') { + this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), 10 * 60 * 1000); + this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), 10 * 60 * 1000); + } + + // Refresh intelligence signals for CII (geopolitical variant only) + if (SITE_VARIANT === 'full') { + this.refreshScheduler.scheduleRefresh('intelligence', () => { + const { military } = this.state.intelligenceCache; + this.state.intelligenceCache = {}; + if (military) this.state.intelligenceCache.military = military; + return this.dataLoader.loadIntelligenceSignals(); + }, 15 * 60 * 1000); + } } } diff --git a/src/app/app-context.ts b/src/app/app-context.ts new file mode 100644 index 000000000..3c7e46f83 --- /dev/null +++ b/src/app/app-context.ts @@ -0,0 +1,108 @@ +import type { NewsItem, Monitor, PanelConfig, MapLayers, InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryFlightCluster, MilitaryVessel, MilitaryVesselCluster, CyberThreat, USNIFleetReport } from '@/types'; +import type { MapContainer, Panel, NewsPanel, SignalModal, StatusPanel, SearchModal } from '@/components'; +import type { IntelligenceGapBadge } from '@/components'; +import type { MarketData, ClusteredEvent } from '@/types'; +import type { PredictionMarket } from '@/services/prediction'; +import type { TimeRange } from '@/components'; +import type { Earthquake } from '@/services/earthquakes'; +import type { CountryBriefPage } from '@/components/CountryBriefPage'; +import type { CountryTimeline } from '@/components/CountryTimeline'; +import type { PlaybackControl } from '@/components'; +import type { ExportPanel } from '@/utils'; +import type { UnifiedSettings } from '@/components/UnifiedSettings'; +import type { MobileWarningModal, PizzIntIndicator } from '@/components'; +import type { ParsedMapUrlState } from '@/utils'; +import type { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; +import type { CountersPanel } from '@/components/CountersPanel'; +import type { ProgressChartsPanel } from '@/components/ProgressChartsPanel'; +import type { BreakthroughsTickerPanel } from '@/components/BreakthroughsTickerPanel'; +import type { HeroSpotlightPanel } from '@/components/HeroSpotlightPanel'; +import type { GoodThingsDigestPanel } from '@/components/GoodThingsDigestPanel'; +import type { SpeciesComebackPanel } from '@/components/SpeciesComebackPanel'; +import type { RenewableEnergyPanel } from '@/components/RenewableEnergyPanel'; +import type { TvModeController } from '@/services/tv-mode'; + +export interface CountryBriefSignals { + protests: number; + militaryFlights: number; + militaryVessels: number; + outages: number; + earthquakes: number; + displacementOutflow: number; + climateStress: number; + conflictEvents: number; + isTier1: boolean; +} + +export interface IntelligenceCache { + outages?: InternetOutage[]; + protests?: { events: SocialUnrestEvent[]; sources: { acled: number; gdelt: number } }; + military?: { flights: MilitaryFlight[]; flightClusters: MilitaryFlightCluster[]; vessels: MilitaryVessel[]; vesselClusters: MilitaryVesselCluster[] }; + earthquakes?: Earthquake[]; + usniFleet?: USNIFleetReport; +} + +export interface AppModule { + init(): void | Promise; + destroy(): void; +} + +export interface AppContext { + map: MapContainer | null; + readonly isMobile: boolean; + readonly isDesktopApp: boolean; + readonly container: HTMLElement; + + panels: Record; + newsPanels: Record; + panelSettings: Record; + + mapLayers: MapLayers; + + allNews: NewsItem[]; + newsByCategory: Record; + latestMarkets: MarketData[]; + latestPredictions: PredictionMarket[]; + latestClusters: ClusteredEvent[]; + intelligenceCache: IntelligenceCache; + cyberThreatsCache: CyberThreat[] | null; + + disabledSources: Set; + currentTimeRange: TimeRange; + + inFlight: Set; + seenGeoAlerts: Set; + monitors: Monitor[]; + + signalModal: SignalModal | null; + statusPanel: StatusPanel | null; + searchModal: SearchModal | null; + findingsBadge: IntelligenceGapBadge | null; + playbackControl: PlaybackControl | null; + exportPanel: ExportPanel | null; + unifiedSettings: UnifiedSettings | null; + mobileWarningModal: MobileWarningModal | null; + pizzintIndicator: PizzIntIndicator | null; + countryBriefPage: CountryBriefPage | null; + countryTimeline: CountryTimeline | null; + + // Happy variant state + positivePanel: PositiveNewsFeedPanel | null; + countersPanel: CountersPanel | null; + progressPanel: ProgressChartsPanel | null; + breakthroughsPanel: BreakthroughsTickerPanel | null; + heroPanel: HeroSpotlightPanel | null; + digestPanel: GoodThingsDigestPanel | null; + speciesPanel: SpeciesComebackPanel | null; + renewablePanel: RenewableEnergyPanel | null; + tvMode: TvModeController | null; + happyAllItems: NewsItem[]; + isDestroyed: boolean; + isPlaybackMode: boolean; + isIdle: boolean; + initialLoadComplete: boolean; + + initialUrlState: ParsedMapUrlState | null; + readonly PANEL_ORDER_KEY: string; + readonly PANEL_SPANS_KEY: string; +} diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts new file mode 100644 index 000000000..079091751 --- /dev/null +++ b/src/app/country-intel.ts @@ -0,0 +1,530 @@ +import type { AppContext, AppModule, CountryBriefSignals } from '@/app/app-context'; +import type { TimelineEvent } from '@/components/CountryTimeline'; +import { CountryTimeline } from '@/components/CountryTimeline'; +import { CountryBriefPage } from '@/components/CountryBriefPage'; +import { reverseGeocode } from '@/utils/reverse-geocode'; +import { getCountryAtCoordinates, hasCountryGeometry, isCoordinateInCountry } from '@/services/country-geometry'; +import { calculateCII, getCountryData, TIER1_COUNTRIES } from '@/services/country-instability'; +import { signalAggregator } from '@/services/signal-aggregator'; +import { dataFreshness } from '@/services/data-freshness'; +import { fetchCountryMarkets } from '@/services/prediction'; +import { collectStoryData } from '@/services/story-data'; +import { renderStoryToCanvas } from '@/services/story-renderer'; +import { openStoryModal } from '@/components/StoryModal'; +import { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client'; +import { IntelligenceServiceClient } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; +import { BETA_MODE } from '@/config/beta'; +import { mlWorker } from '@/services/ml-worker'; +import { t } from '@/services/i18n'; +import { trackCountrySelected, trackCountryBriefOpened } from '@/services/analytics'; +import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel'; + +type IntlDisplayNamesCtor = new ( + locales: string | string[], + options: { type: 'region' } +) => { of: (code: string) => string | undefined }; + +export class CountryIntelManager implements AppModule { + private ctx: AppContext; + private briefRequestToken = 0; + + constructor(ctx: AppContext) { + this.ctx = ctx; + } + + init(): void { + this.setupCountryIntel(); + } + + destroy(): void { + this.ctx.countryTimeline?.destroy(); + this.ctx.countryTimeline = null; + this.ctx.countryBriefPage = null; + } + + private setupCountryIntel(): void { + if (!this.ctx.map) return; + this.ctx.countryBriefPage = new CountryBriefPage(); + this.ctx.countryBriefPage.setShareStoryHandler((code, name) => { + this.ctx.countryBriefPage?.hide(); + this.openCountryStory(code, name); + }); + this.ctx.countryBriefPage.setExportImageHandler(async (code, name) => { + try { + const signals = this.getCountrySignals(code, name); + const cluster = signalAggregator.getCountryClusters().find(c => c.country === code); + const regional = signalAggregator.getRegionalConvergence().filter(r => r.countries.includes(code)); + const convergence = cluster ? { + score: cluster.convergenceScore, + signalTypes: [...cluster.signalTypes], + regionalDescriptions: regional.map(r => r.description), + } : null; + const posturePanel = this.ctx.panels['strategic-posture'] as StrategicPosturePanel | undefined; + const postures = posturePanel?.getPostures() || []; + const data = collectStoryData(code, name, this.ctx.latestClusters, postures, this.ctx.latestPredictions, signals, convergence); + const canvas = await renderStoryToCanvas(data); + const dataUrl = canvas.toDataURL('image/png'); + const a = document.createElement('a'); + a.href = dataUrl; + a.download = `country-brief-${code.toLowerCase()}-${Date.now()}.png`; + a.click(); + } catch (err) { + console.error('[CountryBrief] Image export failed:', err); + } + }); + + this.ctx.map.onCountryClicked(async (countryClick) => { + if (countryClick.code && countryClick.name) { + trackCountrySelected(countryClick.code, countryClick.name, 'map'); + this.openCountryBriefByCode(countryClick.code, countryClick.name); + } else { + this.openCountryBrief(countryClick.lat, countryClick.lon); + } + }); + + this.ctx.countryBriefPage.onClose(() => { + this.briefRequestToken++; + this.ctx.map?.clearCountryHighlight(); + this.ctx.map?.setRenderPaused(false); + this.ctx.countryTimeline?.destroy(); + this.ctx.countryTimeline = null; + }); + } + + async openCountryBrief(lat: number, lon: number): Promise { + if (!this.ctx.countryBriefPage) return; + const token = ++this.briefRequestToken; + this.ctx.countryBriefPage.showLoading(); + this.ctx.map?.setRenderPaused(true); + + const localGeo = getCountryAtCoordinates(lat, lon); + if (localGeo) { + if (token !== this.briefRequestToken) return; + this.openCountryBriefByCode(localGeo.code, localGeo.name); + return; + } + + const geo = await reverseGeocode(lat, lon); + if (token !== this.briefRequestToken) return; + if (!geo) { + this.ctx.countryBriefPage.hide(); + this.ctx.map?.setRenderPaused(false); + return; + } + + this.openCountryBriefByCode(geo.code, geo.country); + } + + async openCountryBriefByCode(code: string, country: string): Promise { + if (!this.ctx.countryBriefPage) return; + this.ctx.map?.setRenderPaused(true); + trackCountryBriefOpened(code); + + const canonicalName = TIER1_COUNTRIES[code] || CountryIntelManager.resolveCountryName(code); + if (canonicalName !== code) country = canonicalName; + + const scores = calculateCII(); + const score = scores.find((s) => s.code === code) ?? null; + const signals = this.getCountrySignals(code, country); + + this.ctx.countryBriefPage.show(country, code, score, signals); + this.ctx.map?.highlightCountry(code); + + const marketClient = new MarketServiceClient('', { fetch: (...args: Parameters) => globalThis.fetch(...args) }); + const stockPromise = marketClient.getCountryStockIndex({ countryCode: code }) + .then((resp) => ({ + available: resp.available, + code: resp.code, + symbol: resp.symbol, + indexName: resp.indexName, + price: String(resp.price), + weekChangePercent: String(resp.weekChangePercent), + currency: resp.currency, + })) + .catch(() => ({ available: false as const, code: '', symbol: '', indexName: '', price: '0', weekChangePercent: '0', currency: '' })); + + stockPromise.then((stock) => { + if (this.ctx.countryBriefPage?.getCode() === code) this.ctx.countryBriefPage.updateStock(stock); + }); + + fetchCountryMarkets(country) + .then((markets) => { + if (this.ctx.countryBriefPage?.getCode() === code) this.ctx.countryBriefPage.updateMarkets(markets); + }) + .catch(() => { + if (this.ctx.countryBriefPage?.getCode() === code) this.ctx.countryBriefPage.updateMarkets([]); + }); + + const searchTerms = CountryIntelManager.getCountrySearchTerms(country, code); + const otherCountryTerms = CountryIntelManager.getOtherCountryTerms(code); + const matchingNews = this.ctx.allNews.filter((n) => { + const t = n.title.toLowerCase(); + return searchTerms.some((term) => t.includes(term)); + }); + const filteredNews = matchingNews.filter((n) => { + const t = n.title.toLowerCase(); + const ourPos = CountryIntelManager.firstMentionPosition(t, searchTerms); + const otherPos = CountryIntelManager.firstMentionPosition(t, otherCountryTerms); + return ourPos !== Infinity && (otherPos === Infinity || ourPos <= otherPos); + }); + if (filteredNews.length > 0) { + this.ctx.countryBriefPage.updateNews(filteredNews.slice(0, 8)); + } + + this.ctx.countryBriefPage.updateInfrastructure(code); + + this.mountCountryTimeline(code, country); + + try { + const context: Record = {}; + if (score) { + context.score = score.score; + context.level = score.level; + context.trend = score.trend; + context.components = score.components; + context.change24h = score.change24h; + } + Object.assign(context, signals); + + const countryCluster = signalAggregator.getCountryClusters().find((c) => c.country === code); + if (countryCluster) { + context.convergenceScore = countryCluster.convergenceScore; + context.signalTypes = [...countryCluster.signalTypes]; + } + + const convergences = signalAggregator.getRegionalConvergence() + .filter((r) => r.countries.includes(code)); + if (convergences.length) { + context.regionalConvergence = convergences.map((r) => r.description); + } + + const headlines = filteredNews.slice(0, 15).map((n) => n.title); + if (headlines.length) context.headlines = headlines; + + const stockData = await stockPromise; + if (stockData.available) { + const pct = parseFloat(stockData.weekChangePercent); + context.stockIndex = `${stockData.indexName}: ${stockData.price} (${pct >= 0 ? '+' : ''}${stockData.weekChangePercent}% week)`; + } + + let briefText = ''; + try { + const intelClient = new IntelligenceServiceClient('', { fetch: (...args: Parameters) => globalThis.fetch(...args) }); + const resp = await intelClient.getCountryIntelBrief({ countryCode: code }); + briefText = resp.brief; + } catch { /* server unreachable */ } + + if (briefText) { + this.ctx.countryBriefPage!.updateBrief({ brief: briefText, country, code }); + } else { + const briefHeadlines = (context.headlines as string[] | undefined) || []; + let fallbackBrief = ''; + const sumModelId = BETA_MODE ? 'summarization-beta' : 'summarization'; + if (briefHeadlines.length >= 2 && mlWorker.isAvailable && mlWorker.isModelLoaded(sumModelId)) { + try { + const prompt = `Summarize the current situation in ${country} based on these headlines: ${briefHeadlines.slice(0, 8).join('. ')}`; + const [summary] = await mlWorker.summarize([prompt], BETA_MODE ? 'summarization-beta' : undefined); + if (summary && summary.length > 20) fallbackBrief = summary; + } catch { /* T5 failed */ } + } + + if (fallbackBrief) { + this.ctx.countryBriefPage!.updateBrief({ brief: fallbackBrief, country, code, fallback: true }); + } else { + const lines: string[] = []; + if (score) lines.push(t('countryBrief.fallback.instabilityIndex', { score: String(score.score), level: t(`countryBrief.levels.${score.level}`), trend: t(`countryBrief.trends.${score.trend}`) })); + if (signals.protests > 0) lines.push(t('countryBrief.fallback.protestsDetected', { count: String(signals.protests) })); + if (signals.militaryFlights > 0) lines.push(t('countryBrief.fallback.aircraftTracked', { count: String(signals.militaryFlights) })); + if (signals.militaryVessels > 0) lines.push(t('countryBrief.fallback.vesselsTracked', { count: String(signals.militaryVessels) })); + if (signals.outages > 0) lines.push(t('countryBrief.fallback.internetOutages', { count: String(signals.outages) })); + if (signals.earthquakes > 0) lines.push(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) })); + if (context.stockIndex) lines.push(t('countryBrief.fallback.stockIndex', { value: context.stockIndex })); + if (briefHeadlines.length > 0) { + lines.push('', t('countryBrief.fallback.recentHeadlines')); + briefHeadlines.slice(0, 5).forEach(h => lines.push(`• ${h}`)); + } + if (lines.length > 0) { + this.ctx.countryBriefPage!.updateBrief({ brief: lines.join('\n'), country, code, fallback: true }); + } else { + this.ctx.countryBriefPage!.updateBrief({ brief: '', country, code, error: 'No AI service available. Configure GROQ_API_KEY in Settings for full briefs.' }); + } + } + } + } catch (err) { + console.error('[CountryBrief] fetch error:', err); + this.ctx.countryBriefPage!.updateBrief({ brief: '', country, code, error: 'Failed to generate brief' }); + } + } + + private mountCountryTimeline(code: string, country: string): void { + this.ctx.countryTimeline?.destroy(); + this.ctx.countryTimeline = null; + + const mount = this.ctx.countryBriefPage?.getTimelineMount(); + if (!mount) return; + + const events: TimelineEvent[] = []; + const countryLower = country.toLowerCase(); + const hasGeoShape = hasCountryGeometry(code) || !!CountryIntelManager.COUNTRY_BOUNDS[code]; + const inCountry = (lat: number, lon: number) => hasGeoShape && this.isInCountry(lat, lon, code); + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + + if (this.ctx.intelligenceCache.protests?.events) { + for (const e of this.ctx.intelligenceCache.protests.events) { + if (e.country?.toLowerCase() === countryLower || inCountry(e.lat, e.lon)) { + events.push({ + timestamp: new Date(e.time).getTime(), + lane: 'protest', + label: e.title || `${e.eventType} in ${e.city || e.country}`, + severity: e.severity === 'high' ? 'high' : e.severity === 'medium' ? 'medium' : 'low', + }); + } + } + } + + if (this.ctx.intelligenceCache.earthquakes) { + for (const eq of this.ctx.intelligenceCache.earthquakes) { + if (inCountry(eq.location?.latitude ?? 0, eq.location?.longitude ?? 0) || eq.place?.toLowerCase().includes(countryLower)) { + events.push({ + timestamp: eq.occurredAt, + lane: 'natural', + label: `M${eq.magnitude.toFixed(1)} ${eq.place}`, + severity: eq.magnitude >= 6 ? 'critical' : eq.magnitude >= 5 ? 'high' : eq.magnitude >= 4 ? 'medium' : 'low', + }); + } + } + } + + if (this.ctx.intelligenceCache.military) { + for (const f of this.ctx.intelligenceCache.military.flights) { + if (hasGeoShape ? this.isInCountry(f.lat, f.lon, code) : f.operatorCountry?.toUpperCase() === code) { + events.push({ + timestamp: new Date(f.lastSeen).getTime(), + lane: 'military', + label: `${f.callsign} (${f.aircraftModel || f.aircraftType})`, + severity: f.isInteresting ? 'high' : 'low', + }); + } + } + for (const v of this.ctx.intelligenceCache.military.vessels) { + if (hasGeoShape ? this.isInCountry(v.lat, v.lon, code) : v.operatorCountry?.toUpperCase() === code) { + events.push({ + timestamp: new Date(v.lastAisUpdate).getTime(), + lane: 'military', + label: `${v.name} (${v.vesselType})`, + severity: v.isDark ? 'high' : 'low', + }); + } + } + } + + const ciiData = getCountryData(code); + if (ciiData?.conflicts) { + for (const c of ciiData.conflicts) { + events.push({ + timestamp: new Date(c.time).getTime(), + lane: 'conflict', + label: `${c.eventType}: ${c.location || c.country}`, + severity: c.fatalities > 0 ? 'critical' : 'high', + }); + } + } + + this.ctx.countryTimeline = new CountryTimeline(mount); + this.ctx.countryTimeline.render(events.filter(e => e.timestamp >= sevenDaysAgo)); + } + + getCountrySignals(code: string, country: string): CountryBriefSignals { + const countryLower = country.toLowerCase(); + const hasGeoShape = hasCountryGeometry(code) || !!CountryIntelManager.COUNTRY_BOUNDS[code]; + + let protests = 0; + if (this.ctx.intelligenceCache.protests?.events) { + protests = this.ctx.intelligenceCache.protests.events.filter((e) => + e.country?.toLowerCase() === countryLower || (hasGeoShape && this.isInCountry(e.lat, e.lon, code)) + ).length; + } + + let militaryFlights = 0; + let militaryVessels = 0; + if (this.ctx.intelligenceCache.military) { + militaryFlights = this.ctx.intelligenceCache.military.flights.filter((f) => + hasGeoShape ? this.isInCountry(f.lat, f.lon, code) : f.operatorCountry?.toUpperCase() === code + ).length; + militaryVessels = this.ctx.intelligenceCache.military.vessels.filter((v) => + hasGeoShape ? this.isInCountry(v.lat, v.lon, code) : v.operatorCountry?.toUpperCase() === code + ).length; + } + + let outages = 0; + if (this.ctx.intelligenceCache.outages) { + outages = this.ctx.intelligenceCache.outages.filter((o) => + o.country?.toLowerCase() === countryLower || (hasGeoShape && this.isInCountry(o.lat, o.lon, code)) + ).length; + } + + let earthquakes = 0; + if (this.ctx.intelligenceCache.earthquakes) { + earthquakes = this.ctx.intelligenceCache.earthquakes.filter((eq) => { + if (hasGeoShape) return this.isInCountry(eq.location?.latitude ?? 0, eq.location?.longitude ?? 0, code); + return eq.place?.toLowerCase().includes(countryLower); + }).length; + } + + const ciiData = getCountryData(code); + const isTier1 = !!TIER1_COUNTRIES[code]; + + return { + protests, + militaryFlights, + militaryVessels, + outages, + earthquakes, + displacementOutflow: ciiData?.displacementOutflow ?? 0, + climateStress: ciiData?.climateStress ?? 0, + conflictEvents: ciiData?.conflicts?.length ?? 0, + isTier1, + }; + } + + openCountryStory(code: string, name: string): void { + if (!dataFreshness.hasSufficientData() || this.ctx.latestClusters.length === 0) { + this.showToast('Data still loading — try again in a moment'); + return; + } + const posturePanel = this.ctx.panels['strategic-posture'] as StrategicPosturePanel | undefined; + const postures = posturePanel?.getPostures() || []; + const signals = this.getCountrySignals(code, name); + const cluster = signalAggregator.getCountryClusters().find(c => c.country === code); + const regional = signalAggregator.getRegionalConvergence().filter(r => r.countries.includes(code)); + const convergence = cluster ? { + score: cluster.convergenceScore, + signalTypes: [...cluster.signalTypes], + regionalDescriptions: regional.map(r => r.description), + } : null; + const data = collectStoryData(code, name, this.ctx.latestClusters, postures, this.ctx.latestPredictions, signals, convergence); + openStoryModal(data); + } + + showToast(msg: string): void { + document.querySelector('.toast-notification')?.remove(); + const el = document.createElement('div'); + el.className = 'toast-notification'; + el.textContent = msg; + document.body.appendChild(el); + requestAnimationFrame(() => el.classList.add('visible')); + setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); + } + + private isInCountry(lat: number, lon: number, code: string): boolean { + const precise = isCoordinateInCountry(lat, lon, code); + if (precise != null) return precise; + const b = CountryIntelManager.COUNTRY_BOUNDS[code]; + if (!b) return false; + return lat >= b.s && lat <= b.n && lon >= b.w && lon <= b.e; + } + + static COUNTRY_BOUNDS: Record = { + IR: { n: 40, s: 25, e: 63, w: 44 }, IL: { n: 33.3, s: 29.5, e: 35.9, w: 34.3 }, + SA: { n: 32, s: 16, e: 55, w: 35 }, AE: { n: 26.1, s: 22.6, e: 56.4, w: 51.6 }, + IQ: { n: 37.4, s: 29.1, e: 48.6, w: 38.8 }, SY: { n: 37.3, s: 32.3, e: 42.4, w: 35.7 }, + YE: { n: 19, s: 12, e: 54.5, w: 42 }, LB: { n: 34.7, s: 33.1, e: 36.6, w: 35.1 }, + CN: { n: 53.6, s: 18.2, e: 134.8, w: 73.5 }, TW: { n: 25.3, s: 21.9, e: 122, w: 120 }, + JP: { n: 45.5, s: 24.2, e: 153.9, w: 122.9 }, KR: { n: 38.6, s: 33.1, e: 131.9, w: 124.6 }, + KP: { n: 43.0, s: 37.7, e: 130.7, w: 124.2 }, IN: { n: 35.5, s: 6.7, e: 97.4, w: 68.2 }, + PK: { n: 37, s: 24, e: 77, w: 61 }, AF: { n: 38.5, s: 29.4, e: 74.9, w: 60.5 }, + UA: { n: 52.4, s: 44.4, e: 40.2, w: 22.1 }, RU: { n: 82, s: 41.2, e: 180, w: 19.6 }, + BY: { n: 56.2, s: 51.3, e: 32.8, w: 23.2 }, PL: { n: 54.8, s: 49, e: 24.1, w: 14.1 }, + EG: { n: 31.7, s: 22, e: 36.9, w: 25 }, LY: { n: 33, s: 19.5, e: 25, w: 9.4 }, + SD: { n: 22, s: 8.7, e: 38.6, w: 21.8 }, US: { n: 49, s: 24.5, e: -66.9, w: -125 }, + GB: { n: 58.7, s: 49.9, e: 1.8, w: -8.2 }, DE: { n: 55.1, s: 47.3, e: 15.0, w: 5.9 }, + FR: { n: 51.1, s: 41.3, e: 9.6, w: -5.1 }, TR: { n: 42.1, s: 36, e: 44.8, w: 26 }, + BR: { n: 5.3, s: -33.8, e: -34.8, w: -73.9 }, + }; + + static COUNTRY_ALIASES: Record = { + IL: ['israel', 'israeli', 'gaza', 'hamas', 'hezbollah', 'netanyahu', 'idf', 'west bank', 'tel aviv', 'jerusalem'], + IR: ['iran', 'iranian', 'tehran', 'persian', 'irgc', 'khamenei'], + RU: ['russia', 'russian', 'moscow', 'kremlin', 'putin', 'ukraine war'], + UA: ['ukraine', 'ukrainian', 'kyiv', 'zelensky', 'zelenskyy'], + CN: ['china', 'chinese', 'beijing', 'taiwan strait', 'south china sea', 'xi jinping'], + TW: ['taiwan', 'taiwanese', 'taipei'], + KP: ['north korea', 'pyongyang', 'kim jong'], + KR: ['south korea', 'seoul'], + SA: ['saudi', 'riyadh', 'mbs'], + SY: ['syria', 'syrian', 'damascus', 'assad'], + YE: ['yemen', 'houthi', 'sanaa'], + IQ: ['iraq', 'iraqi', 'baghdad'], + AF: ['afghanistan', 'afghan', 'kabul', 'taliban'], + PK: ['pakistan', 'pakistani', 'islamabad'], + IN: ['india', 'indian', 'new delhi', 'modi'], + EG: ['egypt', 'egyptian', 'cairo', 'suez'], + LB: ['lebanon', 'lebanese', 'beirut'], + TR: ['turkey', 'turkish', 'ankara', 'erdogan', 'türkiye'], + US: ['united states', 'american', 'washington', 'pentagon', 'white house'], + GB: ['united kingdom', 'british', 'london', 'uk '], + BR: ['brazil', 'brazilian', 'brasilia', 'lula', 'bolsonaro'], + AE: ['united arab emirates', 'uae', 'emirati', 'dubai', 'abu dhabi'], + }; + + private static otherCountryTermsCache: Map = new Map(); + + static firstMentionPosition(text: string, terms: string[]): number { + let earliest = Infinity; + for (const term of terms) { + const idx = text.indexOf(term); + if (idx !== -1 && idx < earliest) earliest = idx; + } + return earliest; + } + + static getOtherCountryTerms(code: string): string[] { + const cached = CountryIntelManager.otherCountryTermsCache.get(code); + if (cached) return cached; + + const dedup = new Set(); + Object.entries(CountryIntelManager.COUNTRY_ALIASES).forEach(([countryCode, aliases]) => { + if (countryCode === code) return; + aliases.forEach((alias) => { + const normalized = alias.toLowerCase(); + if (normalized.trim().length > 0) dedup.add(normalized); + }); + }); + + const terms = [...dedup]; + CountryIntelManager.otherCountryTermsCache.set(code, terms); + return terms; + } + + static resolveCountryName(code: string): string { + if (TIER1_COUNTRIES[code]) return TIER1_COUNTRIES[code]; + + try { + const displayNamesCtor = (Intl as unknown as { DisplayNames?: IntlDisplayNamesCtor }).DisplayNames; + if (!displayNamesCtor) return code; + const displayNames = new displayNamesCtor(['en'], { type: 'region' }); + const resolved = displayNames.of(code); + if (resolved && resolved.toUpperCase() !== code) return resolved; + } catch { + // Intl.DisplayNames unavailable in older runtimes. + } + + return code; + } + + static getCountrySearchTerms(country: string, code: string): string[] { + const aliases = CountryIntelManager.COUNTRY_ALIASES[code]; + if (aliases) return aliases; + if (/^[A-Z]{2}$/i.test(country.trim())) return []; + return [country.toLowerCase()]; + } + + static toFlagEmoji(code: string): string { + const upperCode = code.toUpperCase(); + if (!/^[A-Z]{2}$/.test(upperCode)) return '🏳️'; + return upperCode + .split('') + .map((char) => String.fromCodePoint(0x1f1e6 + char.charCodeAt(0) - 65)) + .join(''); + } +} diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts new file mode 100644 index 000000000..b30eb65c2 --- /dev/null +++ b/src/app/data-loader.ts @@ -0,0 +1,1868 @@ +import type { AppContext, AppModule } from '@/app/app-context'; +import type { NewsItem, MapLayers, SocialUnrestEvent } from '@/types'; +import type { MarketData } from '@/types'; +import type { TimeRange } from '@/components'; +import { + FEEDS, + INTEL_SOURCES, + SECTORS, + COMMODITIES, + MARKET_SYMBOLS, + SITE_VARIANT, + LAYER_TO_SOURCE, +} from '@/config'; +import { INTEL_HOTSPOTS, CONFLICT_ZONES } from '@/config/geo'; +import { + fetchCategoryFeeds, + getFeedFailures, + fetchMultipleStocks, + fetchCrypto, + fetchPredictions, + fetchEarthquakes, + fetchWeatherAlerts, + fetchFredData, + fetchInternetOutages, + isOutagesConfigured, + fetchAisSignals, + getAisStatus, + isAisConfigured, + fetchCableActivity, + fetchCableHealth, + fetchProtestEvents, + getProtestStatus, + fetchFlightDelays, + fetchMilitaryFlights, + fetchMilitaryVessels, + initMilitaryVesselStream, + isMilitaryVesselTrackingConfigured, + fetchUSNIFleetReport, + updateBaseline, + calculateDeviation, + addToSignalHistory, + analysisWorker, + fetchPizzIntStatus, + fetchGdeltTensions, + fetchNaturalEvents, + fetchRecentAwards, + fetchOilAnalytics, + fetchBisData, + fetchCyberThreats, + drainTrendingSignals, + fetchTradeRestrictions, + fetchTariffTrends, + fetchTradeFlows, + fetchTradeBarriers, + fetchShippingRates, + fetchChokepointStatus, + fetchCriticalMinerals, +} from '@/services'; +import { mlWorker } from '@/services/ml-worker'; +import { clusterNewsHybrid } from '@/services/clustering'; +import { ingestProtests, ingestFlights, ingestVessels, ingestEarthquakes, detectGeoConvergence, geoConvergenceToSignal } from '@/services/geo-convergence'; +import { signalAggregator } from '@/services/signal-aggregator'; +import { updateAndCheck } from '@/services/temporal-baseline'; +import { fetchAllFires, flattenFires, computeRegionStats, toMapFires } from '@/services/wildfires'; +import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresence, foreignPresenceToSignal, type TheaterPostureSummary } from '@/services/military-surge'; +import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture'; +import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, isInLearningMode } from '@/services/country-instability'; +import { dataFreshness, type DataSourceId } from '@/services/data-freshness'; +import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchUcdpEvents, deduplicateAgainstAcled } from '@/services/conflict'; +import { fetchUnhcrPopulation } from '@/services/displacement'; +import { fetchClimateAnomalies } from '@/services/climate'; +import { enrichEventsWithExposure } from '@/services/population-exposure'; +import { debounce, getCircuitBreakerCooldownInfo } from '@/utils'; +import { isFeatureAvailable } from '@/services/runtime-config'; +import { getAiFlowSettings } from '@/services/ai-flow-settings'; +import { t } from '@/services/i18n'; +import { maybeShowDownloadBanner } from '@/components/DownloadBanner'; +import { mountCommunityWidget } from '@/components/CommunityWidget'; +import { ResearchServiceClient } from '@/generated/client/worldmonitor/research/v1/service_client'; +import { + MarketPanel, + HeatmapPanel, + CommoditiesPanel, + CryptoPanel, + PredictionPanel, + MonitorPanel, + InsightsPanel, + CIIPanel, + StrategicPosturePanel, + EconomicPanel, + TechReadinessPanel, + UcdpEventsPanel, + DisplacementPanel, + ClimateAnomalyPanel, + PopulationExposurePanel, + TradePolicyPanel, + SupplyChainPanel, +} from '@/components'; +import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; +import { classifyNewsItem } from '@/services/positive-classifier'; +import { fetchGivingSummary } from '@/services/giving'; +import { GivingPanel } from '@/components'; +import { fetchProgressData } from '@/services/progress-data'; +import { fetchConservationWins } from '@/services/conservation-data'; +import { fetchRenewableEnergyData, fetchEnergyCapacity } from '@/services/renewable-energy-data'; +import { checkMilestones } from '@/services/celebration'; +import { fetchHappinessScores } from '@/services/happiness-data'; +import { fetchRenewableInstallations } from '@/services/renewable-installations'; +import { filterBySentiment } from '@/services/sentiment-gate'; +import { fetchAllPositiveTopicIntelligence } from '@/services/gdelt-intel'; +import { fetchPositiveGeoEvents, geocodePositiveNewsItems } from '@/services/positive-events-geo'; +import { fetchKindnessData } from '@/services/kindness-data'; +import { getPersistentCache, setPersistentCache } from '@/services/persistent-cache'; + +const CYBER_LAYER_ENABLED = import.meta.env.VITE_ENABLE_CYBER_LAYER === 'true'; + +export interface DataLoaderCallbacks { + renderCriticalBanner: (postures: TheaterPostureSummary[]) => void; +} + +export class DataLoaderManager implements AppModule { + private ctx: AppContext; + private callbacks: DataLoaderCallbacks; + + private mapFlashCache: Map = new Map(); + private readonly MAP_FLASH_COOLDOWN_MS = 10 * 60 * 1000; + private readonly applyTimeRangeFilterToNewsPanelsDebounced = debounce(() => { + this.applyTimeRangeFilterToNewsPanels(); + }, 120); + + public updateSearchIndex: () => void = () => {}; + + constructor(ctx: AppContext, callbacks: DataLoaderCallbacks) { + this.ctx = ctx; + this.callbacks = callbacks; + } + + init(): void {} + + destroy(): void {} + + private shouldShowIntelligenceNotifications(): boolean { + return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled(); + } + + async loadAllData(): Promise { + const runGuarded = async (name: string, fn: () => Promise): Promise => { + if (this.ctx.isDestroyed || this.ctx.inFlight.has(name)) return; + this.ctx.inFlight.add(name); + try { + await fn(); + } catch (e) { + if (!this.ctx.isDestroyed) console.error(`[App] ${name} failed:`, e); + } finally { + this.ctx.inFlight.delete(name); + } + }; + + const tasks: Array<{ name: string; task: Promise }> = [ + { name: 'news', task: runGuarded('news', () => this.loadNews()) }, + ]; + + // Happy variant only loads news data -- skip all geopolitical/financial/military data + if (SITE_VARIANT !== 'happy') { + tasks.push({ name: 'markets', task: runGuarded('markets', () => this.loadMarkets()) }); + tasks.push({ name: 'predictions', task: runGuarded('predictions', () => this.loadPredictions()) }); + tasks.push({ name: 'pizzint', task: runGuarded('pizzint', () => this.loadPizzInt()) }); + tasks.push({ name: 'fred', task: runGuarded('fred', () => this.loadFredData()) }); + tasks.push({ name: 'oil', task: runGuarded('oil', () => this.loadOilAnalytics()) }); + tasks.push({ name: 'spending', task: runGuarded('spending', () => this.loadGovernmentSpending()) }); + tasks.push({ name: 'bis', task: runGuarded('bis', () => this.loadBisData()) }); + + // Trade policy data (FULL and FINANCE only) + if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') { + tasks.push({ name: 'tradePolicy', task: runGuarded('tradePolicy', () => this.loadTradePolicy()) }); + tasks.push({ name: 'supplyChain', task: runGuarded('supplyChain', () => this.loadSupplyChain()) }); + } + } + + // Progress charts data (happy variant only) + if (SITE_VARIANT === 'happy') { + tasks.push({ + name: 'progress', + task: runGuarded('progress', () => this.loadProgressData()), + }); + tasks.push({ + name: 'species', + task: runGuarded('species', () => this.loadSpeciesData()), + }); + tasks.push({ + name: 'renewable', + task: runGuarded('renewable', () => this.loadRenewableData()), + }); + tasks.push({ + name: 'happinessMap', + task: runGuarded('happinessMap', async () => { + const data = await fetchHappinessScores(); + this.ctx.map?.setHappinessScores(data); + }), + }); + tasks.push({ + name: 'renewableMap', + task: runGuarded('renewableMap', async () => { + const installations = await fetchRenewableInstallations(); + this.ctx.map?.setRenewableInstallations(installations); + }), + }); + } + + // Global giving activity data (all variants) + tasks.push({ + name: 'giving', + task: runGuarded('giving', async () => { + const givingResult = await fetchGivingSummary(); + if (!givingResult.ok) { + dataFreshness.recordError('giving', 'Giving data unavailable (retaining prior state)'); + return; + } + const data = givingResult.data; + (this.ctx.panels['giving'] as GivingPanel)?.setData(data); + if (data.platforms.length > 0) dataFreshness.recordUpdate('giving', data.platforms.length); + }), + }); + + if (SITE_VARIANT === 'full') { + tasks.push({ name: 'intelligence', task: runGuarded('intelligence', () => this.loadIntelligenceSignals()) }); + } + + if (SITE_VARIANT === 'full') tasks.push({ name: 'firms', task: runGuarded('firms', () => this.loadFirmsData()) }); + if (this.ctx.mapLayers.natural) tasks.push({ name: 'natural', task: runGuarded('natural', () => this.loadNatural()) }); + if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.weather) tasks.push({ name: 'weather', task: runGuarded('weather', () => this.loadWeatherAlerts()) }); + if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.ais) tasks.push({ name: 'ais', task: runGuarded('ais', () => this.loadAisSignals()) }); + if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.cables) tasks.push({ name: 'cables', task: runGuarded('cables', () => this.loadCableActivity()) }); + if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.cables) tasks.push({ name: 'cableHealth', task: runGuarded('cableHealth', () => this.loadCableHealth()) }); + if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.flights) tasks.push({ name: 'flights', task: runGuarded('flights', () => this.loadFlightDelays()) }); + if (SITE_VARIANT !== 'happy' && CYBER_LAYER_ENABLED && this.ctx.mapLayers.cyberThreats) tasks.push({ name: 'cyberThreats', task: runGuarded('cyberThreats', () => this.loadCyberThreats()) }); + if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) }); + + if (SITE_VARIANT === 'tech') { + tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) }); + } + + const results = await Promise.allSettled(tasks.map(t => t.task)); + + results.forEach((result, idx) => { + if (result.status === 'rejected') { + console.error(`[App] ${tasks[idx]?.name} load failed:`, result.reason); + } + }); + + this.updateSearchIndex(); + } + + async loadDataForLayer(layer: keyof MapLayers): Promise { + if (this.ctx.isDestroyed || this.ctx.inFlight.has(layer)) return; + this.ctx.inFlight.add(layer); + this.ctx.map?.setLayerLoading(layer, true); + try { + switch (layer) { + case 'natural': + await this.loadNatural(); + break; + case 'fires': + await this.loadFirmsData(); + break; + case 'weather': + await this.loadWeatherAlerts(); + break; + case 'outages': + await this.loadOutages(); + break; + case 'cyberThreats': + await this.loadCyberThreats(); + break; + case 'ais': + await this.loadAisSignals(); + break; + case 'cables': + await Promise.all([this.loadCableActivity(), this.loadCableHealth()]); + break; + case 'protests': + await this.loadProtests(); + break; + case 'flights': + await this.loadFlightDelays(); + break; + case 'military': + await this.loadMilitary(); + break; + case 'techEvents': + console.log('[loadDataForLayer] Loading techEvents...'); + await this.loadTechEvents(); + console.log('[loadDataForLayer] techEvents loaded'); + break; + case 'positiveEvents': + await this.loadPositiveEvents(); + break; + case 'kindness': + this.loadKindnessData(); + break; + case 'ucdpEvents': + case 'displacement': + case 'climate': + await this.loadIntelligenceSignals(); + break; + } + } finally { + this.ctx.inFlight.delete(layer); + this.ctx.map?.setLayerLoading(layer, false); + } + } + + private findFlashLocation(title: string): { lat: number; lon: number } | null { + const titleLower = title.toLowerCase(); + let bestMatch: { lat: number; lon: number; matches: number } | null = null; + + const countKeywordMatches = (keywords: string[] | undefined): number => { + if (!keywords) return 0; + let matches = 0; + for (const keyword of keywords) { + const cleaned = keyword.trim().toLowerCase(); + if (cleaned.length >= 3 && titleLower.includes(cleaned)) { + matches++; + } + } + return matches; + }; + + for (const hotspot of INTEL_HOTSPOTS) { + const matches = countKeywordMatches(hotspot.keywords); + if (matches > 0 && (!bestMatch || matches > bestMatch.matches)) { + bestMatch = { lat: hotspot.lat, lon: hotspot.lon, matches }; + } + } + + for (const conflict of CONFLICT_ZONES) { + const matches = countKeywordMatches(conflict.keywords); + if (matches > 0 && (!bestMatch || matches > bestMatch.matches)) { + bestMatch = { lat: conflict.center[1], lon: conflict.center[0], matches }; + } + } + + return bestMatch; + } + + private flashMapForNews(items: NewsItem[]): void { + if (!this.ctx.map || !this.ctx.initialLoadComplete) return; + if (!getAiFlowSettings().mapNewsFlash) return; + const now = Date.now(); + + for (const [key, timestamp] of this.mapFlashCache.entries()) { + if (now - timestamp > this.MAP_FLASH_COOLDOWN_MS) { + this.mapFlashCache.delete(key); + } + } + + for (const item of items) { + const cacheKey = `${item.source}|${item.link || item.title}`; + const lastSeen = this.mapFlashCache.get(cacheKey); + if (lastSeen && now - lastSeen < this.MAP_FLASH_COOLDOWN_MS) { + continue; + } + + const location = this.findFlashLocation(item.title); + if (!location) continue; + + this.ctx.map.flashLocation(location.lat, location.lon); + this.mapFlashCache.set(cacheKey, now); + } + } + + getTimeRangeWindowMs(range: TimeRange): number { + const ranges: Record = { + '1h': 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '48h': 48 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + 'all': Infinity, + }; + return ranges[range]; + } + + filterItemsByTimeRange(items: NewsItem[], range: TimeRange = this.ctx.currentTimeRange): NewsItem[] { + if (range === 'all') return items; + const cutoff = Date.now() - this.getTimeRangeWindowMs(range); + return items.filter((item) => { + const ts = item.pubDate instanceof Date ? item.pubDate.getTime() : new Date(item.pubDate).getTime(); + return Number.isFinite(ts) ? ts >= cutoff : true; + }); + } + + getTimeRangeLabel(range: TimeRange = this.ctx.currentTimeRange): string { + const labels: Record = { + '1h': 'the last hour', + '6h': 'the last 6 hours', + '24h': 'the last 24 hours', + '48h': 'the last 48 hours', + '7d': 'the last 7 days', + 'all': 'all time', + }; + return labels[range]; + } + + renderNewsForCategory(category: string, items: NewsItem[]): void { + this.ctx.newsByCategory[category] = items; + const panel = this.ctx.newsPanels[category]; + if (!panel) return; + const filteredItems = this.filterItemsByTimeRange(items); + if (filteredItems.length === 0 && items.length > 0) { + panel.renderFilteredEmpty(`No items in ${this.getTimeRangeLabel()}`); + return; + } + panel.renderNews(filteredItems); + } + + applyTimeRangeFilterToNewsPanels(): void { + Object.entries(this.ctx.newsByCategory).forEach(([category, items]) => { + this.renderNewsForCategory(category, items); + }); + } + + applyTimeRangeFilterDebounced(): void { + this.applyTimeRangeFilterToNewsPanelsDebounced(); + } + + private async loadNewsCategory(category: string, feeds: typeof FEEDS.politics): Promise { + try { + const panel = this.ctx.newsPanels[category]; + const renderIntervalMs = 100; + let lastRenderTime = 0; + let renderTimeout: ReturnType | null = null; + let pendingItems: NewsItem[] | null = null; + + const enabledFeeds = (feeds ?? []).filter(f => !this.ctx.disabledSources.has(f.name)); + if (enabledFeeds.length === 0) { + delete this.ctx.newsByCategory[category]; + if (panel) panel.showError(t('common.allSourcesDisabled')); + this.ctx.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { + status: 'ok', + itemCount: 0, + }); + return []; + } + + const flushPendingRender = () => { + if (!pendingItems) return; + this.renderNewsForCategory(category, pendingItems); + pendingItems = null; + lastRenderTime = Date.now(); + }; + + const scheduleRender = (partialItems: NewsItem[]) => { + if (!panel) return; + pendingItems = partialItems; + const elapsed = Date.now() - lastRenderTime; + if (elapsed >= renderIntervalMs) { + if (renderTimeout) { + clearTimeout(renderTimeout); + renderTimeout = null; + } + flushPendingRender(); + return; + } + + if (!renderTimeout) { + renderTimeout = setTimeout(() => { + renderTimeout = null; + flushPendingRender(); + }, renderIntervalMs - elapsed); + } + }; + + const items = await fetchCategoryFeeds(enabledFeeds, { + onBatch: (partialItems) => { + scheduleRender(partialItems); + this.flashMapForNews(partialItems); + }, + }); + + this.renderNewsForCategory(category, items); + if (panel) { + if (renderTimeout) { + clearTimeout(renderTimeout); + renderTimeout = null; + pendingItems = null; + } + + if (items.length === 0) { + const failures = getFeedFailures(); + const failedFeeds = enabledFeeds.filter(f => failures.has(f.name)); + if (failedFeeds.length > 0) { + const names = failedFeeds.map(f => f.name).join(', '); + panel.showError(`${t('common.noNewsAvailable')} (${names} failed)`); + } + } + + try { + const baseline = await updateBaseline(`news:${category}`, items.length); + const deviation = calculateDeviation(items.length, baseline); + panel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + } catch (e) { console.warn(`[Baseline] news:${category} write failed:`, e); } + } + + this.ctx.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { + status: 'ok', + itemCount: items.length, + }); + this.ctx.statusPanel?.updateApi('RSS2JSON', { status: 'ok' }); + + return items; + } catch (error) { + this.ctx.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { + status: 'error', + errorMessage: String(error), + }); + this.ctx.statusPanel?.updateApi('RSS2JSON', { status: 'error' }); + delete this.ctx.newsByCategory[category]; + return []; + } + } + + async loadNews(): Promise { + // Reset happy variant accumulator for fresh pipeline run + if (SITE_VARIANT === 'happy') { + this.ctx.happyAllItems = []; + } + + const categories = Object.entries(FEEDS) + .filter((entry): entry is [string, typeof FEEDS[keyof typeof FEEDS]] => Array.isArray(entry[1]) && entry[1].length > 0) + .map(([key, feeds]) => ({ key, feeds })); + + const maxCategoryConcurrency = SITE_VARIANT === 'tech' ? 4 : 5; + const categoryConcurrency = Math.max(1, Math.min(maxCategoryConcurrency, categories.length)); + const categoryResults: PromiseSettledResult[] = []; + for (let i = 0; i < categories.length; i += categoryConcurrency) { + const chunk = categories.slice(i, i + categoryConcurrency); + const chunkResults = await Promise.allSettled( + chunk.map(({ key, feeds }) => this.loadNewsCategory(key, feeds)) + ); + categoryResults.push(...chunkResults); + } + + const collectedNews: NewsItem[] = []; + categoryResults.forEach((result, idx) => { + if (result.status === 'fulfilled') { + const items = result.value; + // Tag items with content categories for happy variant + if (SITE_VARIANT === 'happy') { + for (const item of items) { + item.happyCategory = classifyNewsItem(item.source, item.title); + } + // Accumulate curated items for the positive news pipeline + this.ctx.happyAllItems = this.ctx.happyAllItems.concat(items); + } + collectedNews.push(...items); + } else { + console.error(`[App] News category ${categories[idx]?.key} failed:`, result.reason); + } + }); + + if (SITE_VARIANT === 'full') { + const enabledIntelSources = INTEL_SOURCES.filter(f => !this.ctx.disabledSources.has(f.name)); + const intelPanel = this.ctx.newsPanels['intel']; + if (enabledIntelSources.length === 0) { + delete this.ctx.newsByCategory['intel']; + if (intelPanel) intelPanel.showError(t('common.allIntelSourcesDisabled')); + this.ctx.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: 0 }); + } else { + const intelResult = await Promise.allSettled([fetchCategoryFeeds(enabledIntelSources)]); + if (intelResult[0]?.status === 'fulfilled') { + const intel = intelResult[0].value; + this.renderNewsForCategory('intel', intel); + if (intelPanel) { + try { + const baseline = await updateBaseline('news:intel', intel.length); + const deviation = calculateDeviation(intel.length, baseline); + intelPanel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + } catch (e) { console.warn('[Baseline] news:intel write failed:', e); } + } + this.ctx.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: intel.length }); + collectedNews.push(...intel); + this.flashMapForNews(intel); + } else { + delete this.ctx.newsByCategory['intel']; + console.error('[App] Intel feed failed:', intelResult[0]?.reason); + } + } + } + + this.ctx.allNews = collectedNews; + this.ctx.initialLoadComplete = true; + maybeShowDownloadBanner(); + mountCommunityWidget(); + updateAndCheck([ + { type: 'news', region: 'global', count: collectedNews.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + + this.ctx.map?.updateHotspotActivity(this.ctx.allNews); + + this.updateMonitorResults(); + + try { + this.ctx.latestClusters = mlWorker.isAvailable + ? await clusterNewsHybrid(this.ctx.allNews) + : await analysisWorker.clusterNews(this.ctx.allNews); + + if (this.ctx.latestClusters.length > 0) { + const insightsPanel = this.ctx.panels['insights'] as InsightsPanel | undefined; + insightsPanel?.updateInsights(this.ctx.latestClusters); + } + + const geoLocated = this.ctx.latestClusters + .filter((c): c is typeof c & { lat: number; lon: number } => c.lat != null && c.lon != null) + .map(c => ({ + lat: c.lat, + lon: c.lon, + title: c.primaryTitle, + threatLevel: c.threat?.level ?? 'info', + timestamp: c.lastUpdated, + })); + if (geoLocated.length > 0) { + this.ctx.map?.setNewsLocations(geoLocated); + } + } catch (error) { + console.error('[App] Clustering failed, clusters unchanged:', error); + } + + // Happy variant: run multi-stage positive news pipeline + map layers + if (SITE_VARIANT === 'happy') { + await this.loadHappySupplementaryAndRender(); + await Promise.allSettled([ + this.ctx.mapLayers.positiveEvents ? this.loadPositiveEvents() : Promise.resolve(), + this.ctx.mapLayers.kindness ? Promise.resolve(this.loadKindnessData()) : Promise.resolve(), + ]); + } + } + + async loadMarkets(): Promise { + try { + const stocksResult = await fetchMultipleStocks(MARKET_SYMBOLS, { + onBatch: (partialStocks) => { + this.ctx.latestMarkets = partialStocks; + (this.ctx.panels['markets'] as MarketPanel).renderMarkets(partialStocks); + }, + }); + + const finnhubConfigMsg = 'FINNHUB_API_KEY not configured — add in Settings'; + this.ctx.latestMarkets = stocksResult.data; + (this.ctx.panels['markets'] as MarketPanel).renderMarkets(stocksResult.data, stocksResult.rateLimited); + + if (stocksResult.rateLimited && stocksResult.data.length === 0) { + const rlMsg = 'Market data temporarily unavailable (rate limited) — retrying shortly'; + this.ctx.panels['heatmap']?.showError(rlMsg); + this.ctx.panels['commodities']?.showError(rlMsg); + } else if (stocksResult.skipped) { + this.ctx.statusPanel?.updateApi('Finnhub', { status: 'error' }); + if (stocksResult.data.length === 0) { + this.ctx.panels['markets']?.showConfigError(finnhubConfigMsg); + } + this.ctx.panels['heatmap']?.showConfigError(finnhubConfigMsg); + } else { + this.ctx.statusPanel?.updateApi('Finnhub', { status: 'ok' }); + + const sectorsResult = await fetchMultipleStocks( + SECTORS.map((s) => ({ ...s, display: s.name })), + { + onBatch: (partialSectors) => { + (this.ctx.panels['heatmap'] as HeatmapPanel).renderHeatmap( + partialSectors.map((s) => ({ name: s.name, change: s.change })) + ); + }, + } + ); + (this.ctx.panels['heatmap'] as HeatmapPanel).renderHeatmap( + sectorsResult.data.map((s) => ({ name: s.name, change: s.change })) + ); + } + + const commoditiesPanel = this.ctx.panels['commodities'] as CommoditiesPanel; + const mapCommodity = (c: MarketData) => ({ display: c.display, price: c.price, change: c.change, sparkline: c.sparkline }); + + let commoditiesLoaded = stocksResult.rateLimited && stocksResult.data.length === 0; + for (let attempt = 0; attempt < 3 && !commoditiesLoaded; attempt++) { + if (attempt > 0) { + commoditiesPanel.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + } + const commoditiesResult = await fetchMultipleStocks(COMMODITIES, { + onBatch: (partial) => commoditiesPanel.renderCommodities(partial.map(mapCommodity)), + }); + const mapped = commoditiesResult.data.map(mapCommodity); + if (mapped.some(d => d.price !== null)) { + commoditiesPanel.renderCommodities(mapped); + commoditiesLoaded = true; + } + } + if (!commoditiesLoaded) { + commoditiesPanel.renderCommodities([]); + } + } catch { + this.ctx.statusPanel?.updateApi('Finnhub', { status: 'error' }); + } + + try { + let crypto = await fetchCrypto(); + if (crypto.length === 0) { + (this.ctx.panels['crypto'] as CryptoPanel).showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + crypto = await fetchCrypto(); + } + (this.ctx.panels['crypto'] as CryptoPanel).renderCrypto(crypto); + this.ctx.statusPanel?.updateApi('CoinGecko', { status: crypto.length > 0 ? 'ok' : 'error' }); + } catch { + this.ctx.statusPanel?.updateApi('CoinGecko', { status: 'error' }); + } + } + + async loadPredictions(): Promise { + try { + const predictions = await fetchPredictions(); + this.ctx.latestPredictions = predictions; + (this.ctx.panels['polymarket'] as PredictionPanel).renderPredictions(predictions); + + this.ctx.statusPanel?.updateFeed('Polymarket', { status: 'ok', itemCount: predictions.length }); + this.ctx.statusPanel?.updateApi('Polymarket', { status: 'ok' }); + dataFreshness.recordUpdate('polymarket', predictions.length); + dataFreshness.recordUpdate('predictions', predictions.length); + + void this.runCorrelationAnalysis(); + } catch (error) { + this.ctx.statusPanel?.updateFeed('Polymarket', { status: 'error', errorMessage: String(error) }); + this.ctx.statusPanel?.updateApi('Polymarket', { status: 'error' }); + dataFreshness.recordError('polymarket', String(error)); + dataFreshness.recordError('predictions', String(error)); + } + } + + async loadNatural(): Promise { + const [earthquakeResult, eonetResult] = await Promise.allSettled([ + fetchEarthquakes(), + fetchNaturalEvents(30), + ]); + + if (earthquakeResult.status === 'fulfilled') { + this.ctx.intelligenceCache.earthquakes = earthquakeResult.value; + this.ctx.map?.setEarthquakes(earthquakeResult.value); + ingestEarthquakes(earthquakeResult.value); + this.ctx.statusPanel?.updateApi('USGS', { status: 'ok' }); + dataFreshness.recordUpdate('usgs', earthquakeResult.value.length); + } else { + this.ctx.intelligenceCache.earthquakes = []; + this.ctx.map?.setEarthquakes([]); + this.ctx.statusPanel?.updateApi('USGS', { status: 'error' }); + dataFreshness.recordError('usgs', String(earthquakeResult.reason)); + } + + if (eonetResult.status === 'fulfilled') { + this.ctx.map?.setNaturalEvents(eonetResult.value); + this.ctx.statusPanel?.updateFeed('EONET', { + status: 'ok', + itemCount: eonetResult.value.length, + }); + this.ctx.statusPanel?.updateApi('NASA EONET', { status: 'ok' }); + } else { + this.ctx.map?.setNaturalEvents([]); + this.ctx.statusPanel?.updateFeed('EONET', { status: 'error', errorMessage: String(eonetResult.reason) }); + this.ctx.statusPanel?.updateApi('NASA EONET', { status: 'error' }); + } + + const hasEarthquakes = earthquakeResult.status === 'fulfilled' && earthquakeResult.value.length > 0; + const hasEonet = eonetResult.status === 'fulfilled' && eonetResult.value.length > 0; + this.ctx.map?.setLayerReady('natural', hasEarthquakes || hasEonet); + } + + async loadTechEvents(): Promise { + console.log('[loadTechEvents] Called. SITE_VARIANT:', SITE_VARIANT, 'techEvents layer:', this.ctx.mapLayers.techEvents); + if (SITE_VARIANT !== 'tech' && !this.ctx.mapLayers.techEvents) { + console.log('[loadTechEvents] Skipping - not tech variant and layer disabled'); + return; + } + + try { + const client = new ResearchServiceClient('', { fetch: (...args: Parameters) => globalThis.fetch(...args) }); + const data = await client.listTechEvents({ + type: 'conference', + mappable: true, + days: 90, + limit: 50, + }); + if (!data.success) throw new Error(data.error || 'Unknown error'); + + const now = new Date(); + const mapEvents = data.events.map((e: any) => ({ + id: e.id, + title: e.title, + location: e.location, + lat: e.coords?.lat ?? 0, + lng: e.coords?.lng ?? 0, + country: e.coords?.country ?? '', + startDate: e.startDate, + endDate: e.endDate, + url: e.url, + daysUntil: Math.ceil((new Date(e.startDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), + })); + + this.ctx.map?.setTechEvents(mapEvents); + this.ctx.map?.setLayerReady('techEvents', mapEvents.length > 0); + this.ctx.statusPanel?.updateFeed('Tech Events', { status: 'ok', itemCount: mapEvents.length }); + + if (SITE_VARIANT === 'tech' && this.ctx.searchModal) { + this.ctx.searchModal.registerSource('techevent', mapEvents.map((e: { id: string; title: string; location: string; startDate: string }) => ({ + id: e.id, + title: e.title, + subtitle: `${e.location} • ${new Date(e.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`, + data: e, + }))); + } + } catch (error) { + console.error('[App] Failed to load tech events:', error); + this.ctx.map?.setTechEvents([]); + this.ctx.map?.setLayerReady('techEvents', false); + this.ctx.statusPanel?.updateFeed('Tech Events', { status: 'error', errorMessage: String(error) }); + } + } + + async loadWeatherAlerts(): Promise { + try { + const alerts = await fetchWeatherAlerts(); + this.ctx.map?.setWeatherAlerts(alerts); + this.ctx.map?.setLayerReady('weather', alerts.length > 0); + this.ctx.statusPanel?.updateFeed('Weather', { status: 'ok', itemCount: alerts.length }); + dataFreshness.recordUpdate('weather', alerts.length); + } catch (error) { + this.ctx.map?.setLayerReady('weather', false); + this.ctx.statusPanel?.updateFeed('Weather', { status: 'error' }); + dataFreshness.recordError('weather', String(error)); + } + } + + async loadIntelligenceSignals(): Promise { + const tasks: Promise[] = []; + + tasks.push((async () => { + try { + const outages = await fetchInternetOutages(); + this.ctx.intelligenceCache.outages = outages; + ingestOutagesForCII(outages); + signalAggregator.ingestOutages(outages); + dataFreshness.recordUpdate('outages', outages.length); + if (this.ctx.mapLayers.outages) { + this.ctx.map?.setOutages(outages); + this.ctx.map?.setLayerReady('outages', outages.length > 0); + this.ctx.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); + } + } catch (error) { + console.error('[Intelligence] Outages fetch failed:', error); + dataFreshness.recordError('outages', String(error)); + } + })()); + + const protestsTask = (async (): Promise => { + try { + const protestData = await fetchProtestEvents(); + this.ctx.intelligenceCache.protests = protestData; + ingestProtests(protestData.events); + ingestProtestsForCII(protestData.events); + signalAggregator.ingestProtests(protestData.events); + const protestCount = protestData.sources.acled + protestData.sources.gdelt; + if (protestCount > 0) dataFreshness.recordUpdate('acled', protestCount); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt_doc', protestData.sources.gdelt); + if (this.ctx.mapLayers.protests) { + this.ctx.map?.setProtests(protestData.events); + this.ctx.map?.setLayerReady('protests', protestData.events.length > 0); + const status = getProtestStatus(); + this.ctx.statusPanel?.updateFeed('Protests', { + status: 'ok', + itemCount: protestData.events.length, + errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined, + }); + } + return protestData.events; + } catch (error) { + console.error('[Intelligence] Protests fetch failed:', error); + dataFreshness.recordError('acled', String(error)); + return []; + } + })(); + tasks.push(protestsTask.then(() => undefined)); + + tasks.push((async () => { + try { + const conflictData = await fetchConflictEvents(); + ingestConflictsForCII(conflictData.events); + if (conflictData.count > 0) dataFreshness.recordUpdate('acled_conflict', conflictData.count); + } catch (error) { + console.error('[Intelligence] Conflict events fetch failed:', error); + dataFreshness.recordError('acled_conflict', String(error)); + } + })()); + + tasks.push((async () => { + try { + const classifications = await fetchUcdpClassifications(); + ingestUcdpForCII(classifications); + if (classifications.size > 0) dataFreshness.recordUpdate('ucdp', classifications.size); + } catch (error) { + console.error('[Intelligence] UCDP fetch failed:', error); + dataFreshness.recordError('ucdp', String(error)); + } + })()); + + tasks.push((async () => { + try { + const summaries = await fetchHapiSummary(); + ingestHapiForCII(summaries); + if (summaries.size > 0) dataFreshness.recordUpdate('hapi', summaries.size); + } catch (error) { + console.error('[Intelligence] HAPI fetch failed:', error); + dataFreshness.recordError('hapi', String(error)); + } + })()); + + tasks.push((async () => { + try { + if (isMilitaryVesselTrackingConfigured()) { + initMilitaryVesselStream(); + } + const [flightData, vesselData] = await Promise.all([ + fetchMilitaryFlights(), + fetchMilitaryVessels(), + ]); + this.ctx.intelligenceCache.military = { + flights: flightData.flights, + flightClusters: flightData.clusters, + vessels: vesselData.vessels, + vesselClusters: vesselData.clusters, + }; + fetchUSNIFleetReport().then((report) => { + if (report) this.ctx.intelligenceCache.usniFleet = report; + }).catch(() => {}); + ingestFlights(flightData.flights); + ingestVessels(vesselData.vessels); + ingestMilitaryForCII(flightData.flights, vesselData.vessels); + signalAggregator.ingestFlights(flightData.flights); + signalAggregator.ingestVessels(vesselData.vessels); + dataFreshness.recordUpdate('opensky', flightData.flights.length); + updateAndCheck([ + { type: 'military_flights', region: 'global', count: flightData.flights.length }, + { type: 'vessels', region: 'global', count: vesselData.vessels.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + if (this.ctx.mapLayers.military) { + this.ctx.map?.setMilitaryFlights(flightData.flights, flightData.clusters); + this.ctx.map?.setMilitaryVessels(vesselData.vessels, vesselData.clusters); + this.ctx.map?.updateMilitaryForEscalation(flightData.flights, vesselData.vessels); + const militaryCount = flightData.flights.length + vesselData.vessels.length; + this.ctx.statusPanel?.updateFeed('Military', { + status: militaryCount > 0 ? 'ok' : 'warning', + itemCount: militaryCount, + }); + } + if (!isInLearningMode()) { + const surgeAlerts = analyzeFlightsForSurge(flightData.flights); + if (surgeAlerts.length > 0) { + const surgeSignals = surgeAlerts.map(surgeAlertToSignal); + addToSignalHistory(surgeSignals); + if (this.shouldShowIntelligenceNotifications()) this.ctx.signalModal?.show(surgeSignals); + } + const foreignAlerts = detectForeignMilitaryPresence(flightData.flights); + if (foreignAlerts.length > 0) { + const foreignSignals = foreignAlerts.map(foreignPresenceToSignal); + addToSignalHistory(foreignSignals); + if (this.shouldShowIntelligenceNotifications()) this.ctx.signalModal?.show(foreignSignals); + } + } + } catch (error) { + console.error('[Intelligence] Military fetch failed:', error); + dataFreshness.recordError('opensky', String(error)); + } + })()); + + tasks.push((async () => { + try { + const protestEvents = await protestsTask; + let result = await fetchUcdpEvents(); + for (let attempt = 1; attempt < 3 && !result.success; attempt++) { + await new Promise(r => setTimeout(r, 15_000)); + result = await fetchUcdpEvents(); + } + if (!result.success) { + dataFreshness.recordError('ucdp_events', 'UCDP events unavailable (retaining prior event state)'); + return; + } + const acledEvents = protestEvents.map(e => ({ + latitude: e.lat, longitude: e.lon, event_date: e.time.toISOString(), fatalities: e.fatalities ?? 0, + })); + const events = deduplicateAgainstAcled(result.data, acledEvents); + (this.ctx.panels['ucdp-events'] as UcdpEventsPanel)?.setEvents(events); + if (this.ctx.mapLayers.ucdpEvents) { + this.ctx.map?.setUcdpEvents(events); + } + if (events.length > 0) dataFreshness.recordUpdate('ucdp_events', events.length); + } catch (error) { + console.error('[Intelligence] UCDP events fetch failed:', error); + dataFreshness.recordError('ucdp_events', String(error)); + } + })()); + + tasks.push((async () => { + try { + const unhcrResult = await fetchUnhcrPopulation(); + if (!unhcrResult.ok) { + dataFreshness.recordError('unhcr', 'UNHCR displacement unavailable (retaining prior displacement state)'); + return; + } + const data = unhcrResult.data; + (this.ctx.panels['displacement'] as DisplacementPanel)?.setData(data); + ingestDisplacementForCII(data.countries); + if (this.ctx.mapLayers.displacement && data.topFlows) { + this.ctx.map?.setDisplacementFlows(data.topFlows); + } + if (data.countries.length > 0) dataFreshness.recordUpdate('unhcr', data.countries.length); + } catch (error) { + console.error('[Intelligence] UNHCR displacement fetch failed:', error); + dataFreshness.recordError('unhcr', String(error)); + } + })()); + + tasks.push((async () => { + try { + const climateResult = await fetchClimateAnomalies(); + if (!climateResult.ok) { + dataFreshness.recordError('climate', 'Climate anomalies unavailable (retaining prior climate state)'); + return; + } + const anomalies = climateResult.anomalies; + (this.ctx.panels['climate'] as ClimateAnomalyPanel)?.setAnomalies(anomalies); + ingestClimateForCII(anomalies); + if (this.ctx.mapLayers.climate) { + this.ctx.map?.setClimateAnomalies(anomalies); + } + if (anomalies.length > 0) dataFreshness.recordUpdate('climate', anomalies.length); + } catch (error) { + console.error('[Intelligence] Climate anomalies fetch failed:', error); + dataFreshness.recordError('climate', String(error)); + } + })()); + + await Promise.allSettled(tasks); + + try { + const ucdpEvts = (this.ctx.panels['ucdp-events'] as UcdpEventsPanel)?.getEvents?.() || []; + const events = [ + ...(this.ctx.intelligenceCache.protests?.events || []).slice(0, 10).map(e => ({ + id: e.id, lat: e.lat, lon: e.lon, type: 'conflict' as const, name: e.title || 'Protest', + })), + ...ucdpEvts.slice(0, 10).map(e => ({ + id: e.id, lat: e.latitude, lon: e.longitude, type: e.type_of_violence as string, name: `${e.side_a} vs ${e.side_b}`, + })), + ]; + if (events.length > 0) { + const exposures = await enrichEventsWithExposure(events); + (this.ctx.panels['population-exposure'] as PopulationExposurePanel)?.setExposures(exposures); + if (exposures.length > 0) dataFreshness.recordUpdate('worldpop', exposures.length); + } else { + (this.ctx.panels['population-exposure'] as PopulationExposurePanel)?.setExposures([]); + } + } catch (error) { + console.error('[Intelligence] Population exposure fetch failed:', error); + dataFreshness.recordError('worldpop', String(error)); + } + + (this.ctx.panels['cii'] as CIIPanel)?.refresh(); + console.log('[Intelligence] All signals loaded for CII calculation'); + } + + async loadOutages(): Promise { + if (this.ctx.intelligenceCache.outages) { + const outages = this.ctx.intelligenceCache.outages; + this.ctx.map?.setOutages(outages); + this.ctx.map?.setLayerReady('outages', outages.length > 0); + this.ctx.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); + return; + } + try { + const outages = await fetchInternetOutages(); + this.ctx.intelligenceCache.outages = outages; + this.ctx.map?.setOutages(outages); + this.ctx.map?.setLayerReady('outages', outages.length > 0); + ingestOutagesForCII(outages); + signalAggregator.ingestOutages(outages); + this.ctx.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); + dataFreshness.recordUpdate('outages', outages.length); + } catch (error) { + this.ctx.map?.setLayerReady('outages', false); + this.ctx.statusPanel?.updateFeed('NetBlocks', { status: 'error' }); + dataFreshness.recordError('outages', String(error)); + } + } + + async loadCyberThreats(): Promise { + if (!CYBER_LAYER_ENABLED) { + this.ctx.mapLayers.cyberThreats = false; + this.ctx.map?.setLayerReady('cyberThreats', false); + return; + } + + if (this.ctx.cyberThreatsCache) { + this.ctx.map?.setCyberThreats(this.ctx.cyberThreatsCache); + this.ctx.map?.setLayerReady('cyberThreats', this.ctx.cyberThreatsCache.length > 0); + this.ctx.statusPanel?.updateFeed('Cyber Threats', { status: 'ok', itemCount: this.ctx.cyberThreatsCache.length }); + return; + } + + try { + const threats = await fetchCyberThreats({ limit: 500, days: 14 }); + this.ctx.cyberThreatsCache = threats; + this.ctx.map?.setCyberThreats(threats); + this.ctx.map?.setLayerReady('cyberThreats', threats.length > 0); + this.ctx.statusPanel?.updateFeed('Cyber Threats', { status: 'ok', itemCount: threats.length }); + this.ctx.statusPanel?.updateApi('Cyber Threats API', { status: 'ok' }); + dataFreshness.recordUpdate('cyber_threats', threats.length); + } catch (error) { + this.ctx.map?.setLayerReady('cyberThreats', false); + this.ctx.statusPanel?.updateFeed('Cyber Threats', { status: 'error', errorMessage: String(error) }); + this.ctx.statusPanel?.updateApi('Cyber Threats API', { status: 'error' }); + dataFreshness.recordError('cyber_threats', String(error)); + } + } + + async loadAisSignals(): Promise { + try { + const { disruptions, density } = await fetchAisSignals(); + const aisStatus = getAisStatus(); + console.log('[Ships] Events:', { disruptions: disruptions.length, density: density.length, vessels: aisStatus.vessels }); + this.ctx.map?.setAisData(disruptions, density); + signalAggregator.ingestAisDisruptions(disruptions); + updateAndCheck([ + { type: 'ais_gaps', region: 'global', count: disruptions.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + + const hasData = disruptions.length > 0 || density.length > 0; + this.ctx.map?.setLayerReady('ais', hasData); + + const shippingCount = disruptions.length + density.length; + const shippingStatus = shippingCount > 0 ? 'ok' : (aisStatus.connected ? 'warning' : 'error'); + this.ctx.statusPanel?.updateFeed('Shipping', { + status: shippingStatus, + itemCount: shippingCount, + errorMessage: !aisStatus.connected && shippingCount === 0 ? 'AIS snapshot unavailable' : undefined, + }); + this.ctx.statusPanel?.updateApi('AISStream', { + status: aisStatus.connected ? 'ok' : 'warning', + }); + if (hasData) { + dataFreshness.recordUpdate('ais', shippingCount); + } + } catch (error) { + this.ctx.map?.setLayerReady('ais', false); + this.ctx.statusPanel?.updateFeed('Shipping', { status: 'error', errorMessage: String(error) }); + this.ctx.statusPanel?.updateApi('AISStream', { status: 'error' }); + dataFreshness.recordError('ais', String(error)); + } + } + + waitForAisData(): void { + const maxAttempts = 30; + let attempts = 0; + + const checkData = () => { + if (this.ctx.isDestroyed) return; + attempts++; + const status = getAisStatus(); + + if (status.vessels > 0 || status.connected) { + this.loadAisSignals(); + this.ctx.map?.setLayerLoading('ais', false); + return; + } + + if (attempts >= maxAttempts) { + this.ctx.map?.setLayerLoading('ais', false); + this.ctx.map?.setLayerReady('ais', false); + this.ctx.statusPanel?.updateFeed('Shipping', { + status: 'error', + errorMessage: 'Connection timeout' + }); + return; + } + + setTimeout(checkData, 1000); + }; + + checkData(); + } + + async loadCableActivity(): Promise { + try { + const activity = await fetchCableActivity(); + this.ctx.map?.setCableActivity(activity.advisories, activity.repairShips); + const itemCount = activity.advisories.length + activity.repairShips.length; + this.ctx.statusPanel?.updateFeed('CableOps', { status: 'ok', itemCount }); + } catch { + this.ctx.statusPanel?.updateFeed('CableOps', { status: 'error' }); + } + } + + async loadCableHealth(): Promise { + try { + const healthData = await fetchCableHealth(); + this.ctx.map?.setCableHealth(healthData.cables); + const cableIds = Object.keys(healthData.cables); + const faultCount = cableIds.filter((id) => healthData.cables[id]?.status === 'fault').length; + const degradedCount = cableIds.filter((id) => healthData.cables[id]?.status === 'degraded').length; + this.ctx.statusPanel?.updateFeed('CableHealth', { status: 'ok', itemCount: faultCount + degradedCount }); + } catch { + this.ctx.statusPanel?.updateFeed('CableHealth', { status: 'error' }); + } + } + + async loadProtests(): Promise { + if (this.ctx.intelligenceCache.protests) { + const protestData = this.ctx.intelligenceCache.protests; + this.ctx.map?.setProtests(protestData.events); + this.ctx.map?.setLayerReady('protests', protestData.events.length > 0); + const status = getProtestStatus(); + this.ctx.statusPanel?.updateFeed('Protests', { + status: 'ok', + itemCount: protestData.events.length, + errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined, + }); + if (status.acledConfigured === true) { + this.ctx.statusPanel?.updateApi('ACLED', { status: 'ok' }); + } else if (status.acledConfigured === null) { + this.ctx.statusPanel?.updateApi('ACLED', { status: 'warning' }); + } + this.ctx.statusPanel?.updateApi('GDELT Doc', { status: 'ok' }); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt_doc', protestData.sources.gdelt); + return; + } + try { + const protestData = await fetchProtestEvents(); + this.ctx.intelligenceCache.protests = protestData; + this.ctx.map?.setProtests(protestData.events); + this.ctx.map?.setLayerReady('protests', protestData.events.length > 0); + ingestProtests(protestData.events); + ingestProtestsForCII(protestData.events); + signalAggregator.ingestProtests(protestData.events); + const protestCount = protestData.sources.acled + protestData.sources.gdelt; + if (protestCount > 0) dataFreshness.recordUpdate('acled', protestCount); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt_doc', protestData.sources.gdelt); + (this.ctx.panels['cii'] as CIIPanel)?.refresh(); + const status = getProtestStatus(); + this.ctx.statusPanel?.updateFeed('Protests', { + status: 'ok', + itemCount: protestData.events.length, + errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined, + }); + if (status.acledConfigured === true) { + this.ctx.statusPanel?.updateApi('ACLED', { status: 'ok' }); + } else if (status.acledConfigured === null) { + this.ctx.statusPanel?.updateApi('ACLED', { status: 'warning' }); + } + this.ctx.statusPanel?.updateApi('GDELT Doc', { status: 'ok' }); + } catch (error) { + this.ctx.map?.setLayerReady('protests', false); + this.ctx.statusPanel?.updateFeed('Protests', { status: 'error', errorMessage: String(error) }); + this.ctx.statusPanel?.updateApi('ACLED', { status: 'error' }); + this.ctx.statusPanel?.updateApi('GDELT Doc', { status: 'error' }); + dataFreshness.recordError('gdelt_doc', String(error)); + } + } + + async loadFlightDelays(): Promise { + try { + const delays = await fetchFlightDelays(); + this.ctx.map?.setFlightDelays(delays); + this.ctx.map?.setLayerReady('flights', delays.length > 0); + this.ctx.statusPanel?.updateFeed('Flights', { + status: 'ok', + itemCount: delays.length, + }); + this.ctx.statusPanel?.updateApi('FAA', { status: 'ok' }); + } catch (error) { + this.ctx.map?.setLayerReady('flights', false); + this.ctx.statusPanel?.updateFeed('Flights', { status: 'error', errorMessage: String(error) }); + this.ctx.statusPanel?.updateApi('FAA', { status: 'error' }); + } + } + + async loadMilitary(): Promise { + if (this.ctx.intelligenceCache.military) { + const { flights, flightClusters, vessels, vesselClusters } = this.ctx.intelligenceCache.military; + this.ctx.map?.setMilitaryFlights(flights, flightClusters); + this.ctx.map?.setMilitaryVessels(vessels, vesselClusters); + this.ctx.map?.updateMilitaryForEscalation(flights, vessels); + this.loadCachedPosturesForBanner(); + const insightsPanel = this.ctx.panels['insights'] as InsightsPanel | undefined; + insightsPanel?.setMilitaryFlights(flights); + const hasData = flights.length > 0 || vessels.length > 0; + this.ctx.map?.setLayerReady('military', hasData); + const militaryCount = flights.length + vessels.length; + this.ctx.statusPanel?.updateFeed('Military', { + status: militaryCount > 0 ? 'ok' : 'warning', + itemCount: militaryCount, + errorMessage: militaryCount === 0 ? 'No military activity in view' : undefined, + }); + this.ctx.statusPanel?.updateApi('OpenSky', { status: 'ok' }); + return; + } + try { + if (isMilitaryVesselTrackingConfigured()) { + initMilitaryVesselStream(); + } + const [flightData, vesselData] = await Promise.all([ + fetchMilitaryFlights(), + fetchMilitaryVessels(), + ]); + this.ctx.intelligenceCache.military = { + flights: flightData.flights, + flightClusters: flightData.clusters, + vessels: vesselData.vessels, + vesselClusters: vesselData.clusters, + }; + fetchUSNIFleetReport().then((report) => { + if (report) this.ctx.intelligenceCache.usniFleet = report; + }).catch(() => {}); + this.ctx.map?.setMilitaryFlights(flightData.flights, flightData.clusters); + this.ctx.map?.setMilitaryVessels(vesselData.vessels, vesselData.clusters); + ingestFlights(flightData.flights); + ingestVessels(vesselData.vessels); + ingestMilitaryForCII(flightData.flights, vesselData.vessels); + signalAggregator.ingestFlights(flightData.flights); + signalAggregator.ingestVessels(vesselData.vessels); + updateAndCheck([ + { type: 'military_flights', region: 'global', count: flightData.flights.length }, + { type: 'vessels', region: 'global', count: vesselData.vessels.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + this.ctx.map?.updateMilitaryForEscalation(flightData.flights, vesselData.vessels); + (this.ctx.panels['cii'] as CIIPanel)?.refresh(); + if (!isInLearningMode()) { + const surgeAlerts = analyzeFlightsForSurge(flightData.flights); + if (surgeAlerts.length > 0) { + const surgeSignals = surgeAlerts.map(surgeAlertToSignal); + addToSignalHistory(surgeSignals); + if (this.shouldShowIntelligenceNotifications()) this.ctx.signalModal?.show(surgeSignals); + } + const foreignAlerts = detectForeignMilitaryPresence(flightData.flights); + if (foreignAlerts.length > 0) { + const foreignSignals = foreignAlerts.map(foreignPresenceToSignal); + addToSignalHistory(foreignSignals); + if (this.shouldShowIntelligenceNotifications()) this.ctx.signalModal?.show(foreignSignals); + } + } + + this.loadCachedPosturesForBanner(); + const insightsPanel = this.ctx.panels['insights'] as InsightsPanel | undefined; + insightsPanel?.setMilitaryFlights(flightData.flights); + + const hasData = flightData.flights.length > 0 || vesselData.vessels.length > 0; + this.ctx.map?.setLayerReady('military', hasData); + const militaryCount = flightData.flights.length + vesselData.vessels.length; + this.ctx.statusPanel?.updateFeed('Military', { + status: militaryCount > 0 ? 'ok' : 'warning', + itemCount: militaryCount, + errorMessage: militaryCount === 0 ? 'No military activity in view' : undefined, + }); + this.ctx.statusPanel?.updateApi('OpenSky', { status: 'ok' }); + dataFreshness.recordUpdate('opensky', flightData.flights.length); + } catch (error) { + this.ctx.map?.setLayerReady('military', false); + this.ctx.statusPanel?.updateFeed('Military', { status: 'error', errorMessage: String(error) }); + this.ctx.statusPanel?.updateApi('OpenSky', { status: 'error' }); + dataFreshness.recordError('opensky', String(error)); + } + } + + private async loadCachedPosturesForBanner(): Promise { + try { + const data = await fetchCachedTheaterPosture(); + if (data && data.postures.length > 0) { + this.callbacks.renderCriticalBanner(data.postures); + const posturePanel = this.ctx.panels['strategic-posture'] as StrategicPosturePanel | undefined; + posturePanel?.updatePostures(data); + } + } catch (error) { + console.warn('[App] Failed to load cached postures for banner:', error); + } + } + + async loadFredData(): Promise { + const economicPanel = this.ctx.panels['economic'] as EconomicPanel; + const cbInfo = getCircuitBreakerCooldownInfo('FRED Economic'); + if (cbInfo.onCooldown) { + economicPanel?.setErrorState(true, `Temporarily unavailable (retry in ${cbInfo.remainingSeconds}s)`); + this.ctx.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + + try { + economicPanel?.setLoading(true); + const data = await fetchFredData(); + + const postInfo = getCircuitBreakerCooldownInfo('FRED Economic'); + if (postInfo.onCooldown) { + economicPanel?.setErrorState(true, `Temporarily unavailable (retry in ${postInfo.remainingSeconds}s)`); + this.ctx.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + + if (data.length === 0) { + if (!isFeatureAvailable('economicFred')) { + economicPanel?.setErrorState(true, 'FRED_API_KEY not configured — add in Settings'); + this.ctx.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + economicPanel?.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + const retryData = await fetchFredData(); + if (retryData.length === 0) { + economicPanel?.setErrorState(true, 'FRED data temporarily unavailable — will retry'); + this.ctx.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + economicPanel?.setErrorState(false); + economicPanel?.update(retryData); + this.ctx.statusPanel?.updateApi('FRED', { status: 'ok' }); + dataFreshness.recordUpdate('economic', retryData.length); + return; + } + + economicPanel?.setErrorState(false); + economicPanel?.update(data); + this.ctx.statusPanel?.updateApi('FRED', { status: 'ok' }); + dataFreshness.recordUpdate('economic', data.length); + } catch { + if (isFeatureAvailable('economicFred')) { + economicPanel?.showRetrying(); + try { + await new Promise(r => setTimeout(r, 20_000)); + const retryData = await fetchFredData(); + if (retryData.length > 0) { + economicPanel?.setErrorState(false); + economicPanel?.update(retryData); + this.ctx.statusPanel?.updateApi('FRED', { status: 'ok' }); + dataFreshness.recordUpdate('economic', retryData.length); + return; + } + } catch { /* fall through */ } + } + this.ctx.statusPanel?.updateApi('FRED', { status: 'error' }); + economicPanel?.setErrorState(true, 'FRED data temporarily unavailable — will retry'); + economicPanel?.setLoading(false); + } + } + + async loadOilAnalytics(): Promise { + const economicPanel = this.ctx.panels['economic'] as EconomicPanel; + try { + const data = await fetchOilAnalytics(); + economicPanel?.updateOil(data); + const hasData = !!(data.wtiPrice || data.brentPrice || data.usProduction || data.usInventory); + this.ctx.statusPanel?.updateApi('EIA', { status: hasData ? 'ok' : 'error' }); + if (hasData) { + const metricCount = [data.wtiPrice, data.brentPrice, data.usProduction, data.usInventory].filter(Boolean).length; + dataFreshness.recordUpdate('oil', metricCount || 1); + } else { + dataFreshness.recordError('oil', 'Oil analytics returned no values'); + } + } catch (e) { + console.error('[App] Oil analytics failed:', e); + this.ctx.statusPanel?.updateApi('EIA', { status: 'error' }); + dataFreshness.recordError('oil', String(e)); + } + } + + async loadGovernmentSpending(): Promise { + const economicPanel = this.ctx.panels['economic'] as EconomicPanel; + try { + const data = await fetchRecentAwards({ daysBack: 7, limit: 15 }); + economicPanel?.updateSpending(data); + this.ctx.statusPanel?.updateApi('USASpending', { status: data.awards.length > 0 ? 'ok' : 'error' }); + if (data.awards.length > 0) { + dataFreshness.recordUpdate('spending', data.awards.length); + } else { + dataFreshness.recordError('spending', 'No awards returned'); + } + } catch (e) { + console.error('[App] Government spending failed:', e); + this.ctx.statusPanel?.updateApi('USASpending', { status: 'error' }); + dataFreshness.recordError('spending', String(e)); + } + } + + async loadBisData(): Promise { + const economicPanel = this.ctx.panels['economic'] as EconomicPanel; + try { + const data = await fetchBisData(); + economicPanel?.updateBis(data); + const hasData = data.policyRates.length > 0; + this.ctx.statusPanel?.updateApi('BIS', { status: hasData ? 'ok' : 'error' }); + if (hasData) { + dataFreshness.recordUpdate('bis', data.policyRates.length); + } + } catch (e) { + console.error('[App] BIS data failed:', e); + this.ctx.statusPanel?.updateApi('BIS', { status: 'error' }); + dataFreshness.recordError('bis', String(e)); + } + } + + async loadTradePolicy(): Promise { + const tradePanel = this.ctx.panels['trade-policy'] as TradePolicyPanel | undefined; + if (!tradePanel) return; + + try { + const [restrictions, tariffs, flows, barriers] = await Promise.all([ + fetchTradeRestrictions([], 50), + fetchTariffTrends('840', '156', '', 10), + fetchTradeFlows('840', '156', 10), + fetchTradeBarriers([], '', 50), + ]); + + tradePanel.updateRestrictions(restrictions); + tradePanel.updateTariffs(tariffs); + tradePanel.updateFlows(flows); + tradePanel.updateBarriers(barriers); + + const totalItems = restrictions.restrictions.length + tariffs.datapoints.length + flows.flows.length + barriers.barriers.length; + const anyUnavailable = restrictions.upstreamUnavailable || tariffs.upstreamUnavailable || flows.upstreamUnavailable || barriers.upstreamUnavailable; + + this.ctx.statusPanel?.updateApi('WTO', { status: anyUnavailable ? 'warning' : totalItems > 0 ? 'ok' : 'error' }); + + if (totalItems > 0) { + dataFreshness.recordUpdate('wto_trade', totalItems); + } else if (anyUnavailable) { + dataFreshness.recordError('wto_trade', 'WTO upstream temporarily unavailable'); + } + } catch (e) { + console.error('[App] Trade policy failed:', e); + this.ctx.statusPanel?.updateApi('WTO', { status: 'error' }); + dataFreshness.recordError('wto_trade', String(e)); + } + } + + async loadSupplyChain(): Promise { + const scPanel = this.ctx.panels['supply-chain'] as SupplyChainPanel | undefined; + if (!scPanel) return; + + try { + const [shipping, chokepoints, minerals] = await Promise.allSettled([ + fetchShippingRates(), + fetchChokepointStatus(), + fetchCriticalMinerals(), + ]); + + const shippingData = shipping.status === 'fulfilled' ? shipping.value : null; + const chokepointData = chokepoints.status === 'fulfilled' ? chokepoints.value : null; + const mineralsData = minerals.status === 'fulfilled' ? minerals.value : null; + + if (shippingData) scPanel.updateShippingRates(shippingData); + if (chokepointData) scPanel.updateChokepointStatus(chokepointData); + if (mineralsData) scPanel.updateCriticalMinerals(mineralsData); + + const totalItems = (shippingData?.indices.length || 0) + (chokepointData?.chokepoints.length || 0) + (mineralsData?.minerals.length || 0); + const anyUnavailable = shippingData?.upstreamUnavailable || chokepointData?.upstreamUnavailable || mineralsData?.upstreamUnavailable; + + this.ctx.statusPanel?.updateApi('SupplyChain', { status: anyUnavailable ? 'warning' : totalItems > 0 ? 'ok' : 'error' }); + + if (totalItems > 0) { + dataFreshness.recordUpdate('supply_chain', totalItems); + } else if (anyUnavailable) { + dataFreshness.recordError('supply_chain', 'Supply chain upstream temporarily unavailable'); + } + } catch (e) { + console.error('[App] Supply chain failed:', e); + this.ctx.statusPanel?.updateApi('SupplyChain', { status: 'error' }); + dataFreshness.recordError('supply_chain', String(e)); + } + } + + updateMonitorResults(): void { + const monitorPanel = this.ctx.panels['monitors'] as MonitorPanel; + monitorPanel.renderResults(this.ctx.allNews); + } + + async runCorrelationAnalysis(): Promise { + try { + if (this.ctx.latestClusters.length === 0 && this.ctx.allNews.length > 0) { + this.ctx.latestClusters = mlWorker.isAvailable + ? await clusterNewsHybrid(this.ctx.allNews) + : await analysisWorker.clusterNews(this.ctx.allNews); + } + + if (this.ctx.latestClusters.length > 0) { + ingestNewsForCII(this.ctx.latestClusters); + dataFreshness.recordUpdate('gdelt', this.ctx.latestClusters.length); + (this.ctx.panels['cii'] as CIIPanel)?.refresh(); + } + + const signals = await analysisWorker.analyzeCorrelations( + this.ctx.latestClusters, + this.ctx.latestPredictions, + this.ctx.latestMarkets + ); + + let geoSignals: ReturnType[] = []; + if (!isInLearningMode()) { + const geoAlerts = detectGeoConvergence(this.ctx.seenGeoAlerts); + geoSignals = geoAlerts.map(geoConvergenceToSignal); + } + + const keywordSpikeSignals = drainTrendingSignals(); + const allSignals = [...signals, ...geoSignals, ...keywordSpikeSignals]; + if (allSignals.length > 0) { + addToSignalHistory(allSignals); + if (this.shouldShowIntelligenceNotifications()) this.ctx.signalModal?.show(allSignals); + } + } catch (error) { + console.error('[App] Correlation analysis failed:', error); + } + } + + async loadFirmsData(): Promise { + try { + const fireResult = await fetchAllFires(1); + if (fireResult.skipped) { + this.ctx.panels['satellite-fires']?.showConfigError('NASA_FIRMS_API_KEY not configured — add in Settings'); + this.ctx.statusPanel?.updateApi('FIRMS', { status: 'error' }); + return; + } + const { regions, totalCount } = fireResult; + if (totalCount > 0) { + const flat = flattenFires(regions); + const stats = computeRegionStats(regions); + + signalAggregator.ingestSatelliteFires(flat.map(f => ({ + lat: f.location?.latitude ?? 0, + lon: f.location?.longitude ?? 0, + brightness: f.brightness, + frp: f.frp, + region: f.region, + acq_date: new Date(f.detectedAt).toISOString().slice(0, 10), + }))); + + this.ctx.map?.setFires(toMapFires(flat)); + + (this.ctx.panels['satellite-fires'] as SatelliteFiresPanel)?.update(stats, totalCount); + + dataFreshness.recordUpdate('firms', totalCount); + + updateAndCheck([ + { type: 'satellite_fires', region: 'global', count: totalCount }, + ]).then(anomalies => { + if (anomalies.length > 0) { + signalAggregator.ingestTemporalAnomalies(anomalies); + } + }).catch(() => { }); + } else { + (this.ctx.panels['satellite-fires'] as SatelliteFiresPanel)?.update([], 0); + } + this.ctx.statusPanel?.updateApi('FIRMS', { status: 'ok' }); + } catch (e) { + console.warn('[App] FIRMS load failed:', e); + (this.ctx.panels['satellite-fires'] as SatelliteFiresPanel)?.update([], 0); + this.ctx.statusPanel?.updateApi('FIRMS', { status: 'error' }); + dataFreshness.recordError('firms', String(e)); + } + } + + async loadPizzInt(): Promise { + try { + const [status, tensions] = await Promise.all([ + fetchPizzIntStatus(), + fetchGdeltTensions() + ]); + + if (status.locationsMonitored === 0) { + this.ctx.pizzintIndicator?.hide(); + this.ctx.statusPanel?.updateApi('PizzINT', { status: 'error' }); + dataFreshness.recordError('pizzint', 'No monitored locations returned'); + return; + } + + this.ctx.pizzintIndicator?.show(); + this.ctx.pizzintIndicator?.updateStatus(status); + this.ctx.pizzintIndicator?.updateTensions(tensions); + this.ctx.statusPanel?.updateApi('PizzINT', { status: 'ok' }); + dataFreshness.recordUpdate('pizzint', Math.max(status.locationsMonitored, tensions.length)); + } catch (error) { + console.error('[App] PizzINT load failed:', error); + this.ctx.pizzintIndicator?.hide(); + this.ctx.statusPanel?.updateApi('PizzINT', { status: 'error' }); + dataFreshness.recordError('pizzint', String(error)); + } + } + + syncDataFreshnessWithLayers(): void { + for (const [layer, sourceIds] of Object.entries(LAYER_TO_SOURCE)) { + const enabled = this.ctx.mapLayers[layer as keyof MapLayers] ?? false; + for (const sourceId of sourceIds) { + dataFreshness.setEnabled(sourceId as DataSourceId, enabled); + } + } + + if (!isAisConfigured()) { + dataFreshness.setEnabled('ais', false); + } + if (isOutagesConfigured() === false) { + dataFreshness.setEnabled('outages', false); + } + } + + private static readonly HAPPY_ITEMS_CACHE_KEY = 'happy-all-items'; + + async hydrateHappyPanelsFromCache(): Promise { + try { + type CachedItem = Omit & { pubDate: number }; + const entry = await getPersistentCache(DataLoaderManager.HAPPY_ITEMS_CACHE_KEY); + if (!entry || !entry.data || entry.data.length === 0) return; + if (Date.now() - entry.updatedAt > 24 * 60 * 60 * 1000) return; + + const items: NewsItem[] = entry.data.map(item => ({ + ...item, + pubDate: new Date(item.pubDate), + })); + + const scienceSources = ['GNN Science', 'ScienceDaily', 'Nature News', 'Live Science', 'New Scientist', 'Singularity Hub', 'Human Progress', 'Greater Good (Berkeley)']; + this.ctx.breakthroughsPanel?.setItems( + items.filter(item => scienceSources.includes(item.source) || item.happyCategory === 'science-health') + ); + this.ctx.heroPanel?.setHeroStory( + items.filter(item => item.happyCategory === 'humanity-kindness') + .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())[0] + ); + this.ctx.digestPanel?.setStories( + [...items].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()).slice(0, 5) + ); + this.ctx.positivePanel?.renderPositiveNews(items); + } catch (err) { + console.warn('[App] Happy panel cache hydration failed:', err); + } + } + + private async loadHappySupplementaryAndRender(): Promise { + if (!this.ctx.positivePanel) return; + + const curated = [...this.ctx.happyAllItems]; + this.ctx.positivePanel.renderPositiveNews(curated); + + let supplementary: NewsItem[] = []; + try { + const gdeltTopics = await fetchAllPositiveTopicIntelligence(); + const gdeltItems: NewsItem[] = gdeltTopics.flatMap(topic => + topic.articles.map(article => ({ + source: 'GDELT', + title: article.title, + link: article.url, + pubDate: article.date ? new Date(article.date) : new Date(), + isAlert: false, + imageUrl: article.image || undefined, + happyCategory: classifyNewsItem('GDELT', article.title), + })) + ); + + supplementary = await filterBySentiment(gdeltItems); + } catch (err) { + console.warn('[App] Happy supplementary pipeline failed, using curated only:', err); + } + + if (supplementary.length > 0) { + const merged = [...curated, ...supplementary]; + merged.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()); + this.ctx.positivePanel.renderPositiveNews(merged); + } + + const scienceSources = ['GNN Science', 'ScienceDaily', 'Nature News', 'Live Science', 'New Scientist', 'Singularity Hub', 'Human Progress', 'Greater Good (Berkeley)']; + const scienceItems = this.ctx.happyAllItems.filter(item => + scienceSources.includes(item.source) || item.happyCategory === 'science-health' + ); + this.ctx.breakthroughsPanel?.setItems(scienceItems); + + const heroItem = this.ctx.happyAllItems + .filter(item => item.happyCategory === 'humanity-kindness') + .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())[0]; + this.ctx.heroPanel?.setHeroStory(heroItem); + + const digestItems = [...this.ctx.happyAllItems] + .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()) + .slice(0, 5); + this.ctx.digestPanel?.setStories(digestItems); + + setPersistentCache( + DataLoaderManager.HAPPY_ITEMS_CACHE_KEY, + this.ctx.happyAllItems.map(item => ({ ...item, pubDate: item.pubDate.getTime() })) + ).catch(() => {}); + } + + private async loadPositiveEvents(): Promise { + const gdeltEvents = await fetchPositiveGeoEvents(); + const rssEvents = geocodePositiveNewsItems( + this.ctx.happyAllItems.map(item => ({ + title: item.title, + category: item.happyCategory, + })) + ); + const seen = new Set(); + const merged = [...gdeltEvents, ...rssEvents].filter(e => { + if (seen.has(e.name)) return false; + seen.add(e.name); + return true; + }); + this.ctx.map?.setPositiveEvents(merged); + } + + private loadKindnessData(): void { + const kindnessItems = fetchKindnessData( + this.ctx.happyAllItems.map(item => ({ + title: item.title, + happyCategory: item.happyCategory, + })) + ); + this.ctx.map?.setKindnessData(kindnessItems); + } + + private async loadProgressData(): Promise { + const datasets = await fetchProgressData(); + this.ctx.progressPanel?.setData(datasets); + } + + private async loadSpeciesData(): Promise { + const species = await fetchConservationWins(); + this.ctx.speciesPanel?.setData(species); + this.ctx.map?.setSpeciesRecoveryZones(species); + if (SITE_VARIANT === 'happy' && species.length > 0) { + checkMilestones({ + speciesRecoveries: species.map(s => ({ name: s.commonName, status: s.recoveryStatus })), + newSpeciesCount: species.length, + }); + } + } + + private async loadRenewableData(): Promise { + const data = await fetchRenewableEnergyData(); + this.ctx.renewablePanel?.setData(data); + if (SITE_VARIANT === 'happy' && data?.globalPercentage) { + checkMilestones({ + renewablePercent: data.globalPercentage, + }); + } + try { + const capacity = await fetchEnergyCapacity(); + this.ctx.renewablePanel?.setCapacityData(capacity); + } catch { + // EIA failure does not break the existing World Bank gauge + } + } +} diff --git a/src/app/desktop-updater.ts b/src/app/desktop-updater.ts new file mode 100644 index 000000000..01d8272b2 --- /dev/null +++ b/src/app/desktop-updater.ts @@ -0,0 +1,211 @@ +import type { AppContext, AppModule } from '@/app/app-context'; +import { invokeTauri } from '@/services/tauri-bridge'; +import { trackUpdateShown, trackUpdateClicked, trackUpdateDismissed } from '@/services/analytics'; +import { escapeHtml } from '@/utils/sanitize'; + +interface DesktopRuntimeInfo { + os: string; + arch: string; +} + +type UpdaterOutcome = 'no_update' | 'update_available' | 'open_failed' | 'fetch_failed'; +type DesktopBuildVariant = 'full' | 'tech' | 'finance'; + +const DESKTOP_BUILD_VARIANT: DesktopBuildVariant = ( + import.meta.env.VITE_VARIANT === 'tech' || import.meta.env.VITE_VARIANT === 'finance' + ? import.meta.env.VITE_VARIANT + : 'full' +); + +export class DesktopUpdater implements AppModule { + private ctx: AppContext; + private updateCheckIntervalId: ReturnType | null = null; + private readonly UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + + constructor(ctx: AppContext) { + this.ctx = ctx; + } + + init(): void { + this.setupUpdateChecks(); + } + + destroy(): void { + if (this.updateCheckIntervalId) { + clearInterval(this.updateCheckIntervalId); + this.updateCheckIntervalId = null; + } + } + + private setupUpdateChecks(): void { + if (!this.ctx.isDesktopApp || this.ctx.isDestroyed) return; + + setTimeout(() => { + if (this.ctx.isDestroyed) return; + void this.checkForUpdate(); + }, 5000); + + if (this.updateCheckIntervalId) { + clearInterval(this.updateCheckIntervalId); + } + this.updateCheckIntervalId = setInterval(() => { + if (this.ctx.isDestroyed) return; + void this.checkForUpdate(); + }, this.UPDATE_CHECK_INTERVAL_MS); + } + + private logUpdaterOutcome(outcome: UpdaterOutcome, context: Record = {}): void { + const logger = outcome === 'open_failed' || outcome === 'fetch_failed' + ? console.warn + : console.info; + logger('[updater]', outcome, context); + } + + private getDesktopBuildVariant(): DesktopBuildVariant { + return DESKTOP_BUILD_VARIANT; + } + + private async checkForUpdate(): Promise { + try { + const res = await fetch('https://worldmonitor.app/api/version'); + if (!res.ok) { + this.logUpdaterOutcome('fetch_failed', { status: res.status }); + return; + } + const data = await res.json(); + const remote = data.version as string; + if (!remote) { + this.logUpdaterOutcome('fetch_failed', { reason: 'missing_remote_version' }); + return; + } + + const current = __APP_VERSION__; + if (!this.isNewerVersion(remote, current)) { + this.logUpdaterOutcome('no_update', { current, remote }); + return; + } + + const dismissKey = `wm-update-dismissed-${remote}`; + if (localStorage.getItem(dismissKey)) { + this.logUpdaterOutcome('update_available', { current, remote, dismissed: true }); + return; + } + + const releaseUrl = typeof data.url === 'string' && data.url + ? data.url + : 'https://github.com/koala73/worldmonitor/releases/latest'; + this.logUpdaterOutcome('update_available', { current, remote, dismissed: false }); + trackUpdateShown(current, remote); + await this.showUpdateToast(remote, releaseUrl); + } catch (error) { + this.logUpdaterOutcome('fetch_failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private isNewerVersion(remote: string, current: string): boolean { + const r = remote.split('.').map(Number); + const c = current.split('.').map(Number); + for (let i = 0; i < Math.max(r.length, c.length); i++) { + const rv = r[i] ?? 0; + const cv = c[i] ?? 0; + if (rv > cv) return true; + if (rv < cv) return false; + } + return false; + } + + private mapDesktopDownloadPlatform(os: string, arch: string): string | null { + const normalizedOs = os.toLowerCase(); + const normalizedArch = arch.toLowerCase() + .replace('amd64', 'x86_64') + .replace('x64', 'x86_64') + .replace('arm64', 'aarch64'); + + if (normalizedOs === 'windows') { + return normalizedArch === 'x86_64' ? 'windows-exe' : null; + } + + if (normalizedOs === 'macos' || normalizedOs === 'darwin') { + if (normalizedArch === 'aarch64') return 'macos-arm64'; + if (normalizedArch === 'x86_64') return 'macos-x64'; + return null; + } + + if (normalizedOs === 'linux') { + if (normalizedArch === 'x86_64') return 'linux-appimage'; + if (normalizedArch === 'aarch64') return 'linux-appimage-arm64'; + return null; + } + + return null; + } + + private async resolveUpdateDownloadUrl(releaseUrl: string): Promise { + try { + const runtimeInfo = await invokeTauri('get_desktop_runtime_info'); + const platform = this.mapDesktopDownloadPlatform(runtimeInfo.os, runtimeInfo.arch); + if (platform) { + const variant = this.getDesktopBuildVariant(); + return `https://worldmonitor.app/api/download?platform=${platform}&variant=${variant}`; + } + } catch { + // Silent fallback to release page when desktop runtime info is unavailable. + } + return releaseUrl; + } + + private async showUpdateToast(version: string, releaseUrl: string): Promise { + const existing = document.querySelector('.update-toast'); + if (existing?.dataset.version === version) return; + existing?.remove(); + + const url = await this.resolveUpdateDownloadUrl(releaseUrl); + + const toast = document.createElement('div'); + toast.className = 'update-toast'; + toast.dataset.version = version; + toast.innerHTML = ` +
+ + + + + +
+
+
Update Available
+
v${escapeHtml(__APP_VERSION__)} \u2192 v${escapeHtml(version)}
+
+ + + `; + + toast.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const action = target.closest('[data-action]')?.dataset.action; + if (action === 'download') { + trackUpdateClicked(version); + if (this.ctx.isDesktopApp) { + void invokeTauri('open_url', { url }).catch((error) => { + this.logUpdaterOutcome('open_failed', { url, error: error instanceof Error ? error.message : String(error) }); + window.open(url, '_blank', 'noopener'); + }); + } else { + window.open(url, '_blank', 'noopener'); + } + } else if (action === 'dismiss') { + trackUpdateDismissed(version); + localStorage.setItem(`wm-update-dismissed-${version}`, '1'); + toast.classList.remove('visible'); + setTimeout(() => toast.remove(), 300); + } + }); + + document.body.appendChild(toast); + requestAnimationFrame(() => { + requestAnimationFrame(() => toast.classList.add('visible')); + }); + } +} diff --git a/src/app/event-handlers.ts b/src/app/event-handlers.ts new file mode 100644 index 000000000..cf07c5c43 --- /dev/null +++ b/src/app/event-handlers.ts @@ -0,0 +1,731 @@ +import type { AppContext, AppModule } from '@/app/app-context'; +import type { PanelConfig } from '@/types'; +import type { MapView } from '@/components'; +import type { ClusteredEvent } from '@/types'; +import type { DashboardSnapshot } from '@/services/storage'; +import { + PlaybackControl, + StatusPanel, + MobileWarningModal, + PizzIntIndicator, + CIIPanel, + PredictionPanel, +} from '@/components'; +import { + buildMapUrl, + debounce, + saveToStorage, + ExportPanel, + getCurrentTheme, + setTheme, +} from '@/utils'; +import { + STORAGE_KEYS, + SITE_VARIANT, + LAYER_TO_SOURCE, + FEEDS, + INTEL_SOURCES, + DEFAULT_PANELS, +} from '@/config'; +import { + saveSnapshot, + initAisStream, + disconnectAisStream, +} from '@/services'; +import { + trackPanelView, + trackVariantSwitch, + trackThemeChanged, + trackMapViewChange, + trackMapLayerToggle, + trackPanelToggled, +} from '@/services/analytics'; +import { invokeTauri } from '@/services/tauri-bridge'; +import { dataFreshness } from '@/services/data-freshness'; +import { mlWorker } from '@/services/ml-worker'; +import { UnifiedSettings } from '@/components/UnifiedSettings'; +import { t } from '@/services/i18n'; +import { TvModeController } from '@/services/tv-mode'; + +export interface EventHandlerCallbacks { + updateSearchIndex: () => void; + loadAllData: () => Promise; + flushStaleRefreshes: () => void; + setHiddenSince: (ts: number) => void; + loadDataForLayer: (layer: string) => void; + waitForAisData: () => void; + syncDataFreshnessWithLayers: () => void; +} + +export class EventHandlerManager implements AppModule { + private ctx: AppContext; + private callbacks: EventHandlerCallbacks; + + private boundFullscreenHandler: (() => void) | null = null; + private boundResizeHandler: (() => void) | null = null; + private boundVisibilityHandler: (() => void) | null = null; + private boundDesktopExternalLinkHandler: ((e: MouseEvent) => void) | null = null; + private boundIdleResetHandler: (() => void) | null = null; + private idleTimeoutId: ReturnType | null = null; + private snapshotIntervalId: ReturnType | null = null; + private clockIntervalId: ReturnType | null = null; + private readonly IDLE_PAUSE_MS = 2 * 60 * 1000; + + constructor(ctx: AppContext, callbacks: EventHandlerCallbacks) { + this.ctx = ctx; + this.callbacks = callbacks; + } + + init(): void { + this.setupEventListeners(); + this.setupIdleDetection(); + this.setupTvMode(); + } + + private setupTvMode(): void { + if (SITE_VARIANT !== 'happy') return; + + const tvBtn = document.getElementById('tvModeBtn'); + const tvExitBtn = document.getElementById('tvExitBtn'); + if (tvBtn) { + tvBtn.addEventListener('click', () => this.toggleTvMode()); + } + if (tvExitBtn) { + tvExitBtn.addEventListener('click', () => this.toggleTvMode()); + } + // Keyboard shortcut: Shift+T + document.addEventListener('keydown', (e) => { + if (e.shiftKey && e.key === 'T' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const active = document.activeElement; + if (active?.tagName !== 'INPUT' && active?.tagName !== 'TEXTAREA') { + e.preventDefault(); + this.toggleTvMode(); + } + } + }); + } + + private toggleTvMode(): void { + const panelKeys = Object.keys(DEFAULT_PANELS).filter( + key => this.ctx.panelSettings[key]?.enabled !== false + ); + if (!this.ctx.tvMode) { + this.ctx.tvMode = new TvModeController({ + panelKeys, + onPanelChange: () => { + document.getElementById('tvModeBtn')?.classList.toggle('active', this.ctx.tvMode?.active ?? false); + } + }); + } else { + this.ctx.tvMode.updatePanelKeys(panelKeys); + } + this.ctx.tvMode.toggle(); + document.getElementById('tvModeBtn')?.classList.toggle('active', this.ctx.tvMode.active); + } + + destroy(): void { + if (this.boundFullscreenHandler) { + document.removeEventListener('fullscreenchange', this.boundFullscreenHandler); + this.boundFullscreenHandler = null; + } + if (this.boundResizeHandler) { + window.removeEventListener('resize', this.boundResizeHandler); + this.boundResizeHandler = null; + } + if (this.boundVisibilityHandler) { + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + this.boundVisibilityHandler = null; + } + if (this.boundDesktopExternalLinkHandler) { + document.removeEventListener('click', this.boundDesktopExternalLinkHandler, true); + this.boundDesktopExternalLinkHandler = null; + } + if (this.idleTimeoutId) { + clearTimeout(this.idleTimeoutId); + this.idleTimeoutId = null; + } + if (this.boundIdleResetHandler) { + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.removeEventListener(event, this.boundIdleResetHandler!); + }); + this.boundIdleResetHandler = null; + } + if (this.snapshotIntervalId) { + clearInterval(this.snapshotIntervalId); + this.snapshotIntervalId = null; + } + if (this.clockIntervalId) { + clearInterval(this.clockIntervalId); + this.clockIntervalId = null; + } + this.ctx.tvMode?.destroy(); + this.ctx.tvMode = null; + this.ctx.unifiedSettings?.destroy(); + this.ctx.unifiedSettings = null; + } + + private setupEventListeners(): void { + document.getElementById('searchBtn')?.addEventListener('click', () => { + this.callbacks.updateSearchIndex(); + this.ctx.searchModal?.open(); + }); + + document.getElementById('copyLinkBtn')?.addEventListener('click', async () => { + const shareUrl = this.getShareUrl(); + if (!shareUrl) return; + const button = document.getElementById('copyLinkBtn'); + try { + await this.copyToClipboard(shareUrl); + this.setCopyLinkFeedback(button, 'Copied!'); + } catch (error) { + console.warn('Failed to copy share link:', error); + this.setCopyLinkFeedback(button, 'Copy failed'); + } + }); + + window.addEventListener('storage', (e) => { + if (e.key === STORAGE_KEYS.panels && e.newValue) { + try { + this.ctx.panelSettings = JSON.parse(e.newValue) as Record; + this.applyPanelSettings(); + this.ctx.unifiedSettings?.refreshPanelToggles(); + } catch (_) {} + } + if (e.key === STORAGE_KEYS.liveChannels && e.newValue) { + const panel = this.ctx.panels['live-news']; + if (panel && typeof (panel as unknown as { refreshChannelsFromStorage?: () => void }).refreshChannelsFromStorage === 'function') { + (panel as unknown as { refreshChannelsFromStorage: () => void }).refreshChannelsFromStorage(); + } + } + }); + + document.getElementById('headerThemeToggle')?.addEventListener('click', () => { + const next = getCurrentTheme() === 'dark' ? 'light' : 'dark'; + setTheme(next); + this.updateHeaderThemeIcon(); + trackThemeChanged(next); + }); + + const isLocalDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; + if (this.ctx.isDesktopApp || isLocalDev) { + this.ctx.container.querySelectorAll('.variant-option').forEach(link => { + link.addEventListener('click', (e) => { + const variant = link.dataset.variant; + if (variant && variant !== SITE_VARIANT) { + e.preventDefault(); + trackVariantSwitch(SITE_VARIANT, variant); + localStorage.setItem('worldmonitor-variant', variant); + window.location.reload(); + } + }); + }); + } + + const fullscreenBtn = document.getElementById('fullscreenBtn'); + if (!this.ctx.isDesktopApp && fullscreenBtn) { + fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); + this.boundFullscreenHandler = () => { + fullscreenBtn.textContent = document.fullscreenElement ? '\u26F6' : '\u26F6'; + fullscreenBtn.classList.toggle('active', !!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', this.boundFullscreenHandler); + } + + const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; + regionSelect?.addEventListener('change', () => { + this.ctx.map?.setView(regionSelect.value as MapView); + trackMapViewChange(regionSelect.value); + }); + + this.boundResizeHandler = () => { + this.ctx.map?.render(); + }; + window.addEventListener('resize', this.boundResizeHandler); + + this.setupMapResize(); + this.setupMapPin(); + + this.boundVisibilityHandler = () => { + document.body.classList.toggle('animations-paused', document.hidden); + if (document.hidden) { + this.callbacks.setHiddenSince(Date.now()); + mlWorker.unloadOptionalModels(); + } else { + this.resetIdleTimer(); + this.callbacks.flushStaleRefreshes(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + + window.addEventListener('focal-points-ready', () => { + (this.ctx.panels['cii'] as CIIPanel)?.refresh(true); + }); + + window.addEventListener('theme-changed', () => { + this.ctx.map?.render(); + this.updateHeaderThemeIcon(); + }); + + if (this.ctx.isDesktopApp) { + if (this.boundDesktopExternalLinkHandler) { + document.removeEventListener('click', this.boundDesktopExternalLinkHandler, true); + } + this.boundDesktopExternalLinkHandler = (e: MouseEvent) => { + if (!(e.target instanceof Element)) return; + const anchor = e.target.closest('a[href]') as HTMLAnchorElement | null; + if (!anchor) return; + const href = anchor.href; + if (!href || href.startsWith('javascript:') || href === '#' || href.startsWith('#')) return; + try { + const url = new URL(href, window.location.href); + if (url.origin === window.location.origin) return; + e.preventDefault(); + e.stopPropagation(); + void invokeTauri('open_url', { url: url.toString() }).catch(() => { + window.open(url.toString(), '_blank'); + }); + } catch { /* malformed URL -- let browser handle */ } + }; + document.addEventListener('click', this.boundDesktopExternalLinkHandler, true); + } + } + + private setupIdleDetection(): void { + this.boundIdleResetHandler = () => { + if (this.ctx.isIdle) { + this.ctx.isIdle = false; + document.body.classList.remove('animations-paused'); + } + this.resetIdleTimer(); + }; + + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.addEventListener(event, this.boundIdleResetHandler!, { passive: true }); + }); + + this.resetIdleTimer(); + } + + resetIdleTimer(): void { + if (this.idleTimeoutId) { + clearTimeout(this.idleTimeoutId); + } + this.idleTimeoutId = setTimeout(() => { + if (!document.hidden) { + this.ctx.isIdle = true; + document.body.classList.add('animations-paused'); + console.log('[App] User idle - pausing animations to save resources'); + } + }, this.IDLE_PAUSE_MS); + } + + setupUrlStateSync(): void { + if (!this.ctx.map) return; + const update = debounce(() => { + const shareUrl = this.getShareUrl(); + if (!shareUrl) return; + history.replaceState(null, '', shareUrl); + }, 250); + + this.ctx.map.onStateChanged(() => { + update(); + const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; + if (regionSelect && this.ctx.map) { + const state = this.ctx.map.getState(); + if (regionSelect.value !== state.view) { + regionSelect.value = state.view; + } + } + }); + update(); + } + + getShareUrl(): string | null { + if (!this.ctx.map) return null; + const state = this.ctx.map.getState(); + const center = this.ctx.map.getCenter(); + const baseUrl = `${window.location.origin}${window.location.pathname}`; + return buildMapUrl(baseUrl, { + view: state.view, + zoom: state.zoom, + center, + timeRange: state.timeRange, + layers: state.layers, + country: this.ctx.countryBriefPage?.isVisible() ? (this.ctx.countryBriefPage.getCode() ?? undefined) : undefined, + }); + } + + private async copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + + private setCopyLinkFeedback(button: HTMLElement | null, message: string): void { + if (!button) return; + const originalText = button.textContent ?? ''; + button.textContent = message; + button.classList.add('copied'); + window.setTimeout(() => { + button.textContent = originalText; + button.classList.remove('copied'); + }, 1500); + } + + toggleFullscreen(): void { + if (document.fullscreenElement) { + try { void document.exitFullscreen()?.catch(() => {}); } catch {} + } else { + const el = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => void }; + if (el.requestFullscreen) { + try { void el.requestFullscreen()?.catch(() => {}); } catch {} + } else if (el.webkitRequestFullscreen) { + try { el.webkitRequestFullscreen(); } catch {} + } + } + } + + updateHeaderThemeIcon(): void { + const btn = document.getElementById('headerThemeToggle'); + if (!btn) return; + const isDark = getCurrentTheme() === 'dark'; + btn.innerHTML = isDark + ? '' + : ''; + } + + startHeaderClock(): void { + const el = document.getElementById('headerClock'); + if (!el) return; + const tick = () => { + el.textContent = new Date().toUTCString().replace('GMT', 'UTC'); + }; + tick(); + this.clockIntervalId = setInterval(tick, 1000); + } + + setupMobileWarning(): void { + if (MobileWarningModal.shouldShow()) { + this.ctx.mobileWarningModal = new MobileWarningModal(); + this.ctx.mobileWarningModal.show(); + } + } + + setupStatusPanel(): void { + this.ctx.statusPanel = new StatusPanel(); + const headerLeft = this.ctx.container.querySelector('.header-left'); + if (headerLeft) { + headerLeft.appendChild(this.ctx.statusPanel.getElement()); + } + } + + setupPizzIntIndicator(): void { + if (SITE_VARIANT === 'tech' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'happy') return; + + this.ctx.pizzintIndicator = new PizzIntIndicator(); + const headerLeft = this.ctx.container.querySelector('.header-left'); + if (headerLeft) { + headerLeft.appendChild(this.ctx.pizzintIndicator.getElement()); + } + } + + setupExportPanel(): void { + this.ctx.exportPanel = new ExportPanel(() => ({ + news: this.ctx.latestClusters.length > 0 ? this.ctx.latestClusters : this.ctx.allNews, + markets: this.ctx.latestMarkets, + predictions: this.ctx.latestPredictions, + timestamp: Date.now(), + })); + + const headerRight = this.ctx.container.querySelector('.header-right'); + if (headerRight) { + headerRight.insertBefore(this.ctx.exportPanel.getElement(), headerRight.firstChild); + } + } + + setupUnifiedSettings(): void { + this.ctx.unifiedSettings = new UnifiedSettings({ + getPanelSettings: () => this.ctx.panelSettings, + togglePanel: (key: string) => { + const config = this.ctx.panelSettings[key]; + if (config) { + config.enabled = !config.enabled; + trackPanelToggled(key, config.enabled); + saveToStorage(STORAGE_KEYS.panels, this.ctx.panelSettings); + this.applyPanelSettings(); + } + }, + getDisabledSources: () => this.ctx.disabledSources, + toggleSource: (name: string) => { + if (this.ctx.disabledSources.has(name)) { + this.ctx.disabledSources.delete(name); + } else { + this.ctx.disabledSources.add(name); + } + saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(this.ctx.disabledSources)); + }, + setSourcesEnabled: (names: string[], enabled: boolean) => { + for (const name of names) { + if (enabled) this.ctx.disabledSources.delete(name); + else this.ctx.disabledSources.add(name); + } + saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(this.ctx.disabledSources)); + }, + getAllSourceNames: () => this.getAllSourceNames(), + getLocalizedPanelName: (key: string, fallback: string) => this.getLocalizedPanelName(key, fallback), + isDesktopApp: this.ctx.isDesktopApp, + }); + + const mount = document.getElementById('unifiedSettingsMount'); + if (mount) { + mount.appendChild(this.ctx.unifiedSettings.getButton()); + } + } + + setupPlaybackControl(): void { + this.ctx.playbackControl = new PlaybackControl(); + this.ctx.playbackControl.onSnapshot((snapshot) => { + if (snapshot) { + this.ctx.isPlaybackMode = true; + this.restoreSnapshot(snapshot); + } else { + this.ctx.isPlaybackMode = false; + this.callbacks.loadAllData(); + } + }); + + const headerRight = this.ctx.container.querySelector('.header-right'); + if (headerRight) { + headerRight.insertBefore(this.ctx.playbackControl.getElement(), headerRight.firstChild); + } + } + + setupSnapshotSaving(): void { + const saveCurrentSnapshot = async () => { + if (this.ctx.isPlaybackMode || this.ctx.isDestroyed) return; + + const marketPrices: Record = {}; + this.ctx.latestMarkets.forEach(m => { + if (m.price !== null) marketPrices[m.symbol] = m.price; + }); + + await saveSnapshot({ + timestamp: Date.now(), + events: this.ctx.latestClusters, + marketPrices, + predictions: this.ctx.latestPredictions.map(p => ({ + title: p.title, + yesPrice: p.yesPrice + })), + hotspotLevels: this.ctx.map?.getHotspotLevels() ?? {} + }); + }; + + void saveCurrentSnapshot().catch((e) => console.warn('[Snapshot] save failed:', e)); + this.snapshotIntervalId = setInterval(() => void saveCurrentSnapshot().catch((e) => console.warn('[Snapshot] save failed:', e)), 15 * 60 * 1000); + } + + restoreSnapshot(snapshot: DashboardSnapshot): void { + for (const panel of Object.values(this.ctx.newsPanels)) { + panel.showLoading(); + } + + const events = snapshot.events as ClusteredEvent[]; + this.ctx.latestClusters = events; + + const predictions = snapshot.predictions.map((p, i) => ({ + id: `snap-${i}`, + title: p.title, + yesPrice: p.yesPrice, + noPrice: 100 - p.yesPrice, + volume24h: 0, + liquidity: 0, + })); + this.ctx.latestPredictions = predictions; + (this.ctx.panels['polymarket'] as PredictionPanel).renderPredictions(predictions); + + this.ctx.map?.setHotspotLevels(snapshot.hotspotLevels); + } + + setupMapLayerHandlers(): void { + this.ctx.map?.setOnLayerChange((layer, enabled, source) => { + console.log(`[App.onLayerChange] ${layer}: ${enabled} (${source})`); + trackMapLayerToggle(layer, enabled, source); + this.ctx.mapLayers[layer] = enabled; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + + const sourceIds = LAYER_TO_SOURCE[layer]; + if (sourceIds) { + for (const sourceId of sourceIds) { + dataFreshness.setEnabled(sourceId, enabled); + } + } + + if (layer === 'ais') { + if (enabled) { + this.ctx.map?.setLayerLoading('ais', true); + initAisStream(); + this.callbacks.waitForAisData(); + } else { + disconnectAisStream(); + } + return; + } + + if (enabled) { + this.callbacks.loadDataForLayer(layer); + } + }); + } + + setupPanelViewTracking(): void { + const viewedPanels = new Set(); + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting && entry.intersectionRatio >= 0.3) { + const id = (entry.target as HTMLElement).dataset.panel; + if (id && !viewedPanels.has(id)) { + viewedPanels.add(id); + trackPanelView(id); + } + } + } + }, { threshold: 0.3 }); + + const grid = document.getElementById('panelsGrid'); + if (grid) { + for (const child of Array.from(grid.children)) { + if ((child as HTMLElement).dataset.panel) { + observer.observe(child); + } + } + } + } + + showToast(msg: string): void { + document.querySelector('.toast-notification')?.remove(); + const el = document.createElement('div'); + el.className = 'toast-notification'; + el.textContent = msg; + document.body.appendChild(el); + requestAnimationFrame(() => el.classList.add('visible')); + setTimeout(() => { el.classList.remove('visible'); setTimeout(() => el.remove(), 300); }, 3000); + } + + shouldShowIntelligenceNotifications(): boolean { + return !this.ctx.isMobile && !!this.ctx.findingsBadge?.isPopupEnabled(); + } + + setupMapResize(): void { + const mapSection = document.getElementById('mapSection'); + const resizeHandle = document.getElementById('mapResizeHandle'); + if (!mapSection || !resizeHandle) return; + + const getMinHeight = () => (window.innerWidth >= 2000 ? 320 : 400); + const getMaxHeight = () => Math.max(getMinHeight(), window.innerHeight - 60); + + const savedHeight = localStorage.getItem('map-height'); + if (savedHeight) { + const numeric = Number.parseInt(savedHeight, 10); + if (Number.isFinite(numeric)) { + const clamped = Math.max(getMinHeight(), Math.min(numeric, getMaxHeight())); + mapSection.style.height = `${clamped}px`; + if (clamped !== numeric) { + localStorage.setItem('map-height', `${clamped}px`); + } + } else { + localStorage.removeItem('map-height'); + } + } + + let isResizing = false; + let startY = 0; + let startHeight = 0; + + resizeHandle.addEventListener('mousedown', (e) => { + isResizing = true; + startY = e.clientY; + startHeight = mapSection.offsetHeight; + mapSection.classList.add('resizing'); + document.body.style.cursor = 'ns-resize'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + const deltaY = e.clientY - startY; + const newHeight = Math.max(getMinHeight(), Math.min(startHeight + deltaY, getMaxHeight())); + mapSection.style.height = `${newHeight}px`; + this.ctx.map?.render(); + }); + + document.addEventListener('mouseup', () => { + if (!isResizing) return; + isResizing = false; + mapSection.classList.remove('resizing'); + document.body.style.cursor = ''; + localStorage.setItem('map-height', mapSection.style.height); + this.ctx.map?.render(); + }); + } + + setupMapPin(): void { + const mapSection = document.getElementById('mapSection'); + const pinBtn = document.getElementById('mapPinBtn'); + if (!mapSection || !pinBtn) return; + + const isPinned = localStorage.getItem('map-pinned') === 'true'; + if (isPinned) { + mapSection.classList.add('pinned'); + pinBtn.classList.add('active'); + } + + pinBtn.addEventListener('click', () => { + const nowPinned = mapSection.classList.toggle('pinned'); + pinBtn.classList.toggle('active', nowPinned); + localStorage.setItem('map-pinned', String(nowPinned)); + }); + } + + getLocalizedPanelName(panelKey: string, fallback: string): string { + if (panelKey === 'runtime-config') { + return t('modals.runtimeConfig.title'); + } + const key = panelKey.replace(/-([a-z])/g, (_match, group: string) => group.toUpperCase()); + const lookup = `panels.${key}`; + const localized = t(lookup); + return localized === lookup ? fallback : localized; + } + + getAllSourceNames(): string[] { + const sources = new Set(); + Object.values(FEEDS).forEach(feeds => { + if (feeds) feeds.forEach(f => sources.add(f.name)); + }); + INTEL_SOURCES.forEach(f => sources.add(f.name)); + return Array.from(sources).sort((a, b) => a.localeCompare(b)); + } + + applyPanelSettings(): void { + Object.entries(this.ctx.panelSettings).forEach(([key, config]) => { + if (key === 'map') { + const mapSection = document.getElementById('mapSection'); + if (mapSection) { + mapSection.classList.toggle('hidden', !config.enabled); + } + return; + } + const panel = this.ctx.panels[key]; + panel?.toggle(config.enabled); + }); + } +} diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 000000000..17493c081 --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1,8 @@ +export type { AppContext, AppModule, CountryBriefSignals, IntelligenceCache } from './app-context'; +export { DesktopUpdater } from './desktop-updater'; +export { CountryIntelManager } from './country-intel'; +export { SearchManager } from './search-manager'; +export { RefreshScheduler } from './refresh-scheduler'; +export { PanelLayoutManager } from './panel-layout'; +export { DataLoaderManager } from './data-loader'; +export { EventHandlerManager } from './event-handlers'; diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts new file mode 100644 index 000000000..474cecc2d --- /dev/null +++ b/src/app/panel-layout.ts @@ -0,0 +1,934 @@ +import type { AppContext, AppModule } from '@/app/app-context'; +import type { RelatedAsset } from '@/types'; +import type { TheaterPostureSummary } from '@/services/military-surge'; +import { + MapContainer, + NewsPanel, + MarketPanel, + HeatmapPanel, + CommoditiesPanel, + CryptoPanel, + PredictionPanel, + MonitorPanel, + EconomicPanel, + GdeltIntelPanel, + LiveNewsPanel, + LiveWebcamsPanel, + CIIPanel, + CascadePanel, + StrategicRiskPanel, + StrategicPosturePanel, + TechEventsPanel, + ServiceStatusPanel, + RuntimeConfigPanel, + InsightsPanel, + TechReadinessPanel, + MacroSignalsPanel, + ETFFlowsPanel, + StablecoinPanel, + UcdpEventsPanel, + DisplacementPanel, + ClimateAnomalyPanel, + PopulationExposurePanel, + InvestmentsPanel, + TradePolicyPanel, + SupplyChainPanel, +} from '@/components'; +import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; +import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; +import { CountersPanel } from '@/components/CountersPanel'; +import { ProgressChartsPanel } from '@/components/ProgressChartsPanel'; +import { BreakthroughsTickerPanel } from '@/components/BreakthroughsTickerPanel'; +import { HeroSpotlightPanel } from '@/components/HeroSpotlightPanel'; +import { GoodThingsDigestPanel } from '@/components/GoodThingsDigestPanel'; +import { SpeciesComebackPanel } from '@/components/SpeciesComebackPanel'; +import { RenewableEnergyPanel } from '@/components/RenewableEnergyPanel'; +import { GivingPanel } from '@/components'; +import { focusInvestmentOnMap } from '@/services/investments-focus'; +import { debounce, saveToStorage } from '@/utils'; +import { escapeHtml } from '@/utils/sanitize'; +import { + FEEDS, + INTEL_SOURCES, + DEFAULT_PANELS, + STORAGE_KEYS, + SITE_VARIANT, +} from '@/config'; +import { BETA_MODE } from '@/config/beta'; +import { t } from '@/services/i18n'; +import { getCurrentTheme } from '@/utils'; +import { trackCriticalBannerAction } from '@/services/analytics'; + +export interface PanelLayoutCallbacks { + openCountryStory: (code: string, name: string) => void; + loadAllData: () => Promise; + updateMonitorResults: () => void; +} + +export class PanelLayoutManager implements AppModule { + private ctx: AppContext; + private callbacks: PanelLayoutCallbacks; + private panelDragCleanupHandlers: Array<() => void> = []; + private criticalBannerEl: HTMLElement | null = null; + private readonly applyTimeRangeFilterDebounced: () => void; + + constructor(ctx: AppContext, callbacks: PanelLayoutCallbacks) { + this.ctx = ctx; + this.callbacks = callbacks; + this.applyTimeRangeFilterDebounced = debounce(() => { + this.applyTimeRangeFilterToNewsPanels(); + }, 120); + } + + init(): void { + this.renderLayout(); + } + + destroy(): void { + this.panelDragCleanupHandlers.forEach((cleanup) => cleanup()); + this.panelDragCleanupHandlers = []; + if (this.criticalBannerEl) { + this.criticalBannerEl.remove(); + this.criticalBannerEl = null; + } + // Clean up happy variant panels + this.ctx.tvMode?.destroy(); + this.ctx.tvMode = null; + this.ctx.countersPanel?.destroy(); + this.ctx.progressPanel?.destroy(); + this.ctx.breakthroughsPanel?.destroy(); + this.ctx.heroPanel?.destroy(); + this.ctx.digestPanel?.destroy(); + this.ctx.speciesPanel?.destroy(); + this.ctx.renewablePanel?.destroy(); + } + + renderLayout(): void { + this.ctx.container.innerHTML = ` +
+
+
${(() => { + const local = this.ctx.isDesktopApp || location.hostname === 'localhost' || location.hostname === '127.0.0.1'; + const vHref = (v: string, prod: string) => local || SITE_VARIANT === v ? '#' : prod; + const vTarget = (v: string) => !local && SITE_VARIANT !== v ? 'target="_blank" rel="noopener"' : ''; + return ` + + 🌍 + ${t('header.world')} + + + + 💻 + ${t('header.tech')} + + + + 📈 + ${t('header.finance')} + + ${SITE_VARIANT === 'happy' ? ` + + ☀️ + Good News + ` : ''}`; + })()}
+ v${__APP_VERSION__}${BETA_MODE ? 'BETA' : ''} + + + @eliehabib + + + + +
+ + ${t('header.live')} +
+
+ +
+
+
+ + ${this.ctx.isDesktopApp ? '' : ``} + + ${this.ctx.isDesktopApp ? '' : ``} + ${SITE_VARIANT === 'happy' ? `` : ''} + +
+
+
+
+
+
+ ${SITE_VARIANT === 'tech' ? t('panels.techMap') : SITE_VARIANT === 'happy' ? 'Good News Map' : t('panels.map')} +
+ + +
+
+ ${SITE_VARIANT === 'happy' ? '' : ''} +
+
+
+
+ `; + + this.createPanels(); + } + + renderCriticalBanner(postures: TheaterPostureSummary[]): void { + if (this.ctx.isMobile) { + if (this.criticalBannerEl) { + this.criticalBannerEl.remove(); + this.criticalBannerEl = null; + } + document.body.classList.remove('has-critical-banner'); + return; + } + + const dismissedAt = sessionStorage.getItem('banner-dismissed'); + if (dismissedAt && Date.now() - parseInt(dismissedAt, 10) < 30 * 60 * 1000) { + return; + } + + const critical = postures.filter( + (p) => p.postureLevel === 'critical' || (p.postureLevel === 'elevated' && p.strikeCapable) + ); + + if (critical.length === 0) { + if (this.criticalBannerEl) { + this.criticalBannerEl.remove(); + this.criticalBannerEl = null; + document.body.classList.remove('has-critical-banner'); + } + return; + } + + const top = critical[0]!; + const isCritical = top.postureLevel === 'critical'; + + if (!this.criticalBannerEl) { + this.criticalBannerEl = document.createElement('div'); + this.criticalBannerEl.className = 'critical-posture-banner'; + const header = document.querySelector('.header'); + if (header) header.insertAdjacentElement('afterend', this.criticalBannerEl); + } + + document.body.classList.add('has-critical-banner'); + this.criticalBannerEl.className = `critical-posture-banner ${isCritical ? 'severity-critical' : 'severity-elevated'}`; + this.criticalBannerEl.innerHTML = ` + + + + `; + + this.criticalBannerEl.querySelector('.banner-view')?.addEventListener('click', () => { + console.log('[Banner] View Region clicked:', top.theaterId, 'lat:', top.centerLat, 'lon:', top.centerLon); + trackCriticalBannerAction('view', top.theaterId); + if (typeof top.centerLat === 'number' && typeof top.centerLon === 'number') { + this.ctx.map?.setCenter(top.centerLat, top.centerLon, 4); + } else { + console.error('[Banner] Missing coordinates for', top.theaterId); + } + }); + + this.criticalBannerEl.querySelector('.banner-dismiss')?.addEventListener('click', () => { + trackCriticalBannerAction('dismiss', top.theaterId); + this.criticalBannerEl?.classList.add('dismissed'); + document.body.classList.remove('has-critical-banner'); + sessionStorage.setItem('banner-dismissed', Date.now().toString()); + }); + } + + applyPanelSettings(): void { + Object.entries(this.ctx.panelSettings).forEach(([key, config]) => { + if (key === 'map') { + const mapSection = document.getElementById('mapSection'); + if (mapSection) { + mapSection.classList.toggle('hidden', !config.enabled); + } + return; + } + const panel = this.ctx.panels[key]; + panel?.toggle(config.enabled); + }); + } + + private createPanels(): void { + const panelsGrid = document.getElementById('panelsGrid')!; + + const mapContainer = document.getElementById('mapContainer') as HTMLElement; + this.ctx.map = new MapContainer(mapContainer, { + zoom: this.ctx.isMobile ? 2.5 : 1.0, + pan: { x: 0, y: 0 }, + view: this.ctx.isMobile ? 'mena' : 'global', + layers: this.ctx.mapLayers, + timeRange: '7d', + }); + + this.ctx.map.initEscalationGetters(); + this.ctx.currentTimeRange = this.ctx.map.getTimeRange(); + + const politicsPanel = new NewsPanel('politics', t('panels.politics')); + this.attachRelatedAssetHandlers(politicsPanel); + this.ctx.newsPanels['politics'] = politicsPanel; + this.ctx.panels['politics'] = politicsPanel; + + const techPanel = new NewsPanel('tech', t('panels.tech')); + this.attachRelatedAssetHandlers(techPanel); + this.ctx.newsPanels['tech'] = techPanel; + this.ctx.panels['tech'] = techPanel; + + const financePanel = new NewsPanel('finance', t('panels.finance')); + this.attachRelatedAssetHandlers(financePanel); + this.ctx.newsPanels['finance'] = financePanel; + this.ctx.panels['finance'] = financePanel; + + const heatmapPanel = new HeatmapPanel(); + this.ctx.panels['heatmap'] = heatmapPanel; + + const marketsPanel = new MarketPanel(); + this.ctx.panels['markets'] = marketsPanel; + + const monitorPanel = new MonitorPanel(this.ctx.monitors); + this.ctx.panels['monitors'] = monitorPanel; + monitorPanel.onChanged((monitors) => { + this.ctx.monitors = monitors; + saveToStorage(STORAGE_KEYS.monitors, monitors); + this.callbacks.updateMonitorResults(); + }); + + const commoditiesPanel = new CommoditiesPanel(); + this.ctx.panels['commodities'] = commoditiesPanel; + + const predictionPanel = new PredictionPanel(); + this.ctx.panels['polymarket'] = predictionPanel; + + const govPanel = new NewsPanel('gov', t('panels.gov')); + this.attachRelatedAssetHandlers(govPanel); + this.ctx.newsPanels['gov'] = govPanel; + this.ctx.panels['gov'] = govPanel; + + const intelPanel = new NewsPanel('intel', t('panels.intel')); + this.attachRelatedAssetHandlers(intelPanel); + this.ctx.newsPanels['intel'] = intelPanel; + this.ctx.panels['intel'] = intelPanel; + + const cryptoPanel = new CryptoPanel(); + this.ctx.panels['crypto'] = cryptoPanel; + + const middleeastPanel = new NewsPanel('middleeast', t('panels.middleeast')); + this.attachRelatedAssetHandlers(middleeastPanel); + this.ctx.newsPanels['middleeast'] = middleeastPanel; + this.ctx.panels['middleeast'] = middleeastPanel; + + const layoffsPanel = new NewsPanel('layoffs', t('panels.layoffs')); + this.attachRelatedAssetHandlers(layoffsPanel); + this.ctx.newsPanels['layoffs'] = layoffsPanel; + this.ctx.panels['layoffs'] = layoffsPanel; + + const aiPanel = new NewsPanel('ai', t('panels.ai')); + this.attachRelatedAssetHandlers(aiPanel); + this.ctx.newsPanels['ai'] = aiPanel; + this.ctx.panels['ai'] = aiPanel; + + const startupsPanel = new NewsPanel('startups', t('panels.startups')); + this.attachRelatedAssetHandlers(startupsPanel); + this.ctx.newsPanels['startups'] = startupsPanel; + this.ctx.panels['startups'] = startupsPanel; + + const vcblogsPanel = new NewsPanel('vcblogs', t('panels.vcblogs')); + this.attachRelatedAssetHandlers(vcblogsPanel); + this.ctx.newsPanels['vcblogs'] = vcblogsPanel; + this.ctx.panels['vcblogs'] = vcblogsPanel; + + const regionalStartupsPanel = new NewsPanel('regionalStartups', t('panels.regionalStartups')); + this.attachRelatedAssetHandlers(regionalStartupsPanel); + this.ctx.newsPanels['regionalStartups'] = regionalStartupsPanel; + this.ctx.panels['regionalStartups'] = regionalStartupsPanel; + + const unicornsPanel = new NewsPanel('unicorns', t('panels.unicorns')); + this.attachRelatedAssetHandlers(unicornsPanel); + this.ctx.newsPanels['unicorns'] = unicornsPanel; + this.ctx.panels['unicorns'] = unicornsPanel; + + const acceleratorsPanel = new NewsPanel('accelerators', t('panels.accelerators')); + this.attachRelatedAssetHandlers(acceleratorsPanel); + this.ctx.newsPanels['accelerators'] = acceleratorsPanel; + this.ctx.panels['accelerators'] = acceleratorsPanel; + + const fundingPanel = new NewsPanel('funding', t('panels.funding')); + this.attachRelatedAssetHandlers(fundingPanel); + this.ctx.newsPanels['funding'] = fundingPanel; + this.ctx.panels['funding'] = fundingPanel; + + const producthuntPanel = new NewsPanel('producthunt', t('panels.producthunt')); + this.attachRelatedAssetHandlers(producthuntPanel); + this.ctx.newsPanels['producthunt'] = producthuntPanel; + this.ctx.panels['producthunt'] = producthuntPanel; + + const securityPanel = new NewsPanel('security', t('panels.security')); + this.attachRelatedAssetHandlers(securityPanel); + this.ctx.newsPanels['security'] = securityPanel; + this.ctx.panels['security'] = securityPanel; + + const policyPanel = new NewsPanel('policy', t('panels.policy')); + this.attachRelatedAssetHandlers(policyPanel); + this.ctx.newsPanels['policy'] = policyPanel; + this.ctx.panels['policy'] = policyPanel; + + const hardwarePanel = new NewsPanel('hardware', t('panels.hardware')); + this.attachRelatedAssetHandlers(hardwarePanel); + this.ctx.newsPanels['hardware'] = hardwarePanel; + this.ctx.panels['hardware'] = hardwarePanel; + + const cloudPanel = new NewsPanel('cloud', t('panels.cloud')); + this.attachRelatedAssetHandlers(cloudPanel); + this.ctx.newsPanels['cloud'] = cloudPanel; + this.ctx.panels['cloud'] = cloudPanel; + + const devPanel = new NewsPanel('dev', t('panels.dev')); + this.attachRelatedAssetHandlers(devPanel); + this.ctx.newsPanels['dev'] = devPanel; + this.ctx.panels['dev'] = devPanel; + + const githubPanel = new NewsPanel('github', t('panels.github')); + this.attachRelatedAssetHandlers(githubPanel); + this.ctx.newsPanels['github'] = githubPanel; + this.ctx.panels['github'] = githubPanel; + + const ipoPanel = new NewsPanel('ipo', t('panels.ipo')); + this.attachRelatedAssetHandlers(ipoPanel); + this.ctx.newsPanels['ipo'] = ipoPanel; + this.ctx.panels['ipo'] = ipoPanel; + + const thinktanksPanel = new NewsPanel('thinktanks', t('panels.thinktanks')); + this.attachRelatedAssetHandlers(thinktanksPanel); + this.ctx.newsPanels['thinktanks'] = thinktanksPanel; + this.ctx.panels['thinktanks'] = thinktanksPanel; + + const economicPanel = new EconomicPanel(); + this.ctx.panels['economic'] = economicPanel; + + if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') { + const tradePolicyPanel = new TradePolicyPanel(); + this.ctx.panels['trade-policy'] = tradePolicyPanel; + + const supplyChainPanel = new SupplyChainPanel(); + this.ctx.panels['supply-chain'] = supplyChainPanel; + } + + const africaPanel = new NewsPanel('africa', t('panels.africa')); + this.attachRelatedAssetHandlers(africaPanel); + this.ctx.newsPanels['africa'] = africaPanel; + this.ctx.panels['africa'] = africaPanel; + + const latamPanel = new NewsPanel('latam', t('panels.latam')); + this.attachRelatedAssetHandlers(latamPanel); + this.ctx.newsPanels['latam'] = latamPanel; + this.ctx.panels['latam'] = latamPanel; + + const asiaPanel = new NewsPanel('asia', t('panels.asia')); + this.attachRelatedAssetHandlers(asiaPanel); + this.ctx.newsPanels['asia'] = asiaPanel; + this.ctx.panels['asia'] = asiaPanel; + + const energyPanel = new NewsPanel('energy', t('panels.energy')); + this.attachRelatedAssetHandlers(energyPanel); + this.ctx.newsPanels['energy'] = energyPanel; + this.ctx.panels['energy'] = energyPanel; + + for (const key of Object.keys(FEEDS)) { + if (this.ctx.newsPanels[key]) continue; + if (!Array.isArray((FEEDS as Record)[key])) continue; + const panelKey = this.ctx.panels[key] && !this.ctx.newsPanels[key] ? `${key}-news` : key; + if (this.ctx.panels[panelKey]) continue; + const panelConfig = DEFAULT_PANELS[panelKey] ?? DEFAULT_PANELS[key]; + const label = panelConfig?.name ?? key.charAt(0).toUpperCase() + key.slice(1); + const panel = new NewsPanel(panelKey, label); + this.attachRelatedAssetHandlers(panel); + this.ctx.newsPanels[key] = panel; + this.ctx.panels[panelKey] = panel; + } + + if (SITE_VARIANT === 'full') { + const gdeltIntelPanel = new GdeltIntelPanel(); + this.ctx.panels['gdelt-intel'] = gdeltIntelPanel; + + const ciiPanel = new CIIPanel(); + ciiPanel.setShareStoryHandler((code, name) => { + this.callbacks.openCountryStory(code, name); + }); + this.ctx.panels['cii'] = ciiPanel; + + const cascadePanel = new CascadePanel(); + this.ctx.panels['cascade'] = cascadePanel; + + const satelliteFiresPanel = new SatelliteFiresPanel(); + this.ctx.panels['satellite-fires'] = satelliteFiresPanel; + + const strategicRiskPanel = new StrategicRiskPanel(); + strategicRiskPanel.setLocationClickHandler((lat, lon) => { + this.ctx.map?.setCenter(lat, lon, 4); + }); + this.ctx.panels['strategic-risk'] = strategicRiskPanel; + + const strategicPosturePanel = new StrategicPosturePanel(); + strategicPosturePanel.setLocationClickHandler((lat, lon) => { + console.log('[App] StrategicPosture handler called:', { lat, lon, hasMap: !!this.ctx.map }); + this.ctx.map?.setCenter(lat, lon, 4); + }); + this.ctx.panels['strategic-posture'] = strategicPosturePanel; + + const ucdpEventsPanel = new UcdpEventsPanel(); + ucdpEventsPanel.setEventClickHandler((lat, lon) => { + this.ctx.map?.setCenter(lat, lon, 5); + }); + this.ctx.panels['ucdp-events'] = ucdpEventsPanel; + + const displacementPanel = new DisplacementPanel(); + displacementPanel.setCountryClickHandler((lat, lon) => { + this.ctx.map?.setCenter(lat, lon, 4); + }); + this.ctx.panels['displacement'] = displacementPanel; + + const climatePanel = new ClimateAnomalyPanel(); + climatePanel.setZoneClickHandler((lat, lon) => { + this.ctx.map?.setCenter(lat, lon, 4); + }); + this.ctx.panels['climate'] = climatePanel; + + const populationExposurePanel = new PopulationExposurePanel(); + this.ctx.panels['population-exposure'] = populationExposurePanel; + } + + if (SITE_VARIANT === 'finance') { + const investmentsPanel = new InvestmentsPanel((inv) => { + focusInvestmentOnMap(this.ctx.map, this.ctx.mapLayers, inv.lat, inv.lon); + }); + this.ctx.panels['gcc-investments'] = investmentsPanel; + } + + if (SITE_VARIANT !== 'happy') { + const liveNewsPanel = new LiveNewsPanel(); + this.ctx.panels['live-news'] = liveNewsPanel; + + const liveWebcamsPanel = new LiveWebcamsPanel(); + this.ctx.panels['live-webcams'] = liveWebcamsPanel; + + this.ctx.panels['events'] = new TechEventsPanel('events'); + + const serviceStatusPanel = new ServiceStatusPanel(); + this.ctx.panels['service-status'] = serviceStatusPanel; + + const techReadinessPanel = new TechReadinessPanel(); + this.ctx.panels['tech-readiness'] = techReadinessPanel; + + this.ctx.panels['macro-signals'] = new MacroSignalsPanel(); + this.ctx.panels['etf-flows'] = new ETFFlowsPanel(); + this.ctx.panels['stablecoins'] = new StablecoinPanel(); + } + + if (this.ctx.isDesktopApp) { + const runtimeConfigPanel = new RuntimeConfigPanel({ mode: 'alert' }); + this.ctx.panels['runtime-config'] = runtimeConfigPanel; + } + + const insightsPanel = new InsightsPanel(); + this.ctx.panels['insights'] = insightsPanel; + + // Global Giving panel (all variants) + this.ctx.panels['giving'] = new GivingPanel(); + + // Happy variant panels + if (SITE_VARIANT === 'happy') { + this.ctx.positivePanel = new PositiveNewsFeedPanel(); + this.ctx.panels['positive-feed'] = this.ctx.positivePanel; + + this.ctx.countersPanel = new CountersPanel(); + this.ctx.panels['counters'] = this.ctx.countersPanel; + this.ctx.countersPanel.startTicking(); + + this.ctx.progressPanel = new ProgressChartsPanel(); + this.ctx.panels['progress'] = this.ctx.progressPanel; + + this.ctx.breakthroughsPanel = new BreakthroughsTickerPanel(); + this.ctx.panels['breakthroughs'] = this.ctx.breakthroughsPanel; + + this.ctx.heroPanel = new HeroSpotlightPanel(); + this.ctx.panels['spotlight'] = this.ctx.heroPanel; + this.ctx.heroPanel.onLocationRequest = (lat: number, lon: number) => { + this.ctx.map?.setCenter(lat, lon, 4); + this.ctx.map?.flashLocation(lat, lon, 3000); + }; + + this.ctx.digestPanel = new GoodThingsDigestPanel(); + this.ctx.panels['digest'] = this.ctx.digestPanel; + + this.ctx.speciesPanel = new SpeciesComebackPanel(); + this.ctx.panels['species'] = this.ctx.speciesPanel; + + this.ctx.renewablePanel = new RenewableEnergyPanel(); + this.ctx.panels['renewable'] = this.ctx.renewablePanel; + } + + const defaultOrder = Object.keys(DEFAULT_PANELS).filter(k => k !== 'map'); + const savedOrder = this.getSavedPanelOrder(); + let panelOrder = defaultOrder; + if (savedOrder.length > 0) { + const missing = defaultOrder.filter(k => !savedOrder.includes(k)); + const valid = savedOrder.filter(k => defaultOrder.includes(k)); + const monitorsIdx = valid.indexOf('monitors'); + if (monitorsIdx !== -1) valid.splice(monitorsIdx, 1); + const insertIdx = valid.indexOf('politics') + 1 || 0; + const newPanels = missing.filter(k => k !== 'monitors'); + valid.splice(insertIdx, 0, ...newPanels); + if (SITE_VARIANT !== 'happy') { + valid.push('monitors'); + } + panelOrder = valid; + } + + if (SITE_VARIANT !== 'happy') { + const liveNewsIdx = panelOrder.indexOf('live-news'); + if (liveNewsIdx > 0) { + panelOrder.splice(liveNewsIdx, 1); + panelOrder.unshift('live-news'); + } + + const webcamsIdx = panelOrder.indexOf('live-webcams'); + if (webcamsIdx !== -1 && webcamsIdx !== panelOrder.indexOf('live-news') + 1) { + panelOrder.splice(webcamsIdx, 1); + const afterNews = panelOrder.indexOf('live-news') + 1; + panelOrder.splice(afterNews, 0, 'live-webcams'); + } + } + + if (this.ctx.isDesktopApp) { + const runtimeIdx = panelOrder.indexOf('runtime-config'); + if (runtimeIdx > 1) { + panelOrder.splice(runtimeIdx, 1); + panelOrder.splice(1, 0, 'runtime-config'); + } else if (runtimeIdx === -1) { + panelOrder.splice(1, 0, 'runtime-config'); + } + } + + panelOrder.forEach((key: string) => { + const panel = this.ctx.panels[key]; + if (panel) { + const el = panel.getElement(); + this.makeDraggable(el, key); + panelsGrid.appendChild(el); + } + }); + + this.ctx.map.onTimeRangeChanged((range) => { + this.ctx.currentTimeRange = range; + this.applyTimeRangeFilterDebounced(); + }); + + this.applyPanelSettings(); + this.applyInitialUrlState(); + } + + private applyTimeRangeFilterToNewsPanels(): void { + Object.entries(this.ctx.newsByCategory).forEach(([category, items]) => { + const panel = this.ctx.newsPanels[category]; + if (!panel) return; + const filtered = this.filterItemsByTimeRange(items); + if (filtered.length === 0 && items.length > 0) { + panel.renderFilteredEmpty(`No items in ${this.getTimeRangeLabel()}`); + return; + } + panel.renderNews(filtered); + }); + } + + private filterItemsByTimeRange(items: import('@/types').NewsItem[], range: import('@/components').TimeRange = this.ctx.currentTimeRange): import('@/types').NewsItem[] { + if (range === 'all') return items; + const ranges: Record = { + '1h': 60 * 60 * 1000, '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, '48h': 48 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, 'all': Infinity, + }; + const cutoff = Date.now() - (ranges[range] ?? Infinity); + return items.filter((item) => { + const ts = item.pubDate instanceof Date ? item.pubDate.getTime() : new Date(item.pubDate).getTime(); + return Number.isFinite(ts) ? ts >= cutoff : true; + }); + } + + private getTimeRangeLabel(): string { + const labels: Record = { + '1h': 'the last hour', '6h': 'the last 6 hours', + '24h': 'the last 24 hours', '48h': 'the last 48 hours', + '7d': 'the last 7 days', 'all': 'all time', + }; + return labels[this.ctx.currentTimeRange] ?? 'the last 7 days'; + } + + private applyInitialUrlState(): void { + if (!this.ctx.initialUrlState || !this.ctx.map) return; + + const { view, zoom, lat, lon, timeRange, layers } = this.ctx.initialUrlState; + + if (view) { + this.ctx.map.setView(view); + } + + if (timeRange) { + this.ctx.map.setTimeRange(timeRange); + } + + if (layers) { + this.ctx.mapLayers = layers; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map.setLayers(layers); + } + + if (!view) { + if (zoom !== undefined) { + this.ctx.map.setZoom(zoom); + } + if (lat !== undefined && lon !== undefined && zoom !== undefined && zoom > 2) { + this.ctx.map.setCenter(lat, lon); + } + } + + const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; + const currentView = this.ctx.map.getState().view; + if (regionSelect && currentView) { + regionSelect.value = currentView; + } + } + + private getSavedPanelOrder(): string[] { + try { + const saved = localStorage.getItem(this.ctx.PANEL_ORDER_KEY); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + } + + savePanelOrder(): void { + const grid = document.getElementById('panelsGrid'); + if (!grid) return; + const order = Array.from(grid.children) + .map((el) => (el as HTMLElement).dataset.panel) + .filter((key): key is string => !!key); + localStorage.setItem(this.ctx.PANEL_ORDER_KEY, JSON.stringify(order)); + } + + private attachRelatedAssetHandlers(panel: NewsPanel): void { + panel.setRelatedAssetHandlers({ + onRelatedAssetClick: (asset) => this.handleRelatedAssetClick(asset), + onRelatedAssetsFocus: (assets) => this.ctx.map?.highlightAssets(assets), + onRelatedAssetsClear: () => this.ctx.map?.highlightAssets(null), + }); + } + + private handleRelatedAssetClick(asset: RelatedAsset): void { + if (!this.ctx.map) return; + + switch (asset.type) { + case 'pipeline': + this.ctx.map.enableLayer('pipelines'); + this.ctx.mapLayers.pipelines = true; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map.triggerPipelineClick(asset.id); + break; + case 'cable': + this.ctx.map.enableLayer('cables'); + this.ctx.mapLayers.cables = true; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map.triggerCableClick(asset.id); + break; + case 'datacenter': + this.ctx.map.enableLayer('datacenters'); + this.ctx.mapLayers.datacenters = true; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map.triggerDatacenterClick(asset.id); + break; + case 'base': + this.ctx.map.enableLayer('bases'); + this.ctx.mapLayers.bases = true; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map.triggerBaseClick(asset.id); + break; + case 'nuclear': + this.ctx.map.enableLayer('nuclear'); + this.ctx.mapLayers.nuclear = true; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map.triggerNuclearClick(asset.id); + break; + } + } + + private makeDraggable(el: HTMLElement, key: string): void { + el.dataset.panel = key; + let isDragging = false; + let dragStarted = false; + let startX = 0; + let startY = 0; + let rafId = 0; + const DRAG_THRESHOLD = 8; + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (el.dataset.resizing === 'true') return; + if (target.classList?.contains('panel-resize-handle') || target.closest?.('.panel-resize-handle')) return; + if (target.closest('button, a, input, select, textarea, .panel-content')) return; + + isDragging = true; + dragStarted = false; + startX = e.clientX; + startY = e.clientY; + e.preventDefault(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + if (!dragStarted) { + const dx = Math.abs(e.clientX - startX); + const dy = Math.abs(e.clientY - startY); + if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return; + dragStarted = true; + el.classList.add('dragging'); + } + const cx = e.clientX; + const cy = e.clientY; + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + this.handlePanelDragMove(el, cx, cy); + rafId = 0; + }); + }; + + const onMouseUp = () => { + if (!isDragging) return; + isDragging = false; + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + if (dragStarted) { + el.classList.remove('dragging'); + this.savePanelOrder(); + } + dragStarted = false; + }; + + el.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + this.panelDragCleanupHandlers.push(() => { + el.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + isDragging = false; + dragStarted = false; + el.classList.remove('dragging'); + }); + } + + private handlePanelDragMove(dragging: HTMLElement, clientX: number, clientY: number): void { + const grid = document.getElementById('panelsGrid'); + if (!grid) return; + + dragging.style.pointerEvents = 'none'; + const target = document.elementFromPoint(clientX, clientY); + dragging.style.pointerEvents = ''; + + if (!target) return; + const targetPanel = target.closest('.panel') as HTMLElement | null; + if (!targetPanel || targetPanel === dragging || targetPanel.classList.contains('hidden')) return; + if (targetPanel.parentElement !== grid) return; + + const targetRect = targetPanel.getBoundingClientRect(); + const draggingRect = dragging.getBoundingClientRect(); + + const children = Array.from(grid.children); + const dragIdx = children.indexOf(dragging); + const targetIdx = children.indexOf(targetPanel); + if (dragIdx === -1 || targetIdx === -1) return; + + const sameRow = Math.abs(draggingRect.top - targetRect.top) < 30; + const targetMid = sameRow + ? targetRect.left + targetRect.width / 2 + : targetRect.top + targetRect.height / 2; + const cursorPos = sameRow ? clientX : clientY; + + if (dragIdx < targetIdx) { + if (cursorPos > targetMid) { + grid.insertBefore(dragging, targetPanel.nextSibling); + } + } else { + if (cursorPos < targetMid) { + grid.insertBefore(dragging, targetPanel); + } + } + } + + getLocalizedPanelName(panelKey: string, fallback: string): string { + if (panelKey === 'runtime-config') { + return t('modals.runtimeConfig.title'); + } + const key = panelKey.replace(/-([a-z])/g, (_match, group: string) => group.toUpperCase()); + const lookup = `panels.${key}`; + const localized = t(lookup); + return localized === lookup ? fallback : localized; + } + + getAllSourceNames(): string[] { + const sources = new Set(); + Object.values(FEEDS).forEach(feeds => { + if (feeds) feeds.forEach(f => sources.add(f.name)); + }); + INTEL_SOURCES.forEach(f => sources.add(f.name)); + return Array.from(sources).sort((a, b) => a.localeCompare(b)); + } +} diff --git a/src/app/refresh-scheduler.ts b/src/app/refresh-scheduler.ts new file mode 100644 index 000000000..a538e8228 --- /dev/null +++ b/src/app/refresh-scheduler.ts @@ -0,0 +1,108 @@ +import type { AppContext, AppModule } from '@/app/app-context'; + +export interface RefreshRegistration { + name: string; + fn: () => Promise; + intervalMs: number; + condition?: () => boolean; +} + +export class RefreshScheduler implements AppModule { + private ctx: AppContext; + private refreshTimeoutIds: Map> = new Map(); + private refreshRunners = new Map Promise; intervalMs: number }>(); + private hiddenSince = 0; + + constructor(ctx: AppContext) { + this.ctx = ctx; + } + + init(): void {} + + destroy(): void { + for (const timeoutId of this.refreshTimeoutIds.values()) { + clearTimeout(timeoutId); + } + this.refreshTimeoutIds.clear(); + this.refreshRunners.clear(); + } + + setHiddenSince(ts: number): void { + this.hiddenSince = ts; + } + + getHiddenSince(): number { + return this.hiddenSince; + } + + scheduleRefresh( + name: string, + fn: () => Promise, + intervalMs: number, + condition?: () => boolean + ): void { + const HIDDEN_REFRESH_MULTIPLIER = 4; + const JITTER_FRACTION = 0.1; + const MIN_REFRESH_MS = 1000; + const computeDelay = (baseMs: number, isHidden: boolean) => { + const adjusted = baseMs * (isHidden ? HIDDEN_REFRESH_MULTIPLIER : 1); + const jitterRange = adjusted * JITTER_FRACTION; + const jittered = adjusted + (Math.random() * 2 - 1) * jitterRange; + return Math.max(MIN_REFRESH_MS, Math.round(jittered)); + }; + const scheduleNext = (delay: number) => { + if (this.ctx.isDestroyed) return; + const timeoutId = setTimeout(run, delay); + this.refreshTimeoutIds.set(name, timeoutId); + }; + const run = async () => { + if (this.ctx.isDestroyed) return; + const isHidden = document.visibilityState === 'hidden'; + if (isHidden) { + scheduleNext(computeDelay(intervalMs, true)); + return; + } + if (condition && !condition()) { + scheduleNext(computeDelay(intervalMs, false)); + return; + } + if (this.ctx.inFlight.has(name)) { + scheduleNext(computeDelay(intervalMs, false)); + return; + } + this.ctx.inFlight.add(name); + try { + await fn(); + } catch (e) { + console.error(`[App] Refresh ${name} failed:`, e); + } finally { + this.ctx.inFlight.delete(name); + scheduleNext(computeDelay(intervalMs, false)); + } + }; + this.refreshRunners.set(name, { run, intervalMs }); + scheduleNext(computeDelay(intervalMs, document.visibilityState === 'hidden')); + } + + flushStaleRefreshes(): void { + if (!this.hiddenSince) return; + const hiddenMs = Date.now() - this.hiddenSince; + this.hiddenSince = 0; + + let stagger = 0; + for (const [name, { run, intervalMs }] of this.refreshRunners) { + if (hiddenMs < intervalMs) continue; + const pending = this.refreshTimeoutIds.get(name); + if (pending) clearTimeout(pending); + const delay = stagger; + stagger += 150; + this.refreshTimeoutIds.set(name, setTimeout(() => void run(), delay)); + } + } + + registerAll(registrations: RefreshRegistration[]): void { + for (const reg of registrations) { + this.scheduleRefresh(reg.name, reg.fn, reg.intervalMs, reg.condition); + } + } +} diff --git a/src/app/search-manager.ts b/src/app/search-manager.ts new file mode 100644 index 000000000..02f6f7b8d --- /dev/null +++ b/src/app/search-manager.ts @@ -0,0 +1,552 @@ +import type { AppContext, AppModule } from '@/app/app-context'; +import type { SearchResult } from '@/components/SearchModal'; +import type { NewsItem, MapLayers } from '@/types'; +import type { MapView } from '@/components'; +import type { Command } from '@/config/commands'; +import { SearchModal } from '@/components'; +import { CIIPanel } from '@/components'; +import { SITE_VARIANT, STORAGE_KEYS } from '@/config'; +import { LAYER_PRESETS, LAYER_KEY_MAP } from '@/config/commands'; +import { calculateCII, TIER1_COUNTRIES } from '@/services/country-instability'; +import { CURATED_COUNTRIES } from '@/config/countries'; +import { INTEL_HOTSPOTS, CONFLICT_ZONES, MILITARY_BASES, UNDERSEA_CABLES, NUCLEAR_FACILITIES } from '@/config/geo'; +import { PIPELINES } from '@/config/pipelines'; +import { AI_DATA_CENTERS } from '@/config/ai-datacenters'; +import { GAMMA_IRRADIATORS } from '@/config/irradiators'; +import { TECH_COMPANIES } from '@/config/tech-companies'; +import { AI_RESEARCH_LABS } from '@/config/ai-research-labs'; +import { STARTUP_ECOSYSTEMS } from '@/config/startup-ecosystems'; +import { TECH_HQS, ACCELERATORS } from '@/config/tech-geo'; +import { STOCK_EXCHANGES, FINANCIAL_CENTERS, CENTRAL_BANKS, COMMODITY_HUBS } from '@/config/finance-geo'; +import { trackSearchResultSelected, trackCountrySelected } from '@/services/analytics'; +import { t } from '@/services/i18n'; +import { saveToStorage, setTheme } from '@/utils'; +import { CountryIntelManager } from '@/app/country-intel'; + +export interface SearchManagerCallbacks { + openCountryBriefByCode: (code: string, country: string) => void; +} + +export class SearchManager implements AppModule { + private ctx: AppContext; + private callbacks: SearchManagerCallbacks; + private boundKeydownHandler: ((e: KeyboardEvent) => void) | null = null; + + constructor(ctx: AppContext, callbacks: SearchManagerCallbacks) { + this.ctx = ctx; + this.callbacks = callbacks; + } + + init(): void { + this.setupSearchModal(); + } + + destroy(): void { + if (this.boundKeydownHandler) { + document.removeEventListener('keydown', this.boundKeydownHandler); + this.boundKeydownHandler = null; + } + } + + private setupSearchModal(): void { + const searchOptions = SITE_VARIANT === 'tech' + ? { + placeholder: t('modals.search.placeholderTech'), + hint: t('modals.search.hintTech'), + } + : SITE_VARIANT === 'happy' + ? { + placeholder: 'Search or type a command...', + hint: 'Good News • Countries • Navigation • Settings', + } + : SITE_VARIANT === 'finance' + ? { + placeholder: t('modals.search.placeholderFinance'), + hint: t('modals.search.hintFinance'), + } + : { + placeholder: t('modals.search.placeholder'), + hint: t('modals.search.hint'), + }; + this.ctx.searchModal = new SearchModal(this.ctx.container, searchOptions); + + if (SITE_VARIANT === 'happy') { + // Happy variant: no geopolitical/military/infrastructure sources + } else if (SITE_VARIANT === 'tech') { + this.ctx.searchModal.registerSource('techcompany', TECH_COMPANIES.map(c => ({ + id: c.id, + title: c.name, + subtitle: `${c.sector} ${c.city} ${c.keyProducts?.join(' ') || ''}`.trim(), + data: c, + }))); + + this.ctx.searchModal.registerSource('ailab', AI_RESEARCH_LABS.map(l => ({ + id: l.id, + title: l.name, + subtitle: `${l.type} ${l.city} ${l.focusAreas?.join(' ') || ''}`.trim(), + data: l, + }))); + + this.ctx.searchModal.registerSource('startup', STARTUP_ECOSYSTEMS.map(s => ({ + id: s.id, + title: s.name, + subtitle: `${s.ecosystemTier} ${s.topSectors?.join(' ') || ''} ${s.notableStartups?.join(' ') || ''}`.trim(), + data: s, + }))); + + this.ctx.searchModal.registerSource('datacenter', AI_DATA_CENTERS.map(d => ({ + id: d.id, + title: d.name, + subtitle: `${d.owner} ${d.chipType || ''}`.trim(), + data: d, + }))); + + this.ctx.searchModal.registerSource('cable', UNDERSEA_CABLES.map(c => ({ + id: c.id, + title: c.name, + subtitle: c.major ? 'Major internet backbone' : 'Undersea cable', + data: c, + }))); + + this.ctx.searchModal.registerSource('techhq', TECH_HQS.map(h => ({ + id: h.id, + title: h.company, + subtitle: `${h.type === 'faang' ? 'Big Tech' : h.type === 'unicorn' ? 'Unicorn' : 'Public'} • ${h.city}, ${h.country}`, + data: h, + }))); + + this.ctx.searchModal.registerSource('accelerator', ACCELERATORS.map(a => ({ + id: a.id, + title: a.name, + subtitle: `${a.type} • ${a.city}, ${a.country}${a.notable ? ` • ${a.notable.slice(0, 2).join(', ')}` : ''}`, + data: a, + }))); + } else { + this.ctx.searchModal.registerSource('hotspot', INTEL_HOTSPOTS.map(h => ({ + id: h.id, + title: h.name, + subtitle: `${h.subtext || ''} ${h.keywords?.join(' ') || ''} ${h.description || ''}`.trim(), + data: h, + }))); + + this.ctx.searchModal.registerSource('conflict', CONFLICT_ZONES.map(c => ({ + id: c.id, + title: c.name, + subtitle: `${c.parties?.join(' ') || ''} ${c.keywords?.join(' ') || ''} ${c.description || ''}`.trim(), + data: c, + }))); + + this.ctx.searchModal.registerSource('base', MILITARY_BASES.map(b => ({ + id: b.id, + title: b.name, + subtitle: `${b.type} ${b.description || ''}`.trim(), + data: b, + }))); + + this.ctx.searchModal.registerSource('pipeline', PIPELINES.map(p => ({ + id: p.id, + title: p.name, + subtitle: `${p.type} ${p.operator || ''} ${p.countries?.join(' ') || ''}`.trim(), + data: p, + }))); + + this.ctx.searchModal.registerSource('cable', UNDERSEA_CABLES.map(c => ({ + id: c.id, + title: c.name, + subtitle: c.major ? 'Major cable' : '', + data: c, + }))); + + this.ctx.searchModal.registerSource('datacenter', AI_DATA_CENTERS.map(d => ({ + id: d.id, + title: d.name, + subtitle: `${d.owner} ${d.chipType || ''}`.trim(), + data: d, + }))); + + this.ctx.searchModal.registerSource('nuclear', NUCLEAR_FACILITIES.map(n => ({ + id: n.id, + title: n.name, + subtitle: `${n.type} ${n.operator || ''}`.trim(), + data: n, + }))); + + this.ctx.searchModal.registerSource('irradiator', GAMMA_IRRADIATORS.map(g => ({ + id: g.id, + title: `${g.city}, ${g.country}`, + subtitle: g.organization || '', + data: g, + }))); + } + + if (SITE_VARIANT === 'finance') { + this.ctx.searchModal.registerSource('exchange', STOCK_EXCHANGES.map(e => ({ + id: e.id, + title: `${e.shortName} - ${e.name}`, + subtitle: `${e.tier} • ${e.city}, ${e.country}${e.marketCap ? ` • $${e.marketCap}T` : ''}`, + data: e, + }))); + + this.ctx.searchModal.registerSource('financialcenter', FINANCIAL_CENTERS.map(f => ({ + id: f.id, + title: f.name, + subtitle: `${f.type} financial center${f.gfciRank ? ` • GFCI #${f.gfciRank}` : ''}${f.specialties ? ` • ${f.specialties.slice(0, 3).join(', ')}` : ''}`, + data: f, + }))); + + this.ctx.searchModal.registerSource('centralbank', CENTRAL_BANKS.map(b => ({ + id: b.id, + title: `${b.shortName} - ${b.name}`, + subtitle: `${b.type}${b.currency ? ` • ${b.currency}` : ''} • ${b.city}, ${b.country}`, + data: b, + }))); + + this.ctx.searchModal.registerSource('commodityhub', COMMODITY_HUBS.map(h => ({ + id: h.id, + title: h.name, + subtitle: `${h.type} • ${h.city}, ${h.country}${h.commodities ? ` • ${h.commodities.slice(0, 3).join(', ')}` : ''}`, + data: h, + }))); + } + + this.ctx.searchModal.registerSource('country', this.buildCountrySearchItems()); + + this.ctx.searchModal.setActivePanels(Object.keys(this.ctx.panels)); + this.ctx.searchModal.setOnSelect((result) => this.handleSearchResult(result)); + this.ctx.searchModal.setOnCommand((cmd) => this.handleCommand(cmd)); + + this.boundKeydownHandler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (this.ctx.searchModal?.isOpen()) { + this.ctx.searchModal.close(); + } else { + this.updateSearchIndex(); + this.ctx.searchModal?.open(); + } + } + }; + document.addEventListener('keydown', this.boundKeydownHandler); + } + + private handleSearchResult(result: SearchResult): void { + trackSearchResultSelected(result.type); + switch (result.type) { + case 'news': { + const item = result.data as NewsItem; + this.scrollToPanel('politics'); + this.highlightNewsItem(item.link); + break; + } + case 'hotspot': { + const hotspot = result.data as typeof INTEL_HOTSPOTS[0]; + this.ctx.map?.setView('global'); + setTimeout(() => { this.ctx.map?.triggerHotspotClick(hotspot.id); }, 300); + break; + } + case 'conflict': { + const conflict = result.data as typeof CONFLICT_ZONES[0]; + this.ctx.map?.setView('global'); + setTimeout(() => { this.ctx.map?.triggerConflictClick(conflict.id); }, 300); + break; + } + case 'market': { + this.scrollToPanel('markets'); + break; + } + case 'prediction': { + this.scrollToPanel('polymarket'); + break; + } + case 'base': { + const base = result.data as typeof MILITARY_BASES[0]; + this.ctx.map?.setView('global'); + setTimeout(() => { this.ctx.map?.triggerBaseClick(base.id); }, 300); + break; + } + case 'pipeline': { + const pipeline = result.data as typeof PIPELINES[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('pipelines'); + this.ctx.mapLayers.pipelines = true; + setTimeout(() => { this.ctx.map?.triggerPipelineClick(pipeline.id); }, 300); + break; + } + case 'cable': { + const cable = result.data as typeof UNDERSEA_CABLES[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('cables'); + this.ctx.mapLayers.cables = true; + setTimeout(() => { this.ctx.map?.triggerCableClick(cable.id); }, 300); + break; + } + case 'datacenter': { + const dc = result.data as typeof AI_DATA_CENTERS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('datacenters'); + this.ctx.mapLayers.datacenters = true; + setTimeout(() => { this.ctx.map?.triggerDatacenterClick(dc.id); }, 300); + break; + } + case 'nuclear': { + const nuc = result.data as typeof NUCLEAR_FACILITIES[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('nuclear'); + this.ctx.mapLayers.nuclear = true; + setTimeout(() => { this.ctx.map?.triggerNuclearClick(nuc.id); }, 300); + break; + } + case 'irradiator': { + const irr = result.data as typeof GAMMA_IRRADIATORS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('irradiators'); + this.ctx.mapLayers.irradiators = true; + setTimeout(() => { this.ctx.map?.triggerIrradiatorClick(irr.id); }, 300); + break; + } + case 'earthquake': + case 'outage': + this.ctx.map?.setView('global'); + break; + case 'techcompany': { + const company = result.data as typeof TECH_COMPANIES[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('techHQs'); + this.ctx.mapLayers.techHQs = true; + setTimeout(() => { this.ctx.map?.setCenter(company.lat, company.lon, 4); }, 300); + break; + } + case 'ailab': { + const lab = result.data as typeof AI_RESEARCH_LABS[0]; + this.ctx.map?.setView('global'); + setTimeout(() => { this.ctx.map?.setCenter(lab.lat, lab.lon, 4); }, 300); + break; + } + case 'startup': { + const ecosystem = result.data as typeof STARTUP_ECOSYSTEMS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('startupHubs'); + this.ctx.mapLayers.startupHubs = true; + setTimeout(() => { this.ctx.map?.setCenter(ecosystem.lat, ecosystem.lon, 4); }, 300); + break; + } + case 'techevent': + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('techEvents'); + this.ctx.mapLayers.techEvents = true; + break; + case 'techhq': { + const hq = result.data as typeof TECH_HQS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('techHQs'); + this.ctx.mapLayers.techHQs = true; + setTimeout(() => { this.ctx.map?.setCenter(hq.lat, hq.lon, 4); }, 300); + break; + } + case 'accelerator': { + const acc = result.data as typeof ACCELERATORS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('accelerators'); + this.ctx.mapLayers.accelerators = true; + setTimeout(() => { this.ctx.map?.setCenter(acc.lat, acc.lon, 4); }, 300); + break; + } + case 'exchange': { + const exchange = result.data as typeof STOCK_EXCHANGES[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('stockExchanges'); + this.ctx.mapLayers.stockExchanges = true; + setTimeout(() => { this.ctx.map?.setCenter(exchange.lat, exchange.lon, 4); }, 300); + break; + } + case 'financialcenter': { + const fc = result.data as typeof FINANCIAL_CENTERS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('financialCenters'); + this.ctx.mapLayers.financialCenters = true; + setTimeout(() => { this.ctx.map?.setCenter(fc.lat, fc.lon, 4); }, 300); + break; + } + case 'centralbank': { + const bank = result.data as typeof CENTRAL_BANKS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('centralBanks'); + this.ctx.mapLayers.centralBanks = true; + setTimeout(() => { this.ctx.map?.setCenter(bank.lat, bank.lon, 4); }, 300); + break; + } + case 'commodityhub': { + const hub = result.data as typeof COMMODITY_HUBS[0]; + this.ctx.map?.setView('global'); + this.ctx.map?.enableLayer('commodityHubs'); + this.ctx.mapLayers.commodityHubs = true; + setTimeout(() => { this.ctx.map?.setCenter(hub.lat, hub.lon, 4); }, 300); + break; + } + case 'country': { + const { code, name } = result.data as { code: string; name: string }; + trackCountrySelected(code, name, 'search'); + this.callbacks.openCountryBriefByCode(code, name); + break; + } + } + } + + private handleCommand(cmd: Command): void { + const colonIdx = cmd.id.indexOf(':'); + if (colonIdx === -1) return; + const category = cmd.id.slice(0, colonIdx); + const action = cmd.id.slice(colonIdx + 1); + + switch (category) { + case 'nav': + this.ctx.map?.setView(action as MapView); + { + const sel = document.getElementById('regionSelect') as HTMLSelectElement; + if (sel) sel.value = action; + } + break; + + case 'layers': { + if (action === 'all') { + for (const key of Object.keys(this.ctx.mapLayers)) + this.ctx.mapLayers[key as keyof MapLayers] = true; + } else if (action === 'none') { + for (const key of Object.keys(this.ctx.mapLayers)) + this.ctx.mapLayers[key as keyof MapLayers] = false; + } else { + const preset = LAYER_PRESETS[action]; + if (preset) { + for (const key of Object.keys(this.ctx.mapLayers)) + this.ctx.mapLayers[key as keyof MapLayers] = false; + for (const layer of preset) + this.ctx.mapLayers[layer] = true; + } + } + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + this.ctx.map?.setLayers(this.ctx.mapLayers); + break; + } + + case 'layer': { + const layerKey = (LAYER_KEY_MAP[action] || action) as keyof MapLayers; + if (!(layerKey in this.ctx.mapLayers)) return; + this.ctx.mapLayers[layerKey] = !this.ctx.mapLayers[layerKey]; + saveToStorage(STORAGE_KEYS.mapLayers, this.ctx.mapLayers); + if (this.ctx.mapLayers[layerKey]) { + this.ctx.map?.enableLayer(layerKey); + } else { + this.ctx.map?.setLayers(this.ctx.mapLayers); + } + break; + } + + case 'panel': + this.scrollToPanel(action); + break; + + case 'view': + if (action === 'dark' || action === 'light') { + setTheme(action); + } else if (action === 'fullscreen') { + if (document.fullscreenElement) { + try { void document.exitFullscreen()?.catch(() => {}); } catch {} + } else { + const el = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => void }; + if (el.requestFullscreen) { + try { void el.requestFullscreen()?.catch(() => {}); } catch {} + } else if (el.webkitRequestFullscreen) { + try { el.webkitRequestFullscreen(); } catch {} + } + } + } else if (action === 'settings') { + this.ctx.unifiedSettings?.open(); + } else if (action === 'refresh') { + window.location.reload(); + } + break; + + case 'time': + this.ctx.map?.setTimeRange(action as import('@/components').TimeRange); + break; + + case 'country': { + const name = TIER1_COUNTRIES[action] + || CURATED_COUNTRIES[action]?.name + || new Intl.DisplayNames(['en'], { type: 'region' }).of(action) + || action; + trackCountrySelected(action, name, 'command'); + this.callbacks.openCountryBriefByCode(action, name); + break; + } + } + } + + private scrollToPanel(panelId: string): void { + const panel = document.querySelector(`[data-panel="${panelId}"]`); + if (panel) { + panel.scrollIntoView({ behavior: 'smooth', block: 'center' }); + panel.classList.add('flash-highlight'); + setTimeout(() => panel.classList.remove('flash-highlight'), 1500); + } + } + + private highlightNewsItem(itemId: string): void { + setTimeout(() => { + const item = document.querySelector(`[data-news-id="${CSS.escape(itemId)}"]`); + if (item) { + item.scrollIntoView({ behavior: 'smooth', block: 'center' }); + item.classList.add('flash-highlight'); + setTimeout(() => item.classList.remove('flash-highlight'), 1500); + } + }, 100); + } + + updateSearchIndex(): void { + if (!this.ctx.searchModal) return; + + this.ctx.searchModal.registerSource('country', this.buildCountrySearchItems()); + + const newsItems = this.ctx.allNews.slice(0, 500).map(n => ({ + id: n.link, + title: n.title, + subtitle: n.source, + data: n, + })); + console.log(`[Search] Indexing ${newsItems.length} news items (allNews total: ${this.ctx.allNews.length})`); + this.ctx.searchModal.registerSource('news', newsItems); + + if (this.ctx.latestPredictions.length > 0) { + this.ctx.searchModal.registerSource('prediction', this.ctx.latestPredictions.map(p => ({ + id: p.title, + title: p.title, + subtitle: `${Math.round(p.yesPrice)}% probability`, + data: p, + }))); + } + + if (this.ctx.latestMarkets.length > 0) { + this.ctx.searchModal.registerSource('market', this.ctx.latestMarkets.map(m => ({ + id: m.symbol, + title: `${m.symbol} - ${m.name}`, + subtitle: `$${m.price?.toFixed(2) || 'N/A'}`, + data: m, + }))); + } + } + + private buildCountrySearchItems(): { id: string; title: string; subtitle: string; data: { code: string; name: string } }[] { + const panelScores = (this.ctx.panels['cii'] as CIIPanel | undefined)?.getScores() ?? []; + const scores = panelScores.length > 0 ? panelScores : calculateCII(); + const ciiByCode = new Map(scores.map((score) => [score.code, score])); + return Object.entries(TIER1_COUNTRIES).map(([code, name]) => { + const score = ciiByCode.get(code); + return { + id: code, + title: `${CountryIntelManager.toFlagEmoji(code)} ${name}`, + subtitle: score ? `CII: ${score.score}/100 • ${score.level}` : 'Country Brief', + data: { code, name }, + }; + }); + } +} diff --git a/src/bootstrap/chunk-reload.ts b/src/bootstrap/chunk-reload.ts new file mode 100644 index 000000000..3519877d9 --- /dev/null +++ b/src/bootstrap/chunk-reload.ts @@ -0,0 +1,43 @@ +interface EventTargetLike { + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; +} + +interface StorageLike { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; +} + +interface ChunkReloadGuardOptions { + eventTarget?: EventTargetLike; + storage?: StorageLike; + eventName?: string; + reload?: () => void; +} + +export function buildChunkReloadStorageKey(version: string): string { + return `wm-chunk-reload:${version}`; +} + +export function installChunkReloadGuard( + version: string, + options: ChunkReloadGuardOptions = {} +): string { + const storageKey = buildChunkReloadStorageKey(version); + const eventName = options.eventName ?? 'vite:preloadError'; + const eventTarget = options.eventTarget ?? window; + const storage = options.storage ?? sessionStorage; + const reload = options.reload ?? (() => window.location.reload()); + + eventTarget.addEventListener(eventName, () => { + if (storage.getItem(storageKey)) return; + storage.setItem(storageKey, '1'); + reload(); + }); + + return storageKey; +} + +export function clearChunkReloadGuard(storageKey: string, storage: StorageLike = sessionStorage): void { + storage.removeItem(storageKey); +} diff --git a/src/components/BreakthroughsTickerPanel.ts b/src/components/BreakthroughsTickerPanel.ts new file mode 100644 index 000000000..e12e25b94 --- /dev/null +++ b/src/components/BreakthroughsTickerPanel.ts @@ -0,0 +1,77 @@ +import { Panel } from './Panel'; +import type { NewsItem } from '@/types'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; + +/** + * BreakthroughsTickerPanel -- Horizontally scrolling ticker of science breakthroughs. + * + * Displays a continuously scrolling strip of science news items. The animation + * is driven entirely by CSS (added in plan 06-03). The JS builds the DOM with + * doubled content for seamless infinite scroll. Hover-pause and tab-hidden + * pause are handled by CSS (:hover rule and .animations-paused body class). + */ +export class BreakthroughsTickerPanel extends Panel { + private tickerTrack: HTMLElement | null = null; + + constructor() { + super({ id: 'breakthroughs', title: 'Breakthroughs', trackActivity: false }); + this.createTickerDOM(); + } + + /** + * Create the ticker wrapper and track elements. + */ + private createTickerDOM(): void { + const wrapper = document.createElement('div'); + wrapper.className = 'breakthroughs-ticker-wrapper'; + + const track = document.createElement('div'); + track.className = 'breakthroughs-ticker-track'; + + wrapper.appendChild(track); + this.tickerTrack = track; + + // Clear loading state and append the ticker + this.content.innerHTML = ''; + this.content.appendChild(wrapper); + } + + /** + * Receive science news items and populate the ticker track. + * Content is doubled for seamless infinite CSS scroll animation. + */ + public setItems(items: NewsItem[]): void { + if (!this.tickerTrack) return; + + if (items.length === 0) { + this.tickerTrack.innerHTML = + 'No science breakthroughs yet'; + return; + } + + // Build HTML for one set of items + const itemsHtml = items + .map( + (item) => + `` + + `${escapeHtml(item.source)}` + + `${escapeHtml(item.title)}` + + ``, + ) + .join(''); + + // Double the content for seamless infinite scroll + this.tickerTrack.innerHTML = itemsHtml + itemsHtml; + } + + /** + * Clean up animation and call parent destroy. + */ + public destroy(): void { + if (this.tickerTrack) { + this.tickerTrack.style.animationPlayState = 'paused'; + this.tickerTrack = null; + } + super.destroy(); + } +} diff --git a/src/components/CIIPanel.ts b/src/components/CIIPanel.ts new file mode 100644 index 000000000..8b257b3d0 --- /dev/null +++ b/src/components/CIIPanel.ts @@ -0,0 +1,135 @@ +import { Panel } from './Panel'; +import { getCSSColor } from '@/utils'; +import { calculateCII, type CountryScore } from '@/services/country-instability'; +import { t } from '../services/i18n'; +import { h, replaceChildren, rawHtml } from '@/utils/dom-utils'; + +export class CIIPanel extends Panel { + private scores: CountryScore[] = []; + private focalPointsReady = false; + private onShareStory?: (code: string, name: string) => void; + + constructor() { + super({ + id: 'cii', + title: t('panels.cii'), + infoTooltip: t('components.cii.infoTooltip'), + }); + this.showLoading(t('common.loading')); + } + + public setShareStoryHandler(handler: (code: string, name: string) => void): void { + this.onShareStory = handler; + } + + private getLevelColor(level: CountryScore['level']): string { + switch (level) { + case 'critical': return getCSSColor('--semantic-critical'); + case 'high': return getCSSColor('--semantic-high'); + case 'elevated': return getCSSColor('--semantic-elevated'); + case 'normal': return getCSSColor('--semantic-normal'); + case 'low': return getCSSColor('--semantic-low'); + } + } + + private getLevelEmoji(level: CountryScore['level']): string { + switch (level) { + case 'critical': return '🔴'; + case 'high': return '🟠'; + case 'elevated': return '🟡'; + case 'normal': return '🟢'; + case 'low': return '⚪'; + } + } + + private static readonly SHARE_SVG = ''; + + private buildTrendArrow(trend: CountryScore['trend'], change: number): HTMLElement { + if (trend === 'rising') return h('span', { className: 'trend-up' }, `↑${change > 0 ? change : ''}`); + if (trend === 'falling') return h('span', { className: 'trend-down' }, `↓${Math.abs(change)}`); + return h('span', { className: 'trend-stable' }, '→'); + } + + private buildCountry(country: CountryScore): HTMLElement { + const color = this.getLevelColor(country.level); + const emoji = this.getLevelEmoji(country.level); + + const shareBtn = h('button', { + className: 'cii-share-btn', + dataset: { code: country.code, name: country.name }, + title: t('common.shareStory'), + }); + shareBtn.appendChild(rawHtml(CIIPanel.SHARE_SVG)); + + return h('div', { className: 'cii-country', dataset: { code: country.code } }, + h('div', { className: 'cii-header' }, + h('span', { className: 'cii-emoji' }, emoji), + h('span', { className: 'cii-name' }, country.name), + h('span', { className: 'cii-score' }, String(country.score)), + this.buildTrendArrow(country.trend, country.change24h), + shareBtn, + ), + h('div', { className: 'cii-bar-container' }, + h('div', { className: 'cii-bar', style: `width: ${country.score}%; background: ${color};` }), + ), + h('div', { className: 'cii-components' }, + h('span', { title: t('common.unrest') }, `U:${country.components.unrest}`), + h('span', { title: t('common.conflict') }, `C:${country.components.conflict}`), + h('span', { title: t('common.security') }, `S:${country.components.security}`), + h('span', { title: t('common.information') }, `I:${country.components.information}`), + ), + ); + } + + private bindShareButtons(): void { + if (!this.onShareStory) return; + this.content.querySelectorAll('.cii-share-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const el = e.currentTarget as HTMLElement; + const code = el.dataset.code || ''; + const name = el.dataset.name || ''; + if (code && name) this.onShareStory!(code, name); + }); + }); + } + + public async refresh(forceLocal = false): Promise { + if (!this.focalPointsReady && !forceLocal) { + return; + } + + if (forceLocal) { + this.focalPointsReady = true; + console.log('[CIIPanel] Focal points ready, calculating scores...'); + } + + this.showLoading(); + + try { + const localScores = calculateCII(); + const localWithData = localScores.filter(s => s.score > 0).length; + this.scores = localScores; + console.log(`[CIIPanel] Calculated ${localWithData} countries with focal point intelligence`); + + const withData = this.scores.filter(s => s.score > 0); + this.setCount(withData.length); + + if (withData.length === 0) { + replaceChildren(this.content, h('div', { className: 'empty-state' }, t('components.cii.noSignals'))); + return; + } + + const listEl = h('div', { className: 'cii-list' }, ...withData.map(s => this.buildCountry(s))); + replaceChildren(this.content, listEl); + this.bindShareButtons(); + } catch (error) { + console.error('[CIIPanel] Refresh error:', error); + this.showError(t('common.failedCII')); + } + } + + public getScores(): CountryScore[] { + return this.scores; + } +} diff --git a/src/components/CascadePanel.ts b/src/components/CascadePanel.ts new file mode 100644 index 000000000..46f21bd1e --- /dev/null +++ b/src/components/CascadePanel.ts @@ -0,0 +1,270 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { getCSSColor } from '@/utils'; +import { + buildDependencyGraph, + calculateCascade, + getGraphStats, + clearGraphCache, + type DependencyGraph, +} from '@/services/infrastructure-cascade'; +import type { CascadeResult, CascadeImpactLevel, InfrastructureNode } from '@/types'; + +type NodeFilter = 'all' | 'cable' | 'pipeline' | 'port' | 'chokepoint'; + +export class CascadePanel extends Panel { + private graph: DependencyGraph | null = null; + private selectedNode: string | null = null; + private cascadeResult: CascadeResult | null = null; + private filter: NodeFilter = 'cable'; + private onSelectCallback: ((nodeId: string | null) => void) | null = null; + + constructor() { + super({ + id: 'cascade', + title: t('panels.cascade'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.cascade.infoTooltip'), + }); + this.init(); + } + + private async init(): Promise { + this.showLoading(); + try { + this.graph = buildDependencyGraph(); + const stats = getGraphStats(); + this.setCount(stats.nodes); + this.render(); + } catch (error) { + console.error('[CascadePanel] Init error:', error); + this.showError(t('common.failedDependencyGraph')); + } + } + + private getImpactColor(level: CascadeImpactLevel): string { + switch (level) { + case 'critical': return getCSSColor('--semantic-critical'); + case 'high': return getCSSColor('--semantic-high'); + case 'medium': return getCSSColor('--semantic-elevated'); + case 'low': return getCSSColor('--semantic-normal'); + } + } + + private getImpactEmoji(level: CascadeImpactLevel): string { + switch (level) { + case 'critical': return '🔴'; + case 'high': return '🟠'; + case 'medium': return '🟡'; + case 'low': return '🟢'; + } + } + + private getNodeTypeEmoji(type: string): string { + switch (type) { + case 'cable': return '🔌'; + case 'pipeline': return '🛢️'; + case 'port': return '⚓'; + case 'chokepoint': return '🚢'; + case 'country': return '🏳️'; + default: return '📍'; + } + } + + private getFilterLabel(filter: Exclude): string { + const labels: Record, string> = { + cable: t('components.cascade.filters.cables'), + pipeline: t('components.cascade.filters.pipelines'), + port: t('components.cascade.filters.ports'), + chokepoint: t('components.cascade.filters.chokepoints'), + }; + return labels[filter]; + } + + private getFilteredNodes(): InfrastructureNode[] { + if (!this.graph) return []; + const nodes: InfrastructureNode[] = []; + for (const node of this.graph.nodes.values()) { + if (this.filter === 'all' || node.type === this.filter) { + if (node.type !== 'country') { + nodes.push(node); + } + } + } + return nodes.sort((a, b) => a.name.localeCompare(b.name)); + } + + private renderSelector(): string { + const nodes = this.getFilteredNodes(); + const filterButtons = ['cable', 'pipeline', 'port', 'chokepoint'].map((f) => + `` + ).join(''); + + const nodeOptions = nodes.map(n => + `` + ).join(''); + const selectedType = t(`components.cascade.filterType.${this.filter}`); + + return ` +
+
${filterButtons}
+ + +
+ `; + } + + private renderCascadeResult(): string { + if (!this.cascadeResult) return ''; + + const { source, countriesAffected, redundancies } = this.cascadeResult; + + const countriesHtml = countriesAffected.length > 0 + ? countriesAffected.map(c => ` +
+ ${this.getImpactEmoji(c.impactLevel)} + ${escapeHtml(c.countryName)} + ${t(`components.cascade.impactLevels.${c.impactLevel}`)} + ${c.affectedCapacity > 0 ? `${t('components.cascade.capacityPercent', { percent: String(Math.round(c.affectedCapacity * 100)) })}` : ''} +
+ `).join('') + : `
${t('components.cascade.noCountryImpacts')}
`; + + const redundanciesHtml = redundancies && redundancies.length > 0 + ? ` +
+
${t('components.cascade.alternativeRoutes')}
+ ${redundancies.map(r => ` +
+ ${escapeHtml(r.name)} + ${Math.round(r.capacityShare * 100)}% +
+ `).join('')} +
+ ` + : ''; + + return ` +
+
+ ${this.getNodeTypeEmoji(source.type)} + ${escapeHtml(source.name)} + ${t(`components.cascade.filterType.${source.type}`)} +
+
+
${t('components.cascade.countriesAffected', { count: String(countriesAffected.length) })}
+
${countriesHtml}
+
+ ${redundanciesHtml} +
+ `; + } + + private render(): void { + if (!this.graph) { + this.showLoading(); + return; + } + + const stats = getGraphStats(); + const statsHtml = ` +
+ 🔌 ${stats.cables} + 🛢️ ${stats.pipelines} + ⚓ ${stats.ports} + 🌊 ${stats.chokepoints} + 🏳️ ${stats.countries} + 📊 ${stats.edges} ${t('components.cascade.links')} +
+ `; + + this.content.innerHTML = ` +
+ ${statsHtml} + ${this.renderSelector()} + ${this.cascadeResult ? this.renderCascadeResult() : `
${t('components.cascade.selectInfrastructureHint')}
`} +
+ `; + + this.attachEventListeners(); + } + + private attachEventListeners(): void { + const filterBtns = this.content.querySelectorAll('.cascade-filter-btn'); + filterBtns.forEach(btn => { + btn.addEventListener('click', () => { + this.filter = btn.getAttribute('data-filter') as NodeFilter; + this.selectedNode = null; + this.cascadeResult = null; + this.render(); + }); + }); + + const select = this.content.querySelector('.cascade-select') as HTMLSelectElement; + if (select) { + select.addEventListener('change', () => { + this.selectedNode = select.value || null; + this.cascadeResult = null; + if (this.onSelectCallback) { + this.onSelectCallback(this.selectedNode); + } + this.render(); + }); + } + + const analyzeBtn = this.content.querySelector('.cascade-analyze-btn'); + if (analyzeBtn) { + analyzeBtn.addEventListener('click', () => this.runAnalysis()); + } + } + + private runAnalysis(): void { + if (!this.selectedNode) return; + + this.cascadeResult = calculateCascade(this.selectedNode); + this.render(); + + if (this.onSelectCallback) { + this.onSelectCallback(this.selectedNode); + } + } + + public selectNode(nodeId: string): void { + this.selectedNode = nodeId; + const nodeType = nodeId.split(':')[0] as NodeFilter; + if (['cable', 'pipeline', 'port', 'chokepoint'].includes(nodeType)) { + this.filter = nodeType; + } + this.runAnalysis(); + } + + public onSelect(callback: (nodeId: string | null) => void): void { + this.onSelectCallback = callback; + } + + public getSelectedNode(): string | null { + return this.selectedNode; + } + + public getCascadeResult(): CascadeResult | null { + return this.cascadeResult; + } + + public refresh(): void { + clearGraphCache(); + this.graph = null; + this.cascadeResult = null; + this.init(); + } +} diff --git a/src/components/ClimateAnomalyPanel.ts b/src/components/ClimateAnomalyPanel.ts new file mode 100644 index 000000000..09a3cbd5d --- /dev/null +++ b/src/components/ClimateAnomalyPanel.ts @@ -0,0 +1,81 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { type ClimateAnomaly, getSeverityIcon, formatDelta } from '@/services/climate'; +import { t } from '@/services/i18n'; + +export class ClimateAnomalyPanel extends Panel { + private anomalies: ClimateAnomaly[] = []; + private onZoneClick?: (lat: number, lon: number) => void; + + constructor() { + super({ + id: 'climate', + title: t('panels.climate'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.climate.infoTooltip'), + }); + this.showLoading(t('common.loadingClimateData')); + } + + public setZoneClickHandler(handler: (lat: number, lon: number) => void): void { + this.onZoneClick = handler; + } + + public setAnomalies(anomalies: ClimateAnomaly[]): void { + this.anomalies = anomalies; + this.setCount(anomalies.length); + this.renderContent(); + } + + private renderContent(): void { + if (this.anomalies.length === 0) { + this.setContent(`
${t('components.climate.noAnomalies')}
`); + return; + } + + const sorted = [...this.anomalies].sort((a, b) => { + const severityOrder = { extreme: 0, moderate: 1, normal: 2 }; + return (severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2); + }); + + const rows = sorted.map(a => { + const icon = getSeverityIcon(a); + const tempClass = a.tempDelta > 0 ? 'climate-warm' : 'climate-cold'; + const precipClass = a.precipDelta > 0 ? 'climate-wet' : 'climate-dry'; + const sevClass = `severity-${a.severity}`; + const rowClass = a.severity === 'extreme' ? ' climate-extreme-row' : ''; + + return ` + ${icon}${escapeHtml(a.zone)} + ${formatDelta(a.tempDelta, '°C')} + ${formatDelta(a.precipDelta, 'mm')} + ${t(`components.climate.severity.${a.severity}`)} + `; + }).join(''); + + this.setContent(` +
+ + + + + + + + + + ${rows} +
${t('components.climate.zone')}${t('components.climate.temp')}${t('components.climate.precip')}${t('components.climate.severityLabel')}
+
+ `); + + this.content.querySelectorAll('.climate-row').forEach(el => { + el.addEventListener('click', () => { + const lat = Number((el as HTMLElement).dataset.lat); + const lon = Number((el as HTMLElement).dataset.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) this.onZoneClick?.(lat, lon); + }); + }); + } +} diff --git a/src/components/CommunityWidget.ts b/src/components/CommunityWidget.ts new file mode 100644 index 000000000..ff2fb3528 --- /dev/null +++ b/src/components/CommunityWidget.ts @@ -0,0 +1,35 @@ +import { t } from '@/services/i18n'; + +const DISMISSED_KEY = 'wm-community-dismissed'; +const DISCUSSION_URL = 'https://github.com/koala73/worldmonitor/discussions/94'; + +export function mountCommunityWidget(): void { + if (localStorage.getItem(DISMISSED_KEY) === 'true') return; + if (document.querySelector('.community-widget')) return; + + const widget = document.createElement('div'); + widget.className = 'community-widget'; + widget.innerHTML = ` +
+
+ ${t('components.community.joinDiscussion')} + ${t('components.community.openDiscussion')} + +
+ + `; + + const dismiss = () => { + widget.classList.add('cw-hiding'); + setTimeout(() => widget.remove(), 300); + }; + + widget.querySelector('.cw-close')!.addEventListener('click', dismiss); + + widget.querySelector('.cw-dismiss')!.addEventListener('click', () => { + localStorage.setItem(DISMISSED_KEY, 'true'); + dismiss(); + }); + + document.body.appendChild(widget); +} diff --git a/src/components/CountersPanel.ts b/src/components/CountersPanel.ts new file mode 100644 index 000000000..a1d2befee --- /dev/null +++ b/src/components/CountersPanel.ts @@ -0,0 +1,121 @@ +import { Panel } from './Panel'; +import { + COUNTER_METRICS, + getCounterValue, + formatCounterValue, + type CounterMetric, +} from '@/services/humanity-counters'; + +/** + * CountersPanel -- Worldometer-style ticking counters showing positive global metrics. + * + * Displays 6 metrics (births, trees, vaccines, graduates, books, renewable MW) + * with values ticking at 60fps via requestAnimationFrame. Values are calculated + * from absolute time (seconds since midnight UTC * per-second rate) to avoid + * drift across tabs, throttling, or background suspension. + * + * No API calls needed -- all data derived from hardcoded annual rates. + */ +export class CountersPanel extends Panel { + private animFrameId: number | null = null; + private valueElements: Map = new Map(); + + constructor() { + super({ id: 'counters', title: 'Live Counters', trackActivity: false }); + this.createCounterGrid(); + this.startTicking(); + } + + /** + * Build the 6 counter cards and insert them into the panel content area. + */ + private createCounterGrid(): void { + const grid = document.createElement('div'); + grid.className = 'counters-grid'; + + for (const metric of COUNTER_METRICS) { + const card = this.createCounterCard(metric); + grid.appendChild(card); + } + + // Clear loading state and append the grid + this.content.innerHTML = ''; + this.content.appendChild(grid); + } + + /** + * Create a single counter card with icon, value, label, and source. + */ + private createCounterCard(metric: CounterMetric): HTMLElement { + const card = document.createElement('div'); + card.className = 'counter-card'; + + const icon = document.createElement('div'); + icon.className = 'counter-icon'; + icon.textContent = metric.icon; + + const value = document.createElement('div'); + value.className = 'counter-value'; + value.dataset.counter = metric.id; + // Set initial value from absolute time + value.textContent = formatCounterValue( + getCounterValue(metric), + metric.formatPrecision, + ); + + const label = document.createElement('div'); + label.className = 'counter-label'; + label.textContent = metric.label; + + const source = document.createElement('div'); + source.className = 'counter-source'; + source.textContent = metric.source; + + card.appendChild(icon); + card.appendChild(value); + card.appendChild(label); + card.appendChild(source); + + // Store reference for fast 60fps updates + this.valueElements.set(metric.id, value); + + return card; + } + + /** + * Start the requestAnimationFrame animation loop. + * Each frame recalculates all counter values from absolute time. + */ + public startTicking(): void { + if (this.animFrameId !== null) return; // Already ticking + this.animFrameId = requestAnimationFrame(this.tick); + } + + /** + * Animation tick -- arrow function for correct `this` binding. + * Updates all 6 counter values using textContent (not innerHTML) + * to avoid layout thrashing at 60fps. + */ + private tick = (): void => { + for (const metric of COUNTER_METRICS) { + const el = this.valueElements.get(metric.id); + if (el) { + const value = getCounterValue(metric); + el.textContent = formatCounterValue(value, metric.formatPrecision); + } + } + this.animFrameId = requestAnimationFrame(this.tick); + }; + + /** + * Clean up animation frame and call parent destroy. + */ + public destroy(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + this.valueElements.clear(); + super.destroy(); + } +} diff --git a/src/components/CountryBriefPage.ts b/src/components/CountryBriefPage.ts new file mode 100644 index 000000000..090417fc9 --- /dev/null +++ b/src/components/CountryBriefPage.ts @@ -0,0 +1,677 @@ +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { getCSSColor } from '@/utils'; +import type { CountryScore } from '@/services/country-instability'; +import type { NewsItem } from '@/types'; +import type { PredictionMarket } from '@/services/prediction'; +import type { AssetType } from '@/types'; +import type { CountryBriefSignals } from '@/app/app-context'; +import type { StockIndexData } from '@/components/CountryIntelModal'; +import { getNearbyInfrastructure, haversineDistanceKm } from '@/services/related-assets'; +import { PORTS } from '@/config/ports'; +import type { Port } from '@/config/ports'; +import { exportCountryBriefJSON, exportCountryBriefCSV } from '@/utils/export'; +import type { CountryBriefExport } from '@/utils/export'; + +type BriefAssetType = AssetType | 'port'; + +interface CountryIntelData { + brief: string; + country: string; + code: string; + cached?: boolean; + generatedAt?: string; + error?: string; + skipped?: boolean; + reason?: string; + fallback?: boolean; +} + +export class CountryBriefPage { + private static BRIEF_BOUNDS: Record = { + IR: { n: 40, s: 25, e: 63, w: 44 }, IL: { n: 33.3, s: 29.5, e: 35.9, w: 34.3 }, + SA: { n: 32, s: 16, e: 55, w: 35 }, AE: { n: 26.1, s: 22.6, e: 56.4, w: 51.6 }, + IQ: { n: 37.4, s: 29.1, e: 48.6, w: 38.8 }, SY: { n: 37.3, s: 32.3, e: 42.4, w: 35.7 }, + YE: { n: 19, s: 12, e: 54.5, w: 42 }, LB: { n: 34.7, s: 33.1, e: 36.6, w: 35.1 }, + CN: { n: 53.6, s: 18.2, e: 134.8, w: 73.5 }, TW: { n: 25.3, s: 21.9, e: 122, w: 120 }, + JP: { n: 45.5, s: 24.2, e: 153.9, w: 122.9 }, KR: { n: 38.6, s: 33.1, e: 131.9, w: 124.6 }, + KP: { n: 43.0, s: 37.7, e: 130.7, w: 124.2 }, IN: { n: 35.5, s: 6.7, e: 97.4, w: 68.2 }, + PK: { n: 37, s: 24, e: 77, w: 61 }, AF: { n: 38.5, s: 29.4, e: 74.9, w: 60.5 }, + UA: { n: 52.4, s: 44.4, e: 40.2, w: 22.1 }, RU: { n: 82, s: 41.2, e: 180, w: 19.6 }, + BY: { n: 56.2, s: 51.3, e: 32.8, w: 23.2 }, PL: { n: 54.8, s: 49, e: 24.1, w: 14.1 }, + EG: { n: 31.7, s: 22, e: 36.9, w: 25 }, LY: { n: 33, s: 19.5, e: 25, w: 9.4 }, + SD: { n: 22, s: 8.7, e: 38.6, w: 21.8 }, US: { n: 49, s: 24.5, e: -66.9, w: -125 }, + GB: { n: 58.7, s: 49.9, e: 1.8, w: -8.2 }, DE: { n: 55.1, s: 47.3, e: 15.0, w: 5.9 }, + FR: { n: 51.1, s: 41.3, e: 9.6, w: -5.1 }, TR: { n: 42.1, s: 36, e: 44.8, w: 26 }, + }; + + private static INFRA_ICONS: Record = { + pipeline: '\u{1F50C}', + cable: '\u{1F310}', + datacenter: '\u{1F5A5}\uFE0F', + base: '\u{1F3DB}\uFE0F', + nuclear: '\u2622\uFE0F', + port: '\u2693', + }; + + private static INFRA_LABELS: Record = { + pipeline: 'pipeline', + cable: 'cable', + datacenter: 'datacenter', + base: 'base', + nuclear: 'nuclear', + port: 'port', + }; + + private overlay: HTMLElement; + private currentCode: string | null = null; + private currentName: string | null = null; + private currentHeadlineCount = 0; + private currentScore: CountryScore | null = null; + private currentSignals: CountryBriefSignals | null = null; + private currentBrief: string | null = null; + private currentHeadlines: NewsItem[] = []; + private onCloseCallback?: () => void; + private onShareStory?: (code: string, name: string) => void; + private onExportImage?: (code: string, name: string) => void; + private boundExportMenuClose: (() => void) | null = null; + private boundCitationClick: ((e: Event) => void) | null = null; + private abortController: AbortController = new AbortController(); + + constructor() { + this.overlay = document.createElement('div'); + this.overlay.className = 'country-brief-overlay'; + document.body.appendChild(this.overlay); + + this.overlay.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('country-brief-overlay')) this.hide(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.overlay.classList.contains('active')) this.hide(); + }); + } + + private countryFlag(code: string): string { + try { + return code + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)) + .join(''); + } catch { + return '🌍'; + } + } + + private levelColor(level: string): string { + const varMap: Record = { + critical: '--semantic-critical', + high: '--semantic-high', + elevated: '--semantic-elevated', + normal: '--semantic-normal', + low: '--semantic-low', + }; + return getCSSColor(varMap[level] || '--text-dim'); + } + + private levelBadge(level: string): string { + const color = this.levelColor(level); + const levelKey = level as 'critical' | 'high' | 'elevated' | 'moderate' | 'normal' | 'low'; + const label = t(`countryBrief.levels.${levelKey}`); + return `${label.toUpperCase()}`; + } + + private trendIndicator(trend: string): string { + const arrow = trend === 'rising' ? '↗' : trend === 'falling' ? '↘' : '→'; + const cls = trend === 'rising' ? 'trend-up' : trend === 'falling' ? 'trend-down' : 'trend-stable'; + const trendKey = trend as 'rising' | 'falling' | 'stable'; + const trendLabel = t(`countryBrief.trends.${trendKey}`); + return `${arrow} ${trendLabel}`; + } + + private scoreRing(score: number, level: string): string { + const color = this.levelColor(level); + const pct = Math.min(100, Math.max(0, score)); + const circumference = 2 * Math.PI * 42; + const dashOffset = circumference * (1 - pct / 100); + return ` +
+ + + + +
${score}
+
/ 100
+
`; + } + + private componentBars(components: CountryScore['components']): string { + const items = [ + { label: t('modals.countryBrief.components.unrest'), value: components.unrest, icon: '📢' }, + { label: t('modals.countryBrief.components.conflict'), value: components.conflict, icon: '⚔' }, + { label: t('modals.countryBrief.components.security'), value: components.security, icon: '🛡️' }, + { label: t('modals.countryBrief.components.information'), value: components.information, icon: '📡' }, + ]; + return items.map(({ label, value, icon }) => { + const pct = Math.min(100, Math.max(0, value)); + const color = pct >= 70 ? getCSSColor('--semantic-critical') : pct >= 50 ? getCSSColor('--semantic-high') : pct >= 30 ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-normal'); + return ` +
+ ${icon} + ${label} +
+ ${Math.round(value)} +
`; + }).join(''); + } + + private signalChips(signals: CountryBriefSignals): string { + const chips: string[] = []; + if (signals.protests > 0) chips.push(`📢 ${signals.protests} ${t('modals.countryBrief.signals.protests')}`); + if (signals.militaryFlights > 0) chips.push(`✈️ ${signals.militaryFlights} ${t('modals.countryBrief.signals.militaryAir')}`); + if (signals.militaryVessels > 0) chips.push(`⚓ ${signals.militaryVessels} ${t('modals.countryBrief.signals.militarySea')}`); + if (signals.outages > 0) chips.push(`🌐 ${signals.outages} ${t('modals.countryBrief.signals.outages')}`); + if (signals.earthquakes > 0) chips.push(`🌍 ${signals.earthquakes} ${t('modals.countryBrief.signals.earthquakes')}`); + if (signals.displacementOutflow > 0) { + const fmt = signals.displacementOutflow >= 1_000_000 + ? `${(signals.displacementOutflow / 1_000_000).toFixed(1)}M` + : `${(signals.displacementOutflow / 1000).toFixed(0)}K`; + chips.push(`🌊 ${fmt} ${t('modals.countryBrief.signals.displaced')}`); + } + if (signals.climateStress > 0) chips.push(`🌡️ ${t('modals.countryBrief.signals.climate')}`); + if (signals.conflictEvents > 0) chips.push(`⚔️ ${signals.conflictEvents} ${t('modals.countryBrief.signals.conflictEvents')}`); + chips.push(`📈 ${t('modals.countryBrief.loadingIndex')}`); + return chips.join(''); + } + + public setShareStoryHandler(handler: (code: string, name: string) => void): void { + this.onShareStory = handler; + } + + public setExportImageHandler(handler: (code: string, name: string) => void): void { + this.onExportImage = handler; + } + + public showLoading(): void { + this.currentCode = '__loading__'; + this.overlay.innerHTML = ` +
+
+
+ 🌍 + ${t('modals.countryBrief.identifying')} +
+
+ +
+
+
+
+
+
+ ${t('modals.countryBrief.locating')} +
+
+
`; + this.overlay.querySelector('.cb-close')?.addEventListener('click', () => this.hide()); + this.overlay.classList.add('active'); + } + + public get signal(): AbortSignal { + return this.abortController.signal; + } + + public show(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void { + this.abortController.abort(); + this.abortController = new AbortController(); + this.currentCode = code; + this.currentName = country; + this.currentScore = score; + this.currentSignals = signals; + this.currentBrief = null; + this.currentHeadlines = []; + this.currentHeadlineCount = 0; + const flag = this.countryFlag(code); + + const tierBadge = !signals.isTier1 + ? `${t('modals.countryBrief.limitedCoverage')}` + : ''; + + this.overlay.innerHTML = ` +
+
+
+ ${flag} + ${escapeHtml(country)} + ${score ? this.levelBadge(score.level) : ''} + ${score ? this.trendIndicator(score.trend) : ''} + ${tierBadge} +
+
+ + +
+ + +
+ +
+
+
+
+
+ ${score ? ` +
+

${t('modals.countryBrief.instabilityIndex')}

+
+ ${this.scoreRing(score.score, score.level)} +
+ ${this.componentBars(score.components)} +
+
+
` : signals.isTier1 ? '' : ` +
+

${t('modals.countryBrief.instabilityIndex')}

+
+ 📊 + ${t('modals.countryBrief.notTracked', { country: escapeHtml(country) })} +
+
`} + +
+

${t('modals.countryBrief.intelBrief')}

+
+
+
+
+
+
+ ${t('modals.countryBrief.generatingBrief')} +
+
+
+ + +
+ +
+
+

${t('modals.countryBrief.activeSignals')}

+
+ ${this.signalChips(signals)} +
+
+ +
+

${t('modals.countryBrief.timeline')}

+
+
+ +
+

${t('modals.countryBrief.predictionMarkets')}

+
+ ${t('modals.countryBrief.loadingMarkets')} +
+
+ + + +
+
+
+
`; + + this.overlay.querySelector('.cb-close')?.addEventListener('click', () => this.hide()); + this.overlay.querySelector('.cb-share-btn')?.addEventListener('click', () => { + if (this.onShareStory && this.currentCode && this.currentName) { + this.onShareStory(this.currentCode, this.currentName); + } + }); + this.overlay.querySelector('.cb-print-btn')?.addEventListener('click', () => { + window.print(); + }); + + const exportBtn = this.overlay.querySelector('.cb-export-btn'); + const exportMenu = this.overlay.querySelector('.cb-export-menu'); + exportBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + exportMenu?.classList.toggle('hidden'); + }); + this.overlay.querySelectorAll('.cb-export-option').forEach(opt => { + opt.addEventListener('click', () => { + const format = (opt as HTMLElement).dataset.format; + if (format === 'image') { + if (this.onExportImage && this.currentCode && this.currentName) { + this.onExportImage(this.currentCode, this.currentName); + } + } else if (format === 'pdf') { + this.exportPdf(); + } else { + this.exportBrief(format as 'json' | 'csv'); + } + exportMenu?.classList.add('hidden'); + }); + }); + // Remove previous overlay-level listeners to prevent accumulation + if (this.boundExportMenuClose) this.overlay.removeEventListener('click', this.boundExportMenuClose); + if (this.boundCitationClick) this.overlay.removeEventListener('click', this.boundCitationClick); + + this.boundExportMenuClose = () => exportMenu?.classList.add('hidden'); + this.overlay.addEventListener('click', this.boundExportMenuClose); + + this.boundCitationClick = (e: Event) => { + const target = e.target as HTMLElement; + if (target.classList.contains('cb-citation')) { + e.preventDefault(); + const href = target.getAttribute('href'); + if (href) { + const el = this.overlay.querySelector(href); + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el?.classList.add('cb-news-highlight'); + setTimeout(() => el?.classList.remove('cb-news-highlight'), 2000); + } + } + }; + this.overlay.addEventListener('click', this.boundCitationClick); + + this.overlay.classList.add('active'); + } + + public updateBrief(data: CountryIntelData): void { + if (data.code !== this.currentCode) return; + const section = this.overlay.querySelector('.cb-brief-content'); + if (!section) return; + + if (data.error || data.skipped || !data.brief) { + const msg = data.error || data.reason || t('modals.countryBrief.briefUnavailable'); + section.innerHTML = `
${escapeHtml(msg)}
`; + return; + } + + this.currentBrief = data.brief; + const formatted = this.formatBrief(data.brief, this.currentHeadlineCount); + section.innerHTML = ` +
${formatted}
+ `; + } + + public updateMarkets(markets: PredictionMarket[]): void { + const section = this.overlay.querySelector('.cb-markets-content'); + if (!section) return; + + if (markets.length === 0) { + section.innerHTML = `${t('modals.countryBrief.noMarkets')}`; + return; + } + + section.innerHTML = markets.slice(0, 3).map(m => { + const pct = Math.round(m.yesPrice); + const noPct = 100 - pct; + const vol = m.volume ? `$${(m.volume / 1000).toFixed(0)}k vol` : ''; + const safeUrl = sanitizeUrl(m.url || ''); + const link = safeUrl ? ` ` : ''; + return ` +
+
${escapeHtml(m.title.slice(0, 100))}${link}
+
+
${pct}%
+
${noPct > 15 ? noPct + '%' : ''}
+
+ ${vol ? `
${vol}
` : ''} +
`; + }).join(''); + } + + public updateStock(data: StockIndexData): void { + const el = this.overlay.querySelector('.stock-loading'); + if (!el) return; + + if (!data.available) { + el.remove(); + return; + } + + const pct = parseFloat(data.weekChangePercent); + const sign = pct >= 0 ? '+' : ''; + const cls = pct >= 0 ? 'stock-up' : 'stock-down'; + const arrow = pct >= 0 ? '📈' : '📉'; + el.className = `signal-chip stock ${cls}`; + el.innerHTML = `${arrow} ${escapeHtml(data.indexName)}: ${sign}${data.weekChangePercent}% (1W)`; + } + + public updateNews(headlines: NewsItem[]): void { + const section = this.overlay.querySelector('.cb-news-section') as HTMLElement | null; + const content = this.overlay.querySelector('.cb-news-content'); + if (!section || !content || headlines.length === 0) return; + + const items = headlines.slice(0, 8); + this.currentHeadlineCount = items.length; + this.currentHeadlines = items; + section.style.display = ''; + + content.innerHTML = items.map((item, i) => { + const safeUrl = sanitizeUrl(item.link); + const threatColor = item.threat?.level === 'critical' ? getCSSColor('--threat-critical') + : item.threat?.level === 'high' ? getCSSColor('--threat-high') + : item.threat?.level === 'medium' ? getCSSColor('--threat-medium') + : getCSSColor('--threat-info'); + const timeAgo = this.timeAgo(item.pubDate); + const cardBody = ` + +
+
${escapeHtml(item.title)}
+
${escapeHtml(item.source)} · ${timeAgo}
+
`; + if (safeUrl) { + return `${cardBody}`; + } + return `
${cardBody}
`; + }).join(''); + } + + + public updateInfrastructure(countryCode: string): void { + const bounds = CountryBriefPage.BRIEF_BOUNDS[countryCode]; + if (!bounds) return; + + const centroidLat = (bounds.n + bounds.s) / 2; + const centroidLon = (bounds.e + bounds.w) / 2; + + const assets = getNearbyInfrastructure(centroidLat, centroidLon, ['pipeline', 'cable', 'datacenter', 'base', 'nuclear']); + + const nearbyPorts = PORTS + .map((p: Port) => ({ port: p, dist: haversineDistanceKm(centroidLat, centroidLon, p.lat, p.lon) })) + .filter(({ dist }) => dist <= 600) + .sort((a, b) => a.dist - b.dist) + .slice(0, 5); + + const grouped = new Map>(); + for (const a of assets) { + const list = grouped.get(a.type) || []; + list.push({ name: a.name, distanceKm: a.distanceKm }); + grouped.set(a.type, list); + } + if (nearbyPorts.length > 0) { + grouped.set('port', nearbyPorts.map(({ port, dist }) => ({ name: port.name, distanceKm: dist }))); + } + + if (grouped.size === 0) return; + + const section = this.overlay.querySelector('.cb-infra-section') as HTMLElement | null; + const content = this.overlay.querySelector('.cb-infra-content'); + if (!section || !content) return; + + const order: BriefAssetType[] = ['pipeline', 'cable', 'datacenter', 'base', 'nuclear', 'port']; + let html = ''; + for (const type of order) { + const items = grouped.get(type); + if (!items || items.length === 0) continue; + const icon = CountryBriefPage.INFRA_ICONS[type]; + const key = CountryBriefPage.INFRA_LABELS[type]; + const label = t(`modals.countryBrief.infra.${key}`); + html += `
`; + html += `
${icon} ${label}
`; + for (const item of items) { + html += `
${escapeHtml(item.name)}${Math.round(item.distanceKm)} km
`; + } + html += `
`; + } + + content.innerHTML = html; + section.style.display = ''; + } + + public getTimelineMount(): HTMLElement | null { + return this.overlay.querySelector('.cb-timeline-mount'); + } + + public getCode(): string | null { + return this.currentCode; + } + + public getName(): string | null { + return this.currentName; + } + + private timeAgo(date: Date): string { + const ms = Date.now() - new Date(date).getTime(); + const hours = Math.floor(ms / 3600000); + if (hours < 1) return t('modals.countryBrief.timeAgo.m', { count: Math.floor(ms / 60000) }); + if (hours < 24) return t('modals.countryBrief.timeAgo.h', { count: hours }); + return t('modals.countryBrief.timeAgo.d', { count: Math.floor(hours / 24) }); + } + + private formatBrief(text: string, headlineCount = 0): string { + let html = escapeHtml(text) + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + .replace(/^/, '

') + .replace(/$/, '

'); + + if (headlineCount > 0) { + html = html.replace(/\[(\d{1,2})\]/g, (_match, numStr) => { + const n = parseInt(numStr, 10); + if (n >= 1 && n <= headlineCount) { + return `[${n}]`; + } + return `[${numStr}]`; + }); + } + + return html; + } + + private exportBrief(format: 'json' | 'csv'): void { + if (!this.currentCode || !this.currentName) return; + const data: CountryBriefExport = { + country: this.currentName, + code: this.currentCode, + generatedAt: new Date().toISOString(), + }; + if (this.currentScore) { + data.score = this.currentScore.score; + data.level = this.currentScore.level; + data.trend = this.currentScore.trend; + data.components = this.currentScore.components; + } + if (this.currentSignals) { + data.signals = { + protests: this.currentSignals.protests, + militaryFlights: this.currentSignals.militaryFlights, + militaryVessels: this.currentSignals.militaryVessels, + outages: this.currentSignals.outages, + earthquakes: this.currentSignals.earthquakes, + displacementOutflow: this.currentSignals.displacementOutflow, + climateStress: this.currentSignals.climateStress, + conflictEvents: this.currentSignals.conflictEvents, + }; + } + if (this.currentBrief) data.brief = this.currentBrief; + if (this.currentHeadlines.length > 0) { + data.headlines = this.currentHeadlines.map(h => ({ + title: h.title, + source: h.source, + link: h.link, + pubDate: h.pubDate ? new Date(h.pubDate).toISOString() : undefined, + })); + } + if (format === 'json') exportCountryBriefJSON(data); + else exportCountryBriefCSV(data); + } + + private exportPdf(): void { + const content = this.overlay.querySelector('.cb-body'); + const header = this.overlay.querySelector('.cb-header'); + if (!content) return; + + const iframe = document.createElement('iframe'); + iframe.style.cssText = 'position:fixed;left:-9999px;width:0;height:0;border:none'; + document.body.appendChild(iframe); + const doc = iframe.contentDocument || iframe.contentWindow?.document; + if (!doc) { document.body.removeChild(iframe); return; } + + const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')) + .map(el => el.outerHTML).join('\n'); + + doc.open(); + doc.write(`${styles} + + ${header ? header.outerHTML : ''}${content.outerHTML}`); + doc.close(); + + iframe.contentWindow!.onafterprint = () => document.body.removeChild(iframe); + setTimeout(() => { + iframe.contentWindow!.print(); + setTimeout(() => { if (iframe.parentNode) document.body.removeChild(iframe); }, 5000); + }, 300); + } + + public hide(): void { + this.abortController.abort(); + this.overlay.classList.remove('active'); + this.currentCode = null; + this.currentName = null; + this.onCloseCallback?.(); + } + + public onClose(cb: () => void): void { + this.onCloseCallback = cb; + } + + public isVisible(): boolean { + return this.overlay.classList.contains('active'); + } +} diff --git a/src/components/CountryIntelModal.ts b/src/components/CountryIntelModal.ts new file mode 100644 index 000000000..f3b9f6ca5 --- /dev/null +++ b/src/components/CountryIntelModal.ts @@ -0,0 +1,283 @@ +/** + * CountryIntelModal - Shows AI-generated intelligence brief when user clicks a country + */ +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { sanitizeUrl } from '@/utils/sanitize'; +import { getCSSColor } from '@/utils'; +import type { CountryScore } from '@/services/country-instability'; +import type { PredictionMarket } from '@/services/prediction'; + +interface CountryIntelData { + brief: string; + country: string; + code: string; + cached?: boolean; + generatedAt?: string; + error?: string; +} + +export interface StockIndexData { + available: boolean; + code: string; + symbol: string; + indexName: string; + price: string; + weekChangePercent: string; + currency: string; + cached?: boolean; +} + +interface ActiveSignals { + protests: number; + militaryFlights: number; + militaryVessels: number; + outages: number; + earthquakes: number; +} + +export class CountryIntelModal { + private overlay: HTMLElement; + private contentEl: HTMLElement; + private headerEl: HTMLElement; + private onCloseCallback?: () => void; + private onShareStory?: (code: string, name: string) => void; + private currentCode: string | null = null; + private currentName: string | null = null; + + constructor() { + this.overlay = document.createElement('div'); + this.overlay.className = 'country-intel-overlay'; + this.overlay.innerHTML = ` +
+
+
+ +
+
+
+ `; + document.body.appendChild(this.overlay); + + this.headerEl = this.overlay.querySelector('.country-intel-title')!; + this.contentEl = this.overlay.querySelector('.country-intel-content')!; + + this.overlay.querySelector('.country-intel-close')?.addEventListener('click', () => this.hide()); + this.overlay.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('country-intel-overlay')) this.hide(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.overlay.classList.contains('active')) this.hide(); + }); + } + + private countryFlag(code: string): string { + try { + return code + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)) + .join(''); + } catch { + return '🌍'; + } + } + + private levelBadge(level: string): string { + const varMap: Record = { + critical: '--semantic-critical', + high: '--semantic-high', + elevated: '--semantic-elevated', + normal: '--semantic-normal', + low: '--semantic-low', + }; + const color = getCSSColor(varMap[level] || '--text-dim'); + return `${level.toUpperCase()}`; + } + + private scoreBar(score: number): string { + const pct = Math.min(100, Math.max(0, score)); + const color = pct >= 70 ? getCSSColor('--semantic-critical') : pct >= 50 ? getCSSColor('--semantic-high') : pct >= 30 ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-normal'); + return ` +
+
+
+ ${score}/100 + `; + } + + public showLoading(): void { + this.currentCode = '__loading__'; + this.headerEl.innerHTML = ` + 🌍 + ${t('modals.countryIntel.identifying')} + `; + this.contentEl.innerHTML = ` +
+
+
+
+ ${t('modals.countryIntel.locating')} +
+
+ `; + this.overlay.classList.add('active'); + } + + public show(country: string, code: string, score: CountryScore | null, signals?: ActiveSignals): void { + this.currentCode = code; + this.currentName = country; + const flag = this.countryFlag(code); + let html = ''; + this.overlay.classList.add('active'); + + this.headerEl.innerHTML = ` + ${flag} + ${escapeHtml(country)} + ${score ? this.levelBadge(score.level) : ''} + + `; + + if (score) { + html += ` +
+
${t('modals.countryIntel.instabilityIndex')} ${this.scoreBar(score.score)}
+
+ 📢 ${score.components.unrest.toFixed(0)} + ⚔ ${score.components.conflict.toFixed(0)} + 🛡️ ${score.components.security.toFixed(0)} + 📡 ${score.components.information.toFixed(0)} + ${score.trend === 'rising' ? '↗' : score.trend === 'falling' ? '↘' : '→'} ${score.trend} +
+
+ `; + } + + const chips: string[] = []; + if (signals) { + if (signals.protests > 0) chips.push(`📢 ${signals.protests} ${t('modals.countryIntel.protests')}`); + if (signals.militaryFlights > 0) chips.push(`✈️ ${signals.militaryFlights} ${t('modals.countryIntel.militaryAircraft')}`); + if (signals.militaryVessels > 0) chips.push(`⚓ ${signals.militaryVessels} ${t('modals.countryIntel.militaryVessels')}`); + if (signals.outages > 0) chips.push(`🌐 ${signals.outages} ${t('modals.countryIntel.outages')}`); + if (signals.earthquakes > 0) chips.push(`🌍 ${signals.earthquakes} ${t('modals.countryIntel.earthquakes')}`); + } + chips.push(`📈 ${t('modals.countryIntel.loadingIndex')}`); + html += `
${chips.join('')}
`; + + html += `
${t('modals.countryIntel.loadingMarkets')}
`; + + html += ` +
+
+
+
+
+
+ ${t('modals.countryIntel.generatingBrief')} +
+
+ `; + + this.contentEl.innerHTML = html; + + const shareBtn = this.headerEl.querySelector('.country-intel-share-btn'); + shareBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.currentCode && this.currentName && this.onShareStory) { + this.onShareStory(this.currentCode, this.currentName); + } + }); + } + + public updateBrief(data: CountryIntelData & { skipped?: boolean; reason?: string; fallback?: boolean }): void { + if (this.currentCode !== data.code && this.currentCode !== '__loading__') return; + + // If modal closed, don't update + if (!this.isVisible()) return; + + if (data.error || data.skipped || !data.brief) { + const msg = data.error || data.reason || t('modals.countryIntel.unavailable'); + const briefSection = this.contentEl.querySelector('.intel-brief-section'); + if (briefSection) { + briefSection.innerHTML = `
${escapeHtml(msg)}
`; + } + return; + } + + const briefSection = this.contentEl.querySelector('.intel-brief-section'); + if (!briefSection) return; + + const formatted = this.formatBrief(data.brief); + briefSection.innerHTML = ` +
${formatted}
+ + `; + } + + public updateMarkets(markets: PredictionMarket[]): void { + const section = this.contentEl.querySelector('.country-markets-section'); + if (!section) return; + + if (markets.length === 0) { + section.innerHTML = `${t('modals.countryIntel.noMarkets')}`; + return; + } + + const items = markets.map(market => { + const href = sanitizeUrl(market.url || '#') || '#'; + return ` +
+ +
Polymarket
+
${escapeHtml(market.title)}
+
${market.yesPrice.toFixed(1)}%
+
+ `; + }).join(''); + + section.innerHTML = `
📊 ${t('modals.countryIntel.predictionMarkets')}
${items}`; + } + + public updateStock(data: StockIndexData): void { + const el = this.contentEl.querySelector('.stock-loading'); + if (!el) return; + + if (!data.available) { + el.remove(); + return; + } + + const pct = parseFloat(data.weekChangePercent); + const sign = pct >= 0 ? '+' : ''; + const cls = pct >= 0 ? 'stock-up' : 'stock-down'; + const arrow = pct >= 0 ? '📈' : '📉'; + el.className = `signal-chip stock ${cls}`; + el.innerHTML = `${arrow} ${escapeHtml(data.indexName)}: ${sign}${data.weekChangePercent}% (1W)`; + } + + private formatBrief(text: string): string { + return escapeHtml(text) + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + .replace(/^/, '

') + .replace(/$/, '

'); + } + + public hide(): void { + this.overlay.classList.remove('active'); + this.currentCode = null; + this.onCloseCallback?.(); + } + + public onClose(cb: () => void): void { + this.onCloseCallback = cb; + } + + public isVisible(): boolean { + return this.overlay.classList.contains('active'); + } +} diff --git a/src/components/CountryTimeline.ts b/src/components/CountryTimeline.ts new file mode 100644 index 000000000..e4d6a3ca7 --- /dev/null +++ b/src/components/CountryTimeline.ts @@ -0,0 +1,285 @@ +import * as d3 from 'd3'; +import { escapeHtml } from '@/utils/sanitize'; +import { getCSSColor } from '@/utils'; +import { t } from '@/services/i18n'; + +export interface TimelineEvent { + timestamp: number; + lane: 'protest' | 'conflict' | 'natural' | 'military'; + label: string; + severity?: 'low' | 'medium' | 'high' | 'critical'; +} + +const LANES: TimelineEvent['lane'][] = ['protest', 'conflict', 'natural', 'military']; + +const LANE_COLORS: Record = { + protest: '#ffaa00', + conflict: '#ff4444', + natural: '#b478ff', + military: '#64b4ff', +}; + +const SEVERITY_RADIUS: Record = { + low: 4, + medium: 5, + high: 7, + critical: 9, +}; + +const MARGIN = { top: 20, right: 20, bottom: 30, left: 80 }; +const HEIGHT = 200; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +export class CountryTimeline { + private container: HTMLElement; + private svg: d3.Selection | null = null; + private tooltip: HTMLDivElement | null = null; + private resizeObserver: ResizeObserver | null = null; + private currentEvents: TimelineEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + this.createTooltip(); + this.resizeObserver = new ResizeObserver(() => { + if (this.currentEvents.length > 0) this.render(this.currentEvents); + }); + this.resizeObserver.observe(this.container); + + window.addEventListener('theme-changed', () => { + // Re-create tooltip with new theme colors + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + this.createTooltip(); + // Re-render chart with new colors + if (this.currentEvents.length > 0) this.render(this.currentEvents); + }); + } + + private createTooltip(): void { + this.tooltip = document.createElement('div'); + Object.assign(this.tooltip.style, { + position: 'absolute', + pointerEvents: 'none', + background: getCSSColor('--bg'), + border: `1px solid ${getCSSColor('--border')}`, + borderRadius: '6px', + padding: '6px 10px', + fontSize: '12px', + color: getCSSColor('--text'), + zIndex: '9999', + display: 'none', + whiteSpace: 'nowrap', + boxShadow: `0 2px 8px ${getCSSColor('--shadow-color')}`, + }); + this.container.style.position = 'relative'; + this.container.appendChild(this.tooltip); + } + + render(events: TimelineEvent[]): void { + this.currentEvents = events; + if (this.svg) this.svg.remove(); + + const width = this.container.clientWidth; + if (width <= 0) return; + + const innerW = width - MARGIN.left - MARGIN.right; + const innerH = HEIGHT - MARGIN.top - MARGIN.bottom; + + this.svg = d3 + .select(this.container) + .append('svg') + .attr('width', width) + .attr('height', HEIGHT) + .attr('style', 'display:block;'); + + const g = this.svg + .append('g') + .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); + + const now = Date.now(); + const xScale = d3 + .scaleTime() + .domain([new Date(now - SEVEN_DAYS_MS), new Date(now)]) + .range([0, innerW]); + + const yScale = d3 + .scaleBand() + .domain(LANES) + .range([0, innerH]) + .padding(0.2); + + this.drawGrid(g, xScale, innerH); + this.drawAxes(g, xScale, yScale, innerH); + this.drawNowMarker(g, xScale, new Date(now), innerH); + this.drawEmptyLaneLabels(g, events, yScale, innerW); + this.drawEvents(g, events, xScale, yScale); + } + + private drawGrid( + g: d3.Selection, + xScale: d3.ScaleTime, + innerH: number, + ): void { + const ticks = xScale.ticks(6); + g.selectAll('.grid-line') + .data(ticks) + .join('line') + .attr('x1', (d) => xScale(d)) + .attr('x2', (d) => xScale(d)) + .attr('y1', 0) + .attr('y2', innerH) + .attr('stroke', getCSSColor('--border-subtle')) + .attr('stroke-width', 1); + } + + private drawAxes( + g: d3.Selection, + xScale: d3.ScaleTime, + yScale: d3.ScaleBand, + innerH: number, + ): void { + const xAxis = d3 + .axisBottom(xScale) + .ticks(6) + .tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue, i: number) => string); + + const xAxisG = g + .append('g') + .attr('transform', `translate(0,${innerH})`) + .call(xAxis); + + xAxisG.selectAll('text').attr('fill', getCSSColor('--text-dim')).attr('font-size', '10px'); + xAxisG.selectAll('line').attr('stroke', getCSSColor('--border')); + xAxisG.select('.domain').attr('stroke', getCSSColor('--border')); + + const laneLabels: Record = { + protest: 'Protest', + conflict: 'Conflict', + natural: 'Natural', + military: 'Military', + }; + + g.selectAll('.lane-label') + .data(LANES) + .join('text') + .attr('x', -10) + .attr('y', (d) => (yScale(d) ?? 0) + yScale.bandwidth() / 2) + .attr('text-anchor', 'end') + .attr('dominant-baseline', 'central') + .attr('fill', (d: TimelineEvent['lane']) => LANE_COLORS[d]) + .attr('font-size', '11px') + .attr('font-weight', '500') + .text((d: TimelineEvent['lane']) => laneLabels[d] || d); + } + + private drawNowMarker( + g: d3.Selection, + xScale: d3.ScaleTime, + now: Date, + innerH: number, + ): void { + const x = xScale(now); + g.append('line') + .attr('x1', x) + .attr('x2', x) + .attr('y1', 0) + .attr('y2', innerH) + .attr('stroke', getCSSColor('--text')) + .attr('stroke-width', 1) + .attr('stroke-dasharray', '4,3') + .attr('opacity', 0.6); + + g.append('text') + .attr('x', x) + .attr('y', -6) + .attr('text-anchor', 'middle') + .attr('fill', getCSSColor('--text-muted')) + .attr('font-size', '9px') + .text(t('components.countryTimeline.now')); + } + + private drawEmptyLaneLabels( + g: d3.Selection, + events: TimelineEvent[], + yScale: d3.ScaleBand, + innerW: number, + ): void { + const populatedLanes = new Set(events.map((e) => e.lane)); + const emptyLanes = LANES.filter((l) => !populatedLanes.has(l)); + + g.selectAll('.empty-label') + .data(emptyLanes) + .join('text') + .attr('x', innerW / 2) + .attr('y', (d) => (yScale(d) ?? 0) + yScale.bandwidth() / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('fill', getCSSColor('--text-ghost')) + .attr('font-size', '10px') + .attr('font-style', 'italic') + .text(t('components.countryTimeline.noEventsIn7Days')); + } + + private drawEvents( + g: d3.Selection, + events: TimelineEvent[], + xScale: d3.ScaleTime, + yScale: d3.ScaleBand, + ): void { + const tooltip = this.tooltip!; + const container = this.container; + const fmt = d3.timeFormat('%b %d, %H:%M'); + + g.selectAll('.event-circle') + .data(events) + .join('circle') + .attr('cx', (d) => xScale(new Date(d.timestamp))) + .attr('cy', (d) => (yScale(d.lane) ?? 0) + yScale.bandwidth() / 2) + .attr('r', (d) => SEVERITY_RADIUS[d.severity ?? 'medium'] ?? 5) + .attr('fill', (d) => LANE_COLORS[d.lane]) + .attr('opacity', 0.85) + .attr('cursor', 'pointer') + .attr('stroke', getCSSColor('--shadow-color')) + .attr('stroke-width', 0.5) + .on('mouseenter', function (event: MouseEvent, d: TimelineEvent) { + d3.select(this).attr('opacity', 1).attr('stroke', getCSSColor('--text')).attr('stroke-width', 1.5); + const dateStr = fmt(new Date(d.timestamp)); + tooltip.innerHTML = `${escapeHtml(d.label)}
${escapeHtml(dateStr)}`; + tooltip.style.display = 'block'; + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left + 12; + const y = event.clientY - rect.top - 10; + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + }) + .on('mousemove', function (event: MouseEvent) { + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left + 12; + const y = event.clientY - rect.top - 10; + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + }) + .on('mouseleave', function () { + d3.select(this).attr('opacity', 0.85).attr('stroke', getCSSColor('--shadow-color')).attr('stroke-width', 0.5); + tooltip.style.display = 'none'; + }); + } + + destroy(): void { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.svg) { + this.svg.remove(); + this.svg = null; + } + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + this.currentEvents = []; + } +} diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts new file mode 100644 index 000000000..feb0b7776 --- /dev/null +++ b/src/components/DeckGLMap.ts @@ -0,0 +1,4258 @@ +/** + * DeckGLMap - WebGL-accelerated map visualization for desktop + * Uses deck.gl for high-performance rendering of large datasets + * Mobile devices gracefully degrade to the D3/SVG-based Map component + */ +import { MapboxOverlay } from '@deck.gl/mapbox'; +import type { Layer, LayersList, PickingInfo } from '@deck.gl/core'; +import { GeoJsonLayer, ScatterplotLayer, PathLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import maplibregl from 'maplibre-gl'; +import Supercluster from 'supercluster'; +import type { + MapLayers, + Hotspot, + NewsItem, + InternetOutage, + RelatedAsset, + AssetType, + AisDisruptionEvent, + AisDensityZone, + CableAdvisory, + RepairShip, + SocialUnrestEvent, + AIDataCenter, + MilitaryFlight, + MilitaryVessel, + MilitaryFlightCluster, + MilitaryVesselCluster, + NaturalEvent, + UcdpGeoEvent, + MapProtestCluster, + MapTechHQCluster, + MapTechEventCluster, + MapDatacenterCluster, + CyberThreat, + CableHealthRecord, +} from '@/types'; +import type { AirportDelayAlert } from '@/services/aviation'; +import type { DisplacementFlow } from '@/services/displacement'; +import type { Earthquake } from '@/services/earthquakes'; +import type { ClimateAnomaly } from '@/services/climate'; +import { ArcLayer } from '@deck.gl/layers'; +import { HeatmapLayer } from '@deck.gl/aggregation-layers'; +import type { WeatherAlert } from '@/services/weather'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { debounce, rafSchedule, getCurrentTheme } from '@/utils/index'; +import { + INTEL_HOTSPOTS, + CONFLICT_ZONES, + MILITARY_BASES, + UNDERSEA_CABLES, + NUCLEAR_FACILITIES, + GAMMA_IRRADIATORS, + PIPELINES, + PIPELINE_COLORS, + STRATEGIC_WATERWAYS, + ECONOMIC_CENTERS, + AI_DATA_CENTERS, + SITE_VARIANT, + STARTUP_HUBS, + ACCELERATORS, + TECH_HQS, + CLOUD_REGIONS, + PORTS, + SPACEPORTS, + APT_GROUPS, + CRITICAL_MINERALS, + STOCK_EXCHANGES, + FINANCIAL_CENTERS, + CENTRAL_BANKS, + COMMODITY_HUBS, + GULF_INVESTMENTS, +} from '@/config'; +import type { GulfInvestment } from '@/types'; +import { resolveTradeRouteSegments, TRADE_ROUTES as TRADE_ROUTES_LIST, type TradeRouteSegment } from '@/config/trade-routes'; +import { MapPopup, type PopupType } from './MapPopup'; +import { + updateHotspotEscalation, + getHotspotEscalation, + setMilitaryData, + setCIIGetter, + setGeoAlertGetter, +} from '@/services/hotspot-escalation'; +import { getCountryScore } from '@/services/country-instability'; +import { getAlertsNearLocation } from '@/services/geo-convergence'; +import type { PositiveGeoEvent } from '@/services/positive-events-geo'; +import type { KindnessPoint } from '@/services/kindness-data'; +import type { HappinessData } from '@/services/happiness-data'; +import type { RenewableInstallation } from '@/services/renewable-installations'; +import type { SpeciesRecovery } from '@/services/conservation-data'; +import { getCountriesGeoJson, getCountryAtCoordinates } from '@/services/country-geometry'; +import type { FeatureCollection, Geometry } from 'geojson'; + +export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; +export type DeckMapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; +type MapInteractionMode = 'flat' | '3d'; + +export interface CountryClickPayload { + lat: number; + lon: number; + code?: string; + name?: string; +} + +interface DeckMapState { + zoom: number; + pan: { x: number; y: number }; + view: DeckMapView; + layers: MapLayers; + timeRange: TimeRange; +} + +interface HotspotWithBreaking extends Hotspot { + hasBreaking?: boolean; +} + +interface TechEventMarker { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; +} + +// View presets with longitude, latitude, zoom +const VIEW_PRESETS: Record = { + global: { longitude: 0, latitude: 20, zoom: 1.5 }, + america: { longitude: -95, latitude: 38, zoom: 3 }, + mena: { longitude: 45, latitude: 28, zoom: 3.5 }, + eu: { longitude: 15, latitude: 50, zoom: 3.5 }, + asia: { longitude: 105, latitude: 35, zoom: 3 }, + latam: { longitude: -60, latitude: -15, zoom: 3 }, + africa: { longitude: 20, latitude: 5, zoom: 3 }, + oceania: { longitude: 135, latitude: -25, zoom: 3.5 }, +}; + +const MAP_INTERACTION_MODE: MapInteractionMode = + import.meta.env.VITE_MAP_INTERACTION_MODE === 'flat' ? 'flat' : '3d'; + +// Theme-aware basemap vector style URLs (English labels, no local scripts) +// Happy variant uses self-hosted warm styles; default uses CARTO CDN +const DARK_STYLE = SITE_VARIANT === 'happy' + ? '/map-styles/happy-dark.json' + : 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; +const LIGHT_STYLE = SITE_VARIANT === 'happy' + ? '/map-styles/happy-light.json' + : 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'; + +// Zoom thresholds for layer visibility and labels (matches old Map.ts) +// Zoom-dependent layer visibility and labels +const LAYER_ZOOM_THRESHOLDS: Partial> = { + bases: { minZoom: 3, showLabels: 5 }, + nuclear: { minZoom: 3 }, + conflicts: { minZoom: 1, showLabels: 3 }, + economic: { minZoom: 3 }, + natural: { minZoom: 1, showLabels: 2 }, + datacenters: { minZoom: 5 }, + irradiators: { minZoom: 4 }, + spaceports: { minZoom: 3 }, + gulfInvestments: { minZoom: 2, showLabels: 5 }, +}; +// Export for external use +export { LAYER_ZOOM_THRESHOLDS }; + +// Theme-aware overlay color function — refreshed each buildLayers() call +function getOverlayColors() { + const isLight = getCurrentTheme() === 'light'; + return { + // Threat dots: IDENTICAL in both modes (user locked decision) + hotspotHigh: [255, 68, 68, 200] as [number, number, number, number], + hotspotElevated: [255, 165, 0, 200] as [number, number, number, number], + hotspotLow: [255, 255, 0, 180] as [number, number, number, number], + + // Conflict zone fills: more transparent in light mode + conflict: isLight + ? [255, 0, 0, 60] as [number, number, number, number] + : [255, 0, 0, 100] as [number, number, number, number], + + // Infrastructure/category markers: darker variants in light mode for map readability + base: [0, 150, 255, 200] as [number, number, number, number], + nuclear: isLight + ? [180, 120, 0, 220] as [number, number, number, number] + : [255, 215, 0, 200] as [number, number, number, number], + datacenter: isLight + ? [13, 148, 136, 200] as [number, number, number, number] + : [0, 255, 200, 180] as [number, number, number, number], + cable: [0, 200, 255, 150] as [number, number, number, number], + cableHighlight: [255, 100, 100, 200] as [number, number, number, number], + cableFault: [255, 50, 50, 220] as [number, number, number, number], + cableDegraded: [255, 165, 0, 200] as [number, number, number, number], + earthquake: [255, 100, 50, 200] as [number, number, number, number], + vesselMilitary: [255, 100, 100, 220] as [number, number, number, number], + flightMilitary: [255, 50, 50, 220] as [number, number, number, number], + protest: [255, 150, 0, 200] as [number, number, number, number], + outage: [255, 50, 50, 180] as [number, number, number, number], + weather: [100, 150, 255, 180] as [number, number, number, number], + startupHub: isLight + ? [22, 163, 74, 220] as [number, number, number, number] + : [0, 255, 150, 200] as [number, number, number, number], + techHQ: [100, 200, 255, 200] as [number, number, number, number], + accelerator: isLight + ? [180, 120, 0, 220] as [number, number, number, number] + : [255, 200, 0, 200] as [number, number, number, number], + cloudRegion: [150, 100, 255, 180] as [number, number, number, number], + stockExchange: isLight + ? [20, 120, 200, 220] as [number, number, number, number] + : [80, 200, 255, 210] as [number, number, number, number], + financialCenter: isLight + ? [0, 150, 110, 215] as [number, number, number, number] + : [0, 220, 150, 200] as [number, number, number, number], + centralBank: isLight + ? [180, 120, 0, 220] as [number, number, number, number] + : [255, 210, 80, 210] as [number, number, number, number], + commodityHub: isLight + ? [190, 95, 40, 220] as [number, number, number, number] + : [255, 150, 80, 200] as [number, number, number, number], + gulfInvestmentSA: [0, 168, 107, 220] as [number, number, number, number], + gulfInvestmentUAE: [255, 0, 100, 220] as [number, number, number, number], + ucdpStateBased: [255, 50, 50, 200] as [number, number, number, number], + ucdpNonState: [255, 165, 0, 200] as [number, number, number, number], + ucdpOneSided: [255, 255, 0, 200] as [number, number, number, number], + }; +} +// Initialize and refresh on every buildLayers() call +let COLORS = getOverlayColors(); + +// SVG icons as data URLs for different marker shapes +const MARKER_ICONS = { + // Square - for datacenters + square: 'data:image/svg+xml;base64,' + btoa(``), + // Diamond - for hotspots + diamond: 'data:image/svg+xml;base64,' + btoa(``), + // Triangle up - for military bases + triangleUp: 'data:image/svg+xml;base64,' + btoa(``), + // Hexagon - for nuclear + hexagon: 'data:image/svg+xml;base64,' + btoa(``), + // Circle - fallback + circle: 'data:image/svg+xml;base64,' + btoa(``), + // Star - for special markers + star: 'data:image/svg+xml;base64,' + btoa(``), +}; + +export class DeckGLMap { + private static readonly MAX_CLUSTER_LEAVES = 200; + + private container: HTMLElement; + private deckOverlay: MapboxOverlay | null = null; + private maplibreMap: maplibregl.Map | null = null; + private state: DeckMapState; + private popup: MapPopup; + + // Data stores + private hotspots: HotspotWithBreaking[]; + private earthquakes: Earthquake[] = []; + private weatherAlerts: WeatherAlert[] = []; + private outages: InternetOutage[] = []; + private cyberThreats: CyberThreat[] = []; + private aisDisruptions: AisDisruptionEvent[] = []; + private aisDensity: AisDensityZone[] = []; + private cableAdvisories: CableAdvisory[] = []; + private repairShips: RepairShip[] = []; + private healthByCableId: Record = {}; + private protests: SocialUnrestEvent[] = []; + private militaryFlights: MilitaryFlight[] = []; + private militaryFlightClusters: MilitaryFlightCluster[] = []; + private militaryVessels: MilitaryVessel[] = []; + private militaryVesselClusters: MilitaryVesselCluster[] = []; + private naturalEvents: NaturalEvent[] = []; + private firmsFireData: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }> = []; + private techEvents: TechEventMarker[] = []; + private flightDelays: AirportDelayAlert[] = []; + private news: NewsItem[] = []; + private newsLocations: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }> = []; + private newsLocationFirstSeen = new Map(); + private ucdpEvents: UcdpGeoEvent[] = []; + private displacementFlows: DisplacementFlow[] = []; + private climateAnomalies: ClimateAnomaly[] = []; + private tradeRouteSegments: TradeRouteSegment[] = resolveTradeRouteSegments(); + private positiveEvents: PositiveGeoEvent[] = []; + private kindnessPoints: KindnessPoint[] = []; + + // Phase 8 overlay data + private happinessScores: Map = new Map(); + private happinessYear = 0; + private happinessSource = ''; + private speciesRecoveryZones: Array = []; + private renewableInstallations: RenewableInstallation[] = []; + private countriesGeoJsonData: FeatureCollection | null = null; + + // Country highlight state + private countryGeoJsonLoaded = false; + private countryHoverSetup = false; + private highlightedCountryCode: string | null = null; + + // Callbacks + private onHotspotClick?: (hotspot: Hotspot) => void; + private onTimeRangeChange?: (range: TimeRange) => void; + private onCountryClick?: (country: CountryClickPayload) => void; + private onLayerChange?: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void; + private onStateChange?: (state: DeckMapState) => void; + + // Highlighted assets + private highlightedAssets: Record> = { + pipeline: new Set(), + cable: new Set(), + datacenter: new Set(), + base: new Set(), + nuclear: new Set(), + }; + + private renderScheduled = false; + private renderPaused = false; + private renderPending = false; + private webglLost = false; + private resizeObserver: ResizeObserver | null = null; + + private layerCache: Map = new Map(); + private lastZoomThreshold = 0; + private protestSC: Supercluster | null = null; + private techHQSC: Supercluster | null = null; + private techEventSC: Supercluster | null = null; + private datacenterSC: Supercluster | null = null; + private protestClusters: MapProtestCluster[] = []; + private techHQClusters: MapTechHQCluster[] = []; + private techEventClusters: MapTechEventCluster[] = []; + private datacenterClusters: MapDatacenterCluster[] = []; + private lastSCZoom = -1; + private lastSCBoundsKey = ''; + private lastSCMask = ''; + private protestSuperclusterSource: SocialUnrestEvent[] = []; + private newsPulseIntervalId: ReturnType | null = null; + private readonly startupTime = Date.now(); + private lastCableHighlightSignature = ''; + private lastCableHealthSignature = ''; + private lastPipelineHighlightSignature = ''; + private debouncedRebuildLayers: () => void; + private rafUpdateLayers: () => void; + private moveTimeoutId: ReturnType | null = null; + + constructor(container: HTMLElement, initialState: DeckMapState) { + this.container = container; + this.state = initialState; + this.hotspots = [...INTEL_HOTSPOTS]; + + this.debouncedRebuildLayers = debounce(() => { + if (this.renderPaused || this.webglLost || !this.maplibreMap) return; + this.maplibreMap.resize(); + try { this.deckOverlay?.setProps({ layers: this.buildLayers() }); } catch { /* map mid-teardown */ } + }, 150); + this.rafUpdateLayers = rafSchedule(() => { + if (this.renderPaused || this.webglLost || !this.maplibreMap) return; + try { this.deckOverlay?.setProps({ layers: this.buildLayers() }); } catch { /* map mid-teardown */ } + }); + + this.setupDOM(); + this.popup = new MapPopup(container); + + window.addEventListener('theme-changed', (e: Event) => { + const theme = (e as CustomEvent).detail?.theme as 'dark' | 'light'; + if (theme) { + this.switchBasemap(theme); + this.render(); // Rebuilds Deck.GL layers with new theme-aware colors + } + }); + + this.initMapLibre(); + + this.maplibreMap?.on('load', () => { + this.rebuildTechHQSupercluster(); + this.rebuildDatacenterSupercluster(); + this.initDeck(); + this.loadCountryBoundaries(); + this.render(); + }); + + this.setupResizeObserver(); + + this.createControls(); + this.createTimeSlider(); + this.createLayerToggles(); + this.createLegend(); + } + + private setupDOM(): void { + const wrapper = document.createElement('div'); + wrapper.className = 'deckgl-map-wrapper'; + wrapper.id = 'deckglMapWrapper'; + wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;'; + + // MapLibre container - deck.gl renders directly into MapLibre via MapboxOverlay + const mapContainer = document.createElement('div'); + mapContainer.id = 'deckgl-basemap'; + mapContainer.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;'; + wrapper.appendChild(mapContainer); + + // Map attribution (CARTO basemap + OpenStreetMap data) + const attribution = document.createElement('div'); + attribution.className = 'map-attribution'; + attribution.innerHTML = '© CARTO © OpenStreetMap'; + wrapper.appendChild(attribution); + + this.container.appendChild(wrapper); + } + + private initMapLibre(): void { + const preset = VIEW_PRESETS[this.state.view]; + const initialTheme = getCurrentTheme(); + + this.maplibreMap = new maplibregl.Map({ + container: 'deckgl-basemap', + style: initialTheme === 'light' ? LIGHT_STYLE : DARK_STYLE, + center: [preset.longitude, preset.latitude], + zoom: preset.zoom, + renderWorldCopies: false, + attributionControl: false, + interactive: true, + ...(MAP_INTERACTION_MODE === 'flat' + ? { + maxPitch: 0, + pitchWithRotate: false, + dragRotate: false, + touchPitch: false, + } + : {}), + }); + + const canvas = this.maplibreMap.getCanvas(); + canvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault(); + this.webglLost = true; + console.warn('[DeckGLMap] WebGL context lost — will restore when browser recovers'); + }); + canvas.addEventListener('webglcontextrestored', () => { + this.webglLost = false; + console.info('[DeckGLMap] WebGL context restored'); + this.maplibreMap?.triggerRepaint(); + }); + } + + private initDeck(): void { + if (!this.maplibreMap) return; + + this.deckOverlay = new MapboxOverlay({ + interleaved: true, + layers: this.buildLayers(), + getTooltip: (info: PickingInfo) => this.getTooltip(info), + onClick: (info: PickingInfo) => this.handleClick(info), + pickingRadius: 10, + useDevicePixels: window.devicePixelRatio > 2 ? 2 : true, + onError: (error: Error) => console.warn('[DeckGLMap] Render error (non-fatal):', error.message), + }); + + this.maplibreMap.addControl(this.deckOverlay as unknown as maplibregl.IControl); + + this.maplibreMap.on('movestart', () => { + if (this.moveTimeoutId) { + clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = null; + } + }); + + this.maplibreMap.on('moveend', () => { + this.lastSCZoom = -1; + this.rafUpdateLayers(); + }); + + this.maplibreMap.on('move', () => { + if (this.moveTimeoutId) clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = setTimeout(() => { + this.lastSCZoom = -1; + this.rafUpdateLayers(); + }, 100); + }); + + this.maplibreMap.on('zoom', () => { + if (this.moveTimeoutId) clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = setTimeout(() => { + this.lastSCZoom = -1; + this.rafUpdateLayers(); + }, 100); + }); + + this.maplibreMap.on('zoomend', () => { + const currentZoom = Math.floor(this.maplibreMap?.getZoom() || 2); + const thresholdCrossed = Math.abs(currentZoom - this.lastZoomThreshold) >= 1; + if (thresholdCrossed) { + this.lastZoomThreshold = currentZoom; + this.debouncedRebuildLayers(); + } + }); + } + + private setupResizeObserver(): void { + this.resizeObserver = new ResizeObserver(() => { + if (this.maplibreMap) { + this.maplibreMap.resize(); + } + }); + this.resizeObserver.observe(this.container); + } + + + private getSetSignature(set: Set): string { + return [...set].sort().join('|'); + } + + private hasRecentNews(now = Date.now()): boolean { + for (const ts of this.newsLocationFirstSeen.values()) { + if (now - ts < 30_000) return true; + } + return false; + } + + private getTimeRangeMs(range: TimeRange = this.state.timeRange): number { + const ranges: Record = { + '1h': 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '48h': 48 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + 'all': Infinity, + }; + return ranges[range]; + } + + private parseTime(value: Date | string | number | undefined | null): number | null { + if (value == null) return null; + const ts = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(ts) ? ts : null; + } + + private filterByTime( + items: T[], + getTime: (item: T) => Date | string | number | undefined | null + ): T[] { + if (this.state.timeRange === 'all') return items; + const cutoff = Date.now() - this.getTimeRangeMs(); + return items.filter((item) => { + const ts = this.parseTime(getTime(item)); + return ts == null ? true : ts >= cutoff; + }); + } + + private getFilteredProtests(): SocialUnrestEvent[] { + return this.filterByTime(this.protests, (event) => event.time); + } + + private filterMilitaryFlightClustersByTime(clusters: MilitaryFlightCluster[]): MilitaryFlightCluster[] { + return clusters + .map((cluster) => { + const flights = this.filterByTime(cluster.flights ?? [], (flight) => flight.lastSeen); + if (flights.length === 0) return null; + return { + ...cluster, + flights, + flightCount: flights.length, + }; + }) + .filter((cluster): cluster is MilitaryFlightCluster => cluster !== null); + } + + private filterMilitaryVesselClustersByTime(clusters: MilitaryVesselCluster[]): MilitaryVesselCluster[] { + return clusters + .map((cluster) => { + const vessels = this.filterByTime(cluster.vessels ?? [], (vessel) => vessel.lastAisUpdate); + if (vessels.length === 0) return null; + return { + ...cluster, + vessels, + vesselCount: vessels.length, + }; + }) + .filter((cluster): cluster is MilitaryVesselCluster => cluster !== null); + } + + private rebuildProtestSupercluster(source: SocialUnrestEvent[] = this.getFilteredProtests()): void { + this.protestSuperclusterSource = source; + const points = source.map((p, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] as [number, number] }, + properties: { + index: i, + country: p.country, + severity: p.severity, + eventType: p.eventType, + validated: Boolean(p.validated), + fatalities: Number.isFinite(p.fatalities) ? Number(p.fatalities) : 0, + }, + })); + this.protestSC = new Supercluster({ + radius: 60, + maxZoom: 14, + map: (props: Record) => ({ + index: Number(props.index ?? 0), + country: String(props.country ?? ''), + maxSeverityRank: props.severity === 'high' ? 2 : props.severity === 'medium' ? 1 : 0, + riotCount: props.eventType === 'riot' ? 1 : 0, + highSeverityCount: props.severity === 'high' ? 1 : 0, + verifiedCount: props.validated ? 1 : 0, + totalFatalities: Number(props.fatalities ?? 0) || 0, + }), + reduce: (acc: Record, props: Record) => { + acc.maxSeverityRank = Math.max(Number(acc.maxSeverityRank ?? 0), Number(props.maxSeverityRank ?? 0)); + acc.riotCount = Number(acc.riotCount ?? 0) + Number(props.riotCount ?? 0); + acc.highSeverityCount = Number(acc.highSeverityCount ?? 0) + Number(props.highSeverityCount ?? 0); + acc.verifiedCount = Number(acc.verifiedCount ?? 0) + Number(props.verifiedCount ?? 0); + acc.totalFatalities = Number(acc.totalFatalities ?? 0) + Number(props.totalFatalities ?? 0); + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.protestSC.load(points); + this.lastSCZoom = -1; + } + + private rebuildTechHQSupercluster(): void { + const points = TECH_HQS.map((h, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [h.lon, h.lat] as [number, number] }, + properties: { + index: i, + city: h.city, + country: h.country, + type: h.type, + }, + })); + this.techHQSC = new Supercluster({ + radius: 50, + maxZoom: 14, + map: (props: Record) => ({ + index: Number(props.index ?? 0), + city: String(props.city ?? ''), + country: String(props.country ?? ''), + faangCount: props.type === 'faang' ? 1 : 0, + unicornCount: props.type === 'unicorn' ? 1 : 0, + publicCount: props.type === 'public' ? 1 : 0, + }), + reduce: (acc: Record, props: Record) => { + acc.faangCount = Number(acc.faangCount ?? 0) + Number(props.faangCount ?? 0); + acc.unicornCount = Number(acc.unicornCount ?? 0) + Number(props.unicornCount ?? 0); + acc.publicCount = Number(acc.publicCount ?? 0) + Number(props.publicCount ?? 0); + if (!acc.city && props.city) acc.city = props.city; + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.techHQSC.load(points); + this.lastSCZoom = -1; + } + + private rebuildTechEventSupercluster(): void { + const points = this.techEvents.map((e, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] as [number, number] }, + properties: { + index: i, + location: e.location, + country: e.country, + daysUntil: e.daysUntil, + }, + })); + this.techEventSC = new Supercluster({ + radius: 50, + maxZoom: 14, + map: (props: Record) => { + const daysUntil = Number(props.daysUntil ?? Number.MAX_SAFE_INTEGER); + return { + index: Number(props.index ?? 0), + location: String(props.location ?? ''), + country: String(props.country ?? ''), + soonestDaysUntil: Number.isFinite(daysUntil) ? daysUntil : Number.MAX_SAFE_INTEGER, + soonCount: Number.isFinite(daysUntil) && daysUntil <= 14 ? 1 : 0, + }; + }, + reduce: (acc: Record, props: Record) => { + acc.soonestDaysUntil = Math.min( + Number(acc.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER), + Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER), + ); + acc.soonCount = Number(acc.soonCount ?? 0) + Number(props.soonCount ?? 0); + if (!acc.location && props.location) acc.location = props.location; + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.techEventSC.load(points); + this.lastSCZoom = -1; + } + + private rebuildDatacenterSupercluster(): void { + const activeDCs = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + const points = activeDCs.map((dc, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [dc.lon, dc.lat] as [number, number] }, + properties: { + index: i, + country: dc.country, + chipCount: dc.chipCount, + powerMW: dc.powerMW ?? 0, + status: dc.status, + }, + })); + this.datacenterSC = new Supercluster({ + radius: 70, + maxZoom: 14, + map: (props: Record) => ({ + index: Number(props.index ?? 0), + country: String(props.country ?? ''), + totalChips: Number(props.chipCount ?? 0) || 0, + totalPowerMW: Number(props.powerMW ?? 0) || 0, + existingCount: props.status === 'existing' ? 1 : 0, + plannedCount: props.status === 'planned' ? 1 : 0, + }), + reduce: (acc: Record, props: Record) => { + acc.totalChips = Number(acc.totalChips ?? 0) + Number(props.totalChips ?? 0); + acc.totalPowerMW = Number(acc.totalPowerMW ?? 0) + Number(props.totalPowerMW ?? 0); + acc.existingCount = Number(acc.existingCount ?? 0) + Number(props.existingCount ?? 0); + acc.plannedCount = Number(acc.plannedCount ?? 0) + Number(props.plannedCount ?? 0); + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.datacenterSC.load(points); + this.lastSCZoom = -1; + } + + private updateClusterData(): void { + const zoom = Math.floor(this.maplibreMap?.getZoom() ?? 2); + const bounds = this.maplibreMap?.getBounds(); + if (!bounds) return; + const bbox: [number, number, number, number] = [ + bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth(), + ]; + const boundsKey = `${bbox[0].toFixed(4)}:${bbox[1].toFixed(4)}:${bbox[2].toFixed(4)}:${bbox[3].toFixed(4)}`; + const layers = this.state.layers; + const useProtests = layers.protests && this.protestSuperclusterSource.length > 0; + const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs; + const useTechEvents = SITE_VARIANT === 'tech' && layers.techEvents && this.techEvents.length > 0; + const useDatacenterClusters = layers.datacenters && zoom < 5; + const layerMask = `${Number(useProtests)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`; + if (zoom === this.lastSCZoom && boundsKey === this.lastSCBoundsKey && layerMask === this.lastSCMask) return; + this.lastSCZoom = zoom; + this.lastSCBoundsKey = boundsKey; + this.lastSCMask = layerMask; + + if (useProtests && this.protestSC) { + this.protestClusters = this.protestSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.protestSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => this.protestSuperclusterSource[l.properties.index]).filter((x): x is SocialUnrestEvent => !!x); + const maxSeverityRank = Number(props.maxSeverityRank ?? 0); + const maxSev = maxSeverityRank >= 2 ? 'high' : maxSeverityRank === 1 ? 'medium' : 'low'; + const riotCount = Number(props.riotCount ?? 0); + const highSeverityCount = Number(props.highSeverityCount ?? 0); + const verifiedCount = Number(props.verifiedCount ?? 0); + const totalFatalities = Number(props.totalFatalities ?? 0); + const clusterCount = Number(f.properties.point_count ?? items.length); + const latestRiotEventTimeMs = items.reduce((max, it) => { + if (it.eventType !== 'riot' || it.sourceType === 'gdelt') return max; + const ts = it.time.getTime(); + return Number.isFinite(ts) ? Math.max(max, ts) : max; + }, 0); + return { + id: `pc-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + country: String(props.country ?? items[0]?.country ?? ''), + maxSeverity: maxSev as 'low' | 'medium' | 'high', + hasRiot: riotCount > 0, + latestRiotEventTimeMs: latestRiotEventTimeMs || undefined, + totalFatalities, + riotCount, + highSeverityCount, + verifiedCount, + sampled: items.length < clusterCount, + }; + } + const item = this.protestSuperclusterSource[f.properties.index]!; + return { + id: `pp-${f.properties.index}`, lat: item.lat, lon: item.lon, + count: 1, items: [item], country: item.country, + maxSeverity: item.severity, hasRiot: item.eventType === 'riot', + latestRiotEventTimeMs: + item.eventType === 'riot' && item.sourceType !== 'gdelt' && Number.isFinite(item.time.getTime()) + ? item.time.getTime() + : undefined, + totalFatalities: item.fatalities ?? 0, + riotCount: item.eventType === 'riot' ? 1 : 0, + highSeverityCount: item.severity === 'high' ? 1 : 0, + verifiedCount: item.validated ? 1 : 0, + sampled: false, + }; + }); + } else { + this.protestClusters = []; + } + + if (useTechHQ && this.techHQSC) { + this.techHQClusters = this.techHQSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.techHQSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => TECH_HQS[l.properties.index]).filter(Boolean) as typeof TECH_HQS; + const faangCount = Number(props.faangCount ?? 0); + const unicornCount = Number(props.unicornCount ?? 0); + const publicCount = Number(props.publicCount ?? 0); + const clusterCount = Number(f.properties.point_count ?? items.length); + const primaryType = faangCount >= unicornCount && faangCount >= publicCount + ? 'faang' + : unicornCount >= publicCount + ? 'unicorn' + : 'public'; + return { + id: `hc-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + city: String(props.city ?? items[0]?.city ?? ''), + country: String(props.country ?? items[0]?.country ?? ''), + primaryType, + faangCount, + unicornCount, + publicCount, + sampled: items.length < clusterCount, + }; + } + const item = TECH_HQS[f.properties.index]!; + return { + id: `hp-${f.properties.index}`, lat: item.lat, lon: item.lon, + count: 1, items: [item], city: item.city, country: item.country, + primaryType: item.type, + faangCount: item.type === 'faang' ? 1 : 0, + unicornCount: item.type === 'unicorn' ? 1 : 0, + publicCount: item.type === 'public' ? 1 : 0, + sampled: false, + }; + }); + } else { + this.techHQClusters = []; + } + + if (useTechEvents && this.techEventSC) { + this.techEventClusters = this.techEventSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.techEventSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => this.techEvents[l.properties.index]).filter((x): x is TechEventMarker => !!x); + const clusterCount = Number(f.properties.point_count ?? items.length); + const soonestDaysUntil = Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER); + const soonCount = Number(props.soonCount ?? 0); + return { + id: `ec-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + location: String(props.location ?? items[0]?.location ?? ''), + country: String(props.country ?? items[0]?.country ?? ''), + soonestDaysUntil: Number.isFinite(soonestDaysUntil) ? soonestDaysUntil : Number.MAX_SAFE_INTEGER, + soonCount, + sampled: items.length < clusterCount, + }; + } + const item = this.techEvents[f.properties.index]!; + return { + id: `ep-${f.properties.index}`, lat: item.lat, lon: item.lng, + count: 1, items: [item], location: item.location, country: item.country, + soonestDaysUntil: item.daysUntil, + soonCount: item.daysUntil <= 14 ? 1 : 0, + sampled: false, + }; + }); + } else { + this.techEventClusters = []; + } + + if (useDatacenterClusters && this.datacenterSC) { + const activeDCs = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + this.datacenterClusters = this.datacenterSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.datacenterSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => activeDCs[l.properties.index]).filter((x): x is AIDataCenter => !!x); + const clusterCount = Number(f.properties.point_count ?? items.length); + const existingCount = Number(props.existingCount ?? 0); + const plannedCount = Number(props.plannedCount ?? 0); + const totalChips = Number(props.totalChips ?? 0); + const totalPowerMW = Number(props.totalPowerMW ?? 0); + return { + id: `dc-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + region: String(props.country ?? items[0]?.country ?? ''), + country: String(props.country ?? items[0]?.country ?? ''), + totalChips, + totalPowerMW, + majorityExisting: existingCount >= Math.max(1, clusterCount / 2), + existingCount, + plannedCount, + sampled: items.length < clusterCount, + }; + } + const item = activeDCs[f.properties.index]!; + return { + id: `dp-${f.properties.index}`, lat: item.lat, lon: item.lon, + count: 1, items: [item], region: item.country, country: item.country, + totalChips: item.chipCount, totalPowerMW: item.powerMW ?? 0, + majorityExisting: item.status === 'existing', + existingCount: item.status === 'existing' ? 1 : 0, + plannedCount: item.status === 'planned' ? 1 : 0, + sampled: false, + }; + }); + } else { + this.datacenterClusters = []; + } + } + + + + + private isLayerVisible(layerKey: keyof MapLayers): boolean { + const threshold = LAYER_ZOOM_THRESHOLDS[layerKey]; + if (!threshold) return true; + const zoom = this.maplibreMap?.getZoom() || 2; + return zoom >= threshold.minZoom; + } + + private buildLayers(): LayersList { + const startTime = performance.now(); + // Refresh theme-aware overlay colors on each rebuild + COLORS = getOverlayColors(); + const layers: (Layer | null | false)[] = []; + const { layers: mapLayers } = this.state; + const filteredEarthquakes = this.filterByTime(this.earthquakes, (eq) => eq.occurredAt); + const filteredNaturalEvents = this.filterByTime(this.naturalEvents, (event) => event.date); + const filteredWeatherAlerts = this.filterByTime(this.weatherAlerts, (alert) => alert.onset); + const filteredOutages = this.filterByTime(this.outages, (outage) => outage.pubDate); + const filteredCableAdvisories = this.filterByTime(this.cableAdvisories, (advisory) => advisory.reported); + const filteredFlightDelays = this.filterByTime(this.flightDelays, (delay) => delay.updatedAt); + const filteredMilitaryFlights = this.filterByTime(this.militaryFlights, (flight) => flight.lastSeen); + const filteredMilitaryVessels = this.filterByTime(this.militaryVessels, (vessel) => vessel.lastAisUpdate); + const filteredMilitaryFlightClusters = this.filterMilitaryFlightClustersByTime(this.militaryFlightClusters); + const filteredMilitaryVesselClusters = this.filterMilitaryVesselClustersByTime(this.militaryVesselClusters); + const filteredUcdpEvents = this.filterByTime(this.ucdpEvents, (event) => event.date_start); + + // Undersea cables layer + if (mapLayers.cables) { + layers.push(this.createCablesLayer()); + } else { + this.layerCache.delete('cables-layer'); + } + + // Pipelines layer + if (mapLayers.pipelines) { + layers.push(this.createPipelinesLayer()); + } else { + this.layerCache.delete('pipelines-layer'); + } + + // Conflict zones layer + if (mapLayers.conflicts) { + layers.push(this.createConflictZonesLayer()); + } + + // Military bases layer — hidden at low zoom (E: progressive disclosure) + ghost + if (mapLayers.bases && this.isLayerVisible('bases')) { + layers.push(this.createBasesLayer()); + layers.push(this.createGhostLayer('bases-layer', MILITARY_BASES, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Nuclear facilities layer — hidden at low zoom + ghost + if (mapLayers.nuclear && this.isLayerVisible('nuclear')) { + layers.push(this.createNuclearLayer()); + layers.push(this.createGhostLayer('nuclear-layer', NUCLEAR_FACILITIES.filter(f => f.status !== 'decommissioned'), d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Gamma irradiators layer — hidden at low zoom + if (mapLayers.irradiators && this.isLayerVisible('irradiators')) { + layers.push(this.createIrradiatorsLayer()); + } + + // Spaceports layer — hidden at low zoom + if (mapLayers.spaceports && this.isLayerVisible('spaceports')) { + layers.push(this.createSpaceportsLayer()); + } + + // Hotspots layer (all hotspots including high/breaking, with pulse + ghost) + if (mapLayers.hotspots) { + layers.push(...this.createHotspotsLayers()); + } + + // Datacenters layer - SQUARE icons at zoom >= 5, cluster dots at zoom < 5 + const currentZoom = this.maplibreMap?.getZoom() || 2; + if (mapLayers.datacenters) { + if (currentZoom >= 5) { + layers.push(this.createDatacentersLayer()); + } else { + layers.push(...this.createDatacenterClusterLayers()); + } + } + + // Earthquakes layer + ghost for easier picking + if (mapLayers.natural && filteredEarthquakes.length > 0) { + layers.push(this.createEarthquakesLayer(filteredEarthquakes)); + layers.push(this.createGhostLayer('earthquakes-layer', filteredEarthquakes, d => [d.location?.longitude ?? 0, d.location?.latitude ?? 0], { radiusMinPixels: 12 })); + } + + // Natural events layer + if (mapLayers.natural && filteredNaturalEvents.length > 0) { + layers.push(this.createNaturalEventsLayer(filteredNaturalEvents)); + } + + // Satellite fires layer (NASA FIRMS) + if (mapLayers.fires && this.firmsFireData.length > 0) { + layers.push(this.createFiresLayer()); + } + + // Weather alerts layer + if (mapLayers.weather && filteredWeatherAlerts.length > 0) { + layers.push(this.createWeatherLayer(filteredWeatherAlerts)); + } + + // Internet outages layer + ghost for easier picking + if (mapLayers.outages && filteredOutages.length > 0) { + layers.push(this.createOutagesLayer(filteredOutages)); + layers.push(this.createGhostLayer('outages-layer', filteredOutages, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Cyber threat IOC layer + if (mapLayers.cyberThreats && this.cyberThreats.length > 0) { + layers.push(this.createCyberThreatsLayer()); + layers.push(this.createGhostLayer('cyber-threats-layer', this.cyberThreats, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // AIS density layer + if (mapLayers.ais && this.aisDensity.length > 0) { + layers.push(this.createAisDensityLayer()); + } + + // AIS disruptions layer (spoofing/jamming) + if (mapLayers.ais && this.aisDisruptions.length > 0) { + layers.push(this.createAisDisruptionsLayer()); + } + + // Strategic ports layer (shown with AIS) + if (mapLayers.ais) { + layers.push(this.createPortsLayer()); + } + + // Cable advisories layer (shown with cables) + if (mapLayers.cables && filteredCableAdvisories.length > 0) { + layers.push(this.createCableAdvisoriesLayer(filteredCableAdvisories)); + } + + // Repair ships layer (shown with cables) + if (mapLayers.cables && this.repairShips.length > 0) { + layers.push(this.createRepairShipsLayer()); + } + + // Flight delays layer + if (mapLayers.flights && filteredFlightDelays.length > 0) { + layers.push(this.createFlightDelaysLayer(filteredFlightDelays)); + } + + // Protests layer (Supercluster-based deck.gl layers) + if (mapLayers.protests && this.protests.length > 0) { + layers.push(...this.createProtestClusterLayers()); + } + + // Military vessels layer + if (mapLayers.military && filteredMilitaryVessels.length > 0) { + layers.push(this.createMilitaryVesselsLayer(filteredMilitaryVessels)); + } + + // Military vessel clusters layer + if (mapLayers.military && filteredMilitaryVesselClusters.length > 0) { + layers.push(this.createMilitaryVesselClustersLayer(filteredMilitaryVesselClusters)); + } + + // Military flights layer + if (mapLayers.military && filteredMilitaryFlights.length > 0) { + layers.push(this.createMilitaryFlightsLayer(filteredMilitaryFlights)); + } + + // Military flight clusters layer + if (mapLayers.military && filteredMilitaryFlightClusters.length > 0) { + layers.push(this.createMilitaryFlightClustersLayer(filteredMilitaryFlightClusters)); + } + + // Strategic waterways layer + if (mapLayers.waterways) { + layers.push(this.createWaterwaysLayer()); + } + + // Economic centers layer — hidden at low zoom + if (mapLayers.economic && this.isLayerVisible('economic')) { + layers.push(this.createEconomicCentersLayer()); + } + + // Finance variant layers + if (mapLayers.stockExchanges) { + layers.push(this.createStockExchangesLayer()); + } + if (mapLayers.financialCenters) { + layers.push(this.createFinancialCentersLayer()); + } + if (mapLayers.centralBanks) { + layers.push(this.createCentralBanksLayer()); + } + if (mapLayers.commodityHubs) { + layers.push(this.createCommodityHubsLayer()); + } + + // Critical minerals layer + if (mapLayers.minerals) { + layers.push(this.createMineralsLayer()); + } + + // APT Groups layer (geopolitical variant only - always shown, no toggle) + if (SITE_VARIANT !== 'tech' && SITE_VARIANT !== 'happy') { + layers.push(this.createAPTGroupsLayer()); + } + + // UCDP georeferenced events layer + if (mapLayers.ucdpEvents && filteredUcdpEvents.length > 0) { + layers.push(this.createUcdpEventsLayer(filteredUcdpEvents)); + } + + // Displacement flows arc layer + if (mapLayers.displacement && this.displacementFlows.length > 0) { + layers.push(this.createDisplacementArcsLayer()); + } + + // Climate anomalies heatmap layer + if (mapLayers.climate && this.climateAnomalies.length > 0) { + layers.push(this.createClimateHeatmapLayer()); + } + + // Trade routes layer + if (mapLayers.tradeRoutes) { + layers.push(this.createTradeRoutesLayer()); + layers.push(this.createTradeChokepointsLayer()); + } else { + this.layerCache.delete('trade-routes-layer'); + this.layerCache.delete('trade-chokepoints-layer'); + } + + // Tech variant layers (Supercluster-based deck.gl layers for HQs and events) + if (SITE_VARIANT === 'tech') { + if (mapLayers.startupHubs) { + layers.push(this.createStartupHubsLayer()); + } + if (mapLayers.techHQs) { + layers.push(...this.createTechHQClusterLayers()); + } + if (mapLayers.accelerators) { + layers.push(this.createAcceleratorsLayer()); + } + if (mapLayers.cloudRegions) { + layers.push(this.createCloudRegionsLayer()); + } + if (mapLayers.techEvents && this.techEvents.length > 0) { + layers.push(...this.createTechEventClusterLayers()); + } + } + + // Gulf FDI investments layer + if (mapLayers.gulfInvestments) { + layers.push(this.createGulfInvestmentsLayer()); + } + + // Positive events layer (happy variant) + if (mapLayers.positiveEvents && this.positiveEvents.length > 0) { + layers.push(...this.createPositiveEventsLayers()); + } + + // Kindness layer (happy variant -- green baseline pulses + real kindness events) + if (mapLayers.kindness && this.kindnessPoints.length > 0) { + layers.push(...this.createKindnessLayers()); + } + + // Phase 8: Happiness choropleth (rendered below point markers) + if (mapLayers.happiness) { + const choropleth = this.createHappinessChoroplethLayer(); + if (choropleth) layers.push(choropleth); + } + // Phase 8: Species recovery zones + if (mapLayers.speciesRecovery && this.speciesRecoveryZones.length > 0) { + layers.push(this.createSpeciesRecoveryLayer()); + } + // Phase 8: Renewable energy installations + if (mapLayers.renewableInstallations && this.renewableInstallations.length > 0) { + layers.push(this.createRenewableInstallationsLayer()); + } + + // News geo-locations (always shown if data exists) + if (this.newsLocations.length > 0) { + layers.push(...this.createNewsLocationsLayer()); + } + + const result = layers.filter(Boolean) as LayersList; + const elapsed = performance.now() - startTime; + if (import.meta.env.DEV && elapsed > 16) { + console.warn(`[DeckGLMap] buildLayers took ${elapsed.toFixed(2)}ms (>16ms budget), ${result.length} layers`); + } + return result; + } + + // Layer creation methods + private createCablesLayer(): PathLayer { + const highlightedCables = this.highlightedAssets.cable; + const cacheKey = 'cables-layer'; + const cached = this.layerCache.get(cacheKey) as PathLayer | undefined; + const highlightSignature = this.getSetSignature(highlightedCables); + const healthSignature = Object.keys(this.healthByCableId).sort().join(','); + if (cached && highlightSignature === this.lastCableHighlightSignature && healthSignature === this.lastCableHealthSignature) return cached; + + const health = this.healthByCableId; + const layer = new PathLayer({ + id: cacheKey, + data: UNDERSEA_CABLES, + getPath: (d) => d.points, + getColor: (d) => { + if (highlightedCables.has(d.id)) return COLORS.cableHighlight; + const h = health[d.id]; + if (h?.status === 'fault') return COLORS.cableFault; + if (h?.status === 'degraded') return COLORS.cableDegraded; + return COLORS.cable; + }, + getWidth: (d) => { + if (highlightedCables.has(d.id)) return 3; + const h = health[d.id]; + if (h?.status === 'fault') return 2.5; + if (h?.status === 'degraded') return 2; + return 1; + }, + widthMinPixels: 1, + widthMaxPixels: 5, + pickable: true, + updateTriggers: { highlighted: highlightSignature, health: healthSignature }, + }); + + this.lastCableHighlightSignature = highlightSignature; + this.lastCableHealthSignature = healthSignature; + this.layerCache.set(cacheKey, layer); + return layer; + } + + private createPipelinesLayer(): PathLayer { + const highlightedPipelines = this.highlightedAssets.pipeline; + const cacheKey = 'pipelines-layer'; + const cached = this.layerCache.get(cacheKey) as PathLayer | undefined; + const highlightSignature = this.getSetSignature(highlightedPipelines); + if (cached && highlightSignature === this.lastPipelineHighlightSignature) return cached; + + const layer = new PathLayer({ + id: cacheKey, + data: PIPELINES, + getPath: (d) => d.points, + getColor: (d) => { + if (highlightedPipelines.has(d.id)) { + return [255, 100, 100, 200] as [number, number, number, number]; + } + const colorKey = d.type as keyof typeof PIPELINE_COLORS; + const hex = PIPELINE_COLORS[colorKey] || '#666666'; + return this.hexToRgba(hex, 150); + }, + getWidth: (d) => highlightedPipelines.has(d.id) ? 3 : 1.5, + widthMinPixels: 1, + widthMaxPixels: 4, + pickable: true, + updateTriggers: { highlighted: highlightSignature }, + }); + + this.lastPipelineHighlightSignature = highlightSignature; + this.layerCache.set(cacheKey, layer); + return layer; + } + + private createConflictZonesLayer(): GeoJsonLayer { + const cacheKey = 'conflict-zones-layer'; + + const geojsonData = { + type: 'FeatureCollection' as const, + features: CONFLICT_ZONES.map(zone => ({ + type: 'Feature' as const, + properties: { id: zone.id, name: zone.name, intensity: zone.intensity }, + geometry: { + type: 'Polygon' as const, + coordinates: [zone.coords], + }, + })), + }; + + const layer = new GeoJsonLayer({ + id: cacheKey, + data: geojsonData, + filled: true, + stroked: true, + getFillColor: () => COLORS.conflict, + getLineColor: () => getCurrentTheme() === 'light' + ? [255, 0, 0, 120] as [number, number, number, number] + : [255, 0, 0, 180] as [number, number, number, number], + getLineWidth: 2, + lineWidthMinPixels: 1, + pickable: true, + }); + return layer; + } + + private createBasesLayer(): IconLayer { + const highlightedBases = this.highlightedAssets.base; + + // Base colors by operator type - semi-transparent for layering + // F: Fade in bases as you zoom — subtle at zoom 3, full at zoom 5+ + const zoom = this.maplibreMap?.getZoom() || 3; + const alphaScale = Math.min(1, (zoom - 2.5) / 2.5); // 0.2 at zoom 3, 1.0 at zoom 5 + const a = Math.round(160 * Math.max(0.3, alphaScale)); + + const getBaseColor = (type: string): [number, number, number, number] => { + switch (type) { + case 'us-nato': return [68, 136, 255, a]; + case 'russia': return [255, 68, 68, a]; + case 'china': return [255, 136, 68, a]; + case 'uk': return [68, 170, 255, a]; + case 'france': return [0, 85, 164, a]; + case 'india': return [255, 153, 51, a]; + case 'japan': return [188, 0, 45, a]; + default: return [136, 136, 136, a]; + } + }; + + // Military bases: TRIANGLE icons - color by operator, semi-transparent + return new IconLayer({ + id: 'bases-layer', + data: MILITARY_BASES, + getPosition: (d) => [d.lon, d.lat], + getIcon: () => 'triangleUp', + iconAtlas: MARKER_ICONS.triangleUp, + iconMapping: { triangleUp: { x: 0, y: 0, width: 32, height: 32, mask: true } }, + getSize: (d) => highlightedBases.has(d.id) ? 16 : 11, + getColor: (d) => { + if (highlightedBases.has(d.id)) { + return [255, 100, 100, 220] as [number, number, number, number]; + } + return getBaseColor(d.type); + }, + sizeScale: 1, + sizeMinPixels: 6, + sizeMaxPixels: 16, + pickable: true, + }); + } + + private createNuclearLayer(): IconLayer { + const highlightedNuclear = this.highlightedAssets.nuclear; + const data = NUCLEAR_FACILITIES.filter(f => f.status !== 'decommissioned'); + + // Nuclear: HEXAGON icons - yellow/orange color, semi-transparent + return new IconLayer({ + id: 'nuclear-layer', + data, + getPosition: (d) => [d.lon, d.lat], + getIcon: () => 'hexagon', + iconAtlas: MARKER_ICONS.hexagon, + iconMapping: { hexagon: { x: 0, y: 0, width: 32, height: 32, mask: true } }, + getSize: (d) => highlightedNuclear.has(d.id) ? 15 : 11, + getColor: (d) => { + if (highlightedNuclear.has(d.id)) { + return [255, 100, 100, 220] as [number, number, number, number]; + } + if (d.status === 'contested') { + return [255, 50, 50, 200] as [number, number, number, number]; + } + return [255, 220, 0, 200] as [number, number, number, number]; // Semi-transparent yellow + }, + sizeScale: 1, + sizeMinPixels: 6, + sizeMaxPixels: 15, + pickable: true, + }); + } + + private createIrradiatorsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'irradiators-layer', + data: GAMMA_IRRADIATORS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: [255, 100, 255, 180] as [number, number, number, number], // Magenta + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createSpaceportsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'spaceports-layer', + data: SPACEPORTS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: [200, 100, 255, 200] as [number, number, number, number], // Purple + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createPortsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'ports-layer', + data: PORTS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: (d) => { + // Color by port type (matching old Map.ts icons) + switch (d.type) { + case 'naval': return [100, 150, 255, 200] as [number, number, number, number]; // Blue - ⚓ + case 'oil': return [255, 140, 0, 200] as [number, number, number, number]; // Orange - 🛢️ + case 'lng': return [255, 200, 50, 200] as [number, number, number, number]; // Yellow - 🛢️ + case 'container': return [0, 200, 255, 180] as [number, number, number, number]; // Cyan - 🏭 + case 'mixed': return [150, 200, 150, 180] as [number, number, number, number]; // Green + case 'bulk': return [180, 150, 120, 180] as [number, number, number, number]; // Brown + default: return [0, 200, 255, 160] as [number, number, number, number]; + } + }, + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createFlightDelaysLayer(delays: AirportDelayAlert[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'flight-delays-layer', + data: delays, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + if (d.severity === 'GDP') return 15000; // Ground Delay Program + if (d.severity === 'GS') return 12000; // Ground Stop + return 8000; + }, + getFillColor: (d) => { + if (d.severity === 'GS') return [255, 50, 50, 200] as [number, number, number, number]; // Red for ground stops + if (d.severity === 'GDP') return [255, 150, 0, 200] as [number, number, number, number]; // Orange for delays + return [255, 200, 100, 180] as [number, number, number, number]; // Yellow + }, + radiusMinPixels: 4, + radiusMaxPixels: 15, + pickable: true, + }); + } + + private createGhostLayer(id: string, data: T[], getPosition: (d: T) => [number, number], opts: { radiusMinPixels?: number } = {}): ScatterplotLayer { + return new ScatterplotLayer({ + id: `${id}-ghost`, + data, + getPosition, + getRadius: 1, + radiusMinPixels: opts.radiusMinPixels ?? 12, + getFillColor: [0, 0, 0, 0], + pickable: true, + }); + } + + + private createDatacentersLayer(): IconLayer { + const highlightedDC = this.highlightedAssets.datacenter; + const data = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + + // Datacenters: SQUARE icons - purple color, semi-transparent for layering + return new IconLayer({ + id: 'datacenters-layer', + data, + getPosition: (d) => [d.lon, d.lat], + getIcon: () => 'square', + iconAtlas: MARKER_ICONS.square, + iconMapping: { square: { x: 0, y: 0, width: 32, height: 32, mask: true } }, + getSize: (d) => highlightedDC.has(d.id) ? 14 : 10, + getColor: (d) => { + if (highlightedDC.has(d.id)) { + return [255, 100, 100, 200] as [number, number, number, number]; + } + if (d.status === 'planned') { + return [136, 68, 255, 100] as [number, number, number, number]; // Transparent for planned + } + return [136, 68, 255, 140] as [number, number, number, number]; // ~55% opacity + }, + sizeScale: 1, + sizeMinPixels: 6, + sizeMaxPixels: 14, + pickable: true, + }); + } + + private createEarthquakesLayer(earthquakes: Earthquake[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'earthquakes-layer', + data: earthquakes, + getPosition: (d) => [d.location?.longitude ?? 0, d.location?.latitude ?? 0], + getRadius: (d) => Math.pow(2, d.magnitude) * 1000, + getFillColor: (d) => { + const mag = d.magnitude; + if (mag >= 6) return [255, 0, 0, 200] as [number, number, number, number]; + if (mag >= 5) return [255, 100, 0, 200] as [number, number, number, number]; + return COLORS.earthquake; + }, + radiusMinPixels: 4, + radiusMaxPixels: 30, + pickable: true, + }); + } + + private createNaturalEventsLayer(events: NaturalEvent[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'natural-events-layer', + data: events, + getPosition: (d: NaturalEvent) => [d.lon, d.lat], + getRadius: (d: NaturalEvent) => d.title.startsWith('🔴') ? 20000 : d.title.startsWith('🟠') ? 15000 : 8000, + getFillColor: (d: NaturalEvent) => { + if (d.title.startsWith('🔴')) return [255, 0, 0, 220] as [number, number, number, number]; + if (d.title.startsWith('🟠')) return [255, 140, 0, 200] as [number, number, number, number]; + return [255, 150, 50, 180] as [number, number, number, number]; + }, + radiusMinPixels: 5, + radiusMaxPixels: 18, + pickable: true, + }); + } + + private createFiresLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'fires-layer', + data: this.firmsFireData, + getPosition: (d: (typeof this.firmsFireData)[0]) => [d.lon, d.lat], + getRadius: (d: (typeof this.firmsFireData)[0]) => Math.min(d.frp * 200, 30000) || 5000, + getFillColor: (d: (typeof this.firmsFireData)[0]) => { + if (d.brightness > 400) return [255, 30, 0, 220] as [number, number, number, number]; + if (d.brightness > 350) return [255, 140, 0, 200] as [number, number, number, number]; + return [255, 220, 50, 180] as [number, number, number, number]; + }, + radiusMinPixels: 3, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createWeatherLayer(alerts: WeatherAlert[]): ScatterplotLayer { + // Filter weather alerts that have centroid coordinates + const alertsWithCoords = alerts.filter(a => a.centroid && a.centroid.length === 2); + + return new ScatterplotLayer({ + id: 'weather-layer', + data: alertsWithCoords, + getPosition: (d) => d.centroid as [number, number], // centroid is [lon, lat] + getRadius: 25000, + getFillColor: (d) => { + if (d.severity === 'Extreme') return [255, 0, 0, 200] as [number, number, number, number]; + if (d.severity === 'Severe') return [255, 100, 0, 180] as [number, number, number, number]; + if (d.severity === 'Moderate') return [255, 170, 0, 160] as [number, number, number, number]; + return COLORS.weather; + }, + radiusMinPixels: 8, + radiusMaxPixels: 20, + pickable: true, + }); + } + + private createOutagesLayer(outages: InternetOutage[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'outages-layer', + data: outages, + getPosition: (d) => [d.lon, d.lat], + getRadius: 20000, + getFillColor: COLORS.outage, + radiusMinPixels: 6, + radiusMaxPixels: 18, + pickable: true, + }); + } + + private createCyberThreatsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'cyber-threats-layer', + data: this.cyberThreats, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + switch (d.severity) { + case 'critical': return 22000; + case 'high': return 17000; + case 'medium': return 13000; + default: return 9000; + } + }, + getFillColor: (d) => { + switch (d.severity) { + case 'critical': return [255, 61, 0, 225] as [number, number, number, number]; + case 'high': return [255, 102, 0, 205] as [number, number, number, number]; + case 'medium': return [255, 176, 0, 185] as [number, number, number, number]; + default: return [255, 235, 59, 170] as [number, number, number, number]; + } + }, + radiusMinPixels: 6, + radiusMaxPixels: 18, + pickable: true, + stroked: true, + getLineColor: [255, 255, 255, 160] as [number, number, number, number], + lineWidthMinPixels: 1, + }); + } + + private createAisDensityLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'ais-density-layer', + data: this.aisDensity, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 4000 + d.intensity * 8000, + getFillColor: (d) => { + const intensity = Math.min(Math.max(d.intensity, 0.15), 1); + const isCongested = (d.deltaPct || 0) >= 15; + const alpha = Math.round(40 + intensity * 160); + // Orange for congested areas, cyan for normal traffic + if (isCongested) { + return [255, 183, 3, alpha] as [number, number, number, number]; // #ffb703 + } + return [0, 209, 255, alpha] as [number, number, number, number]; // #00d1ff + }, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createAisDisruptionsLayer(): ScatterplotLayer { + // AIS spoofing/jamming events + return new ScatterplotLayer({ + id: 'ais-disruptions-layer', + data: this.aisDisruptions, + getPosition: (d) => [d.lon, d.lat], + getRadius: 12000, + getFillColor: (d) => { + // Color by severity/type + if (d.severity === 'high' || d.type === 'spoofing') { + return [255, 50, 50, 220] as [number, number, number, number]; // Red + } + if (d.severity === 'medium') { + return [255, 150, 0, 200] as [number, number, number, number]; // Orange + } + return [255, 200, 100, 180] as [number, number, number, number]; // Yellow + }, + radiusMinPixels: 6, + radiusMaxPixels: 14, + pickable: true, + stroked: true, + getLineColor: [255, 255, 255, 150] as [number, number, number, number], + lineWidthMinPixels: 1, + }); + } + + private createCableAdvisoriesLayer(advisories: CableAdvisory[]): ScatterplotLayer { + // Cable fault/maintenance advisories + return new ScatterplotLayer({ + id: 'cable-advisories-layer', + data: advisories, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: (d) => { + if (d.severity === 'fault') { + return [255, 50, 50, 220] as [number, number, number, number]; // Red for faults + } + return [255, 200, 0, 200] as [number, number, number, number]; // Yellow for maintenance + }, + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + stroked: true, + getLineColor: [0, 200, 255, 200] as [number, number, number, number], // Cyan outline (cable color) + lineWidthMinPixels: 2, + }); + } + + private createRepairShipsLayer(): ScatterplotLayer { + // Cable repair ships + return new ScatterplotLayer({ + id: 'repair-ships-layer', + data: this.repairShips, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: [0, 255, 200, 200] as [number, number, number, number], // Teal + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createMilitaryVesselsLayer(vessels: MilitaryVessel[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-vessels-layer', + data: vessels, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: (d) => { + if (d.usniSource) return [255, 160, 60, 160] as [number, number, number, number]; // Orange, lower alpha for USNI-only + return COLORS.vesselMilitary; + }, + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + stroked: true, + getLineColor: (d) => { + if (d.usniSource) return [255, 180, 80, 200] as [number, number, number, number]; // Orange outline + return [0, 0, 0, 0] as [number, number, number, number]; // No outline for AIS + }, + lineWidthMinPixels: 2, + }); + } + + private createMilitaryVesselClustersLayer(clusters: MilitaryVesselCluster[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-vessel-clusters-layer', + data: clusters, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 15000 + (d.vesselCount || 1) * 3000, + getFillColor: (d) => { + // Vessel types: 'exercise' | 'deployment' | 'transit' | 'unknown' + const activity = d.activityType || 'unknown'; + if (activity === 'exercise' || activity === 'deployment') return [255, 100, 100, 200] as [number, number, number, number]; + if (activity === 'transit') return [255, 180, 100, 180] as [number, number, number, number]; + return [200, 150, 150, 160] as [number, number, number, number]; + }, + radiusMinPixels: 8, + radiusMaxPixels: 25, + pickable: true, + }); + } + + private createMilitaryFlightsLayer(flights: MilitaryFlight[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-flights-layer', + data: flights, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: COLORS.flightMilitary, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createMilitaryFlightClustersLayer(clusters: MilitaryFlightCluster[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-flight-clusters-layer', + data: clusters, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 15000 + (d.flightCount || 1) * 3000, + getFillColor: (d) => { + const activity = d.activityType || 'unknown'; + if (activity === 'exercise' || activity === 'patrol') return [100, 150, 255, 200] as [number, number, number, number]; + if (activity === 'transport') return [255, 200, 100, 180] as [number, number, number, number]; + return [150, 150, 200, 160] as [number, number, number, number]; + }, + radiusMinPixels: 8, + radiusMaxPixels: 25, + pickable: true, + }); + } + + private createWaterwaysLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'waterways-layer', + data: STRATEGIC_WATERWAYS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: [100, 150, 255, 180] as [number, number, number, number], + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createEconomicCentersLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'economic-centers-layer', + data: ECONOMIC_CENTERS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: [255, 215, 0, 180] as [number, number, number, number], + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createStockExchangesLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'stock-exchanges-layer', + data: STOCK_EXCHANGES, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.tier === 'mega' ? 18000 : d.tier === 'major' ? 14000 : 11000, + getFillColor: (d) => { + if (d.tier === 'mega') return [255, 215, 80, 220] as [number, number, number, number]; + if (d.tier === 'major') return COLORS.stockExchange; + return [140, 210, 255, 190] as [number, number, number, number]; + }, + radiusMinPixels: 5, + radiusMaxPixels: 14, + pickable: true, + }); + } + + private createFinancialCentersLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'financial-centers-layer', + data: FINANCIAL_CENTERS, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.type === 'global' ? 17000 : d.type === 'regional' ? 13000 : 10000, + getFillColor: (d) => { + if (d.type === 'global') return COLORS.financialCenter; + if (d.type === 'regional') return [0, 190, 130, 185] as [number, number, number, number]; + return [0, 150, 110, 165] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createCentralBanksLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'central-banks-layer', + data: CENTRAL_BANKS, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.type === 'major' ? 15000 : d.type === 'supranational' ? 17000 : 12000, + getFillColor: (d) => { + if (d.type === 'major') return COLORS.centralBank; + if (d.type === 'supranational') return [255, 235, 140, 220] as [number, number, number, number]; + return [235, 180, 80, 185] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createCommodityHubsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'commodity-hubs-layer', + data: COMMODITY_HUBS, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.type === 'exchange' ? 14000 : d.type === 'port' ? 12000 : 10000, + getFillColor: (d) => { + if (d.type === 'exchange') return COLORS.commodityHub; + if (d.type === 'port') return [80, 170, 255, 190] as [number, number, number, number]; + return [255, 110, 80, 185] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: 11, + pickable: true, + }); + } + + private createAPTGroupsLayer(): ScatterplotLayer { + // APT Groups - cyber threat actor markers (geopolitical variant only) + // Made subtle to avoid visual clutter - small orange dots + return new ScatterplotLayer({ + id: 'apt-groups-layer', + data: APT_GROUPS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: [255, 140, 0, 140] as [number, number, number, number], // Subtle orange + radiusMinPixels: 4, + radiusMaxPixels: 8, + pickable: true, + stroked: false, // No outline - cleaner look + }); + } + + private createMineralsLayer(): ScatterplotLayer { + // Critical minerals projects + return new ScatterplotLayer({ + id: 'minerals-layer', + data: CRITICAL_MINERALS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: (d) => { + // Color by mineral type + switch (d.mineral) { + case 'Lithium': return [0, 200, 255, 200] as [number, number, number, number]; // Cyan + case 'Cobalt': return [100, 100, 255, 200] as [number, number, number, number]; // Blue + case 'Rare Earths': return [255, 100, 200, 200] as [number, number, number, number]; // Pink + case 'Nickel': return [100, 255, 100, 200] as [number, number, number, number]; // Green + default: return [200, 200, 200, 200] as [number, number, number, number]; // Gray + } + }, + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + // Tech variant layers + private createStartupHubsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'startup-hubs-layer', + data: STARTUP_HUBS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: COLORS.startupHub, + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createAcceleratorsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'accelerators-layer', + data: ACCELERATORS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: COLORS.accelerator, + radiusMinPixels: 3, + radiusMaxPixels: 8, + pickable: true, + }); + } + + private createCloudRegionsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'cloud-regions-layer', + data: CLOUD_REGIONS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 12000, + getFillColor: COLORS.cloudRegion, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createProtestClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'protest-clusters-layer', + data: this.protestClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 15000 + d.count * 2000, + radiusMinPixels: 6, + radiusMaxPixels: 22, + getFillColor: d => { + if (d.hasRiot) return [220, 40, 40, 200] as [number, number, number, number]; + if (d.maxSeverity === 'high') return [255, 80, 60, 180] as [number, number, number, number]; + if (d.maxSeverity === 'medium') return [255, 160, 40, 160] as [number, number, number, number]; + return [255, 220, 80, 140] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom, getFillColor: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('protest-clusters-layer', this.protestClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.protestClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'protest-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + const pulseClusters = this.protestClusters.filter(c => c.maxSeverity === 'high' || c.hasRiot); + if (pulseClusters.length > 0) { + const pulse = 1.0 + 0.8 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 400)); + layers.push(new ScatterplotLayer({ + id: 'protest-clusters-pulse', + data: pulseClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 15000 + d.count * 2000, + radiusScale: pulse, + radiusMinPixels: 8, + radiusMaxPixels: 30, + stroked: true, + filled: false, + getLineColor: d => d.hasRiot ? [220, 40, 40, 120] as [number, number, number, number] : [255, 80, 60, 100] as [number, number, number, number], + lineWidthMinPixels: 1.5, + pickable: false, + updateTriggers: { radiusScale: this.pulseTime }, + })); + } + + return layers; + } + + private createTechHQClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + const zoom = this.maplibreMap?.getZoom() || 2; + + layers.push(new ScatterplotLayer({ + id: 'tech-hq-clusters-layer', + data: this.techHQClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 10000 + d.count * 1500, + radiusMinPixels: 5, + radiusMaxPixels: 18, + getFillColor: d => { + if (d.primaryType === 'faang') return [0, 220, 120, 200] as [number, number, number, number]; + if (d.primaryType === 'unicorn') return [255, 100, 200, 180] as [number, number, number, number]; + return [80, 160, 255, 180] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('tech-hq-clusters-layer', this.techHQClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.techHQClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'tech-hq-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + if (zoom >= 3) { + const singles = this.techHQClusters.filter(c => c.count === 1); + if (singles.length > 0) { + layers.push(new TextLayer({ + id: 'tech-hq-clusters-label', + data: singles, + getText: d => d.items[0]?.company ?? '', + getPosition: d => [d.lon, d.lat], + getSize: 11, + getColor: [220, 220, 220, 200], + getPixelOffset: [0, 12], + pickable: false, + fontFamily: 'system-ui, sans-serif', + })); + } + } + + return layers; + } + + private createTechEventClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'tech-event-clusters-layer', + data: this.techEventClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 10000 + d.count * 1500, + radiusMinPixels: 5, + radiusMaxPixels: 18, + getFillColor: d => { + if (d.soonestDaysUntil <= 14) return [255, 220, 50, 200] as [number, number, number, number]; + return [80, 140, 255, 180] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('tech-event-clusters-layer', this.techEventClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.techEventClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'tech-event-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + return layers; + } + + private createDatacenterClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'datacenter-clusters-layer', + data: this.datacenterClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 15000 + d.count * 2000, + radiusMinPixels: 6, + radiusMaxPixels: 20, + getFillColor: d => { + if (d.majorityExisting) return [160, 80, 255, 180] as [number, number, number, number]; + return [80, 160, 255, 180] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('datacenter-clusters-layer', this.datacenterClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.datacenterClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'datacenter-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + return layers; + } + + private createHotspotsLayers(): Layer[] { + const zoom = this.maplibreMap?.getZoom() || 2; + const zoomScale = Math.min(1, (zoom - 1) / 3); + const maxPx = 6 + Math.round(14 * zoomScale); + const baseOpacity = zoom < 2.5 ? 0.5 : zoom < 4 ? 0.7 : 1.0; + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'hotspots-layer', + data: this.hotspots, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + const score = d.escalationScore || 1; + return 10000 + score * 5000; + }, + getFillColor: (d) => { + const score = d.escalationScore || 1; + const a = Math.round((score >= 4 ? 200 : score >= 2 ? 200 : 180) * baseOpacity); + if (score >= 4) return [255, 68, 68, a] as [number, number, number, number]; + if (score >= 2) return [255, 165, 0, a] as [number, number, number, number]; + return [255, 255, 0, a] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: maxPx, + pickable: true, + stroked: true, + getLineColor: (d) => + d.hasBreaking ? [255, 255, 255, 255] as [number, number, number, number] : [0, 0, 0, 0] as [number, number, number, number], + lineWidthMinPixels: 2, + })); + + layers.push(this.createGhostLayer('hotspots-layer', this.hotspots, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const highHotspots = this.hotspots.filter(h => h.level === 'high' || h.hasBreaking); + if (highHotspots.length > 0) { + const pulse = 1.0 + 0.8 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 400)); + layers.push(new ScatterplotLayer({ + id: 'hotspots-pulse', + data: highHotspots, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + const score = d.escalationScore || 1; + return 10000 + score * 5000; + }, + radiusScale: pulse, + radiusMinPixels: 6, + radiusMaxPixels: 30, + stroked: true, + filled: false, + getLineColor: (d) => { + const a = Math.round(120 * baseOpacity); + return d.hasBreaking ? [255, 50, 50, a] as [number, number, number, number] : [255, 165, 0, a] as [number, number, number, number]; + }, + lineWidthMinPixels: 1.5, + pickable: false, + updateTriggers: { radiusScale: this.pulseTime }, + })); + + } + + return layers; + } + + private createGulfInvestmentsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'gulf-investments-layer', + data: GULF_INVESTMENTS, + getPosition: (d: GulfInvestment) => [d.lon, d.lat], + getRadius: (d: GulfInvestment) => { + if (!d.investmentUSD) return 20000; + if (d.investmentUSD >= 50000) return 70000; + if (d.investmentUSD >= 10000) return 55000; + if (d.investmentUSD >= 1000) return 40000; + return 25000; + }, + getFillColor: (d: GulfInvestment) => + d.investingCountry === 'SA' ? COLORS.gulfInvestmentSA : COLORS.gulfInvestmentUAE, + getLineColor: [255, 255, 255, 80] as [number, number, number, number], + lineWidthMinPixels: 1, + radiusMinPixels: 5, + radiusMaxPixels: 28, + pickable: true, + }); + } + + private pulseTime = 0; + + private canPulse(now = Date.now()): boolean { + return now - this.startupTime > 60_000; + } + + private hasRecentRiot(now = Date.now(), windowMs = 2 * 60 * 60 * 1000): boolean { + const hasRecentClusterRiot = this.protestClusters.some(c => + c.hasRiot && c.latestRiotEventTimeMs != null && (now - c.latestRiotEventTimeMs) < windowMs + ); + if (hasRecentClusterRiot) return true; + + // Fallback to raw protests because syncPulseAnimation can run before cluster data refreshes. + return this.protests.some((p) => { + if (p.eventType !== 'riot' || p.sourceType === 'gdelt') return false; + const ts = p.time.getTime(); + return Number.isFinite(ts) && (now - ts) < windowMs; + }); + } + + private needsPulseAnimation(now = Date.now()): boolean { + return this.hasRecentNews(now) + || this.hasRecentRiot(now) + || this.hotspots.some(h => h.hasBreaking) + || this.positiveEvents.some(e => e.count > 10) + || this.kindnessPoints.some(p => p.type === 'real'); + } + + private syncPulseAnimation(now = Date.now()): void { + if (this.renderPaused) { + if (this.newsPulseIntervalId !== null) this.stopPulseAnimation(); + return; + } + const shouldPulse = this.canPulse(now) && this.needsPulseAnimation(now); + if (shouldPulse && this.newsPulseIntervalId === null) { + this.startPulseAnimation(); + } else if (!shouldPulse && this.newsPulseIntervalId !== null) { + this.stopPulseAnimation(); + } + } + + private startPulseAnimation(): void { + if (this.newsPulseIntervalId !== null) return; + const PULSE_UPDATE_INTERVAL_MS = 500; + + this.newsPulseIntervalId = setInterval(() => { + const now = Date.now(); + if (!this.needsPulseAnimation(now)) { + this.pulseTime = now; + this.stopPulseAnimation(); + this.rafUpdateLayers(); + return; + } + this.pulseTime = now; + this.rafUpdateLayers(); + }, PULSE_UPDATE_INTERVAL_MS); + } + + private stopPulseAnimation(): void { + if (this.newsPulseIntervalId !== null) { + clearInterval(this.newsPulseIntervalId); + this.newsPulseIntervalId = null; + } + } + + private createNewsLocationsLayer(): ScatterplotLayer[] { + const zoom = this.maplibreMap?.getZoom() || 2; + const alphaScale = zoom < 2.5 ? 0.4 : zoom < 4 ? 0.7 : 1.0; + const filteredNewsLocations = this.filterByTime(this.newsLocations, (location) => location.timestamp); + const THREAT_RGB: Record = { + critical: [239, 68, 68], + high: [249, 115, 22], + medium: [234, 179, 8], + low: [34, 197, 94], + info: [59, 130, 246], + }; + const THREAT_ALPHA: Record = { + critical: 220, + high: 190, + medium: 160, + low: 120, + info: 80, + }; + + const now = this.pulseTime || Date.now(); + const PULSE_DURATION = 30_000; + + const layers: ScatterplotLayer[] = [ + new ScatterplotLayer({ + id: 'news-locations-layer', + data: filteredNewsLocations, + getPosition: (d) => [d.lon, d.lat], + getRadius: 18000, + getFillColor: (d) => { + const rgb = THREAT_RGB[d.threatLevel] || [59, 130, 246]; + const a = Math.round((THREAT_ALPHA[d.threatLevel] || 120) * alphaScale); + return [...rgb, a] as [number, number, number, number]; + }, + radiusMinPixels: 3, + radiusMaxPixels: 12, + pickable: true, + }), + ]; + + const recentNews = filteredNewsLocations.filter(d => { + const firstSeen = this.newsLocationFirstSeen.get(d.title); + return firstSeen && (now - firstSeen) < PULSE_DURATION; + }); + + if (recentNews.length > 0) { + const pulse = 1.0 + 1.5 * (0.5 + 0.5 * Math.sin(now / 318)); + + layers.push(new ScatterplotLayer({ + id: 'news-pulse-layer', + data: recentNews, + getPosition: (d) => [d.lon, d.lat], + getRadius: 18000, + radiusScale: pulse, + radiusMinPixels: 6, + radiusMaxPixels: 30, + pickable: false, + stroked: true, + filled: false, + getLineColor: (d) => { + const rgb = THREAT_RGB[d.threatLevel] || [59, 130, 246]; + const firstSeen = this.newsLocationFirstSeen.get(d.title) || now; + const age = now - firstSeen; + const fadeOut = Math.max(0, 1 - age / PULSE_DURATION); + const a = Math.round(150 * fadeOut * alphaScale); + return [...rgb, a] as [number, number, number, number]; + }, + lineWidthMinPixels: 1.5, + updateTriggers: { pulseTime: now }, + })); + } + + return layers; + } + + private createPositiveEventsLayers(): Layer[] { + const layers: Layer[] = []; + + const getCategoryColor = (category: string): [number, number, number, number] => { + switch (category) { + case 'nature-wildlife': + case 'humanity-kindness': + return [34, 197, 94, 200]; // green + case 'science-health': + case 'innovation-tech': + case 'climate-wins': + return [234, 179, 8, 200]; // gold + case 'culture-community': + return [139, 92, 246, 200]; // purple + default: + return [34, 197, 94, 200]; // green default + } + }; + + // Dot layer (tooltip on hover via getTooltip) + layers.push(new ScatterplotLayer({ + id: 'positive-events-layer', + data: this.positiveEvents, + getPosition: (d: PositiveGeoEvent) => [d.lon, d.lat], + getRadius: 12000, + getFillColor: (d: PositiveGeoEvent) => getCategoryColor(d.category), + radiusMinPixels: 5, + radiusMaxPixels: 10, + pickable: true, + })); + + // Gentle pulse ring for significant events (count > 8) + const significantEvents = this.positiveEvents.filter(e => e.count > 8); + if (significantEvents.length > 0) { + const pulse = 1.0 + 0.4 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 800)); + layers.push(new ScatterplotLayer({ + id: 'positive-events-pulse', + data: significantEvents, + getPosition: (d: PositiveGeoEvent) => [d.lon, d.lat], + getRadius: 15000, + radiusScale: pulse, + radiusMinPixels: 8, + radiusMaxPixels: 24, + stroked: true, + filled: false, + getLineColor: (d: PositiveGeoEvent) => getCategoryColor(d.category), + lineWidthMinPixels: 1.5, + pickable: false, + updateTriggers: { radiusScale: this.pulseTime }, + })); + } + + return layers; + } + + private createKindnessLayers(): Layer[] { + const layers: Layer[] = []; + if (this.kindnessPoints.length === 0) return layers; + + // Dot layer (tooltip on hover via getTooltip) + layers.push(new ScatterplotLayer({ + id: 'kindness-layer', + data: this.kindnessPoints, + getPosition: (d: KindnessPoint) => [d.lon, d.lat], + getRadius: 12000, + getFillColor: [74, 222, 128, 200] as [number, number, number, number], + radiusMinPixels: 5, + radiusMaxPixels: 10, + pickable: true, + })); + + // Pulse for real events + const pulse = 1.0 + 0.4 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 800)); + layers.push(new ScatterplotLayer({ + id: 'kindness-pulse', + data: this.kindnessPoints, + getPosition: (d: KindnessPoint) => [d.lon, d.lat], + getRadius: 14000, + radiusScale: pulse, + radiusMinPixels: 6, + radiusMaxPixels: 18, + stroked: true, + filled: false, + getLineColor: [74, 222, 128, 80] as [number, number, number, number], + lineWidthMinPixels: 1, + pickable: false, + updateTriggers: { radiusScale: this.pulseTime }, + })); + + return layers; + } + + private createHappinessChoroplethLayer(): GeoJsonLayer | null { + if (!this.countriesGeoJsonData || this.happinessScores.size === 0) return null; + const scores = this.happinessScores; + return new GeoJsonLayer({ + id: 'happiness-choropleth-layer', + data: this.countriesGeoJsonData, + filled: true, + stroked: true, + getFillColor: (feature: { properties?: Record }) => { + const code = feature.properties?.['ISO3166-1-Alpha-2'] as string | undefined; + const score = code ? scores.get(code) : undefined; + if (score == null) return [0, 0, 0, 0] as [number, number, number, number]; + const t = score / 10; + return [ + Math.round(40 + (1 - t) * 180), + Math.round(180 + t * 60), + Math.round(40 + (1 - t) * 100), + 140, + ] as [number, number, number, number]; + }, + getLineColor: [100, 100, 100, 60] as [number, number, number, number], + getLineWidth: 1, + lineWidthMinPixels: 0.5, + pickable: true, + updateTriggers: { getFillColor: [scores.size] }, + }); + } + + private createSpeciesRecoveryLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'species-recovery-layer', + data: this.speciesRecoveryZones, + getPosition: (d: (typeof this.speciesRecoveryZones)[number]) => [d.recoveryZone.lon, d.recoveryZone.lat], + getRadius: 50000, + radiusMinPixels: 8, + radiusMaxPixels: 25, + getFillColor: [74, 222, 128, 120] as [number, number, number, number], + stroked: true, + getLineColor: [74, 222, 128, 200] as [number, number, number, number], + lineWidthMinPixels: 1.5, + pickable: true, + }); + } + + private createRenewableInstallationsLayer(): ScatterplotLayer { + const typeColors: Record = { + solar: [255, 200, 50, 200], + wind: [100, 200, 255, 200], + hydro: [0, 180, 180, 200], + geothermal: [255, 150, 80, 200], + }; + const typeLineColors: Record = { + solar: [255, 200, 50, 255], + wind: [100, 200, 255, 255], + hydro: [0, 180, 180, 255], + geothermal: [255, 150, 80, 255], + }; + return new ScatterplotLayer({ + id: 'renewable-installations-layer', + data: this.renewableInstallations, + getPosition: (d: RenewableInstallation) => [d.lon, d.lat], + getRadius: 30000, + radiusMinPixels: 5, + radiusMaxPixels: 18, + getFillColor: (d: RenewableInstallation) => typeColors[d.type] ?? [200, 200, 200, 200] as [number, number, number, number], + stroked: true, + getLineColor: (d: RenewableInstallation) => typeLineColors[d.type] ?? [200, 200, 200, 255] as [number, number, number, number], + lineWidthMinPixels: 1, + pickable: true, + }); + } + + private getTooltip(info: PickingInfo): { html: string } | null { + if (!info.object) return null; + + const rawLayerId = info.layer?.id || ''; + const layerId = rawLayerId.endsWith('-ghost') ? rawLayerId.slice(0, -6) : rawLayerId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = info.object as any; + const text = (value: unknown): string => escapeHtml(String(value ?? '')); + + switch (layerId) { + case 'hotspots-layer': + return { html: `
${text(obj.name)}
${text(obj.subtext)}
` }; + case 'earthquakes-layer': + return { html: `
M${(obj.magnitude || 0).toFixed(1)} ${t('components.deckgl.tooltip.earthquake')}
${text(obj.place)}
` }; + case 'military-vessels-layer': + return { html: `
${text(obj.name)}
${text(obj.operatorCountry)}
` }; + case 'military-flights-layer': + return { html: `
${text(obj.callsign || obj.registration || t('components.deckgl.tooltip.militaryAircraft'))}
${text(obj.type)}
` }; + case 'military-vessel-clusters-layer': + return { html: `
${text(obj.name || t('components.deckgl.tooltip.vesselCluster'))}
${obj.vesselCount || 0} ${t('components.deckgl.tooltip.vessels')}
${text(obj.activityType)}
` }; + case 'military-flight-clusters-layer': + return { html: `
${text(obj.name || t('components.deckgl.tooltip.flightCluster'))}
${obj.flightCount || 0} ${t('components.deckgl.tooltip.aircraft')}
${text(obj.activityType)}
` }; + case 'protests-layer': + return { html: `
${text(obj.title)}
${text(obj.country)}
` }; + case 'protest-clusters-layer': + if (obj.count === 1) { + const item = obj.items?.[0]; + return { html: `
${text(item?.title || t('components.deckgl.tooltip.protest'))}
${text(item?.city || item?.country || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.protestsCount', { count: String(obj.count) })}
${text(obj.country)}
` }; + case 'tech-hq-clusters-layer': + if (obj.count === 1) { + const hq = obj.items?.[0]; + return { html: `
${text(hq?.company || '')}
${text(hq?.city || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.techHQsCount', { count: String(obj.count) })}
${text(obj.city)}
` }; + case 'tech-event-clusters-layer': + if (obj.count === 1) { + const ev = obj.items?.[0]; + return { html: `
${text(ev?.title || '')}
${text(ev?.location || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.techEventsCount', { count: String(obj.count) })}
${text(obj.location)}
` }; + case 'datacenter-clusters-layer': + if (obj.count === 1) { + const dc = obj.items?.[0]; + return { html: `
${text(dc?.name || '')}
${text(dc?.owner || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.dataCentersCount', { count: String(obj.count) })}
${text(obj.country)}
` }; + case 'bases-layer': + return { html: `
${text(obj.name)}
${text(obj.country)}
` }; + case 'nuclear-layer': + return { html: `
${text(obj.name)}
${text(obj.type)}
` }; + case 'datacenters-layer': + return { html: `
${text(obj.name)}
${text(obj.owner)}
` }; + case 'cables-layer': + return { html: `
${text(obj.name)}
${t('components.deckgl.tooltip.underseaCable')}
` }; + case 'pipelines-layer': { + const pipelineType = String(obj.type || '').toLowerCase(); + const pipelineTypeLabel = pipelineType === 'oil' + ? t('popups.pipeline.types.oil') + : pipelineType === 'gas' + ? t('popups.pipeline.types.gas') + : pipelineType === 'products' + ? t('popups.pipeline.types.products') + : `${text(obj.type)} ${t('components.deckgl.tooltip.pipeline')}`; + return { html: `
${text(obj.name)}
${pipelineTypeLabel}
` }; + } + case 'conflict-zones-layer': { + const props = obj.properties || obj; + return { html: `
${text(props.name)}
${t('components.deckgl.tooltip.conflictZone')}
` }; + } + case 'natural-events-layer': + return { html: `
${text(obj.title)}
${text(obj.category || t('components.deckgl.tooltip.naturalEvent'))}
` }; + case 'ais-density-layer': + return { html: `
${t('components.deckgl.layers.shipTraffic')}
${t('popups.intensity')}: ${text(obj.intensity)}
` }; + case 'waterways-layer': + return { html: `
${text(obj.name)}
${t('components.deckgl.layers.strategicWaterways')}
` }; + case 'economic-centers-layer': + return { html: `
${text(obj.name)}
${text(obj.country)}
` }; + case 'stock-exchanges-layer': + return { html: `
${text(obj.shortName)}
${text(obj.city)}, ${text(obj.country)}
` }; + case 'financial-centers-layer': + return { html: `
${text(obj.name)}
${text(obj.type)} ${t('components.deckgl.tooltip.financialCenter')}
` }; + case 'central-banks-layer': + return { html: `
${text(obj.shortName)}
${text(obj.city)}, ${text(obj.country)}
` }; + case 'commodity-hubs-layer': + return { html: `
${text(obj.name)}
${text(obj.type)} · ${text(obj.city)}
` }; + case 'startup-hubs-layer': + return { html: `
${text(obj.city)}
${text(obj.country)}
` }; + case 'tech-hqs-layer': + return { html: `
${text(obj.company)}
${text(obj.city)}
` }; + case 'accelerators-layer': + return { html: `
${text(obj.name)}
${text(obj.city)}
` }; + case 'cloud-regions-layer': + return { html: `
${text(obj.provider)}
${text(obj.region)}
` }; + case 'tech-events-layer': + return { html: `
${text(obj.title)}
${text(obj.location)}
` }; + case 'irradiators-layer': + return { html: `
${text(obj.name)}
${text(obj.type || t('components.deckgl.layers.gammaIrradiators'))}
` }; + case 'spaceports-layer': + return { html: `
${text(obj.name)}
${text(obj.country || t('components.deckgl.layers.spaceports'))}
` }; + case 'ports-layer': { + const typeIcon = obj.type === 'naval' ? '⚓' : obj.type === 'oil' || obj.type === 'lng' ? '🛢️' : '🏭'; + return { html: `
${typeIcon} ${text(obj.name)}
${text(obj.type || t('components.deckgl.tooltip.port'))} - ${text(obj.country)}
` }; + } + case 'flight-delays-layer': + return { html: `
${text(obj.airport)}
${text(obj.severity)}: ${text(obj.reason)}
` }; + case 'apt-groups-layer': + return { html: `
${text(obj.name)}
${text(obj.aka)}
${t('popups.sponsor')}: ${text(obj.sponsor)}
` }; + case 'minerals-layer': + return { html: `
${text(obj.name)}
${text(obj.mineral)} - ${text(obj.country)}
${text(obj.operator)}
` }; + case 'ais-disruptions-layer': + return { html: `
AIS ${text(obj.type || t('components.deckgl.tooltip.disruption'))}
${text(obj.severity)} ${t('popups.severity')}
${text(obj.description)}
` }; + case 'cable-advisories-layer': { + const cableName = UNDERSEA_CABLES.find(c => c.id === obj.cableId)?.name || obj.cableId; + return { html: `
${text(cableName)}
${text(obj.severity || t('components.deckgl.tooltip.advisory'))}
${text(obj.description)}
` }; + } + case 'repair-ships-layer': + return { html: `
${text(obj.name || t('components.deckgl.tooltip.repairShip'))}
${text(obj.status)}
` }; + case 'weather-layer': { + const areaDesc = typeof obj.areaDesc === 'string' ? obj.areaDesc : ''; + const area = areaDesc ? `
${text(areaDesc.slice(0, 50))}${areaDesc.length > 50 ? '...' : ''}` : ''; + return { html: `
${text(obj.event || t('components.deckgl.layers.weatherAlerts'))}
${text(obj.severity)}${area}
` }; + } + case 'outages-layer': + return { html: `
${text(obj.asn || t('components.deckgl.tooltip.internetOutage'))}
${text(obj.country)}
` }; + case 'cyber-threats-layer': + return { html: `
${t('popups.cyberThreat.title')}
${text(obj.severity || t('components.deckgl.tooltip.medium'))} · ${text(obj.country || t('popups.unknown'))}
` }; + case 'news-locations-layer': + return { html: `
📰 ${t('components.deckgl.tooltip.news')}
${text(obj.title?.slice(0, 80) || '')}
` }; + case 'positive-events-layer': { + const catLabel = obj.category ? obj.category.replace(/-/g, ' & ') : 'Positive Event'; + const countInfo = obj.count > 1 ? `
${obj.count} sources reporting` : ''; + return { html: `
${text(obj.name)}
${text(catLabel)}${countInfo}
` }; + } + case 'kindness-layer': + return { html: `
${text(obj.name)}
` }; + case 'happiness-choropleth-layer': { + const hcName = obj.properties?.name ?? 'Unknown'; + const hcCode = obj.properties?.['ISO3166-1-Alpha-2']; + const hcScore = hcCode ? this.happinessScores.get(hcCode as string) : undefined; + const hcScoreStr = hcScore != null ? hcScore.toFixed(1) : 'No data'; + return { html: `
${text(hcName)}
Happiness: ${hcScoreStr}/10${hcScore != null ? `
${text(this.happinessSource)} (${this.happinessYear})` : ''}
` }; + } + case 'species-recovery-layer': { + return { html: `
${text(obj.commonName)}
${text(obj.recoveryZone?.name ?? obj.region)}
Status: ${text(obj.recoveryStatus)}
` }; + } + case 'renewable-installations-layer': { + const riTypeLabel = obj.type ? String(obj.type).charAt(0).toUpperCase() + String(obj.type).slice(1) : 'Renewable'; + return { html: `
${text(obj.name)}
${riTypeLabel} · ${obj.capacityMW?.toLocaleString() ?? '?'} MW
${text(obj.country)} · ${obj.year}
` }; + } + case 'gulf-investments-layer': { + const inv = obj as GulfInvestment; + const flag = inv.investingCountry === 'SA' ? '🇸🇦' : '🇦🇪'; + const usd = inv.investmentUSD != null + ? (inv.investmentUSD >= 1000 ? `$${(inv.investmentUSD / 1000).toFixed(1)}B` : `$${inv.investmentUSD}M`) + : t('components.deckgl.tooltip.undisclosed'); + const stake = inv.stakePercent != null ? `
${text(String(inv.stakePercent))}% ${t('components.deckgl.tooltip.stake')}` : ''; + return { + html: `
+ ${flag} ${text(inv.assetName)}
+ ${text(inv.investingEntity)}
+ ${text(inv.targetCountry)} · ${text(inv.sector)}
+ ${usd}${stake}
+ ${text(inv.status)} +
`, + }; + } + default: + return null; + } + } + + private handleClick(info: PickingInfo): void { + if (!info.object) { + // Empty map click → country detection + if (info.coordinate && this.onCountryClick) { + const [lon, lat] = info.coordinate as [number, number]; + const country = this.resolveCountryFromCoordinate(lon, lat); + this.onCountryClick({ + lat, + lon, + ...(country ? { code: country.code, name: country.name } : {}), + }); + } + return; + } + + const rawClickLayerId = info.layer?.id || ''; + const layerId = rawClickLayerId.endsWith('-ghost') ? rawClickLayerId.slice(0, -6) : rawClickLayerId; + + // Hotspots show popup with related news + if (layerId === 'hotspots-layer') { + const hotspot = info.object as Hotspot; + const relatedNews = this.getRelatedNews(hotspot); + this.popup.show({ + type: 'hotspot', + data: hotspot, + relatedNews, + x: info.x, + y: info.y, + }); + this.popup.loadHotspotGdeltContext(hotspot); + this.onHotspotClick?.(hotspot); + return; + } + + // Handle cluster layers with single/multi logic + if (layerId === 'protest-clusters-layer') { + const cluster = info.object as MapProtestCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'protest', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'protestCluster', + data: { + items: cluster.items, + country: cluster.country, + count: cluster.count, + riotCount: cluster.riotCount, + highSeverityCount: cluster.highSeverityCount, + verifiedCount: cluster.verifiedCount, + totalFatalities: cluster.totalFatalities, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + if (layerId === 'tech-hq-clusters-layer') { + const cluster = info.object as MapTechHQCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'techHQ', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'techHQCluster', + data: { + items: cluster.items, + city: cluster.city, + country: cluster.country, + count: cluster.count, + faangCount: cluster.faangCount, + unicornCount: cluster.unicornCount, + publicCount: cluster.publicCount, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + if (layerId === 'tech-event-clusters-layer') { + const cluster = info.object as MapTechEventCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'techEvent', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'techEventCluster', + data: { + items: cluster.items, + location: cluster.location, + country: cluster.country, + count: cluster.count, + soonCount: cluster.soonCount, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + if (layerId === 'datacenter-clusters-layer') { + const cluster = info.object as MapDatacenterCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'datacenter', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'datacenterCluster', + data: { + items: cluster.items, + region: cluster.region || cluster.country, + country: cluster.country, + count: cluster.count, + totalChips: cluster.totalChips, + totalPowerMW: cluster.totalPowerMW, + existingCount: cluster.existingCount, + plannedCount: cluster.plannedCount, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + + // Map layer IDs to popup types + const layerToPopupType: Record = { + 'conflict-zones-layer': 'conflict', + 'bases-layer': 'base', + 'nuclear-layer': 'nuclear', + 'irradiators-layer': 'irradiator', + 'datacenters-layer': 'datacenter', + 'cables-layer': 'cable', + 'pipelines-layer': 'pipeline', + 'earthquakes-layer': 'earthquake', + 'weather-layer': 'weather', + 'outages-layer': 'outage', + 'cyber-threats-layer': 'cyberThreat', + 'protests-layer': 'protest', + 'military-flights-layer': 'militaryFlight', + 'military-vessels-layer': 'militaryVessel', + 'military-vessel-clusters-layer': 'militaryVesselCluster', + 'military-flight-clusters-layer': 'militaryFlightCluster', + 'natural-events-layer': 'natEvent', + 'waterways-layer': 'waterway', + 'economic-centers-layer': 'economic', + 'stock-exchanges-layer': 'stockExchange', + 'financial-centers-layer': 'financialCenter', + 'central-banks-layer': 'centralBank', + 'commodity-hubs-layer': 'commodityHub', + 'spaceports-layer': 'spaceport', + 'ports-layer': 'port', + 'flight-delays-layer': 'flight', + 'startup-hubs-layer': 'startupHub', + 'tech-hqs-layer': 'techHQ', + 'accelerators-layer': 'accelerator', + 'cloud-regions-layer': 'cloudRegion', + 'tech-events-layer': 'techEvent', + 'apt-groups-layer': 'apt', + 'minerals-layer': 'mineral', + 'ais-disruptions-layer': 'ais', + 'cable-advisories-layer': 'cable-advisory', + 'repair-ships-layer': 'repair-ship', + }; + + const popupType = layerToPopupType[layerId]; + if (!popupType) return; + + // For GeoJSON layers, the data is in properties + let data = info.object; + if (layerId === 'conflict-zones-layer' && info.object.properties) { + // Find the full conflict zone data from config + const conflictId = info.object.properties.id; + const fullConflict = CONFLICT_ZONES.find(c => c.id === conflictId); + if (fullConflict) data = fullConflict; + } + + // Get click coordinates relative to container + const x = info.x ?? 0; + const y = info.y ?? 0; + + this.popup.show({ + type: popupType, + data: data, + x, + y, + }); + } + + // Utility methods + private hexToRgba(hex: string, alpha: number): [number, number, number, number] { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result && result[1] && result[2] && result[3]) { + return [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16), + alpha, + ]; + } + return [100, 100, 100, alpha]; + } + + // UI Creation methods + private createControls(): void { + const controls = document.createElement('div'); + controls.className = 'map-controls deckgl-controls'; + controls.innerHTML = ` +
+ + + +
+
+ +
+ `; + + this.container.appendChild(controls); + + // Bind events - use event delegation for reliability + controls.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('zoom-in')) this.zoomIn(); + else if (target.classList.contains('zoom-out')) this.zoomOut(); + else if (target.classList.contains('zoom-reset')) this.resetView(); + }); + + const viewSelect = controls.querySelector('.view-select') as HTMLSelectElement; + viewSelect.value = this.state.view; + viewSelect.addEventListener('change', () => { + this.setView(viewSelect.value as DeckMapView); + }); + } + + private createTimeSlider(): void { + const slider = document.createElement('div'); + slider.className = 'time-slider deckgl-time-slider'; + slider.innerHTML = ` +
+ + + + + + +
+ `; + + this.container.appendChild(slider); + + slider.querySelectorAll('.time-btn').forEach(btn => { + btn.addEventListener('click', () => { + const range = (btn as HTMLElement).dataset.range as TimeRange; + this.setTimeRange(range); + }); + }); + } + + private updateTimeSliderButtons(): void { + const slider = this.container.querySelector('.deckgl-time-slider'); + if (!slider) return; + slider.querySelectorAll('.time-btn').forEach((btn) => { + const range = (btn as HTMLElement).dataset.range as TimeRange | undefined; + btn.classList.toggle('active', range === this.state.timeRange); + }); + } + + private createLayerToggles(): void { + const toggles = document.createElement('div'); + toggles.className = 'layer-toggles deckgl-layer-toggles'; + + const layerConfig = SITE_VARIANT === 'tech' + ? [ + { key: 'startupHubs', label: t('components.deckgl.layers.startupHubs'), icon: '🚀' }, + { key: 'techHQs', label: t('components.deckgl.layers.techHQs'), icon: '🏢' }, + { key: 'accelerators', label: t('components.deckgl.layers.accelerators'), icon: '⚡' }, + { key: 'cloudRegions', label: t('components.deckgl.layers.cloudRegions'), icon: '☁' }, + { key: 'datacenters', label: t('components.deckgl.layers.aiDataCenters'), icon: '🖥' }, + { key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '🔌' }, + { key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '📡' }, + { key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '🛡' }, + { key: 'techEvents', label: t('components.deckgl.layers.techEvents'), icon: '📅' }, + { key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' }, + { key: 'fires', label: t('components.deckgl.layers.fires'), icon: '🔥' }, + ] + : SITE_VARIANT === 'finance' + ? [ + { key: 'stockExchanges', label: t('components.deckgl.layers.stockExchanges'), icon: '🏛' }, + { key: 'financialCenters', label: t('components.deckgl.layers.financialCenters'), icon: '💰' }, + { key: 'centralBanks', label: t('components.deckgl.layers.centralBanks'), icon: '🏦' }, + { key: 'commodityHubs', label: t('components.deckgl.layers.commodityHubs'), icon: '📦' }, + { key: 'gulfInvestments', label: t('components.deckgl.layers.gulfInvestments'), icon: '🌐' }, + { key: 'tradeRoutes', label: t('components.deckgl.layers.tradeRoutes'), icon: '🚢' }, + { key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '🔌' }, + { key: 'pipelines', label: t('components.deckgl.layers.pipelines'), icon: '🛢' }, + { key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '📡' }, + { key: 'weather', label: t('components.deckgl.layers.weatherAlerts'), icon: '⛈' }, + { key: 'economic', label: t('components.deckgl.layers.economicCenters'), icon: '💰' }, + { key: 'waterways', label: t('components.deckgl.layers.strategicWaterways'), icon: '⚓' }, + { key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' }, + { key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '🛡' }, + ] + : SITE_VARIANT === 'happy' + ? [ + { key: 'positiveEvents', label: 'Positive Events', icon: '🌟' }, + { key: 'kindness', label: 'Acts of Kindness', icon: '💚' }, + { key: 'happiness', label: 'World Happiness', icon: '😊' }, + { key: 'speciesRecovery', label: 'Species Recovery', icon: '🐾' }, + { key: 'renewableInstallations', label: 'Clean Energy', icon: '⚡' }, + ] + : [ + { key: 'hotspots', label: t('components.deckgl.layers.intelHotspots'), icon: '🎯' }, + { key: 'conflicts', label: t('components.deckgl.layers.conflictZones'), icon: '⚔' }, + { key: 'bases', label: t('components.deckgl.layers.militaryBases'), icon: '🏛' }, + { key: 'nuclear', label: t('components.deckgl.layers.nuclearSites'), icon: '☢' }, + { key: 'irradiators', label: t('components.deckgl.layers.gammaIrradiators'), icon: '⚠' }, + { key: 'spaceports', label: t('components.deckgl.layers.spaceports'), icon: '🚀' }, + { key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '🔌' }, + { key: 'pipelines', label: t('components.deckgl.layers.pipelines'), icon: '🛢' }, + { key: 'datacenters', label: t('components.deckgl.layers.aiDataCenters'), icon: '🖥' }, + { key: 'military', label: t('components.deckgl.layers.militaryActivity'), icon: '✈' }, + { key: 'ais', label: t('components.deckgl.layers.shipTraffic'), icon: '🚢' }, + { key: 'tradeRoutes', label: t('components.deckgl.layers.tradeRoutes'), icon: '⚓' }, + { key: 'flights', label: t('components.deckgl.layers.flightDelays'), icon: '✈' }, + { key: 'protests', label: t('components.deckgl.layers.protests'), icon: '📢' }, + { key: 'ucdpEvents', label: t('components.deckgl.layers.ucdpEvents'), icon: '⚔' }, + { key: 'displacement', label: t('components.deckgl.layers.displacementFlows'), icon: '👥' }, + { key: 'climate', label: t('components.deckgl.layers.climateAnomalies'), icon: '🌫' }, + { key: 'weather', label: t('components.deckgl.layers.weatherAlerts'), icon: '⛈' }, + { key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '📡' }, + { key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '🛡' }, + { key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' }, + { key: 'fires', label: t('components.deckgl.layers.fires'), icon: '🔥' }, + { key: 'waterways', label: t('components.deckgl.layers.strategicWaterways'), icon: '⚓' }, + { key: 'economic', label: t('components.deckgl.layers.economicCenters'), icon: '💰' }, + { key: 'minerals', label: t('components.deckgl.layers.criticalMinerals'), icon: '💎' }, + ]; + + toggles.innerHTML = ` +
+ ${t('components.deckgl.layersTitle')} + + +
+
+ ${layerConfig.map(({ key, label, icon }) => ` + + `).join('')} +
+ `; + + this.container.appendChild(toggles); + + // Bind toggle events + toggles.querySelectorAll('.layer-toggle input').forEach(input => { + input.addEventListener('change', () => { + const layer = (input as HTMLInputElement).closest('.layer-toggle')?.getAttribute('data-layer') as keyof MapLayers; + if (layer) { + this.state.layers[layer] = (input as HTMLInputElement).checked; + this.render(); + this.onLayerChange?.(layer, (input as HTMLInputElement).checked, 'user'); + } + }); + }); + + // Help button + const helpBtn = toggles.querySelector('.layer-help-btn'); + helpBtn?.addEventListener('click', () => this.showLayerHelp()); + + // Collapse toggle + const collapseBtn = toggles.querySelector('.toggle-collapse'); + const toggleList = toggles.querySelector('.toggle-list'); + + // Manual scroll: intercept wheel, prevent map zoom, scroll the list ourselves + if (toggleList) { + toggles.addEventListener('wheel', (e) => { + e.stopPropagation(); + e.preventDefault(); + toggleList.scrollTop += e.deltaY; + }, { passive: false }); + toggles.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: false }); + } + collapseBtn?.addEventListener('click', () => { + toggleList?.classList.toggle('collapsed'); + if (collapseBtn) collapseBtn.innerHTML = toggleList?.classList.contains('collapsed') ? '▶' : '▼'; + }); + } + + /** Show layer help popup explaining each layer */ + private showLayerHelp(): void { + const existing = this.container.querySelector('.layer-help-popup'); + if (existing) { + existing.remove(); + return; + } + + const popup = document.createElement('div'); + popup.className = 'layer-help-popup'; + + const label = (layerKey: string): string => t(`components.deckgl.layers.${layerKey}`).toUpperCase(); + const staticLabel = (labelKey: string): string => t(`components.deckgl.layerHelp.labels.${labelKey}`).toUpperCase(); + const helpItem = (layerLabel: string, descriptionKey: string): string => + `
${layerLabel} ${t(`components.deckgl.layerHelp.descriptions.${descriptionKey}`)}
`; + const helpSection = (titleKey: string, items: string[], noteKey?: string): string => ` +
+
${t(`components.deckgl.layerHelp.sections.${titleKey}`)}
+ ${items.join('')} + ${noteKey ? `
${t(`components.deckgl.layerHelp.notes.${noteKey}`)}
` : ''} +
+ `; + const helpHeader = ` +
+ ${t('components.deckgl.layerHelp.title')} + +
+ `; + + const techHelpContent = ` + ${helpHeader} +
+ ${helpSection('techEcosystem', [ + helpItem(label('startupHubs'), 'techStartupHubs'), + helpItem(label('cloudRegions'), 'techCloudRegions'), + helpItem(label('techHQs'), 'techHQs'), + helpItem(label('accelerators'), 'techAccelerators'), + helpItem(label('techEvents'), 'techEvents'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCables'), + helpItem(label('aiDataCenters'), 'infraDatacenters'), + helpItem(label('internetOutages'), 'infraOutages'), + helpItem(label('cyberThreats'), 'techCyberThreats'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsTech'), + helpItem(label('fires'), 'techFires'), + helpItem(staticLabel('countries'), 'countriesOverlay'), + ])} +
+ `; + + const financeHelpContent = ` + ${helpHeader} +
+ ${helpSection('financeCore', [ + helpItem(label('stockExchanges'), 'financeExchanges'), + helpItem(label('financialCenters'), 'financeCenters'), + helpItem(label('centralBanks'), 'financeCentralBanks'), + helpItem(label('commodityHubs'), 'financeCommodityHubs'), + helpItem(label('gulfInvestments'), 'financeGulfInvestments'), + ])} + ${helpSection('infrastructureRisk', [ + helpItem(label('underseaCables'), 'financeCables'), + helpItem(label('pipelines'), 'financePipelines'), + helpItem(label('internetOutages'), 'financeOutages'), + helpItem(label('cyberThreats'), 'financeCyberThreats'), + helpItem(label('tradeRoutes'), 'tradeRoutes'), + ])} + ${helpSection('macroContext', [ + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(label('strategicWaterways'), 'macroWaterways'), + helpItem(label('weatherAlerts'), 'weatherAlertsMarket'), + helpItem(label('naturalEvents'), 'naturalEventsMacro'), + ])} +
+ `; + + const fullHelpContent = ` + ${helpHeader} +
+ ${helpSection('timeFilter', [ + helpItem(staticLabel('timeRecent'), 'timeRecent'), + helpItem(staticLabel('timeExtended'), 'timeExtended'), + ], 'timeAffects')} + ${helpSection('geopolitical', [ + helpItem(label('conflictZones'), 'geoConflicts'), + helpItem(label('intelHotspots'), 'geoHotspots'), + helpItem(staticLabel('sanctions'), 'geoSanctions'), + helpItem(label('protests'), 'geoProtests'), + helpItem(label('ucdpEvents'), 'geoUcdpEvents'), + helpItem(label('displacementFlows'), 'geoDisplacement'), + ])} + ${helpSection('militaryStrategic', [ + helpItem(label('militaryBases'), 'militaryBases'), + helpItem(label('nuclearSites'), 'militaryNuclear'), + helpItem(label('gammaIrradiators'), 'militaryIrradiators'), + helpItem(label('militaryActivity'), 'militaryActivity'), + helpItem(label('spaceports'), 'militarySpaceports'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCablesFull'), + helpItem(label('pipelines'), 'infraPipelinesFull'), + helpItem(label('internetOutages'), 'infraOutages'), + helpItem(label('aiDataCenters'), 'infraDatacentersFull'), + helpItem(label('cyberThreats'), 'infraCyberThreats'), + ])} + ${helpSection('transport', [ + helpItem(label('shipTraffic'), 'transportShipping'), + helpItem(label('tradeRoutes'), 'tradeRoutes'), + helpItem(label('flightDelays'), 'transportDelays'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsFull'), + helpItem(label('fires'), 'firesFull'), + helpItem(label('weatherAlerts'), 'weatherAlerts'), + helpItem(label('climateAnomalies'), 'climateAnomalies'), + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(label('criticalMinerals'), 'mineralsFull'), + ])} + ${helpSection('labels', [ + helpItem(staticLabel('countries'), 'countriesOverlay'), + helpItem(label('strategicWaterways'), 'waterwaysLabels'), + ])} +
+ `; + + popup.innerHTML = SITE_VARIANT === 'tech' + ? techHelpContent + : SITE_VARIANT === 'finance' + ? financeHelpContent + : fullHelpContent; + + popup.querySelector('.layer-help-close')?.addEventListener('click', () => popup.remove()); + + // Prevent scroll events from propagating to map + const content = popup.querySelector('.layer-help-content'); + if (content) { + content.addEventListener('wheel', (e) => e.stopPropagation(), { passive: false }); + content.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: false }); + } + + // Close on click outside + setTimeout(() => { + const closeHandler = (e: MouseEvent) => { + if (!popup.contains(e.target as Node)) { + popup.remove(); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 100); + + this.container.appendChild(popup); + } + + private createLegend(): void { + const legend = document.createElement('div'); + legend.className = 'map-legend deckgl-legend'; + + // SVG shapes for different marker types + const shapes = { + circle: (color: string) => ``, + triangle: (color: string) => ``, + square: (color: string) => ``, + hexagon: (color: string) => ``, + }; + + const isLight = getCurrentTheme() === 'light'; + const legendItems = SITE_VARIANT === 'tech' + ? [ + { shape: shapes.circle(isLight ? 'rgb(22, 163, 74)' : 'rgb(0, 255, 150)'), label: t('components.deckgl.legend.startupHub') }, + { shape: shapes.circle('rgb(100, 200, 255)'), label: t('components.deckgl.legend.techHQ') }, + { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 200, 0)'), label: t('components.deckgl.legend.accelerator') }, + { shape: shapes.circle('rgb(150, 100, 255)'), label: t('components.deckgl.legend.cloudRegion') }, + { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') }, + ] + : SITE_VARIANT === 'finance' + ? [ + { shape: shapes.circle('rgb(255, 215, 80)'), label: t('components.deckgl.legend.stockExchange') }, + { shape: shapes.circle('rgb(0, 220, 150)'), label: t('components.deckgl.legend.financialCenter') }, + { shape: shapes.hexagon('rgb(255, 210, 80)'), label: t('components.deckgl.legend.centralBank') }, + { shape: shapes.square('rgb(255, 150, 80)'), label: t('components.deckgl.legend.commodityHub') }, + { shape: shapes.triangle('rgb(80, 170, 255)'), label: t('components.deckgl.legend.waterway') }, + ] + : SITE_VARIANT === 'happy' + ? [ + { shape: shapes.circle('rgb(34, 197, 94)'), label: 'Positive Event' }, + { shape: shapes.circle('rgb(234, 179, 8)'), label: 'Breakthrough' }, + { shape: shapes.circle('rgb(74, 222, 128)'), label: 'Act of Kindness' }, + { shape: shapes.circle('rgb(255, 100, 50)'), label: 'Natural Event' }, + { shape: shapes.square('rgb(34, 180, 100)'), label: 'Happy Country' }, + { shape: shapes.circle('rgb(74, 222, 128)'), label: 'Species Recovery Zone' }, + { shape: shapes.circle('rgb(255, 200, 50)'), label: 'Renewable Installation' }, + ] + : [ + { shape: shapes.circle('rgb(255, 68, 68)'), label: t('components.deckgl.legend.highAlert') }, + { shape: shapes.circle('rgb(255, 165, 0)'), label: t('components.deckgl.legend.elevated') }, + { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 255, 0)'), label: t('components.deckgl.legend.monitoring') }, + { shape: shapes.triangle('rgb(68, 136, 255)'), label: t('components.deckgl.legend.base') }, + { shape: shapes.hexagon(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 220, 0)'), label: t('components.deckgl.legend.nuclear') }, + { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') }, + ]; + + legend.innerHTML = ` + ${t('components.deckgl.legend.title')} + ${legendItems.map(({ shape, label }) => `${shape}${label}`).join('')} + `; + + this.container.appendChild(legend); + } + + // Public API methods (matching MapComponent interface) + public render(): void { + if (this.renderPaused) { + this.renderPending = true; + return; + } + if (this.renderScheduled) return; + this.renderScheduled = true; + + requestAnimationFrame(() => { + this.renderScheduled = false; + this.updateLayers(); + }); + } + + public setRenderPaused(paused: boolean): void { + if (this.renderPaused === paused) return; + this.renderPaused = paused; + if (paused) { + this.stopPulseAnimation(); + return; + } + + this.syncPulseAnimation(); + if (!paused && this.renderPending) { + this.renderPending = false; + this.render(); + } + } + + private updateLayers(): void { + if (this.renderPaused || this.webglLost || !this.maplibreMap) return; + const startTime = performance.now(); + try { + this.deckOverlay?.setProps({ layers: this.buildLayers() }); + } catch { /* map may be mid-teardown (null.getProjection) */ } + const elapsed = performance.now() - startTime; + if (import.meta.env.DEV && elapsed > 16) { + console.warn(`[DeckGLMap] updateLayers took ${elapsed.toFixed(2)}ms (>16ms budget)`); + } + } + + public setView(view: DeckMapView): void { + this.state.view = view; + const preset = VIEW_PRESETS[view]; + + if (this.maplibreMap) { + this.maplibreMap.flyTo({ + center: [preset.longitude, preset.latitude], + zoom: preset.zoom, + duration: 1000, + }); + } + + const viewSelect = this.container.querySelector('.view-select') as HTMLSelectElement; + if (viewSelect) viewSelect.value = view; + + this.onStateChange?.(this.state); + } + + public setZoom(zoom: number): void { + this.state.zoom = zoom; + if (this.maplibreMap) { + this.maplibreMap.setZoom(zoom); + } + } + + public setCenter(lat: number, lon: number, zoom?: number): void { + if (this.maplibreMap) { + this.maplibreMap.flyTo({ + center: [lon, lat], + ...(zoom != null && { zoom }), + duration: 500, + }); + } + } + + public getCenter(): { lat: number; lon: number } | null { + if (this.maplibreMap) { + const center = this.maplibreMap.getCenter(); + return { lat: center.lat, lon: center.lng }; + } + return null; + } + + public setTimeRange(range: TimeRange): void { + this.state.timeRange = range; + this.rebuildProtestSupercluster(); + this.onTimeRangeChange?.(range); + this.updateTimeSliderButtons(); + this.render(); // Debounced + } + + public getTimeRange(): TimeRange { + return this.state.timeRange; + } + + public setLayers(layers: MapLayers): void { + this.state.layers = layers; + this.render(); // Debounced + + // Update toggle checkboxes + Object.entries(layers).forEach(([key, value]) => { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${key}"] input`) as HTMLInputElement; + if (toggle) toggle.checked = value; + }); + } + + public getState(): DeckMapState { + return { ...this.state }; + } + + // Zoom controls - public for external access + public zoomIn(): void { + if (this.maplibreMap) { + this.maplibreMap.zoomIn(); + } + } + + public zoomOut(): void { + if (this.maplibreMap) { + this.maplibreMap.zoomOut(); + } + } + + private resetView(): void { + this.setView('global'); + } + + private createUcdpEventsLayer(events: UcdpGeoEvent[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'ucdp-events-layer', + data: events, + getPosition: (d) => [d.longitude, d.latitude], + getRadius: (d) => Math.max(4000, Math.sqrt(d.deaths_best || 1) * 3000), + getFillColor: (d) => { + switch (d.type_of_violence) { + case 'state-based': return COLORS.ucdpStateBased; + case 'non-state': return COLORS.ucdpNonState; + case 'one-sided': return COLORS.ucdpOneSided; + default: return COLORS.ucdpStateBased; + } + }, + radiusMinPixels: 3, + radiusMaxPixels: 20, + pickable: false, + }); + } + + private createDisplacementArcsLayer(): ArcLayer { + const withCoords = this.displacementFlows.filter(f => f.originLat != null && f.asylumLat != null); + const top50 = withCoords.slice(0, 50); + const maxCount = Math.max(1, ...top50.map(f => f.refugees)); + return new ArcLayer({ + id: 'displacement-arcs-layer', + data: top50, + getSourcePosition: (d) => [d.originLon!, d.originLat!], + getTargetPosition: (d) => [d.asylumLon!, d.asylumLat!], + getSourceColor: getCurrentTheme() === 'light' ? [50, 80, 180, 220] : [100, 150, 255, 180], + getTargetColor: getCurrentTheme() === 'light' ? [20, 150, 100, 220] : [100, 255, 200, 180], + getWidth: (d) => Math.max(1, (d.refugees / maxCount) * 8), + widthMinPixels: 1, + widthMaxPixels: 8, + pickable: false, + }); + } + + private createClimateHeatmapLayer(): HeatmapLayer { + return new HeatmapLayer({ + id: 'climate-heatmap-layer', + data: this.climateAnomalies, + getPosition: (d) => [d.lon, d.lat], + getWeight: (d) => Math.abs(d.tempDelta) + Math.abs(d.precipDelta) * 0.1, + radiusPixels: 40, + intensity: 0.6, + threshold: 0.15, + opacity: 0.45, + colorRange: [ + [68, 136, 255], + [100, 200, 255], + [255, 255, 100], + [255, 200, 50], + [255, 100, 50], + [255, 50, 50], + ], + pickable: false, + }); + } + + private createTradeRoutesLayer(): ArcLayer { + const active: [number, number, number, number] = getCurrentTheme() === 'light' ? [30, 100, 180, 200] : [100, 200, 255, 160]; + const disrupted: [number, number, number, number] = getCurrentTheme() === 'light' ? [200, 40, 40, 220] : [255, 80, 80, 200]; + const highRisk: [number, number, number, number] = getCurrentTheme() === 'light' ? [200, 140, 20, 200] : [255, 180, 50, 180]; + const colorFor = (status: string): [number, number, number, number] => + status === 'disrupted' ? disrupted : status === 'high_risk' ? highRisk : active; + + return new ArcLayer({ + id: 'trade-routes-layer', + data: this.tradeRouteSegments, + getSourcePosition: (d) => d.sourcePosition, + getTargetPosition: (d) => d.targetPosition, + getSourceColor: (d) => colorFor(d.status), + getTargetColor: (d) => colorFor(d.status), + getWidth: (d) => d.category === 'energy' ? 3 : 2, + widthMinPixels: 1, + widthMaxPixels: 6, + greatCircle: true, + pickable: false, + }); + } + + private createTradeChokepointsLayer(): ScatterplotLayer { + const routeWaypointIds = new Set(); + for (const seg of this.tradeRouteSegments) { + const route = TRADE_ROUTES_LIST.find(r => r.id === seg.routeId); + if (route) for (const wp of route.waypoints) routeWaypointIds.add(wp); + } + const chokepoints = STRATEGIC_WATERWAYS.filter(w => routeWaypointIds.has(w.id)); + const isLight = getCurrentTheme() === 'light'; + + return new ScatterplotLayer({ + id: 'trade-chokepoints-layer', + data: chokepoints, + getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], + getFillColor: isLight ? [200, 140, 20, 200] : [255, 180, 50, 180], + getLineColor: isLight ? [100, 70, 10, 255] : [255, 220, 120, 255], + getRadius: 30000, + stroked: true, + lineWidthMinPixels: 1, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: false, + }); + } + + // Data setters - all use render() for debouncing + public setEarthquakes(earthquakes: Earthquake[]): void { + this.earthquakes = earthquakes; + this.render(); + } + + public setWeatherAlerts(alerts: WeatherAlert[]): void { + this.weatherAlerts = alerts; + const withCentroid = alerts.filter(a => a.centroid && a.centroid.length === 2).length; + console.log(`[DeckGLMap] Weather alerts: ${alerts.length} total, ${withCentroid} with coordinates`); + this.render(); + } + + public setOutages(outages: InternetOutage[]): void { + this.outages = outages; + this.render(); + } + + public setCyberThreats(threats: CyberThreat[]): void { + this.cyberThreats = threats; + this.render(); + } + + public setAisData(disruptions: AisDisruptionEvent[], density: AisDensityZone[]): void { + this.aisDisruptions = disruptions; + this.aisDensity = density; + this.render(); + } + + public setCableActivity(advisories: CableAdvisory[], repairShips: RepairShip[]): void { + this.cableAdvisories = advisories; + this.repairShips = repairShips; + this.render(); + } + + public setCableHealth(healthMap: Record): void { + this.healthByCableId = healthMap; + this.layerCache.delete('cables-layer'); + this.render(); + } + + public setProtests(events: SocialUnrestEvent[]): void { + this.protests = events; + this.rebuildProtestSupercluster(); + this.render(); + this.syncPulseAnimation(); + } + + public setFlightDelays(delays: AirportDelayAlert[]): void { + this.flightDelays = delays; + this.render(); + } + + public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { + this.militaryFlights = flights; + this.militaryFlightClusters = clusters; + this.render(); + } + + public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { + this.militaryVessels = vessels; + this.militaryVesselClusters = clusters; + this.render(); + } + + public setNaturalEvents(events: NaturalEvent[]): void { + this.naturalEvents = events; + this.render(); + } + + public setFires(fires: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }>): void { + this.firmsFireData = fires; + this.render(); + } + + public setTechEvents(events: TechEventMarker[]): void { + this.techEvents = events; + this.rebuildTechEventSupercluster(); + this.render(); + } + + public setUcdpEvents(events: UcdpGeoEvent[]): void { + this.ucdpEvents = events; + this.render(); + } + + public setDisplacementFlows(flows: DisplacementFlow[]): void { + this.displacementFlows = flows; + this.render(); + } + + public setClimateAnomalies(anomalies: ClimateAnomaly[]): void { + this.climateAnomalies = anomalies; + this.render(); + } + + public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { + const now = Date.now(); + for (const d of data) { + if (!this.newsLocationFirstSeen.has(d.title)) { + this.newsLocationFirstSeen.set(d.title, now); + } + } + for (const [key, ts] of this.newsLocationFirstSeen) { + if (now - ts > 60_000) this.newsLocationFirstSeen.delete(key); + } + this.newsLocations = data; + this.render(); + + this.syncPulseAnimation(now); + } + + public setPositiveEvents(events: PositiveGeoEvent[]): void { + this.positiveEvents = events; + this.syncPulseAnimation(); + this.render(); + } + + public setKindnessData(points: KindnessPoint[]): void { + this.kindnessPoints = points; + this.syncPulseAnimation(); + this.render(); + } + + public setHappinessScores(data: HappinessData): void { + this.happinessScores = data.scores; + this.happinessYear = data.year; + this.happinessSource = data.source; + this.render(); + } + + public setSpeciesRecoveryZones(species: SpeciesRecovery[]): void { + this.speciesRecoveryZones = species.filter( + (s): s is SpeciesRecovery & { recoveryZone: { name: string; lat: number; lon: number } } => + s.recoveryZone != null + ); + this.render(); + } + + public setRenewableInstallations(installations: RenewableInstallation[]): void { + this.renewableInstallations = installations; + this.render(); + } + + public updateHotspotActivity(news: NewsItem[]): void { + this.news = news; // Store for related news lookup + + // Update hotspot "breaking" indicators based on recent news + const breakingKeywords = new Set(); + const recentNews = news.filter(n => + Date.now() - n.pubDate.getTime() < 2 * 60 * 60 * 1000 // Last 2 hours + ); + + // Count matches per hotspot for escalation tracking + const matchCounts = new Map(); + + recentNews.forEach(item => { + this.hotspots.forEach(hotspot => { + if (hotspot.keywords.some(kw => + item.title.toLowerCase().includes(kw.toLowerCase()) + )) { + breakingKeywords.add(hotspot.id); + matchCounts.set(hotspot.id, (matchCounts.get(hotspot.id) || 0) + 1); + } + }); + }); + + this.hotspots.forEach(h => { + h.hasBreaking = breakingKeywords.has(h.id); + const matchCount = matchCounts.get(h.id) || 0; + // Calculate a simple velocity metric (matches per hour normalized) + const velocity = matchCount > 0 ? matchCount / 2 : 0; // 2 hour window + updateHotspotEscalation(h.id, matchCount, h.hasBreaking || false, velocity); + }); + + this.render(); + this.syncPulseAnimation(); + } + + /** Get news items related to a hotspot by keyword matching */ + private getRelatedNews(hotspot: Hotspot): NewsItem[] { + // High-priority conflict keywords that indicate the news is really about another topic + const conflictTopics = ['gaza', 'ukraine', 'russia', 'israel', 'iran', 'china', 'taiwan', 'korea', 'syria']; + + return this.news + .map((item) => { + const titleLower = item.title.toLowerCase(); + const matchedKeywords = hotspot.keywords.filter((kw) => titleLower.includes(kw.toLowerCase())); + + if (matchedKeywords.length === 0) return null; + + // Check if this news mentions other hotspot conflict topics + const conflictMatches = conflictTopics.filter(t => + titleLower.includes(t) && !hotspot.keywords.some(k => k.toLowerCase().includes(t)) + ); + + // If article mentions a major conflict topic that isn't this hotspot, deprioritize heavily + if (conflictMatches.length > 0) { + // Only include if it ALSO has a strong local keyword (city name, agency) + const strongLocalMatch = matchedKeywords.some(kw => + kw.toLowerCase() === hotspot.name.toLowerCase() || + hotspot.agencies?.some(a => titleLower.includes(a.toLowerCase())) + ); + if (!strongLocalMatch) return null; + } + + // Score: more keyword matches = more relevant + const score = matchedKeywords.length; + return { item, score }; + }) + .filter((x): x is { item: NewsItem; score: number } => x !== null) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map(x => x.item); + } + + public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void { + setMilitaryData(flights, vessels); + } + + public getHotspotDynamicScore(hotspotId: string) { + return getHotspotEscalation(hotspotId); + } + + /** Get military flight clusters for rendering/analysis */ + public getMilitaryFlightClusters(): MilitaryFlightCluster[] { + return this.militaryFlightClusters; + } + + /** Get military vessel clusters for rendering/analysis */ + public getMilitaryVesselClusters(): MilitaryVesselCluster[] { + return this.militaryVesselClusters; + } + + public highlightAssets(assets: RelatedAsset[] | null): void { + // Clear previous highlights + Object.values(this.highlightedAssets).forEach(set => set.clear()); + + if (assets) { + assets.forEach(asset => { + this.highlightedAssets[asset.type].add(asset.id); + }); + } + + this.render(); // Debounced + } + + public setOnHotspotClick(callback: (hotspot: Hotspot) => void): void { + this.onHotspotClick = callback; + } + + public setOnTimeRangeChange(callback: (range: TimeRange) => void): void { + this.onTimeRangeChange = callback; + } + + public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void): void { + this.onLayerChange = callback; + } + + public setOnStateChange(callback: (state: DeckMapState) => void): void { + this.onStateChange = callback; + } + + public getHotspotLevels(): Record { + const levels: Record = {}; + this.hotspots.forEach(h => { + levels[h.name] = h.level || 'low'; + }); + return levels; + } + + public setHotspotLevels(levels: Record): void { + this.hotspots.forEach(h => { + if (levels[h.name]) { + h.level = levels[h.name] as 'low' | 'elevated' | 'high'; + } + }); + this.render(); // Debounced + } + + public initEscalationGetters(): void { + setCIIGetter(getCountryScore); + setGeoAlertGetter(getAlertsNearLocation); + } + + // UI visibility methods + public hideLayerToggle(layer: keyof MapLayers): void { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (toggle) (toggle as HTMLElement).style.display = 'none'; + } + + public setLayerLoading(layer: keyof MapLayers, loading: boolean): void { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (toggle) toggle.classList.toggle('loading', loading); + } + + public setLayerReady(layer: keyof MapLayers, hasData: boolean): void { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (!toggle) return; + + toggle.classList.remove('loading'); + // Match old Map.ts behavior: set 'active' only when layer enabled AND has data + if (this.state.layers[layer] && hasData) { + toggle.classList.add('active'); + } else { + toggle.classList.remove('active'); + } + } + + public flashAssets(assetType: AssetType, ids: string[]): void { + // Temporarily highlight assets + ids.forEach(id => this.highlightedAssets[assetType].add(id)); + this.render(); + + setTimeout(() => { + ids.forEach(id => this.highlightedAssets[assetType].delete(id)); + this.render(); + }, 3000); + } + + // Enable layer programmatically + public enableLayer(layer: keyof MapLayers): void { + if (!this.state.layers[layer]) { + this.state.layers[layer] = true; + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"] input`) as HTMLInputElement; + if (toggle) toggle.checked = true; + this.render(); + this.onLayerChange?.(layer, true, 'programmatic'); + } + } + + // Toggle layer on/off programmatically + public toggleLayer(layer: keyof MapLayers): void { + console.log(`[DeckGLMap.toggleLayer] ${layer}: ${this.state.layers[layer]} -> ${!this.state.layers[layer]}`); + this.state.layers[layer] = !this.state.layers[layer]; + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"] input`) as HTMLInputElement; + if (toggle) toggle.checked = this.state.layers[layer]; + this.render(); + this.onLayerChange?.(layer, this.state.layers[layer], 'programmatic'); + } + + // Get center coordinates for programmatic popup positioning + private getContainerCenter(): { x: number; y: number } { + const rect = this.container.getBoundingClientRect(); + return { x: rect.width / 2, y: rect.height / 2 }; + } + + // Project lat/lon to screen coordinates without moving the map + private projectToScreen(lat: number, lon: number): { x: number; y: number } | null { + if (!this.maplibreMap) return null; + const point = this.maplibreMap.project([lon, lat]); + return { x: point.x, y: point.y }; + } + + // Trigger click methods - show popup at item location without moving the map + public triggerHotspotClick(id: string): void { + const hotspot = this.hotspots.find(h => h.id === id); + if (!hotspot) return; + + // Get screen position for popup + const screenPos = this.projectToScreen(hotspot.lat, hotspot.lon); + const { x, y } = screenPos || this.getContainerCenter(); + + // Get related news and show popup + const relatedNews = this.getRelatedNews(hotspot); + this.popup.show({ + type: 'hotspot', + data: hotspot, + relatedNews, + x, + y, + }); + this.popup.loadHotspotGdeltContext(hotspot); + this.onHotspotClick?.(hotspot); + } + + public triggerConflictClick(id: string): void { + const conflict = CONFLICT_ZONES.find(c => c.id === id); + if (conflict) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(conflict.center[1], conflict.center[0]); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'conflict', data: conflict, x, y }); + } + } + + public triggerBaseClick(id: string): void { + const base = MILITARY_BASES.find(b => b.id === id); + if (base) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(base.lat, base.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'base', data: base, x, y }); + } + } + + public triggerPipelineClick(id: string): void { + const pipeline = PIPELINES.find(p => p.id === id); + if (pipeline && pipeline.points.length > 0) { + const midIdx = Math.floor(pipeline.points.length / 2); + const midPoint = pipeline.points[midIdx]; + // Don't pan - show popup at projected screen position or center + const screenPos = midPoint ? this.projectToScreen(midPoint[1], midPoint[0]) : null; + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'pipeline', data: pipeline, x, y }); + } + } + + public triggerCableClick(id: string): void { + const cable = UNDERSEA_CABLES.find(c => c.id === id); + if (cable && cable.points.length > 0) { + const midIdx = Math.floor(cable.points.length / 2); + const midPoint = cable.points[midIdx]; + // Don't pan - show popup at projected screen position or center + const screenPos = midPoint ? this.projectToScreen(midPoint[1], midPoint[0]) : null; + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'cable', data: cable, x, y }); + } + } + + public triggerDatacenterClick(id: string): void { + const dc = AI_DATA_CENTERS.find(d => d.id === id); + if (dc) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(dc.lat, dc.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'datacenter', data: dc, x, y }); + } + } + + public triggerNuclearClick(id: string): void { + const facility = NUCLEAR_FACILITIES.find(n => n.id === id); + if (facility) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(facility.lat, facility.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'nuclear', data: facility, x, y }); + } + } + + public triggerIrradiatorClick(id: string): void { + const irradiator = GAMMA_IRRADIATORS.find(i => i.id === id); + if (irradiator) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(irradiator.lat, irradiator.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'irradiator', data: irradiator, x, y }); + } + } + + public flashLocation(lat: number, lon: number, durationMs = 2000): void { + // Don't pan - project coordinates to screen position + const screenPos = this.projectToScreen(lat, lon); + if (!screenPos) return; + + // Flash effect by temporarily adding a highlight at the location + const flashMarker = document.createElement('div'); + flashMarker.className = 'flash-location-marker'; + flashMarker.style.cssText = ` + position: absolute; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + border: 2px solid #fff; + animation: flash-pulse 0.5s ease-out infinite; + pointer-events: none; + z-index: 1000; + left: ${screenPos.x}px; + top: ${screenPos.y}px; + transform: translate(-50%, -50%); + `; + + // Add animation keyframes if not present + if (!document.getElementById('flash-animation-styles')) { + const style = document.createElement('style'); + style.id = 'flash-animation-styles'; + style.textContent = ` + @keyframes flash-pulse { + 0% { transform: translate(-50%, -50%) scale(1); opacity: 1; } + 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; } + } + `; + document.head.appendChild(style); + } + + const wrapper = this.container.querySelector('.deckgl-map-wrapper'); + if (wrapper) { + wrapper.appendChild(flashMarker); + setTimeout(() => flashMarker.remove(), durationMs); + } + } + + // --- Country click + highlight --- + + public setOnCountryClick(cb: (country: CountryClickPayload) => void): void { + this.onCountryClick = cb; + } + + private resolveCountryFromCoordinate(lon: number, lat: number): { code: string; name: string } | null { + const fromGeometry = getCountryAtCoordinates(lat, lon); + if (fromGeometry) return fromGeometry; + if (!this.maplibreMap || !this.countryGeoJsonLoaded) return null; + try { + const point = this.maplibreMap.project([lon, lat]); + const features = this.maplibreMap.queryRenderedFeatures(point, { layers: ['country-interactive'] }); + const properties = (features?.[0]?.properties ?? {}) as Record; + const code = typeof properties['ISO3166-1-Alpha-2'] === 'string' + ? properties['ISO3166-1-Alpha-2'].trim().toUpperCase() + : ''; + const name = typeof properties.name === 'string' + ? properties.name.trim() + : ''; + if (!code || !name) return null; + return { code, name }; + } catch { + return null; + } + } + + private loadCountryBoundaries(): void { + if (!this.maplibreMap || this.countryGeoJsonLoaded) return; + this.countryGeoJsonLoaded = true; + + getCountriesGeoJson() + .then((geojson) => { + if (!this.maplibreMap || !geojson) return; + this.countriesGeoJsonData = geojson; + this.maplibreMap.addSource('country-boundaries', { + type: 'geojson', + data: geojson, + }); + this.maplibreMap.addLayer({ + id: 'country-interactive', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0, + }, + }); + this.maplibreMap.addLayer({ + id: 'country-hover-fill', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.06, + }, + filter: ['==', ['get', 'name'], ''], + }); + this.maplibreMap.addLayer({ + id: 'country-highlight-fill', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.12, + }, + filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], + }); + this.maplibreMap.addLayer({ + id: 'country-highlight-border', + type: 'line', + source: 'country-boundaries', + paint: { + 'line-color': '#3b82f6', + 'line-width': 1.5, + 'line-opacity': 0.5, + }, + filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], + }); + + if (!this.countryHoverSetup) this.setupCountryHover(); + this.updateCountryLayerPaint(getCurrentTheme()); + if (this.highlightedCountryCode) this.highlightCountry(this.highlightedCountryCode); + console.log('[DeckGLMap] Country boundaries loaded'); + }) + .catch((err) => console.warn('[DeckGLMap] Failed to load country boundaries:', err)); + } + + private setupCountryHover(): void { + if (!this.maplibreMap || this.countryHoverSetup) return; + this.countryHoverSetup = true; + const map = this.maplibreMap; + let hoveredName: string | null = null; + + map.on('mousemove', (e) => { + if (!this.onCountryClick) return; + const features = map.queryRenderedFeatures(e.point, { layers: ['country-interactive'] }); + const name = features?.[0]?.properties?.name as string | undefined; + + try { + if (name && name !== hoveredName) { + hoveredName = name; + map.setFilter('country-hover-fill', ['==', ['get', 'name'], name]); + map.getCanvas().style.cursor = 'pointer'; + } else if (!name && hoveredName) { + hoveredName = null; + map.setFilter('country-hover-fill', ['==', ['get', 'name'], '']); + map.getCanvas().style.cursor = ''; + } + } catch { /* style not done loading during theme switch */ } + }); + + map.on('mouseout', () => { + if (hoveredName) { + hoveredName = null; + try { + map.setFilter('country-hover-fill', ['==', ['get', 'name'], '']); + } catch { /* style not done loading */ } + map.getCanvas().style.cursor = ''; + } + }); + } + + public highlightCountry(code: string): void { + this.highlightedCountryCode = code; + if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; + const filter = ['==', ['get', 'ISO3166-1-Alpha-2'], code] as maplibregl.FilterSpecification; + try { + this.maplibreMap.setFilter('country-highlight-fill', filter); + this.maplibreMap.setFilter('country-highlight-border', filter); + } catch { /* layer not ready yet */ } + } + + public clearCountryHighlight(): void { + this.highlightedCountryCode = null; + if (!this.maplibreMap) return; + const noMatch = ['==', ['get', 'ISO3166-1-Alpha-2'], ''] as maplibregl.FilterSpecification; + try { + this.maplibreMap.setFilter('country-highlight-fill', noMatch); + this.maplibreMap.setFilter('country-highlight-border', noMatch); + } catch { /* layer not ready */ } + } + + private switchBasemap(theme: 'dark' | 'light'): void { + if (!this.maplibreMap) return; + this.maplibreMap.setStyle(theme === 'light' ? LIGHT_STYLE : DARK_STYLE); + // setStyle() replaces all sources/layers — reset guard so country layers are re-added + this.countryGeoJsonLoaded = false; + this.maplibreMap.once('style.load', () => { + this.loadCountryBoundaries(); + this.updateCountryLayerPaint(theme); + // Re-render deck.gl overlay after style swap — interleaved layers need + // the new MapLibre style to be loaded before they can re-insert. + this.render(); + }); + } + + private updateCountryLayerPaint(theme: 'dark' | 'light'): void { + if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; + const hoverOpacity = theme === 'light' ? 0.10 : 0.06; + const highlightOpacity = theme === 'light' ? 0.18 : 0.12; + try { + this.maplibreMap.setPaintProperty('country-hover-fill', 'fill-opacity', hoverOpacity); + this.maplibreMap.setPaintProperty('country-highlight-fill', 'fill-opacity', highlightOpacity); + } catch { /* layers may not be ready */ } + } + + public destroy(): void { + if (this.moveTimeoutId) { + clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = null; + } + + this.stopPulseAnimation(); + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + + this.layerCache.clear(); + + this.deckOverlay?.finalize(); + this.deckOverlay = null; + this.maplibreMap?.remove(); + this.maplibreMap = null; + + this.container.innerHTML = ''; + } +} diff --git a/src/components/DisplacementPanel.ts b/src/components/DisplacementPanel.ts new file mode 100644 index 000000000..837bda9fc --- /dev/null +++ b/src/components/DisplacementPanel.ts @@ -0,0 +1,137 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import type { UnhcrSummary, CountryDisplacement } from '@/services/displacement'; +import { formatPopulation } from '@/services/displacement'; +import { t } from '@/services/i18n'; + +type DisplacementTab = 'origins' | 'hosts'; + +export class DisplacementPanel extends Panel { + private data: UnhcrSummary | null = null; + private activeTab: DisplacementTab = 'origins'; + private onCountryClick?: (lat: number, lon: number) => void; + + constructor() { + super({ + id: 'displacement', + title: t('panels.displacement'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.displacement.infoTooltip'), + }); + this.showLoading(t('common.loadingDisplacement')); + } + + public setCountryClickHandler(handler: (lat: number, lon: number) => void): void { + this.onCountryClick = handler; + } + + public setData(data: UnhcrSummary): void { + this.data = data; + this.setCount(data.countries.length); + this.renderContent(); + } + + private renderContent(): void { + if (!this.data) return; + + const g = this.data.globalTotals; + + const stats = [ + { label: t('components.displacement.refugees'), value: formatPopulation(g.refugees), cls: 'disp-stat-refugees' }, + { label: t('components.displacement.asylumSeekers'), value: formatPopulation(g.asylumSeekers), cls: 'disp-stat-asylum' }, + { label: t('components.displacement.idps'), value: formatPopulation(g.idps), cls: 'disp-stat-idps' }, + { label: t('components.displacement.total'), value: formatPopulation(g.total), cls: 'disp-stat-total' }, + ]; + + const statsHtml = stats.map(s => + `
+ ${s.value} + ${s.label} +
` + ).join(''); + + const tabsHtml = ` +
+ + +
+ `; + + let countries: CountryDisplacement[]; + if (this.activeTab === 'origins') { + countries = [...this.data.countries] + .filter(c => c.refugees + c.asylumSeekers > 0) + .sort((a, b) => (b.refugees + b.asylumSeekers) - (a.refugees + a.asylumSeekers)); + } else { + countries = [...this.data.countries] + .filter(c => (c.hostTotal || 0) > 0) + .sort((a, b) => (b.hostTotal || 0) - (a.hostTotal || 0)); + } + + const displayed = countries.slice(0, 30); + let tableHtml: string; + + if (displayed.length === 0) { + tableHtml = `
${t('common.noDataShort')}
`; + } else { + const rows = displayed.map(c => { + const hostTotal = c.hostTotal || 0; + const count = this.activeTab === 'origins' ? c.refugees + c.asylumSeekers : hostTotal; + const total = this.activeTab === 'origins' ? c.totalDisplaced : hostTotal; + const badgeCls = total >= 1_000_000 ? 'disp-crisis' + : total >= 500_000 ? 'disp-high' + : total >= 100_000 ? 'disp-elevated' + : ''; + const badgeLabel = total >= 1_000_000 ? t('components.displacement.badges.crisis') + : total >= 500_000 ? t('components.displacement.badges.high') + : total >= 100_000 ? t('components.displacement.badges.elevated') + : ''; + const badgeHtml = badgeLabel + ? `${badgeLabel}` + : ''; + + return ` + ${escapeHtml(c.name)} + ${badgeHtml} + ${formatPopulation(count)} + `; + }).join(''); + + tableHtml = ` + + + + + + + + + ${rows} +
${t('components.displacement.country')}${t('components.displacement.status')}${t('components.displacement.count')}
`; + } + + this.setContent(` +
+
${statsHtml}
+ ${tabsHtml} + ${tableHtml} +
+ `); + + this.content.querySelectorAll('.disp-tab').forEach(btn => { + btn.addEventListener('click', () => { + this.activeTab = (btn as HTMLElement).dataset.tab as DisplacementTab; + this.renderContent(); + }); + }); + + this.content.querySelectorAll('.disp-row').forEach(el => { + el.addEventListener('click', () => { + const lat = Number((el as HTMLElement).dataset.lat); + const lon = Number((el as HTMLElement).dataset.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) this.onCountryClick?.(lat, lon); + }); + }); + } +} diff --git a/src/components/DownloadBanner.ts b/src/components/DownloadBanner.ts new file mode 100644 index 000000000..fb0a5055a --- /dev/null +++ b/src/components/DownloadBanner.ts @@ -0,0 +1,135 @@ +import { isDesktopRuntime } from '@/services/runtime'; +import { t } from '@/services/i18n'; +import { isMobileDevice } from '@/utils'; +import { trackDownloadClicked, trackDownloadBannerDismissed } from '@/services/analytics'; + +const STORAGE_KEY = 'wm-download-banner-dismissed'; +const SHOW_DELAY_MS = 12_000; +let bannerScheduled = false; + +export function maybeShowDownloadBanner(): void { + if (bannerScheduled) return; + if (isDesktopRuntime()) return; + if (isMobileDevice()) return; + if (localStorage.getItem(STORAGE_KEY)) return; + + bannerScheduled = true; + setTimeout(() => { + if (localStorage.getItem(STORAGE_KEY)) return; + const panel = buildPanel(); + document.body.appendChild(panel); + requestAnimationFrame(() => { + requestAnimationFrame(() => panel.classList.add('wm-dl-show')); + }); + }, SHOW_DELAY_MS); +} + +function dismiss(panel: HTMLElement, fromDownload = false): void { + if (!fromDownload) trackDownloadBannerDismissed(); + localStorage.setItem(STORAGE_KEY, '1'); + panel.classList.remove('wm-dl-show'); + panel.addEventListener('transitionend', () => panel.remove(), { once: true }); +} + +type Platform = 'macos-arm64' | 'macos-x64' | 'macos' | 'windows' | 'linux' | 'linux-x64' | 'linux-arm64' | 'unknown'; + +function detectPlatform(): Platform { + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return 'windows'; + if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux'; + if (/Mac/i.test(ua)) { + // WebGL renderer can reveal Apple Silicon vs Intel GPU + try { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl') as WebGLRenderingContext | null; + if (gl) { + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + if (dbg) { + const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); + if (/Apple M/i.test(renderer)) return 'macos-arm64'; + if (/Intel/i.test(renderer)) return 'macos-x64'; + } + } + } catch { /* ignore */ } + // Can't determine architecture — show both Mac options + return 'macos'; + } + return 'unknown'; +} + +interface DlButton { cls: string; href: string; label: string } + +function allButtons(): DlButton[] { + return [ + { cls: 'mac', href: '/api/download?platform=macos-arm64', label: `\uF8FF ${t('modals.downloadBanner.macSilicon')}` }, + { cls: 'mac', href: '/api/download?platform=macos-x64', label: `\uF8FF ${t('modals.downloadBanner.macIntel')}` }, + { cls: 'win', href: '/api/download?platform=windows-exe', label: `\u229E ${t('modals.downloadBanner.windows')}` }, + { cls: 'linux', href: '/api/download?platform=linux-appimage', label: `\u{1F427} ${t('modals.downloadBanner.linux')} (x64)` }, + { cls: 'linux', href: '/api/download?platform=linux-appimage-arm64', label: `\u{1F427} ${t('modals.downloadBanner.linux')} (ARM64)` }, + ]; +} + +function buttonsForPlatform(p: Platform): DlButton[] { + const buttons = allButtons(); + switch (p) { + case 'macos-arm64': return buttons.filter(b => b.href.includes('macos-arm64')); + case 'macos-x64': return buttons.filter(b => b.href.includes('macos-x64')); + case 'macos': return buttons.filter(b => b.cls === 'mac'); + case 'windows': return buttons.filter(b => b.cls === 'win'); + case 'linux': return buttons.filter(b => b.cls === 'linux'); + case 'linux-x64': return buttons.filter(b => b.href.includes('linux-appimage') && !b.href.includes('arm64')); + case 'linux-arm64': return buttons.filter(b => b.href.includes('linux-appimage-arm64')); + default: return buttons; + } +} + +function renderButtons(container: HTMLElement, buttons: DlButton[], panel: HTMLElement): void { + container.innerHTML = buttons + .map(b => `${b.label}`) + .join(''); + container.querySelectorAll('.wm-dl-btn').forEach(btn => { + btn.addEventListener('click', () => { + const platform = new URL(btn.href, location.origin).searchParams.get('platform') || 'unknown'; + trackDownloadClicked(platform); + dismiss(panel, true); + }); + }); +} + +function buildPanel(): HTMLElement { + const platform = detectPlatform(); + const primaryButtons = buttonsForPlatform(platform); + const buttons = allButtons(); + const showToggle = platform !== 'unknown' && primaryButtons.length < buttons.length; + + const el = document.createElement('div'); + el.className = 'wm-dl-panel'; + el.innerHTML = ` +
+
\u{1F5A5} ${t('modals.downloadBanner.title')}
+ +
+
${t('modals.downloadBanner.description')}
+
+ ${showToggle ? `` : ''} + `; + + const btnsContainer = el.querySelector('.wm-dl-btns') as HTMLElement; + renderButtons(btnsContainer, primaryButtons, el); + + el.querySelector('.wm-dl-close')!.addEventListener('click', () => dismiss(el, false)); + + const toggle = el.querySelector('.wm-dl-toggle'); + if (toggle) { + let showingAll = false; + toggle.addEventListener('click', () => { + showingAll = !showingAll; + renderButtons(btnsContainer, showingAll ? buttons : primaryButtons, el); + toggle.textContent = showingAll + ? t('modals.downloadBanner.showLess') + : t('modals.downloadBanner.showAllPlatforms'); + }); + } + + return el; +} diff --git a/src/components/ETFFlowsPanel.ts b/src/components/ETFFlowsPanel.ts new file mode 100644 index 000000000..cf7e8f8ee --- /dev/null +++ b/src/components/ETFFlowsPanel.ts @@ -0,0 +1,146 @@ +import { Panel } from './Panel'; +import { t } from '@/services/i18n'; +import { escapeHtml } from '@/utils/sanitize'; +import { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client'; +import type { ListEtfFlowsResponse } from '@/generated/client/worldmonitor/market/v1/service_client'; + +type ETFFlowsResult = ListEtfFlowsResponse; + +function formatVolume(v: number): string { + if (Math.abs(v) >= 1e9) return `${(v / 1e9).toFixed(1)}B`; + if (Math.abs(v) >= 1e6) return `${(v / 1e6).toFixed(1)}M`; + if (Math.abs(v) >= 1e3) return `${(v / 1e3).toFixed(0)}K`; + return v.toLocaleString(); +} + +function flowClass(direction: string): string { + if (direction === 'inflow') return 'flow-inflow'; + if (direction === 'outflow') return 'flow-outflow'; + return 'flow-neutral'; +} + +function changeClass(val: number): string { + if (val > 0.1) return 'change-positive'; + if (val < -0.1) return 'change-negative'; + return 'change-neutral'; +} + +export class ETFFlowsPanel extends Panel { + private data: ETFFlowsResult | null = null; + private loading = true; + private error: string | null = null; + private refreshInterval: ReturnType | null = null; + + constructor() { + super({ id: 'etf-flows', title: t('panels.etfFlows'), showCount: false }); + // Delay initial fetch by 8s to avoid competing with stock/commodity Yahoo calls + // during cold start — all share a global yahooGate() rate limiter on the sidecar + setTimeout(() => void this.fetchData(), 8_000); + this.refreshInterval = setInterval(() => this.fetchData(), 3 * 60000); + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + private async fetchData(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const client = new MarketServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); + this.data = await client.listEtfFlows({}); + this.error = null; + + if (this.data && this.data.etfs.length === 0 && !this.data.rateLimited && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + break; + } catch (err) { + if (this.isAbortError(err)) return; + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + this.error = err instanceof Error ? err.message : 'Failed to fetch'; + } + } + this.loading = false; + this.renderPanel(); + } + + private renderPanel(): void { + if (this.loading) { + this.showLoading(t('common.loadingEtfData')); + return; + } + + if (this.error || !this.data) { + this.showError(this.error || t('common.noDataShort')); + return; + } + + const d = this.data; + if (!d.etfs.length) { + const msg = d.rateLimited ? t('components.etfFlows.rateLimited') : t('components.etfFlows.unavailable'); + this.setContent(`
${msg}
`); + return; + } + + const s = d.summary || { etfCount: 0, totalVolume: 0, totalEstFlow: 0, netDirection: 'NEUTRAL', inflowCount: 0, outflowCount: 0 }; + const dirClass = s.netDirection.includes('INFLOW') ? 'flow-inflow' : s.netDirection.includes('OUTFLOW') ? 'flow-outflow' : 'flow-neutral'; + + const rows = d.etfs.map(etf => ` + + ${escapeHtml(etf.ticker)} + ${escapeHtml(etf.issuer)} + ${etf.direction === 'inflow' ? '+' : etf.direction === 'outflow' ? '-' : ''}$${formatVolume(Math.abs(etf.estFlow))} + ${formatVolume(etf.volume)} + ${etf.priceChange > 0 ? '+' : ''}${etf.priceChange.toFixed(2)}% + + `).join(''); + + const html = ` +
+
+
+ ${t('components.etfFlows.netFlow')} + ${s.netDirection.includes('INFLOW') ? t('components.etfFlows.netInflow') : t('components.etfFlows.netOutflow')} +
+
+ ${t('components.etfFlows.estFlow')} + $${formatVolume(Math.abs(s.totalEstFlow))} +
+
+ ${t('components.etfFlows.totalVol')} + ${formatVolume(s.totalVolume)} +
+
+ ${t('components.etfFlows.etfs')} + ${s.inflowCount}↑ ${s.outflowCount}↓ +
+
+
+ + + + + + + + + + + ${rows} +
${t('components.etfFlows.table.ticker')}${t('components.etfFlows.table.issuer')}${t('components.etfFlows.table.estFlow')}${t('components.etfFlows.table.volume')}${t('components.etfFlows.table.change')}
+
+
+ `; + + this.setContent(html); + } +} diff --git a/src/components/EconomicPanel.ts b/src/components/EconomicPanel.ts index 43bff0ea5..a270035b7 100644 --- a/src/components/EconomicPanel.ts +++ b/src/components/EconomicPanel.ts @@ -1,57 +1,144 @@ -import type { FredSeries } from '@/services/fred'; -import { getChangeClass, formatChange } from '@/services/fred'; +import { Panel } from './Panel'; +import type { FredSeries, OilAnalytics, BisData } from '@/services/economic'; +import { t } from '@/services/i18n'; +import type { SpendingSummary } from '@/services/usa-spending'; +import { getChangeClass, formatChange, formatOilValue, getTrendIndicator, getTrendColor } from '@/services/economic'; +import { formatAwardAmount, getAwardTypeIcon } from '@/services/usa-spending'; +import { escapeHtml } from '@/utils/sanitize'; +import { isFeatureAvailable } from '@/services/runtime-config'; +import { isDesktopRuntime } from '@/services/runtime'; +import { getCSSColor } from '@/utils'; -export class EconomicPanel { - private container: HTMLElement; - private data: FredSeries[] = []; - private isLoading = true; +type TabId = 'indicators' | 'oil' | 'spending' | 'centralBanks'; + +export class EconomicPanel extends Panel { + private fredData: FredSeries[] = []; + private oilData: OilAnalytics | null = null; + private spendingData: SpendingSummary | null = null; + private bisData: BisData | null = null; private lastUpdate: Date | null = null; + private activeTab: TabId = 'indicators'; - constructor(container: HTMLElement) { - this.container = container; - this.render(); + constructor() { + super({ id: 'economic', title: t('panels.economic') }); + this.content.addEventListener('click', (e) => { + const tab = (e.target as HTMLElement).closest('.economic-tab') as HTMLElement | null; + if (tab?.dataset.tab) { + this.activeTab = tab.dataset.tab as TabId; + this.render(); + } + }); } public update(data: FredSeries[]): void { - this.data = data; - this.isLoading = false; + this.fredData = data; this.lastUpdate = new Date(); this.render(); } - public setLoading(loading: boolean): void { - this.isLoading = loading; + public updateOil(data: OilAnalytics): void { + this.oilData = data; + this.render(); + } + + public updateSpending(data: SpendingSummary): void { + this.spendingData = data; this.render(); } + public updateBis(data: BisData): void { + this.bisData = data; + this.render(); + } + + public setLoading(loading: boolean): void { + if (loading) { + this.showLoading(); + } + } + private render(): void { - if (this.isLoading) { - this.container.innerHTML = ` -
-
- ECONOMIC INDICATORS - FRED -
-
Loading economic data...
-
- `; - return; + const hasOil = this.oilData && (this.oilData.wtiPrice || this.oilData.brentPrice); + const hasSpending = this.spendingData && this.spendingData.awards.length > 0; + const hasBis = this.bisData && this.bisData.policyRates.length > 0; + + // Build tabs HTML + const tabsHtml = ` +
+ + ${hasOil ? ` + + ` : ''} + ${hasSpending ? ` + + ` : ''} + ${hasBis ? ` + + ` : ''} +
+ `; + + let contentHtml = ''; + + switch (this.activeTab) { + case 'indicators': + contentHtml = this.renderIndicators(); + break; + case 'oil': + contentHtml = this.renderOil(); + break; + case 'spending': + contentHtml = this.renderSpending(); + break; + case 'centralBanks': + contentHtml = this.renderCentralBanks(); + break; } - if (this.data.length === 0) { - this.container.innerHTML = ` -
-
- ECONOMIC INDICATORS - FRED -
-
No data available
-
- `; - return; + const updateTime = this.lastUpdate + ? this.lastUpdate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : ''; + + this.setContent(` + ${tabsHtml} +
+ ${contentHtml} +
+ + `); + + } + + private getSourceLabel(): string { + switch (this.activeTab) { + case 'indicators': return 'FRED'; + case 'oil': return 'EIA'; + case 'spending': return 'USASpending.gov'; + case 'centralBanks': return 'BIS'; + } + } + + private renderIndicators(): string { + if (this.fredData.length === 0) { + if (isDesktopRuntime() && !isFeatureAvailable('economicFred')) { + return `
${t('components.economic.fredKeyMissing')}
`; + } + return `
${t('components.economic.noIndicatorData')}
`; } - const indicatorsHtml = this.data.map(series => { + return ` +
+ ${this.fredData.map(series => { const changeClass = getChangeClass(series.change); const changeStr = formatChange(series.change, series.unit); const arrow = series.change !== null @@ -59,38 +146,191 @@ export class EconomicPanel { : ''; return ` -
-
- ${series.name} - ${series.id} -
-
- ${series.value !== null ? series.value : 'N/A'}${series.unit} - ${arrow} ${changeStr} -
-
${series.date}
-
- `; - }).join(''); +
+
+ ${escapeHtml(series.name)} + ${escapeHtml(series.id)} +
+
+ ${escapeHtml(String(series.value !== null ? series.value : 'N/A'))}${escapeHtml(series.unit)} + ${escapeHtml(arrow)} ${escapeHtml(changeStr)} +
+
${escapeHtml(series.date)}
+
+ `; + }).join('')} +
+ `; + } - const updateTime = this.lastUpdate - ? this.lastUpdate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : ''; + private renderOil(): string { + if (!this.oilData) { + return `
${t('components.economic.noOilDataRetry')}
`; + } + + const metrics = [ + this.oilData.wtiPrice, + this.oilData.brentPrice, + this.oilData.usProduction, + this.oilData.usInventory, + ].filter(Boolean); + + if (metrics.length === 0) { + return `
${t('components.economic.noOilMetrics')}
`; + } - this.container.innerHTML = ` -
-
- ECONOMIC INDICATORS - FRED • ${updateTime} + return ` +
+ ${metrics.map(metric => { + if (!metric) return ''; + const trendIcon = getTrendIndicator(metric.trend); + const trendColor = getTrendColor(metric.trend, metric.name.includes('Production')); + + return ` +
+
+ ${escapeHtml(metric.name)} +
+
+ ${escapeHtml(formatOilValue(metric.current, metric.unit))} ${escapeHtml(metric.unit)} + + ${escapeHtml(trendIcon)} ${escapeHtml(String(metric.changePct > 0 ? '+' : ''))}${escapeHtml(String(metric.changePct))}% + +
+
${t('components.economic.vsPreviousWeek')}
+
+ `; + }).join('')} +
+ `; + } + + private renderSpending(): string { + if (!this.spendingData || this.spendingData.awards.length === 0) { + return `
${t('components.economic.noSpending')}
`; + } + + const { awards, totalAmount, periodStart, periodEnd } = this.spendingData; + + return ` +
+
+ ${escapeHtml(formatAwardAmount(totalAmount))} ${t('components.economic.in')} ${escapeHtml(String(awards.length))} ${t('components.economic.awards')} + ${escapeHtml(periodStart)} – ${escapeHtml(periodEnd)}
+
+
+ ${awards.slice(0, 8).map(award => ` +
+
+ ${escapeHtml(getAwardTypeIcon(award.awardType))} + ${escapeHtml(formatAwardAmount(award.amount))} +
+
${escapeHtml(award.recipientName)}
+
${escapeHtml(award.agency)}
+ ${award.description ? `
${escapeHtml(award.description.slice(0, 100))}${award.description.length > 100 ? '...' : ''}
` : ''} +
+ `).join('')} +
+ `; + } + + private renderCentralBanks(): string { + if (!this.bisData || this.bisData.policyRates.length === 0) { + return `
${t('components.economic.noBisData')}
`; + } + + const greenColor = getCSSColor('--semantic-normal'); + const redColor = getCSSColor('--semantic-critical'); + const neutralColor = getCSSColor('--text-dim'); + + // Policy Rates — sorted by rate descending + const sortedRates = [...this.bisData.policyRates].sort((a, b) => b.rate - a.rate); + const policyHtml = ` +
+
${t('components.economic.policyRate')}
- ${indicatorsHtml} + ${sortedRates.map(r => { + const diff = r.rate - r.previousRate; + const color = diff < 0 ? greenColor : diff > 0 ? redColor : neutralColor; + const label = diff < 0 ? t('components.economic.cut') : diff > 0 ? t('components.economic.hike') : t('components.economic.hold'); + const arrow = diff < 0 ? '▼' : diff > 0 ? '▲' : '–'; + return ` +
+
+ ${escapeHtml(r.centralBank)} + ${escapeHtml(r.countryCode)} +
+
+ ${escapeHtml(String(r.rate))}% + ${escapeHtml(arrow)} ${escapeHtml(label)} +
+
${escapeHtml(r.date)}
+
`; + }).join('')}
`; - } - public getElement(): HTMLElement { - return this.container; + // Exchange Rates + let eerHtml = ''; + if (this.bisData.exchangeRates.length > 0) { + eerHtml = ` +
+
${t('components.economic.realEer')}
+
+ ${this.bisData.exchangeRates.map(r => { + const color = r.realChange > 0 ? redColor : r.realChange < 0 ? greenColor : neutralColor; + const arrow = r.realChange > 0 ? '▲' : r.realChange < 0 ? '▼' : '–'; + return ` +
+
+ ${escapeHtml(r.countryName)} + ${escapeHtml(r.countryCode)} +
+
+ ${escapeHtml(String(r.realEer))} + ${escapeHtml(arrow)} ${escapeHtml(String(r.realChange > 0 ? '+' : ''))}${escapeHtml(String(r.realChange))}% +
+
${escapeHtml(r.date)}
+
`; + }).join('')} +
+
+ `; + } + + // Credit-to-GDP + let creditHtml = ''; + if (this.bisData.creditToGdp.length > 0) { + const sortedCredit = [...this.bisData.creditToGdp].sort((a, b) => b.creditGdpRatio - a.creditGdpRatio); + creditHtml = ` +
+
${t('components.economic.creditToGdp')}
+
+ ${sortedCredit.map(r => { + const diff = r.creditGdpRatio - r.previousRatio; + const color = diff > 0 ? redColor : diff < 0 ? greenColor : neutralColor; + const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '–'; + const changeStr = diff !== 0 ? `${diff > 0 ? '+' : ''}${(Math.round(diff * 10) / 10)}pp` : '–'; + return ` +
+
+ ${escapeHtml(r.countryName)} + ${escapeHtml(r.countryCode)} +
+
+ ${escapeHtml(String(r.creditGdpRatio))}% + ${escapeHtml(arrow)} ${escapeHtml(changeStr)} +
+
${escapeHtml(r.date)}
+
`; + }).join('')} +
+
+ `; + } + + return policyHtml + eerHtml + creditHtml; } } diff --git a/src/components/GdeltIntelPanel.ts b/src/components/GdeltIntelPanel.ts new file mode 100644 index 000000000..d13d8c21f --- /dev/null +++ b/src/components/GdeltIntelPanel.ts @@ -0,0 +1,137 @@ +import { Panel } from './Panel'; +import { sanitizeUrl } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { h, replaceChildren } from '@/utils/dom-utils'; +import { + getIntelTopics, + fetchTopicIntelligence, + formatArticleDate, + extractDomain, + type GdeltArticle, + type IntelTopic, + type TopicIntelligence, +} from '@/services/gdelt-intel'; + +export class GdeltIntelPanel extends Panel { + private activeTopic: IntelTopic = getIntelTopics()[0]!; + private topicData = new Map(); + private tabsEl: HTMLElement | null = null; + + constructor() { + super({ + id: 'gdelt-intel', + title: t('panels.gdeltIntel'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.gdeltIntel.infoTooltip'), + }); + this.createTabs(); + this.loadActiveTopic(); + } + + private createTabs(): void { + this.tabsEl = h('div', { className: 'gdelt-intel-tabs' }, + ...getIntelTopics().map(topic => + h('button', { + className: `gdelt-intel-tab ${topic.id === this.activeTopic.id ? 'active' : ''}`, + dataset: { topicId: topic.id }, + title: topic.description, + onClick: () => this.selectTopic(topic), + }, + h('span', { className: 'tab-icon' }, topic.icon), + h('span', { className: 'tab-label' }, topic.name), + ), + ), + ); + + this.element.insertBefore(this.tabsEl, this.content); + } + + private selectTopic(topic: IntelTopic): void { + if (topic.id === this.activeTopic.id) return; + + this.activeTopic = topic; + + this.tabsEl?.querySelectorAll('.gdelt-intel-tab').forEach(tab => { + tab.classList.toggle('active', (tab as HTMLElement).dataset.topicId === topic.id); + }); + + const cached = this.topicData.get(topic.id); + if (cached && Date.now() - cached.fetchedAt.getTime() < 5 * 60 * 1000) { + this.renderArticles(cached.articles); + } else { + this.loadActiveTopic(); + } + } + + private async loadActiveTopic(): Promise { + this.showLoading(); + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const data = await fetchTopicIntelligence(this.activeTopic); + this.topicData.set(this.activeTopic.id, data); + + if (data.articles.length === 0 && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 15_000)); + continue; + } + + this.renderArticles(data.articles); + this.setCount(data.articles.length); + return; + } catch (error) { + if (this.isAbortError(error)) return; + console.error(`[GdeltIntelPanel] Load error (attempt ${attempt + 1}):`, error); + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 15_000)); + continue; + } + this.showError(t('common.failedIntelFeed')); + } + } + } + + private renderArticles(articles: GdeltArticle[]): void { + if (articles.length === 0) { + replaceChildren(this.content, h('div', { className: 'empty-state' }, t('components.gdelt.empty'))); + return; + } + + replaceChildren(this.content, + h('div', { className: 'gdelt-intel-articles' }, + ...articles.map(article => this.buildArticle(article)), + ), + ); + } + + private buildArticle(article: GdeltArticle): HTMLElement { + const domain = article.source || extractDomain(article.url); + const timeAgo = formatArticleDate(article.date); + const toneClass = article.tone ? (article.tone < -2 ? 'tone-negative' : article.tone > 2 ? 'tone-positive' : '') : ''; + + return h('a', { + href: sanitizeUrl(article.url), + target: '_blank', + rel: 'noopener', + className: `gdelt-intel-article ${toneClass}`.trim(), + }, + h('div', { className: 'article-header' }, + h('span', { className: 'article-source' }, domain), + h('span', { className: 'article-time' }, timeAgo), + ), + h('div', { className: 'article-title' }, article.title), + ); + } + + public async refresh(): Promise { + await this.loadActiveTopic(); + } + + public async refreshAll(): Promise { + this.topicData.clear(); + await this.loadActiveTopic(); + } +} diff --git a/src/components/GeoHubsPanel.ts b/src/components/GeoHubsPanel.ts new file mode 100644 index 000000000..0bc28772c --- /dev/null +++ b/src/components/GeoHubsPanel.ts @@ -0,0 +1,123 @@ +import { Panel } from './Panel'; +import type { GeoHubActivity } from '@/services/geo-activity'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { getCSSColor } from '@/utils'; + +const COUNTRY_FLAGS: Record = { + 'USA': '🇺🇸', 'Russia': '🇷🇺', 'China': '🇨🇳', 'UK': '🇬🇧', 'Belgium': '🇧🇪', + 'Israel': '🇮🇱', 'Iran': '🇮🇷', 'Ukraine': '🇺🇦', 'Taiwan': '🇹🇼', 'Japan': '🇯🇵', + 'South Korea': '🇰🇷', 'North Korea': '🇰🇵', 'India': '🇮🇳', 'Saudi Arabia': '🇸🇦', + 'Turkey': '🇹🇷', 'France': '🇫🇷', 'Germany': '🇩🇪', 'Egypt': '🇪🇬', 'Pakistan': '🇵🇰', + 'Palestine': '🇵🇸', 'Yemen': '🇾🇪', 'Syria': '🇸🇾', 'Lebanon': '🇱🇧', + 'Sudan': '🇸🇩', 'Ethiopia': '🇪🇹', 'Myanmar': '🇲🇲', 'Austria': '🇦🇹', + 'International': '🌐', +}; + +const TYPE_ICONS: Record = { + capital: '🏛️', + conflict: '⚔️', + strategic: '⚓', + organization: '🏢', +}; + +const TYPE_LABELS: Record = { + capital: 'Capital', + conflict: 'Conflict Zone', + strategic: 'Strategic', + organization: 'Organization', +}; + +export class GeoHubsPanel extends Panel { + private activities: GeoHubActivity[] = []; + private onHubClick?: (hub: GeoHubActivity) => void; + + constructor() { + super({ + id: 'geo-hubs', + title: t('panels.geoHubs'), + showCount: true, + infoTooltip: t('components.geoHubs.infoTooltip', { + highColor: getCSSColor('--semantic-critical'), + elevatedColor: getCSSColor('--semantic-high'), + lowColor: getCSSColor('--text-dim'), + }), + }); + } + + public setOnHubClick(handler: (hub: GeoHubActivity) => void): void { + this.onHubClick = handler; + } + + public setActivities(activities: GeoHubActivity[]): void { + this.activities = activities.slice(0, 10); + this.setCount(this.activities.length); + this.render(); + } + + private getFlag(country: string): string { + return COUNTRY_FLAGS[country] || '🌐'; + } + + private getTypeIcon(type: string): string { + return TYPE_ICONS[type] || '📍'; + } + + private getTypeLabel(type: string): string { + return TYPE_LABELS[type] || type; + } + + private render(): void { + if (this.activities.length === 0) { + this.showError(t('common.noActiveGeoHubs')); + return; + } + + const html = this.activities.map((hub, index) => { + const trendIcon = hub.trend === 'rising' ? '↑' : hub.trend === 'falling' ? '↓' : ''; + const breakingTag = hub.hasBreaking ? 'ALERT' : ''; + const topStory = hub.topStories[0]; + + return ` +
+
${index + 1}
+ +
+
+ ${escapeHtml(hub.name)} + ${this.getFlag(hub.country)} + ${breakingTag} +
+
+ ${hub.newsCount} ${hub.newsCount === 1 ? t('components.geoHubs.story') : t('components.geoHubs.stories')} + ${trendIcon ? `${trendIcon}` : ''} + ${this.getTypeIcon(hub.type)} ${this.getTypeLabel(hub.type)} +
+
+
${Math.round(hub.score)}
+
+ ${topStory ? ` + + ${escapeHtml(topStory.title.length > 80 ? topStory.title.slice(0, 77) + '...' : topStory.title)} + + ` : ''} + `; + }).join(''); + + this.setContent(html); + this.bindEvents(); + } + + private bindEvents(): void { + const items = this.content.querySelectorAll('.geo-hub-item'); + items.forEach((item) => { + item.addEventListener('click', () => { + const hubId = item.dataset.hubId; + const hub = this.activities.find(a => a.hubId === hubId); + if (hub && this.onHubClick) { + this.onHubClick(hub); + } + }); + }); + } +} diff --git a/src/components/GivingPanel.ts b/src/components/GivingPanel.ts new file mode 100644 index 000000000..b646b7e07 --- /dev/null +++ b/src/components/GivingPanel.ts @@ -0,0 +1,231 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import type { GivingSummary, PlatformGiving, CategoryBreakdown } from '@/services/giving'; +import { formatCurrency, formatPercent, getActivityColor, getTrendIcon, getTrendColor } from '@/services/giving'; +import { t } from '@/services/i18n'; + +type GivingTab = 'platforms' | 'categories' | 'crypto' | 'institutional'; + +export class GivingPanel extends Panel { + private data: GivingSummary | null = null; + private activeTab: GivingTab = 'platforms'; + + constructor() { + super({ + id: 'giving', + title: t('panels.giving'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.giving.infoTooltip'), + }); + this.showLoading(t('common.loadingGiving')); + } + + public setData(data: GivingSummary): void { + this.data = data; + this.setCount(data.platforms.length); + this.renderContent(); + } + + private renderContent(): void { + if (!this.data) return; + + const d = this.data; + const trendIcon = getTrendIcon(d.trend); + const trendColor = getTrendColor(d.trend); + const indexColor = getActivityColor(d.activityIndex); + + // Activity Index + summary stats + const statsHtml = ` +
+ ${d.activityIndex} + ${t('components.giving.activityIndex')} +
+
+ ${trendIcon} ${escapeHtml(d.trend)} + ${t('components.giving.trend')} +
+
+ ${formatCurrency(d.estimatedDailyFlowUsd)} + ${t('components.giving.estDailyFlow')} +
+
+ ${formatCurrency(d.crypto.dailyInflowUsd)} + ${t('components.giving.cryptoDaily')} +
+ `; + + // Tabs + const tabs: GivingTab[] = ['platforms', 'categories', 'crypto', 'institutional']; + const tabLabels: Record = { + platforms: t('components.giving.tabs.platforms'), + categories: t('components.giving.tabs.categories'), + crypto: t('components.giving.tabs.crypto'), + institutional: t('components.giving.tabs.institutional'), + }; + const tabsHtml = ` +
+ ${tabs.map(tab => ``).join('')} +
+ `; + + // Tab content + let contentHtml: string; + switch (this.activeTab) { + case 'platforms': + contentHtml = this.renderPlatforms(d.platforms); + break; + case 'categories': + contentHtml = this.renderCategories(d.categories); + break; + case 'crypto': + contentHtml = this.renderCrypto(); + break; + case 'institutional': + contentHtml = this.renderInstitutional(); + break; + } + + // Write directly to bypass debounced setContent — tabs need immediate listeners + this.content.innerHTML = ` +
+
${statsHtml}
+ ${tabsHtml} + ${contentHtml} +
+ `; + + // Attach tab click listeners + this.content.querySelectorAll('.giving-tab').forEach(btn => { + btn.addEventListener('click', () => { + this.activeTab = (btn as HTMLElement).dataset.tab as GivingTab; + this.renderContent(); + }); + }); + } + + private renderPlatforms(platforms: PlatformGiving[]): string { + if (platforms.length === 0) { + return `
${t('common.noDataShort')}
`; + } + + const rows = platforms.map(p => { + const freshnessCls = p.dataFreshness === 'live' ? 'giving-fresh-live' + : p.dataFreshness === 'daily' ? 'giving-fresh-daily' + : p.dataFreshness === 'weekly' ? 'giving-fresh-weekly' + : 'giving-fresh-annual'; + + return ` + ${escapeHtml(p.platform)} + ${formatCurrency(p.dailyVolumeUsd)} + ${p.donationVelocity > 0 ? `${p.donationVelocity.toFixed(0)}/hr` : '\u2014'} + ${escapeHtml(p.dataFreshness)} + `; + }).join(''); + + return ` + + + + + + + + + + ${rows} +
${t('components.giving.platform')}${t('components.giving.dailyVol')}${t('components.giving.velocity')}${t('components.giving.freshness')}
`; + } + + private renderCategories(categories: CategoryBreakdown[]): string { + if (categories.length === 0) { + return `
${t('common.noDataShort')}
`; + } + + const rows = categories.map(c => { + const barWidth = Math.round(c.share * 100); + const trendingBadge = c.trending ? `${t('components.giving.trending')}` : ''; + + return ` + ${escapeHtml(c.category)} ${trendingBadge} + +
+
+
+ ${formatPercent(c.share)} + + `; + }).join(''); + + return ` + + + + + + + + ${rows} +
${t('components.giving.category')}${t('components.giving.share')}
`; + } + + private renderCrypto(): string { + if (!this.data?.crypto) { + return `
${t('common.noDataShort')}
`; + } + const c = this.data.crypto; + + return ` +
+
+
+ ${formatCurrency(c.dailyInflowUsd)} + ${t('components.giving.dailyInflow')} +
+
+ ${c.trackedWallets} + ${t('components.giving.wallets')} +
+
+ ${formatPercent(c.pctOfTotal / 100)} + ${t('components.giving.ofTotal')} +
+
+
+
${t('components.giving.topReceivers')}
+
    + ${c.topReceivers.map(r => `
  • ${escapeHtml(r)}
  • `).join('')} +
+
+
`; + } + + private renderInstitutional(): string { + if (!this.data?.institutional) { + return `
${t('common.noDataShort')}
`; + } + const inst = this.data.institutional; + + return ` +
+
+
+ $${inst.oecdOdaAnnualUsdBn.toFixed(1)}B + ${t('components.giving.oecdOda')} (${inst.oecdDataYear}) +
+
+ ${inst.cafWorldGivingIndex}% + ${t('components.giving.cafIndex')} (${inst.cafDataYear}) +
+
+ ${inst.candidGrantsTracked >= 1_000_000 ? `${(inst.candidGrantsTracked / 1_000_000).toFixed(0)}M` : inst.candidGrantsTracked.toLocaleString()} + ${t('components.giving.candidGrants')} +
+
+ ${escapeHtml(inst.dataLag)} + ${t('components.giving.dataLag')} +
+
+
`; + } +} diff --git a/src/components/GoodThingsDigestPanel.ts b/src/components/GoodThingsDigestPanel.ts new file mode 100644 index 000000000..81e018191 --- /dev/null +++ b/src/components/GoodThingsDigestPanel.ts @@ -0,0 +1,113 @@ +import { Panel } from './Panel'; +import type { NewsItem } from '@/types'; +import { generateSummary } from '@/services/summarization'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; + +/** + * GoodThingsDigestPanel -- Displays the top 5 positive stories of the day, + * each with an AI-generated summary of 50 words or less. + * + * Progressive rendering: titles render immediately as numbered cards, + * then AI summaries fill in asynchronously via generateSummary(). + * Handles abort on re-render and graceful fallback on summarization failure. + */ +export class GoodThingsDigestPanel extends Panel { + private cardElements: HTMLElement[] = []; + private summaryAbort: AbortController | null = null; + + constructor() { + super({ id: 'digest', title: '5 Good Things', trackActivity: false }); + this.content.innerHTML = '

Loading today\u2019s digest\u2026

'; + } + + /** + * Set the stories to display. Takes the first 5 items, renders stub cards + * with titles immediately, then summarizes each in parallel. + */ + public async setStories(items: NewsItem[]): Promise { + // Cancel any previous summarization batch + if (this.summaryAbort) { + this.summaryAbort.abort(); + } + this.summaryAbort = new AbortController(); + + const top5 = items.slice(0, 5); + + if (top5.length === 0) { + this.content.innerHTML = '

No stories available

'; + this.cardElements = []; + return; + } + + // Render stub cards immediately (titles only, no summaries yet) + this.content.innerHTML = ''; + const list = document.createElement('div'); + list.className = 'digest-list'; + this.cardElements = []; + + for (let i = 0; i < top5.length; i++) { + const item = top5[i]!; + const card = document.createElement('div'); + card.className = 'digest-card'; + card.innerHTML = ` + ${i + 1} +
+ + ${escapeHtml(item.title)} + + ${escapeHtml(item.source)} +

Summarizing\u2026

+
+ `; + list.appendChild(card); + this.cardElements.push(card); + } + this.content.appendChild(list); + + // Summarize in parallel with progressive updates + const signal = this.summaryAbort.signal; + await Promise.allSettled(top5.map(async (item, idx) => { + if (signal.aborted) return; + try { + // Pass [title, source] as two headlines to satisfy generateSummary's + // minimum length requirement (headlines.length >= 2). + const result = await generateSummary( + [item.title, item.source], + undefined, + item.locationName, + ); + if (signal.aborted) return; + const summary = result?.summary ?? item.title.slice(0, 200); + this.updateCardSummary(idx, summary); + } catch { + if (!signal.aborted) { + this.updateCardSummary(idx, item.title.slice(0, 200)); + } + } + })); + } + + /** + * Update a single card's summary text and remove the loading indicator. + */ + private updateCardSummary(idx: number, summary: string): void { + const card = this.cardElements[idx]; + if (!card) return; + const summaryEl = card.querySelector('.digest-card-summary'); + if (!summaryEl) return; + summaryEl.textContent = summary; + summaryEl.classList.remove('digest-card-summary--loading'); + } + + /** + * Clean up abort controller, card references, and parent resources. + */ + public destroy(): void { + if (this.summaryAbort) { + this.summaryAbort.abort(); + this.summaryAbort = null; + } + this.cardElements = []; + super.destroy(); + } +} diff --git a/src/components/HeroSpotlightPanel.ts b/src/components/HeroSpotlightPanel.ts new file mode 100644 index 000000000..1115e7975 --- /dev/null +++ b/src/components/HeroSpotlightPanel.ts @@ -0,0 +1,87 @@ +import { Panel } from './Panel'; +import type { NewsItem } from '@/types'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; + +/** + * HeroSpotlightPanel -- Daily hero spotlight card with photo, excerpt, and map location. + * + * Displays a single featured story about an extraordinary person or act of kindness. + * The hero story is set via setHeroStory() (wired by App.ts in plan 06-03). + * If the story has lat/lon coordinates, a "Show on map" button is rendered and + * wired to the onLocationRequest callback for map integration. + */ +export class HeroSpotlightPanel extends Panel { + /** + * Callback for map integration -- set by App.ts to fly the map to the hero's location. + */ + public onLocationRequest?: (lat: number, lon: number) => void; + + constructor() { + super({ id: 'spotlight', title: "Today's Hero", trackActivity: false }); + this.content.innerHTML = + '
Loading today\'s hero...
'; + } + + /** + * Set the hero story to display. If undefined, shows a fallback message. + */ + public setHeroStory(item: NewsItem | undefined): void { + if (!item) { + this.content.innerHTML = + '
No hero story available today
'; + return; + } + + // Image section (optional) + const imageHtml = item.imageUrl + ? `
` + : ''; + + // Time formatting + const timeStr = item.pubDate.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + // Location button -- only when BOTH lat and lon are defined + const hasLocation = item.lat !== undefined && item.lon !== undefined; + const locationHtml = hasLocation + ? `` + : ''; + + this.content.innerHTML = `
+ ${imageHtml} +
+ ${escapeHtml(item.source)} +

+ ${escapeHtml(item.title)} +

+ ${escapeHtml(timeStr)} + ${locationHtml} +
+
`; + + // Wire location button click handler + if (hasLocation) { + const btn = this.content.querySelector('.hero-card-location-btn'); + if (btn) { + btn.addEventListener('click', () => { + const lat = Number(btn.getAttribute('data-lat')); + const lon = Number(btn.getAttribute('data-lon')); + if (!isNaN(lat) && !isNaN(lon)) { + this.onLocationRequest?.(lat, lon); + } + }); + } + } + } + + /** + * Clean up callback reference and call parent destroy. + */ + public destroy(): void { + this.onLocationRequest = undefined; + super.destroy(); + } +} diff --git a/src/components/InsightsPanel.ts b/src/components/InsightsPanel.ts new file mode 100644 index 000000000..ca9a04315 --- /dev/null +++ b/src/components/InsightsPanel.ts @@ -0,0 +1,713 @@ +import { Panel } from './Panel'; +import { mlWorker } from '@/services/ml-worker'; +import { generateSummary, type SummarizeOptions } from '@/services/summarization'; +import { parallelAnalysis, type AnalyzedHeadline } from '@/services/parallel-analysis'; +import { signalAggregator, logSignalSummary, type RegionalConvergence } from '@/services/signal-aggregator'; +import { focalPointDetector } from '@/services/focal-point-detector'; +import { ingestNewsForCII } from '@/services/country-instability'; +import { getTheaterPostureSummaries } from '@/services/military-surge'; +import { isMobileDevice } from '@/utils'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { SITE_VARIANT } from '@/config'; +import { deletePersistentCache, getPersistentCache, setPersistentCache } from '@/services/persistent-cache'; +import { t } from '@/services/i18n'; +import { isDesktopRuntime } from '@/services/runtime'; +import { getAiFlowSettings, isAnyAiProviderEnabled, subscribeAiFlowChange } from '@/services/ai-flow-settings'; +import type { ClusteredEvent, FocalPoint, MilitaryFlight } from '@/types'; + +export class InsightsPanel extends Panel { + private isHidden = false; + private lastBriefUpdate = 0; + private cachedBrief: string | null = null; + private lastMissedStories: AnalyzedHeadline[] = []; + private lastConvergenceZones: RegionalConvergence[] = []; + private lastFocalPoints: FocalPoint[] = []; + private lastMilitaryFlights: MilitaryFlight[] = []; + private lastClusters: ClusteredEvent[] = []; + private aiFlowUnsubscribe: (() => void) | null = null; + private updateGeneration = 0; + private static readonly BRIEF_COOLDOWN_MS = 120000; // 2 min cooldown (API has limits) + private static readonly BRIEF_CACHE_KEY = 'summary:world-brief'; + + constructor() { + super({ + id: 'insights', + title: t('panels.insights'), + showCount: false, + infoTooltip: t('components.insights.infoTooltip'), + }); + + if (isMobileDevice()) { + this.hide(); + this.isHidden = true; + } + + // Web-only: subscribe to AI flow changes so toggling providers re-runs analysis + if (!isDesktopRuntime() && !isMobileDevice()) { + this.aiFlowUnsubscribe = subscribeAiFlowChange((changedKey) => { + if (changedKey === 'mapNewsFlash') return; + void this.onAiFlowChanged(); + }); + } + } + + public setMilitaryFlights(flights: MilitaryFlight[]): void { + this.lastMilitaryFlights = flights; + } + + private getTheaterPostureContext(): string { + if (this.lastMilitaryFlights.length === 0) { + return ''; + } + + const postures = getTheaterPostureSummaries(this.lastMilitaryFlights); + const significant = postures.filter( + (p) => p.postureLevel === 'critical' || p.postureLevel === 'elevated' || p.strikeCapable + ); + + if (significant.length === 0) { + return ''; + } + + const lines = significant.map((p) => { + const parts: string[] = []; + parts.push(`${p.theaterName}: ${p.totalAircraft} aircraft`); + parts.push(`(${p.postureLevel.toUpperCase()})`); + if (p.strikeCapable) parts.push('STRIKE CAPABLE'); + parts.push(`- ${p.summary}`); + if (p.targetNation) parts.push(`Focus: ${p.targetNation}`); + return parts.join(' '); + }); + + return `\n\nCRITICAL MILITARY POSTURE:\n${lines.join('\n')}`; + } + + + private async loadBriefFromCache(): Promise { + if (this.cachedBrief) return false; + const entry = await getPersistentCache<{ summary: string }>(InsightsPanel.BRIEF_CACHE_KEY); + if (!entry?.data?.summary) return false; + this.cachedBrief = entry.data.summary; + this.lastBriefUpdate = entry.updatedAt; + return true; + } + // High-priority military/conflict keywords (huge boost) + private static readonly MILITARY_KEYWORDS = [ + 'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops', + 'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship', + 'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack', + ]; + + // Violence/casualty keywords (huge boost - human cost stories) + private static readonly VIOLENCE_KEYWORDS = [ + 'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter', + 'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution', + 'crackdown', 'violent', 'clashes', 'gunfire', 'shooting', + ]; + + // Civil unrest keywords (high boost) + private static readonly UNREST_KEYWORDS = [ + 'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots', + 'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow', + 'coup', 'martial law', 'curfew', 'shutdown', 'blackout', + ]; + + // Geopolitical flashpoints (major boost) + private static readonly FLASHPOINT_KEYWORDS = [ + 'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv', + 'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus', + 'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner', + ]; + + // Crisis keywords (moderate boost) + private static readonly CRISIS_KEYWORDS = [ + 'crisis', 'emergency', 'catastrophe', 'disaster', 'collapse', 'humanitarian', + 'sanctions', 'ultimatum', 'threat', 'retaliation', 'escalation', 'tensions', + 'breaking', 'urgent', 'developing', 'exclusive', + ]; + + // Business/tech context that should REDUCE score (demote business news with military words) + private static readonly DEMOTE_KEYWORDS = [ + 'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue', + 'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation', + ]; + + private getImportanceScore(cluster: ClusteredEvent): number { + let score = 0; + const titleLower = cluster.primaryTitle.toLowerCase(); + + // Source confirmation (base signal) + score += cluster.sourceCount * 10; + + // Violence/casualty keywords: highest priority (+100 base, +25 per match) + // "Pools of blood" type stories should always surface + const violenceMatches = InsightsPanel.VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (violenceMatches.length > 0) { + score += 100 + (violenceMatches.length * 25); + } + + // Military keywords: highest priority (+80 base, +20 per match) + const militaryMatches = InsightsPanel.MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (militaryMatches.length > 0) { + score += 80 + (militaryMatches.length * 20); + } + + // Civil unrest: high priority (+70 base, +18 per match) + const unrestMatches = InsightsPanel.UNREST_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (unrestMatches.length > 0) { + score += 70 + (unrestMatches.length * 18); + } + + // Flashpoint keywords: high priority (+60 base, +15 per match) + const flashpointMatches = InsightsPanel.FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (flashpointMatches.length > 0) { + score += 60 + (flashpointMatches.length * 15); + } + + // COMBO BONUS: Violence/unrest + flashpoint location = critical story + // e.g., "Iran protests" + "blood" = huge boost + if ((violenceMatches.length > 0 || unrestMatches.length > 0) && flashpointMatches.length > 0) { + score *= 1.5; // 50% bonus for flashpoint unrest + } + + // Crisis keywords: moderate priority (+30 base, +10 per match) + const crisisMatches = InsightsPanel.CRISIS_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (crisisMatches.length > 0) { + score += 30 + (crisisMatches.length * 10); + } + + // Demote business/tech news that happens to contain military words + const demoteMatches = InsightsPanel.DEMOTE_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (demoteMatches.length > 0) { + score *= 0.3; // Heavy penalty for business context + } + + // Velocity multiplier + const velMultiplier: Record = { + 'viral': 3, + 'spike': 2.5, + 'elevated': 1.5, + 'normal': 1 + }; + score *= velMultiplier[cluster.velocity?.level ?? 'normal'] ?? 1; + + // Alert bonus + if (cluster.isAlert) score += 50; + + // Recency bonus (decay over 12 hours) + const ageMs = Date.now() - cluster.firstSeen.getTime(); + const ageHours = ageMs / 3600000; + const recencyMultiplier = Math.max(0.5, 1 - (ageHours / 12)); + score *= recencyMultiplier; + + return score; + } + + private selectTopStories(clusters: ClusteredEvent[], maxCount: number): ClusteredEvent[] { + // Score ALL clusters first - high-scoring stories override source requirements + const allScored = clusters + .map(c => ({ cluster: c, score: this.getImportanceScore(c) })); + + // Filter: require at least 2 sources OR alert OR elevated velocity OR high score + // High score (>100) means critical keywords were matched - don't require multi-source + const candidates = allScored.filter(({ cluster: c, score }) => + c.sourceCount >= 2 || + c.isAlert || + (c.velocity && c.velocity.level !== 'normal') || + score > 100 // Critical stories bypass source requirement + ); + + // Sort by score + const scored = candidates.sort((a, b) => b.score - a.score); + + // Select with source diversity (max 3 from same primary source) + const selected: ClusteredEvent[] = []; + const sourceCount = new Map(); + const MAX_PER_SOURCE = 3; + + for (const { cluster } of scored) { + const source = cluster.primarySource; + const count = sourceCount.get(source) || 0; + + if (count < MAX_PER_SOURCE) { + selected.push(cluster); + sourceCount.set(source, count + 1); + } + + if (selected.length >= maxCount) break; + } + + return selected; + } + + private setProgress(step: number, total: number, message: string): void { + const percent = Math.round((step / total) * 100); + this.setContent(` +
+
+
+
+
+ ${t('components.insights.step', { step: String(step), total: String(total) })} + ${message} +
+
+ `); + } + + public async updateInsights(clusters: ClusteredEvent[]): Promise { + if (this.isHidden) return; + + this.lastClusters = clusters; + this.updateGeneration++; + const thisGeneration = this.updateGeneration; + + if (clusters.length === 0) { + this.setDataBadge('unavailable'); + this.setContent(`
${t('components.insights.waitingForData')}
`); + return; + } + + // Web-only: if no AI providers enabled, show disabled state + if (!isDesktopRuntime() && !isAnyAiProviderEnabled()) { + this.setDataBadge('unavailable'); + this.renderDisabledState(); + return; + } + + // Build summarize options from AI flow settings (web) or defaults (desktop) + const aiFlow = isDesktopRuntime() ? { cloudLlm: true, browserModel: true } : getAiFlowSettings(); + const summarizeOpts: SummarizeOptions = { + skipCloudProviders: !aiFlow.cloudLlm, + skipBrowserFallback: !aiFlow.browserModel, + }; + + const totalSteps = 4; + + try { + // Step 1: Filter and rank stories by composite importance score + this.setProgress(1, totalSteps, t('components.insights.rankingStories')); + + const importantClusters = this.selectTopStories(clusters, 8); + + // Run parallel multi-perspective analysis in background (logs to console) + // This analyzes ALL clusters, not just the keyword-filtered ones + const parallelPromise = parallelAnalysis.analyzeHeadlines(clusters).then(report => { + this.lastMissedStories = report.missedByKeywords; + const suggestions = parallelAnalysis.getSuggestedImprovements(); + if (suggestions.length > 0) { + console.log('%c💡 Improvement Suggestions:', 'color: #f59e0b; font-weight: bold'); + suggestions.forEach(s => console.log(` • ${s}`)); + } + }).catch(err => { + console.warn('[ParallelAnalysis] Error:', err); + }); + + // Get geographic signal correlations (geopolitical variant only) + // Tech variant focuses on tech news, not military/protest signals + let signalSummary: ReturnType; + let focalSummary: ReturnType; + + if (SITE_VARIANT === 'full') { + signalSummary = signalAggregator.getSummary(); + this.lastConvergenceZones = signalSummary.convergenceZones; + if (signalSummary.totalSignals > 0) { + logSignalSummary(); + } + + // Run focal point detection (correlates news entities with map signals) + focalSummary = focalPointDetector.analyze(clusters, signalSummary); + this.lastFocalPoints = focalSummary.focalPoints; + if (focalSummary.focalPoints.length > 0) { + focalPointDetector.logSummary(); + // Ingest news for CII BEFORE signaling (so CII has data when it calculates) + ingestNewsForCII(clusters); + // Signal CII to refresh now that focal points AND news data are available + window.dispatchEvent(new CustomEvent('focal-points-ready')); + } + } else { + // Tech variant: no geopolitical signals, just summarize tech news + signalSummary = { + timestamp: new Date(), + totalSignals: 0, + byType: {} as Record, + convergenceZones: [], + topCountries: [], + aiContext: '', + }; + focalSummary = { + focalPoints: [], + aiContext: '', + timestamp: new Date(), + topCountries: [], + topCompanies: [], + }; + this.lastConvergenceZones = []; + this.lastFocalPoints = []; + } + + if (importantClusters.length === 0) { + this.setContent(`
${t('components.insights.noStories')}
`); + return; + } + + // Cap titles sent to AI at 5 to reduce entity conflation in small models + const titles = importantClusters.slice(0, 5).map(c => c.primaryTitle); + + // Step 2: Analyze sentiment (browser-based, fast) + this.setProgress(2, totalSteps, t('components.insights.analyzingSentiment')); + let sentiments: Array<{ label: string; score: number }> | null = null; + + if (mlWorker.isAvailable) { + sentiments = await mlWorker.classifySentiment(titles).catch(() => null); + } + if (this.updateGeneration !== thisGeneration) return; + + // Step 3: Generate World Brief (with cooldown) + const loadedFromPersistentCache = await this.loadBriefFromCache(); + if (this.updateGeneration !== thisGeneration) return; + + let worldBrief = this.cachedBrief; + const now = Date.now(); + + let usedCachedBrief = loadedFromPersistentCache; + if (!worldBrief || now - this.lastBriefUpdate > InsightsPanel.BRIEF_COOLDOWN_MS) { + this.setProgress(3, totalSteps, t('components.insights.generatingBrief')); + + // Pass focal point context + theater posture to AI for correlation-aware summarization + // Tech variant: no geopolitical context, just tech news summarization + const theaterContext = SITE_VARIANT === 'full' ? this.getTheaterPostureContext() : ''; + const geoContext = SITE_VARIANT === 'full' + ? (focalSummary.aiContext || signalSummary.aiContext) + theaterContext + : ''; + const result = await generateSummary(titles, (_step, _total, msg) => { + // Show sub-progress for summarization + this.setProgress(3, totalSteps, `Generating brief: ${msg}`); + }, geoContext, undefined, summarizeOpts); + + if (this.updateGeneration !== thisGeneration) return; + + if (result) { + worldBrief = result.summary; + this.cachedBrief = worldBrief; + this.lastBriefUpdate = now; + usedCachedBrief = false; + void setPersistentCache(InsightsPanel.BRIEF_CACHE_KEY, { summary: worldBrief }); + console.log(`[InsightsPanel] Brief generated${result.cached ? ' (cached)' : ''}${geoContext ? ' (with geo context)' : ''}`); + } + } else { + usedCachedBrief = true; + this.setProgress(3, totalSteps, 'Using cached brief...'); + } + + this.setDataBadge(worldBrief ? (usedCachedBrief ? 'cached' : 'live') : 'unavailable'); + + // Step 4: Wait for parallel analysis to complete + this.setProgress(4, totalSteps, 'Multi-perspective analysis...'); + await parallelPromise; + + if (this.updateGeneration !== thisGeneration) return; + + this.renderInsights(importantClusters, sentiments, worldBrief); + } catch (error) { + console.error('[InsightsPanel] Error:', error); + this.setContent('
Analysis failed - retrying...
'); + } + } + + private renderInsights( + clusters: ClusteredEvent[], + sentiments: Array<{ label: string; score: number }> | null, + worldBrief: string | null + ): void { + const briefHtml = worldBrief ? this.renderWorldBrief(worldBrief) : ''; + const focalPointsHtml = this.renderFocalPoints(); + const convergenceHtml = this.renderConvergenceZones(); + const sentimentOverview = this.renderSentimentOverview(sentiments); + const breakingHtml = this.renderBreakingStories(clusters, sentiments); + const statsHtml = this.renderStats(clusters); + const missedHtml = this.renderMissedStories(); + + this.setContent(` + ${briefHtml} + ${focalPointsHtml} + ${convergenceHtml} + ${sentimentOverview} + ${statsHtml} +
+
BREAKING & CONFIRMED
+ ${breakingHtml} +
+ ${missedHtml} + `); + } + + private renderWorldBrief(brief: string): string { + return ` +
+
${SITE_VARIANT === 'tech' ? '🚀 TECH BRIEF' : '🌍 WORLD BRIEF'}
+
${escapeHtml(brief)}
+
+ `; + } + + private renderBreakingStories( + clusters: ClusteredEvent[], + sentiments: Array<{ label: string; score: number }> | null + ): string { + return clusters.map((cluster, i) => { + const sentiment = sentiments?.[i]; + const sentimentClass = sentiment?.label === 'negative' ? 'negative' : + sentiment?.label === 'positive' ? 'positive' : 'neutral'; + + const badges: string[] = []; + + if (cluster.sourceCount >= 3) { + badges.push(`✓ ${cluster.sourceCount} sources`); + } else if (cluster.sourceCount >= 2) { + badges.push(`${cluster.sourceCount} sources`); + } + + if (cluster.velocity && cluster.velocity.level !== 'normal') { + const velIcon = cluster.velocity.trend === 'rising' ? '↑' : ''; + badges.push(`${velIcon}+${cluster.velocity.sourcesPerHour}/hr`); + } + + if (cluster.isAlert) { + badges.push('⚠ ALERT'); + } + + return ` +
+
+ + ${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''} +
+ ${badges.length > 0 ? `
${badges.join('')}
` : ''} +
+ `; + }).join(''); + } + + private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string { + if (!sentiments || sentiments.length === 0) { + return ''; + } + + const negative = sentiments.filter(s => s.label === 'negative').length; + const positive = sentiments.filter(s => s.label === 'positive').length; + const neutral = sentiments.length - negative - positive; + + const total = sentiments.length; + const negPct = Math.round((negative / total) * 100); + const neuPct = Math.round((neutral / total) * 100); + const posPct = 100 - negPct - neuPct; + + let toneLabel = 'Mixed'; + let toneClass = 'neutral'; + if (negative > positive + neutral) { + toneLabel = 'Negative'; + toneClass = 'negative'; + } else if (positive > negative + neutral) { + toneLabel = 'Positive'; + toneClass = 'positive'; + } + + return ` +
+
+
+
+
+
+
+ ${negative} + ${neutral} + ${positive} +
+
Overall: ${toneLabel}
+
+ `; + } + + private renderStats(clusters: ClusteredEvent[]): string { + const multiSource = clusters.filter(c => c.sourceCount >= 2).length; + const fastMoving = clusters.filter(c => c.velocity && c.velocity.level !== 'normal').length; + const alerts = clusters.filter(c => c.isAlert).length; + + return ` +
+
+ ${multiSource} + Multi-source +
+
+ ${fastMoving} + Fast-moving +
+ ${alerts > 0 ? ` +
+ ${alerts} + Alerts +
+ ` : ''} +
+ `; + } + + private renderMissedStories(): string { + if (this.lastMissedStories.length === 0) { + return ''; + } + + const storiesHtml = this.lastMissedStories.slice(0, 3).map(story => { + const topPerspective = story.perspectives + .filter(p => p.name !== 'keywords') + .sort((a, b) => b.score - a.score)[0]; + + const perspectiveName = topPerspective?.name ?? 'ml'; + const perspectiveScore = topPerspective?.score ?? 0; + + return ` +
+
+ + ${escapeHtml(story.title.slice(0, 80))}${story.title.length > 80 ? '...' : ''} +
+
+ 🔬 ${perspectiveName}: ${(perspectiveScore * 100).toFixed(0)}% +
+
+ `; + }).join(''); + + return ` +
+
🎯 ML DETECTED
+ ${storiesHtml} +
+ `; + } + + private renderConvergenceZones(): string { + if (this.lastConvergenceZones.length === 0) { + return ''; + } + + const zonesHtml = this.lastConvergenceZones.slice(0, 3).map(zone => { + const signalIcons: Record = { + internet_outage: '🌐', + military_flight: '✈️', + military_vessel: '🚢', + protest: '🪧', + ais_disruption: '⚓', + }; + + const icons = zone.signalTypes.map(t => signalIcons[t] || '📍').join(''); + + return ` +
+
${icons} ${escapeHtml(zone.region)}
+
${escapeHtml(zone.description)}
+
${zone.signalTypes.length} signal types • ${zone.totalSignals} events
+
+ `; + }).join(''); + + return ` +
+
📍 GEOGRAPHIC CONVERGENCE
+ ${zonesHtml} +
+ `; + } + + private renderFocalPoints(): string { + // Only show focal points that have both news AND signals (true correlations) + const correlatedFPs = this.lastFocalPoints.filter( + fp => fp.newsMentions > 0 && fp.signalCount > 0 + ).slice(0, 5); + + if (correlatedFPs.length === 0) { + return ''; + } + + const signalIcons: Record = { + internet_outage: '🌐', + military_flight: '✈️', + military_vessel: '⚓', + protest: '📢', + ais_disruption: '🚢', + }; + + const focalPointsHtml = correlatedFPs.map(fp => { + const urgencyClass = fp.urgency; + const icons = fp.signalTypes.map(t => signalIcons[t] || '').join(' '); + const topHeadline = fp.topHeadlines[0]; + const headlineText = topHeadline?.title?.slice(0, 60) || ''; + const headlineUrl = sanitizeUrl(topHeadline?.url || ''); + + return ` +
+
+ ${escapeHtml(fp.displayName)} + ${fp.urgency.toUpperCase()} +
+
${icons}
+
+ ${fp.newsMentions} news • ${fp.signalCount} signals +
+ ${headlineText && headlineUrl ? `"${escapeHtml(headlineText)}..."` : ''} +
+ `; + }).join(''); + + return ` +
+
🎯 FOCAL POINTS
+ ${focalPointsHtml} +
+ `; + } + + private renderDisabledState(): void { + this.setContent(` +
+
+
${t('components.insights.insightsDisabledTitle')}
+
${t('components.insights.insightsDisabledHint')}
+
+ `); + } + + private async onAiFlowChanged(): Promise { + this.updateGeneration++; + // Reset brief cache so new provider settings take effect immediately + this.cachedBrief = null; + this.lastBriefUpdate = 0; + try { + await deletePersistentCache(InsightsPanel.BRIEF_CACHE_KEY); + } catch { + // Best effort; fallback regeneration still works from memory reset. + } + + if (!isAnyAiProviderEnabled()) { + this.setDataBadge('unavailable'); + this.renderDisabledState(); + return; + } + + if (this.lastClusters.length > 0) { + void this.updateInsights(this.lastClusters); + return; + } + + this.setDataBadge('unavailable'); + this.setContent(`
${t('components.insights.waitingForData')}
`); + } + + public override destroy(): void { + this.aiFlowUnsubscribe?.(); + super.destroy(); + } +} diff --git a/src/components/IntelligenceGapBadge.ts b/src/components/IntelligenceGapBadge.ts new file mode 100644 index 000000000..940e7f47b --- /dev/null +++ b/src/components/IntelligenceGapBadge.ts @@ -0,0 +1,525 @@ +import { getRecentSignals, type CorrelationSignal } from '@/services/correlation'; +import { getRecentAlerts, type UnifiedAlert } from '@/services/cross-module-integration'; +import { t } from '@/services/i18n'; +import { getSignalContext } from '@/utils/analysis-constants'; +import { escapeHtml } from '@/utils/sanitize'; +import { trackFindingClicked } from '@/services/analytics'; + +const LOW_COUNT_THRESHOLD = 3; +const MAX_VISIBLE_FINDINGS = 10; +const SORT_TIME_TOLERANCE_MS = 60000; +const REFRESH_INTERVAL_MS = 10000; +const ALERT_HOURS = 6; +const STORAGE_KEY = 'worldmonitor-intel-findings'; +const POPUP_STORAGE_KEY = 'wm-alert-popup-enabled'; + +type FindingSource = 'signal' | 'alert'; + +interface UnifiedFinding { + id: string; + source: FindingSource; + type: string; + title: string; + description: string; + confidence: number; + priority: 'critical' | 'high' | 'medium' | 'low'; + timestamp: Date; + original: CorrelationSignal | UnifiedAlert; +} + +export class IntelligenceFindingsBadge { + private badge: HTMLElement; + private dropdown: HTMLElement; + private isOpen = false; + private refreshInterval: ReturnType | null = null; + private lastFindingCount = 0; + private onSignalClick: ((signal: CorrelationSignal) => void) | null = null; + private onAlertClick: ((alert: UnifiedAlert) => void) | null = null; + private findings: UnifiedFinding[] = []; + private boundCloseDropdown = () => this.closeDropdown(); + private audio: HTMLAudioElement | null = null; + private audioEnabled = true; + private enabled: boolean; + private popupEnabled: boolean; + private contextMenu: HTMLElement | null = null; + + constructor() { + this.enabled = IntelligenceFindingsBadge.getStoredEnabledState(); + this.popupEnabled = localStorage.getItem(POPUP_STORAGE_KEY) === '1'; + + this.badge = document.createElement('button'); + this.badge.className = 'intel-findings-badge'; + this.badge.title = t('components.intelligenceFindings.badgeTitle'); + this.badge.innerHTML = '🎯0'; + + this.dropdown = document.createElement('div'); + this.dropdown.className = 'intel-findings-dropdown'; + + this.badge.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleDropdown(); + }); + + this.badge.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showContextMenu(e.clientX, e.clientY); + }); + + // Event delegation for finding items, toggle, and "more" link + this.dropdown.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + // Handle popup toggle click + if (target.closest('.popup-toggle-row')) { + e.stopPropagation(); + this.popupEnabled = !this.popupEnabled; + if (this.popupEnabled) { + localStorage.setItem(POPUP_STORAGE_KEY, '1'); + } else { + localStorage.removeItem(POPUP_STORAGE_KEY); + } + this.renderDropdown(); + return; + } + + // Handle "more findings" click - show all in modal + if (target.closest('.findings-more')) { + e.stopPropagation(); + this.showAllFindings(); + this.closeDropdown(); + return; + } + + // Handle individual finding click + const item = target.closest('.finding-item'); + if (!item) return; + e.stopPropagation(); + const id = item.getAttribute('data-finding-id'); + const finding = this.findings.find(f => f.id === id); + if (!finding) return; + + trackFindingClicked(finding.id, finding.source, finding.type, finding.priority); + if (finding.source === 'signal' && this.onSignalClick) { + this.onSignalClick(finding.original as CorrelationSignal); + } else if (finding.source === 'alert' && this.onAlertClick) { + this.onAlertClick(finding.original as UnifiedAlert); + } + this.closeDropdown(); + }); + + if (this.enabled) { + document.addEventListener('click', this.boundCloseDropdown); + this.mount(); + this.initAudio(); + this.update(); + this.startRefresh(); + } + } + + private initAudio(): void { + this.audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleQYjfKapmWswEjCJvuPQfSoXZZ+3qqBJESSP0unGaxMJVYiytrFeLhR6p8znrFUXRW+bs7V3Qx1hn8Xjp1cYPnegprhkMCFmoLi1k0sZTYGlqqlUIA=='); + this.audio.volume = 0.3; + } + + private playSound(): void { + if (this.audioEnabled && this.audio) { + this.audio.currentTime = 0; + this.audio.play().catch(() => {}); + } + } + + public setOnSignalClick(handler: (signal: CorrelationSignal) => void): void { + this.onSignalClick = handler; + } + + public setOnAlertClick(handler: (alert: UnifiedAlert) => void): void { + this.onAlertClick = handler; + } + + public static getStoredEnabledState(): boolean { + return localStorage.getItem(STORAGE_KEY) !== 'hidden'; + } + + public isEnabled(): boolean { + return this.enabled; + } + + public isPopupEnabled(): boolean { + return this.popupEnabled; + } + + public setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + + if (enabled) { + localStorage.removeItem(STORAGE_KEY); + document.addEventListener('click', this.boundCloseDropdown); + this.mount(); + this.initAudio(); + this.update(); + this.startRefresh(); + } else { + localStorage.setItem(STORAGE_KEY, 'hidden'); + document.removeEventListener('click', this.boundCloseDropdown); + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + this.closeDropdown(); + this.dismissContextMenu(); + this.badge.remove(); + } + } + + private showContextMenu(x: number, y: number): void { + this.dismissContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'intel-findings-context-menu'; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + menu.innerHTML = `
${t('components.intelligenceFindings.hideFindings')}
`; + + menu.querySelector('.context-menu-item')!.addEventListener('click', (e) => { + e.stopPropagation(); + this.setEnabled(false); + this.dismissContextMenu(); + }); + + const dismiss = () => this.dismissContextMenu(); + document.addEventListener('click', dismiss, { once: true }); + + this.contextMenu = menu; + document.body.appendChild(menu); + } + + private dismissContextMenu(): void { + if (this.contextMenu) { + this.contextMenu.remove(); + this.contextMenu = null; + } + } + + private mount(): void { + const headerRight = document.querySelector('.header-right'); + if (headerRight) { + this.badge.appendChild(this.dropdown); + headerRight.insertBefore(this.badge, headerRight.firstChild); + } + } + + private startRefresh(): void { + this.refreshInterval = setInterval(() => this.update(), REFRESH_INTERVAL_MS); + } + + public update(): void { + this.findings = this.mergeFindings(); + const count = this.findings.length; + + const countEl = this.badge.querySelector('.findings-count'); + if (countEl) { + countEl.textContent = String(count); + } + + // Pulse animation and sound when new findings arrive + if (count > this.lastFindingCount && this.lastFindingCount > 0) { + this.badge.classList.add('pulse'); + setTimeout(() => this.badge.classList.remove('pulse'), 1000); + if (this.popupEnabled) this.playSound(); + } + this.lastFindingCount = count; + + // Update badge status based on priority + const hasCritical = this.findings.some(f => f.priority === 'critical'); + const hasHigh = this.findings.some(f => f.priority === 'high' || f.confidence >= 0.7); + + this.badge.classList.remove('status-none', 'status-low', 'status-high'); + if (count === 0) { + this.badge.classList.add('status-none'); + this.badge.title = t('components.intelligenceFindings.none'); + } else if (hasCritical || hasHigh) { + this.badge.classList.add('status-high'); + this.badge.title = t('components.intelligenceFindings.reviewRecommended', { count: String(count) }); + } else if (count <= LOW_COUNT_THRESHOLD) { + this.badge.classList.add('status-low'); + this.badge.title = t('components.intelligenceFindings.count', { count: String(count) }); + } else { + this.badge.classList.add('status-high'); + this.badge.title = t('components.intelligenceFindings.reviewRecommended', { count: String(count) }); + } + + this.renderDropdown(); + } + + private mergeFindings(): UnifiedFinding[] { + const signals = getRecentSignals(); + const alerts = getRecentAlerts(ALERT_HOURS); + + const signalFindings: UnifiedFinding[] = signals.map(s => ({ + id: `signal-${s.id}`, + source: 'signal' as FindingSource, + type: s.type, + title: s.title, + description: s.description, + confidence: s.confidence, + priority: s.confidence >= 0.7 ? 'high' as const : s.confidence >= 0.5 ? 'medium' as const : 'low' as const, + timestamp: s.timestamp, + original: s, + })); + + const alertFindings: UnifiedFinding[] = alerts.map(a => ({ + id: `alert-${a.id}`, + source: 'alert' as FindingSource, + type: a.type, + title: a.title, + description: a.summary, + confidence: this.priorityToConfidence(a.priority), + priority: a.priority, + timestamp: a.timestamp, + original: a, + })); + + // Merge and sort by timestamp (newest first), then by priority + return [...signalFindings, ...alertFindings].sort((a, b) => { + const timeDiff = b.timestamp.getTime() - a.timestamp.getTime(); + if (Math.abs(timeDiff) < SORT_TIME_TOLERANCE_MS) { + return this.priorityScore(b.priority) - this.priorityScore(a.priority); + } + return timeDiff; + }); + } + + private priorityToConfidence(priority: string): number { + const map: Record = { critical: 95, high: 80, medium: 60, low: 40 }; + return map[priority] ?? 50; + } + + private priorityScore(priority: string): number { + const map: Record = { critical: 4, high: 3, medium: 2, low: 1 }; + return map[priority] ?? 0; + } + + private renderPopupToggle(): string { + const label = t('components.intelligenceFindings.popupAlerts'); + const checked = this.popupEnabled; + return ``; + } + + private renderDropdown(): void { + const toggleHtml = this.renderPopupToggle(); + + if (this.findings.length === 0) { + this.dropdown.innerHTML = ` +
+ ${t('components.intelligenceFindings.title')} + ${t('components.intelligenceFindings.monitoring')} +
+ ${toggleHtml} +
+
+ 📡 + ${t('components.intelligenceFindings.scanning')} +
+
+ `; + return; + } + + const criticalCount = this.findings.filter(f => f.priority === 'critical').length; + const highCount = this.findings.filter(f => f.priority === 'high' || f.confidence >= 70).length; + + let statusClass = 'moderate'; + let statusText = t('components.intelligenceFindings.detected', { count: String(this.findings.length) }); + if (criticalCount > 0) { + statusClass = 'critical'; + statusText = t('components.intelligenceFindings.critical', { count: String(criticalCount) }); + } else if (highCount > 0) { + statusClass = 'high'; + statusText = t('components.intelligenceFindings.highPriority', { count: String(highCount) }); + } + + const findingsHtml = this.findings.slice(0, MAX_VISIBLE_FINDINGS).map(finding => { + const timeAgo = this.formatTimeAgo(finding.timestamp); + const icon = this.getTypeIcon(finding.type); + const priorityClass = finding.priority; + const insight = this.getInsight(finding); + + return ` +
+
+ ${icon} ${escapeHtml(finding.title)} + ${t(`components.intelligenceFindings.priority.${finding.priority}`)} +
+
${escapeHtml(finding.description)}
+
+ ${escapeHtml(insight)} + ${timeAgo} +
+
+ `; + }).join(''); + + const moreCount = this.findings.length - MAX_VISIBLE_FINDINGS; + this.dropdown.innerHTML = ` +
+ ${t('components.intelligenceFindings.title')} + ${statusText} +
+ ${toggleHtml} +
+
+ ${findingsHtml} +
+ ${moreCount > 0 ? `
${t('components.intelligenceFindings.more', { count: String(moreCount) })}
` : ''} +
+ `; + } + + private getInsight(finding: UnifiedFinding): string { + if (finding.source === 'signal') { + const context = getSignalContext((finding.original as CorrelationSignal).type); + return (context.actionableInsight ?? '').split('.')[0] || ''; + } + // For alerts, provide actionable insight based on type and severity + const alert = finding.original as UnifiedAlert; + if (alert.type === 'cii_spike') { + const cii = alert.components.ciiChange; + if (cii && cii.change >= 30) return t('components.intelligenceFindings.insights.criticalDestabilization'); + if (cii && cii.change >= 20) return t('components.intelligenceFindings.insights.significantShift'); + return t('components.intelligenceFindings.insights.developingSituation'); + } + if (alert.type === 'convergence') return t('components.intelligenceFindings.insights.convergence'); + if (alert.type === 'cascade') return t('components.intelligenceFindings.insights.cascade'); + return t('components.intelligenceFindings.insights.review'); + } + + private getTypeIcon(type: string): string { + const icons: Record = { + // Correlation signals + breaking_surge: '🔥', + silent_divergence: '🔇', + flow_price_divergence: '📊', + explained_market_move: '💡', + prediction_leads_news: '🔮', + geo_convergence: '🌍', + hotspot_escalation: '⚠️', + news_leads_markets: '📰', + velocity_spike: '📈', + keyword_spike: '📊', + convergence: '🔀', + triangulation: '🔺', + flow_drop: '⬇️', + sector_cascade: '🌊', + // Unified alerts + cii_spike: '🔴', + cascade: '⚡', + composite: '🔗', + }; + return icons[type] || '📌'; + } + + private formatTimeAgo(date: Date): string { + const ms = Date.now() - date.getTime(); + if (ms < 60000) return t('components.intelligenceFindings.time.justNow'); + if (ms < 3600000) return t('components.intelligenceFindings.time.minutesAgo', { count: String(Math.floor(ms / 60000)) }); + if (ms < 86400000) return t('components.intelligenceFindings.time.hoursAgo', { count: String(Math.floor(ms / 3600000)) }); + return t('components.intelligenceFindings.time.daysAgo', { count: String(Math.floor(ms / 86400000)) }); + } + + private toggleDropdown(): void { + this.isOpen = !this.isOpen; + this.dropdown.classList.toggle('open', this.isOpen); + this.badge.classList.toggle('active', this.isOpen); + if (this.isOpen) { + this.update(); + } + } + + private closeDropdown(): void { + this.isOpen = false; + this.dropdown.classList.remove('open'); + this.badge.classList.remove('active'); + } + + private showAllFindings(): void { + // Create modal overlay + const overlay = document.createElement('div'); + overlay.className = 'findings-modal-overlay'; + + const findingsHtml = this.findings.map(finding => { + const timeAgo = this.formatTimeAgo(finding.timestamp); + const icon = this.getTypeIcon(finding.type); + const insight = this.getInsight(finding); + + return ` +
+
+ ${icon} ${escapeHtml(finding.title)} + ${t(`components.intelligenceFindings.priority.${finding.priority}`)} +
+
${escapeHtml(finding.description)}
+
+ ${escapeHtml(insight)} + ${timeAgo} +
+
+ `; + }).join(''); + + overlay.innerHTML = ` +
+
+ 🎯 ${t('components.intelligenceFindings.all', { count: String(this.findings.length) })} + +
+
+ ${findingsHtml} +
+
+ `; + + // Add click handlers + overlay.querySelector('.findings-modal-close')?.addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('findings-modal-overlay')) { + overlay.remove(); + } + }); + + // Handle clicking individual items + overlay.querySelectorAll('.findings-modal-item').forEach(item => { + item.addEventListener('click', () => { + const id = item.getAttribute('data-finding-id'); + const finding = this.findings.find(f => f.id === id); + if (!finding) return; + + trackFindingClicked(finding.id, finding.source, finding.type, finding.priority); + if (finding.source === 'signal' && this.onSignalClick) { + this.onSignalClick(finding.original as CorrelationSignal); + overlay.remove(); + } else if (finding.source === 'alert' && this.onAlertClick) { + this.onAlertClick(finding.original as UnifiedAlert); + overlay.remove(); + } + }); + }); + + document.body.appendChild(overlay); + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + document.removeEventListener('click', this.boundCloseDropdown); + this.badge.remove(); + } +} + +// Re-export with old name for backwards compatibility +export { IntelligenceFindingsBadge as IntelligenceGapBadge }; diff --git a/src/components/InvestmentsPanel.ts b/src/components/InvestmentsPanel.ts new file mode 100644 index 000000000..632dd9e7e --- /dev/null +++ b/src/components/InvestmentsPanel.ts @@ -0,0 +1,229 @@ +import { Panel } from './Panel'; +import { GULF_INVESTMENTS } from '@/config/gulf-fdi'; +import type { + GulfInvestment, + GulfInvestmentSector, + GulfInvestorCountry, + GulfInvestingEntity, + GulfInvestmentStatus, +} from '@/types'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; + +interface InvestmentFilters { + investingCountry: GulfInvestorCountry | 'ALL'; + sector: GulfInvestmentSector | 'ALL'; + entity: GulfInvestingEntity | 'ALL'; + status: GulfInvestmentStatus | 'ALL'; + search: string; +} + +function getSectorLabel(sector: GulfInvestmentSector): string { + const labels: Record = { + ports: t('components.investments.sectors.ports'), + pipelines: t('components.investments.sectors.pipelines'), + energy: t('components.investments.sectors.energy'), + datacenters: t('components.investments.sectors.datacenters'), + airports: t('components.investments.sectors.airports'), + railways: t('components.investments.sectors.railways'), + telecoms: t('components.investments.sectors.telecoms'), + water: t('components.investments.sectors.water'), + logistics: t('components.investments.sectors.logistics'), + mining: t('components.investments.sectors.mining'), + 'real-estate': t('components.investments.sectors.realEstate'), + manufacturing: t('components.investments.sectors.manufacturing'), + }; + return labels[sector] || sector; +} + +const STATUS_COLORS: Record = { + 'operational': '#22c55e', + 'under-construction': '#f59e0b', + 'announced': '#60a5fa', + 'rumoured': '#a78bfa', + 'cancelled': '#ef4444', + 'divested': '#6b7280', +}; + +const FLAG: Record = { + SA: '🇸🇦', + UAE: '🇦🇪', +}; + +function formatUSD(usd?: number): string { + if (usd === undefined) return t('components.investments.undisclosed'); + if (usd >= 100000) return `$${(usd / 1000).toFixed(0)}B`; + if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}B`; + return `$${usd.toLocaleString()}M`; +} + +export class InvestmentsPanel extends Panel { + private filters: InvestmentFilters = { + investingCountry: 'ALL', + sector: 'ALL', + entity: 'ALL', + status: 'ALL', + search: '', + }; + private sortKey: keyof GulfInvestment = 'assetName'; + private sortAsc = true; + private onInvestmentClick?: (inv: GulfInvestment) => void; + + constructor(onInvestmentClick?: (inv: GulfInvestment) => void) { + super({ + id: 'gcc-investments', + title: t('panels.gccInvestments'), + showCount: true, + infoTooltip: t('components.investments.infoTooltip'), + }); + this.onInvestmentClick = onInvestmentClick; + this.setupEventDelegation(); + this.render(); + } + + private getFiltered(): GulfInvestment[] { + const { investingCountry, sector, entity, status, search } = this.filters; + const q = search.toLowerCase(); + + return GULF_INVESTMENTS + .filter(inv => { + if (investingCountry !== 'ALL' && inv.investingCountry !== investingCountry) return false; + if (sector !== 'ALL' && inv.sector !== sector) return false; + if (entity !== 'ALL' && inv.investingEntity !== entity) return false; + if (status !== 'ALL' && inv.status !== status) return false; + if (q && !inv.assetName.toLowerCase().includes(q) + && !inv.targetCountry.toLowerCase().includes(q) + && !inv.description.toLowerCase().includes(q) + && !inv.investingEntity.toLowerCase().includes(q)) return false; + return true; + }) + .sort((a, b) => { + const key = this.sortKey; + const av = a[key] ?? ''; + const bv = b[key] ?? ''; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return this.sortAsc ? cmp : -cmp; + }); + } + + private render(): void { + const filtered = this.getFiltered(); + + // Build unique entity list for dropdown + const entities = Array.from(new Set(GULF_INVESTMENTS.map(i => i.investingEntity))).sort(); + const sectors = Array.from(new Set(GULF_INVESTMENTS.map(i => i.sector))).sort(); + + const sortArrow = (key: keyof GulfInvestment) => + this.sortKey === key ? (this.sortAsc ? ' ↑' : ' ↓') : ''; + + const rows = filtered.map(inv => { + const statusColor = STATUS_COLORS[inv.status] || '#6b7280'; + const flag = FLAG[inv.investingCountry] || ''; + const sector = getSectorLabel(inv.sector); + return ` + + + ${flag} + ${escapeHtml(inv.assetName)} +
${escapeHtml(inv.investingEntity)}
+ + ${escapeHtml(inv.targetCountry)} + ${escapeHtml(sector)} + ${escapeHtml(inv.status)} + ${escapeHtml(formatUSD(inv.investmentUSD))} + ${inv.yearAnnounced ?? inv.yearOperational ?? '—'} + `; + }).join(''); + + const html = ` +
+ + + + + +
+
+ + + + + + + + + + + + ${rows || ``} +
${t('components.investments.asset')}${sortArrow('assetName')}${t('components.investments.country')}${sortArrow('targetCountry')}${t('components.investments.sector')}${sortArrow('sector')}${t('components.investments.status')}${sortArrow('status')}${t('components.investments.investment')}${sortArrow('investmentUSD')}${t('components.investments.year')}${sortArrow('yearAnnounced')}
${t('components.investments.noMatch')}
+
`; + + this.setContent(html); + if (this.countEl) this.countEl.textContent = String(filtered.length); + } + + private setupEventDelegation(): void { + this.content.addEventListener('input', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('fdi-search')) { + this.filters.search = (target as HTMLInputElement).value; + this.render(); + } + }); + + this.content.addEventListener('change', (e) => { + const sel = (e.target as HTMLElement).closest('.fdi-filter') as HTMLSelectElement | null; + if (sel) { + const key = sel.dataset.filter as keyof InvestmentFilters; + (this.filters as unknown as Record)[key] = sel.value; + this.render(); + } + }); + + this.content.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const th = target.closest('.fdi-sort') as HTMLElement | null; + if (th) { + const key = th.dataset.sort as keyof GulfInvestment; + if (this.sortKey === key) { + this.sortAsc = !this.sortAsc; + } else { + this.sortKey = key; + this.sortAsc = true; + } + this.render(); + return; + } + const row = target.closest('.fdi-row') as HTMLElement | null; + if (row) { + const inv = GULF_INVESTMENTS.find(i => i.id === row.dataset.id); + if (inv && this.onInvestmentClick) { + this.onInvestmentClick(inv); + } + } + }); + } +} diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts new file mode 100644 index 000000000..e90c5e4a4 --- /dev/null +++ b/src/components/LiveNewsPanel.ts @@ -0,0 +1,1207 @@ +import { Panel } from './Panel'; +import { fetchLiveVideoId } from '@/services/live-news'; +import { isDesktopRuntime, getRemoteApiBaseUrl, getApiBaseUrl } from '@/services/runtime'; +import { t } from '../services/i18n'; +import { loadFromStorage, saveToStorage } from '@/utils'; +import { STORAGE_KEYS, SITE_VARIANT } from '@/config'; +import { getStreamQuality } from '@/services/ai-flow-settings'; + +// YouTube IFrame Player API types +type YouTubePlayer = { + mute(): void; + unMute(): void; + playVideo(): void; + pauseVideo(): void; + loadVideoById(videoId: string): void; + cueVideoById(videoId: string): void; + setPlaybackQuality?(quality: string): void; + getIframe?(): HTMLIFrameElement; + getVolume?(): number; + destroy(): void; +}; + +type YouTubePlayerConstructor = new ( + elementId: string | HTMLElement, + options: { + videoId: string; + host?: string; + playerVars: Record; + events: { + onReady: () => void; + onError?: (event: { data: number }) => void; + }; + }, +) => YouTubePlayer; + +type YouTubeNamespace = { + Player: YouTubePlayerConstructor; +}; + +declare global { + interface Window { + YT?: YouTubeNamespace; + onYouTubeIframeAPIReady?: () => void; + } +} + +export interface LiveChannel { + id: string; + name: string; + handle: string; // YouTube channel handle (e.g., @bloomberg) + fallbackVideoId?: string; // Fallback if no live stream detected + videoId?: string; // Dynamically fetched live video ID + isLive?: boolean; + useFallbackOnly?: boolean; // Skip auto-detection, always use fallback +} + + +// Full variant: World news channels (24/7 live streams) +const FULL_LIVE_CHANNELS: LiveChannel[] = [ + { id: 'bloomberg', name: 'Bloomberg', handle: '@Bloomberg', fallbackVideoId: 'iEpJwprxDdk' }, + { id: 'sky', name: 'SkyNews', handle: '@SkyNews', fallbackVideoId: 'YDvsBbKfLPA' }, + { id: 'euronews', name: 'Euronews', handle: '@euabortnews', fallbackVideoId: 'pykpO5kQJ98' }, + { id: 'dw', name: 'DW', handle: '@DWNews', fallbackVideoId: 'LuKwFajn37U' }, + { id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' }, + { id: 'france24', name: 'France24', handle: '@FRANCE24English', fallbackVideoId: 'Ap-UM1O9RBU' }, + { id: 'alarabiya', name: 'AlArabiya', handle: '@AlArabiya', fallbackVideoId: 'n7eQejkXbnM', useFallbackOnly: true }, + { id: 'aljazeera', name: 'AlJazeera', handle: '@AlJazeeraEnglish', fallbackVideoId: 'gCNeDWCI0vo', useFallbackOnly: true }, +]; + +// Tech variant: Tech & business channels +const TECH_LIVE_CHANNELS: LiveChannel[] = [ + { id: 'bloomberg', name: 'Bloomberg', handle: '@Bloomberg', fallbackVideoId: 'iEpJwprxDdk' }, + { id: 'yahoo', name: 'Yahoo Finance', handle: '@YahooFinance', fallbackVideoId: 'KQp-e_XQnDE' }, + { id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' }, + { id: 'nasa', name: 'Sen Space Live', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true }, +]; + +// Optional channels users can add from the "Available Channels" tab UI +export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [ + // North America + { id: 'livenow-fox', name: 'LiveNOW from FOX', handle: '@LiveNOWfromFOX' }, + { id: 'fox-news', name: 'Fox News', handle: '@FoxNews', fallbackVideoId: 'QaftgYkG-ek', useFallbackOnly: true }, + { id: 'newsmax', name: 'Newsmax', handle: '@NEWSMAX', fallbackVideoId: 'cZikyozILOY', useFallbackOnly: true }, + { id: 'abc-news', name: 'ABC News', handle: '@ABCNews' }, + { id: 'cbs-news', name: 'CBS News', handle: '@CBSNews' }, + { id: 'nbc-news', name: 'NBC News', handle: '@NBCNews' }, + // Europe + { id: 'bbc-news', name: 'BBC News', handle: '@BBCNews' }, + { id: 'france24-en', name: 'France 24 English', handle: '@FRANCE24English' }, + { id: 'welt', name: 'WELT', handle: '@WELTNachrichtensender' }, + { id: 'rtve', name: 'RTVE 24H', handle: '@RTVENoticias', fallbackVideoId: '7_srED6k0bE' }, + { id: 'trt-haber', name: 'TRT Haber', handle: '@trthaber' }, + { id: 'ntv-turkey', name: 'NTV', handle: '@NTV' }, + { id: 'cnn-turk', name: 'CNN TURK', handle: '@cnnturk' }, + { id: 'tv-rain', name: 'TV Rain', handle: '@tvrain' }, + // Latin America & Portuguese + { id: 'cnn-brasil', name: 'CNN Brasil', handle: '@CNNbrasil', fallbackVideoId: '1kWRw-DA6Ns' }, + { id: 'jovem-pan', name: 'Jovem Pan News', handle: '@jovempannews' }, + { id: 'record-news', name: 'Record News', handle: '@recordnewsoficial' }, + { id: 'band-jornalismo', name: 'Band Jornalismo', handle: '@BandJornalismo' }, + { id: 'tn-argentina', name: 'TN (Todo Noticias)', handle: '@todonoticias', fallbackVideoId: 'cb12KmMMDJA' }, + { id: 'c5n', name: 'C5N', handle: '@c5n', fallbackVideoId: 'NdQSOItOQ5Y' }, + { id: 'milenio', name: 'MILENIO', handle: '@MILENIO' }, + { id: 'noticias-caracol', name: 'Noticias Caracol', handle: '@NoticiasCaracol' }, + { id: 'ntn24', name: 'NTN24', handle: '@NTN24' }, + { id: 't13', name: 'T13', handle: '@T13' }, + // Asia + { id: 'tbs-news', name: 'TBS NEWS DIG', handle: '@tbsnewsdig', fallbackVideoId: 'ohI356mwBp8' }, + { id: 'ann-news', name: 'ANN News', handle: '@ANNnewsCH' }, + { id: 'ntv-news', name: 'NTV News (Japan)', handle: '@ntv_news' }, + { id: 'cti-news', name: 'CTI News (Taiwan)', handle: '@CtiTv', fallbackVideoId: 'wUPPkSANpyo', useFallbackOnly: true }, + { id: 'wion', name: 'WION', handle: '@WIONews' }, + { id: 'vtc-now', name: 'VTC NOW', handle: '@VTCNOW' }, + { id: 'cna-asia', name: 'CNA (NewsAsia)', handle: '@channelnewsasia' }, + { id: 'nhk-world', name: 'NHK World Japan', handle: '@NHKWORLDJAPAN' }, + // Middle East + { id: 'al-hadath', name: 'Al Hadath', handle: '@AlHadath', fallbackVideoId: 'xWXpl7azI8k', useFallbackOnly: true }, + { id: 'sky-news-arabia', name: 'Sky News Arabia', handle: '@skynewsarabia' }, + { id: 'trt-world', name: 'TRT World', handle: '@taborrtworld' }, + { id: 'iran-intl', name: 'Iran International', handle: '@IranIntl' }, + { id: 'cgtn-arabic', name: 'CGTN Arabic', handle: '@CGTNArabic' }, + // Africa + { id: 'africanews', name: 'Africanews', handle: '@africanews' }, + { id: 'channels-tv', name: 'Channels TV', handle: '@channelstv' }, + { id: 'ktn-news', name: 'KTN News', handle: '@KTNNewsKE' }, + { id: 'enca', name: 'eNCA', handle: '@enewschannel' }, + { id: 'sabc-news', name: 'SABC News', handle: '@SABCNews' }, +]; + +export const OPTIONAL_CHANNEL_REGIONS: { key: string; labelKey: string; channelIds: string[] }[] = [ + { key: 'na', labelKey: 'components.liveNews.regionNorthAmerica', channelIds: ['livenow-fox', 'fox-news', 'newsmax', 'abc-news', 'cbs-news', 'nbc-news'] }, + { key: 'eu', labelKey: 'components.liveNews.regionEurope', channelIds: ['bbc-news', 'france24-en', 'welt', 'rtve', 'trt-haber', 'ntv-turkey', 'cnn-turk', 'tv-rain'] }, + { key: 'latam', labelKey: 'components.liveNews.regionLatinAmerica', channelIds: ['cnn-brasil', 'jovem-pan', 'record-news', 'band-jornalismo', 'tn-argentina', 'c5n', 'milenio', 'noticias-caracol', 'ntn24', 't13'] }, + { key: 'asia', labelKey: 'components.liveNews.regionAsia', channelIds: ['tbs-news', 'ann-news', 'ntv-news', 'cti-news', 'wion', 'vtc-now', 'cna-asia', 'nhk-world'] }, + { key: 'me', labelKey: 'components.liveNews.regionMiddleEast', channelIds: ['al-hadath', 'sky-news-arabia', 'trt-world', 'iran-intl', 'cgtn-arabic'] }, + { key: 'africa', labelKey: 'components.liveNews.regionAfrica', channelIds: ['africanews', 'channels-tv', 'ktn-news', 'enca', 'sabc-news'] }, +]; + +const DEFAULT_LIVE_CHANNELS = SITE_VARIANT === 'tech' ? TECH_LIVE_CHANNELS : SITE_VARIANT === 'happy' ? [] : FULL_LIVE_CHANNELS; + +/** Default channel list for the current variant (for restore in channel management). */ +export function getDefaultLiveChannels(): LiveChannel[] { + return [...DEFAULT_LIVE_CHANNELS]; +} + +export interface StoredLiveChannels { + order: string[]; + custom?: LiveChannel[]; + /** Display name overrides for built-in channels (and custom). */ + displayNameOverrides?: Record; +} + +const DEFAULT_STORED: StoredLiveChannels = { + order: DEFAULT_LIVE_CHANNELS.map((c) => c.id), +}; + +export const BUILTIN_IDS = new Set([ + ...FULL_LIVE_CHANNELS.map((c) => c.id), + ...TECH_LIVE_CHANNELS.map((c) => c.id), + ...OPTIONAL_LIVE_CHANNELS.map((c) => c.id), +]); + +export function loadChannelsFromStorage(): LiveChannel[] { + const stored = loadFromStorage(STORAGE_KEYS.liveChannels, DEFAULT_STORED); + const order = stored.order?.length ? stored.order : DEFAULT_STORED.order; + const channelMap = new Map(); + for (const c of FULL_LIVE_CHANNELS) channelMap.set(c.id, { ...c }); + for (const c of TECH_LIVE_CHANNELS) channelMap.set(c.id, { ...c }); + for (const c of OPTIONAL_LIVE_CHANNELS) channelMap.set(c.id, { ...c }); + for (const c of stored.custom ?? []) { + if (c.id && c.handle) channelMap.set(c.id, { ...c }); + } + const overrides = stored.displayNameOverrides ?? {}; + for (const [id, name] of Object.entries(overrides)) { + const ch = channelMap.get(id); + if (ch) ch.name = name; + } + const result: LiveChannel[] = []; + for (const id of order) { + const ch = channelMap.get(id); + if (ch) result.push(ch); + } + return result; +} + +export function saveChannelsToStorage(channels: LiveChannel[]): void { + const order = channels.map((c) => c.id); + const custom = channels.filter((c) => !BUILTIN_IDS.has(c.id)); + const builtinNames = new Map(); + for (const c of [...FULL_LIVE_CHANNELS, ...TECH_LIVE_CHANNELS, ...OPTIONAL_LIVE_CHANNELS]) builtinNames.set(c.id, c.name); + const displayNameOverrides: Record = {}; + for (const c of channels) { + if (builtinNames.has(c.id) && c.name !== builtinNames.get(c.id)) { + displayNameOverrides[c.id] = c.name; + } + } + saveToStorage(STORAGE_KEYS.liveChannels, { order, custom, displayNameOverrides }); +} + +export class LiveNewsPanel extends Panel { + private static apiPromise: Promise | null = null; + private channels: LiveChannel[] = []; + private activeChannel!: LiveChannel; + private channelSwitcher: HTMLElement | null = null; + private isMuted = true; + private isPlaying = true; + private wasPlayingBeforeIdle = true; + private muteBtn: HTMLButtonElement | null = null; + private liveBtn: HTMLButtonElement | null = null; + private idleTimeout: ReturnType | null = null; + private readonly IDLE_PAUSE_MS = 5 * 60 * 1000; // 5 minutes + private boundVisibilityHandler!: () => void; + private boundIdleResetHandler!: () => void; + + // YouTube Player API state + private player: YouTubePlayer | null = null; + private playerContainer: HTMLDivElement | null = null; + private playerElement: HTMLDivElement | null = null; + private playerElementId: string; + private isPlayerReady = false; + private currentVideoId: string | null = null; + private readonly youtubeOrigin: string | null; + private forceFallbackVideoForNextInit = false; + + // Desktop fallback: embed via cloud bridge page to avoid YouTube 153. + // Starts false — try native JS API first; switches to true on Error 153. + private useDesktopEmbedProxy = false; + private desktopEmbedIframe: HTMLIFrameElement | null = null; + private desktopEmbedRenderToken = 0; + private suppressChannelClick = false; + private boundMessageHandler!: (e: MessageEvent) => void; + private muteSyncInterval: ReturnType | null = null; + private static readonly MUTE_SYNC_POLL_MS = 500; + + // Bot-check detection: if player doesn't become ready within this timeout, + // YouTube is likely showing "Sign in to confirm you're not a bot". + private botCheckTimeout: ReturnType | null = null; + private static readonly BOT_CHECK_TIMEOUT_MS = 15_000; + + private deferredInit = false; + private lazyObserver: IntersectionObserver | null = null; + private idleCallbackId: number | ReturnType | null = null; + + constructor() { + super({ id: 'live-news', title: t('panels.liveNews') }); + this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin(); + this.playerElementId = `live-news-player-${Date.now()}`; + this.element.classList.add('panel-wide'); + this.channels = loadChannelsFromStorage(); + if (this.channels.length === 0) this.channels = getDefaultLiveChannels(); + this.activeChannel = this.channels[0]!; + this.createLiveButton(); + this.createMuteButton(); + this.createChannelSwitcher(); + this.setupBridgeMessageListener(); + this.renderPlaceholder(); + this.setupLazyInit(); + this.setupIdleDetection(); + } + + private renderPlaceholder(): void { + this.content.innerHTML = ''; + const container = document.createElement('div'); + container.className = 'live-news-placeholder'; + container.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:12px;cursor:pointer;'; + + const label = document.createElement('div'); + label.style.cssText = 'color:var(--text-secondary);font-size:13px;'; + label.textContent = this.activeChannel.name; + + const playBtn = document.createElement('button'); + playBtn.className = 'offline-retry'; + playBtn.textContent = 'Load Player'; + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.triggerInit(); + }); + + container.appendChild(label); + container.appendChild(playBtn); + container.addEventListener('click', () => this.triggerInit()); + this.content.appendChild(container); + } + + private setupLazyInit(): void { + this.lazyObserver = new IntersectionObserver( + (entries) => { + if (entries.some(e => e.isIntersecting)) { + this.lazyObserver?.disconnect(); + this.lazyObserver = null; + if ('requestIdleCallback' in window) { + this.idleCallbackId = (window as any).requestIdleCallback( + () => { this.idleCallbackId = null; this.triggerInit(); }, + { timeout: 1000 }, + ); + } else { + this.idleCallbackId = setTimeout(() => { this.idleCallbackId = null; this.triggerInit(); }, 1000); + } + } + }, + { threshold: 0.1 }, + ); + this.lazyObserver.observe(this.element); + } + + private triggerInit(): void { + if (this.deferredInit) return; + this.deferredInit = true; + if (this.lazyObserver) { this.lazyObserver.disconnect(); this.lazyObserver = null; } + if (this.idleCallbackId !== null) { + if ('cancelIdleCallback' in window) (window as any).cancelIdleCallback(this.idleCallbackId); + else clearTimeout(this.idleCallbackId as ReturnType); + this.idleCallbackId = null; + } + this.renderPlayer(); + } + + private saveChannels(): void { + saveChannelsToStorage(this.channels); + } + + private get embedOrigin(): string { + try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; } + } + + private setupBridgeMessageListener(): void { + this.boundMessageHandler = (e: MessageEvent) => { + if (e.source !== this.desktopEmbedIframe?.contentWindow) return; + const expected = this.embedOrigin; + const localOrigin = getApiBaseUrl(); + if (e.origin !== expected && (!localOrigin || e.origin !== localOrigin)) return; + const msg = e.data; + if (!msg || typeof msg !== 'object' || !msg.type) return; + if (msg.type === 'yt-ready') { + this.clearBotCheckTimeout(); + this.isPlayerReady = true; + this.syncDesktopEmbedState(); + } else if (msg.type === 'yt-error') { + this.clearBotCheckTimeout(); + const code = Number(msg.code ?? 0); + if (code === 153 && this.activeChannel.fallbackVideoId && + this.activeChannel.videoId !== this.activeChannel.fallbackVideoId) { + this.activeChannel.videoId = this.activeChannel.fallbackVideoId; + this.renderDesktopEmbed(true); + } else { + this.showEmbedError(this.activeChannel, code); + } + } else if (msg.type === 'yt-mute-state') { + const muted = msg.muted === true; + if (this.isMuted !== muted) { + this.isMuted = muted; + this.updateMuteIcon(); + } + } + }; + window.addEventListener('message', this.boundMessageHandler); + } + + private static resolveYouTubeOrigin(): string | null { + const fallbackOrigin = SITE_VARIANT === 'tech' + ? 'https://worldmonitor.app' + : 'https://worldmonitor.app'; + + try { + const { protocol, origin, host } = window.location; + if (protocol === 'http:' || protocol === 'https:') { + // Desktop webviews commonly run from tauri.localhost which can trigger + // YouTube embed restrictions. Use canonical public origin instead. + if (host === 'tauri.localhost' || host.endsWith('.tauri.localhost')) { + return fallbackOrigin; + } + return origin; + } + if (protocol === 'tauri:' || protocol === 'asset:') { + return fallbackOrigin; + } + } catch { + // Ignore invalid location values. + } + return fallbackOrigin; + } + + private setupIdleDetection(): void { + // Suspend idle timer when hidden, resume when visible + this.boundVisibilityHandler = () => { + if (document.hidden) { + // Suspend idle timer so background playback isn't killed + if (this.idleTimeout) clearTimeout(this.idleTimeout); + } else { + this.resumeFromIdle(); + this.boundIdleResetHandler(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + + // Track user activity to detect idle (pauses after 5 min inactivity) + this.boundIdleResetHandler = () => { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + this.resumeFromIdle(); + this.idleTimeout = setTimeout(() => this.pauseForIdle(), this.IDLE_PAUSE_MS); + }; + + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.addEventListener(event, this.boundIdleResetHandler, { passive: true }); + }); + + // Start the idle timer + this.boundIdleResetHandler(); + } + + private pauseForIdle(): void { + if (this.isPlaying) { + this.wasPlayingBeforeIdle = true; + this.isPlaying = false; + this.updateLiveIndicator(); + } + this.destroyPlayer(); + } + + private stopMuteSyncPolling(): void { + if (this.muteSyncInterval !== null) { + clearInterval(this.muteSyncInterval); + this.muteSyncInterval = null; + } + } + + private startMuteSyncPolling(): void { + this.stopMuteSyncPolling(); + this.muteSyncInterval = setInterval(() => this.syncMuteStateFromPlayer(), LiveNewsPanel.MUTE_SYNC_POLL_MS); + } + + private syncMuteStateFromPlayer(): void { + if (this.useDesktopEmbedProxy || !this.player || !this.isPlayerReady) return; + const p = this.player as { getVolume?(): number; isMuted?(): boolean }; + const muted = typeof p.isMuted === 'function' + ? p.isMuted() + : (p.getVolume?.() === 0); + if (typeof muted === 'boolean' && muted !== this.isMuted) { + this.isMuted = muted; + this.updateMuteIcon(); + } + } + + private destroyPlayer(): void { + this.clearBotCheckTimeout(); + this.stopMuteSyncPolling(); + if (this.player) { + if (typeof this.player.destroy === 'function') this.player.destroy(); + this.player = null; + } + + this.desktopEmbedIframe = null; + this.desktopEmbedRenderToken += 1; + this.isPlayerReady = false; + this.currentVideoId = null; + + // Clear the container to remove player/iframe + if (this.playerContainer) { + this.playerContainer.innerHTML = ''; + + if (!this.useDesktopEmbedProxy) { + // Recreate player element for JS API mode + this.playerElement = document.createElement('div'); + this.playerElement.id = this.playerElementId; + this.playerContainer.appendChild(this.playerElement); + } else { + this.playerElement = null; + } + } + } + + private resumeFromIdle(): void { + if (this.wasPlayingBeforeIdle && !this.isPlaying) { + this.isPlaying = true; + this.updateLiveIndicator(); + void this.initializePlayer(); + } + } + + private createLiveButton(): void { + this.liveBtn = document.createElement('button'); + this.liveBtn.className = 'live-indicator-btn'; + this.liveBtn.title = 'Toggle playback'; + this.updateLiveIndicator(); + this.liveBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.togglePlayback(); + }); + + const header = this.element.querySelector('.panel-header'); + header?.appendChild(this.liveBtn); + } + + private updateLiveIndicator(): void { + if (!this.liveBtn) return; + this.liveBtn.innerHTML = this.isPlaying + ? 'Live' + : 'Paused'; + this.liveBtn.classList.toggle('paused', !this.isPlaying); + } + + private togglePlayback(): void { + this.isPlaying = !this.isPlaying; + this.wasPlayingBeforeIdle = this.isPlaying; + this.updateLiveIndicator(); + if (this.isPlaying && !this.player && !this.desktopEmbedIframe) { + this.ensurePlayerContainer(); + void this.initializePlayer(); + } else { + this.syncPlayerState(); + } + } + + private createMuteButton(): void { + this.muteBtn = document.createElement('button'); + this.muteBtn.className = 'live-mute-btn'; + this.muteBtn.title = 'Toggle sound'; + this.updateMuteIcon(); + this.muteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleMute(); + }); + + const header = this.element.querySelector('.panel-header'); + header?.appendChild(this.muteBtn); + } + + private updateMuteIcon(): void { + if (!this.muteBtn) return; + this.muteBtn.innerHTML = this.isMuted + ? '' + : ''; + this.muteBtn.classList.toggle('unmuted', !this.isMuted); + } + + private toggleMute(): void { + this.isMuted = !this.isMuted; + this.updateMuteIcon(); + this.syncPlayerState(); + } + + /** Creates a single channel tab button with click and drag handlers. */ + private createChannelButton(channel: LiveChannel): HTMLButtonElement { + const btn = document.createElement('button'); + btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`; + btn.dataset.channelId = channel.id; + btn.textContent = channel.name; + btn.style.cursor = 'grab'; + btn.addEventListener('click', (e) => { + if (this.suppressChannelClick) { + e.preventDefault(); + e.stopPropagation(); + return; + } + e.preventDefault(); + this.switchChannel(channel); + }); + return btn; + } + + private createChannelSwitcher(): void { + this.channelSwitcher = document.createElement('div'); + this.channelSwitcher.className = 'live-news-switcher'; + + for (const channel of this.channels) { + this.channelSwitcher.appendChild(this.createChannelButton(channel)); + } + + // Mouse-based drag reorder (works in WKWebView/Tauri) + let dragging: HTMLElement | null = null; + let dragStarted = false; + let startX = 0; + const THRESHOLD = 6; + + this.channelSwitcher.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + const btn = (e.target as HTMLElement).closest('.live-channel-btn') as HTMLElement | null; + if (!btn) return; + this.suppressChannelClick = false; + dragging = btn; + dragStarted = false; + startX = e.clientX; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!dragging || !this.channelSwitcher) return; + if (!dragStarted) { + if (Math.abs(e.clientX - startX) < THRESHOLD) return; + dragStarted = true; + dragging.classList.add('live-channel-dragging'); + } + const target = document.elementFromPoint(e.clientX, e.clientY)?.closest('.live-channel-btn') as HTMLElement | null; + if (!target || target === dragging) return; + const all = Array.from(this.channelSwitcher!.querySelectorAll('.live-channel-btn')); + const idx = all.indexOf(dragging); + const targetIdx = all.indexOf(target); + if (idx === -1 || targetIdx === -1) return; + if (idx < targetIdx) { + target.parentElement?.insertBefore(dragging, target.nextSibling); + } else { + target.parentElement?.insertBefore(dragging, target); + } + }); + + document.addEventListener('mouseup', () => { + if (!dragging) return; + if (dragStarted) { + dragging.classList.remove('live-channel-dragging'); + this.applyChannelOrderFromDom(); + this.suppressChannelClick = true; + setTimeout(() => { + this.suppressChannelClick = false; + }, 0); + } + dragging = null; + dragStarted = false; + }); + + const toolbar = document.createElement('div'); + toolbar.className = 'live-news-toolbar'; + toolbar.appendChild(this.channelSwitcher); + this.createManageButton(toolbar); + this.element.insertBefore(toolbar, this.content); + } + + private createManageButton(toolbar: HTMLElement): void { + const openBtn = document.createElement('button'); + openBtn.type = 'button'; + openBtn.className = 'live-news-settings-btn'; + openBtn.title = t('components.liveNews.channelSettings') ?? 'Channel Settings'; + openBtn.innerHTML = + ''; + openBtn.addEventListener('click', () => { + this.openChannelManagementModal(); + }); + toolbar.appendChild(openBtn); + } + + private openChannelManagementModal(): void { + const existing = document.querySelector('.live-channels-modal-overlay'); + if (existing) return; + + const overlay = document.createElement('div'); + overlay.className = 'live-channels-modal-overlay'; + overlay.setAttribute('aria-modal', 'true'); + + const modal = document.createElement('div'); + modal.className = 'live-channels-modal'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'live-channels-modal-close'; + closeBtn.setAttribute('aria-label', t('common.close') ?? 'Close'); + closeBtn.innerHTML = '×'; + + const container = document.createElement('div'); + + modal.appendChild(closeBtn); + modal.appendChild(container); + overlay.appendChild(modal); + document.body.appendChild(overlay); + + requestAnimationFrame(() => overlay.classList.add('active')); + + import('@/live-channels-window').then(({ initLiveChannelsWindow }) => { + initLiveChannelsWindow(container); + }); + + const close = () => { + overlay.remove(); + this.refreshChannelsFromStorage(); + }; + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + } + + private refreshChannelSwitcher(): void { + if (!this.channelSwitcher) return; + this.channelSwitcher.innerHTML = ''; + for (const channel of this.channels) { + this.channelSwitcher.appendChild(this.createChannelButton(channel)); + } + } + + private applyChannelOrderFromDom(): void { + if (!this.channelSwitcher) return; + const ids = Array.from(this.channelSwitcher.querySelectorAll('.live-channel-btn')) + .map((el) => el.dataset.channelId) + .filter((id): id is string => !!id); + const orderMap = new Map(this.channels.map((c) => [c.id, c])); + this.channels = ids.map((id) => orderMap.get(id)).filter((c): c is LiveChannel => !!c); + this.saveChannels(); + } + + private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise { + const useFallbackVideo = channel.useFallbackOnly || forceFallback; + const liveVideoId = useFallbackVideo ? null : await fetchLiveVideoId(channel.handle); + channel.videoId = liveVideoId || channel.fallbackVideoId; + channel.isLive = !!liveVideoId; + } + + private async switchChannel(channel: LiveChannel): Promise { + if (channel.id === this.activeChannel.id) return; + + this.activeChannel = channel; + + this.channelSwitcher?.querySelectorAll('.live-channel-btn').forEach(btn => { + const btnEl = btn as HTMLElement; + btnEl.classList.toggle('active', btnEl.dataset.channelId === channel.id); + if (btnEl.dataset.channelId === channel.id) { + btnEl.classList.add('loading'); + } + }); + + await this.resolveChannelVideo(channel); + + this.channelSwitcher?.querySelectorAll('.live-channel-btn').forEach(btn => { + const btnEl = btn as HTMLElement; + btnEl.classList.remove('loading'); + if (btnEl.dataset.channelId === channel.id && !channel.videoId) { + btnEl.classList.add('offline'); + } + }); + + if (!channel.videoId || !/^[\w-]{10,12}$/.test(channel.videoId)) { + this.showOfflineMessage(channel); + return; + } + + if (this.useDesktopEmbedProxy) { + this.renderDesktopEmbed(true); + return; + } + + if (!this.player) { + this.ensurePlayerContainer(); + void this.initializePlayer(); + return; + } + + this.syncPlayerState(); + } + + private showOfflineMessage(channel: LiveChannel): void { + this.destroyPlayer(); + this.content.innerHTML = ` +
+
📺
+
${t('components.liveNews.notLive', { name: channel.name })}
+ +
+ `; + } + + private showEmbedError(channel: LiveChannel, errorCode: number): void { + this.destroyPlayer(); + const watchUrl = channel.videoId + ? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}` + : `https://www.youtube.com/${channel.handle}`; + + this.content.innerHTML = ` +
+
!
+
${t('components.liveNews.cannotEmbed', { name: channel.name, code: String(errorCode) })}
+ ${t('components.liveNews.openOnYouTube')} +
+ `; + } + + private renderPlayer(): void { + this.ensurePlayerContainer(); + void this.initializePlayer(); + } + + private ensurePlayerContainer(): void { + this.deferredInit = true; + this.content.innerHTML = ''; + this.playerContainer = document.createElement('div'); + this.playerContainer.className = 'live-news-player'; + + if (!this.useDesktopEmbedProxy) { + this.playerElement = document.createElement('div'); + this.playerElement.id = this.playerElementId; + this.playerContainer.appendChild(this.playerElement); + } else { + this.playerElement = null; + } + + this.content.appendChild(this.playerContainer); + } + + private get parentPostMessageOrigin(): string | null { + try { + const { origin, protocol } = window.location; + if (origin && origin !== 'null') return origin; + if (protocol === 'tauri:' || protocol === 'asset:') return '*'; + } catch { + // Ignore invalid location values. + } + return null; + } + + private buildDesktopEmbedPath(videoId: string): string { + const params = new URLSearchParams({ + videoId, + autoplay: this.isPlaying ? '1' : '0', + mute: this.isMuted ? '1' : '0', + }); + if (this.youtubeOrigin) params.set('origin', this.youtubeOrigin); + const parentOrigin = this.parentPostMessageOrigin; + if (parentOrigin) params.set('parentOrigin', parentOrigin); + const quality = getStreamQuality(); + if (quality !== 'auto') params.set('vq', quality); + return `/api/youtube/embed?${params.toString()}`; + } + + + + private postToEmbed(msg: Record): void { + if (!this.desktopEmbedIframe?.contentWindow) return; + this.desktopEmbedIframe.contentWindow.postMessage(msg, this.embedOrigin); + } + + private syncDesktopEmbedState(): void { + this.postToEmbed({ type: this.isPlaying ? 'play' : 'pause' }); + this.postToEmbed({ type: this.isMuted ? 'mute' : 'unmute' }); + } + + private renderDesktopEmbed(force = false): void { + if (!this.useDesktopEmbedProxy) return; + void this.renderDesktopEmbedAsync(force); + } + + private async renderDesktopEmbedAsync(force = false): Promise { + const videoId = this.activeChannel.videoId; + if (!videoId) { + this.showOfflineMessage(this.activeChannel); + return; + } + + // Only recreate iframe when video ID changes (not for play/mute toggling). + if (!force && this.currentVideoId === videoId && this.desktopEmbedIframe) { + this.syncDesktopEmbedState(); + return; + } + + const renderToken = ++this.desktopEmbedRenderToken; + this.currentVideoId = videoId; + this.isPlayerReady = true; + + // Always recreate if container was removed from DOM (e.g. showEmbedError replaced content). + if (!this.playerContainer || !this.playerContainer.parentElement) { + this.ensurePlayerContainer(); + } + + if (!this.playerContainer) { + return; + } + + this.playerContainer.innerHTML = ''; + + // Always use cloud URL for iframe embeds — the local sidecar requires + // an Authorization header that iframe src requests cannot carry. + const remoteBase = getRemoteApiBaseUrl(); + const embedUrl = `${remoteBase}${this.buildDesktopEmbedPath(videoId)}`; + + if (renderToken !== this.desktopEmbedRenderToken) { + return; + } + + const iframe = document.createElement('iframe'); + iframe.className = 'live-news-embed-frame'; + iframe.src = embedUrl; + iframe.title = `${this.activeChannel.name} live feed`; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = '0'; + iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen'; + iframe.allowFullscreen = true; + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + iframe.setAttribute('loading', 'eager'); + + this.playerContainer.appendChild(iframe); + this.desktopEmbedIframe = iframe; + this.startBotCheckTimeout(); + } + + private static loadYouTubeApi(): Promise { + if (LiveNewsPanel.apiPromise) return LiveNewsPanel.apiPromise; + + LiveNewsPanel.apiPromise = new Promise((resolve) => { + if (window.YT?.Player) { + resolve(); + return; + } + + const existingScript = document.querySelector( + 'script[data-youtube-iframe-api="true"]', + ); + + if (existingScript) { + if (window.YT?.Player) { + resolve(); + return; + } + const previousReady = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + previousReady?.(); + resolve(); + }; + return; + } + + const previousReady = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + previousReady?.(); + resolve(); + }; + + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.async = true; + script.dataset.youtubeIframeApi = 'true'; + script.onerror = () => { + console.warn('[LiveNews] YouTube IFrame API failed to load (ad blocker or network issue)'); + LiveNewsPanel.apiPromise = null; + script.remove(); + resolve(); + }; + document.head.appendChild(script); + }); + + return LiveNewsPanel.apiPromise; + } + + private async initializePlayer(): Promise { + if (!this.useDesktopEmbedProxy && this.player) return; + + const useFallbackVideo = this.activeChannel.useFallbackOnly || this.forceFallbackVideoForNextInit; + this.forceFallbackVideoForNextInit = false; + await this.resolveChannelVideo(this.activeChannel, useFallbackVideo); + + if (!this.activeChannel.videoId || !/^[\w-]{10,12}$/.test(this.activeChannel.videoId)) { + this.showOfflineMessage(this.activeChannel); + return; + } + + if (this.useDesktopEmbedProxy) { + this.renderDesktopEmbed(true); + return; + } + + await LiveNewsPanel.loadYouTubeApi(); + if (this.player || !this.playerElement || !window.YT?.Player) return; + + this.player = new window.YT!.Player(this.playerElement, { + host: 'https://www.youtube.com', + videoId: this.activeChannel.videoId, + playerVars: { + autoplay: this.isPlaying ? 1 : 0, + mute: this.isMuted ? 1 : 0, + rel: 0, + playsinline: 1, + enablejsapi: 1, + ...(this.youtubeOrigin + ? { + origin: this.youtubeOrigin, + widget_referrer: this.youtubeOrigin, + } + : {}), + }, + events: { + onReady: () => { + this.clearBotCheckTimeout(); + this.isPlayerReady = true; + this.currentVideoId = this.activeChannel.videoId || null; + const iframe = this.player?.getIframe?.(); + if (iframe) iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + const quality = getStreamQuality(); + if (quality !== 'auto') this.player?.setPlaybackQuality?.(quality); + this.syncPlayerState(); + this.startMuteSyncPolling(); + }, + onError: (event) => { + this.clearBotCheckTimeout(); + const errorCode = Number(event?.data ?? 0); + + // Retry once with known fallback stream. + if ( + errorCode === 153 && + this.activeChannel.fallbackVideoId && + this.activeChannel.videoId !== this.activeChannel.fallbackVideoId + ) { + this.destroyPlayer(); + this.forceFallbackVideoForNextInit = true; + this.ensurePlayerContainer(); + void this.initializePlayer(); + return; + } + + // Desktop-specific last resort: switch to cloud bridge embed. + if (errorCode === 153 && isDesktopRuntime()) { + this.useDesktopEmbedProxy = true; + this.destroyPlayer(); + this.ensurePlayerContainer(); + this.renderDesktopEmbed(true); + return; + } + + this.destroyPlayer(); + this.showEmbedError(this.activeChannel, errorCode); + }, + }, + }); + + this.startBotCheckTimeout(); + } + + private startBotCheckTimeout(): void { + this.clearBotCheckTimeout(); + this.botCheckTimeout = setTimeout(() => { + this.botCheckTimeout = null; + if (!this.isPlayerReady) { + this.showBotCheckPrompt(); + } + }, LiveNewsPanel.BOT_CHECK_TIMEOUT_MS); + } + + private clearBotCheckTimeout(): void { + if (this.botCheckTimeout) { + clearTimeout(this.botCheckTimeout); + this.botCheckTimeout = null; + } + } + + private showBotCheckPrompt(): void { + const channel = this.activeChannel; + const watchUrl = channel.videoId + ? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}` + : `https://www.youtube.com/${encodeURIComponent(channel.handle)}`; + + this.destroyPlayer(); + this.content.innerHTML = ''; + + const wrapper = document.createElement('div'); + wrapper.className = 'live-offline'; + + const icon = document.createElement('div'); + icon.className = 'offline-icon'; + icon.textContent = '\u26A0\uFE0F'; + + const text = document.createElement('div'); + text.className = 'offline-text'; + text.textContent = t('components.liveNews.botCheck', { name: channel.name }) || 'YouTube is requesting sign-in verification'; + + const actions = document.createElement('div'); + actions.className = 'bot-check-actions'; + + const signinBtn = document.createElement('button'); + signinBtn.className = 'offline-retry bot-check-signin'; + signinBtn.textContent = t('components.liveNews.signInToYouTube') || 'Sign in to YouTube'; + signinBtn.addEventListener('click', () => this.openYouTubeSignIn()); + + const retryBtn = document.createElement('button'); + retryBtn.className = 'offline-retry bot-check-retry'; + retryBtn.textContent = t('common.retry') || 'Retry'; + retryBtn.addEventListener('click', () => { + this.ensurePlayerContainer(); + if (this.useDesktopEmbedProxy) { + this.renderDesktopEmbed(true); + } else { + void this.initializePlayer(); + } + }); + + const ytLink = document.createElement('a'); + ytLink.className = 'offline-retry'; + ytLink.href = watchUrl; + ytLink.target = '_blank'; + ytLink.rel = 'noopener noreferrer'; + ytLink.textContent = t('components.liveNews.openOnYouTube') || 'Open on YouTube'; + + actions.append(signinBtn, retryBtn, ytLink); + wrapper.append(icon, text, actions); + this.content.appendChild(wrapper); + } + + private async openYouTubeSignIn(): Promise { + const youtubeLoginUrl = 'https://accounts.google.com/ServiceLogin?service=youtube&continue=https://www.youtube.com/'; + if (isDesktopRuntime()) { + try { + const { tryInvokeTauri } = await import('@/services/tauri-bridge'); + await tryInvokeTauri('open_youtube_login'); + } catch { + window.open(youtubeLoginUrl, '_blank'); + } + } else { + window.open(youtubeLoginUrl, '_blank'); + } + } + + private syncPlayerState(): void { + if (this.useDesktopEmbedProxy) { + const videoId = this.activeChannel.videoId; + if (videoId && this.currentVideoId !== videoId) { + this.renderDesktopEmbed(true); + } else { + this.syncDesktopEmbedState(); + } + return; + } + + if (!this.player || !this.isPlayerReady) return; + + const videoId = this.activeChannel.videoId; + if (!videoId) return; + + // Handle channel switch + const isNewVideo = this.currentVideoId !== videoId; + if (isNewVideo) { + this.currentVideoId = videoId; + if (!this.playerElement || !document.getElementById(this.playerElementId)) { + this.ensurePlayerContainer(); + void this.initializePlayer(); + return; + } + if (this.isPlaying) { + this.player.loadVideoById(videoId); + } else { + this.player.cueVideoById(videoId); + } + } + + if (this.isMuted) { + this.player.mute?.(); + } else { + this.player.unMute?.(); + } + + if (this.isPlaying) { + if (isNewVideo) { + // WKWebView loses user gesture context after await. + // Pause then play after a delay — mimics the manual workaround. + this.player.pauseVideo(); + setTimeout(() => { + if (this.player && this.isPlaying) { + this.player.mute?.(); + this.player.playVideo?.(); + // Restore mute state after play starts + if (!this.isMuted) { + setTimeout(() => { this.player?.unMute?.(); }, 500); + } + } + }, 800); + } else { + this.player.playVideo?.(); + } + } else { + this.player.pauseVideo?.(); + } + } + + public refresh(): void { + this.syncPlayerState(); + } + + /** Reload channel list from storage (e.g. after edit in separate channel management window). */ + public refreshChannelsFromStorage(): void { + this.channels = loadChannelsFromStorage(); + if (this.channels.length === 0) this.channels = getDefaultLiveChannels(); + if (!this.channels.some((c) => c.id === this.activeChannel.id)) { + this.activeChannel = this.channels[0]!; + void this.switchChannel(this.activeChannel); + } + this.refreshChannelSwitcher(); + } + + public destroy(): void { + this.destroyPlayer(); + + if (this.lazyObserver) { this.lazyObserver.disconnect(); this.lazyObserver = null; } + if (this.idleCallbackId !== null) { + if ('cancelIdleCallback' in window) (window as any).cancelIdleCallback(this.idleCallbackId); + else clearTimeout(this.idleCallbackId as ReturnType); + this.idleCallbackId = null; + } + + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + window.removeEventListener('message', this.boundMessageHandler); + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.removeEventListener(event, this.boundIdleResetHandler); + }); + + this.playerContainer = null; + + super.destroy(); + } +} diff --git a/src/components/LiveWebcamsPanel.ts b/src/components/LiveWebcamsPanel.ts new file mode 100644 index 000000000..3df1264d3 --- /dev/null +++ b/src/components/LiveWebcamsPanel.ts @@ -0,0 +1,350 @@ +import { Panel } from './Panel'; +import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '../services/i18n'; +import { trackWebcamSelected, trackWebcamRegionFiltered } from '@/services/analytics'; +import { getStreamQuality, subscribeStreamQualityChange } from '@/services/ai-flow-settings'; + +type WebcamRegion = 'middle-east' | 'europe' | 'asia' | 'americas'; + +interface WebcamFeed { + id: string; + city: string; + country: string; + region: WebcamRegion; + channelHandle: string; + fallbackVideoId: string; +} + +// Verified YouTube live stream IDs — validated Feb 2026 via title cross-check. +// IDs may rotate; update when stale. +const WEBCAM_FEEDS: WebcamFeed[] = [ + // Middle East — Jerusalem & Tehran adjacent (conflict hotspots) + { id: 'jerusalem', city: 'Jerusalem', country: 'Israel', region: 'middle-east', channelHandle: '@TheWesternWall', fallbackVideoId: 'UyduhBUpO7Q' }, + { id: 'tehran', city: 'Tehran', country: 'Iran', region: 'middle-east', channelHandle: '@IranHDCams', fallbackVideoId: '-zGuR1qVKrU' }, + { id: 'tel-aviv', city: 'Tel Aviv', country: 'Israel', region: 'middle-east', channelHandle: '@IsraelLiveCam', fallbackVideoId: '-VLcYT5QBrY' }, + { id: 'mecca', city: 'Mecca', country: 'Saudi Arabia', region: 'middle-east', channelHandle: '@MakkahLive', fallbackVideoId: 'DEcpmPUbkDQ' }, + // Europe + { id: 'kyiv', city: 'Kyiv', country: 'Ukraine', region: 'europe', channelHandle: '@DWNews', fallbackVideoId: '-Q7FuPINDjA' }, + { id: 'odessa', city: 'Odessa', country: 'Ukraine', region: 'europe', channelHandle: '@UkraineLiveCam', fallbackVideoId: 'e2gC37ILQmk' }, + { id: 'paris', city: 'Paris', country: 'France', region: 'europe', channelHandle: '@PalaisIena', fallbackVideoId: 'OzYp4NRZlwQ' }, + { id: 'st-petersburg', city: 'St. Petersburg', country: 'Russia', region: 'europe', channelHandle: '@SPBLiveCam', fallbackVideoId: 'CjtIYbmVfck' }, + { id: 'london', city: 'London', country: 'UK', region: 'europe', channelHandle: '@EarthCam', fallbackVideoId: 'Lxqcg1qt0XU' }, + // Americas + { id: 'washington', city: 'Washington DC', country: 'USA', region: 'americas', channelHandle: '@AxisCommunications', fallbackVideoId: '1wV9lLe14aU' }, + { id: 'new-york', city: 'New York', country: 'USA', region: 'americas', channelHandle: '@EarthCam', fallbackVideoId: '4qyZLflp-sI' }, + { id: 'los-angeles', city: 'Los Angeles', country: 'USA', region: 'americas', channelHandle: '@VeniceVHotel', fallbackVideoId: 'EO_1LWqsCNE' }, + { id: 'miami', city: 'Miami', country: 'USA', region: 'americas', channelHandle: '@FloridaLiveCams', fallbackVideoId: '5YCajRjvWCg' }, + // Asia-Pacific — Taipei first (strait hotspot), then Shanghai, Tokyo, Seoul + { id: 'taipei', city: 'Taipei', country: 'Taiwan', region: 'asia', channelHandle: '@JackyWuTaipei', fallbackVideoId: 'z_fY1pj1VBw' }, + { id: 'shanghai', city: 'Shanghai', country: 'China', region: 'asia', channelHandle: '@SkylineWebcams', fallbackVideoId: '76EwqI5XZIc' }, + { id: 'tokyo', city: 'Tokyo', country: 'Japan', region: 'asia', channelHandle: '@TokyoLiveCam4K', fallbackVideoId: '4pu9sF5Qssw' }, + { id: 'seoul', city: 'Seoul', country: 'South Korea', region: 'asia', channelHandle: '@UNvillage_live', fallbackVideoId: '-JhoMGoAfFc' }, + { id: 'sydney', city: 'Sydney', country: 'Australia', region: 'asia', channelHandle: '@WebcamSydney', fallbackVideoId: '7pcL-0Wo77U' }, +]; + +const MAX_GRID_CELLS = 4; + +type ViewMode = 'grid' | 'single'; +type RegionFilter = 'all' | WebcamRegion; + +export class LiveWebcamsPanel extends Panel { + private viewMode: ViewMode = 'grid'; + private regionFilter: RegionFilter = 'all'; + private activeFeed: WebcamFeed = WEBCAM_FEEDS[0]!; + private toolbar: HTMLElement | null = null; + private iframes: HTMLIFrameElement[] = []; + private observer: IntersectionObserver | null = null; + private isVisible = false; + private idleTimeout: ReturnType | null = null; + private boundIdleResetHandler!: () => void; + private boundVisibilityHandler!: () => void; + private readonly IDLE_PAUSE_MS = 5 * 60 * 1000; + private isIdle = false; + + constructor() { + super({ id: 'live-webcams', title: t('panels.liveWebcams') }); + this.element.classList.add('panel-wide'); + this.createToolbar(); + this.setupIntersectionObserver(); + this.setupIdleDetection(); + subscribeStreamQualityChange(() => this.render()); + this.render(); + } + + private get filteredFeeds(): WebcamFeed[] { + if (this.regionFilter === 'all') return WEBCAM_FEEDS; + return WEBCAM_FEEDS.filter(f => f.region === this.regionFilter); + } + + private static readonly ALL_GRID_IDS = ['jerusalem', 'tehran', 'kyiv', 'washington']; + + private get gridFeeds(): WebcamFeed[] { + if (this.regionFilter === 'all') { + return LiveWebcamsPanel.ALL_GRID_IDS + .map(id => WEBCAM_FEEDS.find(f => f.id === id)!) + .filter(Boolean); + } + return this.filteredFeeds.slice(0, MAX_GRID_CELLS); + } + + private createToolbar(): void { + this.toolbar = document.createElement('div'); + this.toolbar.className = 'webcam-toolbar'; + + const regionGroup = document.createElement('div'); + regionGroup.className = 'webcam-toolbar-group'; + + const regions: { key: RegionFilter; label: string }[] = [ + { key: 'all', label: t('components.webcams.regions.all') }, + { key: 'middle-east', label: t('components.webcams.regions.mideast') }, + { key: 'europe', label: t('components.webcams.regions.europe') }, + { key: 'americas', label: t('components.webcams.regions.americas') }, + { key: 'asia', label: t('components.webcams.regions.asia') }, + ]; + + regions.forEach(({ key, label }) => { + const btn = document.createElement('button'); + btn.className = `webcam-region-btn${key === this.regionFilter ? ' active' : ''}`; + btn.dataset.region = key; + btn.textContent = label; + btn.addEventListener('click', () => this.setRegionFilter(key)); + regionGroup.appendChild(btn); + }); + + const viewGroup = document.createElement('div'); + viewGroup.className = 'webcam-toolbar-group'; + + const gridBtn = document.createElement('button'); + gridBtn.className = `webcam-view-btn${this.viewMode === 'grid' ? ' active' : ''}`; + gridBtn.dataset.mode = 'grid'; + gridBtn.innerHTML = ''; + gridBtn.title = 'Grid view'; + gridBtn.addEventListener('click', () => this.setViewMode('grid')); + + const singleBtn = document.createElement('button'); + singleBtn.className = `webcam-view-btn${this.viewMode === 'single' ? ' active' : ''}`; + singleBtn.dataset.mode = 'single'; + singleBtn.innerHTML = ''; + singleBtn.title = 'Single view'; + singleBtn.addEventListener('click', () => this.setViewMode('single')); + + viewGroup.appendChild(gridBtn); + viewGroup.appendChild(singleBtn); + + this.toolbar.appendChild(regionGroup); + this.toolbar.appendChild(viewGroup); + this.element.insertBefore(this.toolbar, this.content); + } + + private setRegionFilter(filter: RegionFilter): void { + if (filter === this.regionFilter) return; + trackWebcamRegionFiltered(filter); + this.regionFilter = filter; + this.toolbar?.querySelectorAll('.webcam-region-btn').forEach(btn => { + (btn as HTMLElement).classList.toggle('active', (btn as HTMLElement).dataset.region === filter); + }); + const feeds = this.filteredFeeds; + if (feeds.length > 0 && !feeds.includes(this.activeFeed)) { + this.activeFeed = feeds[0]!; + } + this.render(); + } + + private setViewMode(mode: ViewMode): void { + if (mode === this.viewMode) return; + this.viewMode = mode; + this.toolbar?.querySelectorAll('.webcam-view-btn').forEach(btn => { + (btn as HTMLElement).classList.toggle('active', (btn as HTMLElement).dataset.mode === mode); + }); + this.render(); + } + + private buildEmbedUrl(videoId: string): string { + const quality = getStreamQuality(); + if (isDesktopRuntime()) { + const remoteBase = getRemoteApiBaseUrl(); + const params = new URLSearchParams({ + videoId, + autoplay: '1', + mute: '1', + }); + if (quality !== 'auto') params.set('vq', quality); + return `${remoteBase}/api/youtube/embed?${params.toString()}`; + } + const vq = quality !== 'auto' ? `&vq=${quality}` : ''; + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&modestbranding=1&playsinline=1&rel=0${vq}`; + } + + private createIframe(feed: WebcamFeed): HTMLIFrameElement { + const iframe = document.createElement('iframe'); + iframe.className = 'webcam-iframe'; + iframe.src = this.buildEmbedUrl(feed.fallbackVideoId); + iframe.title = `${feed.city} live webcam`; + iframe.allow = 'autoplay; encrypted-media; picture-in-picture'; + iframe.allowFullscreen = true; + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + iframe.setAttribute('loading', 'lazy'); + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + return iframe; + } + + private render(): void { + this.destroyIframes(); + + if (!this.isVisible || this.isIdle) { + this.content.innerHTML = '
Webcams paused
'; + return; + } + + if (this.viewMode === 'grid') { + this.renderGrid(); + } else { + this.renderSingle(); + } + } + + private renderGrid(): void { + this.content.innerHTML = ''; + this.content.className = 'panel-content webcam-content'; + + const grid = document.createElement('div'); + grid.className = 'webcam-grid'; + + this.gridFeeds.forEach(feed => { + const cell = document.createElement('div'); + cell.className = 'webcam-cell'; + cell.addEventListener('click', () => { + trackWebcamSelected(feed.id, feed.city, 'grid'); + this.activeFeed = feed; + this.setViewMode('single'); + }); + + const label = document.createElement('div'); + label.className = 'webcam-cell-label'; + label.innerHTML = `${escapeHtml(feed.city.toUpperCase())}`; + + const iframe = this.createIframe(feed); + cell.appendChild(iframe); + cell.appendChild(label); + grid.appendChild(cell); + this.iframes.push(iframe); + }); + + this.content.appendChild(grid); + } + + private renderSingle(): void { + this.content.innerHTML = ''; + this.content.className = 'panel-content webcam-content'; + + const wrapper = document.createElement('div'); + wrapper.className = 'webcam-single'; + + const iframe = this.createIframe(this.activeFeed); + wrapper.appendChild(iframe); + this.iframes.push(iframe); + + const switcher = document.createElement('div'); + switcher.className = 'webcam-switcher'; + + const backBtn = document.createElement('button'); + backBtn.className = 'webcam-feed-btn webcam-back-btn'; + backBtn.innerHTML = ' Grid'; + backBtn.addEventListener('click', () => this.setViewMode('grid')); + switcher.appendChild(backBtn); + + this.filteredFeeds.forEach(feed => { + const btn = document.createElement('button'); + btn.className = `webcam-feed-btn${feed.id === this.activeFeed.id ? ' active' : ''}`; + btn.textContent = feed.city; + btn.addEventListener('click', () => { + trackWebcamSelected(feed.id, feed.city, 'single'); + this.activeFeed = feed; + this.render(); + }); + switcher.appendChild(btn); + }); + + this.content.appendChild(wrapper); + this.content.appendChild(switcher); + } + + private destroyIframes(): void { + this.iframes.forEach(iframe => { + iframe.src = 'about:blank'; + iframe.remove(); + }); + this.iframes = []; + } + + private setupIntersectionObserver(): void { + this.observer = new IntersectionObserver( + (entries) => { + const wasVisible = this.isVisible; + this.isVisible = entries.some(e => e.isIntersecting); + if (this.isVisible && !wasVisible && !this.isIdle) { + this.render(); + } else if (!this.isVisible && wasVisible) { + this.destroyIframes(); + } + }, + { threshold: 0.1 } + ); + this.observer.observe(this.element); + } + + private setupIdleDetection(): void { + this.boundVisibilityHandler = () => { + if (document.hidden) { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + } else { + if (this.isIdle) { + this.isIdle = false; + if (this.isVisible) this.render(); + } + this.boundIdleResetHandler(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + + this.boundIdleResetHandler = () => { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + if (this.isIdle) { + this.isIdle = false; + if (this.isVisible) this.render(); + } + this.idleTimeout = setTimeout(() => { + this.isIdle = true; + this.destroyIframes(); + this.content.innerHTML = '
Webcams paused — move mouse to resume
'; + }, this.IDLE_PAUSE_MS); + }; + + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.addEventListener(event, this.boundIdleResetHandler, { passive: true }); + }); + + this.boundIdleResetHandler(); + } + + public refresh(): void { + if (this.isVisible && !this.isIdle) { + this.render(); + } + } + + public destroy(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.removeEventListener(event, this.boundIdleResetHandler); + }); + this.observer?.disconnect(); + this.destroyIframes(); + super.destroy(); + } +} diff --git a/src/components/MacroSignalsPanel.ts b/src/components/MacroSignalsPanel.ts new file mode 100644 index 000000000..0761087dd --- /dev/null +++ b/src/components/MacroSignalsPanel.ts @@ -0,0 +1,245 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client'; +import type { GetMacroSignalsResponse } from '@/generated/client/worldmonitor/economic/v1/service_client'; + +interface MacroSignalData { + timestamp: string; + verdict: string; + bullishCount: number; + totalCount: number; + signals: { + liquidity: { status: string; value: number | null; sparkline: number[] }; + flowStructure: { status: string; btcReturn5: number | null; qqqReturn5: number | null }; + macroRegime: { status: string; qqqRoc20: number | null; xlpRoc20: number | null }; + technicalTrend: { status: string; btcPrice: number | null; sma50: number | null; sma200: number | null; vwap30d: number | null; mayerMultiple: number | null; sparkline: number[] }; + hashRate: { status: string; change30d: number | null }; + miningCost: { status: string }; + fearGreed: { status: string; value: number | null; history: Array<{ value: number; date: string }> }; + }; + meta: { qqqSparkline: number[] }; + unavailable?: boolean; +} + +const economicClient = new EconomicServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); + +/** Map proto response (optional fields = undefined) to MacroSignalData (null for absent values). */ +function mapProtoToData(r: GetMacroSignalsResponse): MacroSignalData { + const s = r.signals; + return { + timestamp: r.timestamp, + verdict: r.verdict, + bullishCount: r.bullishCount, + totalCount: r.totalCount, + signals: { + liquidity: { + status: s?.liquidity?.status ?? 'UNKNOWN', + value: s?.liquidity?.value ?? null, + sparkline: s?.liquidity?.sparkline ?? [], + }, + flowStructure: { + status: s?.flowStructure?.status ?? 'UNKNOWN', + btcReturn5: s?.flowStructure?.btcReturn5 ?? null, + qqqReturn5: s?.flowStructure?.qqqReturn5 ?? null, + }, + macroRegime: { + status: s?.macroRegime?.status ?? 'UNKNOWN', + qqqRoc20: s?.macroRegime?.qqqRoc20 ?? null, + xlpRoc20: s?.macroRegime?.xlpRoc20 ?? null, + }, + technicalTrend: { + status: s?.technicalTrend?.status ?? 'UNKNOWN', + btcPrice: s?.technicalTrend?.btcPrice ?? null, + sma50: s?.technicalTrend?.sma50 ?? null, + sma200: s?.technicalTrend?.sma200 ?? null, + vwap30d: s?.technicalTrend?.vwap30d ?? null, + mayerMultiple: s?.technicalTrend?.mayerMultiple ?? null, + sparkline: s?.technicalTrend?.sparkline ?? [], + }, + hashRate: { + status: s?.hashRate?.status ?? 'UNKNOWN', + change30d: s?.hashRate?.change30d ?? null, + }, + miningCost: { + status: s?.miningCost?.status ?? 'UNKNOWN', + }, + fearGreed: { + status: s?.fearGreed?.status ?? 'UNKNOWN', + value: s?.fearGreed?.value ?? null, + history: s?.fearGreed?.history ?? [], + }, + }, + meta: { qqqSparkline: r.meta?.qqqSparkline ?? [] }, + unavailable: r.unavailable, + }; +} + +function sparklineSvg(data: number[], width = 80, height = 24, color = '#4fc3f7'): string { + if (!data || data.length < 2) return ''; + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + const points = data.map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((v - min) / range) * (height - 2) - 1; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ``; +} + +function donutGaugeSvg(value: number | null, size = 48): string { + if (value === null) return 'N/A'; + const v = Math.max(0, Math.min(100, value)); + const r = (size - 6) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference - (v / 100) * circumference; + let color = '#f44336'; + if (v >= 75) color = '#4caf50'; + else if (v >= 50) color = '#ff9800'; + else if (v >= 25) color = '#ff5722'; + return ` + + + ${v} + `; +} + +function statusBadgeClass(status: string): string { + const s = status.toUpperCase(); + if (['BULLISH', 'RISK-ON', 'GROWING', 'PROFITABLE', 'ALIGNED', 'NORMAL', 'EXTREME GREED', 'GREED'].includes(s)) return 'badge-bullish'; + if (['BEARISH', 'DEFENSIVE', 'DECLINING', 'SQUEEZE', 'PASSIVE GAP', 'EXTREME FEAR', 'FEAR'].includes(s)) return 'badge-bearish'; + return 'badge-neutral'; +} + +function formatNum(v: number | null, suffix = '%'): string { + if (v === null) return 'N/A'; + const sign = v > 0 ? '+' : ''; + return `${sign}${v.toFixed(1)}${suffix}`; +} + +export class MacroSignalsPanel extends Panel { + private data: MacroSignalData | null = null; + private loading = true; + private error: string | null = null; + + private refreshInterval: ReturnType | null = null; + + constructor() { + super({ id: 'macro-signals', title: t('panels.macroSignals'), showCount: false }); + void this.fetchData(); + this.refreshInterval = setInterval(() => this.fetchData(), 3 * 60000); + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + private async fetchData(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await economicClient.getMacroSignals({}); + this.data = mapProtoToData(res); + this.error = null; + + if (this.data && this.data.unavailable && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + break; + } catch (err) { + if (this.isAbortError(err)) return; + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + this.error = err instanceof Error ? err.message : 'Failed to fetch'; + } + } + this.loading = false; + this.renderPanel(); + } + + private renderPanel(): void { + if (this.loading) { + this.showLoading(t('common.computingSignals')); + return; + } + + if (this.error || !this.data) { + this.showError(this.error || t('common.noDataShort')); + return; + } + + if (this.data.unavailable) { + this.showError(t('common.upstreamUnavailable')); + return; + } + + const d = this.data; + const s = d.signals; + + const verdictClass = d.verdict === 'BUY' ? 'verdict-buy' : d.verdict === 'CASH' ? 'verdict-cash' : 'verdict-unknown'; + + const html = ` +
+
+ ${t('components.macroSignals.overall')} + ${d.verdict === 'BUY' ? t('components.macroSignals.verdict.buy') : d.verdict === 'CASH' ? t('components.macroSignals.verdict.cash') : escapeHtml(d.verdict)} + ${t('components.macroSignals.bullish', { count: String(d.bullishCount), total: String(d.totalCount) })} +
+
+ ${this.renderSignalCard(t('components.macroSignals.signals.liquidity'), s.liquidity.status, formatNum(s.liquidity.value), sparklineSvg(s.liquidity.sparkline, 60, 20, '#4fc3f7'), 'JPY 30d ROC', 'https://www.tradingview.com/symbols/JPYUSD/')} + ${this.renderSignalCard(t('components.macroSignals.signals.flow'), s.flowStructure.status, `BTC ${formatNum(s.flowStructure.btcReturn5)} / QQQ ${formatNum(s.flowStructure.qqqReturn5)}`, '', '5d returns', null)} + ${this.renderSignalCard(t('components.macroSignals.signals.regime'), s.macroRegime.status, `QQQ ${formatNum(s.macroRegime.qqqRoc20)} / XLP ${formatNum(s.macroRegime.xlpRoc20)}`, sparklineSvg(d.meta.qqqSparkline, 60, 20, '#ab47bc'), '20d ROC', 'https://www.tradingview.com/symbols/QQQ/')} + ${this.renderSignalCard(t('components.macroSignals.signals.btcTrend'), s.technicalTrend.status, `$${s.technicalTrend.btcPrice?.toLocaleString() ?? 'N/A'}`, sparklineSvg(s.technicalTrend.sparkline, 60, 20, '#ff9800'), `SMA50: $${s.technicalTrend.sma50?.toLocaleString() ?? '-'} | VWAP: $${s.technicalTrend.vwap30d?.toLocaleString() ?? '-'} | Mayer: ${s.technicalTrend.mayerMultiple ?? '-'}`, 'https://www.tradingview.com/symbols/BTCUSD/')} + ${this.renderSignalCard(t('components.macroSignals.signals.hashRate'), s.hashRate.status, formatNum(s.hashRate.change30d), '', '30d change', 'https://mempool.space/mining')} + ${this.renderSignalCard(t('components.macroSignals.signals.mining'), s.miningCost.status, '', '', 'Hashprice model', null)} + ${this.renderFearGreedCard(s.fearGreed)} +
+
+ `; + + this.setContent(html); + } + + private renderSignalCard(name: string, status: string, value: string, sparkline: string, detail: string, link: string | null): string { + const badgeClass = statusBadgeClass(status); + return ` + + `; + } + + private renderFearGreedCard(fg: MacroSignalData['signals']['fearGreed']): string { + const badgeClass = statusBadgeClass(fg.status); + return ` +
+
+ ${t('components.macroSignals.signals.fearGreed')} + ${escapeHtml(fg.status)} +
+
+ ${donutGaugeSvg(fg.value)} +
+ +
+ `; + } +} diff --git a/src/components/Map.ts b/src/components/Map.ts index 7b29fad6e..ca58838dc 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -1,7 +1,15 @@ import * as d3 from 'd3'; import * as topojson from 'topojson-client'; +import { escapeHtml } from '@/utils/sanitize'; +import { getCSSColor } from '@/utils'; import type { Topology, GeometryCollection } from 'topojson-specification'; -import type { MapLayers, Hotspot, NewsItem, Earthquake, InternetOutage, RelatedAsset, AssetType, AisDisruptionEvent, AisDensityZone, CableAdvisory, RepairShip, SocialUnrestEvent } from '@/types'; +import type { Feature, Geometry } from 'geojson'; +import type { MapLayers, Hotspot, NewsItem, InternetOutage, RelatedAsset, AssetType, AisDisruptionEvent, AisDensityZone, CableAdvisory, RepairShip, SocialUnrestEvent, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, NaturalEvent, CyberThreat, CableHealthRecord } from '@/types'; +import type { AirportDelayAlert } from '@/services/aviation'; +import type { Earthquake } from '@/services/earthquakes'; +import type { TechHubActivity } from '@/services/tech-activity'; +import type { GeoHubActivity } from '@/services/geo-activity'; +import { getNaturalEventIcon } from '@/services/eonet'; import type { WeatherAlert } from '@/services/weather'; import { getSeverityColor } from '@/services/weather'; import { @@ -17,14 +25,37 @@ import { SANCTIONED_COUNTRIES, STRATEGIC_WATERWAYS, APT_GROUPS, - COUNTRY_LABELS, ECONOMIC_CENTERS, AI_DATA_CENTERS, + PORTS, + SPACEPORTS, + CRITICAL_MINERALS, + SITE_VARIANT, + // Tech variant data + STARTUP_HUBS, + ACCELERATORS, + TECH_HQS, + CLOUD_REGIONS, + // Finance variant data + STOCK_EXCHANGES, + FINANCIAL_CENTERS, + CENTRAL_BANKS, + COMMODITY_HUBS, } from '@/config'; import { MapPopup } from './MapPopup'; +import { + updateHotspotEscalation, + getHotspotEscalation, + setMilitaryData, + setCIIGetter, + setGeoAlertGetter, +} from '@/services/hotspot-escalation'; +import { getCountryScore } from '@/services/country-instability'; +import { getAlertsNearLocation } from '@/services/geo-convergence'; +import { t } from '@/services/i18n'; export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; -export type MapView = 'global' | 'us' | 'mena'; +export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; interface MapState { zoom: number; @@ -38,15 +69,22 @@ interface HotspotWithBreaking extends Hotspot { hasBreaking?: boolean; } -interface WorldTopology extends Topology { - objects: { - countries: GeometryCollection; - }; +interface TechEventMarker { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; } -interface USTopology extends Topology { +interface WorldTopology extends Topology { objects: { - states: GeometryCollection; + countries: GeometryCollection; }; } @@ -55,19 +93,26 @@ export class MapComponent { Record > = { bases: { minZoom: 3, showLabels: 5 }, - nuclear: { minZoom: 2, showLabels: 4 }, + nuclear: { minZoom: 2 }, conflicts: { minZoom: 1, showLabels: 3 }, - economic: { minZoom: 2, showLabels: 4 }, - earthquakes: { minZoom: 1, showLabels: 2 }, + economic: { minZoom: 2 }, + natural: { minZoom: 1, showLabels: 2 }, }; private container: HTMLElement; private svg: d3.Selection; private wrapper: HTMLElement; private overlays: HTMLElement; + private clusterCanvas: HTMLCanvasElement; + private clusterGl: WebGLRenderingContext | null = null; private state: MapState; private worldData: WorldTopology | null = null; - private usData: USTopology | null = null; + private countryFeatures: Feature[] | null = null; + private baseLayerGroup: d3.Selection | null = null; + private dynamicLayerGroup: d3.Selection | null = null; + private baseRendered = false; + private baseWidth = 0; + private baseHeight = 0; private hotspots: HotspotWithBreaking[]; private earthquakes: Earthquake[] = []; private weatherAlerts: WeatherAlert[] = []; @@ -76,12 +121,25 @@ export class MapComponent { private aisDensity: AisDensityZone[] = []; private cableAdvisories: CableAdvisory[] = []; private repairShips: RepairShip[] = []; + private healthByCableId: Record = {}; private protests: SocialUnrestEvent[] = []; + private flightDelays: AirportDelayAlert[] = []; + private militaryFlights: MilitaryFlight[] = []; + private militaryFlightClusters: MilitaryFlightCluster[] = []; + private militaryVessels: MilitaryVessel[] = []; + private militaryVesselClusters: MilitaryVesselCluster[] = []; + private naturalEvents: NaturalEvent[] = []; + private firmsFireData: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }> = []; + private techEvents: TechEventMarker[] = []; + private techActivities: TechHubActivity[] = []; + private geoActivities: GeoHubActivity[] = []; private news: NewsItem[] = []; + private onTechHubClick?: (hub: TechHubActivity) => void; + private onGeoHubClick?: (hub: GeoHubActivity) => void; private popup: MapPopup; private onHotspotClick?: (hotspot: Hotspot) => void; private onTimeRangeChange?: (range: TimeRange) => void; - private onLayerChange?: (layer: keyof MapLayers, enabled: boolean) => void; + private onLayerChange?: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void; private layerZoomOverrides: Partial> = {}; private onStateChange?: (state: MapState) => void; private highlightedAssets: Record> = { @@ -91,6 +149,11 @@ export class MapComponent { base: new Set(), nuclear: new Set(), }; + private boundVisibilityHandler!: () => void; + private renderScheduled = false; + private lastRenderTime = 0; + private readonly MIN_RENDER_INTERVAL_MS = 100; + private healthCheckIntervalId: ReturnType | null = null; constructor(container: HTMLElement, initialState: MapState) { this.container = container; @@ -106,6 +169,11 @@ export class MapComponent { svgElement.id = 'mapSvg'; this.wrapper.appendChild(svgElement); + this.clusterCanvas = document.createElement('canvas'); + this.clusterCanvas.className = 'map-cluster-canvas'; + this.clusterCanvas.id = 'mapClusterCanvas'; + this.wrapper.appendChild(this.clusterCanvas); + // Overlays inside wrapper so they transform together on zoom/pan this.overlays = document.createElement('div'); this.overlays.id = 'mapOverlays'; @@ -116,13 +184,54 @@ export class MapComponent { container.appendChild(this.createTimeSlider()); container.appendChild(this.createLayerToggles()); container.appendChild(this.createLegend()); - container.appendChild(this.createTimestamp()); + this.healthCheckIntervalId = setInterval(() => this.runHealthCheck(), 30000); this.svg = d3.select(svgElement); + this.baseLayerGroup = this.svg.append('g').attr('class', 'map-base'); + this.dynamicLayerGroup = this.svg.append('g').attr('class', 'map-dynamic'); this.popup = new MapPopup(container); + this.initClusterRenderer(); this.setupZoomHandlers(); this.loadMapData(); + this.setupResizeObserver(); + + window.addEventListener('theme-changed', () => { + this.baseRendered = false; + this.render(); + }); + } + + private setupResizeObserver(): void { + let lastWidth = 0; + let lastHeight = 0; + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0 && (width !== lastWidth || height !== lastHeight)) { + lastWidth = width; + lastHeight = height; + requestAnimationFrame(() => this.render()); + } + } + }); + resizeObserver.observe(this.container); + + // Re-render when page becomes visible again (after browser throttling) + this.boundVisibilityHandler = () => { + if (!document.hidden) { + requestAnimationFrame(() => this.render()); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + } + + public destroy(): void { + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + if (this.healthCheckIntervalId) { + clearInterval(this.healthCheckIntervalId); + this.healthCheckIntervalId = null; + } } private createControls(): HTMLElement { @@ -212,38 +321,249 @@ export class MapComponent { return ranges[this.state.timeRange]; } - private filterByTime(items: T[]): T[] { - if (this.state.timeRange === 'all') return items; - const now = Date.now(); - const cutoff = now - this.getTimeRangeMs(); - return items.filter((item) => { - if (!item.time) return true; - return item.time.getTime() >= cutoff; - }); - } + private createLayerToggles(): HTMLElement { const toggles = document.createElement('div'); toggles.className = 'layer-toggles'; toggles.id = 'layerToggles'; - const layers: (keyof MapLayers)[] = ['conflicts', 'bases', 'cables', 'pipelines', 'hotspots', 'ais', 'earthquakes', 'weather', 'nuclear', 'irradiators', 'outages', 'datacenters', 'sanctions', 'economic', 'countries', 'waterways', 'protests']; - const layerLabels: Partial> = { - ais: 'AIS', + // Variant-aware layer buttons + const fullLayers: (keyof MapLayers)[] = [ + 'conflicts', 'hotspots', 'sanctions', 'protests', // geopolitical + 'bases', 'nuclear', 'irradiators', // military/strategic + 'military', // military tracking (flights + vessels) + 'cables', 'pipelines', 'outages', 'datacenters', // infrastructure + // cyberThreats is intentionally hidden on SVG/mobile fallback (DeckGL desktop only) + 'ais', 'flights', // transport + 'natural', 'weather', // natural + 'economic', // economic + 'waterways', // labels + ]; + const techLayers: (keyof MapLayers)[] = [ + 'cables', 'datacenters', 'outages', // tech infrastructure + 'startupHubs', 'cloudRegions', 'accelerators', 'techHQs', 'techEvents', // tech ecosystem + 'natural', 'weather', // natural events + 'economic', // economic/geographic + ]; + const financeLayers: (keyof MapLayers)[] = [ + 'stockExchanges', 'financialCenters', 'centralBanks', 'commodityHubs', // finance ecosystem + 'cables', 'pipelines', 'outages', // infrastructure + 'sanctions', 'economic', 'waterways', // geopolitical/economic + 'natural', 'weather', // natural events + ]; + const happyLayers: (keyof MapLayers)[] = [ + 'positiveEvents', 'kindness', 'happiness', 'speciesRecovery', 'renewableInstallations', + ]; + const layers = SITE_VARIANT === 'tech' ? techLayers : SITE_VARIANT === 'finance' ? financeLayers : SITE_VARIANT === 'happy' ? happyLayers : fullLayers; + const layerLabelKeys: Partial> = { + hotspots: 'components.deckgl.layers.intelHotspots', + conflicts: 'components.deckgl.layers.conflictZones', + bases: 'components.deckgl.layers.militaryBases', + nuclear: 'components.deckgl.layers.nuclearSites', + irradiators: 'components.deckgl.layers.gammaIrradiators', + military: 'components.deckgl.layers.militaryActivity', + cables: 'components.deckgl.layers.underseaCables', + pipelines: 'components.deckgl.layers.pipelines', + outages: 'components.deckgl.layers.internetOutages', + datacenters: 'components.deckgl.layers.aiDataCenters', + ais: 'components.deckgl.layers.shipTraffic', + flights: 'components.deckgl.layers.flightDelays', + natural: 'components.deckgl.layers.naturalEvents', + weather: 'components.deckgl.layers.weatherAlerts', + economic: 'components.deckgl.layers.economicCenters', + waterways: 'components.deckgl.layers.strategicWaterways', + startupHubs: 'components.deckgl.layers.startupHubs', + cloudRegions: 'components.deckgl.layers.cloudRegions', + accelerators: 'components.deckgl.layers.accelerators', + techHQs: 'components.deckgl.layers.techHQs', + techEvents: 'components.deckgl.layers.techEvents', + stockExchanges: 'components.deckgl.layers.stockExchanges', + financialCenters: 'components.deckgl.layers.financialCenters', + centralBanks: 'components.deckgl.layers.centralBanks', + commodityHubs: 'components.deckgl.layers.commodityHubs', + gulfInvestments: 'components.deckgl.layers.gulfInvestments', + }; + const getLayerLabel = (layer: keyof MapLayers): string => { + if (layer === 'sanctions') return t('components.deckgl.layerHelp.labels.sanctions'); + const key = layerLabelKeys[layer]; + return key ? t(key) : layer; }; layers.forEach((layer) => { const btn = document.createElement('button'); btn.className = `layer-toggle ${this.state.layers[layer] ? 'active' : ''}`; btn.dataset.layer = layer; - btn.textContent = layerLabels[layer] || layer; + btn.textContent = getLayerLabel(layer); btn.addEventListener('click', () => this.toggleLayer(layer)); toggles.appendChild(btn); }); + // Add help button + const helpBtn = document.createElement('button'); + helpBtn.className = 'layer-help-btn'; + helpBtn.textContent = '?'; + helpBtn.title = t('components.deckgl.layerGuide'); + helpBtn.addEventListener('click', () => this.showLayerHelp()); + toggles.appendChild(helpBtn); + return toggles; } + private showLayerHelp(): void { + const existing = this.container.querySelector('.layer-help-popup'); + if (existing) { + existing.remove(); + return; + } + + const popup = document.createElement('div'); + popup.className = 'layer-help-popup'; + + const label = (layerKey: string): string => t(`components.deckgl.layers.${layerKey}`).toUpperCase(); + const staticLabel = (labelKey: string): string => t(`components.deckgl.layerHelp.labels.${labelKey}`).toUpperCase(); + const helpItem = (layerLabel: string, descriptionKey: string): string => + `
${layerLabel} ${t(`components.deckgl.layerHelp.descriptions.${descriptionKey}`)}
`; + const helpSection = (titleKey: string, items: string[], noteKey?: string): string => ` +
+
${t(`components.deckgl.layerHelp.sections.${titleKey}`)}
+ ${items.join('')} + ${noteKey ? `
${t(`components.deckgl.layerHelp.notes.${noteKey}`)}
` : ''} +
+ `; + const helpHeader = ` +
+ ${t('components.deckgl.layerHelp.title')} + +
+ `; + + const techHelpContent = ` + ${helpHeader} +
+ ${helpSection('techEcosystem', [ + helpItem(label('startupHubs'), 'techStartupHubs'), + helpItem(label('cloudRegions'), 'techCloudRegions'), + helpItem(label('techHQs'), 'techHQs'), + helpItem(label('accelerators'), 'techAccelerators'), + helpItem(label('techEvents'), 'techEvents'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCables'), + helpItem(label('aiDataCenters'), 'infraDatacenters'), + helpItem(label('internetOutages'), 'infraOutages'), + helpItem(label('cyberThreats'), 'techCyberThreats'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsTech'), + helpItem(label('fires'), 'techFires'), + helpItem(staticLabel('countries'), 'countriesOverlay'), + ])} +
+ `; + + const financeHelpContent = ` + ${helpHeader} +
+ ${helpSection('financeCore', [ + helpItem(label('stockExchanges'), 'financeExchanges'), + helpItem(label('financialCenters'), 'financeCenters'), + helpItem(label('centralBanks'), 'financeCentralBanks'), + helpItem(label('commodityHubs'), 'financeCommodityHubs'), + helpItem(label('gulfInvestments'), 'financeGulfInvestments'), + ])} + ${helpSection('infrastructureRisk', [ + helpItem(label('underseaCables'), 'financeCables'), + helpItem(label('pipelines'), 'financePipelines'), + helpItem(label('internetOutages'), 'financeOutages'), + helpItem(label('cyberThreats'), 'financeCyberThreats'), + ])} + ${helpSection('macroContext', [ + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(label('strategicWaterways'), 'macroWaterways'), + helpItem(label('weatherAlerts'), 'weatherAlertsMarket'), + helpItem(label('naturalEvents'), 'naturalEventsMacro'), + ])} +
+ `; + + const fullHelpContent = ` + ${helpHeader} +
+ ${helpSection('timeFilter', [ + helpItem(staticLabel('timeRecent'), 'timeRecent'), + helpItem(staticLabel('timeExtended'), 'timeExtended'), + ], 'timeAffects')} + ${helpSection('geopolitical', [ + helpItem(label('conflictZones'), 'geoConflicts'), + helpItem(label('intelHotspots'), 'geoHotspots'), + helpItem(staticLabel('sanctions'), 'geoSanctions'), + helpItem(label('protests'), 'geoProtests'), + helpItem(label('ucdpEvents'), 'geoUcdpEvents'), + helpItem(label('displacementFlows'), 'geoDisplacement'), + ])} + ${helpSection('militaryStrategic', [ + helpItem(label('militaryBases'), 'militaryBases'), + helpItem(label('nuclearSites'), 'militaryNuclear'), + helpItem(label('gammaIrradiators'), 'militaryIrradiators'), + helpItem(label('militaryActivity'), 'militaryActivity'), + helpItem(label('spaceports'), 'militarySpaceports'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCablesFull'), + helpItem(label('pipelines'), 'infraPipelinesFull'), + helpItem(label('internetOutages'), 'infraOutages'), + helpItem(label('aiDataCenters'), 'infraDatacentersFull'), + helpItem(label('cyberThreats'), 'infraCyberThreats'), + ])} + ${helpSection('transport', [ + helpItem(label('shipTraffic'), 'transportShipping'), + helpItem(label('flightDelays'), 'transportDelays'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsFull'), + helpItem(label('fires'), 'firesFull'), + helpItem(label('weatherAlerts'), 'weatherAlerts'), + helpItem(label('climateAnomalies'), 'climateAnomalies'), + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(label('criticalMinerals'), 'mineralsFull'), + ])} + ${helpSection('labels', [ + helpItem(staticLabel('countries'), 'countriesOverlay'), + helpItem(label('strategicWaterways'), 'waterwaysLabels'), + ])} +
+ `; + + popup.innerHTML = SITE_VARIANT === 'tech' + ? techHelpContent + : SITE_VARIANT === 'finance' + ? financeHelpContent + : fullHelpContent; + + popup.querySelector('.layer-help-close')?.addEventListener('click', () => popup.remove()); + + // Prevent scroll events from propagating to map + const content = popup.querySelector('.layer-help-content'); + if (content) { + content.addEventListener('wheel', (e) => e.stopPropagation(), { passive: false }); + content.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: false }); + } + + // Close on click outside + setTimeout(() => { + const closeHandler = (e: MouseEvent) => { + if (!popup.contains(e.target as Node)) { + popup.remove(); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 100); + + this.container.appendChild(popup); + } + private syncLayerButtons(): void { this.container.querySelectorAll('.layer-toggle').forEach((btn) => { const layer = btn.dataset.layer as keyof MapLayers | undefined; @@ -255,29 +575,56 @@ export class MapComponent { private createLegend(): HTMLElement { const legend = document.createElement('div'); legend.className = 'map-legend'; - legend.innerHTML = ` -
HIGH ALERT
-
ELEVATED
-
MONITORING
-
CONFLICT
-
EARTHQUAKE
-
APT
- `; + + if (SITE_VARIANT === 'tech') { + // Tech variant legend + legend.innerHTML = ` +
${escapeHtml(t('components.deckgl.layers.techHQs').toUpperCase())}
+
${escapeHtml(t('components.deckgl.layers.startupHubs').toUpperCase())}
+
${escapeHtml(t('components.deckgl.layers.cloudRegions').toUpperCase())}
+
📅${escapeHtml(t('components.deckgl.layers.techEvents').toUpperCase())}
+
💾${escapeHtml(t('components.deckgl.layers.aiDataCenters').toUpperCase())}
+ `; + } else if (SITE_VARIANT === 'happy') { + // Happy variant legend — natural events only + legend.innerHTML = ` +
${escapeHtml(t('components.deckgl.layers.naturalEvents').toUpperCase())}
+ `; + } else { + // Geopolitical variant legend + legend.innerHTML = ` +
${escapeHtml((t('popups.hotspot.levels.high') ?? 'HIGH').toUpperCase())}
+
${escapeHtml((t('popups.hotspot.levels.elevated') ?? 'ELEVATED').toUpperCase())}
+
${escapeHtml((t('popups.monitoring') ?? 'MONITORING').toUpperCase())}
+
${escapeHtml(t('modals.search.types.conflict').toUpperCase())}
+
${escapeHtml(t('modals.search.types.earthquake').toUpperCase())}
+
APT
+ `; + } return legend; } - private createTimestamp(): HTMLElement { - const timestamp = document.createElement('div'); - timestamp.className = 'map-timestamp'; - timestamp.id = 'mapTimestamp'; - this.updateTimestamp(timestamp); - setInterval(() => this.updateTimestamp(timestamp), 60000); - return timestamp; - } + private runHealthCheck(): void { + // Skip if page is hidden (no need to check while user isn't looking) + if (document.hidden) return; + + const svgNode = this.svg.node(); + if (!svgNode) return; - private updateTimestamp(el: HTMLElement): void { - const now = new Date(); - el.innerHTML = `LAST UPDATE: ${now.toUTCString().replace('GMT', 'UTC')}`; + // Verify base layer exists and has content + const baseGroup = svgNode.querySelector('.map-base'); + const countryCount = baseGroup?.querySelectorAll('.country').length ?? 0; + + // If we have country data but no rendered countries, something is wrong + if (this.countryFeatures && this.countryFeatures.length > 0 && countryCount === 0) { + console.warn('[Map] Health check: Base layer missing countries, initiating recovery'); + this.baseRendered = false; + // Also check if d3 selection is stale + if (baseGroup && this.baseLayerGroup?.node() !== baseGroup) { + console.warn('[Map] Health check: Stale d3 selection detected'); + } + this.render(); + } } private setupZoomHandlers(): void { @@ -285,6 +632,14 @@ export class MapComponent { let lastPos = { x: 0, y: 0 }; let lastTouchDist = 0; let lastTouchCenter = { x: 0, y: 0 }; + const shouldIgnoreInteractionStart = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) return false; + return Boolean( + target.closest( + '.map-controls, .time-slider, .layer-toggles, .map-legend, .layer-help-popup, .map-popup, button, select, input, textarea, a' + ) + ); + }; // Wheel zoom with smooth delta this.container.addEventListener( @@ -317,6 +672,7 @@ export class MapComponent { // Mouse drag for panning this.container.addEventListener('mousedown', (e) => { + if (shouldIgnoreInteractionStart(e.target)) return; if (e.button === 0) { // Left click isDragging = true; lastPos = { x: e.clientX, y: e.clientY }; @@ -347,6 +703,7 @@ export class MapComponent { // Touch events for mobile and trackpad this.container.addEventListener('touchstart', (e) => { + if (shouldIgnoreInteractionStart(e.target)) return; const touch1 = e.touches[0]; const touch2 = e.touches[1]; @@ -417,78 +774,200 @@ export class MapComponent { private async loadMapData(): Promise { try { - const [worldResponse, usResponse] = await Promise.all([ - fetch(MAP_URLS.world), - fetch(MAP_URLS.us), - ]); - + const worldResponse = await fetch(MAP_URLS.world); this.worldData = await worldResponse.json(); - this.usData = await usResponse.json(); - + if (this.worldData) { + const countries = topojson.feature( + this.worldData, + this.worldData.objects.countries + ); + this.countryFeatures = 'features' in countries ? countries.features : [countries]; + } + this.baseRendered = false; this.render(); + // Re-render after layout stabilizes to catch full container width + requestAnimationFrame(() => requestAnimationFrame(() => this.render())); } catch (e) { console.error('Failed to load map data:', e); } } + private initClusterRenderer(): void { + // WebGL clustering disabled - just get context for clearing canvas + const gl = this.clusterCanvas.getContext('webgl'); + if (!gl) return; + this.clusterGl = gl; + } + + private clearClusterCanvas(): void { + if (!this.clusterGl) return; + this.clusterGl.clearColor(0, 0, 0, 0); + this.clusterGl.clear(this.clusterGl.COLOR_BUFFER_BIT); + } + + private renderClusterLayer(_projection: d3.GeoProjection): void { + // WebGL clustering disabled - all layers use HTML markers for visual fidelity + // (severity colors, emoji icons, magnitude sizing, animations) + this.wrapper.classList.toggle('cluster-active', false); + this.clearClusterCanvas(); + } + + public scheduleRender(): void { + if (this.renderScheduled) return; + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderScheduled = false; + this.render(); + }); + } + public render(): void { + const now = performance.now(); + if (now - this.lastRenderTime < this.MIN_RENDER_INTERVAL_MS) { + this.scheduleRender(); + return; + } + this.lastRenderTime = now; + const width = this.container.clientWidth; const height = this.container.clientHeight; + // Skip render if container has no dimensions (tab throttled, hidden, etc.) + if (width === 0 || height === 0) { + return; + } + // Simple viewBox matching container - keeps SVG and overlays aligned + if (!this.svg) return; this.svg.attr('viewBox', `0 0 ${width} ${height}`); - this.svg.selectAll('*').remove(); - // Background - this.svg - .append('rect') - .attr('width', width) - .attr('height', height) - .attr('fill', '#020a08'); + // CRITICAL: Always refresh d3 selections from actual DOM to prevent stale references + // D3 selections can become stale if the DOM is modified externally + const svgNode = this.svg.node(); + if (!svgNode) return; + + // Query DOM directly for layer groups + const existingBase = svgNode.querySelector('.map-base') as SVGGElement | null; + const existingDynamic = svgNode.querySelector('.map-dynamic') as SVGGElement | null; + + // Recreate layer groups if missing or if d3 selections are stale + const baseStale = !existingBase || this.baseLayerGroup?.node() !== existingBase; + const dynamicStale = !existingDynamic || this.dynamicLayerGroup?.node() !== existingDynamic; + + if (baseStale || dynamicStale) { + // Clear any orphaned groups and create fresh ones + svgNode.querySelectorAll('.map-base, .map-dynamic').forEach(el => el.remove()); + this.baseLayerGroup = this.svg.append('g').attr('class', 'map-base'); + this.dynamicLayerGroup = this.svg.append('g').attr('class', 'map-dynamic'); + this.baseRendered = false; + console.warn('[Map] Layer groups recreated - baseStale:', baseStale, 'dynamicStale:', dynamicStale); + } + + // Double-check selections are valid after recreation + if (!this.baseLayerGroup?.node() || !this.dynamicLayerGroup?.node()) { + console.error('[Map] Failed to create layer groups'); + return; + } - // Grid - this.renderGrid(width, height); + // Check if base layer has actual country content (not just empty group) + const countryCount = this.baseLayerGroup.node()!.querySelectorAll('.country').length; + const shouldRenderBase = !this.baseRendered || countryCount === 0 || width !== this.baseWidth || height !== this.baseHeight; - // Setup projection - const projection = this.getProjection(width, height); - const path = d3.geoPath().projection(projection); + // Debug: log when base layer needs re-render + if (shouldRenderBase && countryCount === 0 && this.baseRendered) { + console.warn('[Map] Base layer missing countries, forcing re-render. countryFeatures:', this.countryFeatures?.length ?? 'null'); + } + + if (shouldRenderBase) { + this.baseWidth = width; + this.baseHeight = height; + // Use native DOM clear for guaranteed effect + const baseNode = this.baseLayerGroup.node()!; + while (baseNode.firstChild) baseNode.removeChild(baseNode.firstChild); + + // Background - extend well beyond viewBox to cover pan/zoom transforms + // 3x size in each direction ensures no black bars when panning + this.baseLayerGroup + .append('rect') + .attr('x', -width) + .attr('y', -height) + .attr('width', width * 3) + .attr('height', height * 3) + .attr('fill', getCSSColor('--map-bg')); + + // Grid + this.renderGrid(this.baseLayerGroup, width, height); + + // Setup projection for base elements + const baseProjection = this.getProjection(width, height); + const basePath = d3.geoPath().projection(baseProjection); + + // Graticule + this.renderGraticule(this.baseLayerGroup, basePath); + + // Countries + this.renderCountries(this.baseLayerGroup, basePath); + this.baseRendered = true; + } + + // Always rebuild dynamic layer - use native DOM clear for reliability + const dynamicNode = this.dynamicLayerGroup.node()!; + while (dynamicNode.firstChild) dynamicNode.removeChild(dynamicNode.firstChild); + // Create overlays-svg group for SVG-based overlays (military tracks, etc.) + this.dynamicLayerGroup.append('g').attr('class', 'overlays-svg'); - // Graticule - this.renderGraticule(path); + // Setup projection for dynamic elements + const projection = this.getProjection(width, height); - // Countries - this.renderCountries(path); + // Update country fills (sanctions toggle without rebuilding geometry) + this.updateCountryFills(); - // Layers (show on global and mena views) - const showGlobalLayers = this.state.view === 'global' || this.state.view === 'mena'; - if (this.state.layers.cables && showGlobalLayers) { + // Render dynamic map layers + if (this.state.layers.cables) { this.renderCables(projection); } - if (this.state.layers.pipelines && showGlobalLayers) { + if (this.state.layers.pipelines) { this.renderPipelines(projection); } - if (this.state.layers.conflicts && showGlobalLayers) { + if (this.state.layers.conflicts) { this.renderConflicts(projection); } - if (this.state.layers.ais && showGlobalLayers) { + if (this.state.layers.ais) { this.renderAisDensity(projection); } - if (this.state.layers.sanctions && showGlobalLayers) { - this.renderSanctions(); - } + // GPU-accelerated cluster markers (LOD) + this.renderClusterLayer(projection); // Overlays this.renderOverlays(projection); + // POST-RENDER VERIFICATION: Ensure base layer actually rendered + // This catches silent failures where d3 operations didn't stick + if (this.baseRendered && this.countryFeatures && this.countryFeatures.length > 0) { + const verifyCount = this.baseLayerGroup?.node()?.querySelectorAll('.country').length ?? 0; + if (verifyCount === 0) { + console.error('[Map] POST-RENDER: Countries failed to render despite baseRendered=true. Forcing full rebuild.'); + this.baseRendered = false; + // Schedule a retry on next frame instead of immediate recursion + requestAnimationFrame(() => this.render()); + return; + } + } + this.applyTransform(); } - private renderGrid(width: number, height: number, yStart = 0): void { - const gridGroup = this.svg.append('g').attr('class', 'grid'); + private renderGrid( + group: d3.Selection, + width: number, + height: number, + yStart = 0 + ): void { + const gridGroup = group.append('g').attr('class', 'grid'); for (let x = 0; x < width; x += 20) { gridGroup @@ -497,7 +976,7 @@ export class MapComponent { .attr('y1', yStart) .attr('x2', x) .attr('y2', yStart + height) - .attr('stroke', '#0a2a20') + .attr('stroke', getCSSColor('--map-grid')) .attr('stroke-width', 0.5); } @@ -508,81 +987,67 @@ export class MapComponent { .attr('y1', y) .attr('x2', width) .attr('y2', y) - .attr('stroke', '#0a2a20') + .attr('stroke', getCSSColor('--map-grid')) .attr('stroke-width', 0.5); } } private getProjection(width: number, height: number): d3.GeoProjection { - if (this.state.view === 'global' || this.state.view === 'mena') { - // Scale by width to fill horizontally, center vertically - return d3 - .geoEquirectangular() - .scale(width / (2 * Math.PI)) - .center([0, 0]) - .translate([width / 2, height / 2]); - } + // Equirectangular with cropped latitude range (72°N to 56°S = 128°) + // Shows Greenland/Iceland while trimming extreme polar regions + const LAT_NORTH = 72; // Includes Greenland (extends to ~83°N but 72 shows most) + const LAT_SOUTH = -56; // Just below Tierra del Fuego + const LAT_RANGE = LAT_NORTH - LAT_SOUTH; // 128° + const LAT_CENTER = (LAT_NORTH + LAT_SOUTH) / 2; // 8°N + + // Scale to fit: 360° longitude in width, 128° latitude in height + const scaleForWidth = width / (2 * Math.PI); + const scaleForHeight = height / (LAT_RANGE * Math.PI / 180); + const scale = Math.min(scaleForWidth, scaleForHeight); return d3 - .geoAlbersUsa() - .scale(width * 1.3) + .geoEquirectangular() + .scale(scale) + .center([0, LAT_CENTER]) .translate([width / 2, height / 2]); } - private renderGraticule(path: d3.GeoPath): void { + private renderGraticule( + group: d3.Selection, + path: d3.GeoPath + ): void { const graticule = d3.geoGraticule(); - this.svg + group .append('path') .datum(graticule()) .attr('class', 'graticule') .attr('d', path) .attr('fill', 'none') - .attr('stroke', '#1a5045') + .attr('stroke', getCSSColor('--map-stroke')) .attr('stroke-width', 0.4); } - private renderCountries(path: d3.GeoPath): void { - if ((this.state.view === 'global' || this.state.view === 'mena') && this.worldData) { - const countries = topojson.feature( - this.worldData, - this.worldData.objects.countries - ); - - const features = 'features' in countries ? countries.features : [countries]; - - this.svg - .selectAll('.country') - .data(features) - .enter() - .append('path') - .attr('class', 'country') - .attr('d', path as unknown as string) - .attr('fill', '#0d3028') - .attr('stroke', '#1a8060') - .attr('stroke-width', 0.7); - } else if (this.state.view === 'us' && this.usData) { - const states = topojson.feature( - this.usData, - this.usData.objects.states - ); - - const features = 'features' in states ? states.features : [states]; + private renderCountries( + group: d3.Selection, + path: d3.GeoPath + ): void { + if (!this.countryFeatures) return; - this.svg - .selectAll('.state') - .data(features) - .enter() - .append('path') - .attr('class', 'state') - .attr('d', path as unknown as string) - .attr('fill', '#0d3028') - .attr('stroke', '#1a8060') - .attr('stroke-width', 0.7); - } + group + .selectAll('.country') + .data(this.countryFeatures) + .enter() + .append('path') + .attr('class', 'country') + .attr('d', path as unknown as string) + .attr('fill', getCSSColor('--map-country')) + .attr('stroke', getCSSColor('--map-stroke')) + .attr('stroke-width', 0.7); } private renderCables(projection: d3.GeoProjection): void { - const cableGroup = this.svg.append('g').attr('class', 'cables'); + if (!this.dynamicLayerGroup) return; + const cableGroup = this.dynamicLayerGroup.append('g').attr('class', 'cables'); UNDERSEA_CABLES.forEach((cable) => { const lineGenerator = d3 @@ -594,11 +1059,13 @@ export class MapComponent { const isHighlighted = this.highlightedAssets.cable.has(cable.id); const cableAdvisory = this.getCableAdvisory(cable.id); const advisoryClass = cableAdvisory ? `cable-${cableAdvisory.severity}` : ''; + const healthRecord = this.healthByCableId[cable.id]; + const healthClass = healthRecord?.status === 'fault' ? 'cable-health-fault' : healthRecord?.status === 'degraded' ? 'cable-health-degraded' : ''; const highlightClass = isHighlighted ? 'asset-highlight asset-highlight-cable' : ''; const path = cableGroup .append('path') - .attr('class', `cable-path ${advisoryClass} ${highlightClass}`.trim()) + .attr('class', `cable-path ${advisoryClass} ${healthClass} ${highlightClass}`.trim()) .attr('d', lineGenerator(cable.points)); path.append('title').text(cable.name); @@ -617,7 +1084,8 @@ export class MapComponent { } private renderPipelines(projection: d3.GeoProjection): void { - const pipelineGroup = this.svg.append('g').attr('class', 'pipelines'); + if (!this.dynamicLayerGroup) return; + const pipelineGroup = this.dynamicLayerGroup.append('g').attr('class', 'pipelines'); PIPELINES.forEach((pipeline) => { const lineGenerator = d3 @@ -626,7 +1094,7 @@ export class MapComponent { .y((d) => projection(d)?.[1] ?? 0) .curve(d3.curveCardinal.tension(0.5)); - const color = PIPELINE_COLORS[pipeline.type] || '#888888'; + const color = PIPELINE_COLORS[pipeline.type] || getCSSColor('--text-dim'); const opacity = 0.85; const dashArray = pipeline.status === 'construction' ? '4,2' : 'none'; @@ -662,7 +1130,8 @@ export class MapComponent { } private renderConflicts(projection: d3.GeoProjection): void { - const conflictGroup = this.svg.append('g').attr('class', 'conflicts'); + if (!this.dynamicLayerGroup) return; + const conflictGroup = this.dynamicLayerGroup.append('g').attr('class', 'conflicts'); CONFLICT_ZONES.forEach((zone) => { const points = zone.coords @@ -679,84 +1148,122 @@ export class MapComponent { }); } - private renderConflictLabels(projection: d3.GeoProjection): void { - CONFLICT_ZONES.forEach((zone) => { - const centerPos = projection(zone.center as [number, number]); - if (!centerPos) return; - - const div = document.createElement('div'); - div.className = 'conflict-label-overlay'; - div.style.left = `${centerPos[0]}px`; - div.style.top = `${centerPos[1]}px`; - div.textContent = zone.name; - - div.addEventListener('click', (e) => { - e.stopPropagation(); - const rect = this.container.getBoundingClientRect(); - this.popup.show({ - type: 'conflict', - data: zone, - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - }); - - this.overlays.appendChild(div); - }); - } - private renderSanctions(): void { - if (!this.worldData) return; + private updateCountryFills(): void { + if (!this.baseLayerGroup || !this.countryFeatures) return; const sanctionColors: Record = { severe: 'rgba(255, 0, 0, 0.35)', high: 'rgba(255, 100, 0, 0.25)', moderate: 'rgba(255, 200, 0, 0.2)', }; + const defaultFill = getCSSColor('--map-country'); + const useSanctions = this.state.layers.sanctions; - this.svg.selectAll('.country').each(function () { + this.baseLayerGroup.selectAll('.country').each(function (datum) { const el = d3.select(this); - const id = el.datum() as { id?: number }; + const id = datum as { id?: number }; + if (!useSanctions) { + el.attr('fill', defaultFill); + return; + } if (id?.id !== undefined && SANCTIONED_COUNTRIES[id.id]) { const level = SANCTIONED_COUNTRIES[id.id]; if (level) { - el.attr('fill', sanctionColors[level] || '#0a2018'); + el.attr('fill', sanctionColors[level] || defaultFill); + return; } } + el.attr('fill', defaultFill); }); } - private renderOverlays(projection: d3.GeoProjection): void { - this.overlays.innerHTML = ''; - - const isGlobalOrMena = this.state.view === 'global' || this.state.view === 'mena'; - - // Global/MENA only overlays - if (isGlobalOrMena) { - // Country labels (rendered first so they appear behind other overlays) - if (this.state.layers.countries) { - this.renderCountryLabels(projection); + // Generic marker clustering - groups markers within pixelRadius into clusters + // groupKey function ensures only items with same key can cluster (e.g., same city) + private clusterMarkers( + items: T[], + projection: d3.GeoProjection, + pixelRadius: number, + getGroupKey?: (item: T) => string + ): Array<{ items: T[]; center: [number, number]; pos: [number, number] }> { + const clusters: Array<{ items: T[]; center: [number, number]; pos: [number, number] }> = []; + const assigned = new Set(); + + for (let i = 0; i < items.length; i++) { + if (assigned.has(i)) continue; + + const item = items[i]!; + if (!Number.isFinite(item.lat) || !Number.isFinite(item.lon)) continue; + const pos = projection([item.lon, item.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) continue; + + const cluster: T[] = [item]; + assigned.add(i); + const itemKey = getGroupKey?.(item); + + // Find nearby items (must share same group key if provided) + for (let j = i + 1; j < items.length; j++) { + if (assigned.has(j)) continue; + const other = items[j]!; + + // Skip if different group keys (e.g., different cities) + if (getGroupKey && getGroupKey(other) !== itemKey) continue; + + if (!Number.isFinite(other.lat) || !Number.isFinite(other.lon)) continue; + const otherPos = projection([other.lon, other.lat]); + if (!otherPos || !Number.isFinite(otherPos[0]) || !Number.isFinite(otherPos[1])) continue; + + const dx = pos[0] - otherPos[0]; + const dy = pos[1] - otherPos[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist <= pixelRadius) { + cluster.push(other); + assigned.add(j); + } } - // Conflict zone labels (HTML overlay with counter-scaling) - if (this.state.layers.conflicts) { - this.renderConflictLabels(projection); + // Calculate cluster center + let sumLat = 0, sumLon = 0; + for (const c of cluster) { + sumLat += c.lat; + sumLon += c.lon; } + const centerLat = sumLat / cluster.length; + const centerLon = sumLon / cluster.length; + const centerPos = projection([centerLon, centerLat]); + const finalPos = (centerPos && Number.isFinite(centerPos[0]) && Number.isFinite(centerPos[1])) + ? centerPos : pos; + + clusters.push({ + items: cluster, + center: [centerLon, centerLat], + pos: finalPos, + }); + } - // Strategic waterways - if (this.state.layers.waterways) { - this.renderWaterways(projection); - } + return clusters; + } - if (this.state.layers.ais) { - this.renderAisDisruptions(projection); - } + private renderOverlays(projection: d3.GeoProjection): void { + this.overlays.innerHTML = ''; + + // Strategic waterways + if (this.state.layers.waterways) { + this.renderWaterways(projection); + } - // APT groups + if (this.state.layers.ais) { + this.renderAisDisruptions(projection); + this.renderPorts(projection); + } + + // APT groups (geopolitical variant only) + if (SITE_VARIANT !== 'tech') { this.renderAPTMarkers(projection); } - // Nuclear facilities + // Nuclear facilities (always HTML - shapes convey status) if (this.state.layers.nuclear) { NUCLEAR_FACILITIES.forEach((facility) => { const pos = projection([facility.lon, facility.lat]); @@ -769,11 +1276,6 @@ export class MapComponent { div.style.top = `${pos[1]}px`; div.title = `${facility.name} (${facility.type})`; - const label = document.createElement('div'); - label.className = 'nuclear-label'; - label.textContent = facility.name; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -789,7 +1291,7 @@ export class MapComponent { }); } - // Gamma irradiators (IAEA DIIF) + // Gamma irradiators (IAEA DIIF) - no labels, click to see details if (this.state.layers.irradiators) { GAMMA_IRRADIATORS.forEach((irradiator) => { const pos = projection([irradiator.lon, irradiator.lat]); @@ -801,11 +1303,6 @@ export class MapComponent { div.style.top = `${pos[1]}px`; div.title = `${irradiator.city}, ${irradiator.country}`; - const label = document.createElement('div'); - label.className = 'irradiator-label'; - label.textContent = irradiator.city; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -850,7 +1347,7 @@ export class MapComponent { }); } - // Hotspots + // Hotspots (always HTML - level colors and BREAKING badges) if (this.state.layers.hotspots) { this.hotspots.forEach((spot) => { const pos = projection([spot.lon, spot.lat]); @@ -861,19 +1358,8 @@ export class MapComponent { div.style.left = `${pos[0]}px`; div.style.top = `${pos[1]}px`; - const breakingBadge = spot.hasBreaking - ? '
BREAKING
' - : ''; - - const subtextHtml = spot.subtext - ? `
${spot.subtext}
` - : ''; - div.innerHTML = ` - ${breakingBadge} -
-
${spot.name}
- ${subtextHtml} +
`; div.addEventListener('click', (e) => { @@ -887,6 +1373,7 @@ export class MapComponent { x: e.clientX - rect.left, y: e.clientY - rect.top, }); + this.popup.loadHotspotGdeltContext(spot); this.onHotspotClick?.(spot); }); @@ -894,7 +1381,7 @@ export class MapComponent { }); } - // Military bases + // Military bases (always HTML - nation colors matter) if (this.state.layers.bases) { MILITARY_BASES.forEach((base) => { const pos = projection([base.lon, base.lat]); @@ -926,16 +1413,18 @@ export class MapComponent { }); } - // Earthquakes - if (this.state.layers.earthquakes) { - console.log('[Map] Rendering earthquakes. Total:', this.earthquakes.length, 'Layer enabled:', this.state.layers.earthquakes); - const filteredQuakes = this.filterByTime(this.earthquakes); + // Earthquakes (magnitude-based sizing) - part of NATURAL layer + if (this.state.layers.natural) { + console.log('[Map] Rendering earthquakes. Total:', this.earthquakes.length, 'Layer enabled:', this.state.layers.natural); + const filteredQuakes = this.state.timeRange === 'all' + ? this.earthquakes + : this.earthquakes.filter((eq) => eq.occurredAt >= Date.now() - this.getTimeRangeMs()); console.log('[Map] After time filter:', filteredQuakes.length, 'earthquakes. TimeRange:', this.state.timeRange); let rendered = 0; filteredQuakes.forEach((eq) => { - const pos = projection([eq.lon, eq.lat]); + const pos = projection([eq.location?.longitude ?? 0, eq.location?.latitude ?? 0]); if (!pos) { - console.log('[Map] Earthquake position null for:', eq.place, eq.lon, eq.lat); + console.log('[Map] Earthquake position null for:', eq.place, eq.location?.longitude, eq.location?.latitude); return; } rendered++; @@ -970,7 +1459,7 @@ export class MapComponent { console.log('[Map] Actually rendered', rendered, 'earthquake markers'); } - // Economic Centers + // Economic Centers (always HTML - emoji icons for type distinction) if (this.state.layers.economic) { ECONOMIC_CENTERS.forEach((center) => { const pos = projection([center.lon, center.lat]); @@ -985,11 +1474,7 @@ export class MapComponent { icon.className = 'economic-icon'; icon.textContent = center.type === 'exchange' ? '📈' : center.type === 'central-bank' ? '🏛' : '💰'; div.appendChild(icon); - - const label = document.createElement('div'); - label.className = 'economic-label'; - label.textContent = center.name; - div.appendChild(label); + div.title = center.name; div.addEventListener('click', (e) => { e.stopPropagation(); @@ -1006,7 +1491,7 @@ export class MapComponent { }); } - // Weather Alerts + // Weather Alerts (severity icons) if (this.state.layers.weather) { this.weatherAlerts.forEach((alert) => { if (!alert.centroid) return; @@ -1024,11 +1509,6 @@ export class MapComponent { icon.textContent = '⚠'; div.appendChild(icon); - const label = document.createElement('div'); - label.className = 'weather-label'; - label.textContent = alert.event; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -1044,7 +1524,7 @@ export class MapComponent { }); } - // Internet Outages + // Internet Outages (severity colors) if (this.state.layers.outages) { this.outages.forEach((outage) => { const pos = projection([outage.lon, outage.lat]); @@ -1081,7 +1561,7 @@ export class MapComponent { } // Cable advisories & repair ships - if (this.state.layers.cables && isGlobalOrMena) { + if (this.state.layers.cables) { this.cableAdvisories.forEach((advisory) => { const pos = projection([advisory.lon, advisory.lat]); if (!pos) return; @@ -1149,9 +1629,10 @@ export class MapComponent { }); } - // AI Data Centers + // AI Data Centers (always HTML - 🖥️ icons, filter to ≥10k GPUs) + const MIN_GPU_COUNT = 10000; if (this.state.layers.datacenters) { - AI_DATA_CENTERS.forEach((dc) => { + AI_DATA_CENTERS.filter(dc => (dc.chipCount || 0) >= MIN_GPU_COUNT).forEach((dc) => { const pos = projection([dc.lon, dc.lat]); if (!pos) return; @@ -1166,11 +1647,6 @@ export class MapComponent { icon.textContent = '🖥️'; div.appendChild(icon); - const label = document.createElement('div'); - label.className = 'datacenter-label'; - label.textContent = dc.owner; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -1186,39 +1662,33 @@ export class MapComponent { }); } - // Protests / Social Unrest Events - if (this.state.layers.protests) { - this.protests.forEach((event) => { - const pos = projection([event.lon, event.lat]); + // Spaceports (🚀 icon) + if (this.state.layers.spaceports) { + SPACEPORTS.forEach((port) => { + const pos = projection([port.lon, port.lat]); if (!pos) return; const div = document.createElement('div'); - div.className = `protest-marker ${event.severity} ${event.eventType}`; + div.className = `spaceport-marker ${port.status}`; div.style.left = `${pos[0]}px`; div.style.top = `${pos[1]}px`; const icon = document.createElement('div'); - icon.className = 'protest-icon'; - icon.textContent = event.eventType === 'riot' ? '🔥' : event.eventType === 'strike' ? '✊' : '📢'; + icon.className = 'spaceport-icon'; + icon.textContent = '🚀'; div.appendChild(icon); - if (this.state.zoom >= 4) { - const label = document.createElement('div'); - label.className = 'protest-label'; - label.textContent = event.city || event.country; - div.appendChild(label); - } - - if (event.validated) { - div.classList.add('validated'); - } + const label = document.createElement('div'); + label.className = 'spaceport-label'; + label.textContent = port.name; + div.appendChild(label); div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); this.popup.show({ - type: 'protest', - data: event, + type: 'spaceport', + data: port, x: e.clientX - rect.left, y: e.clientY - rect.top, }); @@ -1227,80 +1697,949 @@ export class MapComponent { this.overlays.appendChild(div); }); } - } - - private renderCountryLabels(projection: d3.GeoProjection): void { - COUNTRY_LABELS.forEach((country) => { - const pos = projection([country.lon, country.lat]); - if (!pos) return; - - const div = document.createElement('div'); - div.className = 'country-label'; - div.style.left = `${pos[0]}px`; - div.style.top = `${pos[1]}px`; - div.textContent = country.name; - div.dataset.countryId = String(country.id); - this.overlays.appendChild(div); - }); - } + // Critical Minerals (💎 icon) + if (this.state.layers.minerals) { + CRITICAL_MINERALS.forEach((mine) => { + const pos = projection([mine.lon, mine.lat]); + if (!pos) return; - private renderWaterways(projection: d3.GeoProjection): void { - STRATEGIC_WATERWAYS.forEach((waterway) => { - const pos = projection([waterway.lon, waterway.lat]); - if (!pos) return; + const div = document.createElement('div'); + div.className = `mineral-marker ${mine.status}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; - const div = document.createElement('div'); - div.className = 'waterway-marker'; - div.style.left = `${pos[0]}px`; - div.style.top = `${pos[1]}px`; - div.title = waterway.name; + const icon = document.createElement('div'); + icon.className = 'mineral-icon'; + // Select icon based on mineral type + icon.textContent = mine.mineral === 'Lithium' ? '🔋' : mine.mineral === 'Rare Earths' ? '🧲' : '💎'; + div.appendChild(icon); - const diamond = document.createElement('div'); - diamond.className = 'waterway-diamond'; - div.appendChild(diamond); + const label = document.createElement('div'); + label.className = 'mineral-label'; + label.textContent = `${mine.mineral} - ${mine.name}`; + div.appendChild(label); - div.addEventListener('click', (e) => { - e.stopPropagation(); - const rect = this.container.getBoundingClientRect(); - this.popup.show({ - type: 'waterway', - data: waterway, - x: e.clientX - rect.left, - y: e.clientY - rect.top, + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'mineral', + data: mine, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); }); + + this.overlays.appendChild(div); }); + } - this.overlays.appendChild(div); - }); - } + // === TECH VARIANT LAYERS === - private renderAisDisruptions(projection: d3.GeoProjection): void { - this.aisDisruptions.forEach((event) => { - const pos = projection([event.lon, event.lat]); - if (!pos) return; + // Startup Hubs (🚀 icon by tier) + if (this.state.layers.startupHubs) { + STARTUP_HUBS.forEach((hub) => { + const pos = projection([hub.lon, hub.lat]); + if (!pos) return; - const div = document.createElement('div'); - div.className = `ais-disruption-marker ${event.severity} ${event.type}`; - div.style.left = `${pos[0]}px`; - div.style.top = `${pos[1]}px`; + const div = document.createElement('div'); + div.className = `startup-hub-marker ${hub.tier}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; - const icon = document.createElement('div'); - icon.className = 'ais-disruption-icon'; - icon.textContent = event.type === 'gap_spike' ? '🛰️' : '🚢'; - div.appendChild(icon); + const icon = document.createElement('div'); + icon.className = 'startup-hub-icon'; + icon.textContent = hub.tier === 'mega' ? '🦄' : hub.tier === 'major' ? '🚀' : '💡'; + div.appendChild(icon); - const label = document.createElement('div'); - label.className = 'ais-disruption-label'; - label.textContent = event.name; - div.appendChild(label); + if (this.state.zoom >= 2 || hub.tier === 'mega') { + const label = document.createElement('div'); + label.className = 'startup-hub-label'; + label.textContent = hub.name; + div.appendChild(label); + } - div.addEventListener('click', (e) => { - e.stopPropagation(); - const rect = this.container.getBoundingClientRect(); - this.popup.show({ - type: 'ais', - data: event, + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'startupHub', + data: hub, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Cloud Regions (☁️ icons by provider) + if (this.state.layers.cloudRegions) { + CLOUD_REGIONS.forEach((region) => { + const pos = projection([region.lon, region.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `cloud-region-marker ${region.provider}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'cloud-region-icon'; + // Provider-specific icons + const icons: Record = { aws: '🟠', gcp: '🔵', azure: '🟣', cloudflare: '🟡' }; + icon.textContent = icons[region.provider] || '☁️'; + div.appendChild(icon); + + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'cloud-region-label'; + label.textContent = region.provider.toUpperCase(); + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'cloudRegion', + data: region, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Tech HQs (🏢 icons by company type) - with clustering by city + if (this.state.layers.techHQs) { + // Cluster radius depends on zoom - tighter clustering when zoomed out + const clusterRadius = this.state.zoom >= 4 ? 15 : this.state.zoom >= 3 ? 25 : 40; + // Group by city to prevent clustering companies from different cities + const clusters = this.clusterMarkers(TECH_HQS, projection, clusterRadius, hq => hq.city); + + clusters.forEach((cluster) => { + if (cluster.items.length === 0) return; + const div = document.createElement('div'); + const isCluster = cluster.items.length > 1; + const primaryItem = cluster.items[0]!; // Use first item for styling + + div.className = `tech-hq-marker ${primaryItem.type} ${isCluster ? 'cluster' : ''}`; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'tech-hq-icon'; + + if (isCluster) { + // Show count for clusters + const unicornCount = cluster.items.filter(h => h.type === 'unicorn').length; + const faangCount = cluster.items.filter(h => h.type === 'faang').length; + icon.textContent = faangCount > 0 ? '🏛️' : unicornCount > 0 ? '🦄' : '🏢'; + + const badge = document.createElement('div'); + badge.className = 'cluster-badge'; + badge.textContent = String(cluster.items.length); + div.appendChild(badge); + + div.title = cluster.items.map(h => h.company).join(', '); + } else { + icon.textContent = primaryItem.type === 'faang' ? '🏛️' : primaryItem.type === 'unicorn' ? '🦄' : '🏢'; + } + div.appendChild(icon); + + // Show label at higher zoom or for single FAANG markers + if (!isCluster && (this.state.zoom >= 3 || primaryItem.type === 'faang')) { + const label = document.createElement('div'); + label.className = 'tech-hq-label'; + label.textContent = primaryItem.company; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + if (isCluster) { + // Show cluster popup with list of companies + this.popup.show({ + type: 'techHQCluster', + data: { items: cluster.items, city: primaryItem.city, country: primaryItem.country }, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } else { + this.popup.show({ + type: 'techHQ', + data: primaryItem, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }); + + this.overlays.appendChild(div); + }); + } + + // Accelerators (🎯 icons) + if (this.state.layers.accelerators) { + ACCELERATORS.forEach((acc) => { + const pos = projection([acc.lon, acc.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `accelerator-marker ${acc.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'accelerator-icon'; + icon.textContent = acc.type === 'accelerator' ? '🎯' : acc.type === 'incubator' ? '🔬' : '🎨'; + div.appendChild(icon); + + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'accelerator-label'; + label.textContent = acc.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'accelerator', + data: acc, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Tech Events / Conferences (📅 icons) - with clustering + if (this.state.layers.techEvents && this.techEvents.length > 0) { + const mapWidth = this.container.clientWidth; + const mapHeight = this.container.clientHeight; + + // Map events to have lon property for clustering, filter visible + const visibleEvents = this.techEvents + .map(e => ({ ...e, lon: e.lng })) + .filter(e => { + const pos = projection([e.lon, e.lat]); + return pos && pos[0] >= 0 && pos[0] <= mapWidth && pos[1] >= 0 && pos[1] <= mapHeight; + }); + + const clusterRadius = this.state.zoom >= 4 ? 15 : this.state.zoom >= 3 ? 25 : 40; + // Group by location to prevent clustering events from different cities + const clusters = this.clusterMarkers(visibleEvents, projection, clusterRadius, e => e.location); + + clusters.forEach((cluster) => { + if (cluster.items.length === 0) return; + const div = document.createElement('div'); + const isCluster = cluster.items.length > 1; + const primaryEvent = cluster.items[0]!; + const hasUpcomingSoon = cluster.items.some(e => e.daysUntil <= 14); + + div.className = `tech-event-marker ${hasUpcomingSoon ? 'upcoming-soon' : ''} ${isCluster ? 'cluster' : ''}`; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + + if (isCluster) { + const badge = document.createElement('div'); + badge.className = 'cluster-badge'; + badge.textContent = String(cluster.items.length); + div.appendChild(badge); + div.title = cluster.items.map(e => e.title).join(', '); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + if (isCluster) { + this.popup.show({ + type: 'techEventCluster', + data: { items: cluster.items, location: primaryEvent.location, country: primaryEvent.country }, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } else { + this.popup.show({ + type: 'techEvent', + data: primaryEvent, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }); + + this.overlays.appendChild(div); + }); + } + + // Stock Exchanges (🏛️ icon by tier) + if (this.state.layers.stockExchanges) { + STOCK_EXCHANGES.forEach((exchange) => { + const pos = projection([exchange.lon, exchange.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = exchange.tier === 'mega' ? '🏛️' : exchange.tier === 'major' ? '📊' : '📈'; + const div = document.createElement('div'); + div.className = `map-marker exchange-marker tier-${exchange.tier}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = exchange.tier === 'mega' ? '50' : '40'; + div.textContent = icon; + div.title = `${exchange.shortName} (${exchange.city})`; + + if ((this.state.zoom >= 2 && exchange.tier === 'mega') || this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = exchange.shortName; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'stockExchange', + data: exchange, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Financial Centers (💰 icon by type) + if (this.state.layers.financialCenters) { + FINANCIAL_CENTERS.forEach((center) => { + const pos = projection([center.lon, center.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = center.type === 'global' ? '💰' : center.type === 'regional' ? '🏦' : '🏝️'; + const div = document.createElement('div'); + div.className = `map-marker financial-center-marker type-${center.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = center.type === 'global' ? '45' : '35'; + div.textContent = icon; + div.title = `${center.name} Financial Center`; + + if ((this.state.zoom >= 2 && center.type === 'global') || this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = center.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'financialCenter', + data: center, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Central Banks (🏛️ icon by type) + if (this.state.layers.centralBanks) { + CENTRAL_BANKS.forEach((bank) => { + const pos = projection([bank.lon, bank.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = bank.type === 'supranational' ? '🌐' : bank.type === 'major' ? '🏛️' : '🏦'; + const div = document.createElement('div'); + div.className = `map-marker central-bank-marker type-${bank.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = bank.type === 'supranational' ? '48' : bank.type === 'major' ? '42' : '38'; + div.textContent = icon; + div.title = `${bank.shortName} - ${bank.name}`; + + if ((this.state.zoom >= 2 && (bank.type === 'major' || bank.type === 'supranational')) || this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = bank.shortName; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'centralBank', + data: bank, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Commodity Hubs (⛽ icon by type) + if (this.state.layers.commodityHubs) { + COMMODITY_HUBS.forEach((hub) => { + const pos = projection([hub.lon, hub.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = hub.type === 'exchange' ? '📦' : hub.type === 'port' ? '🚢' : '⛽'; + const div = document.createElement('div'); + div.className = `map-marker commodity-hub-marker type-${hub.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = '38'; + div.textContent = icon; + div.title = `${hub.name} (${hub.city})`; + + if (this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = hub.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'commodityHub', + data: hub, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Tech Hub Activity Markers (shows activity heatmap for tech hubs with news activity) + if (SITE_VARIANT === 'tech' && this.techActivities.length > 0) { + this.techActivities.forEach((activity) => { + const pos = projection([activity.lon, activity.lat]); + if (!pos) return; + + // Only show markers for hubs with actual activity + if (activity.newsCount === 0) return; + + const div = document.createElement('div'); + div.className = `tech-activity-marker ${activity.activityLevel}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = activity.activityLevel === 'high' ? '60' : activity.activityLevel === 'elevated' ? '50' : '40'; + div.title = `${activity.city}: ${activity.newsCount} stories`; + + div.addEventListener('click', (e) => { + e.stopPropagation(); + this.onTechHubClick?.(activity); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'techActivity', + data: activity, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + + // Add label for high/elevated activity hubs at sufficient zoom + if ((activity.activityLevel === 'high' || (activity.activityLevel === 'elevated' && this.state.zoom >= 2)) && this.state.zoom >= 1.5) { + const label = document.createElement('div'); + label.className = 'tech-activity-label'; + label.textContent = activity.city; + label.style.left = `${pos[0]}px`; + label.style.top = `${pos[1] + 14}px`; + this.overlays.appendChild(label); + } + }); + } + + // Geo Hub Activity Markers (shows activity heatmap for geopolitical hubs - full variant) + if (SITE_VARIANT === 'full' && this.geoActivities.length > 0) { + this.geoActivities.forEach((activity) => { + const pos = projection([activity.lon, activity.lat]); + if (!pos) return; + + // Only show markers for hubs with actual activity + if (activity.newsCount === 0) return; + + const div = document.createElement('div'); + div.className = `geo-activity-marker ${activity.activityLevel}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = activity.activityLevel === 'high' ? '60' : activity.activityLevel === 'elevated' ? '50' : '40'; + div.title = `${activity.name}: ${activity.newsCount} stories`; + + div.addEventListener('click', (e) => { + e.stopPropagation(); + this.onGeoHubClick?.(activity); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'geoActivity', + data: activity, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Protests / Social Unrest Events (severity colors + icons) - with clustering + // Filter to show only significant events on map (all events still used for CII analysis) + if (this.state.layers.protests) { + const significantProtests = this.protests.filter((event) => { + // Only show riots and high severity (red markers) + // All protests still counted in CII analysis + return event.eventType === 'riot' || event.severity === 'high'; + }); + + const clusterRadius = this.state.zoom >= 4 ? 12 : this.state.zoom >= 3 ? 20 : 35; + const clusters = this.clusterMarkers(significantProtests, projection, clusterRadius, p => p.country); + + clusters.forEach((cluster) => { + if (cluster.items.length === 0) return; + const div = document.createElement('div'); + const isCluster = cluster.items.length > 1; + const primaryEvent = cluster.items[0]!; + const hasRiot = cluster.items.some(e => e.eventType === 'riot'); + const hasHighSeverity = cluster.items.some(e => e.severity === 'high'); + + div.className = `protest-marker ${hasHighSeverity ? 'high' : primaryEvent.severity} ${hasRiot ? 'riot' : primaryEvent.eventType} ${isCluster ? 'cluster' : ''}`; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'protest-icon'; + icon.textContent = hasRiot ? '🔥' : primaryEvent.eventType === 'strike' ? '✊' : '📢'; + div.appendChild(icon); + + if (isCluster) { + const badge = document.createElement('div'); + badge.className = 'cluster-badge'; + badge.textContent = String(cluster.items.length); + div.appendChild(badge); + div.title = `${primaryEvent.country}: ${cluster.items.length} ${t('popups.events')}`; + } else { + div.title = `${primaryEvent.city || primaryEvent.country} - ${primaryEvent.eventType} (${primaryEvent.severity})`; + if (primaryEvent.validated) { + div.classList.add('validated'); + } + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + if (isCluster) { + this.popup.show({ + type: 'protestCluster', + data: { items: cluster.items, country: primaryEvent.country }, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } else { + this.popup.show({ + type: 'protest', + data: primaryEvent, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }); + + this.overlays.appendChild(div); + }); + } + + // Flight Delays (delay severity colors + ✈️ icons) + if (this.state.layers.flights) { + this.flightDelays.forEach((delay) => { + const pos = projection([delay.lon, delay.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `flight-delay-marker ${delay.severity}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'flight-delay-icon'; + icon.textContent = delay.delayType === 'ground_stop' ? '🛑' : delay.severity === 'severe' ? '✈️' : '🛫'; + div.appendChild(icon); + + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'flight-delay-label'; + label.textContent = `${delay.iata} ${delay.avgDelayMinutes > 0 ? `+${delay.avgDelayMinutes}m` : ''}`; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'flight', + data: delay, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Military Tracking (flights and vessels) + if (this.state.layers.military) { + // Render individual flights + this.militaryFlights.forEach((flight) => { + const pos = projection([flight.lon, flight.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-flight-marker ${flight.operator} ${flight.aircraftType}${flight.isInteresting ? ' interesting' : ''}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + // Crosshair icon - rotates with heading + const icon = document.createElement('div'); + icon.className = `military-flight-icon ${flight.aircraftType}`; + icon.style.transform = `rotate(${flight.heading}deg)`; + // CSS handles the crosshair rendering + div.appendChild(icon); + + // Show callsign at higher zoom levels + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'military-flight-label'; + label.textContent = flight.callsign; + div.appendChild(label); + } + + // Show altitude indicator + if (flight.altitude > 0) { + const alt = document.createElement('div'); + alt.className = 'military-flight-altitude'; + alt.textContent = `FL${Math.round(flight.altitude / 100)}`; + div.appendChild(alt); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryFlight', + data: flight, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + + // Render flight track if available + if (flight.track && flight.track.length > 1 && this.state.zoom >= 2) { + const trackLine = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + const points = flight.track + .map((p) => { + const pt = projection([p[1], p[0]]); + return pt ? `${pt[0]},${pt[1]}` : null; + }) + .filter(Boolean) + .join(' '); + + if (points) { + trackLine.setAttribute('points', points); + trackLine.setAttribute('class', `military-flight-track ${flight.operator}`); + trackLine.setAttribute('fill', 'none'); + trackLine.setAttribute('stroke-width', '1.5'); + trackLine.setAttribute('stroke-dasharray', '4,2'); + this.dynamicLayerGroup?.select('.overlays-svg').append(() => trackLine); + } + } + }); + + // Render flight clusters + this.militaryFlightClusters.forEach((cluster) => { + const pos = projection([cluster.lon, cluster.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-cluster-marker flight-cluster ${cluster.activityType || 'unknown'}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const count = document.createElement('div'); + count.className = 'cluster-count'; + count.textContent = String(cluster.flightCount); + div.appendChild(count); + + const label = document.createElement('div'); + label.className = 'cluster-label'; + label.textContent = cluster.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryFlightCluster', + data: cluster, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + + // Military Vessels (warships, carriers, submarines) + // Render individual vessels + this.militaryVessels.forEach((vessel) => { + const pos = projection([vessel.lon, vessel.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-vessel-marker ${vessel.operator} ${vessel.vesselType}${vessel.isDark ? ' dark-vessel' : ''}${vessel.isInteresting ? ' interesting' : ''}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = `military-vessel-icon ${vessel.vesselType}`; + icon.style.transform = `rotate(${vessel.heading}deg)`; + // CSS handles the diamond/anchor rendering + div.appendChild(icon); + + // Dark vessel warning indicator + if (vessel.isDark) { + const darkIndicator = document.createElement('div'); + darkIndicator.className = 'dark-vessel-indicator'; + darkIndicator.textContent = '⚠️'; + darkIndicator.title = 'AIS Signal Lost'; + div.appendChild(darkIndicator); + } + + // Show vessel name at higher zoom + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'military-vessel-label'; + label.textContent = vessel.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryVessel', + data: vessel, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + + // Render vessel track if available + if (vessel.track && vessel.track.length > 1 && this.state.zoom >= 2) { + const trackLine = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + const points = vessel.track + .map((p) => { + const pt = projection([p[1], p[0]]); + return pt ? `${pt[0]},${pt[1]}` : null; + }) + .filter(Boolean) + .join(' '); + + if (points) { + trackLine.setAttribute('points', points); + trackLine.setAttribute('class', `military-vessel-track ${vessel.operator}`); + trackLine.setAttribute('fill', 'none'); + trackLine.setAttribute('stroke-width', '2'); + this.dynamicLayerGroup?.select('.overlays-svg').append(() => trackLine); + } + } + }); + + // Render vessel clusters + this.militaryVesselClusters.forEach((cluster) => { + const pos = projection([cluster.lon, cluster.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-cluster-marker vessel-cluster ${cluster.activityType || 'unknown'}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const count = document.createElement('div'); + count.className = 'cluster-count'; + count.textContent = String(cluster.vesselCount); + div.appendChild(count); + + const label = document.createElement('div'); + label.className = 'cluster-label'; + label.textContent = cluster.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryVesselCluster', + data: cluster, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Natural Events (NASA EONET) - part of NATURAL layer + if (this.state.layers.natural) { + this.naturalEvents.forEach((event) => { + const pos = projection([event.lon, event.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `nat-event-marker ${event.category}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'nat-event-icon'; + icon.textContent = getNaturalEventIcon(event.category); + div.appendChild(icon); + + if (this.state.zoom >= 2) { + const label = document.createElement('div'); + label.className = 'nat-event-label'; + label.textContent = event.title.length > 25 ? event.title.slice(0, 25) + '…' : event.title; + div.appendChild(label); + } + + if (event.magnitude) { + const mag = document.createElement('div'); + mag.className = 'nat-event-magnitude'; + mag.textContent = `${event.magnitude}${event.magnitudeUnit ? ` ${event.magnitudeUnit}` : ''}`; + div.appendChild(mag); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'natEvent', + data: event, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Satellite Fires (NASA FIRMS) - separate fires layer + if (this.state.layers.fires) { + this.firmsFireData.forEach((fire) => { + const pos = projection([fire.lon, fire.lat]); + if (!pos) return; + + const color = fire.brightness > 400 ? getCSSColor('--semantic-critical') : fire.brightness > 350 ? getCSSColor('--semantic-high') : getCSSColor('--semantic-elevated'); + const size = Math.max(4, Math.min(10, (fire.frp || 1) * 0.5)); + + const dot = document.createElement('div'); + dot.className = 'fire-dot'; + dot.style.left = `${pos[0]}px`; + dot.style.top = `${pos[1]}px`; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.backgroundColor = color; + dot.title = `${fire.region} — ${Math.round(fire.brightness)}K, ${fire.frp}MW`; + + this.overlays.appendChild(dot); + }); + } + } + + private renderWaterways(projection: d3.GeoProjection): void { + STRATEGIC_WATERWAYS.forEach((waterway) => { + const pos = projection([waterway.lon, waterway.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = 'waterway-marker'; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.title = waterway.name; + + const diamond = document.createElement('div'); + diamond.className = 'waterway-diamond'; + div.appendChild(diamond); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'waterway', + data: waterway, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + private renderAisDisruptions(projection: d3.GeoProjection): void { + this.aisDisruptions.forEach((event) => { + const pos = projection([event.lon, event.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `ais-disruption-marker ${event.severity} ${event.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'ais-disruption-icon'; + icon.textContent = event.type === 'gap_spike' ? '🛰️' : '🚢'; + div.appendChild(icon); + + const label = document.createElement('div'); + label.className = 'ais-disruption-label'; + label.textContent = event.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'ais', + data: event, x: e.clientX - rect.left, y: e.clientY - rect.top, }); @@ -1311,17 +2650,18 @@ export class MapComponent { } private renderAisDensity(projection: d3.GeoProjection): void { - const densityGroup = this.svg.append('g').attr('class', 'ais-density'); + if (!this.dynamicLayerGroup) return; + const densityGroup = this.dynamicLayerGroup.append('g').attr('class', 'ais-density'); this.aisDensity.forEach((zone) => { const pos = projection([zone.lon, zone.lat]); if (!pos) return; const intensity = Math.min(Math.max(zone.intensity, 0.15), 1); - const radius = 18 + intensity * 45; + const radius = 4 + intensity * 8; // Small dots (4-12px) const isCongested = zone.deltaPct >= 15; - const color = isCongested ? '#ffb703' : '#00d1ff'; - const fillOpacity = 0.08 + intensity * 0.22; + const color = isCongested ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-info'); + const fillOpacity = 0.15 + intensity * 0.25; // More visible individual dots densityGroup .append('circle') @@ -1331,9 +2671,42 @@ export class MapComponent { .attr('r', radius) .attr('fill', color) .attr('fill-opacity', fillOpacity) - .attr('stroke', color) - .attr('stroke-opacity', 0.35) - .attr('stroke-width', 1); + .attr('stroke', 'none'); + }); + } + + private renderPorts(projection: d3.GeoProjection): void { + PORTS.forEach((port) => { + const pos = projection([port.lon, port.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `port-marker port-${port.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'port-icon'; + icon.textContent = port.type === 'naval' ? '⚓' : port.type === 'oil' || port.type === 'lng' ? '🛢️' : '🏭'; + div.appendChild(icon); + + const label = document.createElement('div'); + label.className = 'port-label'; + label.textContent = port.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'port', + data: port, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); }); } @@ -1348,7 +2721,7 @@ export class MapComponent { div.style.top = `${pos[1]}px`; div.innerHTML = `
-
${apt.name}
+
${escapeHtml(apt.name)}
`; div.addEventListener('click', (e) => { @@ -1454,21 +2827,78 @@ export class MapComponent { spot.level = 'low'; spot.status = 'Monitoring'; } + + // Update dynamic escalation score + const velocity = matchedCount > 0 ? score / matchedCount : 0; + updateHotspotEscalation(spot.id, matchedCount, hasBreaking, velocity); }); this.render(); } + public flashLocation(lat: number, lon: number, durationMs = 2000): void { + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (!width || !height) return; + + const projection = this.getProjection(width, height); + const pos = projection([lon, lat]); + if (!pos) return; + + const flash = document.createElement('div'); + flash.className = 'map-flash'; + flash.style.left = `${pos[0]}px`; + flash.style.top = `${pos[1]}px`; + flash.style.setProperty('--flash-duration', `${durationMs}ms`); + this.overlays.appendChild(flash); + + window.setTimeout(() => { + flash.remove(); + }, durationMs); + } + + public initEscalationGetters(): void { + setCIIGetter(getCountryScore); + setGeoAlertGetter(getAlertsNearLocation); + } + + public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void { + setMilitaryData(flights, vessels); + } + + public getHotspotDynamicScore(hotspotId: string) { + return getHotspotEscalation(hotspotId); + } + public setView(view: MapView): void { this.state.view = view; - // Reset zoom when changing views for better UX - this.state.zoom = view === 'mena' ? 2.5 : 1; - this.state.pan = view === 'mena' ? { x: -180, y: 60 } : { x: 0, y: 0 }; + + // Region-specific zoom and pan settings + // Pan: +x = west, -x = east, +y = north, -y = south + const viewSettings: Record = { + global: { zoom: 1, pan: { x: 0, y: 0 } }, + america: { zoom: 1.8, pan: { x: 180, y: 30 } }, + mena: { zoom: 3.5, pan: { x: -100, y: 50 } }, + eu: { zoom: 2.4, pan: { x: -30, y: 100 } }, + asia: { zoom: 2.0, pan: { x: -320, y: 40 } }, + latam: { zoom: 2.0, pan: { x: 120, y: -100 } }, + africa: { zoom: 2.2, pan: { x: -40, y: -30 } }, + oceania: { zoom: 2.2, pan: { x: -420, y: -100 } }, + }; + + const settings = viewSettings[view]; + this.state.zoom = settings.zoom; + this.state.pan = settings.pan; this.applyTransform(); this.render(); } - public toggleLayer(layer: keyof MapLayers): void { + private static readonly ASYNC_DATA_LAYERS: Set = new Set([ + 'natural', 'weather', 'outages', 'ais', 'protests', 'flights', 'military', 'techEvents', + ]); + + public toggleLayer(layer: keyof MapLayers, source: 'user' | 'programmatic' = 'user'): void { + console.log(`[Map.toggleLayer] ${layer}: ${this.state.layers[layer]} -> ${!this.state.layers[layer]}`); this.state.layers[layer] = !this.state.layers[layer]; if (this.state.layers[layer]) { const thresholds = MapComponent.LAYER_ZOOM_THRESHOLDS[layer]; @@ -1481,17 +2911,55 @@ export class MapComponent { delete this.layerZoomOverrides[layer]; } - const btn = document.querySelector(`[data-layer="${layer}"]`); - btn?.classList.toggle('active'); + const btn = this.container.querySelector(`[data-layer="${layer}"]`); + const isEnabled = this.state.layers[layer]; + const isAsyncLayer = MapComponent.ASYNC_DATA_LAYERS.has(layer); - this.onLayerChange?.(layer, this.state.layers[layer]); - this.render(); + if (isEnabled && isAsyncLayer) { + // Async layers: start in loading state, will be set to active when data arrives + btn?.classList.remove('active'); + btn?.classList.add('loading'); + } else { + // Static layers or disabling: toggle active immediately + btn?.classList.toggle('active', isEnabled); + btn?.classList.remove('loading'); + } + + this.onLayerChange?.(layer, this.state.layers[layer], source); + // Defer render to next frame to avoid blocking the click handler + requestAnimationFrame(() => this.render()); } - public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean) => void): void { + public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void): void { this.onLayerChange = callback; } + public hideLayerToggle(layer: keyof MapLayers): void { + const btn = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (btn) { + (btn as HTMLElement).style.display = 'none'; + } + } + + public setLayerLoading(layer: keyof MapLayers, loading: boolean): void { + const btn = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (btn) { + btn.classList.toggle('loading', loading); + } + } + + public setLayerReady(layer: keyof MapLayers, hasData: boolean): void { + const btn = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (!btn) return; + + btn.classList.remove('loading'); + if (this.state.layers[layer] && hasData) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + } + public onStateChanged(callback: (state: MapState) => void): void { this.onStateChange = callback; } @@ -1509,7 +2977,12 @@ export class MapComponent { public reset(): void { this.state.zoom = 1; this.state.pan = { x: 0, y: 0 }; - this.applyTransform(); + if (this.state.view !== 'global') { + this.state.view = 'global'; + this.render(); + } else { + this.applyTransform(); + } } public triggerHotspotClick(id: string): void { @@ -1530,6 +3003,7 @@ export class MapComponent { x: pos[0], y: pos[1], }); + this.popup.loadHotspotGdeltContext(hotspot); this.onHotspotClick?.(hotspot); } @@ -1672,7 +3146,7 @@ export class MapComponent { } const btn = document.querySelector(`[data-layer="${layer}"]`); btn?.classList.add('active'); - this.onLayerChange?.(layer, true); + this.onLayerChange?.(layer, true, 'programmatic'); this.render(); } } @@ -1695,15 +3169,11 @@ export class MapComponent { const zoom = this.state.zoom; const width = this.container.clientWidth; const height = this.container.clientHeight; - const mapHeight = width / 2; // Equirectangular 2:1 ratio - - // Horizontal: at zoom 1, no pan. At higher zooms, allow proportional pan - const maxPanX = ((zoom - 1) / zoom) * (width / 2); - // Vertical: allow panning to see poles if map extends beyond container - const extraVertical = Math.max(0, (mapHeight - height) / 2); - const zoomPanY = ((zoom - 1) / zoom) * (height / 2); - const maxPanY = extraVertical + zoomPanY; + // Allow generous panning - maps should be explorable + // Scale limits with zoom to allow reaching edges at higher zoom + const maxPanX = (width / 2) * Math.max(1, zoom * 0.8); + const maxPanY = (height / 2) * Math.max(1, zoom * 0.8); this.state.pan.x = Math.max(-maxPanX, Math.min(maxPanX, this.state.pan.x)); this.state.pan.y = Math.max(-maxPanY, Math.min(maxPanY, this.state.pan.y)); @@ -1712,7 +3182,17 @@ export class MapComponent { private applyTransform(): void { this.clampPan(); const zoom = this.state.zoom; - this.wrapper.style.transform = `scale(${zoom}) translate(${this.state.pan.x}px, ${this.state.pan.y}px)`; + const width = this.container.clientWidth; + const height = this.container.clientHeight; + + // With transform-origin: 0 0, we need to offset to keep center in view + // Formula: translate first to re-center, then scale + const centerOffsetX = (width / 2) * (1 - zoom); + const centerOffsetY = (height / 2) * (1 - zoom); + const tx = centerOffsetX + this.state.pan.x * zoom; + const ty = centerOffsetY + this.state.pan.y * zoom; + + this.wrapper.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`; // Set CSS variable for counter-scaling labels/markers // Labels: max 1.5x scale, so counter-scale = min(1.5, zoom) / zoom @@ -1766,13 +3246,13 @@ export class MapComponent { } private updateLabelVisibility(zoom: number): void { - const labels = this.overlays.querySelectorAll('.hotspot-label, .earthquake-label, .nuclear-label, .weather-label, .apt-label'); + const labels = this.overlays.querySelectorAll('.hotspot-label, .earthquake-label, .weather-label, .apt-label'); const labelRects: { el: Element; rect: DOMRect; priority: number }[] = []; // Collect all label bounds with priority labels.forEach((label) => { const el = label as HTMLElement; - const parent = el.closest('.hotspot, .earthquake-marker, .nuclear-marker, .weather-marker, .apt-marker'); + const parent = el.closest('.hotspot, .earthquake-marker, .weather-marker, .apt-marker'); // Assign priority based on parent type and level let priority = 1; @@ -1787,9 +3267,6 @@ export class MapComponent { if (parent.classList.contains('extreme')) priority = 5; else if (parent.classList.contains('severe')) priority = 4; else priority = 2; - } else if (parent?.classList.contains('nuclear-marker')) { - if (parent.classList.contains('contested')) priority = 5; - else priority = 3; } // Reset visibility first @@ -1856,20 +3333,53 @@ export class MapComponent { public setZoom(zoom: number): void { this.state.zoom = Math.max(1, Math.min(10, zoom)); this.applyTransform(); + // Ensure base layer is intact after zoom change + this.ensureBaseLayerIntact(); + } + + private ensureBaseLayerIntact(): void { + // Query DOM directly instead of relying on cached d3 selection + const svgNode = this.svg.node(); + const domBaseGroup = svgNode?.querySelector('.map-base'); + const selectionNode = this.baseLayerGroup?.node(); + + // Check for stale selection (d3 reference doesn't match DOM) + if (domBaseGroup && selectionNode !== domBaseGroup) { + console.warn('[Map] Stale base layer selection detected, forcing full rebuild'); + this.baseRendered = false; + this.render(); + return; + } + + // Check for missing countries + const countryCount = domBaseGroup?.querySelectorAll('.country').length ?? 0; + if (countryCount === 0 && this.countryFeatures && this.countryFeatures.length > 0) { + console.warn('[Map] Base layer missing countries, triggering recovery render'); + this.baseRendered = false; + this.render(); + } } public setCenter(lat: number, lon: number): void { + console.log('[Map] setCenter called:', { lat, lon }); const width = this.container.clientWidth; const height = this.container.clientHeight; const projection = this.getProjection(width, height); const pos = projection([lon, lat]); + console.log('[Map] projected pos:', pos, 'container:', { width, height }, 'zoom:', this.state.zoom); if (!pos) return; - const zoom = this.state.zoom; + // Pan formula: after applyTransform() computes tx = centerOffset + pan*zoom, + // and transform is translate(tx,ty) scale(zoom), to center on pos: + // pos*zoom + tx = width/2 → tx = width/2 - pos*zoom + // Solving: (width/2)(1-zoom) + pan*zoom = width/2 - pos*zoom + // → pan = width/2 - pos (independent of zoom) this.state.pan = { - x: width / (2 * zoom) - pos[0], - y: height / (2 * zoom) - pos[1], + x: width / 2 - pos[0], + y: height / 2 - pos[1], }; this.applyTransform(); + // Ensure base layer is intact after pan + this.ensureBaseLayerIntact(); } public setLayers(layers: MapLayers): void { @@ -1911,11 +3421,75 @@ export class MapComponent { this.render(); } + public setCableHealth(healthMap: Record): void { + this.healthByCableId = healthMap; + this.render(); + } + public setProtests(events: SocialUnrestEvent[]): void { this.protests = events; this.render(); } + public setFlightDelays(delays: AirportDelayAlert[]): void { + this.flightDelays = delays; + this.render(); + } + + public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { + this.militaryFlights = flights; + this.militaryFlightClusters = clusters; + this.render(); + } + + public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { + this.militaryVessels = vessels; + this.militaryVesselClusters = clusters; + this.render(); + } + + public setNaturalEvents(events: NaturalEvent[]): void { + this.naturalEvents = events; + this.render(); + } + + public setFires(fires: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }>): void { + this.firmsFireData = fires; + this.render(); + } + + public setTechEvents(events: TechEventMarker[]): void { + this.techEvents = events; + this.render(); + } + + public setCyberThreats(_threats: CyberThreat[]): void { + // SVG/mobile fallback intentionally does not render this layer to stay lightweight. + } + + public setNewsLocations(_data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { + // SVG fallback: news locations rendered as simple circles + // For now, skip on SVG map to keep mobile lightweight + } + + public setTechActivity(activities: TechHubActivity[]): void { + this.techActivities = activities; + this.render(); + } + + public setOnTechHubClick(handler: (hub: TechHubActivity) => void): void { + this.onTechHubClick = handler; + } + + public setGeoActivity(activities: GeoHubActivity[]): void { + this.geoActivities = activities; + this.render(); + } + + public setOnGeoHubClick(handler: (hub: GeoHubActivity) => void): void { + this.onGeoHubClick = handler; + } + private getCableAdvisory(cableId: string): CableAdvisory | undefined { const advisories = this.cableAdvisories.filter((advisory) => advisory.cableId === cableId); return advisories.reduce((latest, advisory) => { diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts new file mode 100644 index 000000000..6d9a2cb91 --- /dev/null +++ b/src/components/MapContainer.ts @@ -0,0 +1,619 @@ +/** + * MapContainer - Conditional map renderer + * Renders DeckGLMap (WebGL) on desktop, fallback to D3/SVG MapComponent on mobile + */ +import { isMobileDevice } from '@/utils'; +import { MapComponent } from './Map'; +import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap'; +import type { + MapLayers, + Hotspot, + NewsItem, + InternetOutage, + RelatedAsset, + AssetType, + AisDisruptionEvent, + AisDensityZone, + CableAdvisory, + RepairShip, + SocialUnrestEvent, + MilitaryFlight, + MilitaryVessel, + MilitaryFlightCluster, + MilitaryVesselCluster, + NaturalEvent, + UcdpGeoEvent, + CyberThreat, + CableHealthRecord, +} from '@/types'; +import type { AirportDelayAlert } from '@/services/aviation'; +import type { DisplacementFlow } from '@/services/displacement'; +import type { Earthquake } from '@/services/earthquakes'; +import type { ClimateAnomaly } from '@/services/climate'; +import type { WeatherAlert } from '@/services/weather'; +import type { PositiveGeoEvent } from '@/services/positive-events-geo'; +import type { KindnessPoint } from '@/services/kindness-data'; +import type { HappinessData } from '@/services/happiness-data'; +import type { SpeciesRecovery } from '@/services/conservation-data'; +import type { RenewableInstallation } from '@/services/renewable-installations'; + +export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; +export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; + +export interface MapContainerState { + zoom: number; + pan: { x: number; y: number }; + view: MapView; + layers: MapLayers; + timeRange: TimeRange; +} + +interface TechEventMarker { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; +} + +/** + * Unified map interface that delegates to either DeckGLMap or MapComponent + * based on device capabilities + */ +export class MapContainer { + private container: HTMLElement; + private isMobile: boolean; + private deckGLMap: DeckGLMap | null = null; + private svgMap: MapComponent | null = null; + private initialState: MapContainerState; + private useDeckGL: boolean; + + constructor(container: HTMLElement, initialState: MapContainerState) { + this.container = container; + this.initialState = initialState; + this.isMobile = isMobileDevice(); + + // Use deck.gl on desktop with WebGL support, SVG on mobile + this.useDeckGL = !this.isMobile && this.hasWebGLSupport(); + + this.init(); + } + + private hasWebGLSupport(): boolean { + try { + const canvas = document.createElement('canvas'); + // deck.gl + maplibre rely on WebGL2 features in desktop mode. + // Some Linux WebKitGTK builds expose only WebGL1, which can lead to + // an empty/black render surface instead of a usable map. + const gl2 = canvas.getContext('webgl2'); + return !!gl2; + } catch { + return false; + } + } + + private initSvgMap(logMessage: string): void { + console.log(logMessage); + this.useDeckGL = false; + this.deckGLMap = null; + this.container.classList.remove('deckgl-mode'); + this.container.classList.add('svg-mode'); + // DeckGLMap mutates DOM early during construction. If initialization throws, + // clear partial deck.gl nodes before creating the SVG fallback. + this.container.innerHTML = ''; + this.svgMap = new MapComponent(this.container, this.initialState); + } + + private init(): void { + if (this.useDeckGL) { + console.log('[MapContainer] Initializing deck.gl map (desktop mode)'); + try { + this.container.classList.add('deckgl-mode'); + this.deckGLMap = new DeckGLMap(this.container, { + ...this.initialState, + view: this.initialState.view as DeckMapView, + }); + } catch (error) { + console.warn('[MapContainer] DeckGL initialization failed, falling back to SVG map', error); + this.initSvgMap('[MapContainer] Initializing SVG map (DeckGL fallback mode)'); + } + } else { + this.initSvgMap('[MapContainer] Initializing SVG map (mobile/fallback mode)'); + } + } + + // Unified public API - delegates to active map implementation + public render(): void { + if (this.useDeckGL) { + this.deckGLMap?.render(); + } else { + this.svgMap?.render(); + } + } + + public setView(view: MapView): void { + if (this.useDeckGL) { + this.deckGLMap?.setView(view as DeckMapView); + } else { + this.svgMap?.setView(view); + } + } + + public setZoom(zoom: number): void { + if (this.useDeckGL) { + this.deckGLMap?.setZoom(zoom); + } else { + this.svgMap?.setZoom(zoom); + } + } + + public setCenter(lat: number, lon: number, zoom?: number): void { + if (this.useDeckGL) { + this.deckGLMap?.setCenter(lat, lon, zoom); + } else { + this.svgMap?.setCenter(lat, lon); + if (zoom != null) this.svgMap?.setZoom(zoom); + } + } + + public getCenter(): { lat: number; lon: number } | null { + if (this.useDeckGL) { + return this.deckGLMap?.getCenter() ?? null; + } + return this.svgMap?.getCenter() ?? null; + } + + public setTimeRange(range: TimeRange): void { + if (this.useDeckGL) { + this.deckGLMap?.setTimeRange(range); + } else { + this.svgMap?.setTimeRange(range); + } + } + + public getTimeRange(): TimeRange { + if (this.useDeckGL) { + return this.deckGLMap?.getTimeRange() ?? '7d'; + } + return this.svgMap?.getTimeRange() ?? '7d'; + } + + public setLayers(layers: MapLayers): void { + if (this.useDeckGL) { + this.deckGLMap?.setLayers(layers); + } else { + this.svgMap?.setLayers(layers); + } + } + + public getState(): MapContainerState { + if (this.useDeckGL) { + const state = this.deckGLMap?.getState(); + return state ? { ...state, view: state.view as MapView } : this.initialState; + } + return this.svgMap?.getState() ?? this.initialState; + } + + // Data setters + public setEarthquakes(earthquakes: Earthquake[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setEarthquakes(earthquakes); + } else { + this.svgMap?.setEarthquakes(earthquakes); + } + } + + public setWeatherAlerts(alerts: WeatherAlert[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setWeatherAlerts(alerts); + } else { + this.svgMap?.setWeatherAlerts(alerts); + } + } + + public setOutages(outages: InternetOutage[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setOutages(outages); + } else { + this.svgMap?.setOutages(outages); + } + } + + public setAisData(disruptions: AisDisruptionEvent[], density: AisDensityZone[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setAisData(disruptions, density); + } else { + this.svgMap?.setAisData(disruptions, density); + } + } + + public setCableActivity(advisories: CableAdvisory[], repairShips: RepairShip[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setCableActivity(advisories, repairShips); + } else { + this.svgMap?.setCableActivity(advisories, repairShips); + } + } + + public setCableHealth(healthMap: Record): void { + if (this.useDeckGL) { + this.deckGLMap?.setCableHealth(healthMap); + } else { + this.svgMap?.setCableHealth(healthMap); + } + } + + public setProtests(events: SocialUnrestEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setProtests(events); + } else { + this.svgMap?.setProtests(events); + } + } + + public setFlightDelays(delays: AirportDelayAlert[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setFlightDelays(delays); + } else { + this.svgMap?.setFlightDelays(delays); + } + } + + public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { + if (this.useDeckGL) { + this.deckGLMap?.setMilitaryFlights(flights, clusters); + } else { + this.svgMap?.setMilitaryFlights(flights, clusters); + } + } + + public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { + if (this.useDeckGL) { + this.deckGLMap?.setMilitaryVessels(vessels, clusters); + } else { + this.svgMap?.setMilitaryVessels(vessels, clusters); + } + } + + public setNaturalEvents(events: NaturalEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setNaturalEvents(events); + } else { + this.svgMap?.setNaturalEvents(events); + } + } + + public setFires(fires: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }>): void { + if (this.useDeckGL) { + this.deckGLMap?.setFires(fires); + } else { + this.svgMap?.setFires(fires); + } + } + + public setTechEvents(events: TechEventMarker[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setTechEvents(events); + } else { + this.svgMap?.setTechEvents(events); + } + } + + public setUcdpEvents(events: UcdpGeoEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setUcdpEvents(events); + } + } + + public setDisplacementFlows(flows: DisplacementFlow[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setDisplacementFlows(flows); + } + } + + public setClimateAnomalies(anomalies: ClimateAnomaly[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setClimateAnomalies(anomalies); + } + } + + public setCyberThreats(threats: CyberThreat[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setCyberThreats(threats); + } else { + this.svgMap?.setCyberThreats(threats); + } + } + + public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { + if (this.useDeckGL) { + this.deckGLMap?.setNewsLocations(data); + } else { + this.svgMap?.setNewsLocations(data); + } + } + + public setPositiveEvents(events: PositiveGeoEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setPositiveEvents(events); + } + // SVG map does not support positive events layer + } + + public setKindnessData(points: KindnessPoint[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setKindnessData(points); + } + // SVG map does not support kindness layer + } + + public setHappinessScores(data: HappinessData): void { + if (this.useDeckGL) { + this.deckGLMap?.setHappinessScores(data); + } + // SVG map does not support choropleth overlay + } + + public setSpeciesRecoveryZones(species: SpeciesRecovery[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setSpeciesRecoveryZones(species); + } + // SVG map does not support species recovery layer + } + + public setRenewableInstallations(installations: RenewableInstallation[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setRenewableInstallations(installations); + } + // SVG map does not support renewable installations layer + } + + public updateHotspotActivity(news: NewsItem[]): void { + if (this.useDeckGL) { + this.deckGLMap?.updateHotspotActivity(news); + } else { + this.svgMap?.updateHotspotActivity(news); + } + } + + public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void { + if (this.useDeckGL) { + this.deckGLMap?.updateMilitaryForEscalation(flights, vessels); + } else { + this.svgMap?.updateMilitaryForEscalation(flights, vessels); + } + } + + public getHotspotDynamicScore(hotspotId: string) { + if (this.useDeckGL) { + return this.deckGLMap?.getHotspotDynamicScore(hotspotId); + } + return this.svgMap?.getHotspotDynamicScore(hotspotId); + } + + public highlightAssets(assets: RelatedAsset[] | null): void { + if (this.useDeckGL) { + this.deckGLMap?.highlightAssets(assets); + } else { + this.svgMap?.highlightAssets(assets); + } + } + + // Callback setters - MapComponent uses different names + public onHotspotClicked(callback: (hotspot: Hotspot) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnHotspotClick(callback); + } else { + this.svgMap?.onHotspotClicked(callback); + } + } + + public onTimeRangeChanged(callback: (range: TimeRange) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnTimeRangeChange(callback); + } else { + this.svgMap?.onTimeRangeChanged(callback); + } + } + + public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnLayerChange(callback); + } else { + this.svgMap?.setOnLayerChange(callback); + } + } + + public onStateChanged(callback: (state: MapContainerState) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnStateChange((state) => { + callback({ ...state, view: state.view as MapView }); + }); + } else { + this.svgMap?.onStateChanged(callback); + } + } + + public getHotspotLevels(): Record { + if (this.useDeckGL) { + return this.deckGLMap?.getHotspotLevels() ?? {}; + } + return this.svgMap?.getHotspotLevels() ?? {}; + } + + public setHotspotLevels(levels: Record): void { + if (this.useDeckGL) { + this.deckGLMap?.setHotspotLevels(levels); + } else { + this.svgMap?.setHotspotLevels(levels); + } + } + + public initEscalationGetters(): void { + if (this.useDeckGL) { + this.deckGLMap?.initEscalationGetters(); + } else { + this.svgMap?.initEscalationGetters(); + } + } + + // UI visibility methods + public hideLayerToggle(layer: keyof MapLayers): void { + if (this.useDeckGL) { + this.deckGLMap?.hideLayerToggle(layer); + } else { + this.svgMap?.hideLayerToggle(layer); + } + } + + public setLayerLoading(layer: keyof MapLayers, loading: boolean): void { + if (this.useDeckGL) { + this.deckGLMap?.setLayerLoading(layer, loading); + } else { + this.svgMap?.setLayerLoading(layer, loading); + } + } + + public setLayerReady(layer: keyof MapLayers, hasData: boolean): void { + if (this.useDeckGL) { + this.deckGLMap?.setLayerReady(layer, hasData); + } else { + this.svgMap?.setLayerReady(layer, hasData); + } + } + + public flashAssets(assetType: AssetType, ids: string[]): void { + if (this.useDeckGL) { + this.deckGLMap?.flashAssets(assetType, ids); + } + // SVG map doesn't have flashAssets - only supported in deck.gl mode + } + + // Layer enable/disable and trigger methods + public enableLayer(layer: keyof MapLayers): void { + if (this.useDeckGL) { + this.deckGLMap?.enableLayer(layer); + } else { + this.svgMap?.enableLayer(layer); + } + } + + public triggerHotspotClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerHotspotClick(id); + } else { + this.svgMap?.triggerHotspotClick(id); + } + } + + public triggerConflictClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerConflictClick(id); + } else { + this.svgMap?.triggerConflictClick(id); + } + } + + public triggerBaseClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerBaseClick(id); + } else { + this.svgMap?.triggerBaseClick(id); + } + } + + public triggerPipelineClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerPipelineClick(id); + } else { + this.svgMap?.triggerPipelineClick(id); + } + } + + public triggerCableClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerCableClick(id); + } else { + this.svgMap?.triggerCableClick(id); + } + } + + public triggerDatacenterClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerDatacenterClick(id); + } else { + this.svgMap?.triggerDatacenterClick(id); + } + } + + public triggerNuclearClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerNuclearClick(id); + } else { + this.svgMap?.triggerNuclearClick(id); + } + } + + public triggerIrradiatorClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerIrradiatorClick(id); + } else { + this.svgMap?.triggerIrradiatorClick(id); + } + } + + public flashLocation(lat: number, lon: number, durationMs?: number): void { + if (this.useDeckGL) { + this.deckGLMap?.flashLocation(lat, lon, durationMs); + } else { + this.svgMap?.flashLocation(lat, lon, durationMs); + } + } + + // Country click + highlight (deck.gl only) + public onCountryClicked(callback: (country: CountryClickPayload) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnCountryClick(callback); + } + } + + public highlightCountry(code: string): void { + if (this.useDeckGL) { + this.deckGLMap?.highlightCountry(code); + } + } + + public clearCountryHighlight(): void { + if (this.useDeckGL) { + this.deckGLMap?.clearCountryHighlight(); + } + } + + public setRenderPaused(paused: boolean): void { + if (this.useDeckGL) { + this.deckGLMap?.setRenderPaused(paused); + } + } + + // Utility methods + public isDeckGLMode(): boolean { + return this.useDeckGL; + } + + public isMobileMode(): boolean { + return this.isMobile; + } + + public destroy(): void { + if (this.useDeckGL) { + this.deckGLMap?.destroy(); + } else { + this.svgMap?.destroy(); + } + } +} diff --git a/src/components/MapPopup.ts b/src/components/MapPopup.ts index 01f0c64b9..9fa5552a5 100644 --- a/src/components/MapPopup.ts +++ b/src/components/MapPopup.ts @@ -1,12 +1,126 @@ -import type { ConflictZone, Hotspot, Earthquake, NewsItem, MilitaryBase, StrategicWaterway, APTGroup, NuclearFacility, EconomicCenter, GammaIrradiator, Pipeline, UnderseaCable, CableAdvisory, RepairShip, InternetOutage, AIDataCenter, AisDisruptionEvent, SocialUnrestEvent } from '@/types'; +import type { ConflictZone, Hotspot, NewsItem, MilitaryBase, StrategicWaterway, APTGroup, NuclearFacility, EconomicCenter, GammaIrradiator, Pipeline, UnderseaCable, CableAdvisory, RepairShip, InternetOutage, AIDataCenter, AisDisruptionEvent, SocialUnrestEvent, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, NaturalEvent, Port, Spaceport, CriticalMineralProject, CyberThreat } from '@/types'; +import type { AirportDelayAlert } from '@/services/aviation'; +import type { Earthquake } from '@/services/earthquakes'; import type { WeatherAlert } from '@/services/weather'; import { UNDERSEA_CABLES } from '@/config'; +import type { StartupHub, Accelerator, TechHQ, CloudRegion } from '@/config/tech-geo'; +import type { TechHubActivity } from '@/services/tech-activity'; +import type { GeoHubActivity } from '@/services/geo-activity'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { isMobileDevice, getCSSColor } from '@/utils'; +import { t } from '@/services/i18n'; +import { fetchHotspotContext, formatArticleDate, extractDomain, type GdeltArticle } from '@/services/gdelt-intel'; +import { getNaturalEventIcon } from '@/services/eonet'; +import { getHotspotEscalation, getEscalationChange24h } from '@/services/hotspot-escalation'; +import { getCableHealthRecord } from '@/services/cable-health'; -export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'ais' | 'protest'; +export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub'; + +interface TechEventPopupData { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; +} + +interface TechHQClusterData { + items: TechHQ[]; + city: string; + country: string; + count?: number; + faangCount?: number; + unicornCount?: number; + publicCount?: number; + sampled?: boolean; +} + +interface TechEventClusterData { + items: TechEventPopupData[]; + location: string; + country: string; + count?: number; + soonCount?: number; + sampled?: boolean; +} + +// Finance popup data types +interface StockExchangePopupData { + id: string; + name: string; + shortName: string; + city: string; + country: string; + tier: string; + marketCap?: number; + tradingHours?: string; + timezone?: string; + description?: string; +} + +interface FinancialCenterPopupData { + id: string; + name: string; + city: string; + country: string; + type: string; + gfciRank?: number; + specialties?: string[]; + description?: string; +} + +interface CentralBankPopupData { + id: string; + name: string; + shortName: string; + city: string; + country: string; + type: string; + currency?: string; + description?: string; +} + +interface CommodityHubPopupData { + id: string; + name: string; + city: string; + country: string; + type: string; + commodities?: string[]; + description?: string; +} + +interface ProtestClusterData { + items: SocialUnrestEvent[]; + country: string; + count?: number; + riotCount?: number; + highSeverityCount?: number; + verifiedCount?: number; + totalFatalities?: number; + sampled?: boolean; +} + +interface DatacenterClusterData { + items: AIDataCenter[]; + region: string; + country: string; + count?: number; + totalChips?: number; + totalPowerMW?: number; + existingCount?: number; + plannedCount?: number; + sampled?: boolean; +} interface PopupData { type: PopupType; - data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent; + data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData; relatedNews?: NewsItem[]; x: number; y: number; @@ -18,6 +132,11 @@ export class MapPopup { private onClose?: () => void; private cableAdvisories: CableAdvisory[] = []; private repairShips: RepairShip[] = []; + private isMobileSheet = false; + private sheetTouchStartY: number | null = null; + private sheetCurrentOffset = 0; + private readonly mobileDismissThreshold = 96; + private outsideListenerTimeoutId: number | null = null; constructor(container: HTMLElement) { this.container = container; @@ -26,40 +145,192 @@ export class MapPopup { public show(data: PopupData): void { this.hide(); + this.isMobileSheet = isMobileDevice(); this.popup = document.createElement('div'); - this.popup.className = 'map-popup'; + this.popup.className = this.isMobileSheet ? 'map-popup map-popup-sheet' : 'map-popup'; const content = this.renderContent(data); - this.popup.innerHTML = content; + this.popup.innerHTML = this.isMobileSheet + ? `${content}` + : content; - // Position popup - const maxX = this.container.clientWidth - 400; - const maxY = this.container.clientHeight - 300; - this.popup.style.left = `${Math.min(data.x + 20, maxX)}px`; - this.popup.style.top = `${Math.min(data.y - 20, maxY)}px`; + // Get container's viewport position for absolute positioning + const containerRect = this.container.getBoundingClientRect(); + + if (this.isMobileSheet) { + this.popup.style.left = ''; + this.popup.style.top = ''; + this.popup.style.transform = ''; + } else { + this.positionDesktopPopup(data, containerRect); + } - this.container.appendChild(this.popup); + // Append to body to avoid container overflow clipping + document.body.appendChild(this.popup); // Close button handler this.popup.querySelector('.popup-close')?.addEventListener('click', () => this.hide()); + this.popup.querySelector('.map-popup-sheet-handle')?.addEventListener('click', () => this.hide()); + + if (this.isMobileSheet) { + this.popup.addEventListener('touchstart', this.handleSheetTouchStart, { passive: true }); + this.popup.addEventListener('touchmove', this.handleSheetTouchMove, { passive: false }); + this.popup.addEventListener('touchend', this.handleSheetTouchEnd); + this.popup.addEventListener('touchcancel', this.handleSheetTouchEnd); + requestAnimationFrame(() => { + if (!this.popup) return; + this.popup.classList.add('open'); + // Remove will-change after slide-in transition to free GPU memory + this.popup.addEventListener('transitionend', () => { + if (this.popup) this.popup.style.willChange = 'auto'; + }, { once: true }); + }); + } // Click outside to close - setTimeout(() => { + if (this.outsideListenerTimeoutId !== null) { + window.clearTimeout(this.outsideListenerTimeoutId); + } + this.outsideListenerTimeoutId = window.setTimeout(() => { document.addEventListener('click', this.handleOutsideClick); - }, 100); + document.addEventListener('touchstart', this.handleOutsideClick); + document.addEventListener('keydown', this.handleEscapeKey); + this.outsideListenerTimeoutId = null; + }, 0); + } + + private positionDesktopPopup(data: PopupData, containerRect: DOMRect): void { + if (!this.popup) return; + + const popupWidth = 380; + const bottomBuffer = 50; // Buffer from viewport bottom + const topBuffer = 60; // Header height + + // Temporarily append popup off-screen to measure actual height + this.popup.style.visibility = 'hidden'; + this.popup.style.top = '0'; + this.popup.style.left = '-9999px'; + document.body.appendChild(this.popup); + const popupHeight = this.popup.offsetHeight; + document.body.removeChild(this.popup); + this.popup.style.visibility = ''; + + // Convert container-relative coords to viewport coords + const viewportX = containerRect.left + data.x; + const viewportY = containerRect.top + data.y; + + // Horizontal positioning (viewport-relative) + const maxX = window.innerWidth - popupWidth - 20; + let left = viewportX + 20; + if (left > maxX) { + // Position to the left of click if it would overflow right + left = Math.max(10, viewportX - popupWidth - 20); + } + + // Vertical positioning - prefer below click, but flip above if needed + const availableBelow = window.innerHeight - viewportY - bottomBuffer; + const availableAbove = viewportY - topBuffer; + + let top: number; + if (availableBelow >= popupHeight) { + // Enough space below - position below click + top = viewportY + 10; + } else if (availableAbove >= popupHeight) { + // Not enough below, but enough above - position above click + top = viewportY - popupHeight - 10; + } else { + // Limited space both ways - position at top buffer + top = topBuffer; + } + + // CRITICAL: Ensure popup stays within viewport vertically + top = Math.max(topBuffer, top); + const maxTop = window.innerHeight - popupHeight - bottomBuffer; + if (maxTop > topBuffer) { + top = Math.min(top, maxTop); + } + + this.popup.style.left = `${left}px`; + this.popup.style.top = `${top}px`; } - private handleOutsideClick = (e: MouseEvent) => { + private handleOutsideClick = (e: Event) => { if (this.popup && !this.popup.contains(e.target as Node)) { this.hide(); } }; + private handleEscapeKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + this.hide(); + } + }; + + private handleSheetTouchStart = (e: TouchEvent): void => { + if (!this.popup || !this.isMobileSheet || e.touches.length !== 1) return; + + const target = e.target as HTMLElement | null; + const popupBody = this.popup.querySelector('.popup-body'); + if (target?.closest('.popup-body') && popupBody && popupBody.scrollTop > 0) { + this.sheetTouchStartY = null; + return; + } + + this.sheetTouchStartY = e.touches[0]?.clientY ?? null; + this.sheetCurrentOffset = 0; + this.popup.classList.add('dragging'); + }; + + private handleSheetTouchMove = (e: TouchEvent): void => { + if (!this.popup || !this.isMobileSheet || this.sheetTouchStartY === null) return; + + const currentY = e.touches[0]?.clientY; + if (currentY == null) return; + + const delta = Math.max(0, currentY - this.sheetTouchStartY); + if (delta <= 0) return; + + this.sheetCurrentOffset = delta; + this.popup.style.transform = `translate3d(0, ${delta}px, 0)`; + e.preventDefault(); + }; + + private handleSheetTouchEnd = (): void => { + if (!this.popup || !this.isMobileSheet || this.sheetTouchStartY === null) return; + + const shouldDismiss = this.sheetCurrentOffset >= this.mobileDismissThreshold; + this.popup.classList.remove('dragging'); + this.sheetTouchStartY = null; + + if (shouldDismiss) { + this.hide(); + return; + } + + this.sheetCurrentOffset = 0; + this.popup.style.transform = ''; + this.popup.classList.add('open'); + }; + public hide(): void { + if (this.outsideListenerTimeoutId !== null) { + window.clearTimeout(this.outsideListenerTimeoutId); + this.outsideListenerTimeoutId = null; + } + if (this.popup) { + this.popup.removeEventListener('touchstart', this.handleSheetTouchStart); + this.popup.removeEventListener('touchmove', this.handleSheetTouchMove); + this.popup.removeEventListener('touchend', this.handleSheetTouchEnd); + this.popup.removeEventListener('touchcancel', this.handleSheetTouchEnd); this.popup.remove(); this.popup = null; + this.isMobileSheet = false; + this.sheetTouchStartY = null; + this.sheetCurrentOffset = 0; document.removeEventListener('click', this.handleOutsideClick); + document.removeEventListener('touchstart', this.handleOutsideClick); + document.removeEventListener('keydown', this.handleEscapeKey); this.onClose?.(); } } @@ -89,6 +360,8 @@ export class MapPopup { return this.renderWaterwayPopup(data.data as StrategicWaterway); case 'apt': return this.renderAPTPopup(data.data as APTGroup); + case 'cyberThreat': + return this.renderCyberThreatPopup(data.data as CyberThreat); case 'nuclear': return this.renderNuclearPopup(data.data as NuclearFacility); case 'economic': @@ -107,10 +380,54 @@ export class MapPopup { return this.renderOutagePopup(data.data as InternetOutage); case 'datacenter': return this.renderDatacenterPopup(data.data as AIDataCenter); + case 'datacenterCluster': + return this.renderDatacenterClusterPopup(data.data as DatacenterClusterData); case 'ais': return this.renderAisPopup(data.data as AisDisruptionEvent); case 'protest': return this.renderProtestPopup(data.data as SocialUnrestEvent); + case 'protestCluster': + return this.renderProtestClusterPopup(data.data as ProtestClusterData); + case 'flight': + return this.renderFlightPopup(data.data as AirportDelayAlert); + case 'militaryFlight': + return this.renderMilitaryFlightPopup(data.data as MilitaryFlight); + case 'militaryVessel': + return this.renderMilitaryVesselPopup(data.data as MilitaryVessel); + case 'militaryFlightCluster': + return this.renderMilitaryFlightClusterPopup(data.data as MilitaryFlightCluster); + case 'militaryVesselCluster': + return this.renderMilitaryVesselClusterPopup(data.data as MilitaryVesselCluster); + case 'natEvent': + return this.renderNaturalEventPopup(data.data as NaturalEvent); + case 'port': + return this.renderPortPopup(data.data as Port); + case 'spaceport': + return this.renderSpaceportPopup(data.data as Spaceport); + case 'mineral': + return this.renderMineralPopup(data.data as CriticalMineralProject); + case 'startupHub': + return this.renderStartupHubPopup(data.data as StartupHub); + case 'cloudRegion': + return this.renderCloudRegionPopup(data.data as CloudRegion); + case 'techHQ': + return this.renderTechHQPopup(data.data as TechHQ); + case 'accelerator': + return this.renderAcceleratorPopup(data.data as Accelerator); + case 'techEvent': + return this.renderTechEventPopup(data.data as TechEventPopupData); + case 'techHQCluster': + return this.renderTechHQClusterPopup(data.data as TechHQClusterData); + case 'techEventCluster': + return this.renderTechEventClusterPopup(data.data as TechEventClusterData); + case 'stockExchange': + return this.renderStockExchangePopup(data.data as StockExchangePopupData); + case 'financialCenter': + return this.renderFinancialCenterPopup(data.data as FinancialCenterPopupData); + case 'centralBank': + return this.renderCentralBankPopup(data.data as CentralBankPopupData); + case 'commodityHub': + return this.renderCommodityHubPopup(data.data as CommodityHubPopupData); default: return ''; } @@ -118,47 +435,47 @@ export class MapPopup { private renderConflictPopup(conflict: ConflictZone): string { const severityClass = conflict.intensity === 'high' ? 'high' : conflict.intensity === 'medium' ? 'medium' : 'low'; - const severityLabel = conflict.intensity?.toUpperCase() || 'UNKNOWN'; + const severityLabel = escapeHtml(conflict.intensity?.toUpperCase() || t('popups.unknown').toUpperCase()); return ` `; } + private renderProtestClusterPopup(data: ProtestClusterData): string { + const totalCount = data.count ?? data.items.length; + const riots = data.riotCount ?? data.items.filter(e => e.eventType === 'riot').length; + const highSeverity = data.highSeverityCount ?? data.items.filter(e => e.severity === 'high').length; + const verified = data.verifiedCount ?? data.items.filter(e => e.validated).length; + const totalFatalities = data.totalFatalities ?? data.items.reduce((sum, e) => sum + (e.fatalities || 0), 0); + + const sortedItems = [...data.items].sort((a, b) => { + const severityOrder: Record = { high: 0, medium: 1, low: 2 }; + const typeOrder: Record = { riot: 0, civil_unrest: 1, strike: 2, demonstration: 3, protest: 4 }; + const sevDiff = (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3); + if (sevDiff !== 0) return sevDiff; + return (typeOrder[a.eventType] ?? 5) - (typeOrder[b.eventType] ?? 5); + }); + + const listItems = sortedItems.slice(0, 10).map(event => { + const icon = event.eventType === 'riot' ? '🔥' : event.eventType === 'strike' ? '✊' : '📢'; + const sevClass = event.severity; + const dateStr = event.time.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const city = event.city ? escapeHtml(event.city) : ''; + const title = event.title ? `: ${escapeHtml(event.title.slice(0, 40))}${event.title.length > 40 ? '...' : ''}` : ''; + return `
  • ${icon} ${dateStr}${city ? ` • ${city}` : ''}${title}
  • `; + }).join(''); + + const renderedCount = Math.min(10, data.items.length); + const remainingCount = Math.max(0, totalCount - renderedCount); + const moreCount = remainingCount > 0 ? `
  • +${remainingCount} ${t('popups.moreEvents')}
  • ` : ''; + const headerClass = highSeverity > 0 ? 'high' : riots > 0 ? 'medium' : 'low'; + + return ` + + + `; + } + + private renderFlightPopup(delay: AirportDelayAlert): string { + const severityClass = escapeHtml(delay.severity); + const severityLabel = escapeHtml(delay.severity.toUpperCase()); + const delayTypeLabels: Record = { + 'ground_stop': t('popups.flight.groundStop'), + 'ground_delay': t('popups.flight.groundDelay'), + 'departure_delay': t('popups.flight.departureDelay'), + 'arrival_delay': t('popups.flight.arrivalDelay'), + 'general': t('popups.flight.delaysReported'), + }; + const delayTypeLabel = delayTypeLabels[delay.delayType] || t('popups.flight.delays'); + const icon = delay.delayType === 'ground_stop' ? '🛑' : delay.severity === 'severe' ? '✈️' : '🛫'; + const sourceLabels: Record = { + 'faa': t('popups.flight.sources.faa'), + 'eurocontrol': t('popups.flight.sources.eurocontrol'), + 'computed': t('popups.flight.sources.computed'), + }; + const sourceLabel = sourceLabels[delay.source] || escapeHtml(delay.source); + const regionLabels: Record = { + 'americas': t('popups.flight.regions.americas'), + 'europe': t('popups.flight.regions.europe'), + 'apac': t('popups.flight.regions.apac'), + 'mena': t('popups.flight.regions.mena'), + 'africa': t('popups.flight.regions.africa'), + }; + const regionLabel = regionLabels[delay.region] || escapeHtml(delay.region); + + const avgDelaySection = delay.avgDelayMinutes > 0 + ? `` + : ''; + const reasonSection = delay.reason + ? `` + : ''; + const cancelledSection = delay.cancelledFlights + ? `` + : ''; + + return ` + + + `; + } + private renderAPTPopup(apt: APTGroup): string { return ` + `; + } + + + private renderCyberThreatPopup(threat: CyberThreat): string { + const severityClass = escapeHtml(threat.severity); + const sourceLabels: Record = { + feodo: 'Feodo Tracker', + urlhaus: 'URLhaus', + c2intel: 'C2 Intel Feeds', + otx: 'AlienVault OTX', + abuseipdb: 'AbuseIPDB', + }; + const sourceLabel = sourceLabels[threat.source] || threat.source; + const typeLabel = threat.type.replace(/_/g, ' ').toUpperCase(); + const tags = (threat.tags || []).slice(0, 6); + + return ` + + `; } private renderNuclearPopup(facility: NuclearFacility): string { const typeLabels: Record = { - 'plant': 'POWER PLANT', - 'enrichment': 'ENRICHMENT', - 'weapons': 'WEAPONS COMPLEX', - 'research': 'RESEARCH', + 'plant': t('popups.nuclear.types.plant'), + 'enrichment': t('popups.nuclear.types.enrichment'), + 'weapons': t('popups.nuclear.types.weapons'), + 'research': t('popups.nuclear.types.research'), }; const statusColors: Record = { 'active': 'elevated', @@ -476,35 +1160,35 @@ export class MapPopup { return ` `; } private renderEconomicPopup(center: EconomicCenter): string { const typeLabels: Record = { - 'exchange': 'STOCK EXCHANGE', - 'central-bank': 'CENTRAL BANK', - 'financial-hub': 'FINANCIAL HUB', + 'exchange': t('popups.economic.types.exchange'), + 'central-bank': t('popups.economic.types.centralBank'), + 'financial-hub': t('popups.economic.types.financialHub'), }; const typeIcons: Record = { 'exchange': '📈', @@ -513,32 +1197,39 @@ export class MapPopup { }; const marketStatus = center.marketHours ? this.getMarketStatus(center.marketHours) : null; + const marketStatusLabel = marketStatus + ? marketStatus === 'open' + ? t('popups.open') + : marketStatus === 'closed' + ? t('popups.economic.closed') + : t('popups.unknown') + : ''; return `