fix(cyber): make GeoIP centroid fallback jitter deterministic#458
fix(cyber): make GeoIP centroid fallback jitter deterministic#458fuleinist wants to merge 1273 commits intokoala73:mainfrom
Conversation
…koala73#124) ## Summary Extracts shared summarization logic (CORS, validation, caching, prompt building) into a reusable `_summarize-handler.js` factory, then uses it to add Ollama as the first provider in the fallback chain. This reduces code duplication across Groq, OpenRouter, and the new Ollama endpoint while maintaining identical behavior. **Fallback chain is now:** Ollama → Groq → OpenRouter → Browser T5 ## Type of change - [x] New feature - [x] Refactor / code cleanup - [x] API endpoints (`/api/*`) ## Affected areas - [x] AI Insights / World Brief - [x] Desktop app (Tauri) - [x] API endpoints (`/api/*`) - [x] Config / Settings ## Changes ### New Files - **`api/_summarize-handler.js`** – Shared handler factory with: - `createSummarizeHandler(providerConfig)` – Creates edge handlers for any LLM provider - `getCacheKey()` – Stable cache key generation (extracted from per-provider code) - `deduplicateHeadlines()` – Headline deduplication logic (extracted from per-provider code) - Unified prompt building for brief/analysis/translate modes with tech/full variants - CORS, validation, caching, and error handling pipeline - **`api/ollama-summarize.js`** – New Ollama endpoint (34 lines): - Calls local/remote Ollama instance via OpenAI-compatible `/v1/chat/completions` - Reads `OLLAMA_API_URL` and `OLLAMA_MODEL` from environment - Shares Redis cache with Groq/OpenRouter (same cache key strategy) - Returns fallback signal when unconfigured or API fails - **`api/ollama-summarize.test.mjs`** – Unit tests for Ollama endpoint: - Fallback signal when `OLLAMA_API_URL` not configured - Success response with provider="ollama" - Error handling (API errors, empty responses) - Model selection via `OLLAMA_MODEL` env - **`api/_summarize-handler.test.mjs`** – Unit tests for shared factory: - Cache key stability and variation by mode/variant/lang/geoContext - Headline deduplication logic - Handler creation with missing credentials - API provider calls and response shaping - **`tests/summarization-chain.test.mjs`** – Integration tests for fallback chain: - Ollama success short-circuits (no downstream calls) - Ollama failure → Groq success - Both fail → fallback signals propagate ### Modified Files **`api/groq-summarize.js`** & **`api/openrouter-summarize.js`** - Replaced ~290 lines of duplicated logic with single call to `createSummarizeHandler()` - Now thin wrappers: 26 lines (Groq) and 28 lines (OpenRouter) - Identical behavior, zero functional changes **`src/services/summarization.ts`** - Updated fallback chain: `Ollama → Groq → OpenRouter → Browser T5` - Refactored `tryGroq()` and `tryOpenRouter()` into unified `tryApiProvider()` function - Added `API_PROVIDERS` config array for provider ordering - Updated `SummarizationProvider` type to include `'ollama'` **`src-tauri/sidecar/local-api-server.mjs`** - Added `OLLAMA_API_URL` and `OLLAMA_MODEL` to `ALLOWED_ENV_KEYS` - Added validation for `OLLAMA_API_URL` in `validateSecretAgainstProvider()`: - Probes `/v1/models` (OpenAI-compatible endpoint) - Falls back to `/api/tags` (native Ollama endpoint) - Returns "Ollama endpoint verified" or "Ollama endpoint verified (native API
…buffers Toggling cables/pipelines off then on caused deck.gl assertion failure because the cached PathLayer had its WebGL resources destroyed on removal.
…USNI News, Oryx OSINT, UK MOD)
…keychain vault - Ollama/LM Studio integration with auto model discovery and 4-tier fallback chain - Settings window split into LLMs, API Keys, and Debug tabs - Consolidated keychain vault (1 OS prompt instead of 20+) - README expanded with privacy architecture, summarization chain docs - CHANGELOG updated with full v2.5.0 release notes - 5 new defense/intel RSS feeds, Koeberg nuclear plant added
Removes circular dev→prod dependency. The new polymarketPlugin() mirrors the edge function logic locally: validates params, fetches from gamma-api.polymarket.com, and gracefully returns [] when Cloudflare JA3 blocks server-side TLS (expected behavior).
The Vite dev plugin was hardcoding `{ videoId: null }` with a TODO,
causing LiveNewsPanel to never resolve actual live streams during local
development. Replace the stub with the same fetch-and-scrape approach
used by the production edge function (api/youtube/live.js): fetch the
channel's /live page and extract videoId + isLive from the HTML.
https://claude.ai/code/session_01684qa7XvS7sf9CShqU8zNg
Store the interval ID returned by setInterval in a new clockIntervalId field and clear it in App.destroy(). Previously, the interval was never stored or cleared, causing DOM writes to double on each Vite HMR reload. https://claude.ai/code/session_0111CXxXM5qKR83UAdUTQDyL
npm install regenerated the lockfile to reflect the current version (2.5.0) and license field from package.json. https://claude.ai/code/session_01684qa7XvS7sf9CShqU8zNg
…eived load Inline a minimal HTML skeleton (header bar, map placeholder, panels grid) with critical CSS directly in index.html so the page shows a structured layout immediately instead of a blank screen while JavaScript boots. - Dark theme skeleton with hardcoded colors matching CSS variables - Light theme support via [data-theme="light"] selectors - Shimmer animation on placeholder lines for visual feedback - 6 skeleton panels in a responsive grid matching the real layout - Map section with radial gradient matching the map background - Skeleton is automatically replaced when App.renderLayout() sets innerHTML - Marked aria-hidden="true" for screen reader accessibility Expected gain: perceived load time drops to <0.5s. https://claude.ai/code/session_01Fxk8GMRn2cEUq3ThC2a8e5
Replace the static LOCALE_LOADERS map (14 explicit dynamic imports) with import.meta.glob for lazy loading. English is now statically imported as the always-needed fallback; all other 13 locales are loaded on demand. - Statically import en.json so it's bundled eagerly (no extra fetch) - Use import.meta.glob with negative pattern to lazy-load non-English locales only when the user actually switches language - Add manualChunks rule to prefix lazy locale chunks with `locale-` - Exclude locale-*.js from service worker precache via globIgnores - Add CacheFirst runtime caching for locale files when loaded on demand SW precache reduced from 43 entries (5587 KiB) to 29 entries (4840 KiB), saving ~747 KiB from the initial download. https://claude.ai/code/session_01TfRgC5GWsv51swxRSGxxeJ
Audit and fix localization coverage gaps across 12 components that were using hardcoded English strings instead of the i18next t() system. Components fixed: - IntelligenceGapBadge.ts: context menu label - InvestmentsPanel.ts: filters, table headers, sector labels, statuses - Panel.ts: aria-label, resize tooltip, settings button - LanguageSelector.ts: aria-label - ServiceStatusPanel.ts: loading state, category filters, status labels - TechEventsPanel.ts: tab labels, stats, badges - StrategicRiskPanel.ts: risk metrics, section titles, time formatting - RegulationPanel.ts: dashboard title, tabs, section headers, stances - TechReadinessPanel.ts: fetching state indicators, source attribution - InsightsPanel.ts: progress steps, empty states - VerificationChecklist.ts: all UI labels and verdict text - LiveNewsPanel.ts: offline/error messages Added ~150 new translation keys to en.json and propagated them as English placeholders to all 13 other locale files. https://claude.ai/code/session_018UKmgomYsVsEmJfX7Ava3v
Convert VERIFICATION_TEMPLATE module-level constant to getVerificationTemplate() function to defer t() calls, and add 8 new i18n keys for checklist item labels. https://claude.ai/code/session_018UKmgomYsVsEmJfX7Ava3v
Add contain: content to the .panel class so the browser knows that layout changes inside a panel don't affect siblings. The .panel-content class already has contain: layout style for scroll performance. Expected gain: faster layout recalculations during panel updates. https://claude.ai/code/session_01E9FpgiebjEuUPhNt8mwX9U
Add will-change: transform, opacity to dragged panels, signal modal, and mobile warning modal. Add will-change: transform to map markers and map popup sheet. Remove will-change via animationend/transitionend listeners after one-shot animations complete to free GPU memory. https://claude.ai/code/session_01DDhT6Ex596eX1CtSb6mHdH
Fix ignoreErrors regex for unsafe-eval CSP violations — word order didn't match actual error messages. Add filter for "Unexpected end of input" from truncated WebView script loads.
…ecache (koala73#140) ## Summary - prevent cloud fallback for local-only sidecar endpoints (`/api/local-*`) in desktop runtime fetch patch - ensure local secret/config routes never send payloads to remote hosts on local errors - add runtime E2E regression proving no remote fallback for: - `/api/local-env-update` - `/api/local-validate-secret` - apply deploy/cache guardrail fixes so SPA HTML is network-driven (not precached) - remove `index.html` from Workbox precache glob - explicitly disable `navigateFallback` (`navigateFallback: null`) - add deploy guardrail test coverage for `navigateFallback: null` ## Why - Desktop secret-management routes can carry API keys and credentials; they must remain local-only even when local calls fail. - Precaching HTML can serve stale shell documents after deploy and point users at deleted chunk hashes. ## Changes - `src/services/runtime.ts` - classify `/api/local-*` as local-only - block cloud fallback for local-only routes on both: - non-OK local responses - local fetch/network errors - `e2e/runtime-fetch.spec.ts` - add test: `runtime fetch patch never sends local-only endpoints to cloud` - `vite.config.ts` - remove `index.html` from Workbox precache `globPatterns` - set `navigateFallback: null` to prevent default SW navigation fallback route generation - `tests/deploy-config.test.mjs` - assert precache glob remains HTML-free - assert `navigateFallback: null` and no `navigateFallbackDenylist` ## Validation - `npm run typecheck` ✅ - `npm run test:e2e:runtime` ✅ (6/6) - `npm run test:sidecar` ✅ - `npm run test:data` ✅ - `npm run build` ✅
…oala73#414) The squash merge of koala73#413 put smoke test steps into build-desktop.yml instead of the intended standalone workflow. This commit: - Reverts build-desktop.yml to its original state - Adds test-linux-app.yml as a separate Linux-only smoke test workflow
* fix(ci): use weston+XWayland for Linux smoke test instead of pure Wayland Previous attempt used GDK_BACKEND=wayland which caused GTK init panic (tao requires X11). Now: weston headless with XWayland provides X11 through a real compositor. Falls back to Xvfb if weston fails. Also uploads weston/app logs as artifacts for debugging. * refactor(ci): use xwfb-run for Linux smoke test display xwfb-run (from xwayland-run package) is purpose-built for this: Xwayland on a headless Wayland compositor, replaces xvfb-run. Falls back to plain Xvfb if xwfb-run is unavailable. Uploads display-server and app logs as artifacts.
Only run CI on PRs from branches within the repo, not from external forks. Prevents unnecessary action minutes from contributor PRs.
* ci(linux): add AppImage smoke test to desktop build Launch the built AppImage under Xvfb after the Linux build to catch startup crashes and render failures automatically. Uploads a screenshot artifact for visual inspection. * Optimize Wingbits API usage and panel polling
…oala73#419) Linux had no keyring backend feature enabled — keyring v3 fell back to in-memory mock store. Secrets appeared to save but were lost on restart. Added `linux-native-sync-persistent` (kernel keyutils + D-Bus Secret Service combo) and `crypto-rust` for Secret Service encryption. This uses GNOME Keyring or KDE Wallet on desktop Linux for persistent storage.
…ala73#424) * chore: bump v2.5.12 ## Changelog - fix(linux): enable keyring persistence via Secret Service + keyutils (koala73#419) - fix(ci): use weston+XWayland for Linux smoke test (koala73#417) - ci: add standalone Test Linux App workflow (koala73#414) - ci: skip Typecheck and Lint on fork PRs (koala73#415) - perf: optimize Wingbits API usage and reduce unnecessary polling (koala73#416) * fix(linux): append host GStreamer plugins to AppImage search path The linuxdeploy GStreamer hook force-overrides GST_PLUGIN_PATH_1_0 and GST_PLUGIN_SYSTEM_PATH_1_0 to only contain bundled plugins from the CI build system (Ubuntu 24.04, GStreamer 1.24). On hosts with newer GStreamer (e.g. Arch 1.28), codec plugins like gst-libav and fakevideosink from gst-plugins-bad are invisible — WebKit can't play video. Append common host GStreamer plugin directories as fallback so the system's codec plugins are discoverable while bundled plugins retain priority. Also fixes: - tauri.conf.json devUrl port mismatch (5173 → 3000) breaking desktop:dev - live-channels-window YouTube validation allowing add on non-OK responses
…channels (koala73#425) The /api/youtube/live validation endpoint may return 429 or non-JSON responses (Vercel WAF, YouTube rate limiting). Previously this caused res.json() to parse HTML → either throw (caught, channel added) or return channelExists:false (blocked add with red border). Now only blocks when the API explicitly returns 200 OK with channelExists:false — any non-OK status or error allows the add. Also bumps version to 2.5.13.
- CI: add ubuntu-24.04-arm matrix entry with aarch64-unknown-linux-gnu - Node sidecar: download linux-arm64 Node.js bundle for ARM runners - Download API: add linux-appimage-arm64 pattern for aarch64 AppImage - Download banner: show both x64 and ARM64 Linux options (UA can't distinguish) - Desktop updater: map Linux aarch64 to linux-appimage-arm64 for in-app updates - Smoke test: fix AppImage search path for cross-target builds
Changes since v2.5.13: - feat: add ARM64 Linux build target and download detection (koala73#427) - fix(live-channels): tolerate YouTube API failures when adding custom channels (koala73#425) - fix(linux): append host GStreamer plugins to AppImage search path (koala73#424) - fix(linux): enable keyring persistence via Secret Service + keyutils (koala73#419)
…rrors (koala73#429) - Widen beforeSend regex to catch `null is not an object (evaluating 'u.id')` pattern from deck.gl internals during variant switch (WORLDMONITOR-4A, 270 events) - Remove `in_app` requirement from TypeError suppression — Sentry SDK marks deck.gl/maplibre frames inconsistently, causing the filter to miss - Fix Firefox lexical declaration wording: `can't access` vs Chrome's `Cannot access` - Add noise filters: isReCreate (Android WebView injection), HTMLImageElement style access, WebGL context loss write access
…3#430) * fix(sentry): tighten noise filters for deck.gl/maplibre and WebView errors - Widen beforeSend regex to catch `null is not an object (evaluating 'u.id')` pattern from deck.gl internals during variant switch (WORLDMONITOR-4A, 270 events) - Remove `in_app` requirement from TypeError suppression — Sentry SDK marks deck.gl/maplibre frames inconsistently, causing the filter to miss - Fix Firefox lexical declaration wording: `can't access` vs Chrome's `Cannot access` - Add noise filters: isReCreate (Android WebView injection), HTMLImageElement style access, WebGL context loss write access * fix: reduce upstream API pressure with cache TTL optimization - Military/posture: 5min → 15min (flight cache, theater posture, panel refresh, intelligence refresh) - Theater posture: fetch 2 targeted bbox regions instead of global states/all (~95% less data) - Wingbits batch: reduce from 20 to 10, sequential with 100ms delay instead of Promise.all burst - Preserve intelligenceCache.military across intelligence refresh cycles - OpenSky edge proxy: add CDN caching (s-maxage=120), align timeout to 20s - list-military-flights: Redis cache 2min → 10min - Market handlers: stablecoins/crypto/commodities/sectors 3min → 5min - Cable health: 3min → 10min - YouTube embed: s-maxage 60s → 15min
…oala73#431) Both ubuntu-24.04 (x64) and ubuntu-24.04-arm (ARM64) upload a smoke test screenshot with the static name `linux-smoke-test-screenshot`. upload-artifact@v4 rejects duplicate names with 409 Conflict. Append matrix.label so each gets a unique artifact name. CI log proof: run 22452393753 shows the ARM64 "Upload smoke test screenshot" step failing on the exact artifact name collision.
…oala73#434) AppImage only bundled gst-plugins-base and gst-plugins-good, missing H.264/AAC (gst-libav), x264 (plugins-ugly), AV1 (plugins-bad), and GL video sink (gst-gl). YouTube's MSE player checks codec support via MediaSource.isTypeSupported() — WebKitGTK delegates to GStreamer and reports no compatible decoders, showing "can't play this video". Add plugins-bad, plugins-ugly, gst-libav, and gst-gl to CI install so bundleMediaFramework includes them in the AppImage.
…ala73#435) * fix(linux): bundle full GStreamer codec suite for YouTube playback AppImage only bundled gst-plugins-base and gst-plugins-good, missing H.264/AAC (gst-libav), x264 (plugins-ugly), AV1 (plugins-bad), and GL video sink (gst-gl). YouTube's MSE player checks codec support via MediaSource.isTypeSupported() — WebKitGTK delegates to GStreamer and reports no compatible decoders, showing "can't play this video". Add plugins-bad, plugins-ugly, gst-libav, and gst-gl to CI install so bundleMediaFramework includes them in the AppImage. * feat: support YouTube URLs in custom channels, add Middle East region - Parse YouTube watch URLs and channel URLs in the custom channel input (watch?v=, youtu.be/, @handle URLs all supported) - Auto-resolve channel/video names via YouTube oembed proxy - Add video ID lookup endpoint to api/youtube/live.js - Return channelName from channel live detection API - Add Middle East region tab (Al Hadath, Sky News Arabia, TRT World, Iran International, CGTN Arabic) - Add BBC News and France 24 English to Europe region - Rename NASA TV to Sen Space Live - Add i18n keys (youtubeHandleOrUrl, regionMiddleEast) to all 17 locales
Add X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Content-Security-Policy, Referrer-Policy, and Permissions-Policy headers to all routes via vercel.json catch-all pattern.
- Indian Express (India section) → asia region - India News Network (diplomacy) → asia region - The War Zone → updated to direct feed (was Google News proxy) - gCaptain (maritime/waterways) → intel sources - Added domains to RSS proxy allowlist
…koala73#441) WebKitGTK promotes iframes, <video>, and canvas elements to GPU-textured compositing layers. In VMs (Apple Virtualization.framework, QEMU, VMware, etc.) the virtio-gpu driver often only supports 2D or limited GL, so GBM buffer allocation for compositing layers fails silently — rendering iframe/video content as black while the main page works fine. Detect VM environments via /proc/cpuinfo hypervisor flag and sys_vendor strings, then set WEBKIT_DISABLE_COMPOSITING_MODE=1 and LIBGL_ALWAYS_SOFTWARE=1 to force software rendering for all content. Native Linux machines with real GPUs are unaffected.
…plugins (koala73#444) The AppImage bundles GStreamer from CI (Ubuntu 24.04, GStreamer 1.24). Previously, host plugin directories (/usr/lib/gstreamer-1.0/) were appended as fallback. This caused ABI version mismatches — host plugins compiled against a different GStreamer version fail with undefined symbol errors (gst_util_floor_log2, mpg123_open_handle64, etc.), leaving WebKit without usable codecs for YouTube playback. Since PR koala73#434 installs the full GStreamer codec suite on CI, the AppImage is fully self-contained. Remove the host fallback and block host plugin scanning to prevent ABI conflicts across distro GStreamer versions.
koala73#446) Linux users with NVIDIA proprietary drivers on Wayland report crashes: "Could not create surfaceless EGL display: EGL_BAD_ALLOC. Aborting..." WebKitGTK's web process calls eglGetPlatformDisplay with the EGL_PLATFORM_SURFACELESS_MESA platform, which fails with NVIDIA's EGL implementation and triggers abort(). WEBKIT_DISABLE_DMABUF_RENDERER=1 (already set) only controls buffer sharing, not EGL initialization. Detect NVIDIA via /proc/driver/nvidia and: - Set __NV_DISABLE_EXPLICIT_SYNC=1 to prevent Wayland flickering - Force GDK_BACKEND=x11 on NVIDIA+Wayland (user can override) Also bumps version to 2.5.19. Refs: tauri-apps/tauri#9394, gitbutlerapp/gitbutler#5282
…fix, embed improvements (koala73#452) * fix(feeds): replace 25 dead/stale RSS URLs and add feed validation script - Replace 16 dead feeds (404/403/timeout) with working alternatives (Google News proxies or corrected direct RSS endpoints) - Replace 6 empty feeds with correct RSS paths (VnExpress, Tuoi Tre, Live Science, Greater Good, News24, ScienceDaily) - Replace 3 stale feeds (CNN World, TVN24, Layoffs.fyi) with active sources - Remove Disrupt Africa (inactive since Jan 2024) - Add scripts/validate-rss-feeds.mjs to check all 420 feeds - Add test:feeds npm script * feat(live-news): use stable CDN HLS feeds for desktop native playback Direct HLS feeds bypass YouTube's expiring tokenized URLs and iframe cookie issues on WKWebView. 10 channels (Sky, DW, France24, Euronews, Al Arabiya, Al Jazeera, CBS News, TRT World, Sky News Arabia, Al Hadath) now play via native <video> on desktop with automatic YouTube fallback when CDN feeds are down (5-min cooldown). Also: - Fix euronews handle typo (@euabortnews → @euronews) - Fix TRT World handle (@taborrtworld → @trtworld) - Add fallbackVideoId to CBS News, Sky News Arabia, TRT World - Extract hlsManifestUrl from YouTube API for non-mapped channels - Add sidecar /api/youtube-embed endpoint (auth-exempt for iframes) - Switch webcam/embed iframes from cloud to local sidecar origin - CSP: allow frame-src http://127.0.0.1:* for sidecar embeds - Remove legacy WEBKIT_FORCE_SANDBOX env var (deprecated in WebKitGTK) - Add 37 tests covering HLS map integrity, decision tree ordering, cooldown logic, race safety, service layer, sidecar endpoint, and CSP * fix(summarization): pass panelId as geoContext to prevent Redis cache key collision When breaking news appeared across multiple panels (World, US, Europe, Middle East), all panels generated identical cache keys because geoContext was always undefined. The first panel's summary was served to all others. * fix(desktop): sidecar embed autoplay, webcam fullscreen, optional channel fallbacks - Sidecar YouTube embed: use mute param (not hardcoded), add play overlay for WKWebView autoplay fallback, add postMessage bridge for play/pause/ mute/unmute commands matching the cloud embed handler - Webcam iframes: only set allowFullscreen on web to prevent grid-breaking fullscreen on desktop click - Optional channels: add fallbackVideoId + useFallbackOnly for livenow-fox, abc-news, nbc-news, wion so they play instead of showing "not currently live" - Tests: 9 new assertions covering mute param, postMessage bridge, play overlay, yt-ready message, and optional channel fallback coverage (46 total)
…ry cache (koala73#456) * fix(ollama): strip thinking tokens, raise max_tokens, fix panel summary cache (koala73#450) - Add OLLAMA_MAX_TOKENS env var (clamped 50-2000, default 300) so thinking models have enough budget for actual summaries instead of truncated reasoning - Strip <|begin_of_thought|>/<|end_of_thought|> tags (terminated + unterminated) - Add mode-scoped min-length gate: reject <20 char outputs for brief/analysis - Extend TASK_NARRATION regex with first/step/my-task/to-summarize patterns - Fix client-side summary cache: store headline signature in value, validate on read, auto-dismiss stale summaries on headline change, discard in-flight results when headlines change during generation - Add tests for new patterns and negative cases (39/39 pass) * fix: hide summary container on stale in-flight discard, fix comment - Add hideSummary() call when headline signature changes mid-generation, preventing a stuck "Generating summary..." overlay - Fix stale comment: cache version is v5, not v4
Uses a hash of the country code to generate deterministic jitter, so the same threat from the same country always appears at the same coordinates instead of jumping around on each request. Fixes issue koala73#203
|
Someone is attempting to deploy a commit to the Elie Team on Vercel. A member of the Team first needs to authorize it. |
koala73
left a comment
There was a problem hiding this comment.
Thanks for the PR! Clean, focused change — the intent is solid and the fix addresses the re-render jitter problem nicely.
A few notes:
🔴 Blocking: Anagram country codes collide
The hash (sum of char codes) is commutative — anagram pairs produce identical jitter and stack on the same point:
| Pair | Hash | Real countries |
|---|---|---|
| AL / LA | 141 | Albania / Laos |
| CN / NC | 145 | China / New Caledonia |
| NI / IN | 151 | Nicaragua / India |
Fix: use a position-sensitive hash:
const hash = countryCode.toUpperCase().split('')
.reduce((acc, char) => acc * 31 + char.charCodeAt(0), 0);The * 31 (standard string hash multiplier) makes character order matter, eliminating anagram collisions.
💡 Suggestion: All threats from same country now stack on one point
Before, Math.random() scattered multiple threats from the same country across the centroid area. Now they all resolve to the exact same [lat, lon]. If the caller ever plots multiple threats per country, they'll overlap completely.
If a per-threat identifier exists (IP hash, event ID), passing it as a second param would give deterministic per-threat jitter while still preventing re-render jumps:
function getCountryCentroid(countryCode: string, seed?: number)If only one marker per country is expected, this is fine as-is.
💡 Suggestion: lat/lon offset correlation
hash and hash * 7 are linearly dependent — all countries' jitter falls along a narrow diagonal in 2D space rather than filling the area. Using a second independent derivation would spread better:
const latOffset = ((hash % 1000) / 1000 - 0.5) * jitterScale;
const lonOffset = (((hash >>> 8 ^ hash * 2654435761) % 1000) / 1000 - 0.5) * jitterScale;
Uses a hash of the country code to generate deterministic jitter, so the same threat from the same country always appears at the same coordinates instead of jumping around on each request.
Fixes issue #203