Files
worldmonitor/src/App.ts
Elie Habib c0bf784d21 feat(finance): crypto sectors heatmap + DeFi/AI/Alt token panels + expanded crypto news (#1900)
* feat(market): add crypto sectors heatmap and token panels (DeFi, AI, Other) backend

- Add shared/crypto-sectors.json, defi-tokens.json, ai-tokens.json, other-tokens.json configs
- Add scripts/seed-crypto-sectors.mjs and seed-token-panels.mjs seed scripts
- Add proto messages for ListCryptoSectors, ListDefiTokens, ListAiTokens, ListOtherTokens
- Add change7d field (field 6) to CryptoQuote proto message
- Run buf generate to produce updated TypeScript bindings
- Add server handlers for all 4 new RPCs reading from seeded Redis cache
- Wire handlers into marketHandler and register cache keys with BOOTSTRAP_TIERS=slow
- Wire seedCryptoSectors and seedTokenPanels into ais-relay.cjs seedAllMarketData loop

* feat(panels): add crypto sectors heatmap and token panels (DeFi, AI, Other)

- Add TokenData interface to src/types/index.ts
- Wire ListCryptoSectorsResponse/ListDefiTokensResponse/ListAiTokensResponse/ListOtherTokensResponse into market service with circuit breakers and hydration fallbacks
- Add CryptoHeatmapPanel, TokenListPanel, DefiTokensPanel, AiTokensPanel, OtherTokensPanel to MarketPanel.ts
- Register 4 new panels in panels.ts FINANCE_PANELS and cryptoDigital category
- Instantiate new panels in panel-layout.ts
- Load data in data-loader.ts loadMarkets() alongside existing crypto fetch

* fix(crypto-panels): resolve test failures and type errors post-review

- Add @ts-nocheck to regenerated market service_server/client (matches repo convention)
- Add 4 new RPC routes to RPC_CACHE_TIER in gateway.ts (route-cache-tier test)
- Sync scripts/shared/ with shared/ for new token/sector JSON configs
- Restore non-market generated files to origin/main state (avoid buf version diff)

* fix(crypto-panels): address code review findings (P1-P3)

- ais-relay seedTokenPanels: add empty-guard before Redis write to
  prevent overwriting cached data when all IDs are unresolvable
- server _feeds.ts: sync 4 missing crypto feeds (Wu Blockchain, Messari,
  NFT News, Stablecoin Policy) with client-side feeds.ts
- data-loader: expose panel refs outside try block so catch can call
  showRetrying(); log error instead of swallowing silently
- MarketPanel: replace hardcoded English error strings with t() calls
  (failedSectorData / failedCryptoData) to honour user locale
- seed-token-panels.mjs: remove unused getRedisCredentials import
- cache-keys.ts: one BOOTSTRAP_TIERS entry per line for consistency

* fix(crypto-panels): three correctness fixes for RSS proxy, refresh, and Redis write visibility

- api/_rss-allowed-domains.js: add 7 new crypto domains (decrypt.co,
  blockworks.co, thedefiant.io, bitcoinmagazine.com, www.dlnews.com,
  cryptoslate.com, unchainedcrypto.com) so rss-proxy.js accepts the
  new finance feeds instead of rejecting them as disallowed hosts
- src/App.ts: add crypto-heatmap/defi-tokens/ai-tokens/other-tokens to
  the periodic markets refresh viewport condition so panels on screen
  continue receiving live updates, not just the initial load
- ais-relay seedTokenPanels: capture upstashSet return values and log
  PARTIAL if any Redis write fails, matching seedCryptoSectors pattern
2026-03-20 10:34:20 +04:00

914 lines
39 KiB
TypeScript

import type { Monitor, PanelConfig, MapLayers } from '@/types';
import type { AppContext } from '@/app/app-context';
import {
REFRESH_INTERVALS,
DEFAULT_PANELS,
DEFAULT_MAP_LAYERS,
MOBILE_DEFAULT_MAP_LAYERS,
STORAGE_KEYS,
SITE_VARIANT,
} from '@/config';
import { sanitizeLayersForVariant } from '@/config/map-layer-definitions';
import type { MapVariant } from '@/config/map-layer-definitions';
import { initDB, cleanOldSnapshots, isAisConfigured, initAisStream, isOutagesConfigured, disconnectAisStream } from '@/services';
import { mlWorker } from '@/services/ml-worker';
import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } from '@/services/ai-flow-settings';
import { startLearning } from '@/services/country-instability';
import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice } from '@/utils';
import type { ParsedMapUrlState } from '@/utils';
import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner } from '@/components';
import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts';
import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel';
import type { StablecoinPanel } from '@/components/StablecoinPanel';
import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel';
import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel';
import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel';
import type { StrategicRiskPanel } from '@/components/StrategicRiskPanel';
import type { GulfEconomiesPanel } from '@/components/GulfEconomiesPanel';
import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime';
import { getSecretState } from '@/services/runtime-config';
import { BETA_MODE } from '@/config/beta';
import { trackEvent, trackDeeplinkOpened } from '@/services/analytics';
import { preloadCountryGeometry, getCountryNameByCode } from '@/services/country-geometry';
import { initI18n } from '@/services/i18n';
import { computeDefaultDisabledSources, getLocaleBoostedSources, getTotalFeedCount } from '@/config/feeds';
import { fetchBootstrapData } from '@/services/bootstrap';
import { DesktopUpdater } from '@/app/desktop-updater';
import { CountryIntelManager } from '@/app/country-intel';
import { SearchManager } from '@/app/search-manager';
import { RefreshScheduler } from '@/app/refresh-scheduler';
import { PanelLayoutManager } from '@/app/panel-layout';
import { DataLoaderManager } from '@/app/data-loader';
import { EventHandlerManager } from '@/app/event-handlers';
import { resolveUserRegion, resolvePreciseUserCoordinates, type PreciseCoordinates } from '@/utils/user-location';
import { showProBanner } from '@/components/ProBanner';
import {
CorrelationEngine,
militaryAdapter,
escalationAdapter,
economicAdapter,
disasterAdapter,
} from '@/services/correlation-engine';
import type { CorrelationPanel } from '@/components/CorrelationPanel';
const CYBER_LAYER_ENABLED = import.meta.env.VITE_ENABLE_CYBER_LAYER === 'true';
export type { CountryBriefSignals } from '@/app/app-context';
export class App {
private state: AppContext;
private pendingDeepLinkCountry: string | null = null;
private pendingDeepLinkExpanded = false;
private pendingDeepLinkStoryCode: string | null = null;
private panelLayout: PanelLayoutManager;
private dataLoader: DataLoaderManager;
private eventHandlers: EventHandlerManager;
private searchManager: SearchManager;
private countryIntel: CountryIntelManager;
private refreshScheduler: RefreshScheduler;
private desktopUpdater: DesktopUpdater;
private modules: { destroy(): void }[] = [];
private unsubAiFlow: (() => void) | null = null;
private visiblePanelPrimed = new Set<string>();
private visiblePanelPrimeRaf: number | null = null;
private readonly handleViewportPrime = (): void => {
if (this.visiblePanelPrimeRaf !== null) return;
this.visiblePanelPrimeRaf = window.requestAnimationFrame(() => {
this.visiblePanelPrimeRaf = null;
void this.primeVisiblePanelData();
});
};
private isPanelNearViewport(panelId: string, marginPx = 400): boolean {
const panel = this.state.panels[panelId] as { isNearViewport?: (marginPx?: number) => boolean } | undefined;
return panel?.isNearViewport?.(marginPx) ?? false;
}
private isAnyPanelNearViewport(panelIds: string[], marginPx = 400): boolean {
return panelIds.some((panelId) => this.isPanelNearViewport(panelId, marginPx));
}
private shouldRefreshIntelligence(): boolean {
return this.isAnyPanelNearViewport(['cii', 'strategic-risk', 'strategic-posture'])
|| !!this.state.countryBriefPage?.isVisible();
}
private shouldRefreshFirms(): boolean {
return this.isPanelNearViewport('satellite-fires');
}
private shouldRefreshCorrelation(): boolean {
return this.isAnyPanelNearViewport(['military-correlation', 'escalation-correlation', 'economic-correlation', 'disaster-correlation']);
}
private async primeVisiblePanelData(forceAll = false): Promise<void> {
const tasks: Promise<unknown>[] = [];
const primeTask = (key: string, task: () => Promise<unknown>): void => {
if (this.visiblePanelPrimed.has(key) || this.state.inFlight.has(key)) return;
const wrapped = (async () => {
this.state.inFlight.add(key);
try {
await task();
this.visiblePanelPrimed.add(key);
} finally {
this.state.inFlight.delete(key);
}
})();
tasks.push(wrapped);
};
const shouldPrime = (id: string): boolean => forceAll || this.isPanelNearViewport(id);
const shouldPrimeAny = (ids: string[]): boolean => forceAll || this.isAnyPanelNearViewport(ids);
if (shouldPrime('service-status')) {
const panel = this.state.panels['service-status'] as ServiceStatusPanel | undefined;
if (panel) primeTask('service-status', () => panel.fetchStatus());
}
if (shouldPrime('macro-signals')) {
const panel = this.state.panels['macro-signals'] as MacroSignalsPanel | undefined;
if (panel) primeTask('macro-signals', () => panel.fetchData());
}
if (shouldPrime('etf-flows')) {
const panel = this.state.panels['etf-flows'] as ETFFlowsPanel | undefined;
if (panel) primeTask('etf-flows', () => panel.fetchData());
}
if (shouldPrime('stablecoins')) {
const panel = this.state.panels.stablecoins as StablecoinPanel | undefined;
if (panel) primeTask('stablecoins', () => panel.fetchData());
}
if (shouldPrime('telegram-intel')) {
primeTask('telegram-intel', () => this.dataLoader.loadTelegramIntel());
}
if (shouldPrime('gulf-economies')) {
const panel = this.state.panels['gulf-economies'] as GulfEconomiesPanel | undefined;
if (panel) primeTask('gulf-economies', () => panel.fetchData());
}
if (shouldPrimeAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex'])) {
primeTask('markets', () => this.dataLoader.loadMarkets());
}
if (shouldPrime('polymarket')) {
primeTask('predictions', () => this.dataLoader.loadPredictions());
}
if (shouldPrime('economic')) {
primeTask('fred', () => this.dataLoader.loadFredData());
primeTask('spending', () => this.dataLoader.loadGovernmentSpending());
primeTask('bis', () => this.dataLoader.loadBisData());
}
if (shouldPrime('energy-complex')) {
primeTask('oil', () => this.dataLoader.loadOilAnalytics());
}
if (shouldPrime('trade-policy')) {
primeTask('tradePolicy', () => this.dataLoader.loadTradePolicy());
}
if (shouldPrime('supply-chain')) {
primeTask('supplyChain', () => this.dataLoader.loadSupplyChain());
}
if (SITE_VARIANT === 'finance' && getSecretState('WORLDMONITOR_API_KEY').present) {
if (shouldPrime('stock-analysis')) {
primeTask('stockAnalysis', () => this.dataLoader.loadStockAnalysis());
}
if (shouldPrime('stock-backtest')) {
primeTask('stockBacktest', () => this.dataLoader.loadStockBacktest());
}
if (shouldPrime('daily-market-brief')) {
primeTask('dailyMarketBrief', () => this.dataLoader.loadDailyMarketBrief());
}
}
if (tasks.length > 0) {
await Promise.allSettled(tasks);
}
}
constructor(containerId: string) {
const el = document.getElementById(containerId);
if (!el) throw new Error(`Container ${containerId} not found`);
const PANEL_ORDER_KEY = 'panel-order';
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
const isMobile = isMobileDevice();
const isDesktopApp = isDesktopRuntime();
const monitors = loadFromStorage<Monitor[]>(STORAGE_KEYS.monitors, []);
// Use mobile-specific defaults on first load (no saved layers)
const defaultLayers = isMobile ? MOBILE_DEFAULT_MAP_LAYERS : DEFAULT_MAP_LAYERS;
let mapLayers: MapLayers;
let panelSettings: Record<string, PanelConfig>;
// Check if variant changed - reset all settings to variant defaults
const storedVariant = localStorage.getItem('worldmonitor-variant');
const currentVariant = SITE_VARIANT;
console.log(`[App] Variant check: stored="${storedVariant}", current="${currentVariant}"`);
if (storedVariant !== currentVariant) {
// Variant changed - use defaults for new variant, clear old settings
console.log('[App] Variant changed - resetting to defaults');
localStorage.setItem('worldmonitor-variant', currentVariant);
localStorage.removeItem(STORAGE_KEYS.mapLayers);
localStorage.removeItem(STORAGE_KEYS.panels);
localStorage.removeItem(PANEL_ORDER_KEY);
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom');
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom-set');
localStorage.removeItem(PANEL_SPANS_KEY);
mapLayers = sanitizeLayersForVariant({ ...defaultLayers }, currentVariant as MapVariant);
panelSettings = { ...DEFAULT_PANELS };
} else {
mapLayers = sanitizeLayersForVariant(
loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, defaultLayers),
currentVariant as MapVariant,
);
panelSettings = loadFromStorage<Record<string, PanelConfig>>(
STORAGE_KEYS.panels,
DEFAULT_PANELS
);
// One-time migration: preserve user preferences across panel key renames.
const PANEL_KEY_RENAMES_MIGRATION_KEY = 'worldmonitor-panel-key-renames-v2.6';
if (!localStorage.getItem(PANEL_KEY_RENAMES_MIGRATION_KEY)) {
const keyRenames: Array<[string, string]> = [
['live-youtube', 'live-webcams'],
['pinned-webcams', 'windy-webcams'],
];
let migrated = false;
for (const [legacyKey, nextKey] of keyRenames) {
if (!panelSettings[legacyKey] || panelSettings[nextKey]) continue;
panelSettings[nextKey] = {
...DEFAULT_PANELS[nextKey],
...panelSettings[legacyKey],
name: DEFAULT_PANELS[nextKey]?.name ?? panelSettings[legacyKey].name,
};
delete panelSettings[legacyKey];
migrated = true;
}
if (migrated) saveToStorage(STORAGE_KEYS.panels, panelSettings);
localStorage.setItem(PANEL_KEY_RENAMES_MIGRATION_KEY, 'done');
}
// Merge in any new panels that didn't exist when settings were saved
for (const [key, config] of Object.entries(DEFAULT_PANELS)) {
if (!(key in panelSettings)) {
panelSettings[key] = { ...config };
}
}
console.log('[App] Loaded panel settings from storage:', Object.entries(panelSettings).filter(([_, v]) => !v.enabled).map(([k]) => k));
// One-time migration: reorder panels for existing users (v1.9 panel layout)
const PANEL_ORDER_MIGRATION_KEY = 'worldmonitor-panel-order-v1.9';
if (!localStorage.getItem(PANEL_ORDER_MIGRATION_KEY)) {
const savedOrder = localStorage.getItem(PANEL_ORDER_KEY);
if (savedOrder) {
try {
const order: string[] = JSON.parse(savedOrder);
const priorityPanels = ['insights', 'strategic-posture', 'cii', 'strategic-risk'];
const filtered = order.filter(k => !priorityPanels.includes(k) && k !== 'live-news');
const liveNewsIdx = order.indexOf('live-news');
const newOrder = liveNewsIdx !== -1 ? ['live-news'] : [];
newOrder.push(...priorityPanels.filter(p => order.includes(p)));
newOrder.push(...filtered);
localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder));
console.log('[App] Migrated panel order to v1.9 layout');
} catch {
// Invalid saved order, will use defaults
}
}
localStorage.setItem(PANEL_ORDER_MIGRATION_KEY, 'done');
}
// Tech variant migration: move insights to top (after live-news)
if (currentVariant === 'tech') {
const TECH_INSIGHTS_MIGRATION_KEY = 'worldmonitor-tech-insights-top-v1';
if (!localStorage.getItem(TECH_INSIGHTS_MIGRATION_KEY)) {
const savedOrder = localStorage.getItem(PANEL_ORDER_KEY);
if (savedOrder) {
try {
const order: string[] = JSON.parse(savedOrder);
const filtered = order.filter(k => k !== 'insights' && k !== 'live-news');
const newOrder: string[] = [];
if (order.includes('live-news')) newOrder.push('live-news');
if (order.includes('insights')) newOrder.push('insights');
newOrder.push(...filtered);
localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder));
console.log('[App] Tech variant: Migrated insights panel to top');
} catch {
// Invalid saved order, will use defaults
}
}
localStorage.setItem(TECH_INSIGHTS_MIGRATION_KEY, 'done');
}
}
}
// One-time migration: prune removed panel keys from stored settings and order
const PANEL_PRUNE_KEY = 'worldmonitor-panel-prune-v1';
if (!localStorage.getItem(PANEL_PRUNE_KEY)) {
const validKeys = new Set(Object.keys(DEFAULT_PANELS));
let pruned = false;
for (const key of Object.keys(panelSettings)) {
if (!validKeys.has(key) && key !== 'runtime-config') {
delete panelSettings[key];
pruned = true;
}
}
if (pruned) saveToStorage(STORAGE_KEYS.panels, panelSettings);
for (const orderKey of [PANEL_ORDER_KEY, PANEL_ORDER_KEY + '-bottom-set', PANEL_ORDER_KEY + '-bottom']) {
try {
const raw = localStorage.getItem(orderKey);
if (!raw) continue;
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) continue;
const filtered = arr.filter((k: string) => validKeys.has(k));
if (filtered.length !== arr.length) localStorage.setItem(orderKey, JSON.stringify(filtered));
} catch { localStorage.removeItem(orderKey); }
}
localStorage.setItem(PANEL_PRUNE_KEY, 'done');
}
// One-time migration: clear stale panel ordering and sizing state
const LAYOUT_RESET_MIGRATION_KEY = 'worldmonitor-layout-reset-v2.5';
if (!localStorage.getItem(LAYOUT_RESET_MIGRATION_KEY)) {
const hadSavedOrder = !!localStorage.getItem(PANEL_ORDER_KEY);
const hadSavedSpans = !!localStorage.getItem(PANEL_SPANS_KEY);
if (hadSavedOrder || hadSavedSpans) {
localStorage.removeItem(PANEL_ORDER_KEY);
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom');
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom-set');
localStorage.removeItem(PANEL_SPANS_KEY);
console.log('[App] Applied layout reset migration (v2.5): cleared panel order/spans');
}
localStorage.setItem(LAYOUT_RESET_MIGRATION_KEY, 'done');
}
// Desktop key management panel must always remain accessible in Tauri.
if (isDesktopApp) {
if (!panelSettings['runtime-config']) {
panelSettings['runtime-config'] = {
name: 'Desktop Configuration',
enabled: true,
priority: 2,
};
saveToStorage(STORAGE_KEYS.panels, panelSettings);
}
}
const initialUrlState: ParsedMapUrlState | null = parseMapUrlState(window.location.search, mapLayers);
if (initialUrlState.layers) {
mapLayers = sanitizeLayersForVariant(initialUrlState.layers, currentVariant as MapVariant);
initialUrlState.layers = mapLayers;
}
if (!CYBER_LAYER_ENABLED) {
mapLayers.cyberThreats = false;
}
// One-time migration: reduce default-enabled sources (full variant only)
if (currentVariant === 'full') {
const baseKey = 'worldmonitor-sources-reduction-v3';
if (!localStorage.getItem(baseKey)) {
const defaultDisabled = computeDefaultDisabledSources();
saveToStorage(STORAGE_KEYS.disabledFeeds, defaultDisabled);
localStorage.setItem(baseKey, 'done');
const total = getTotalFeedCount();
console.log(`[App] Sources reduction: ${defaultDisabled.length} disabled, ${total - defaultDisabled.length} enabled`);
}
// Locale boost: additively enable locale-matched sources (runs once per locale)
const userLang = ((navigator.language ?? 'en').split('-')[0] ?? 'en').toLowerCase();
const localeKey = `worldmonitor-locale-boost-${userLang}`;
if (userLang !== 'en' && !localStorage.getItem(localeKey)) {
const boosted = getLocaleBoostedSources(userLang);
if (boosted.size > 0) {
const current = loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []);
const updated = current.filter(name => !boosted.has(name));
saveToStorage(STORAGE_KEYS.disabledFeeds, updated);
console.log(`[App] Locale boost (${userLang}): enabled ${current.length - updated.length} sources`);
}
localStorage.setItem(localeKey, 'done');
}
}
const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
// Build shared state object
this.state = {
map: null,
isMobile,
isDesktopApp,
container: el,
panels: {},
newsPanels: {},
panelSettings,
mapLayers,
allNews: [],
newsByCategory: {},
latestMarkets: [],
latestPredictions: [],
latestClusters: [],
intelligenceCache: {},
cyberThreatsCache: null,
disabledSources,
currentTimeRange: '7d',
inFlight: new Set(),
seenGeoAlerts: new Set(),
monitors,
signalModal: null,
statusPanel: null,
searchModal: null,
findingsBadge: null,
breakingBanner: null,
playbackControl: null,
exportPanel: null,
unifiedSettings: null,
pizzintIndicator: null,
correlationEngine: null,
llmStatusIndicator: null,
countryBriefPage: null,
countryTimeline: null,
positivePanel: null,
countersPanel: null,
progressPanel: null,
breakthroughsPanel: null,
heroPanel: null,
digestPanel: null,
speciesPanel: null,
renewablePanel: null,
tvMode: null,
happyAllItems: [],
isDestroyed: false,
isPlaybackMode: false,
isIdle: false,
initialLoadComplete: false,
resolvedLocation: 'global',
initialUrlState,
PANEL_ORDER_KEY,
PANEL_SPANS_KEY,
};
// Instantiate modules (callbacks wired after all modules exist)
this.refreshScheduler = new RefreshScheduler(this.state);
this.countryIntel = new CountryIntelManager(this.state);
this.desktopUpdater = new DesktopUpdater(this.state);
this.dataLoader = new DataLoaderManager(this.state, {
renderCriticalBanner: (postures) => this.panelLayout.renderCriticalBanner(postures),
refreshOpenCountryBrief: () => this.countryIntel.refreshOpenBrief(),
});
this.searchManager = new SearchManager(this.state, {
openCountryBriefByCode: (code, country) => this.countryIntel.openCountryBriefByCode(code, country),
});
this.panelLayout = new PanelLayoutManager(this.state, {
openCountryStory: (code, name) => this.countryIntel.openCountryStory(code, name),
openCountryBrief: (code) => {
const name = CountryIntelManager.resolveCountryName(code);
void this.countryIntel.openCountryBriefByCode(code, name);
},
loadAllData: () => this.dataLoader.loadAllData(),
updateMonitorResults: () => this.dataLoader.updateMonitorResults(),
loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(),
});
this.eventHandlers = new EventHandlerManager(this.state, {
updateSearchIndex: () => this.searchManager.updateSearchIndex(),
loadAllData: () => this.dataLoader.loadAllData(),
flushStaleRefreshes: () => this.refreshScheduler.flushStaleRefreshes(),
setHiddenSince: (ts) => this.refreshScheduler.setHiddenSince(ts),
loadDataForLayer: (layer) => { void this.dataLoader.loadDataForLayer(layer as keyof MapLayers); },
waitForAisData: () => this.dataLoader.waitForAisData(),
syncDataFreshnessWithLayers: () => this.dataLoader.syncDataFreshnessWithLayers(),
ensureCorrectZones: () => this.panelLayout.ensureCorrectZones(),
refreshOpenCountryBrief: () => this.countryIntel.refreshOpenBrief(),
stopLayerActivity: (layer) => this.dataLoader.stopLayerActivity(layer),
});
// Wire cross-module callback: DataLoader → SearchManager
this.dataLoader.updateSearchIndex = () => this.searchManager.updateSearchIndex();
// Track destroy order (reverse of init)
this.modules = [
this.desktopUpdater,
this.panelLayout,
this.countryIntel,
this.searchManager,
this.dataLoader,
this.refreshScheduler,
this.eventHandlers,
];
}
public async init(): Promise<void> {
const initStart = performance.now();
await initDB();
await initI18n();
const aiFlow = getAiFlowSettings();
if (aiFlow.browserModel || isDesktopRuntime()) {
await mlWorker.init();
if (BETA_MODE) mlWorker.loadModel('summarization-beta').catch(() => { });
}
if (aiFlow.headlineMemory) {
mlWorker.init().then(ok => {
if (ok) mlWorker.loadModel('embeddings').catch(() => { });
}).catch(() => { });
}
this.unsubAiFlow = subscribeAiFlowChange((key) => {
if (key === 'browserModel') {
const s = getAiFlowSettings();
if (s.browserModel) {
mlWorker.init();
} else if (!isHeadlineMemoryEnabled()) {
mlWorker.terminate();
}
}
if (key === 'headlineMemory') {
if (isHeadlineMemoryEnabled()) {
mlWorker.init().then(ok => {
if (ok) mlWorker.loadModel('embeddings').catch(() => { });
}).catch(() => { });
} else {
mlWorker.unloadModel('embeddings').catch(() => { });
const s = getAiFlowSettings();
if (!s.browserModel && !isDesktopRuntime()) {
mlWorker.terminate();
}
}
}
});
// Check AIS configuration before init
if (!isAisConfigured()) {
this.state.mapLayers.ais = false;
} else if (this.state.mapLayers.ais) {
initAisStream();
}
// Wait for sidecar readiness on desktop so bootstrap hits a live server
if (isDesktopRuntime()) {
await waitForSidecarReady(3000);
}
// Hydrate in-memory cache from bootstrap endpoint (before panels construct and fetch)
await fetchBootstrapData();
const geoCoordsPromise: Promise<PreciseCoordinates | null> =
this.state.isMobile && this.state.initialUrlState?.lat === undefined && this.state.initialUrlState?.lon === undefined
? resolvePreciseUserCoordinates(5000)
: Promise.resolve(null);
const resolvedRegion = await resolveUserRegion();
this.state.resolvedLocation = resolvedRegion;
// Phase 1: Layout (creates map + panels — they'll find hydrated data)
this.panelLayout.init();
showProBanner(this.state.container);
const mobileGeoCoords = await geoCoordsPromise;
if (mobileGeoCoords && this.state.map) {
this.state.map.setCenter(mobileGeoCoords.lat, mobileGeoCoords.lon, 6);
}
// Happy variant: pre-populate panels from persistent cache for instant render
if (SITE_VARIANT === 'happy') {
await this.dataLoader.hydrateHappyPanelsFromCache();
}
// Phase 2: Shared UI components
this.state.signalModal = new SignalModal();
this.state.signalModal.setLocationClickHandler((lat, lon) => {
this.state.map?.setCenter(lat, lon, 4);
});
if (!this.state.isMobile) {
this.state.findingsBadge = new IntelligenceGapBadge();
this.state.findingsBadge.setOnSignalClick((signal) => {
if (this.state.countryBriefPage?.isVisible()) return;
if (localStorage.getItem('wm-settings-open') === '1') return;
this.state.signalModal?.showSignal(signal);
});
this.state.findingsBadge.setOnAlertClick((alert) => {
if (this.state.countryBriefPage?.isVisible()) return;
if (localStorage.getItem('wm-settings-open') === '1') return;
this.state.signalModal?.showAlert(alert);
});
}
if (!this.state.isMobile) {
initBreakingNewsAlerts();
this.state.breakingBanner = new BreakingNewsBanner();
}
// Phase 3: UI setup methods
this.eventHandlers.startHeaderClock();
this.eventHandlers.setupPlaybackControl();
this.eventHandlers.setupStatusPanel();
this.eventHandlers.setupPizzIntIndicator();
this.eventHandlers.setupLlmStatusIndicator();
this.eventHandlers.setupExportPanel();
// Correlation engine
const correlationEngine = new CorrelationEngine();
correlationEngine.registerAdapter(militaryAdapter);
correlationEngine.registerAdapter(escalationAdapter);
correlationEngine.registerAdapter(economicAdapter);
correlationEngine.registerAdapter(disasterAdapter);
this.state.correlationEngine = correlationEngine;
this.eventHandlers.setupUnifiedSettings();
// Phase 4: SearchManager, MapLayerHandlers, CountryIntel
this.searchManager.init();
this.eventHandlers.setupMapLayerHandlers();
this.countryIntel.init();
// Phase 5: Event listeners + URL sync
this.eventHandlers.init();
// Capture deep link params BEFORE URL sync overwrites them
const initState = parseMapUrlState(window.location.search, this.state.mapLayers);
this.pendingDeepLinkCountry = initState.country ?? null;
this.pendingDeepLinkExpanded = initState.expanded === true;
const earlyParams = new URLSearchParams(window.location.search);
this.pendingDeepLinkStoryCode = earlyParams.get('c') ?? null;
this.eventHandlers.setupUrlStateSync();
this.state.countryBriefPage?.onStateChange?.(() => {
this.eventHandlers.syncUrlState();
});
// Start deep link handling early — its retry loop polls hasSufficientData()
// independently, so it must not be gated behind loadAllData() which can hang.
this.handleDeepLinks();
// Phase 6: Data loading
this.dataLoader.syncDataFreshnessWithLayers();
await preloadCountryGeometry();
// Prime panel-specific data concurrently with bulk loading.
// primeVisiblePanelData owns ETF, Stablecoins, Gulf Economies, etc. that
// are NOT part of loadAllData. Running them in parallel prevents those
// panels from being blocked when a loadAllData batch is slow.
window.addEventListener('scroll', this.handleViewportPrime, { passive: true });
window.addEventListener('resize', this.handleViewportPrime);
await Promise.all([
this.dataLoader.loadAllData(true),
this.primeVisiblePanelData(true),
]);
// Initial correlation engine run
if (this.state.correlationEngine) {
void this.state.correlationEngine.run(this.state).then(() => {
for (const domain of ['military', 'escalation', 'economic', 'disaster'] as const) {
const panel = this.state.panels[`${domain}-correlation`] as CorrelationPanel | undefined;
panel?.updateCards(this.state.correlationEngine!.getCards(domain));
}
});
}
startLearning();
// Hide unconfigured layers after first data load
if (!isAisConfigured()) {
this.state.map?.hideLayerToggle('ais');
}
if (isOutagesConfigured() === false) {
this.state.map?.hideLayerToggle('outages');
}
if (!CYBER_LAYER_ENABLED) {
this.state.map?.hideLayerToggle('cyberThreats');
}
// Phase 7: Refresh scheduling
this.setupRefreshIntervals();
this.eventHandlers.setupSnapshotSaving();
cleanOldSnapshots().catch((e) => console.warn('[Storage] Snapshot cleanup failed:', e));
// Phase 8: Update checks
this.desktopUpdater.init();
// Analytics
trackEvent('wm_app_loaded', {
load_time_ms: Math.round(performance.now() - initStart),
panel_count: Object.keys(this.state.panels).length,
});
this.eventHandlers.setupPanelViewTracking();
}
public destroy(): void {
this.state.isDestroyed = true;
window.removeEventListener('scroll', this.handleViewportPrime);
window.removeEventListener('resize', this.handleViewportPrime);
if (this.visiblePanelPrimeRaf !== null) {
window.cancelAnimationFrame(this.visiblePanelPrimeRaf);
this.visiblePanelPrimeRaf = null;
}
// Destroy all modules in reverse order
for (let i = this.modules.length - 1; i >= 0; i--) {
this.modules[i]!.destroy();
}
// Clean up subscriptions, map, AIS, and breaking news
this.unsubAiFlow?.();
this.state.breakingBanner?.destroy();
destroyBreakingNewsAlerts();
this.state.map?.destroy();
disconnectAisStream();
}
private handleDeepLinks(): void {
const url = new URL(window.location.href);
const DEEP_LINK_INITIAL_DELAY_MS = 1500;
// Check for country brief deep link: ?c=IR (captured early before URL sync)
const storyCode = this.pendingDeepLinkStoryCode ?? url.searchParams.get('c');
this.pendingDeepLinkStoryCode = null;
if (url.pathname === '/story' || storyCode) {
const countryCode = storyCode;
if (countryCode) {
trackDeeplinkOpened('country', countryCode);
const countryName = getCountryNameByCode(countryCode.toUpperCase()) || countryCode;
setTimeout(() => {
this.countryIntel.openCountryBriefByCode(countryCode.toUpperCase(), countryName, {
maximize: true,
});
this.eventHandlers.syncUrlState();
}, DEEP_LINK_INITIAL_DELAY_MS);
return;
}
}
// Check for country brief deep link: ?country=UA or ?country=UA&expanded=1
const deepLinkCountry = this.pendingDeepLinkCountry;
const deepLinkExpanded = this.pendingDeepLinkExpanded;
this.pendingDeepLinkCountry = null;
this.pendingDeepLinkExpanded = false;
if (deepLinkCountry) {
trackDeeplinkOpened('country', deepLinkCountry);
const cName = CountryIntelManager.resolveCountryName(deepLinkCountry);
setTimeout(() => {
this.countryIntel.openCountryBriefByCode(deepLinkCountry, cName, {
maximize: deepLinkExpanded,
});
this.eventHandlers.syncUrlState();
}, DEEP_LINK_INITIAL_DELAY_MS);
}
}
private setupRefreshIntervals(): void {
// Always refresh news for all variants
this.refreshScheduler.scheduleRefresh('news', () => this.dataLoader.loadNews(), REFRESH_INTERVALS.feeds);
// Happy variant only refreshes news -- skip all geopolitical/financial/military refreshes
if (SITE_VARIANT !== 'happy') {
this.refreshScheduler.registerAll([
{
name: 'markets',
fn: () => this.dataLoader.loadMarkets(),
intervalMs: REFRESH_INTERVALS.markets,
condition: () => this.isAnyPanelNearViewport(['markets', 'heatmap', 'commodities', 'crypto', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens']),
},
{
name: 'predictions',
fn: () => this.dataLoader.loadPredictions(),
intervalMs: REFRESH_INTERVALS.predictions,
condition: () => this.isPanelNearViewport('polymarket'),
},
{
name: 'forecasts',
fn: () => this.dataLoader.loadForecasts(),
intervalMs: REFRESH_INTERVALS.forecasts,
condition: () => this.isPanelNearViewport('forecast'),
},
{ name: 'pizzint', fn: () => this.dataLoader.loadPizzInt(), intervalMs: REFRESH_INTERVALS.pizzint, condition: () => SITE_VARIANT === 'full' },
{ name: 'natural', fn: () => this.dataLoader.loadNatural(), intervalMs: REFRESH_INTERVALS.natural, condition: () => this.state.mapLayers.natural },
{ name: 'weather', fn: () => this.dataLoader.loadWeatherAlerts(), intervalMs: REFRESH_INTERVALS.weather, condition: () => this.state.mapLayers.weather },
{ name: 'fred', fn: () => this.dataLoader.loadFredData(), intervalMs: REFRESH_INTERVALS.fred, condition: () => this.isPanelNearViewport('economic') },
{ name: 'spending', fn: () => this.dataLoader.loadGovernmentSpending(), intervalMs: REFRESH_INTERVALS.spending, condition: () => this.isPanelNearViewport('economic') },
{ name: 'bis', fn: () => this.dataLoader.loadBisData(), intervalMs: REFRESH_INTERVALS.bis, condition: () => this.isPanelNearViewport('economic') },
{ name: 'oil', fn: () => this.dataLoader.loadOilAnalytics(), intervalMs: REFRESH_INTERVALS.oil, condition: () => this.isPanelNearViewport('energy-complex') },
{ name: 'firms', fn: () => this.dataLoader.loadFirmsData(), intervalMs: REFRESH_INTERVALS.firms, condition: () => this.shouldRefreshFirms() },
{ name: 'ais', fn: () => this.dataLoader.loadAisSignals(), intervalMs: REFRESH_INTERVALS.ais, condition: () => this.state.mapLayers.ais },
{ name: 'cables', fn: () => this.dataLoader.loadCableActivity(), intervalMs: REFRESH_INTERVALS.cables, condition: () => this.state.mapLayers.cables },
{ name: 'cableHealth', fn: () => this.dataLoader.loadCableHealth(), intervalMs: REFRESH_INTERVALS.cableHealth, condition: () => this.state.mapLayers.cables },
{ name: 'flights', fn: () => this.dataLoader.loadFlightDelays(), intervalMs: REFRESH_INTERVALS.flights, condition: () => this.state.mapLayers.flights },
{
name: 'cyberThreats', fn: () => {
this.state.cyberThreatsCache = null;
return this.dataLoader.loadCyberThreats();
}, intervalMs: REFRESH_INTERVALS.cyberThreats, condition: () => CYBER_LAYER_ENABLED && this.state.mapLayers.cyberThreats
},
]);
}
if (SITE_VARIANT === 'finance') {
this.refreshScheduler.scheduleRefresh(
'stock-analysis',
() => this.dataLoader.loadStockAnalysis(),
REFRESH_INTERVALS.stockAnalysis,
() => getSecretState('WORLDMONITOR_API_KEY').present && this.isPanelNearViewport('stock-analysis'),
);
this.refreshScheduler.scheduleRefresh(
'daily-market-brief',
() => this.dataLoader.loadDailyMarketBrief(),
REFRESH_INTERVALS.dailyMarketBrief,
() => getSecretState('WORLDMONITOR_API_KEY').present && this.isPanelNearViewport('daily-market-brief'),
);
this.refreshScheduler.scheduleRefresh(
'stock-backtest',
() => this.dataLoader.loadStockBacktest(),
REFRESH_INTERVALS.stockBacktest,
() => getSecretState('WORLDMONITOR_API_KEY').present && this.isPanelNearViewport('stock-backtest'),
);
}
// Panel-level refreshes (moved from panel constructors into scheduler for hidden-tab awareness + jitter)
this.refreshScheduler.scheduleRefresh(
'service-status',
() => (this.state.panels['service-status'] as ServiceStatusPanel).fetchStatus(),
REFRESH_INTERVALS.serviceStatus,
() => this.isPanelNearViewport('service-status')
);
this.refreshScheduler.scheduleRefresh(
'stablecoins',
() => (this.state.panels.stablecoins as StablecoinPanel).fetchData(),
REFRESH_INTERVALS.stablecoins,
() => this.isPanelNearViewport('stablecoins')
);
this.refreshScheduler.scheduleRefresh(
'etf-flows',
() => (this.state.panels['etf-flows'] as ETFFlowsPanel).fetchData(),
REFRESH_INTERVALS.etfFlows,
() => this.isPanelNearViewport('etf-flows')
);
this.refreshScheduler.scheduleRefresh(
'macro-signals',
() => (this.state.panels['macro-signals'] as MacroSignalsPanel).fetchData(),
REFRESH_INTERVALS.macroSignals,
() => this.isPanelNearViewport('macro-signals')
);
this.refreshScheduler.scheduleRefresh(
'strategic-posture',
() => (this.state.panels['strategic-posture'] as StrategicPosturePanel).refresh(),
REFRESH_INTERVALS.strategicPosture,
() => this.isPanelNearViewport('strategic-posture')
);
this.refreshScheduler.scheduleRefresh(
'strategic-risk',
() => (this.state.panels['strategic-risk'] as StrategicRiskPanel).refresh(),
REFRESH_INTERVALS.strategicRisk,
() => this.isPanelNearViewport('strategic-risk')
);
// Server-side temporal anomalies (news + satellite_fires)
if (SITE_VARIANT !== 'happy') {
this.refreshScheduler.scheduleRefresh('temporalBaseline', () => this.dataLoader.refreshTemporalBaseline(), REFRESH_INTERVALS.temporalBaseline, () => this.shouldRefreshIntelligence());
}
// WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity') {
this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), REFRESH_INTERVALS.tradePolicy, () => this.isPanelNearViewport('trade-policy'));
this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), REFRESH_INTERVALS.supplyChain, () => this.isPanelNearViewport('supply-chain'));
}
// Telegram Intel (near real-time, 60s refresh)
this.refreshScheduler.scheduleRefresh(
'telegram-intel',
() => this.dataLoader.loadTelegramIntel(),
REFRESH_INTERVALS.telegramIntel,
() => this.isPanelNearViewport('telegram-intel')
);
this.refreshScheduler.scheduleRefresh(
'gulf-economies',
() => (this.state.panels['gulf-economies'] as GulfEconomiesPanel).fetchData(),
REFRESH_INTERVALS.gulfEconomies,
() => this.isPanelNearViewport('gulf-economies')
);
// Refresh intelligence signals for CII (geopolitical variant only)
if (SITE_VARIANT === 'full') {
this.refreshScheduler.scheduleRefresh('intelligence', () => {
const { military, iranEvents } = this.state.intelligenceCache;
this.state.intelligenceCache = {};
if (military) this.state.intelligenceCache.military = military;
if (iranEvents) this.state.intelligenceCache.iranEvents = iranEvents;
return this.dataLoader.loadIntelligenceSignals();
}, REFRESH_INTERVALS.intelligence, () => this.shouldRefreshIntelligence());
}
// Correlation engine refresh
this.refreshScheduler.scheduleRefresh(
'correlation-engine',
async () => {
const engine = this.state.correlationEngine;
if (!engine) return;
await engine.run(this.state);
for (const domain of ['military', 'escalation', 'economic', 'disaster'] as const) {
const panel = this.state.panels[`${domain}-correlation`] as CorrelationPanel | undefined;
panel?.updateCards(engine.getCards(domain));
}
},
REFRESH_INTERVALS.correlationEngine,
() => this.shouldRefreshCorrelation(),
);
}
}