From 6f41c2ff3d7b9cc0ad8eb2e3527446034d73b616 Mon Sep 17 00:00:00 2001 From: Octopus Date: Mon, 16 Mar 2026 08:45:35 -0500 Subject: [PATCH 01/12] feat: add MiniMax as LLM provider Add MiniMax (api.minimax.io) as a fifth LLM provider option alongside Anthropic, OpenAI, Gemini, and Codex. MiniMax offers an OpenAI-compatible Chat Completions API with the M2.5 model (204K context window). Changes: - lib/llm/minimax.mjs: new provider using raw fetch (no SDK) - lib/llm/index.mjs: register MiniMax in the factory - .env.example, crucix.config.mjs, README.md: document the new option - test/llm-minimax.test.mjs: 10 unit tests (node:test) - test/llm-minimax-integration.test.mjs: live API integration test Usage: LLM_PROVIDER=minimax LLM_API_KEY=sk-... LLM_MODEL=MiniMax-M2.5 # optional, this is the default --- .env.example | 4 +- README.md | 12 ++- crucix.config.mjs | 2 +- lib/llm/index.mjs | 4 + lib/llm/minimax.mjs | 51 +++++++++ test/llm-minimax-integration.test.mjs | 30 ++++++ test/llm-minimax.test.mjs | 144 ++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 lib/llm/minimax.mjs create mode 100644 test/llm-minimax-integration.test.mjs create mode 100644 test/llm-minimax.test.mjs diff --git a/.env.example b/.env.example index 00b6683..3585dbb 100644 --- a/.env.example +++ b/.env.example @@ -31,12 +31,12 @@ REFRESH_INTERVAL_MINUTES=15 # === LLM Layer (optional) === # Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex +# Provider options: anthropic | openai | gemini | codex | minimax LLM_PROVIDER= # Not needed for codex (uses ~/.codex/auth.json) LLM_API_KEY= # Optional override. Each provider has a sensible default: -# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex +# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | minimax: MiniMax-M2.5 LLM_MODEL= # === Telegram Alerts (optional, requires LLM) === diff --git a/README.md b/README.md index 875a86d..4edc9f6 100644 --- a/README.md +++ b/README.md @@ -148,10 +148,10 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye **Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode. ### Optional LLM Layer -Connect any of 4 LLM providers for enhanced analysis: +Connect any of 5 LLM providers for enhanced analysis: - **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data - **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring -- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription) +- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription), MiniMax - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. --- @@ -184,7 +184,7 @@ These three unlock the most valuable economic and satellite data. Each takes abo ### LLM Provider (optional, for AI-enhanced ideas) -Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex` +Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `minimax` | Provider | Key Required | Default Model | |----------|-------------|---------------| @@ -192,6 +192,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex` | `openai` | `LLM_API_KEY` | gpt-5.4 | | `gemini` | `LLM_API_KEY` | gemini-3.1-pro | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | +| `minimax` | `LLM_API_KEY` | MiniMax-M2.5 | For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription. @@ -261,12 +262,13 @@ crucix/ │ └── jarvis.html # Self-contained Jarvis HUD │ ├── lib/ -│ ├── llm/ # LLM abstraction (4 providers, raw fetch, no SDKs) +│ ├── llm/ # LLM abstraction (5 providers, raw fetch, no SDKs) │ │ ├── provider.mjs # Base class │ │ ├── anthropic.mjs # Claude │ │ ├── openai.mjs # GPT │ │ ├── gemini.mjs # Gemini │ │ ├── codex.mjs # Codex (ChatGPT subscription) +│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context) │ │ ├── ideas.mjs # LLM-powered trade idea generation │ │ └── index.mjs # Factory: createLLMProvider() │ ├── delta/ # Change tracking between sweeps @@ -368,7 +370,7 @@ All settings are in `.env` with sensible defaults: |----------|---------|-------------| | `PORT` | `3117` | Dashboard server port | | `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval | -| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, or `codex` | +| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, or `minimax` | | `LLM_API_KEY` | — | API key (not needed for codex) | | `LLM_MODEL` | per-provider default | Override model selection | | `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands | diff --git a/crucix.config.mjs b/crucix.config.mjs index 5e7b098..4d4c36b 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -7,7 +7,7 @@ export default { refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, llm: { - provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | minimax apiKey: process.env.LLM_API_KEY || null, model: process.env.LLM_MODEL || null, }, diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index 5711350..d6d3840 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -4,12 +4,14 @@ import { AnthropicProvider } from './anthropic.mjs'; import { OpenAIProvider } from './openai.mjs'; import { GeminiProvider } from './gemini.mjs'; import { CodexProvider } from './codex.mjs'; +import { MiniMaxProvider } from './minimax.mjs'; export { LLMProvider } from './provider.mjs'; export { AnthropicProvider } from './anthropic.mjs'; export { OpenAIProvider } from './openai.mjs'; export { GeminiProvider } from './gemini.mjs'; export { CodexProvider } from './codex.mjs'; +export { MiniMaxProvider } from './minimax.mjs'; /** * Create an LLM provider based on config. @@ -30,6 +32,8 @@ export function createLLMProvider(llmConfig) { return new GeminiProvider({ apiKey, model }); case 'codex': return new CodexProvider({ model }); + case 'minimax': + return new MiniMaxProvider({ apiKey, model }); default: console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`); return null; diff --git a/lib/llm/minimax.mjs b/lib/llm/minimax.mjs new file mode 100644 index 0000000..7c9160b --- /dev/null +++ b/lib/llm/minimax.mjs @@ -0,0 +1,51 @@ +// MiniMax Provider — raw fetch, no SDK +// Uses MiniMax's OpenAI-compatible Chat Completions API + +import { LLMProvider } from './provider.mjs'; + +export class MiniMaxProvider extends LLMProvider { + constructor(config) { + super(config); + this.name = 'minimax'; + this.apiKey = config.apiKey; + this.model = config.model || 'MiniMax-M2.5'; + } + + get isConfigured() { return !!this.apiKey; } + + async complete(systemPrompt, userMessage, opts = {}) { + const res = await fetch('https://api.minimax.io/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: opts.maxTokens || 4096, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }), + signal: AbortSignal.timeout(opts.timeout || 60000), + }); + + if (!res.ok) { + const err = await res.text().catch(() => ''); + throw new Error(`MiniMax API ${res.status}: ${err.substring(0, 200)}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content || ''; + + return { + text, + usage: { + inputTokens: data.usage?.prompt_tokens || 0, + outputTokens: data.usage?.completion_tokens || 0, + }, + model: data.model || this.model, + }; + } +} diff --git a/test/llm-minimax-integration.test.mjs b/test/llm-minimax-integration.test.mjs new file mode 100644 index 0000000..08b0c7c --- /dev/null +++ b/test/llm-minimax-integration.test.mjs @@ -0,0 +1,30 @@ +// MiniMax provider — integration test (calls real API) +// Requires MINIMAX_API_KEY environment variable +// Run: MINIMAX_API_KEY=sk-... node --test test/llm-minimax-integration.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { MiniMaxProvider } from '../lib/llm/minimax.mjs'; + +const API_KEY = process.env.MINIMAX_API_KEY; + +describe('MiniMax integration', { skip: !API_KEY && 'MINIMAX_API_KEY not set' }, () => { + it('should complete a prompt with MiniMax-M2.5', async () => { + const provider = new MiniMaxProvider({ apiKey: API_KEY, model: 'MiniMax-M2.5' }); + assert.equal(provider.isConfigured, true); + + const result = await provider.complete( + 'You are a helpful assistant. Respond in exactly one sentence.', + 'What is 2+2?', + { maxTokens: 128, timeout: 30000 } + ); + + assert.ok(result.text.length > 0, 'Response text should not be empty'); + assert.ok(result.usage.inputTokens > 0, 'Should report input tokens'); + assert.ok(result.usage.outputTokens > 0, 'Should report output tokens'); + assert.ok(result.model, 'Should report model name'); + console.log(` Response: ${result.text}`); + console.log(` Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out`); + console.log(` Model: ${result.model}`); + }); +}); diff --git a/test/llm-minimax.test.mjs b/test/llm-minimax.test.mjs new file mode 100644 index 0000000..f2d3b7d --- /dev/null +++ b/test/llm-minimax.test.mjs @@ -0,0 +1,144 @@ +// MiniMax provider — unit tests +// Uses Node.js built-in test runner (node:test) — no extra dependencies + +import { describe, it, mock, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { MiniMaxProvider } from '../lib/llm/minimax.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +// ─── Unit Tests ─── + +describe('MiniMaxProvider', () => { + it('should set defaults correctly', () => { + const provider = new MiniMaxProvider({ apiKey: 'sk-test' }); + assert.equal(provider.name, 'minimax'); + assert.equal(provider.model, 'MiniMax-M2.5'); + assert.equal(provider.isConfigured, true); + }); + + it('should accept custom model', () => { + const provider = new MiniMaxProvider({ apiKey: 'sk-test', model: 'MiniMax-M2.5-highspeed' }); + assert.equal(provider.model, 'MiniMax-M2.5-highspeed'); + }); + + it('should report not configured without API key', () => { + const provider = new MiniMaxProvider({}); + assert.equal(provider.isConfigured, false); + }); + + it('should throw on API error', async () => { + const provider = new MiniMaxProvider({ apiKey: 'sk-test' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }) + ); + try { + await assert.rejects( + () => provider.complete('system', 'user'), + (err) => { + assert.match(err.message, /MiniMax API 401/); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should parse successful response', async () => { + const provider = new MiniMaxProvider({ apiKey: 'sk-test' }); + const mockResponse = { + choices: [{ message: { content: 'Hello from MiniMax' } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + model: 'MiniMax-M2.5', + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) }) + ); + try { + const result = await provider.complete('You are helpful.', 'Say hello'); + assert.equal(result.text, 'Hello from MiniMax'); + assert.equal(result.usage.inputTokens, 10); + assert.equal(result.usage.outputTokens, 5); + assert.equal(result.model, 'MiniMax-M2.5'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should send correct request format', async () => { + const provider = new MiniMaxProvider({ apiKey: 'sk-test-key', model: 'MiniMax-M2.5' }); + let capturedUrl, capturedOpts; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn((url, opts) => { + capturedUrl = url; + capturedOpts = opts; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + model: 'MiniMax-M2.5', + }), + }); + }); + try { + await provider.complete('system prompt', 'user message', { maxTokens: 2048 }); + assert.equal(capturedUrl, 'https://api.minimax.io/v1/chat/completions'); + assert.equal(capturedOpts.method, 'POST'); + const headers = capturedOpts.headers; + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['Authorization'], 'Bearer sk-test-key'); + const body = JSON.parse(capturedOpts.body); + assert.equal(body.model, 'MiniMax-M2.5'); + assert.equal(body.max_tokens, 2048); + assert.equal(body.messages[0].role, 'system'); + assert.equal(body.messages[0].content, 'system prompt'); + assert.equal(body.messages[1].role, 'user'); + assert.equal(body.messages[1].content, 'user message'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should handle empty response gracefully', async () => { + const provider = new MiniMaxProvider({ apiKey: 'sk-test' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [], usage: {} }), + }) + ); + try { + const result = await provider.complete('sys', 'user'); + assert.equal(result.text, ''); + assert.equal(result.usage.inputTokens, 0); + assert.equal(result.usage.outputTokens, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +// ─── Factory Tests ─── + +describe('createLLMProvider — minimax', () => { + it('should create MiniMaxProvider for provider=minimax', () => { + const provider = createLLMProvider({ provider: 'minimax', apiKey: 'sk-test', model: null }); + assert.ok(provider instanceof MiniMaxProvider); + assert.equal(provider.name, 'minimax'); + assert.equal(provider.isConfigured, true); + }); + + it('should be case-insensitive', () => { + const provider = createLLMProvider({ provider: 'MiniMax', apiKey: 'sk-test', model: null }); + assert.ok(provider instanceof MiniMaxProvider); + }); + + it('should return null for empty provider', () => { + const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null }); + assert.equal(provider, null); + }); +}); From ea2e13e0c572bb381ff28581029d6cc28ef6f127 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:16:43 +0000 Subject: [PATCH 02/12] feat(llm): add OpenRouter provider implementation --- lib/llm/index.mjs | 4 ++++ lib/llm/openrouter.mjs | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 lib/llm/openrouter.mjs diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index 5711350..e590b0a 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -2,12 +2,14 @@ import { AnthropicProvider } from './anthropic.mjs'; import { OpenAIProvider } from './openai.mjs'; +import { OpenRouterProvider } from './openrouter.mjs'; import { GeminiProvider } from './gemini.mjs'; import { CodexProvider } from './codex.mjs'; export { LLMProvider } from './provider.mjs'; export { AnthropicProvider } from './anthropic.mjs'; export { OpenAIProvider } from './openai.mjs'; +export { OpenRouterProvider } from './openrouter.mjs'; export { GeminiProvider } from './gemini.mjs'; export { CodexProvider } from './codex.mjs'; @@ -26,6 +28,8 @@ export function createLLMProvider(llmConfig) { return new AnthropicProvider({ apiKey, model }); case 'openai': return new OpenAIProvider({ apiKey, model }); + case 'openrouter': + return new OpenRouterProvider({ apiKey, model }); case 'gemini': return new GeminiProvider({ apiKey, model }); case 'codex': diff --git a/lib/llm/openrouter.mjs b/lib/llm/openrouter.mjs new file mode 100644 index 0000000..4805de8 --- /dev/null +++ b/lib/llm/openrouter.mjs @@ -0,0 +1,52 @@ +// OpenRouter Provider — raw fetch, no SDK + +import { LLMProvider } from './provider.mjs'; + +export class OpenRouterProvider extends LLMProvider { + constructor(config) { + super(config); + this.name = 'openrouter'; + this.apiKey = config.apiKey; + this.model = config.model || 'openrouter/auto'; + } + + get isConfigured() { return !!this.apiKey; } + + async complete(systemPrompt, userMessage, opts = {}) { + const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'HTTP-Referer': 'https://github.com/calesthio/Crucix', + 'X-Title': 'Crucix', + }, + body: JSON.stringify({ + model: this.model, + max_tokens: opts.maxTokens || 4096, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }), + signal: AbortSignal.timeout(opts.timeout || 60000), + }); + + if (!res.ok) { + const err = await res.text().catch(() => ''); + throw new Error(`OpenRouter API ${res.status}: ${err.substring(0, 200)}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content || ''; + + return { + text, + usage: { + inputTokens: data.usage?.prompt_tokens || 0, + outputTokens: data.usage?.completion_tokens || 0, + }, + model: data.model || this.model, + }; + } +} From 79dc1fd28706709808f8ce24e59a8b72d4ff8056 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:16:48 +0000 Subject: [PATCH 03/12] chore: update config and env templates for OpenRouter --- .env.example | 4 ++-- crucix.config.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 00b6683..4830306 100644 --- a/.env.example +++ b/.env.example @@ -31,12 +31,12 @@ REFRESH_INTERVAL_MINUTES=15 # === LLM Layer (optional) === # Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex +# Provider options: anthropic | openai | gemini | codex | openrouter LLM_PROVIDER= # Not needed for codex (uses ~/.codex/auth.json) LLM_API_KEY= # Optional override. Each provider has a sensible default: -# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex +# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto LLM_MODEL= # === Telegram Alerts (optional, requires LLM) === diff --git a/crucix.config.mjs b/crucix.config.mjs index 5e7b098..e76e55c 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -7,7 +7,7 @@ export default { refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, llm: { - provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter apiKey: process.env.LLM_API_KEY || null, model: process.env.LLM_MODEL || null, }, From f1352ce674a8fc25d1b4adc3d035ae33abd562bd Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:17:00 +0000 Subject: [PATCH 04/12] test(llm): add unit and integration tests for OpenRouter --- test/llm-openrouter.test.mjs | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 test/llm-openrouter.test.mjs diff --git a/test/llm-openrouter.test.mjs b/test/llm-openrouter.test.mjs new file mode 100644 index 0000000..b776ae3 --- /dev/null +++ b/test/llm-openrouter.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { OpenRouterProvider } from '../lib/llm/openrouter.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +test('OpenRouterProvider Unit Tests', async (t) => { + await t.test('initializes correctly', () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'openrouter/auto' }); + assert.equal(provider.name, 'openrouter'); + assert.equal(provider.apiKey, 'test-key'); + assert.equal(provider.model, 'openrouter/auto'); + assert.equal(provider.isConfigured, true); + }); + + await t.test('isConfigured is false without apiKey', () => { + const provider = new OpenRouterProvider({ apiKey: null }); + assert.equal(provider.isConfigured, false); + }); + + await t.test('createLLMProvider factory returns OpenRouterProvider', () => { + const provider = createLLMProvider({ provider: 'openrouter', apiKey: 'test-key', model: 'test-model' }); + assert.ok(provider instanceof OpenRouterProvider); + assert.equal(provider.apiKey, 'test-key'); + assert.equal(provider.model, 'test-model'); + }); + + await t.test('complete() returns expected result', async () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' }); + + // Mock the global fetch + const originalFetch = global.fetch; + global.fetch = async (url, options) => { + assert.equal(url, 'https://openrouter.ai/api/v1/chat/completions'); + assert.equal(options.headers['Authorization'], 'Bearer test-key'); + assert.equal(options.headers['X-Title'], 'Crucix'); + assert.equal(options.headers['HTTP-Referer'], 'https://github.com/calesthio/Crucix'); + + const body = JSON.parse(options.body); + assert.equal(body.model, 'test-model'); + assert.deepEqual(body.messages, [ + { role: 'system', content: 'You are a test.' }, + { role: 'user', content: 'Hello' } + ]); + + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Test response' } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + model: 'test-model' + }) + }; + }; + + try { + const result = await provider.complete('You are a test.', 'Hello'); + assert.equal(result.text, 'Test response'); + assert.deepEqual(result.usage, { inputTokens: 10, outputTokens: 5 }); + assert.equal(result.model, 'test-model'); + } finally { + // Restore original fetch + global.fetch = originalFetch; + } + }); + + await t.test('complete() throws error on API failure', async () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' }); + + const originalFetch = global.fetch; + global.fetch = async () => { + return { + ok: false, + status: 401, + text: async () => 'Unauthorized access' + }; + }; + + try { + await assert.rejects( + provider.complete('system', 'user'), + { + name: 'Error', + message: 'OpenRouter API 401: Unauthorized access' + } + ); + } finally { + global.fetch = originalFetch; + } + }); +}); + +test('OpenRouterProvider Integration Test', { skip: !process.env.LLM_API_KEY || process.env.LLM_PROVIDER !== 'openrouter' }, async (t) => { + await t.test('Performs live API call', async () => { + const provider = createLLMProvider({ + provider: 'openrouter', + apiKey: process.env.LLM_API_KEY, + model: process.env.LLM_MODEL || 'openrouter/auto' + }); + + const result = await provider.complete('Reply with exactly "Hello".', 'Hi'); + assert.ok(result.text.length > 0, 'Should return text'); + assert.ok(result.usage.inputTokens > 0, 'Should return input token usage'); + }); +}); From 82731bf0905e285b704512ea2da291e5c16f67e0 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:17:03 +0000 Subject: [PATCH 05/12] docs: add OpenRouter to README --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b522851..6651453 100644 --- a/README.md +++ b/README.md @@ -148,10 +148,10 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye **Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode. ### Optional LLM Layer -Connect any of 4 LLM providers for enhanced analysis: +Connect any of 5 LLM providers for enhanced analysis: - **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data - **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring -- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription) +- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription) - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. --- @@ -191,6 +191,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex` | `anthropic` | `LLM_API_KEY` | claude-sonnet-4-6 | | `openai` | `LLM_API_KEY` | gpt-5.4 | | `gemini` | `LLM_API_KEY` | gemini-3.1-pro | +| `openrouter` | `LLM_API_KEY` | openrouter/auto | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription. @@ -261,11 +262,12 @@ crucix/ │ └── jarvis.html # Self-contained Jarvis HUD │ ├── lib/ -│ ├── llm/ # LLM abstraction (4 providers, raw fetch, no SDKs) +│ ├── llm/ # LLM abstraction (5 providers, raw fetch, no SDKs) │ │ ├── provider.mjs # Base class │ │ ├── anthropic.mjs # Claude │ │ ├── openai.mjs # GPT │ │ ├── gemini.mjs # Gemini +│ │ ├── openrouter.mjs # OpenRouter (Unified API) │ │ ├── codex.mjs # Codex (ChatGPT subscription) │ │ ├── ideas.mjs # LLM-powered trade idea generation │ │ └── index.mjs # Factory: createLLMProvider() @@ -368,7 +370,7 @@ All settings are in `.env` with sensible defaults: |----------|---------|-------------| | `PORT` | `3117` | Dashboard server port | | `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval | -| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, or `codex` | +| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, or `openrouter` | | `LLM_API_KEY` | — | API key (not needed for codex) | | `LLM_MODEL` | per-provider default | Override model selection | | `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands | From 0612915695a11d3595d8a12711aa32fd85c4e424 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 21:24:46 +0000 Subject: [PATCH 06/12] test(llm): split OpenRouter unit and integration tests --- test/llm-openrouter-integration.test.mjs | 17 +++++++++++++++++ test/llm-openrouter.test.mjs | 14 -------------- 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 test/llm-openrouter-integration.test.mjs diff --git a/test/llm-openrouter-integration.test.mjs b/test/llm-openrouter-integration.test.mjs new file mode 100644 index 0000000..7d8af58 --- /dev/null +++ b/test/llm-openrouter-integration.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +test('OpenRouterProvider Integration Test', { skip: !process.env.LLM_API_KEY || process.env.LLM_PROVIDER !== 'openrouter' }, async (t) => { + await t.test('Performs live API call', async () => { + const provider = createLLMProvider({ + provider: 'openrouter', + apiKey: process.env.LLM_API_KEY, + model: process.env.LLM_MODEL || 'openrouter/auto' + }); + + const result = await provider.complete('Reply with exactly "Hello".', 'Hi'); + assert.ok(result.text.length > 0, 'Should return text'); + assert.ok(result.usage.inputTokens > 0, 'Should return input token usage'); + }); +}); diff --git a/test/llm-openrouter.test.mjs b/test/llm-openrouter.test.mjs index b776ae3..bc13ac6 100644 --- a/test/llm-openrouter.test.mjs +++ b/test/llm-openrouter.test.mjs @@ -88,17 +88,3 @@ test('OpenRouterProvider Unit Tests', async (t) => { } }); }); - -test('OpenRouterProvider Integration Test', { skip: !process.env.LLM_API_KEY || process.env.LLM_PROVIDER !== 'openrouter' }, async (t) => { - await t.test('Performs live API call', async () => { - const provider = createLLMProvider({ - provider: 'openrouter', - apiKey: process.env.LLM_API_KEY, - model: process.env.LLM_MODEL || 'openrouter/auto' - }); - - const result = await provider.complete('Reply with exactly "Hello".', 'Hi'); - assert.ok(result.text.length > 0, 'Should return text'); - assert.ok(result.usage.inputTokens > 0, 'Should return input token usage'); - }); -}); From da69912b10b6410302728025f732a9f5d6c8c2e5 Mon Sep 17 00:00:00 2001 From: XAOSTECH <69734795+xaoscience@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:59:22 +0100 Subject: [PATCH 07/12] fix(README+docker-compose): reduce install friction Remove docker-compose version (obsolete) and fix install step capitalisation in README --- README.md | 4 ++-- docker-compose.yml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b522851..94a08a7 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ It was built for anyone who wants to understand what's actually happening in the ```bash # 1. Clone the repo git clone https://github.com/calesthio/Crucix.git -cd crucix +cd Crucix # 2. Install dependencies (just Express) npm install @@ -76,7 +76,7 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg ```bash git clone https://github.com/calesthio/Crucix.git -cd crucix +cd Crucix cp .env.example .env # add your API keys docker compose up -d ``` diff --git a/docker-compose.yml b/docker-compose.yml index 2724ce4..cdad7b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: crucix: build: . From b5726cd2f695723706928b8af91e1d12791a4d7e Mon Sep 17 00:00:00 2001 From: XAOSTECH <69734795+xaoscience@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:55:38 +0100 Subject: [PATCH 08/12] fix(security): patch undici CVEs, restore discord.js@14, sync license in lockfile - npm audit fix --force had silently installed discord.js@13 (deprecated) despite package.json declaring ^14.25.0; restored to 14.25.1 - Added overrides.undici >=7.24.4 to patch GHSA-g9mf, GHSA-f269, GHSA-2mjp, GHSA-vrm6, GHSA-v9p9, GHSA-4992 without breaking changes - package-lock.json license field corrected ISC -> AGPL-3.0-only to match package.json (lockfile was out of sync from project init) --- package-lock.json | 332 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 12 +- 2 files changed, 339 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42c3545..2dba6a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,221 @@ "": { "name": "crucix", "version": "2.0.0", - "license": "ISC", + "license": "AGPL-3.0-only", "dependencies": { "express": "^5.1.0" }, "engines": { - "node": ">=22" + "node": ">=22", + "npm": ">=10" + }, + "optionalDependencies": { + "discord.js": "^14.25.1" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" } }, "node_modules/accepts": { @@ -156,6 +365,44 @@ "node": ">= 0.8" } }, + "node_modules/discord-api-types": { + "version": "0.38.42", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz", + "integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -273,6 +520,13 @@ "url": "https://opencollective.com/express" } }, + "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==", + "license": "MIT", + "optional": true + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -451,6 +705,27 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT", + "optional": true + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT", + "optional": true + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -788,6 +1063,20 @@ "node": ">=0.6" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT", + "optional": true + }, + "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", + "optional": true + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -802,6 +1091,23 @@ "node": ">= 0.6" } }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "optional": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -825,6 +1131,28 @@ "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==", + "license": "MIT", + "optional": true, + "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/package.json b/package.json index 92c3c20..cdf3519 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,12 @@ "clean": "node scripts/clean.mjs", "fresh-start": "npm run clean && npm start" }, - "keywords": ["osint", "intelligence", "dashboard", "geopolitical"], + "keywords": [ + "osint", + "intelligence", + "dashboard", + "geopolitical" + ], "author": "Crucix", "license": "AGPL-3.0-only", "engines": { @@ -25,6 +30,7 @@ "express": "^5.1.0" }, "optionalDependencies": { - "discord.js": "^14.25.0" - } + "discord.js": "^14.25.1" }, + "overrides": { + "undici": "^7.24.4" } } From cf941e1d8789c88d5f66015ad3956a600d36f624 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Tue, 17 Mar 2026 06:11:16 -0400 Subject: [PATCH 09/12] Fix Al Jazeera RSS feed URL --- dashboard/inject.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index bdad625..22a67d6 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -125,7 +125,7 @@ export async function fetchAllNews() { const feeds = [ ['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'], ['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'], - ['https://feeds.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'], + ['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'], ['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'], ['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'], ['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'], From cd206e4efd92e6eea9bde86ee2a3744edb11e6d0 Mon Sep 17 00:00:00 2001 From: calesthio Date: Tue, 17 Mar 2026 13:05:04 -0700 Subject: [PATCH 10/12] Promote live demo in repo copy --- .gitignore | 3 +++ README.md | 10 ++++++++++ package.json | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4dd3c66..e855d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ npm-debug.log* # Local maintainer notes MAINTAINER_DECISIONS.local.md + +# Local deploy config +dashboard/public/vercel.json diff --git a/README.md b/README.md index b522851..6b31914 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ **Your own intelligence terminal. 27 sources. One command. Zero cloud.** +## [Visit The Live Site: crucix.live](https://www.crucix.live/) + +[![Live Website](https://img.shields.io/badge/live-crucix.live-00d4ff?style=for-the-badge)](https://www.crucix.live/) +[![Open Demo](https://img.shields.io/badge/open-live%20dashboard-0b1220?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.crucix.live/) + [![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start) [![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE) [![Dependencies](https://img.shields.io/badge/dependencies-1%20(express)-orange)](#architecture) @@ -27,10 +32,15 @@ +> **Live website:** [https://www.crucix.live/](https://www.crucix.live/) +> Explore the public demo first, then clone the repo to run Crucix locally. + Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard. Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep. +Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack. + No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running. --- diff --git a/package.json b/package.json index 92c3c20..9b5105c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crucix", "version": "2.0.0", - "description": "Local intelligence engine — 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.", + "description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.", "type": "module", "scripts": { "start": "node server.mjs", From 54bbcd4b049cdb9779b19e09cd7359c9602efe5e Mon Sep 17 00:00:00 2001 From: calesthio Date: Tue, 17 Mar 2026 13:47:55 -0700 Subject: [PATCH 11/12] Fix static dashboard injection flow --- dashboard/inject.mjs | 46 ++++++++++++++++++++++++++++++++---- dashboard/public/jarvis.html | 7 +++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index bdad625..d011f52 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -9,6 +9,9 @@ import { readFileSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { exec } from 'child_process'; +import config from '../crucix.config.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; +import { generateLLMIdeas } from '../lib/llm/ideas.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -511,11 +514,42 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) { } // === CLI Mode: inject into HTML file === +function getCliArg(flag) { + const idx = process.argv.indexOf(flag); + return idx >= 0 ? process.argv[idx + 1] : null; +} + async function cliInject() { const data = JSON.parse(readFileSync(join(ROOT, 'runs/latest.json'), 'utf8')); + const htmlOverride = getCliArg('--html'); + const shouldOpen = !process.argv.includes('--no-open'); console.log('Fetching RSS news feeds...'); const V2 = await synthesize(data); + const llmProvider = createLLMProvider(config.llm); + + if (llmProvider?.isConfigured) { + try { + console.log(`[LLM] Generating ideas via ${llmProvider.name}...`); + const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []); + if (llmIdeas?.length) { + V2.ideas = llmIdeas; + V2.ideasSource = 'llm'; + console.log(`[LLM] Generated ${llmIdeas.length} ideas`); + } else { + V2.ideas = []; + V2.ideasSource = 'llm-failed'; + console.log('[LLM] No ideas returned'); + } + } catch (err) { + V2.ideas = []; + V2.ideasSource = 'llm-failed'; + console.log('[LLM] Idea generation failed:', err.message); + } + } else { + V2.ideas = []; + V2.ideasSource = 'disabled'; + } console.log(`Generated ${V2.ideas.length} leverageable ideas`); const json = JSON.stringify(V2); @@ -523,12 +557,15 @@ async function cliInject() { console.log('Size:', json.length, 'bytes | Air:', V2.air.length, '| Thermal:', V2.thermal.length, '| News:', V2.news.length, '| Ideas:', V2.ideas.length, '| Sources:', V2.health.length); - const htmlPath = join(ROOT, 'dashboard/public/jarvis.html'); + const htmlPath = htmlOverride || join(ROOT, 'dashboard/public/jarvis.html'); let html = readFileSync(htmlPath, 'utf8'); - html = html.replace(/^(let|const) D = .*;\s*$/m, 'let D = ' + json + ';'); + // Use a replacer function so JSON is inserted literally even if it contains `$`. + html = html.replace(/^(let|const) D = .*;\s*$/m, () => 'let D = ' + json + ';'); writeFileSync(htmlPath, html); console.log('Data injected into jarvis.html!'); + if (!shouldOpen) return; + // Auto-open dashboard in default browser // NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start. // We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell. @@ -542,7 +579,8 @@ async function cliInject() { } // Run CLI if invoked directly -const isMain = process.argv[1] && fileURLToPath(import.meta.url).includes(process.argv[1].replace(/\\/g, '/')); +const isMain = process.argv[1] + && fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/'); if (isMain) { - cliInject(); + await cliInject(); } diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 7acfca3..77adcd2 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -1202,9 +1202,10 @@ function init(){ } document.addEventListener('DOMContentLoaded', () => { - const isServer = location.protocol !== 'file:'; + const hasInlineData = !!(D && D.meta); + const canProbeApi = location.protocol !== 'file:'; - if (isServer) { + if (canProbeApi && !hasInlineData) { // Server mode: always fetch live data from API (ignore any stale inline D) fetch('/api/data') .then(r => r.json()) @@ -1213,7 +1214,7 @@ document.addEventListener('DOMContentLoaded', () => { // Should not reach here — server routes to loading.html when no data if (D && D.meta) { init(); connectSSE(); } }); - } else if (D && D.meta) { + } else if (hasInlineData) { // File mode: use inline data init(); } From a8682c50d05b8e0ca3af02fa30dd31c904a9caa9 Mon Sep 17 00:00:00 2001 From: calesthio Date: Tue, 17 Mar 2026 19:34:08 -0700 Subject: [PATCH 12/12] Add zoom-aware symbology, fix globe popovers, expand geo coverage (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add zoom-aware symbology, fix globe popovers, expand geo coverage Map rendering improvements based on user feedback: - Globe markers now scale with camera altitude (onZoom hook) - Priority-based visibility culls low-priority markers at world view - Globe popovers use getScreenCoords for accurate positioning - Flat map labels hidden at low zoom, revealed progressively - Default globe altitude lowered from 2.5 to 1.8 for better fill - Americas region zoom tightened to CONUS focus Geographic coverage expansion: - 4 new OpenSky air theaters: Caribbean, Gulf of Guinea, Cape Route, Horn of Africa - Flight corridors now span Americas and Africa - NOAA alerts extract centroid lat/lon from GeoJSON geometry - EPA RadNet stations geocoded with hardcoded coords for 10 US cities - ISS + Tiangong positions estimated from TLE orbital elements - GDELT geoEvents() now called in briefing for mapped event points - New legend entries: Weather Alert, EPA RadNet, Space Station, GDELT Event * Fix null-safe coordinate checks and remove injected data blob - Use `!= null` instead of truthy checks for lat/lon in noaa.mjs and inject.mjs so valid 0-coordinates (equator/prime meridian) are not silently dropped - Reset jarvis.html `let D` back to null placeholder so generated runtime data is not part of the PR diff * Remove re-injected data blob from jarvis.html Reset let D back to null — previous commit was correct but inject.mjs build verification re-injected the payload. --- apis/sources/epa.mjs | 29 ++++--- apis/sources/gdelt.mjs | 15 ++++ apis/sources/noaa.mjs | 36 ++++++-- apis/sources/opensky.mjs | 4 + dashboard/inject.mjs | 49 ++++++++++- dashboard/public/jarvis.html | 159 +++++++++++++++++++++++++++-------- 6 files changed, 234 insertions(+), 58 deletions(-) diff --git a/apis/sources/epa.mjs b/apis/sources/epa.mjs index 9a9f0ce..583a2ad 100644 --- a/apis/sources/epa.mjs +++ b/apis/sources/epa.mjs @@ -14,16 +14,16 @@ const RADNET_AUX = `${BASE}/RADNET_AUX`; // Key US cities with RadNet monitoring stations const MONITORING_STATIONS = { - washingtonDC: { label: 'Washington, DC', state: 'DC' }, - newYork: { label: 'New York, NY', state: 'NY' }, - losAngeles: { label: 'Los Angeles, CA', state: 'CA' }, - chicago: { label: 'Chicago, IL', state: 'IL' }, - seattle: { label: 'Seattle, WA', state: 'WA' }, - denver: { label: 'Denver, CO', state: 'CO' }, - honolulu: { label: 'Honolulu, HI', state: 'HI' }, - anchorage: { label: 'Anchorage, AK', state: 'AK' }, - miami: { label: 'Miami, FL', state: 'FL' }, - sanFrancisco: { label: 'San Francisco, CA', state: 'CA' }, + washingtonDC: { label: 'Washington, DC', state: 'DC', lat: 38.9, lon: -77.0 }, + newYork: { label: 'New York, NY', state: 'NY', lat: 40.7, lon: -74.0 }, + losAngeles: { label: 'Los Angeles, CA', state: 'CA', lat: 34.1, lon: -118.2 }, + chicago: { label: 'Chicago, IL', state: 'IL', lat: 41.9, lon: -87.6 }, + seattle: { label: 'Seattle, WA', state: 'WA', lat: 47.6, lon: -122.3 }, + denver: { label: 'Denver, CO', state: 'CO', lat: 39.7, lon: -105.0 }, + honolulu: { label: 'Honolulu, HI', state: 'HI', lat: 21.3, lon: -157.9 }, + anchorage: { label: 'Anchorage, AK', state: 'AK', lat: 61.2, lon: -149.9 }, + miami: { label: 'Miami, FL', state: 'FL', lat: 25.8, lon: -80.2 }, + sanFrancisco: { label: 'San Francisco, CA', state: 'CA', lat: 37.8, lon: -122.4 }, }; // Analyte types that indicate concerning radiation @@ -76,8 +76,15 @@ export async function getResultsByAnalyte(analyte, opts = {}) { ); } +// Lookup coords by city name or state +const CITY_COORDS = Object.fromEntries( + Object.values(MONITORING_STATIONS).map(s => [s.label.split(',')[0].toUpperCase(), s]) +); + // Compact a reading for briefing output function compactReading(r) { + const city = (r.ANA_CITY || r.LOCATION || '').toUpperCase().trim(); + const station = CITY_COORDS[city]; return { location: r.ANA_CITY || r.LOCATION || 'Unknown', state: r.ANA_STATE || r.STATE || null, @@ -86,6 +93,8 @@ function compactReading(r) { unit: r.RESULT_UNIT || r.ANA_UNIT || null, collectDate: r.COLLECT_DATE || r.SAMPLE_DATE || null, medium: r.SAMPLE_TYPE || r.MEDIUM || null, + lat: station?.lat || null, + lon: station?.lon || null, }; } diff --git a/apis/sources/gdelt.mjs b/apis/sources/gdelt.mjs index 73ad9ba..34e1f82 100644 --- a/apis/sources/gdelt.mjs +++ b/apis/sources/gdelt.mjs @@ -104,11 +104,26 @@ export async function briefing() { keywords.some(k => a.title?.toLowerCase().includes(k)) ); + // Geo events — get mapped event locations (separate API, respects rate limit) + await delay(5500); + let geoPoints = []; + try { + const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' }); + geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({ + lat: f.geometry.coordinates[1], + lon: f.geometry.coordinates[0], + name: f.properties?.name || f.properties?.html || '', + count: f.properties?.count || 1, + type: f.properties?.type || 'event', + })); + } catch (e) { /* geo endpoint optional — don't break briefing */ } + return { source: 'GDELT', timestamp: new Date().toISOString(), totalArticles: articles.length, allArticles: articles, + geoPoints, conflicts: categorize(['military', 'conflict', 'war', 'strike', 'missile', 'attack', 'bomb', 'troops']), economy: categorize(['economy', 'recession', 'inflation', 'market', 'sanctions', 'tariff', 'trade', 'gdp']), health: categorize(['pandemic', 'outbreak', 'epidemic', 'disease', 'virus', 'health']), diff --git a/apis/sources/noaa.mjs b/apis/sources/noaa.mjs index 8762ad1..0c1d68f 100644 --- a/apis/sources/noaa.mjs +++ b/apis/sources/noaa.mjs @@ -57,15 +57,33 @@ export async function briefing() { wildfires: fire.length, other: other.length, }, - topAlerts: features.slice(0, 15).map(f => ({ - event: f.properties?.event, - severity: f.properties?.severity, - urgency: f.properties?.urgency, - headline: f.properties?.headline, - areas: f.properties?.areaDesc, - onset: f.properties?.onset, - expires: f.properties?.expires, - })), + topAlerts: features.slice(0, 15).map(f => { + // Extract centroid from GeoJSON geometry + let lat = null, lon = null; + const geo = f.geometry; + if (geo?.type === 'Polygon' && geo.coordinates?.[0]?.length) { + const coords = geo.coordinates[0]; + lat = coords.reduce((s, c) => s + c[1], 0) / coords.length; + lon = coords.reduce((s, c) => s + c[0], 0) / coords.length; + } else if (geo?.type === 'MultiPolygon' && geo.coordinates?.length) { + const coords = geo.coordinates[0][0]; + lat = coords.reduce((s, c) => s + c[1], 0) / coords.length; + lon = coords.reduce((s, c) => s + c[0], 0) / coords.length; + } else if (geo?.type === 'Point') { + [lon, lat] = geo.coordinates; + } + return { + event: f.properties?.event, + severity: f.properties?.severity, + urgency: f.properties?.urgency, + headline: f.properties?.headline, + areas: f.properties?.areaDesc, + onset: f.properties?.onset, + expires: f.properties?.expires, + lat: lat != null ? +lat.toFixed(3) : null, + lon: lon != null ? +lon.toFixed(3) : null, + }; + }), }; } diff --git a/apis/sources/opensky.mjs b/apis/sources/opensky.mjs index 5ec0712..70d595b 100644 --- a/apis/sources/opensky.mjs +++ b/apis/sources/opensky.mjs @@ -57,6 +57,10 @@ const HOTSPOTS = { baltics: { lamin: 53, lomin: 19, lamax: 60, lomax: 29, label: 'Baltic Region' }, southChinaSea: { lamin: 5, lomin: 105, lamax: 23, lomax: 122, label: 'South China Sea' }, koreanPeninsula: { lamin: 33, lomin: 124, lamax: 43, lomax: 132, label: 'Korean Peninsula' }, + caribbean: { lamin: 18, lomin: -90, lamax: 30, lomax: -72, label: 'Caribbean' }, + gulfOfGuinea: { lamin: -2, lomin: -5, lamax: 8, lomax: 10, label: 'Gulf of Guinea' }, + capeRoute: { lamin: -38, lomin: 12, lamax: -28, lomax: 24, label: 'Cape Route' }, + hornOfAfrica: { lamin: 5, lomin: 40, lamax: 15, lomax: 55, label: 'Horn of Africa' }, }; // Briefing — check hotspot regions for flight activity diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index a4f5401..9dfd39b 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -367,16 +367,54 @@ export async function synthesize(data) { const defense = (data.sources.USAspending?.recentDefenseContracts || []).slice(0, 5).map(c => ({ recipient: c.recipient?.substring(0, 40), amount: c.amount, desc: c.description?.substring(0, 80) })); - const noaa = { totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0 }; + const noaa = { + totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0, + alerts: (data.sources.NOAA?.topAlerts || []).filter(a => a.lat != null && a.lon != null).slice(0, 10).map(a => ({ + event: a.event, severity: a.severity, headline: a.headline?.substring(0, 120), + lat: a.lat, lon: a.lon + })) + }; + + // EPA RadNet — pass through geo-tagged readings + const epaData = data.sources.EPA || {}; + const epaStations = []; + const seenEpa = new Set(); + for (const r of (epaData.readings || [])) { + if (r.lat == null || r.lon == null) continue; + const key = `${r.lat},${r.lon}`; + if (seenEpa.has(key)) continue; + seenEpa.add(key); + epaStations.push({ location: r.location, state: r.state, lat: r.lat, lon: r.lon, analyte: r.analyte, result: r.result, unit: r.unit }); + } + const epa = { totalReadings: epaData.totalReadings || 0, stations: epaStations.slice(0, 10) }; // Space/CelesTrak satellite data const spaceData = data.sources.Space || {}; + // Approximate subsatellite position from TLE orbital elements + function estimateSatPosition(sat) { + if (!sat?.inclination || !sat?.epoch) return null; + const epoch = new Date(sat.epoch); + const now = new Date(); + const elapsed = (now - epoch) / 1000; + const period = (sat.period || 92.7) * 60; // minutes to seconds + const orbits = elapsed / period; + const frac = orbits % 1; + const lat = sat.inclination * Math.sin(frac * 2 * Math.PI); + const lonShift = (elapsed / 86400) * 360; + const orbitLon = frac * 360; + const lon = ((orbitLon - lonShift) % 360 + 540) % 360 - 180; + return { lat: +lat.toFixed(2), lon: +lon.toFixed(2), name: sat.name }; + } + const issPos = estimateSatPosition(spaceData.iss); + const spaceStations = (spaceData.spaceStations || []).map(s => estimateSatPosition(s)).filter(Boolean); const space = { totalNewObjects: spaceData.totalNewObjects || 0, militarySats: spaceData.militarySatellites || 0, militaryByCountry: spaceData.militaryByCountry || {}, constellations: spaceData.constellations || {}, iss: spaceData.iss || null, + issPosition: issPos, + stationPositions: spaceStations.slice(0, 5), recentLaunches: (spaceData.recentLaunches || []).slice(0, 10).map(l => ({ name: l.name, country: l.country, epoch: l.epoch, apogee: l.apogee, perigee: l.perigee, type: l.objectType @@ -398,7 +436,7 @@ export async function synthesize(data) { })) }; - // GDELT news articles + // GDELT news articles + geo events const gdeltData = data.sources.GDELT || {}; const gdelt = { totalArticles: gdeltData.totalArticles || 0, @@ -406,7 +444,10 @@ export async function synthesize(data) { economy: (gdeltData.economy || []).length, health: (gdeltData.health || []).length, crisis: (gdeltData.crisis || []).length, - topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80)) + topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80)), + geoPoints: (gdeltData.geoPoints || []).slice(0, 20).map(p => ({ + lat: p.lat, lon: p.lon, name: (p.name || '').substring(0, 80), count: p.count || 1 + })) }; const health = Object.entries(data.sources).map(([name, src]) => ({ @@ -457,7 +498,7 @@ export async function synthesize(data) { meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals, sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, - who, fred, energy, bls, treasury, gscpi, defense, noaa, acled, gdelt, space, health, news, + who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news, markets, // Live Yahoo Finance market data ideas: [], ideasSource: 'disabled', // newsFeed for ticker (merged RSS + GDELT + Telegram) diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 77adcd2..45b5b8e 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -298,12 +298,12 @@ let flightsVisible = true; let isFlat = true; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; const regionPOV = { - world: { lat: 20, lng: 20, altitude: 2.5 }, - americas: { lat: 15, lng: -80, altitude: 1.6 }, - europe: { lat: 50, lng: 15, altitude: 1.2 }, - middleEast: { lat: 28, lng: 45, altitude: 1.4 }, - asiaPacific: { lat: 25, lng: 110, altitude: 1.6 }, - africa: { lat: 5, lng: 20, altitude: 1.5 } + world: { lat: 20, lng: 20, altitude: 1.8 }, + americas: { lat: 35, lng: -95, altitude: 1.0 }, + europe: { lat: 50, lng: 15, altitude: 1.0 }, + middleEast: { lat: 28, lng: 45, altitude: 1.1 }, + asiaPacific: { lat: 25, lng: 110, altitude: 1.2 }, + africa: { lat: 5, lng: 20, altitude: 1.2 } }; // === TOPBAR === @@ -414,7 +414,7 @@ function initMap(){ .pointRadius(d => d.size || 0.3) .pointColor(d => d.color) .pointLabel(d => `${d.popHead||''}
${d.popMeta||''}`) - .onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta); }) + .onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta, pt.lat, pt.lng, pt.alt); }) .onPointHover(pt => { document.getElementById('globeViz').style.cursor = pt ? 'pointer' : 'grab'; }) // Arcs layer (flight corridors) .arcColor(d => d.color) @@ -502,7 +502,7 @@ function initMap(){ // Legend document.getElementById('mapLegend').innerHTML= [{c:'#64f0c8',l:'Air Traffic'},{c:'#ff5f63',l:'Thermal/Fire'},{c:'rgba(255,120,80,0.8)',l:'Conflict'},{c:'#44ccff',l:'SDR Receiver'}, - {c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'}] + {c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'},{c:'#ff9800',l:'Weather Alert'},{c:'#cddc39',l:'EPA RadNet'},{c:'#ffffff',l:'Space Station'},{c:'#6495ed',l:'GDELT Event'}] .map(x=>`
${x.l}
`).join(''); } @@ -511,12 +511,12 @@ function plotMarkers(){ const labels = []; // === Air hotspots (green) === - const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}]; + const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; D.air.forEach((a,i)=>{ const c=airCoords[i]; if(!c) return; points.push({ lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015, - color:'rgba(100,240,200,0.8)', type:'air', + color:'rgba(100,240,200,0.8)', type:'air', priority:1, label: a.region.replace(' Region','')+' '+a.total, popHead: a.region, popMeta: 'Air Activity', popText: `${a.total} aircraft tracked
No callsign: ${a.noCallsign}
High altitude: ${a.highAlt}
Top: ${a.top.slice(0,3).map(t=>t[0]+' ('+t[1]+')').join(', ')}` @@ -529,7 +529,7 @@ function plotMarkers(){ t.fires.forEach(f=>{ points.push({ lat:f.lat, lng:f.lon, size:0.12+Math.min(f.frp/200,0.3), alt:0.008, - color:'rgba(255,95,99,0.7)', type:'thermal', + color:'rgba(255,95,99,0.7)', type:'thermal', priority:3, popHead:'Thermal Detection', popMeta:'FIRMS Satellite', popText:`Region: ${t.region}
FRP: ${f.frp.toFixed(1)} MW
Total: ${t.det.toLocaleString()}
Night: ${t.night.toLocaleString()}` }); @@ -540,7 +540,7 @@ function plotMarkers(){ D.chokepoints.forEach(cp=>{ points.push({ lat:cp.lat, lng:cp.lon, size:0.35, alt:0.02, - color:'rgba(179,136,255,0.8)', type:'maritime', + color:'rgba(179,136,255,0.8)', type:'maritime', priority:1, popHead:cp.label, popMeta:'Maritime Intelligence', popText:cp.note }); labels.push({lat:cp.lat, lng:cp.lon+1.5, text:cp.label, size:0.3, color:'rgba(179,136,255,0.6)'}); @@ -552,7 +552,7 @@ function plotMarkers(){ const c=nukeCoords[i]; if(!c) return; points.push({ lat:c.lat, lng:c.lon, size:0.3, alt:0.012, - color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', + color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', priority:2, popHead:n.site, popMeta:'Radiation Monitoring', popText:`Status: ${n.anom?'ANOMALY':'Normal'}
Avg CPM: ${n.cpm?.toFixed(1)||'No data'}
Readings: ${n.n}` }); @@ -563,7 +563,7 @@ function plotMarkers(){ z.receivers.forEach(r=>{ points.push({ lat:r.lat, lng:r.lon, size:0.15, alt:0.005, - color:'rgba(68,204,255,0.6)', type:'sdr', + color:'rgba(68,204,255,0.6)', type:'sdr', priority:3, popHead:'SDR Receiver', popMeta:'KiwiSDR Network', popText:`${r.name}
Zone: ${z.region}
${z.count} in zone` }); @@ -576,7 +576,7 @@ function plotMarkers(){ const post=D.tg.urgent[o.idx]; if(!post) return; points.push({ lat:o.lat, lng:o.lon, size:0.3, alt:0.018, - color:'rgba(255,184,76,0.8)', type:'osint', + color:'rgba(255,184,76,0.8)', type:'osint', priority:2, popHead:(post.channel||'').toUpperCase(), popMeta:`${post.views?.toLocaleString()||'?'} views`, popText:cleanText(post.text?.substring(0,200)||'') }); @@ -588,7 +588,7 @@ function plotMarkers(){ const c=whoGeo[i]; if(!c) return; points.push({ lat:c.lat, lng:c.lon, size:0.25, alt:0.01, - color:'rgba(105,240,174,0.7)', type:'health', + color:'rgba(105,240,174,0.7)', type:'health', priority:2, popHead:w.title, popMeta:'WHO Outbreak', popText:w.summary||'' }); }); @@ -597,12 +597,53 @@ function plotMarkers(){ (D.news||[]).forEach(n=>{ points.push({ lat:n.lat, lng:n.lon, size:0.2, alt:0.008, - color:'rgba(129,212,250,0.7)', type:'news', + color:'rgba(129,212,250,0.7)', type:'news', priority:3, popHead:n.source+' NEWS', popMeta:n.region+' · '+getAge(n.date), popText:cleanText(n.title) }); }); + // === NOAA severe weather alerts (orange) === + (D.noaa?.alerts||[]).forEach(a=>{ + points.push({ + lat:a.lat, lng:a.lon, size:0.22, alt:0.01, + color:'rgba(255,152,0,0.8)', type:'weather', priority:2, + popHead:a.event, popMeta:'NOAA/NWS · '+a.severity, + popText:a.headline||'' + }); + }); + + // === EPA RadNet stations (lime green) === + (D.epa?.stations||[]).forEach(s=>{ + points.push({ + lat:s.lat, lng:s.lon, size:0.18, alt:0.006, + color:'rgba(205,220,57,0.7)', type:'radiation', priority:3, + popHead:'RadNet: '+s.location, popMeta:'EPA Radiation Monitor', + popText:`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}
State: ${s.state}` + }); + }); + + // === ISS + Space Stations (bright white, pulsing) === + (D.space?.stationPositions||[]).forEach(s=>{ + points.push({ + lat:s.lat, lng:s.lon, size:0.4, alt:0.04, + color:'rgba(255,255,255,0.95)', type:'space', priority:1, + popHead:s.name, popMeta:'Space Station (approx.)', + popText:`Orbital position estimate
Lat: ${s.lat}° Lon: ${s.lon}°` + }); + labels.push({lat:s.lat, lng:s.lon+3, text:s.name.split('(')[0].trim(), size:0.35, color:'rgba(255,255,255,0.7)'}); + }); + + // === GDELT geo events (steel blue) === + (D.gdelt?.geoPoints||[]).forEach(g=>{ + points.push({ + lat:g.lat, lng:g.lon, size:0.15+Math.min(g.count/50,0.2), alt:0.007, + color:'rgba(100,149,237,0.6)', type:'gdelt', priority:3, + popHead:'GDELT Event', popMeta:g.count+' reports', + popText:g.name||'Global event detection' + }); + }); + // Set points on globe globe.pointsData(points); globe.labelsData(labels); @@ -625,7 +666,9 @@ function plotMarkers(){ const airCoordsFlight = [ {region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120}, {region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24}, - {region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127} + {region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127}, + {region:'Caribbean',lat:25,lon:-80}, {region:'Gulf of Guinea',lat:4,lon:2}, + {region:'Cape Route',lat:-34,lon:18}, {region:'Horn of Africa',lat:10,lon:51} ]; const globalHubs = [ {lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4}, @@ -667,14 +710,41 @@ function plotMarkers(){ }); }); globe.arcsData(arcs); + + // Zoom-aware marker sizing: scale markers and labels with camera altitude + const onGlobeZoom = () => { + const alt = globe.pointOfView().altitude; + const sf = Math.max(0.6, Math.min(2.5, 1.5 / alt)); + globe.pointRadius(d => (d.size || 0.3) * sf); + // Hide labels when zoomed far out to reduce clutter + const showLabels = alt < 1.8; + globe.labelSize(d => showLabels ? (d.size || 0.4) : 0); + // Scale arc strokes with zoom + globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt))); + // Priority-based point visibility: hide low-priority markers when zoomed out + if(alt > 2.0){ + globe.pointsData(points.filter(p => (p.priority||3) <= 1)); + } else if(alt > 1.2){ + globe.pointsData(points.filter(p => (p.priority||3) <= 2)); + } else { + globe.pointsData(points); + } + }; + if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom); } -function showPopup(event,head,text,meta){ +function showPopup(event,head,text,meta,lat,lng,alt){ const popup=document.getElementById('mapPopup'); const container=document.getElementById('mapContainer'); const rect=container.getBoundingClientRect(); let left, top; - if(event && event.clientX != null){ + if(!isFlat && lat!=null && globe && typeof globe.getScreenCoords==='function'){ + const sc=globe.getScreenCoords(lat,lng,alt||0.01); + if(!sc||isNaN(sc.x)||isNaN(sc.y)||sc.x<0||sc.y<0||sc.x>rect.width||sc.y>rect.height){ + if(event&&event.clientX!=null){left=event.clientX-rect.left+10;top=event.clientY-rect.top-10;} + else return; + } else {left=sc.x+10;top=sc.y-10;} + } else if(event && event.clientX != null){ left=event.clientX - rect.left + 10; top=event.clientY - rect.top - 10; } else { @@ -711,7 +781,7 @@ function toggleFlights() { // === FLAT/GLOBE TOGGLE === const flatRegionBounds = { - world:[[-180,-60],[180,80]], americas:[[-170,-56],[-30,72]], europe:[[-12,34],[45,72]], + world:[[-180,-60],[180,80]], americas:[[-130,10],[-60,55]], europe:[[-12,34],[45,72]], middleEast:[[24,10],[65,45]], asiaPacific:[[60,-12],[180,55]], africa:[[-20,-36],[55,38]] }; @@ -745,7 +815,15 @@ function initFlatMap(){ flatG.attr('transform',event.transform); const k=event.transform.k; flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)}); - flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px'); + flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px') + .style('display',k>=2.5?'block':'none'); + // Priority-based visibility: hide low-priority markers at low zoom + flatG.selectAll('[data-priority]').style('display',function(){ + const p=+this.dataset.priority; + if(p<=1) return 'block'; + if(p<=2) return k>=2?'block':'none'; + return k>=3.5?'block':'none'; + }); }); flatSvg.call(flatZoom); drawFlatMap(); @@ -765,58 +843,69 @@ function drawFlatMap(){ function plotFlatMarkers(){ const mg=flatG.append('g').attr('class','markers'); const proj=flatProjection; - const addPt=(lat,lon,r,fill,stroke,onClick)=>{ + const addPt=(lat,lon,r,fill,stroke,onClick,priority)=>{ const[x,y]=proj([lon,lat]);if(!x||!y)return null; - const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer'); + const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3); if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)}); g.append('circle').attr('class','marker-circle').attr('r',r).attr('data-base-r',r).attr('fill',fill).attr('stroke',stroke).attr('stroke-width',0.8); return g; }; // Air - const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}]; + const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; D.air.forEach((a,i)=>{ const c=airCoords[i];if(!c)return; const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)', - ev=>showPopup(ev,a.region,`${a.total} aircraft
No callsign: ${a.noCallsign}
High alt: ${a.highAlt}`,'Air Activity')); + ev=>showPopup(ev,a.region,`${a.total} aircraft
No callsign: ${a.noCallsign}
High alt: ${a.highAlt}`,'Air Activity'),1); if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total); }); // Thermal D.thermal.forEach(t=>t.fires.forEach(f=>{ addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)', - ev=>showPopup(ev,'Thermal',`${t.region}
FRP: ${f.frp.toFixed(1)} MW`,'FIRMS')); + ev=>showPopup(ev,'Thermal',`${t.region}
FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3); })); // Chokepoints D.chokepoints.forEach(cp=>{ const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return; - const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer') + const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1) .on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')}); g.append('rect').attr('x',-4).attr('y',-4).attr('width',8).attr('height',8).attr('fill','rgba(179,136,255,0.7)').attr('stroke','rgba(179,136,255,0.3)').attr('stroke-width',0.5).attr('transform','rotate(45)'); g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','var(--dim)').attr('font-size','8px').attr('font-family','var(--mono)').text(cp.label); }); // Nuclear const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}]; - D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'))}); + D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)}); // SDR - D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}
${z.region}`,'KiwiSDR'))})); + D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}
${z.region}`,'KiwiSDR'),3)})); // OSINT const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}]; - osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`))}); + osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)}); // WHO const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}]; - D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'))}); + D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)}); // News - (D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region))}); + (D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)}); + // NOAA weather + (D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)}); + // EPA RadNet + (D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)}); + // Space stations + (D.space?.stationPositions||[]).forEach(s=>{ + const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1); + if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim()); + }); + // GDELT geo events + (D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)}); // ACLED (D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{ const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return; const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5)); - const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer') + const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1) .on('click',ev=>{ev.stopPropagation();showPopup(ev,e.type||'CONFLICT',`${e.fatalities} fatalities
${e.location}, ${e.country}`,'ACLED')}); g.append('circle').attr('class','conflict-ring marker-circle').attr('r',r).attr('data-base-r',r).attr('fill','none').attr('stroke','rgba(255,120,80,0.7)').attr('stroke-width',1.5); g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); }); // Flight corridors - const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}]; + const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; const cG=flatG.append('g').attr('class','corridors-layer'); for(let i=0;i