- Global Situation + ${SITE_VARIANT === 'tech' ? t('panels.techMap') : t('panels.map')}
+ +
@@ -483,45 +1778,193 @@ export class App { + `; this.createPanels(); this.renderPanelToggles(); - this.updateTime(); - setInterval(() => this.updateTime(), 1000); + } + + /** + * Render critical military posture banner when buildup detected + */ + private renderCriticalBanner(postures: TheaterPostureSummary[]): void { + if (this.isMobile) { + if (this.criticalBannerEl) { + this.criticalBannerEl.remove(); + this.criticalBannerEl = null; + } + document.body.classList.remove('has-critical-banner'); + return; + } + + // Check if banner was dismissed this session + const dismissedAt = sessionStorage.getItem('banner-dismissed'); + if (dismissedAt && Date.now() - parseInt(dismissedAt, 10) < 30 * 60 * 1000) { + return; // Stay dismissed for 30 minutes + } + + const critical = postures.filter( + (p) => p.postureLevel === 'critical' || (p.postureLevel === 'elevated' && p.strikeCapable) + ); + + if (critical.length === 0) { + if (this.criticalBannerEl) { + this.criticalBannerEl.remove(); + this.criticalBannerEl = null; + document.body.classList.remove('has-critical-banner'); + } + return; + } + + const top = critical[0]!; + const isCritical = top.postureLevel === 'critical'; + + if (!this.criticalBannerEl) { + this.criticalBannerEl = document.createElement('div'); + this.criticalBannerEl.className = 'critical-posture-banner'; + const header = document.querySelector('.header'); + if (header) header.insertAdjacentElement('afterend', this.criticalBannerEl); + } + + // Always ensure body class is set when showing banner + document.body.classList.add('has-critical-banner'); + this.criticalBannerEl.className = `critical-posture-banner ${isCritical ? 'severity-critical' : 'severity-elevated'}`; + this.criticalBannerEl.innerHTML = ` + + + + `; + + // Event handlers + this.criticalBannerEl.querySelector('.banner-view')?.addEventListener('click', () => { + console.log('[Banner] View Region clicked:', top.theaterId, 'lat:', top.centerLat, 'lon:', top.centerLon); + // Use typeof check - truthy check would fail for coordinate 0 + if (typeof top.centerLat === 'number' && typeof top.centerLon === 'number') { + this.map?.setCenter(top.centerLat, top.centerLon, 4); + } else { + console.error('[Banner] Missing coordinates for', top.theaterId); + } + }); + + this.criticalBannerEl.querySelector('.banner-dismiss')?.addEventListener('click', () => { + this.criticalBannerEl?.classList.add('dismissed'); + document.body.classList.remove('has-critical-banner'); + sessionStorage.setItem('banner-dismissed', Date.now().toString()); + }); + } + + /** + * Clean up resources (for HMR/testing) + */ + public destroy(): void { + this.isDestroyed = true; + + // Clear snapshot saving interval + if (this.snapshotIntervalId) { + clearInterval(this.snapshotIntervalId); + this.snapshotIntervalId = null; + } + + // Clear all refresh timeouts + for (const timeoutId of this.refreshTimeoutIds.values()) { + clearTimeout(timeoutId); + } + this.refreshTimeoutIds.clear(); + + // Remove global event listeners + if (this.boundKeydownHandler) { + document.removeEventListener('keydown', this.boundKeydownHandler); + this.boundKeydownHandler = null; + } + if (this.boundFullscreenHandler) { + document.removeEventListener('fullscreenchange', this.boundFullscreenHandler); + this.boundFullscreenHandler = null; + } + if (this.boundResizeHandler) { + window.removeEventListener('resize', this.boundResizeHandler); + this.boundResizeHandler = null; + } + if (this.boundVisibilityHandler) { + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + this.boundVisibilityHandler = null; + } + + // Clean up idle detection + if (this.idleTimeoutId) { + clearTimeout(this.idleTimeoutId); + this.idleTimeoutId = null; + } + if (this.boundIdleResetHandler) { + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.removeEventListener(event, this.boundIdleResetHandler!); + }); + this.boundIdleResetHandler = null; + } + + // Clean up map and AIS + this.map?.destroy(); + disconnectAisStream(); } private createPanels(): void { const panelsGrid = document.getElementById('panelsGrid')!; // Initialize map in the map section + // Default to MENA view on mobile for better focus + // Uses deck.gl (WebGL) on desktop, falls back to D3/SVG on mobile const mapContainer = document.getElementById('mapContainer') as HTMLElement; - this.map = new MapComponent(mapContainer, { - zoom: 1.5, - pan: { x: 0, y: 0 }, - view: 'global', + this.map = new MapContainer(mapContainer, { + zoom: this.isMobile ? 2.5 : 1.0, + pan: { x: 0, y: 0 }, // Centered view to show full world + view: this.isMobile ? 'mena' : 'global', layers: this.mapLayers, timeRange: '7d', }); + // Initialize escalation service with data getters + this.map.initEscalationGetters(); + this.currentTimeRange = this.map.getTimeRange(); + // Create all panels - const politicsPanel = new NewsPanel('politics', 'World / Geopolitical'); + const politicsPanel = new NewsPanel('politics', t('panels.politics')); this.attachRelatedAssetHandlers(politicsPanel); this.newsPanels['politics'] = politicsPanel; this.panels['politics'] = politicsPanel; - const techPanel = new NewsPanel('tech', 'Technology / AI'); + const techPanel = new NewsPanel('tech', t('panels.tech')); this.attachRelatedAssetHandlers(techPanel); this.newsPanels['tech'] = techPanel; this.panels['tech'] = techPanel; - const financePanel = new NewsPanel('finance', 'Financial News'); + const financePanel = new NewsPanel('finance', t('panels.finance')); this.attachRelatedAssetHandlers(financePanel); this.newsPanels['finance'] = financePanel; this.panels['finance'] = financePanel; @@ -546,12 +1989,12 @@ export class App { const predictionPanel = new PredictionPanel(); this.panels['polymarket'] = predictionPanel; - const govPanel = new NewsPanel('gov', 'Government / Policy'); + const govPanel = new NewsPanel('gov', t('panels.gov')); this.attachRelatedAssetHandlers(govPanel); this.newsPanels['gov'] = govPanel; this.panels['gov'] = govPanel; - const intelPanel = new NewsPanel('intel', 'Intel Feed'); + const intelPanel = new NewsPanel('intel', t('panels.intel')); this.attachRelatedAssetHandlers(intelPanel); this.newsPanels['intel'] = intelPanel; this.panels['intel'] = intelPanel; @@ -559,33 +2002,231 @@ export class App { const cryptoPanel = new CryptoPanel(); this.panels['crypto'] = cryptoPanel; - const middleeastPanel = new NewsPanel('middleeast', 'Middle East / MENA'); + const middleeastPanel = new NewsPanel('middleeast', t('panels.middleeast')); this.attachRelatedAssetHandlers(middleeastPanel); this.newsPanels['middleeast'] = middleeastPanel; this.panels['middleeast'] = middleeastPanel; - const layoffsPanel = new NewsPanel('layoffs', 'Layoffs Tracker'); + const layoffsPanel = new NewsPanel('layoffs', t('panels.layoffs')); this.attachRelatedAssetHandlers(layoffsPanel); this.newsPanels['layoffs'] = layoffsPanel; this.panels['layoffs'] = layoffsPanel; - const congressPanel = new NewsPanel('congress', 'Congress Trades'); - this.attachRelatedAssetHandlers(congressPanel); - this.newsPanels['congress'] = congressPanel; - this.panels['congress'] = congressPanel; - - const aiPanel = new NewsPanel('ai', 'AI / ML'); + const aiPanel = new NewsPanel('ai', t('panels.ai')); this.attachRelatedAssetHandlers(aiPanel); this.newsPanels['ai'] = aiPanel; this.panels['ai'] = aiPanel; - const thinktanksPanel = new NewsPanel('thinktanks', 'Think Tanks'); + // Tech variant panels + const startupsPanel = new NewsPanel('startups', t('panels.startups')); + this.attachRelatedAssetHandlers(startupsPanel); + this.newsPanels['startups'] = startupsPanel; + this.panels['startups'] = startupsPanel; + + const vcblogsPanel = new NewsPanel('vcblogs', t('panels.vcblogs')); + this.attachRelatedAssetHandlers(vcblogsPanel); + this.newsPanels['vcblogs'] = vcblogsPanel; + this.panels['vcblogs'] = vcblogsPanel; + + const regionalStartupsPanel = new NewsPanel('regionalStartups', t('panels.regionalStartups')); + this.attachRelatedAssetHandlers(regionalStartupsPanel); + this.newsPanels['regionalStartups'] = regionalStartupsPanel; + this.panels['regionalStartups'] = regionalStartupsPanel; + + const unicornsPanel = new NewsPanel('unicorns', t('panels.unicorns')); + this.attachRelatedAssetHandlers(unicornsPanel); + this.newsPanels['unicorns'] = unicornsPanel; + this.panels['unicorns'] = unicornsPanel; + + const acceleratorsPanel = new NewsPanel('accelerators', t('panels.accelerators')); + this.attachRelatedAssetHandlers(acceleratorsPanel); + this.newsPanels['accelerators'] = acceleratorsPanel; + this.panels['accelerators'] = acceleratorsPanel; + + const fundingPanel = new NewsPanel('funding', t('panels.funding')); + this.attachRelatedAssetHandlers(fundingPanel); + this.newsPanels['funding'] = fundingPanel; + this.panels['funding'] = fundingPanel; + + const producthuntPanel = new NewsPanel('producthunt', t('panels.producthunt')); + this.attachRelatedAssetHandlers(producthuntPanel); + this.newsPanels['producthunt'] = producthuntPanel; + this.panels['producthunt'] = producthuntPanel; + + const securityPanel = new NewsPanel('security', t('panels.security')); + this.attachRelatedAssetHandlers(securityPanel); + this.newsPanels['security'] = securityPanel; + this.panels['security'] = securityPanel; + + const policyPanel = new NewsPanel('policy', t('panels.policy')); + this.attachRelatedAssetHandlers(policyPanel); + this.newsPanels['policy'] = policyPanel; + this.panels['policy'] = policyPanel; + + const hardwarePanel = new NewsPanel('hardware', t('panels.hardware')); + this.attachRelatedAssetHandlers(hardwarePanel); + this.newsPanels['hardware'] = hardwarePanel; + this.panels['hardware'] = hardwarePanel; + + const cloudPanel = new NewsPanel('cloud', t('panels.cloud')); + this.attachRelatedAssetHandlers(cloudPanel); + this.newsPanels['cloud'] = cloudPanel; + this.panels['cloud'] = cloudPanel; + + const devPanel = new NewsPanel('dev', t('panels.dev')); + this.attachRelatedAssetHandlers(devPanel); + this.newsPanels['dev'] = devPanel; + this.panels['dev'] = devPanel; + + const githubPanel = new NewsPanel('github', t('panels.github')); + this.attachRelatedAssetHandlers(githubPanel); + this.newsPanels['github'] = githubPanel; + this.panels['github'] = githubPanel; + + const ipoPanel = new NewsPanel('ipo', t('panels.ipo')); + this.attachRelatedAssetHandlers(ipoPanel); + this.newsPanels['ipo'] = ipoPanel; + this.panels['ipo'] = ipoPanel; + + const thinktanksPanel = new NewsPanel('thinktanks', t('panels.thinktanks')); this.attachRelatedAssetHandlers(thinktanksPanel); this.newsPanels['thinktanks'] = thinktanksPanel; this.panels['thinktanks'] = thinktanksPanel; + const economicPanel = new EconomicPanel(); + this.panels['economic'] = economicPanel; + + // New Regional Panels + const africaPanel = new NewsPanel('africa', t('panels.africa')); + this.attachRelatedAssetHandlers(africaPanel); + this.newsPanels['africa'] = africaPanel; + this.panels['africa'] = africaPanel; + + const latamPanel = new NewsPanel('latam', t('panels.latam')); + this.attachRelatedAssetHandlers(latamPanel); + this.newsPanels['latam'] = latamPanel; + this.panels['latam'] = latamPanel; + + const asiaPanel = new NewsPanel('asia', t('panels.asia')); + this.attachRelatedAssetHandlers(asiaPanel); + this.newsPanels['asia'] = asiaPanel; + this.panels['asia'] = asiaPanel; + + const energyPanel = new NewsPanel('energy', t('panels.energy')); + this.attachRelatedAssetHandlers(energyPanel); + this.newsPanels['energy'] = energyPanel; + this.panels['energy'] = energyPanel; + + // Dynamically create NewsPanel instances for any FEEDS category. + // If a category key collides with an existing data panel key (e.g. markets), + // create a separate `${key}-news` panel to avoid clobbering the data panel. + for (const key of Object.keys(FEEDS)) { + if (this.newsPanels[key]) continue; + if (!Array.isArray((FEEDS as Record)[key])) continue; + const panelKey = this.panels[key] && !this.newsPanels[key] ? `${key}-news` : key; + if (this.panels[panelKey]) continue; + const panelConfig = DEFAULT_PANELS[panelKey] ?? DEFAULT_PANELS[key]; + const label = panelConfig?.name ?? key.charAt(0).toUpperCase() + key.slice(1); + const panel = new NewsPanel(panelKey, label); + this.attachRelatedAssetHandlers(panel); + this.newsPanels[key] = panel; + this.panels[panelKey] = panel; + } + + // Geopolitical-only panels (not needed for tech variant) + if (SITE_VARIANT === 'full') { + const gdeltIntelPanel = new GdeltIntelPanel(); + this.panels['gdelt-intel'] = gdeltIntelPanel; + + const ciiPanel = new CIIPanel(); + ciiPanel.setShareStoryHandler((code, name) => { + this.openCountryStory(code, name); + }); + this.panels['cii'] = ciiPanel; + + const cascadePanel = new CascadePanel(); + this.panels['cascade'] = cascadePanel; + + const satelliteFiresPanel = new SatelliteFiresPanel(); + this.panels['satellite-fires'] = satelliteFiresPanel; + + const strategicRiskPanel = new StrategicRiskPanel(); + strategicRiskPanel.setLocationClickHandler((lat, lon) => { + this.map?.setCenter(lat, lon, 4); + }); + this.panels['strategic-risk'] = strategicRiskPanel; + + const strategicPosturePanel = new StrategicPosturePanel(); + strategicPosturePanel.setLocationClickHandler((lat, lon) => { + console.log('[App] StrategicPosture handler called:', { lat, lon, hasMap: !!this.map }); + this.map?.setCenter(lat, lon, 4); + }); + this.panels['strategic-posture'] = strategicPosturePanel; + + const ucdpEventsPanel = new UcdpEventsPanel(); + ucdpEventsPanel.setEventClickHandler((lat, lon) => { + this.map?.setCenter(lat, lon, 5); + }); + this.panels['ucdp-events'] = ucdpEventsPanel; + + const displacementPanel = new DisplacementPanel(); + displacementPanel.setCountryClickHandler((lat, lon) => { + this.map?.setCenter(lat, lon, 4); + }); + this.panels['displacement'] = displacementPanel; + + const climatePanel = new ClimateAnomalyPanel(); + climatePanel.setZoneClickHandler((lat, lon) => { + this.map?.setCenter(lat, lon, 4); + }); + this.panels['climate'] = climatePanel; + + const populationExposurePanel = new PopulationExposurePanel(); + this.panels['population-exposure'] = populationExposurePanel; + } + + // GCC Investments Panel (finance variant) + if (SITE_VARIANT === 'finance') { + const investmentsPanel = new InvestmentsPanel((inv) => { + focusInvestmentOnMap(this.map, this.mapLayers, inv.lat, inv.lon); + }); + this.panels['gcc-investments'] = investmentsPanel; + } + + const liveNewsPanel = new LiveNewsPanel(); + this.panels['live-news'] = liveNewsPanel; + + const liveWebcamsPanel = new LiveWebcamsPanel(); + this.panels['live-webcams'] = liveWebcamsPanel; + + // Tech Events Panel (tech variant only - but create for all to allow toggling) + this.panels['events'] = new TechEventsPanel('events'); + + // Service Status Panel (primarily for tech variant) + const serviceStatusPanel = new ServiceStatusPanel(); + this.panels['service-status'] = serviceStatusPanel; + + if (this.isDesktopApp) { + const runtimeConfigPanel = new RuntimeConfigPanel({ mode: 'alert' }); + this.panels['runtime-config'] = runtimeConfigPanel; + } + + // Tech Readiness Panel (tech variant only - World Bank tech indicators) + const techReadinessPanel = new TechReadinessPanel(); + this.panels['tech-readiness'] = techReadinessPanel; + + // Crypto & Market Intelligence Panels + this.panels['macro-signals'] = new MacroSignalsPanel(); + this.panels['etf-flows'] = new ETFFlowsPanel(); + this.panels['stablecoins'] = new StablecoinPanel(); + + // AI Insights Panel (desktop only - hides itself on mobile) + const insightsPanel = new InsightsPanel(); + this.panels['insights'] = insightsPanel; + // Add panels to grid in saved order - const defaultOrder = ['politics', 'middleeast', 'tech', 'ai', 'finance', 'layoffs', 'congress', 'heatmap', 'markets', 'commodities', 'crypto', 'polymarket', 'gov', 'thinktanks', 'intel', 'monitors']; + // Use DEFAULT_PANELS keys for variant-aware panel order + const defaultOrder = Object.keys(DEFAULT_PANELS).filter(k => k !== 'map'); const savedOrder = this.getSavedPanelOrder(); // Merge saved order with default to include new panels let panelOrder = defaultOrder; @@ -604,6 +2245,33 @@ export class App { panelOrder = valid; } + // CRITICAL: live-news MUST be first for CSS Grid layout (spans 2 columns) + // Move it to position 0 if it exists and isn't already first + const liveNewsIdx = panelOrder.indexOf('live-news'); + if (liveNewsIdx > 0) { + panelOrder.splice(liveNewsIdx, 1); + panelOrder.unshift('live-news'); + } + + // live-webcams MUST follow live-news (one-time migration for existing users) + const webcamsIdx = panelOrder.indexOf('live-webcams'); + if (webcamsIdx !== -1 && webcamsIdx !== panelOrder.indexOf('live-news') + 1) { + panelOrder.splice(webcamsIdx, 1); + const afterNews = panelOrder.indexOf('live-news') + 1; + panelOrder.splice(afterNews, 0, 'live-webcams'); + } + + // Desktop configuration should stay easy to reach in Tauri builds. + if (this.isDesktopApp) { + const runtimeIdx = panelOrder.indexOf('runtime-config'); + if (runtimeIdx > 1) { + panelOrder.splice(runtimeIdx, 1); + panelOrder.splice(1, 0, 'runtime-config'); + } else if (runtimeIdx === -1) { + panelOrder.splice(1, 0, 'runtime-config'); + } + } + panelOrder.forEach((key: string) => { const panel = this.panels[key]; if (panel) { @@ -613,8 +2281,14 @@ export class App { } }); + this.map.onTimeRangeChanged((range) => { + this.currentTimeRange = range; + this.applyTimeRangeFilterToNewsPanelsDebounced(); + }); + this.applyPanelSettings(); this.applyInitialUrlState(); + } private applyInitialUrlState(): void { @@ -624,7 +2298,6 @@ export class App { if (view) { this.map.setView(view); - this.setActiveViewButton(view); } if (timeRange) { @@ -637,18 +2310,31 @@ export class App { this.map.setLayers(layers); } - if (zoom !== undefined) { - this.map.setZoom(zoom); + // Only apply custom lat/lon/zoom if NO view preset is specified + // When a view is specified (eu, mena, etc.), use the preset's positioning + if (!view) { + if (zoom !== undefined) { + this.map.setZoom(zoom); + } + + // Only apply lat/lon if user has zoomed in significantly (zoom > 2) + // At default zoom (~1-1.5), show centered global view to avoid clipping issues + if (lat !== undefined && lon !== undefined && zoom !== undefined && zoom > 2) { + this.map.setCenter(lat, lon); + } } - if (lat !== undefined && lon !== undefined) { - this.map.setCenter(lat, lon); + // Sync header region selector with initial view + const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; + const currentView = this.map.getState().view; + if (regionSelect && currentView) { + regionSelect.value = currentView; } } private getSavedPanelOrder(): string[] { try { - const saved = localStorage.getItem('panel-order'); + const saved = localStorage.getItem(this.PANEL_ORDER_KEY); return saved ? JSON.parse(saved) : []; } catch { return []; @@ -661,7 +2347,7 @@ export class App { const order = Array.from(grid.children) .map((el) => (el as HTMLElement).dataset.panel) .filter((key): key is string => !!key); - localStorage.setItem('panel-order', JSON.stringify(order)); + localStorage.setItem(this.PANEL_ORDER_KEY, JSON.stringify(order)); } private attachRelatedAssetHandlers(panel: NewsPanel): void { @@ -714,6 +2400,17 @@ export class App { el.dataset.panel = key; el.addEventListener('dragstart', (e) => { + const target = e.target as HTMLElement; + // Don't start drag if panel is being resized + if (el.dataset.resizing === 'true') { + e.preventDefault(); + return; + } + // Don't start drag if target is the resize handle + if (target.classList?.contains('panel-resize-handle') || target.closest?.('.panel-resize-handle')) { + e.preventDefault(); + return; + } el.classList.add('dragging'); e.dataTransfer?.setData('text/plain', key); }); @@ -746,15 +2443,6 @@ export class App { } private setupEventListeners(): void { - // View buttons - document.querySelectorAll('.view-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const view = (btn as HTMLElement).dataset.view as 'global' | 'us' | 'mena'; - this.setActiveViewButton(view); - this.map?.setView(view); - }); - }); - // Search button document.getElementById('searchBtn')?.addEventListener('click', () => { this.updateSearchIndex(); @@ -785,18 +2473,127 @@ export class App { }); document.getElementById('settingsModal')?.addEventListener('click', (e) => { - if ((e.target as HTMLElement).classList.contains('modal-overlay')) { - (e.target as HTMLElement).classList.remove('active'); + if ((e.target as HTMLElement)?.classList?.contains('modal-overlay')) { + document.getElementById('settingsModal')?.classList.remove('active'); } }); + + // Header theme toggle button + document.getElementById('headerThemeToggle')?.addEventListener('click', () => { + const next = getCurrentTheme() === 'dark' ? 'light' : 'dark'; + setTheme(next); + this.updateHeaderThemeIcon(); + }); + + // Sources modal + this.setupSourcesModal(); + + // Variant switcher: switch variant locally on desktop (reload with new config) + if (this.isDesktopApp) { + this.container.querySelectorAll('.variant-option').forEach(link => { + link.addEventListener('click', (e) => { + const variant = link.dataset.variant; + if (variant && variant !== SITE_VARIANT) { + e.preventDefault(); + localStorage.setItem('worldmonitor-variant', variant); + window.location.reload(); + } + }); + }); + } + + // Fullscreen toggle + const fullscreenBtn = document.getElementById('fullscreenBtn'); + if (!this.isDesktopApp && fullscreenBtn) { + fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); + this.boundFullscreenHandler = () => { + fullscreenBtn.textContent = document.fullscreenElement ? '⛶' : '⛶'; + fullscreenBtn.classList.toggle('active', !!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', this.boundFullscreenHandler); + } + + // Region selector + const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; + regionSelect?.addEventListener('change', () => { + this.map?.setView(regionSelect.value as MapView); + }); + + // Language selector + const langSelect = document.getElementById('langSelect') as HTMLSelectElement; + langSelect?.addEventListener('change', () => { + void changeLanguage(langSelect.value); + }); + // Window resize - window.addEventListener('resize', () => { + this.boundResizeHandler = () => { this.map?.render(); - }); + }; + window.addEventListener('resize', this.boundResizeHandler); // Map section resize handle this.setupMapResize(); + + // Map pin toggle + this.setupMapPin(); + + // Pause animations when tab is hidden, unload ML models to free memory + this.boundVisibilityHandler = () => { + document.body.classList.toggle('animations-paused', document.hidden); + if (document.hidden) { + mlWorker.unloadOptionalModels(); + } else { + this.resetIdleTimer(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + + // Refresh CII when focal points are ready (ensures focal point urgency is factored in) + window.addEventListener('focal-points-ready', () => { + (this.panels['cii'] as CIIPanel)?.refresh(true); // forceLocal to use focal point data + }); + + // Re-render components with baked getCSSColor() values on theme change + window.addEventListener('theme-changed', () => { + this.map?.render(); + this.updateHeaderThemeIcon(); + }); + + // Idle detection - pause animations after 2 minutes of inactivity + this.setupIdleDetection(); + } + + private setupIdleDetection(): void { + this.boundIdleResetHandler = () => { + // User is active - resume animations if we were idle + if (this.isIdle) { + this.isIdle = false; + document.body.classList.remove('animations-paused'); + } + this.resetIdleTimer(); + }; + + // Track user activity + ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(event => { + document.addEventListener(event, this.boundIdleResetHandler!, { passive: true }); + }); + + // Start the idle timer + this.resetIdleTimer(); + } + + private resetIdleTimer(): void { + if (this.idleTimeoutId) { + clearTimeout(this.idleTimeoutId); + } + this.idleTimeoutId = setTimeout(() => { + if (!document.hidden) { + this.isIdle = true; + document.body.classList.add('animations-paused'); + console.log('[App] User idle - pausing animations to save resources'); + } + }, this.IDLE_PAUSE_MS); } private setupUrlStateSync(): void { @@ -807,7 +2604,17 @@ export class App { history.replaceState(null, '', shareUrl); }, 250); - this.map.onStateChanged(() => update()); + this.map.onStateChanged(() => { + update(); + // Sync header region selector with map view + const regionSelect = document.getElementById('regionSelect') as HTMLSelectElement; + if (regionSelect && this.map) { + const state = this.map.getState(); + if (regionSelect.value !== state.view) { + regionSelect.value = state.view; + } + } + }); update(); } @@ -822,6 +2629,7 @@ export class App { center, timeRange: state.timeRange, layers: state.layers, + country: this.countryBriefPage?.isVisible() ? (this.countryBriefPage.getCode() ?? undefined) : undefined, }); } @@ -851,11 +2659,17 @@ export class App { }, 1500); } - private setActiveViewButton(view: 'global' | 'us' | 'mena'): void { - document.querySelectorAll('.view-btn').forEach((btn) => { - const isActive = (btn as HTMLElement).dataset.view === view; - btn.classList.toggle('active', isActive); - }); + private toggleFullscreen(): void { + if (document.fullscreenElement) { + void document.exitFullscreen().catch(() => {}); + } else { + const el = document.documentElement as HTMLElement & { webkitRequestFullscreen?: () => void }; + if (el.requestFullscreen) { + void el.requestFullscreen().catch(() => {}); + } else if (el.webkitRequestFullscreen) { + try { el.webkitRequestFullscreen(); } catch {} + } + } } private setupMapResize(): void { @@ -885,7 +2699,7 @@ export class App { document.addEventListener('mousemove', (e) => { if (!isResizing) return; const deltaY = e.clientY - startY; - const newHeight = Math.max(400, Math.min(startHeight + deltaY, window.innerHeight * 0.85)); + const newHeight = Math.max(400, Math.min(startHeight + deltaY, window.innerHeight - 60)); mapSection.style.height = `${newHeight}px`; this.map?.render(); }); @@ -901,31 +2715,175 @@ export class App { }); } + private setupMapPin(): void { + const mapSection = document.getElementById('mapSection'); + const pinBtn = document.getElementById('mapPinBtn'); + if (!mapSection || !pinBtn) return; + + // Load saved pin state + const isPinned = localStorage.getItem('map-pinned') === 'true'; + if (isPinned) { + mapSection.classList.add('pinned'); + pinBtn.classList.add('active'); + } + + pinBtn.addEventListener('click', () => { + const nowPinned = mapSection.classList.toggle('pinned'); + pinBtn.classList.toggle('active', nowPinned); + localStorage.setItem('map-pinned', String(nowPinned)); + }); + } + private renderPanelToggles(): void { const container = document.getElementById('panelToggles')!; - container.innerHTML = Object.entries(this.panelSettings) + const panelHtml = Object.entries(this.panelSettings) + .filter(([key]) => key !== 'runtime-config' || this.isDesktopApp) .map( ([key, panel]) => `
${panel.enabled ? '✓' : ''}
- ${panel.name} + ${this.getLocalizedPanelName(key, panel.name)}
` ) .join(''); + const findingsHtml = this.isMobile + ? '' + : (() => { + const findingsEnabled = this.findingsBadge?.isEnabled() ?? IntelligenceGapBadge.getStoredEnabledState(); + return ` +
+
${findingsEnabled ? '✓' : ''}
+ Intelligence Findings +
+ `; + })(); + + container.innerHTML = panelHtml + findingsHtml; + container.querySelectorAll('.panel-toggle-item').forEach((item) => { item.addEventListener('click', () => { const panelKey = (item as HTMLElement).dataset.panel!; + + if (panelKey === 'intel-findings') { + if (!this.findingsBadge) return; + this.findingsBadge.setEnabled(!this.findingsBadge.isEnabled()); + this.renderPanelToggles(); + return; + } + const config = this.panelSettings[panelKey]; + console.log('[Panel Toggle] Clicked:', panelKey, 'Current enabled:', config?.enabled); if (config) { config.enabled = !config.enabled; + console.log('[Panel Toggle] New enabled:', config.enabled); saveToStorage(STORAGE_KEYS.panels, this.panelSettings); this.renderPanelToggles(); this.applyPanelSettings(); + console.log('[Panel Toggle] After apply - config.enabled:', this.panelSettings[panelKey]?.enabled); + } + }); + }); + } + + private getLocalizedPanelName(panelKey: string, fallback: string): string { + if (panelKey === 'runtime-config') { + return t('modals.runtimeConfig.title'); + } + const key = panelKey.replace(/-([a-z])/g, (_match, group: string) => group.toUpperCase()); + const lookup = `panels.${key}`; + const localized = t(lookup); + return localized === lookup ? fallback : localized; + } + + private getAllSourceNames(): string[] { + const sources = new Set(); + Object.values(FEEDS).forEach(feeds => { + if (feeds) feeds.forEach(f => sources.add(f.name)); + }); + INTEL_SOURCES.forEach(f => sources.add(f.name)); + return Array.from(sources).sort((a, b) => a.localeCompare(b)); + } + + private renderSourceToggles(filter = ''): void { + const container = document.getElementById('sourceToggles')!; + const allSources = this.getAllSourceNames(); + const filterLower = filter.toLowerCase(); + const filteredSources = filter + ? allSources.filter(s => s.toLowerCase().includes(filterLower)) + : allSources; + + container.innerHTML = filteredSources.map(source => { + const isEnabled = !this.disabledSources.has(source); + const escaped = escapeHtml(source); + return ` +
+
${isEnabled ? '✓' : ''}
+ ${escaped} +
+ `; + }).join(''); + + container.querySelectorAll('.source-toggle-item').forEach(item => { + item.addEventListener('click', () => { + const sourceName = (item as HTMLElement).dataset.source!; + if (this.disabledSources.has(sourceName)) { + this.disabledSources.delete(sourceName); + } else { + this.disabledSources.add(sourceName); } + saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(this.disabledSources)); + this.renderSourceToggles(filter); }); }); + + // Update counter + const enabledCount = allSources.length - this.disabledSources.size; + const counterEl = document.getElementById('sourcesCounter'); + if (counterEl) { + counterEl.textContent = t('header.sourcesEnabled', { enabled: String(enabledCount), total: String(allSources.length) }); + } + } + + private setupSourcesModal(): void { + document.getElementById('sourcesBtn')?.addEventListener('click', () => { + document.getElementById('sourcesModal')?.classList.add('active'); + // Clear search and show all sources on open + const searchInput = document.getElementById('sourcesSearch') as HTMLInputElement | null; + if (searchInput) searchInput.value = ''; + this.renderSourceToggles(); + }); + + document.getElementById('sourcesModalClose')?.addEventListener('click', () => { + document.getElementById('sourcesModal')?.classList.remove('active'); + }); + + document.getElementById('sourcesModal')?.addEventListener('click', (e) => { + if ((e.target as HTMLElement)?.classList?.contains('modal-overlay')) { + document.getElementById('sourcesModal')?.classList.remove('active'); + } + }); + + document.getElementById('sourcesSearch')?.addEventListener('input', (e) => { + const filter = (e.target as HTMLInputElement).value; + this.renderSourceToggles(filter); + }); + + document.getElementById('sourcesSelectAll')?.addEventListener('click', () => { + this.disabledSources.clear(); + saveToStorage(STORAGE_KEYS.disabledFeeds, []); + const filter = (document.getElementById('sourcesSearch') as HTMLInputElement)?.value || ''; + this.renderSourceToggles(filter); + }); + + document.getElementById('sourcesSelectNone')?.addEventListener('click', () => { + const allSources = this.getAllSourceNames(); + this.disabledSources = new Set(allSources); + saveToStorage(STORAGE_KEYS.disabledFeeds, allSources); + const filter = (document.getElementById('sourcesSearch') as HTMLInputElement)?.value || ''; + this.renderSourceToggles(filter); + }); } private applyPanelSettings(): void { @@ -942,49 +2900,314 @@ export class App { }); } - private updateTime(): void { - const now = new Date(); - const el = document.getElementById('timeDisplay'); - if (el) { - el.textContent = now.toUTCString().split(' ')[4] + ' UTC'; + private updateHeaderThemeIcon(): void { + const btn = document.getElementById('headerThemeToggle'); + if (!btn) return; + const isDark = getCurrentTheme() === 'dark'; + btn.innerHTML = isDark + ? '' + : ''; + } + + private async loadAllData(): Promise { + const runGuarded = async (name: string, fn: () => Promise): Promise => { + if (this.inFlight.has(name)) return; + this.inFlight.add(name); + try { + await fn(); + } catch (e) { + console.error(`[App] ${name} failed:`, e); + } finally { + this.inFlight.delete(name); + } + }; + + const tasks: Array<{ name: string; task: Promise }> = [ + { name: 'news', task: runGuarded('news', () => this.loadNews()) }, + { name: 'markets', task: runGuarded('markets', () => this.loadMarkets()) }, + { name: 'predictions', task: runGuarded('predictions', () => this.loadPredictions()) }, + { name: 'pizzint', task: runGuarded('pizzint', () => this.loadPizzInt()) }, + { name: 'fred', task: runGuarded('fred', () => this.loadFredData()) }, + { name: 'oil', task: runGuarded('oil', () => this.loadOilAnalytics()) }, + { name: 'spending', task: runGuarded('spending', () => this.loadGovernmentSpending()) }, + ]; + + // Load intelligence signals for CII calculation (protests, military, outages) + // Only for geopolitical variant - tech variant doesn't need CII/focal points + if (SITE_VARIANT === 'full') { + tasks.push({ name: 'intelligence', task: runGuarded('intelligence', () => this.loadIntelligenceSignals()) }); + } + + // Conditionally load non-intelligence layers + // NOTE: outages, protests, military are handled by loadIntelligenceSignals() above + // They update the map when layers are enabled, so no duplicate tasks needed here + if (SITE_VARIANT === 'full') tasks.push({ name: 'firms', task: runGuarded('firms', () => this.loadFirmsData()) }); + if (this.mapLayers.natural) tasks.push({ name: 'natural', task: runGuarded('natural', () => this.loadNatural()) }); + if (this.mapLayers.weather) tasks.push({ name: 'weather', task: runGuarded('weather', () => this.loadWeatherAlerts()) }); + if (this.mapLayers.ais) tasks.push({ name: 'ais', task: runGuarded('ais', () => this.loadAisSignals()) }); + if (this.mapLayers.cables) tasks.push({ name: 'cables', task: runGuarded('cables', () => this.loadCableActivity()) }); + if (this.mapLayers.flights) tasks.push({ name: 'flights', task: runGuarded('flights', () => this.loadFlightDelays()) }); + if (CYBER_LAYER_ENABLED && this.mapLayers.cyberThreats) tasks.push({ name: 'cyberThreats', task: runGuarded('cyberThreats', () => this.loadCyberThreats()) }); + if (this.mapLayers.techEvents || SITE_VARIANT === 'tech') tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) }); + + // Tech Readiness panel (tech variant only) + if (SITE_VARIANT === 'tech') { + tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) }); + } + + // Use allSettled to ensure all tasks complete and search index always updates + const results = await Promise.allSettled(tasks.map(t => t.task)); + + // Log any failures but don't block + results.forEach((result, idx) => { + if (result.status === 'rejected') { + console.error(`[App] ${tasks[idx]?.name} load failed:`, result.reason); + } + }); + + // Always update search index regardless of individual task failures + this.updateSearchIndex(); + } + + private async loadDataForLayer(layer: keyof MapLayers): Promise { + if (this.inFlight.has(layer)) return; + this.inFlight.add(layer); + this.map?.setLayerLoading(layer, true); + try { + switch (layer) { + case 'natural': + await this.loadNatural(); + break; + case 'fires': + await this.loadFirmsData(); + break; + case 'weather': + await this.loadWeatherAlerts(); + break; + case 'outages': + await this.loadOutages(); + break; + case 'cyberThreats': + await this.loadCyberThreats(); + break; + case 'ais': + await this.loadAisSignals(); + break; + case 'cables': + await this.loadCableActivity(); + break; + case 'protests': + await this.loadProtests(); + break; + case 'flights': + await this.loadFlightDelays(); + break; + case 'military': + await this.loadMilitary(); + break; + case 'techEvents': + console.log('[loadDataForLayer] Loading techEvents...'); + await this.loadTechEvents(); + console.log('[loadDataForLayer] techEvents loaded'); + break; + case 'ucdpEvents': + case 'displacement': + case 'climate': + await this.loadIntelligenceSignals(); + break; + } + } finally { + this.inFlight.delete(layer); + this.map?.setLayerLoading(layer, false); + } + } + + private findFlashLocation(title: string): { lat: number; lon: number } | null { + const titleLower = title.toLowerCase(); + let bestMatch: { lat: number; lon: number; matches: number } | null = null; + + const countKeywordMatches = (keywords: string[] | undefined): number => { + if (!keywords) return 0; + let matches = 0; + for (const keyword of keywords) { + const cleaned = keyword.trim().toLowerCase(); + if (cleaned.length >= 3 && titleLower.includes(cleaned)) { + matches++; + } + } + return matches; + }; + + for (const hotspot of INTEL_HOTSPOTS) { + const matches = countKeywordMatches(hotspot.keywords); + if (matches > 0 && (!bestMatch || matches > bestMatch.matches)) { + bestMatch = { lat: hotspot.lat, lon: hotspot.lon, matches }; + } + } + + for (const conflict of CONFLICT_ZONES) { + const matches = countKeywordMatches(conflict.keywords); + if (matches > 0 && (!bestMatch || matches > bestMatch.matches)) { + bestMatch = { lat: conflict.center[1], lon: conflict.center[0], matches }; + } + } + + return bestMatch; + } + + private flashMapForNews(items: NewsItem[]): void { + if (!this.map || !this.initialLoadComplete) return; + const now = Date.now(); + + for (const [key, timestamp] of this.mapFlashCache.entries()) { + if (now - timestamp > this.MAP_FLASH_COOLDOWN_MS) { + this.mapFlashCache.delete(key); + } + } + + for (const item of items) { + const cacheKey = `${item.source}|${item.link || item.title}`; + const lastSeen = this.mapFlashCache.get(cacheKey); + if (lastSeen && now - lastSeen < this.MAP_FLASH_COOLDOWN_MS) { + continue; + } + + const location = this.findFlashLocation(item.title); + if (!location) continue; + + this.map.flashLocation(location.lat, location.lon); + this.mapFlashCache.set(cacheKey, now); + } + } + + private getTimeRangeWindowMs(range: TimeRange): number { + const ranges: Record = { + '1h': 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '48h': 48 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + 'all': Infinity, + }; + return ranges[range]; + } + + private filterItemsByTimeRange(items: NewsItem[], range: TimeRange = this.currentTimeRange): NewsItem[] { + if (range === 'all') return items; + const cutoff = Date.now() - this.getTimeRangeWindowMs(range); + return items.filter((item) => { + const ts = item.pubDate instanceof Date ? item.pubDate.getTime() : new Date(item.pubDate).getTime(); + return Number.isFinite(ts) ? ts >= cutoff : true; + }); + } + + private getTimeRangeLabel(range: TimeRange = this.currentTimeRange): string { + const labels: Record = { + '1h': 'the last hour', + '6h': 'the last 6 hours', + '24h': 'the last 24 hours', + '48h': 'the last 48 hours', + '7d': 'the last 7 days', + 'all': 'all time', + }; + return labels[range]; + } + + private renderNewsForCategory(category: string, items: NewsItem[]): void { + this.newsByCategory[category] = items; + const panel = this.newsPanels[category]; + if (!panel) return; + const filteredItems = this.filterItemsByTimeRange(items); + if (filteredItems.length === 0 && items.length > 0) { + panel.renderFilteredEmpty(`No items in ${this.getTimeRangeLabel()}`); + return; } + panel.renderNews(filteredItems); } - private async loadAllData(): Promise { - await Promise.all([ - this.loadNews(), - this.loadMarkets(), - this.loadPredictions(), - this.loadEarthquakes(), - this.loadWeatherAlerts(), - this.loadFredData(), - this.loadOutages(), - this.loadAisSignals(), - this.loadCableActivity(), - this.loadProtests(), - ]); - - // Update search index after all data loads - this.updateSearchIndex(); + private applyTimeRangeFilterToNewsPanels(): void { + Object.entries(this.newsByCategory).forEach(([category, items]) => { + this.renderNewsForCategory(category, items); + }); } private async loadNewsCategory(category: string, feeds: typeof FEEDS.politics): Promise { try { const panel = this.newsPanels[category]; - const items = await fetchCategoryFeeds(feeds ?? [], { - onBatch: (partialItems) => { - if (panel) { - panel.renderNews(partialItems); + const renderIntervalMs = 250; + let lastRenderTime = 0; + let renderTimeout: ReturnType | null = null; + let pendingItems: NewsItem[] | null = null; + + // Filter out disabled sources + const enabledFeeds = (feeds ?? []).filter(f => !this.disabledSources.has(f.name)); + if (enabledFeeds.length === 0) { + delete this.newsByCategory[category]; + if (panel) panel.showError(t('common.allSourcesDisabled')); + this.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { + status: 'ok', + itemCount: 0, + }); + return []; + } + + const flushPendingRender = () => { + if (!pendingItems) return; + this.renderNewsForCategory(category, pendingItems); + pendingItems = null; + lastRenderTime = Date.now(); + }; + + const scheduleRender = (partialItems: NewsItem[]) => { + if (!panel) return; + pendingItems = partialItems; + const elapsed = Date.now() - lastRenderTime; + if (elapsed >= renderIntervalMs) { + if (renderTimeout) { + clearTimeout(renderTimeout); + renderTimeout = null; } + flushPendingRender(); + return; + } + + if (!renderTimeout) { + renderTimeout = setTimeout(() => { + renderTimeout = null; + flushPendingRender(); + }, renderIntervalMs - elapsed); + } + }; + + const items = await fetchCategoryFeeds(enabledFeeds, { + onBatch: (partialItems) => { + scheduleRender(partialItems); + this.flashMapForNews(partialItems); }, }); + this.renderNewsForCategory(category, items); if (panel) { - panel.renderNews(items); + if (renderTimeout) { + clearTimeout(renderTimeout); + renderTimeout = null; + pendingItems = null; + } - const baseline = await updateBaseline(`news:${category}`, items.length); - const deviation = calculateDeviation(items.length, baseline); - panel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + if (items.length === 0) { + const failures = getFeedFailures(); + const failedFeeds = enabledFeeds.filter(f => failures.has(f.name)); + if (failedFeeds.length > 0) { + const names = failedFeeds.map(f => f.name).join(', '); + panel.showError(`${t('common.noNewsAvailable')} (${names} failed)`); + } + } + + try { + const baseline = await updateBaseline(`news:${category}`, items.length); + const deviation = calculateDeviation(items.length, baseline); + panel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + } catch (e) { console.warn(`[Baseline] news:${category} write failed:`, e); } } this.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { @@ -1000,40 +3223,79 @@ export class App { errorMessage: String(error), }); this.statusPanel?.updateApi('RSS2JSON', { status: 'error' }); + delete this.newsByCategory[category]; return []; } } private async loadNews(): Promise { - this.allNews = []; - - const categories = [ - { key: 'politics', feeds: FEEDS.politics }, - { key: 'tech', feeds: FEEDS.tech }, - { key: 'finance', feeds: FEEDS.finance }, - { key: 'gov', feeds: FEEDS.gov }, - { key: 'middleeast', feeds: FEEDS.middleeast }, - { key: 'layoffs', feeds: FEEDS.layoffs }, - { key: 'congress', feeds: FEEDS.congress }, - { key: 'ai', feeds: FEEDS.ai }, - { key: 'thinktanks', feeds: FEEDS.thinktanks }, - ]; - - for (const { key, feeds } of categories) { - const items = await this.loadNewsCategory(key, feeds); - this.allNews.push(...items); + // Build categories dynamically from whatever feeds the current variant exports + const categories = Object.entries(FEEDS) + .filter((entry): entry is [string, typeof FEEDS[keyof typeof FEEDS]] => Array.isArray(entry[1]) && entry[1].length > 0) + .map(([key, feeds]) => ({ key, feeds })); + + // Stage category fetches to avoid startup bursts and API pressure in all variants. + const maxCategoryConcurrency = SITE_VARIANT === 'finance' ? 3 : SITE_VARIANT === 'tech' ? 4 : 5; + const categoryConcurrency = Math.max(1, Math.min(maxCategoryConcurrency, categories.length)); + const categoryResults: PromiseSettledResult[] = []; + for (let i = 0; i < categories.length; i += categoryConcurrency) { + const chunk = categories.slice(i, i + categoryConcurrency); + const chunkResults = await Promise.allSettled( + chunk.map(({ key, feeds }) => this.loadNewsCategory(key, feeds)) + ); + categoryResults.push(...chunkResults); } - // Intel (uses different source) - const intel = await fetchCategoryFeeds(INTEL_SOURCES); - const intelPanel = this.newsPanels['intel']; - if (intelPanel) { - intelPanel.renderNews(intel); - const baseline = await updateBaseline('news:intel', intel.length); - const deviation = calculateDeviation(intel.length, baseline); - intelPanel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + // Collect successful results + const collectedNews: NewsItem[] = []; + categoryResults.forEach((result, idx) => { + if (result.status === 'fulfilled') { + collectedNews.push(...result.value); + } else { + console.error(`[App] News category ${categories[idx]?.key} failed:`, result.reason); + } + }); + + // Intel (uses different source) - full variant only (defense/military news) + if (SITE_VARIANT === 'full') { + const enabledIntelSources = INTEL_SOURCES.filter(f => !this.disabledSources.has(f.name)); + const intelPanel = this.newsPanels['intel']; + if (enabledIntelSources.length === 0) { + delete this.newsByCategory['intel']; + if (intelPanel) intelPanel.showError(t('common.allIntelSourcesDisabled')); + this.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: 0 }); + } else { + const intelResult = await Promise.allSettled([fetchCategoryFeeds(enabledIntelSources)]); + if (intelResult[0]?.status === 'fulfilled') { + const intel = intelResult[0].value; + this.renderNewsForCategory('intel', intel); + if (intelPanel) { + try { + const baseline = await updateBaseline('news:intel', intel.length); + const deviation = calculateDeviation(intel.length, baseline); + intelPanel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + } catch (e) { console.warn('[Baseline] news:intel write failed:', e); } + } + this.statusPanel?.updateFeed('Intel', { status: 'ok', itemCount: intel.length }); + collectedNews.push(...intel); + this.flashMapForNews(intel); + } else { + delete this.newsByCategory['intel']; + console.error('[App] Intel feed failed:', intelResult[0]?.reason); + } + } } - this.allNews.push(...intel); + + this.allNews = collectedNews; + this.initialLoadComplete = true; + maybeShowDownloadBanner(); + mountCommunityWidget(); + // Temporal baseline: report news volume + updateAndCheck([ + { type: 'news', region: 'global', count: collectedNews.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); // Update map hotspots this.map?.updateHotspotActivity(this.allNews); @@ -1041,55 +3303,90 @@ export class App { // Update monitors this.updateMonitorResults(); - // Update clusters for correlation analysis - this.latestClusters = clusterNews(this.allNews); + // Update clusters for correlation analysis (hybrid: semantic + Jaccard when ML available) + try { + this.latestClusters = mlWorker.isAvailable + ? await clusterNewsHybrid(this.allNews) + : await analysisWorker.clusterNews(this.allNews); + + // Update AI Insights panel with new clusters (if ML available) + if (mlWorker.isAvailable && this.latestClusters.length > 0) { + const insightsPanel = this.panels['insights'] as InsightsPanel | undefined; + insightsPanel?.updateInsights(this.latestClusters); + } + + // Push geo-located news clusters to map + const geoLocated = this.latestClusters + .filter((c): c is typeof c & { lat: number; lon: number } => c.lat != null && c.lon != null) + .map(c => ({ + lat: c.lat, + lon: c.lon, + title: c.primaryTitle, + threatLevel: c.threat?.level ?? 'info', + timestamp: c.lastUpdated, + })); + if (geoLocated.length > 0) { + this.map?.setNewsLocations(geoLocated); + } + } catch (error) { + console.error('[App] Clustering failed, clusters unchanged:', error); + } } private async loadMarkets(): Promise { try { - // Stocks - const stocks = await fetchMultipleStocks(MARKET_SYMBOLS, { + const stocksResult = await fetchMultipleStocks(MARKET_SYMBOLS, { onBatch: (partialStocks) => { this.latestMarkets = partialStocks; (this.panels['markets'] as MarketPanel).renderMarkets(partialStocks); }, }); - this.latestMarkets = stocks; - (this.panels['markets'] as MarketPanel).renderMarkets(stocks); - this.statusPanel?.updateApi('Alpha Vantage', { status: 'ok' }); - - // Sectors - const sectors = await fetchMultipleStocks( - SECTORS.map((s) => ({ ...s, display: s.name })), - { - onBatch: (partialSectors) => { - (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( - partialSectors.map((s) => ({ name: s.name, change: s.change })) - ); - }, + + const finnhubConfigMsg = 'FINNHUB_API_KEY not configured — add in Settings'; + this.latestMarkets = stocksResult.data; + (this.panels['markets'] as MarketPanel).renderMarkets(stocksResult.data); + + if (stocksResult.skipped) { + this.statusPanel?.updateApi('Finnhub', { status: 'error' }); + if (stocksResult.data.length === 0) { + this.panels['markets']?.showConfigError(finnhubConfigMsg); } - ); - (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( - sectors.map((s) => ({ name: s.name, change: s.change })) - ); + this.panels['heatmap']?.showConfigError(finnhubConfigMsg); + } else { + this.statusPanel?.updateApi('Finnhub', { status: 'ok' }); + + const sectorsResult = await fetchMultipleStocks( + SECTORS.map((s) => ({ ...s, display: s.name })), + { + onBatch: (partialSectors) => { + (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( + partialSectors.map((s) => ({ name: s.name, change: s.change })) + ); + }, + } + ); + (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( + sectorsResult.data.map((s) => ({ name: s.name, change: s.change })) + ); + } - // Commodities - const commodities = await fetchMultipleStocks(COMMODITIES, { + const commoditiesResult = await fetchMultipleStocks(COMMODITIES, { onBatch: (partialCommodities) => { (this.panels['commodities'] as CommoditiesPanel).renderCommodities( partialCommodities.map((c) => ({ display: c.display, price: c.price, change: c.change, + sparkline: c.sparkline, })) ); }, }); (this.panels['commodities'] as CommoditiesPanel).renderCommodities( - commodities.map((c) => ({ display: c.display, price: c.price, change: c.change })) + commoditiesResult.data.map((c) => ({ display: c.display, price: c.price, change: c.change, sparkline: c.sparkline })) ); } catch { - this.statusPanel?.updateApi('Alpha Vantage', { status: 'error' }); + this.statusPanel?.updateApi('Finnhub', { status: 'error' }); } try { @@ -1110,21 +3407,114 @@ export class App { this.statusPanel?.updateFeed('Polymarket', { status: 'ok', itemCount: predictions.length }); this.statusPanel?.updateApi('Polymarket', { status: 'ok' }); + dataFreshness.recordUpdate('polymarket', predictions.length); - this.runCorrelationAnalysis(); + // Run correlation analysis in background (fire-and-forget via Web Worker) + void this.runCorrelationAnalysis(); } catch (error) { this.statusPanel?.updateFeed('Polymarket', { status: 'error', errorMessage: String(error) }); this.statusPanel?.updateApi('Polymarket', { status: 'error' }); + dataFreshness.recordError('polymarket', String(error)); } } - private async loadEarthquakes(): Promise { - try { - const earthquakes = await fetchEarthquakes(); - this.map?.setEarthquakes(earthquakes); + private async loadNatural(): Promise { + // Load both USGS earthquakes and NASA EONET natural events in parallel + const [earthquakeResult, eonetResult] = await Promise.allSettled([ + fetchEarthquakes(), + fetchNaturalEvents(30), + ]); + + // Handle earthquakes (USGS) + if (earthquakeResult.status === 'fulfilled') { + this.intelligenceCache.earthquakes = earthquakeResult.value; + this.map?.setEarthquakes(earthquakeResult.value); + ingestEarthquakes(earthquakeResult.value); this.statusPanel?.updateApi('USGS', { status: 'ok' }); - } catch { + dataFreshness.recordUpdate('usgs', earthquakeResult.value.length); + } else { + this.intelligenceCache.earthquakes = []; + this.map?.setEarthquakes([]); this.statusPanel?.updateApi('USGS', { status: 'error' }); + dataFreshness.recordError('usgs', String(earthquakeResult.reason)); + } + + // Handle natural events (EONET - storms, fires, volcanoes, etc.) + if (eonetResult.status === 'fulfilled') { + this.map?.setNaturalEvents(eonetResult.value); + this.statusPanel?.updateFeed('EONET', { + status: 'ok', + itemCount: eonetResult.value.length, + }); + this.statusPanel?.updateApi('NASA EONET', { status: 'ok' }); + } else { + this.map?.setNaturalEvents([]); + this.statusPanel?.updateFeed('EONET', { status: 'error', errorMessage: String(eonetResult.reason) }); + this.statusPanel?.updateApi('NASA EONET', { status: 'error' }); + } + + // Set layer ready based on combined data + const hasEarthquakes = earthquakeResult.status === 'fulfilled' && earthquakeResult.value.length > 0; + const hasEonet = eonetResult.status === 'fulfilled' && eonetResult.value.length > 0; + this.map?.setLayerReady('natural', hasEarthquakes || hasEonet); + } + + private async loadTechEvents(): Promise { + console.log('[loadTechEvents] Called. SITE_VARIANT:', SITE_VARIANT, 'techEvents layer:', this.mapLayers.techEvents); + // Only load for tech variant or if techEvents layer is enabled + if (SITE_VARIANT !== 'tech' && !this.mapLayers.techEvents) { + console.log('[loadTechEvents] Skipping - not tech variant and layer disabled'); + return; + } + + try { + const res = await fetch('/api/tech-events?type=conference&mappable=true&days=90&limit=50'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Unknown error'); + + // Transform events for map markers + const now = new Date(); + const mapEvents = data.events.map((e: { + id: string; + title: string; + location: string; + coords: { lat: number; lng: number; country: string }; + startDate: string; + endDate: string; + url: string | null; + }) => ({ + id: e.id, + title: e.title, + location: e.location, + lat: e.coords.lat, + lng: e.coords.lng, + country: e.coords.country, + startDate: e.startDate, + endDate: e.endDate, + url: e.url, + daysUntil: Math.ceil((new Date(e.startDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), + })); + + this.map?.setTechEvents(mapEvents); + this.map?.setLayerReady('techEvents', mapEvents.length > 0); + this.statusPanel?.updateFeed('Tech Events', { status: 'ok', itemCount: mapEvents.length }); + + // Register tech events as searchable source + if (SITE_VARIANT === 'tech' && this.searchModal) { + this.searchModal.registerSource('techevent', mapEvents.map((e: { id: string; title: string; location: string; startDate: string }) => ({ + id: e.id, + title: e.title, + subtitle: `${e.location} • ${new Date(e.startDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`, + data: e, + }))); + } + } catch (error) { + console.error('[App] Failed to load tech events:', error); + this.map?.setTechEvents([]); + this.map?.setLayerReady('techEvents', false); + this.statusPanel?.updateFeed('Tech Events', { status: 'error', errorMessage: String(error) }); } } @@ -1132,19 +3522,331 @@ export class App { try { const alerts = await fetchWeatherAlerts(); this.map?.setWeatherAlerts(alerts); + this.map?.setLayerReady('weather', alerts.length > 0); this.statusPanel?.updateFeed('Weather', { status: 'ok', itemCount: alerts.length }); - } catch { + dataFreshness.recordUpdate('weather', alerts.length); + } catch (error) { + this.map?.setLayerReady('weather', false); this.statusPanel?.updateFeed('Weather', { status: 'error' }); + dataFreshness.recordError('weather', String(error)); + } + } + + // Cache for intelligence data - allows CII to work even when layers are disabled + private intelligenceCache: { + outages?: InternetOutage[]; + protests?: { events: SocialUnrestEvent[]; sources: { acled: number; gdelt: number } }; + military?: { flights: MilitaryFlight[]; flightClusters: MilitaryFlightCluster[]; vessels: MilitaryVessel[]; vesselClusters: MilitaryVesselCluster[] }; + earthquakes?: import('@/types').Earthquake[]; + } = {}; + private cyberThreatsCache: CyberThreat[] | null = null; + + /** + * Load intelligence-critical signals for CII/focal point calculation + * This runs ALWAYS, regardless of layer visibility + * Map rendering is separate and still gated by layer visibility + */ + private async loadIntelligenceSignals(): Promise { + const tasks: Promise[] = []; + + // Always fetch outages for CII (internet blackouts = major instability signal) + tasks.push((async () => { + try { + const outages = await fetchInternetOutages(); + this.intelligenceCache.outages = outages; + ingestOutagesForCII(outages); + signalAggregator.ingestOutages(outages); + dataFreshness.recordUpdate('outages', outages.length); + // Update map only if layer is visible + if (this.mapLayers.outages) { + this.map?.setOutages(outages); + this.map?.setLayerReady('outages', outages.length > 0); + this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); + } + } catch (error) { + console.error('[Intelligence] Outages fetch failed:', error); + dataFreshness.recordError('outages', String(error)); + } + })()); + + // Always fetch protests for CII (unrest = core instability metric) + // This task is also used by UCDP deduplication, so keep it as a shared promise. + const protestsTask = (async (): Promise => { + try { + const protestData = await fetchProtestEvents(); + this.intelligenceCache.protests = protestData; + ingestProtests(protestData.events); + ingestProtestsForCII(protestData.events); + signalAggregator.ingestProtests(protestData.events); + const protestCount = protestData.sources.acled + protestData.sources.gdelt; + if (protestCount > 0) dataFreshness.recordUpdate('acled', protestCount); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt); + // Update map only if layer is visible + if (this.mapLayers.protests) { + this.map?.setProtests(protestData.events); + this.map?.setLayerReady('protests', protestData.events.length > 0); + const status = getProtestStatus(); + this.statusPanel?.updateFeed('Protests', { + status: 'ok', + itemCount: protestData.events.length, + errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined, + }); + } + return protestData.events; + } catch (error) { + console.error('[Intelligence] Protests fetch failed:', error); + dataFreshness.recordError('acled', String(error)); + return []; + } + })(); + tasks.push(protestsTask.then(() => undefined)); + + // Fetch armed conflict events (battles, explosions, violence) for CII + tasks.push((async () => { + try { + const conflictData = await fetchConflictEvents(); + ingestConflictsForCII(conflictData.events); + if (conflictData.count > 0) dataFreshness.recordUpdate('acled_conflict', conflictData.count); + } catch (error) { + console.error('[Intelligence] Conflict events fetch failed:', error); + dataFreshness.recordError('acled_conflict', String(error)); + } + })()); + + // Fetch UCDP conflict classifications (war vs minor vs none) + tasks.push((async () => { + try { + const classifications = await fetchUcdpClassifications(); + ingestUcdpForCII(classifications); + if (classifications.size > 0) dataFreshness.recordUpdate('ucdp', classifications.size); + } catch (error) { + console.error('[Intelligence] UCDP fetch failed:', error); + dataFreshness.recordError('ucdp', String(error)); + } + })()); + + // Fetch HDX HAPI aggregated conflict data (fallback/validation) + tasks.push((async () => { + try { + const summaries = await fetchHapiSummary(); + ingestHapiForCII(summaries); + if (summaries.size > 0) dataFreshness.recordUpdate('hapi', summaries.size); + } catch (error) { + console.error('[Intelligence] HAPI fetch failed:', error); + dataFreshness.recordError('hapi', String(error)); + } + })()); + + // Always fetch military for CII (security = core instability metric) + tasks.push((async () => { + try { + if (isMilitaryVesselTrackingConfigured()) { + initMilitaryVesselStream(); + } + const [flightData, vesselData] = await Promise.all([ + fetchMilitaryFlights(), + fetchMilitaryVessels(), + ]); + this.intelligenceCache.military = { + flights: flightData.flights, + flightClusters: flightData.clusters, + vessels: vesselData.vessels, + vesselClusters: vesselData.clusters, + }; + ingestFlights(flightData.flights); + ingestVessels(vesselData.vessels); + ingestMilitaryForCII(flightData.flights, vesselData.vessels); + signalAggregator.ingestFlights(flightData.flights); + signalAggregator.ingestVessels(vesselData.vessels); + dataFreshness.recordUpdate('opensky', flightData.flights.length); + // Temporal baseline: report counts and check for anomalies + updateAndCheck([ + { type: 'military_flights', region: 'global', count: flightData.flights.length }, + { type: 'vessels', region: 'global', count: vesselData.vessels.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + // Update map only if layer is visible + if (this.mapLayers.military) { + this.map?.setMilitaryFlights(flightData.flights, flightData.clusters); + this.map?.setMilitaryVessels(vesselData.vessels, vesselData.clusters); + this.map?.updateMilitaryForEscalation(flightData.flights, vesselData.vessels); + const militaryCount = flightData.flights.length + vesselData.vessels.length; + this.statusPanel?.updateFeed('Military', { + status: militaryCount > 0 ? 'ok' : 'warning', + itemCount: militaryCount, + }); + } + // Detect military airlift surges and foreign presence (suppress during learning mode) + if (!isInLearningMode()) { + const surgeAlerts = analyzeFlightsForSurge(flightData.flights); + if (surgeAlerts.length > 0) { + const surgeSignals = surgeAlerts.map(surgeAlertToSignal); + addToSignalHistory(surgeSignals); + if (this.shouldShowIntelligenceNotifications()) this.signalModal?.show(surgeSignals); + } + const foreignAlerts = detectForeignMilitaryPresence(flightData.flights); + if (foreignAlerts.length > 0) { + const foreignSignals = foreignAlerts.map(foreignPresenceToSignal); + addToSignalHistory(foreignSignals); + if (this.shouldShowIntelligenceNotifications()) this.signalModal?.show(foreignSignals); + } + } + } catch (error) { + console.error('[Intelligence] Military fetch failed:', error); + dataFreshness.recordError('opensky', String(error)); + } + })()); + + // Fetch UCDP georeferenced events (battles, one-sided violence, non-state conflict) + tasks.push((async () => { + try { + const [result, protestEvents] = await Promise.all([ + fetchUcdpEvents(), + protestsTask, + ]); + if (!result.success) { + dataFreshness.recordError('ucdp_events', 'UCDP events unavailable (retaining prior event state)'); + return; + } + const acledEvents = protestEvents.map(e => ({ + latitude: e.lat, longitude: e.lon, event_date: e.time.toISOString(), fatalities: e.fatalities ?? 0, + })); + const events = deduplicateAgainstAcled(result.data, acledEvents); + (this.panels['ucdp-events'] as UcdpEventsPanel)?.setEvents(events); + if (this.mapLayers.ucdpEvents) { + this.map?.setUcdpEvents(events); + } + if (events.length > 0) dataFreshness.recordUpdate('ucdp_events', events.length); + } catch (error) { + console.error('[Intelligence] UCDP events fetch failed:', error); + dataFreshness.recordError('ucdp_events', String(error)); + } + })()); + + // Fetch UNHCR displacement data (refugees, asylum seekers, IDPs) + tasks.push((async () => { + try { + const unhcrResult = await fetchUnhcrPopulation(); + if (!unhcrResult.ok) { + dataFreshness.recordError('unhcr', 'UNHCR displacement unavailable (retaining prior displacement state)'); + return; + } + const data = unhcrResult.data; + (this.panels['displacement'] as DisplacementPanel)?.setData(data); + ingestDisplacementForCII(data.countries); + if (this.mapLayers.displacement && data.topFlows) { + this.map?.setDisplacementFlows(data.topFlows); + } + if (data.countries.length > 0) dataFreshness.recordUpdate('unhcr', data.countries.length); + } catch (error) { + console.error('[Intelligence] UNHCR displacement fetch failed:', error); + dataFreshness.recordError('unhcr', String(error)); + } + })()); + + // Fetch climate anomalies (temperature/precipitation deviations) + tasks.push((async () => { + try { + const climateResult = await fetchClimateAnomalies(); + if (!climateResult.ok) { + dataFreshness.recordError('climate', 'Climate anomalies unavailable (retaining prior climate state)'); + return; + } + const anomalies = climateResult.anomalies; + (this.panels['climate'] as ClimateAnomalyPanel)?.setAnomalies(anomalies); + ingestClimateForCII(anomalies); + if (this.mapLayers.climate) { + this.map?.setClimateAnomalies(anomalies); + } + if (anomalies.length > 0) dataFreshness.recordUpdate('climate', anomalies.length); + } catch (error) { + console.error('[Intelligence] Climate anomalies fetch failed:', error); + dataFreshness.recordError('climate', String(error)); + } + })()); + + await Promise.allSettled(tasks); + + // Fetch population exposure estimates after upstream intelligence loads complete. + // This avoids race conditions where UCDP/protest data is still in-flight. + try { + const ucdpEvts = (this.panels['ucdp-events'] as UcdpEventsPanel)?.getEvents?.() || []; + const events = [ + ...(this.intelligenceCache.protests?.events || []).slice(0, 10).map(e => ({ + id: e.id, lat: e.lat, lon: e.lon, type: 'conflict' as const, name: e.title || 'Protest', + })), + ...ucdpEvts.slice(0, 10).map(e => ({ + id: e.id, lat: e.latitude, lon: e.longitude, type: e.type_of_violence as string, name: `${e.side_a} vs ${e.side_b}`, + })), + ]; + if (events.length > 0) { + const exposures = await enrichEventsWithExposure(events); + (this.panels['population-exposure'] as PopulationExposurePanel)?.setExposures(exposures); + if (exposures.length > 0) dataFreshness.recordUpdate('worldpop', exposures.length); + } + } catch (error) { + console.error('[Intelligence] Population exposure fetch failed:', error); + dataFreshness.recordError('worldpop', String(error)); } + + // Now trigger CII refresh with all intelligence data + (this.panels['cii'] as CIIPanel)?.refresh(); + console.log('[Intelligence] All signals loaded for CII calculation'); } private async loadOutages(): Promise { + // Use cached data if available + if (this.intelligenceCache.outages) { + const outages = this.intelligenceCache.outages; + this.map?.setOutages(outages); + this.map?.setLayerReady('outages', outages.length > 0); + this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); + return; + } try { const outages = await fetchInternetOutages(); + this.intelligenceCache.outages = outages; this.map?.setOutages(outages); + this.map?.setLayerReady('outages', outages.length > 0); + ingestOutagesForCII(outages); + signalAggregator.ingestOutages(outages); this.statusPanel?.updateFeed('NetBlocks', { status: 'ok', itemCount: outages.length }); - } catch { + dataFreshness.recordUpdate('outages', outages.length); + } catch (error) { + this.map?.setLayerReady('outages', false); this.statusPanel?.updateFeed('NetBlocks', { status: 'error' }); + dataFreshness.recordError('outages', String(error)); + } + } + + private async loadCyberThreats(): Promise { + if (!CYBER_LAYER_ENABLED) { + this.mapLayers.cyberThreats = false; + this.map?.setLayerReady('cyberThreats', false); + return; + } + + if (this.cyberThreatsCache) { + this.map?.setCyberThreats(this.cyberThreatsCache); + this.map?.setLayerReady('cyberThreats', this.cyberThreatsCache.length > 0); + this.statusPanel?.updateFeed('Cyber Threats', { status: 'ok', itemCount: this.cyberThreatsCache.length }); + return; + } + + try { + const threats = await fetchCyberThreats({ limit: 500, days: 14 }); + this.cyberThreatsCache = threats; + this.map?.setCyberThreats(threats); + this.map?.setLayerReady('cyberThreats', threats.length > 0); + this.statusPanel?.updateFeed('Cyber Threats', { status: 'ok', itemCount: threats.length }); + this.statusPanel?.updateApi('Cyber Threats API', { status: 'ok' }); + dataFreshness.recordUpdate('cyber_threats', threats.length); + } catch (error) { + this.map?.setLayerReady('cyberThreats', false); + this.statusPanel?.updateFeed('Cyber Threats', { status: 'error', errorMessage: String(error) }); + this.statusPanel?.updateApi('Cyber Threats API', { status: 'error' }); + dataFreshness.recordError('cyber_threats', String(error)); } } @@ -1152,30 +3854,70 @@ export class App { try { const { disruptions, density } = await fetchAisSignals(); const aisStatus = getAisStatus(); + console.log('[Ships] Events:', { disruptions: disruptions.length, density: density.length, vessels: aisStatus.vessels }); this.map?.setAisData(disruptions, density); - - if (aisStatus.connected) { - this.statusPanel?.updateFeed('AIS', { - status: 'ok', - itemCount: disruptions.length + density.length, - }); - this.statusPanel?.updateApi('AISStream', { status: 'ok' }); - } else { - this.statusPanel?.updateFeed('AIS', { - status: aisStatus.vessels > 0 ? 'ok' : 'error', - itemCount: disruptions.length + density.length, - errorMessage: aisStatus.vessels === 0 ? 'No API key - set VITE_AISSTREAM_API_KEY' : undefined, - }); - this.statusPanel?.updateApi('AISStream', { - status: aisStatus.vessels > 0 ? 'ok' : 'error', - }); + signalAggregator.ingestAisDisruptions(disruptions); + // Temporal baseline: report AIS gap counts + updateAndCheck([ + { type: 'ais_gaps', region: 'global', count: disruptions.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + + const hasData = disruptions.length > 0 || density.length > 0; + this.map?.setLayerReady('ais', hasData); + + const shippingCount = disruptions.length + density.length; + const shippingStatus = shippingCount > 0 ? 'ok' : (aisStatus.connected ? 'warning' : 'error'); + this.statusPanel?.updateFeed('Shipping', { + status: shippingStatus, + itemCount: shippingCount, + errorMessage: !aisStatus.connected && shippingCount === 0 ? 'AIS snapshot unavailable' : undefined, + }); + this.statusPanel?.updateApi('AISStream', { + status: aisStatus.connected ? 'ok' : 'warning', + }); + if (hasData) { + dataFreshness.recordUpdate('ais', shippingCount); } } catch (error) { - this.statusPanel?.updateFeed('AIS', { status: 'error', errorMessage: String(error) }); + this.map?.setLayerReady('ais', false); + this.statusPanel?.updateFeed('Shipping', { status: 'error', errorMessage: String(error) }); this.statusPanel?.updateApi('AISStream', { status: 'error' }); + dataFreshness.recordError('ais', String(error)); } } + private waitForAisData(): void { + const maxAttempts = 30; + let attempts = 0; + + const checkData = () => { + attempts++; + const status = getAisStatus(); + + if (status.vessels > 0 || status.connected) { + this.loadAisSignals(); + this.map?.setLayerLoading('ais', false); + return; + } + + if (attempts >= maxAttempts) { + this.map?.setLayerLoading('ais', false); + this.map?.setLayerReady('ais', false); + this.statusPanel?.updateFeed('Shipping', { + status: 'error', + errorMessage: 'Connection timeout' + }); + return; + } + + setTimeout(checkData, 1000); + }; + + checkData(); + } + private async loadCableActivity(): Promise { try { const activity = await fetchCableActivity(); @@ -1188,37 +3930,246 @@ export class App { } private async loadProtests(): Promise { + // Use cached data if available (from loadIntelligenceSignals) + if (this.intelligenceCache.protests) { + const protestData = this.intelligenceCache.protests; + this.map?.setProtests(protestData.events); + this.map?.setLayerReady('protests', protestData.events.length > 0); + const status = getProtestStatus(); + this.statusPanel?.updateFeed('Protests', { + status: 'ok', + itemCount: protestData.events.length, + errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined, + }); + if (status.acledConfigured === true) { + this.statusPanel?.updateApi('ACLED', { status: 'ok' }); + } else if (status.acledConfigured === null) { + this.statusPanel?.updateApi('ACLED', { status: 'warning' }); + } + this.statusPanel?.updateApi('GDELT Doc', { status: 'ok' }); + return; + } try { const protestData = await fetchProtestEvents(); + this.intelligenceCache.protests = protestData; this.map?.setProtests(protestData.events); + this.map?.setLayerReady('protests', protestData.events.length > 0); + ingestProtests(protestData.events); + ingestProtestsForCII(protestData.events); + signalAggregator.ingestProtests(protestData.events); + const protestCount = protestData.sources.acled + protestData.sources.gdelt; + if (protestCount > 0) dataFreshness.recordUpdate('acled', protestCount); + if (protestData.sources.gdelt > 0) dataFreshness.recordUpdate('gdelt', protestData.sources.gdelt); + (this.panels['cii'] as CIIPanel)?.refresh(); const status = getProtestStatus(); - this.statusPanel?.updateFeed('Protests', { status: 'ok', itemCount: protestData.events.length, - errorMessage: !status.acledConfigured ? 'ACLED not configured - using GDELT only' : undefined, + errorMessage: status.acledConfigured === false ? 'ACLED not configured - using GDELT only' : undefined, }); - - if (status.acledConfigured) { + if (status.acledConfigured === true) { this.statusPanel?.updateApi('ACLED', { status: 'ok' }); + } else if (status.acledConfigured === null) { + this.statusPanel?.updateApi('ACLED', { status: 'warning' }); } - this.statusPanel?.updateApi('GDELT', { status: 'ok' }); + this.statusPanel?.updateApi('GDELT Doc', { status: 'ok' }); } catch (error) { + this.map?.setLayerReady('protests', false); this.statusPanel?.updateFeed('Protests', { status: 'error', errorMessage: String(error) }); this.statusPanel?.updateApi('ACLED', { status: 'error' }); - this.statusPanel?.updateApi('GDELT', { status: 'error' }); + this.statusPanel?.updateApi('GDELT Doc', { status: 'error' }); + } + } + + private async loadFlightDelays(): Promise { + try { + const delays = await fetchFlightDelays(); + this.map?.setFlightDelays(delays); + this.map?.setLayerReady('flights', delays.length > 0); + this.statusPanel?.updateFeed('Flights', { + status: 'ok', + itemCount: delays.length, + }); + this.statusPanel?.updateApi('FAA', { status: 'ok' }); + } catch (error) { + this.map?.setLayerReady('flights', false); + this.statusPanel?.updateFeed('Flights', { status: 'error', errorMessage: String(error) }); + this.statusPanel?.updateApi('FAA', { status: 'error' }); + } + } + + private async loadMilitary(): Promise { + // Use cached data if available (from loadIntelligenceSignals) + if (this.intelligenceCache.military) { + const { flights, flightClusters, vessels, vesselClusters } = this.intelligenceCache.military; + this.map?.setMilitaryFlights(flights, flightClusters); + this.map?.setMilitaryVessels(vessels, vesselClusters); + this.map?.updateMilitaryForEscalation(flights, vessels); + // Fetch cached postures for banner (posture panel fetches its own data) + this.loadCachedPosturesForBanner(); + const insightsPanel = this.panels['insights'] as InsightsPanel | undefined; + insightsPanel?.setMilitaryFlights(flights); + const hasData = flights.length > 0 || vessels.length > 0; + this.map?.setLayerReady('military', hasData); + const militaryCount = flights.length + vessels.length; + this.statusPanel?.updateFeed('Military', { + status: militaryCount > 0 ? 'ok' : 'warning', + itemCount: militaryCount, + errorMessage: militaryCount === 0 ? 'No military activity in view' : undefined, + }); + this.statusPanel?.updateApi('OpenSky', { status: 'ok' }); + return; + } + try { + if (isMilitaryVesselTrackingConfigured()) { + initMilitaryVesselStream(); + } + const [flightData, vesselData] = await Promise.all([ + fetchMilitaryFlights(), + fetchMilitaryVessels(), + ]); + this.intelligenceCache.military = { + flights: flightData.flights, + flightClusters: flightData.clusters, + vessels: vesselData.vessels, + vesselClusters: vesselData.clusters, + }; + this.map?.setMilitaryFlights(flightData.flights, flightData.clusters); + this.map?.setMilitaryVessels(vesselData.vessels, vesselData.clusters); + ingestFlights(flightData.flights); + ingestVessels(vesselData.vessels); + ingestMilitaryForCII(flightData.flights, vesselData.vessels); + signalAggregator.ingestFlights(flightData.flights); + signalAggregator.ingestVessels(vesselData.vessels); + // Temporal baseline: report counts from standalone military load + updateAndCheck([ + { type: 'military_flights', region: 'global', count: flightData.flights.length }, + { type: 'vessels', region: 'global', count: vesselData.vessels.length }, + ]).then(anomalies => { + if (anomalies.length > 0) signalAggregator.ingestTemporalAnomalies(anomalies); + }).catch(() => { }); + this.map?.updateMilitaryForEscalation(flightData.flights, vesselData.vessels); + (this.panels['cii'] as CIIPanel)?.refresh(); + if (!isInLearningMode()) { + const surgeAlerts = analyzeFlightsForSurge(flightData.flights); + if (surgeAlerts.length > 0) { + const surgeSignals = surgeAlerts.map(surgeAlertToSignal); + addToSignalHistory(surgeSignals); + if (this.shouldShowIntelligenceNotifications()) this.signalModal?.show(surgeSignals); + } + const foreignAlerts = detectForeignMilitaryPresence(flightData.flights); + if (foreignAlerts.length > 0) { + const foreignSignals = foreignAlerts.map(foreignPresenceToSignal); + addToSignalHistory(foreignSignals); + if (this.shouldShowIntelligenceNotifications()) this.signalModal?.show(foreignSignals); + } + } + + // Fetch cached postures for banner (posture panel fetches its own data) + this.loadCachedPosturesForBanner(); + const insightsPanel = this.panels['insights'] as InsightsPanel | undefined; + insightsPanel?.setMilitaryFlights(flightData.flights); + + const hasData = flightData.flights.length > 0 || vesselData.vessels.length > 0; + this.map?.setLayerReady('military', hasData); + const militaryCount = flightData.flights.length + vesselData.vessels.length; + this.statusPanel?.updateFeed('Military', { + status: militaryCount > 0 ? 'ok' : 'warning', + itemCount: militaryCount, + errorMessage: militaryCount === 0 ? 'No military activity in view' : undefined, + }); + this.statusPanel?.updateApi('OpenSky', { status: 'ok' }); + dataFreshness.recordUpdate('opensky', flightData.flights.length); + } catch (error) { + this.map?.setLayerReady('military', false); + this.statusPanel?.updateFeed('Military', { status: 'error', errorMessage: String(error) }); + this.statusPanel?.updateApi('OpenSky', { status: 'error' }); + dataFreshness.recordError('opensky', String(error)); + } + } + + /** + * Load cached theater postures for banner display + * Uses server-side cached data to avoid redundant calculation per user + */ + private async loadCachedPosturesForBanner(): Promise { + try { + const data = await fetchCachedTheaterPosture(); + if (data && data.postures.length > 0) { + this.renderCriticalBanner(data.postures); + // Also update posture panel with shared data (saves a duplicate fetch) + const posturePanel = this.panels['strategic-posture'] as StrategicPosturePanel | undefined; + posturePanel?.updatePostures(data); + } + } catch (error) { + console.warn('[App] Failed to load cached postures for banner:', error); } } + private async loadFredData(): Promise { + const economicPanel = this.panels['economic'] as EconomicPanel; + const cbInfo = getCircuitBreakerCooldownInfo('FRED Economic'); + if (cbInfo.onCooldown) { + economicPanel?.setErrorState(true, `Temporarily unavailable (retry in ${cbInfo.remainingSeconds}s)`); + this.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + try { - this.economicPanel?.setLoading(true); + economicPanel?.setLoading(true); const data = await fetchFredData(); - this.economicPanel?.update(data); + + // Check if circuit breaker tripped after fetch + const postInfo = getCircuitBreakerCooldownInfo('FRED Economic'); + if (postInfo.onCooldown) { + economicPanel?.setErrorState(true, `Temporarily unavailable (retry in ${postInfo.remainingSeconds}s)`); + this.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + + if (data.length === 0) { + const reason = isFeatureAvailable('economicFred') + ? 'FRED data temporarily unavailable — will retry' + : 'FRED_API_KEY not configured — add in Settings'; + economicPanel?.setErrorState(true, reason); + this.statusPanel?.updateApi('FRED', { status: 'error' }); + return; + } + + economicPanel?.setErrorState(false); + economicPanel?.update(data); this.statusPanel?.updateApi('FRED', { status: 'ok' }); + dataFreshness.recordUpdate('economic', data.length); } catch { this.statusPanel?.updateApi('FRED', { status: 'error' }); - this.economicPanel?.setLoading(false); + economicPanel?.setErrorState(true, 'FRED data temporarily unavailable — will retry'); + economicPanel?.setLoading(false); + } + } + + private async loadOilAnalytics(): Promise { + const economicPanel = this.panels['economic'] as EconomicPanel; + try { + const data = await fetchOilAnalytics(); + economicPanel?.updateOil(data); + const hasData = !!(data.wtiPrice || data.brentPrice || data.usProduction || data.usInventory); + this.statusPanel?.updateApi('EIA', { status: hasData ? 'ok' : 'error' }); + } catch (e) { + console.error('[App] Oil analytics failed:', e); + this.statusPanel?.updateApi('EIA', { status: 'error' }); + } + } + + private async loadGovernmentSpending(): Promise { + const economicPanel = this.panels['economic'] as EconomicPanel; + try { + const data = await fetchRecentAwards({ daysBack: 7, limit: 15 }); + economicPanel?.updateSpending(data); + this.statusPanel?.updateApi('USASpending', { status: data.awards.length > 0 ? 'ok' : 'error' }); + } catch (e) { + console.error('[App] Government spending failed:', e); + this.statusPanel?.updateApi('USASpending', { status: 'error' }); } } @@ -1227,33 +4178,179 @@ export class App { monitorPanel.renderResults(this.allNews); } - private runCorrelationAnalysis(): void { - if (this.latestClusters.length === 0) { - this.latestClusters = clusterNews(this.allNews); - } + private async runCorrelationAnalysis(): Promise { + try { + // Ensure we have clusters (hybrid: semantic + Jaccard when ML available) + if (this.latestClusters.length === 0 && this.allNews.length > 0) { + this.latestClusters = mlWorker.isAvailable + ? await clusterNewsHybrid(this.allNews) + : await analysisWorker.clusterNews(this.allNews); + } - const signals = analyzeCorrelations( - this.latestClusters, - this.latestPredictions, - this.latestMarkets - ); + // Ingest news clusters for CII + if (this.latestClusters.length > 0) { + ingestNewsForCII(this.latestClusters); + dataFreshness.recordUpdate('gdelt', this.latestClusters.length); + (this.panels['cii'] as CIIPanel)?.refresh(); + } + + // Run correlation analysis off main thread via Web Worker + const signals = await analysisWorker.analyzeCorrelations( + this.latestClusters, + this.latestPredictions, + this.latestMarkets + ); + + // Detect geographic convergence (suppress during learning mode) + let geoSignals: ReturnType[] = []; + if (!isInLearningMode()) { + const geoAlerts = detectGeoConvergence(this.seenGeoAlerts); + geoSignals = geoAlerts.map(geoConvergenceToSignal); + } + + const keywordSpikeSignals = drainTrendingSignals(); + const allSignals = [...signals, ...geoSignals, ...keywordSpikeSignals]; + if (allSignals.length > 0) { + addToSignalHistory(allSignals); + if (this.shouldShowIntelligenceNotifications()) this.signalModal?.show(allSignals); + } + } catch (error) { + console.error('[App] Correlation analysis failed:', error); + } + } - if (signals.length > 0) { - addToSignalHistory(signals); - this.signalModal?.show(signals); + private async loadFirmsData(): Promise { + try { + const fireResult = await fetchAllFires(1); + if (fireResult.skipped) { + this.panels['satellite-fires']?.showConfigError('NASA_FIRMS_API_KEY not configured — add in Settings'); + this.statusPanel?.updateApi('FIRMS', { status: 'error' }); + return; + } + const { regions, totalCount } = fireResult; + if (totalCount > 0) { + const flat = flattenFires(regions); + const stats = computeRegionStats(regions); + + // Feed signal aggregator + signalAggregator.ingestSatelliteFires(flat.map(f => ({ + lat: f.lat, + lon: f.lon, + brightness: f.brightness, + frp: f.frp, + region: f.region, + acq_date: f.acq_date, + }))); + + // Feed map layer + this.map?.setFires(flat); + + // Feed panel + (this.panels['satellite-fires'] as SatelliteFiresPanel)?.update(stats, totalCount); + + dataFreshness.recordUpdate('firms', totalCount); + + // Report to temporal baseline (fire-and-forget) + updateAndCheck([ + { type: 'satellite_fires', region: 'global', count: totalCount }, + ]).then(anomalies => { + if (anomalies.length > 0) { + signalAggregator.ingestTemporalAnomalies(anomalies); + } + }).catch(() => { }); + } else { + // Still update panel so it exits loading spinner + (this.panels['satellite-fires'] as SatelliteFiresPanel)?.update([], 0); + } + this.statusPanel?.updateApi('FIRMS', { status: 'ok' }); + } catch (e) { + console.warn('[App] FIRMS load failed:', e); + (this.panels['satellite-fires'] as SatelliteFiresPanel)?.update([], 0); + this.statusPanel?.updateApi('FIRMS', { status: 'error' }); + dataFreshness.recordError('firms', String(e)); } } + private scheduleRefresh( + name: string, + fn: () => Promise, + intervalMs: number, + condition?: () => boolean + ): void { + const HIDDEN_REFRESH_MULTIPLIER = 4; + const JITTER_FRACTION = 0.1; + const MIN_REFRESH_MS = 1000; + const computeDelay = (baseMs: number, isHidden: boolean) => { + const adjusted = baseMs * (isHidden ? HIDDEN_REFRESH_MULTIPLIER : 1); + const jitterRange = adjusted * JITTER_FRACTION; + const jittered = adjusted + (Math.random() * 2 - 1) * jitterRange; + return Math.max(MIN_REFRESH_MS, Math.round(jittered)); + }; + const scheduleNext = (delay: number) => { + if (this.isDestroyed) return; + const timeoutId = setTimeout(run, delay); + this.refreshTimeoutIds.set(name, timeoutId); + }; + const run = async () => { + if (this.isDestroyed) return; + const isHidden = document.visibilityState === 'hidden'; + if (isHidden) { + scheduleNext(computeDelay(intervalMs, true)); + return; + } + if (condition && !condition()) { + scheduleNext(computeDelay(intervalMs, false)); + return; + } + if (this.inFlight.has(name)) { + scheduleNext(computeDelay(intervalMs, false)); + return; + } + this.inFlight.add(name); + try { + await fn(); + } catch (e) { + console.error(`[App] Refresh ${name} failed:`, e); + } finally { + this.inFlight.delete(name); + scheduleNext(computeDelay(intervalMs, false)); + } + }; + scheduleNext(computeDelay(intervalMs, document.visibilityState === 'hidden')); + } + private setupRefreshIntervals(): void { - setInterval(() => this.loadNews(), REFRESH_INTERVALS.feeds); - setInterval(() => this.loadMarkets(), REFRESH_INTERVALS.markets); - setInterval(() => this.loadPredictions(), REFRESH_INTERVALS.predictions); - setInterval(() => this.loadEarthquakes(), 5 * 60 * 1000); - setInterval(() => this.loadWeatherAlerts(), 10 * 60 * 1000); - setInterval(() => this.loadFredData(), 30 * 60 * 1000); - setInterval(() => this.loadOutages(), 60 * 60 * 1000); // 1 hour - Cloudflare rate limit - setInterval(() => this.loadAisSignals(), REFRESH_INTERVALS.ais); - setInterval(() => this.loadCableActivity(), 30 * 60 * 1000); - setInterval(() => this.loadProtests(), 15 * 60 * 1000); // 15 min - GDELT updates frequently + // Always refresh news, markets, predictions, pizzint + this.scheduleRefresh('news', () => this.loadNews(), REFRESH_INTERVALS.feeds); + this.scheduleRefresh('markets', () => this.loadMarkets(), REFRESH_INTERVALS.markets); + this.scheduleRefresh('predictions', () => this.loadPredictions(), REFRESH_INTERVALS.predictions); + this.scheduleRefresh('pizzint', () => this.loadPizzInt(), 10 * 60 * 1000); + + // Only refresh layer data if layer is enabled + this.scheduleRefresh('natural', () => this.loadNatural(), 5 * 60 * 1000, () => this.mapLayers.natural); + this.scheduleRefresh('weather', () => this.loadWeatherAlerts(), 10 * 60 * 1000, () => this.mapLayers.weather); + this.scheduleRefresh('fred', () => this.loadFredData(), 30 * 60 * 1000); + this.scheduleRefresh('oil', () => this.loadOilAnalytics(), 30 * 60 * 1000); + this.scheduleRefresh('spending', () => this.loadGovernmentSpending(), 60 * 60 * 1000); + + // Refresh intelligence signals for CII (geopolitical variant only) + // This handles outages, protests, military - updates map when layers enabled + if (SITE_VARIANT === 'full') { + this.scheduleRefresh('intelligence', () => { + this.intelligenceCache = {}; // Clear cache to force fresh fetch + return this.loadIntelligenceSignals(); + }, 5 * 60 * 1000); + } + + // Non-intelligence layer refreshes only + // NOTE: outages, protests, military are refreshed by intelligence schedule above + this.scheduleRefresh('firms', () => this.loadFirmsData(), 30 * 60 * 1000); + this.scheduleRefresh('ais', () => this.loadAisSignals(), REFRESH_INTERVALS.ais, () => this.mapLayers.ais); + this.scheduleRefresh('cables', () => this.loadCableActivity(), 30 * 60 * 1000, () => this.mapLayers.cables); + this.scheduleRefresh('flights', () => this.loadFlightDelays(), 10 * 60 * 1000, () => this.mapLayers.flights); + this.scheduleRefresh('cyberThreats', () => { + this.cyberThreatsCache = null; + return this.loadCyberThreats(); + }, 10 * 60 * 1000, () => CYBER_LAYER_ENABLED && this.mapLayers.cyberThreats); } } diff --git a/src/bootstrap/chunk-reload.ts b/src/bootstrap/chunk-reload.ts new file mode 100644 index 000000000..3519877d9 --- /dev/null +++ b/src/bootstrap/chunk-reload.ts @@ -0,0 +1,43 @@ +interface EventTargetLike { + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; +} + +interface StorageLike { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; +} + +interface ChunkReloadGuardOptions { + eventTarget?: EventTargetLike; + storage?: StorageLike; + eventName?: string; + reload?: () => void; +} + +export function buildChunkReloadStorageKey(version: string): string { + return `wm-chunk-reload:${version}`; +} + +export function installChunkReloadGuard( + version: string, + options: ChunkReloadGuardOptions = {} +): string { + const storageKey = buildChunkReloadStorageKey(version); + const eventName = options.eventName ?? 'vite:preloadError'; + const eventTarget = options.eventTarget ?? window; + const storage = options.storage ?? sessionStorage; + const reload = options.reload ?? (() => window.location.reload()); + + eventTarget.addEventListener(eventName, () => { + if (storage.getItem(storageKey)) return; + storage.setItem(storageKey, '1'); + reload(); + }); + + return storageKey; +} + +export function clearChunkReloadGuard(storageKey: string, storage: StorageLike = sessionStorage): void { + storage.removeItem(storageKey); +} diff --git a/src/components/CIIPanel.ts b/src/components/CIIPanel.ts new file mode 100644 index 000000000..3eb663c9d --- /dev/null +++ b/src/components/CIIPanel.ts @@ -0,0 +1,130 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { getCSSColor } from '@/utils'; +import { calculateCII, type CountryScore } from '@/services/country-instability'; +import { t } from '../services/i18n'; + +export class CIIPanel extends Panel { + private scores: CountryScore[] = []; + private focalPointsReady = false; + private onShareStory?: (code: string, name: string) => void; + + constructor() { + super({ + id: 'cii', + title: t('panels.cii'), + infoTooltip: t('components.cii.infoTooltip'), + }); + this.showLoading(t('common.loading')); + } + + public setShareStoryHandler(handler: (code: string, name: string) => void): void { + this.onShareStory = handler; + } + + private getLevelColor(level: CountryScore['level']): string { + switch (level) { + case 'critical': return getCSSColor('--semantic-critical'); + case 'high': return getCSSColor('--semantic-high'); + case 'elevated': return getCSSColor('--semantic-elevated'); + case 'normal': return getCSSColor('--semantic-normal'); + case 'low': return getCSSColor('--semantic-low'); + } + } + + private getLevelEmoji(level: CountryScore['level']): string { + switch (level) { + case 'critical': return '🔴'; + case 'high': return '🟠'; + case 'elevated': return '🟡'; + case 'normal': return '🟢'; + case 'low': return '⚪'; + } + } + + private getTrendArrow(trend: CountryScore['trend'], change: number): string { + if (trend === 'rising') return `↑${change > 0 ? change : ''}`; + if (trend === 'falling') return `↓${Math.abs(change)}`; + return ''; + } + + private renderCountry(country: CountryScore): string { + const barWidth = country.score; + const color = this.getLevelColor(country.level); + const emoji = this.getLevelEmoji(country.level); + const trend = this.getTrendArrow(country.trend, country.change24h); + + return ` +
+
+ ${emoji} + ${escapeHtml(country.name)} + ${country.score} + ${trend} + +
+
+
+
+
+ U:${country.components.unrest} + C:${country.components.conflict} + S:${country.components.security} + I:${country.components.information} +
+
+ `; + } + + private bindShareButtons(): void { + if (!this.onShareStory) return; + this.content.querySelectorAll('.cii-share-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const el = e.currentTarget as HTMLElement; + const code = el.dataset.code || ''; + const name = el.dataset.name || ''; + if (code && name) this.onShareStory!(code, name); + }); + }); + } + + public async refresh(forceLocal = false): Promise { + if (!this.focalPointsReady && !forceLocal) { + return; + } + + if (forceLocal) { + this.focalPointsReady = true; + console.log('[CIIPanel] Focal points ready, calculating scores...'); + } + + this.showLoading(); + + try { + const localScores = calculateCII(); + const localWithData = localScores.filter(s => s.score > 0).length; + this.scores = localScores; + console.log(`[CIIPanel] Calculated ${localWithData} countries with focal point intelligence`); + + const withData = this.scores.filter(s => s.score > 0); + this.setCount(withData.length); + + if (withData.length === 0) { + this.content.innerHTML = '
No instability signals detected
'; + return; + } + + const html = withData.map(s => this.renderCountry(s)).join(''); + this.content.innerHTML = `
${html}
`; + this.bindShareButtons(); + } catch (error) { + console.error('[CIIPanel] Refresh error:', error); + this.showError(t('common.failedCII')); + } + } + + public getScores(): CountryScore[] { + return this.scores; + } +} diff --git a/src/components/CascadePanel.ts b/src/components/CascadePanel.ts new file mode 100644 index 000000000..46f21bd1e --- /dev/null +++ b/src/components/CascadePanel.ts @@ -0,0 +1,270 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { getCSSColor } from '@/utils'; +import { + buildDependencyGraph, + calculateCascade, + getGraphStats, + clearGraphCache, + type DependencyGraph, +} from '@/services/infrastructure-cascade'; +import type { CascadeResult, CascadeImpactLevel, InfrastructureNode } from '@/types'; + +type NodeFilter = 'all' | 'cable' | 'pipeline' | 'port' | 'chokepoint'; + +export class CascadePanel extends Panel { + private graph: DependencyGraph | null = null; + private selectedNode: string | null = null; + private cascadeResult: CascadeResult | null = null; + private filter: NodeFilter = 'cable'; + private onSelectCallback: ((nodeId: string | null) => void) | null = null; + + constructor() { + super({ + id: 'cascade', + title: t('panels.cascade'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.cascade.infoTooltip'), + }); + this.init(); + } + + private async init(): Promise { + this.showLoading(); + try { + this.graph = buildDependencyGraph(); + const stats = getGraphStats(); + this.setCount(stats.nodes); + this.render(); + } catch (error) { + console.error('[CascadePanel] Init error:', error); + this.showError(t('common.failedDependencyGraph')); + } + } + + private getImpactColor(level: CascadeImpactLevel): string { + switch (level) { + case 'critical': return getCSSColor('--semantic-critical'); + case 'high': return getCSSColor('--semantic-high'); + case 'medium': return getCSSColor('--semantic-elevated'); + case 'low': return getCSSColor('--semantic-normal'); + } + } + + private getImpactEmoji(level: CascadeImpactLevel): string { + switch (level) { + case 'critical': return '🔴'; + case 'high': return '🟠'; + case 'medium': return '🟡'; + case 'low': return '🟢'; + } + } + + private getNodeTypeEmoji(type: string): string { + switch (type) { + case 'cable': return '🔌'; + case 'pipeline': return '🛢️'; + case 'port': return '⚓'; + case 'chokepoint': return '🚢'; + case 'country': return '🏳️'; + default: return '📍'; + } + } + + private getFilterLabel(filter: Exclude): string { + const labels: Record, string> = { + cable: t('components.cascade.filters.cables'), + pipeline: t('components.cascade.filters.pipelines'), + port: t('components.cascade.filters.ports'), + chokepoint: t('components.cascade.filters.chokepoints'), + }; + return labels[filter]; + } + + private getFilteredNodes(): InfrastructureNode[] { + if (!this.graph) return []; + const nodes: InfrastructureNode[] = []; + for (const node of this.graph.nodes.values()) { + if (this.filter === 'all' || node.type === this.filter) { + if (node.type !== 'country') { + nodes.push(node); + } + } + } + return nodes.sort((a, b) => a.name.localeCompare(b.name)); + } + + private renderSelector(): string { + const nodes = this.getFilteredNodes(); + const filterButtons = ['cable', 'pipeline', 'port', 'chokepoint'].map((f) => + `` + ).join(''); + + const nodeOptions = nodes.map(n => + `` + ).join(''); + const selectedType = t(`components.cascade.filterType.${this.filter}`); + + return ` +
+
${filterButtons}
+ + +
+ `; + } + + private renderCascadeResult(): string { + if (!this.cascadeResult) return ''; + + const { source, countriesAffected, redundancies } = this.cascadeResult; + + const countriesHtml = countriesAffected.length > 0 + ? countriesAffected.map(c => ` +
+ ${this.getImpactEmoji(c.impactLevel)} + ${escapeHtml(c.countryName)} + ${t(`components.cascade.impactLevels.${c.impactLevel}`)} + ${c.affectedCapacity > 0 ? `${t('components.cascade.capacityPercent', { percent: String(Math.round(c.affectedCapacity * 100)) })}` : ''} +
+ `).join('') + : `
${t('components.cascade.noCountryImpacts')}
`; + + const redundanciesHtml = redundancies && redundancies.length > 0 + ? ` +
+
${t('components.cascade.alternativeRoutes')}
+ ${redundancies.map(r => ` +
+ ${escapeHtml(r.name)} + ${Math.round(r.capacityShare * 100)}% +
+ `).join('')} +
+ ` + : ''; + + return ` +
+
+ ${this.getNodeTypeEmoji(source.type)} + ${escapeHtml(source.name)} + ${t(`components.cascade.filterType.${source.type}`)} +
+
+
${t('components.cascade.countriesAffected', { count: String(countriesAffected.length) })}
+
${countriesHtml}
+
+ ${redundanciesHtml} +
+ `; + } + + private render(): void { + if (!this.graph) { + this.showLoading(); + return; + } + + const stats = getGraphStats(); + const statsHtml = ` +
+ 🔌 ${stats.cables} + 🛢️ ${stats.pipelines} + ⚓ ${stats.ports} + 🌊 ${stats.chokepoints} + 🏳️ ${stats.countries} + 📊 ${stats.edges} ${t('components.cascade.links')} +
+ `; + + this.content.innerHTML = ` +
+ ${statsHtml} + ${this.renderSelector()} + ${this.cascadeResult ? this.renderCascadeResult() : `
${t('components.cascade.selectInfrastructureHint')}
`} +
+ `; + + this.attachEventListeners(); + } + + private attachEventListeners(): void { + const filterBtns = this.content.querySelectorAll('.cascade-filter-btn'); + filterBtns.forEach(btn => { + btn.addEventListener('click', () => { + this.filter = btn.getAttribute('data-filter') as NodeFilter; + this.selectedNode = null; + this.cascadeResult = null; + this.render(); + }); + }); + + const select = this.content.querySelector('.cascade-select') as HTMLSelectElement; + if (select) { + select.addEventListener('change', () => { + this.selectedNode = select.value || null; + this.cascadeResult = null; + if (this.onSelectCallback) { + this.onSelectCallback(this.selectedNode); + } + this.render(); + }); + } + + const analyzeBtn = this.content.querySelector('.cascade-analyze-btn'); + if (analyzeBtn) { + analyzeBtn.addEventListener('click', () => this.runAnalysis()); + } + } + + private runAnalysis(): void { + if (!this.selectedNode) return; + + this.cascadeResult = calculateCascade(this.selectedNode); + this.render(); + + if (this.onSelectCallback) { + this.onSelectCallback(this.selectedNode); + } + } + + public selectNode(nodeId: string): void { + this.selectedNode = nodeId; + const nodeType = nodeId.split(':')[0] as NodeFilter; + if (['cable', 'pipeline', 'port', 'chokepoint'].includes(nodeType)) { + this.filter = nodeType; + } + this.runAnalysis(); + } + + public onSelect(callback: (nodeId: string | null) => void): void { + this.onSelectCallback = callback; + } + + public getSelectedNode(): string | null { + return this.selectedNode; + } + + public getCascadeResult(): CascadeResult | null { + return this.cascadeResult; + } + + public refresh(): void { + clearGraphCache(); + this.graph = null; + this.cascadeResult = null; + this.init(); + } +} diff --git a/src/components/ClimateAnomalyPanel.ts b/src/components/ClimateAnomalyPanel.ts new file mode 100644 index 000000000..68721e263 --- /dev/null +++ b/src/components/ClimateAnomalyPanel.ts @@ -0,0 +1,104 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import type { ClimateAnomaly } from '@/types'; +import { getSeverityIcon, formatDelta } from '@/services/climate'; +import { t } from '@/services/i18n'; + +export class ClimateAnomalyPanel extends Panel { + private anomalies: ClimateAnomaly[] = []; + private onZoneClick?: (lat: number, lon: number) => void; + + constructor() { + super({ + id: 'climate', + title: t('panels.climate'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.climate.infoTooltip'), + }); + this.showLoading(t('common.loadingClimateData')); + } + + public setZoneClickHandler(handler: (lat: number, lon: number) => void): void { + this.onZoneClick = handler; + } + + public setAnomalies(anomalies: ClimateAnomaly[]): void { + this.anomalies = anomalies; + this.setCount(anomalies.length); + this.renderContent(); + } + + private renderContent(): void { + if (this.anomalies.length === 0) { + this.setContent(`
${t('components.climate.noAnomalies')}
`); + return; + } + + const sorted = [...this.anomalies].sort((a, b) => { + const severityOrder = { extreme: 0, moderate: 1, normal: 2 }; + return (severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2); + }); + + const rows = sorted.map(a => { + const icon = getSeverityIcon(a); + const tempClass = a.tempDelta > 0 ? 'climate-warm' : 'climate-cold'; + const precipClass = a.precipDelta > 0 ? 'climate-wet' : 'climate-dry'; + const sevClass = `severity-${a.severity}`; + const rowClass = a.severity === 'extreme' ? ' climate-extreme-row' : ''; + + return ` + ${icon}${escapeHtml(a.zone)} + ${formatDelta(a.tempDelta, '°C')} + ${formatDelta(a.precipDelta, 'mm')} + ${t(`components.climate.severity.${a.severity}`)} + `; + }).join(''); + + this.setContent(` +
+ + + + + + + + + + ${rows} +
${t('components.climate.zone')}${t('components.climate.temp')}${t('components.climate.precip')}${t('components.climate.severityLabel')}
+
+ + `); + + this.content.querySelectorAll('.climate-row').forEach(el => { + el.addEventListener('click', () => { + const lat = Number((el as HTMLElement).dataset.lat); + const lon = Number((el as HTMLElement).dataset.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) this.onZoneClick?.(lat, lon); + }); + }); + } +} diff --git a/src/components/CommunityWidget.ts b/src/components/CommunityWidget.ts new file mode 100644 index 000000000..ff2fb3528 --- /dev/null +++ b/src/components/CommunityWidget.ts @@ -0,0 +1,35 @@ +import { t } from '@/services/i18n'; + +const DISMISSED_KEY = 'wm-community-dismissed'; +const DISCUSSION_URL = 'https://github.com/koala73/worldmonitor/discussions/94'; + +export function mountCommunityWidget(): void { + if (localStorage.getItem(DISMISSED_KEY) === 'true') return; + if (document.querySelector('.community-widget')) return; + + const widget = document.createElement('div'); + widget.className = 'community-widget'; + widget.innerHTML = ` +
+
+ ${t('components.community.joinDiscussion')} + ${t('components.community.openDiscussion')} + +
+ + `; + + const dismiss = () => { + widget.classList.add('cw-hiding'); + setTimeout(() => widget.remove(), 300); + }; + + widget.querySelector('.cw-close')!.addEventListener('click', dismiss); + + widget.querySelector('.cw-dismiss')!.addEventListener('click', () => { + localStorage.setItem(DISMISSED_KEY, 'true'); + dismiss(); + }); + + document.body.appendChild(widget); +} diff --git a/src/components/CountryBriefPage.ts b/src/components/CountryBriefPage.ts new file mode 100644 index 000000000..c76be8875 --- /dev/null +++ b/src/components/CountryBriefPage.ts @@ -0,0 +1,625 @@ +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { getCSSColor } from '@/utils'; +import type { CountryScore } from '@/services/country-instability'; +import type { PredictionMarket, NewsItem } from '@/types'; +import type { AssetType } from '@/types'; +import type { CountryBriefSignals } from '@/App'; +import type { StockIndexData } from '@/components/CountryIntelModal'; +import { getNearbyInfrastructure, haversineDistanceKm } from '@/services/related-assets'; +import { PORTS } from '@/config/ports'; +import type { Port } from '@/config/ports'; +import { exportCountryBriefJSON, exportCountryBriefCSV } from '@/utils/export'; +import type { CountryBriefExport } from '@/utils/export'; + +type BriefAssetType = AssetType | 'port'; + +interface CountryIntelData { + brief: string; + country: string; + code: string; + cached?: boolean; + generatedAt?: string; + error?: string; + skipped?: boolean; + reason?: string; + fallback?: boolean; +} + +export class CountryBriefPage { + private static BRIEF_BOUNDS: Record = { + IR: { n: 40, s: 25, e: 63, w: 44 }, IL: { n: 33.3, s: 29.5, e: 35.9, w: 34.3 }, + SA: { n: 32, s: 16, e: 55, w: 35 }, AE: { n: 26.1, s: 22.6, e: 56.4, w: 51.6 }, + IQ: { n: 37.4, s: 29.1, e: 48.6, w: 38.8 }, SY: { n: 37.3, s: 32.3, e: 42.4, w: 35.7 }, + YE: { n: 19, s: 12, e: 54.5, w: 42 }, LB: { n: 34.7, s: 33.1, e: 36.6, w: 35.1 }, + CN: { n: 53.6, s: 18.2, e: 134.8, w: 73.5 }, TW: { n: 25.3, s: 21.9, e: 122, w: 120 }, + JP: { n: 45.5, s: 24.2, e: 153.9, w: 122.9 }, KR: { n: 38.6, s: 33.1, e: 131.9, w: 124.6 }, + KP: { n: 43.0, s: 37.7, e: 130.7, w: 124.2 }, IN: { n: 35.5, s: 6.7, e: 97.4, w: 68.2 }, + PK: { n: 37, s: 24, e: 77, w: 61 }, AF: { n: 38.5, s: 29.4, e: 74.9, w: 60.5 }, + UA: { n: 52.4, s: 44.4, e: 40.2, w: 22.1 }, RU: { n: 82, s: 41.2, e: 180, w: 19.6 }, + BY: { n: 56.2, s: 51.3, e: 32.8, w: 23.2 }, PL: { n: 54.8, s: 49, e: 24.1, w: 14.1 }, + EG: { n: 31.7, s: 22, e: 36.9, w: 25 }, LY: { n: 33, s: 19.5, e: 25, w: 9.4 }, + SD: { n: 22, s: 8.7, e: 38.6, w: 21.8 }, US: { n: 49, s: 24.5, e: -66.9, w: -125 }, + GB: { n: 58.7, s: 49.9, e: 1.8, w: -8.2 }, DE: { n: 55.1, s: 47.3, e: 15.0, w: 5.9 }, + FR: { n: 51.1, s: 41.3, e: 9.6, w: -5.1 }, TR: { n: 42.1, s: 36, e: 44.8, w: 26 }, + }; + + private static INFRA_ICONS: Record = { + pipeline: '\u{1F50C}', + cable: '\u{1F310}', + datacenter: '\u{1F5A5}\uFE0F', + base: '\u{1F3DB}\uFE0F', + nuclear: '\u2622\uFE0F', + port: '\u2693', + }; + + private static INFRA_LABELS: Record = { + pipeline: 'pipeline', + cable: 'cable', + datacenter: 'datacenter', + base: 'base', + nuclear: 'nuclear', + port: 'port', + }; + + private overlay: HTMLElement; + private currentCode: string | null = null; + private currentName: string | null = null; + private currentHeadlineCount = 0; + private currentScore: CountryScore | null = null; + private currentSignals: CountryBriefSignals | null = null; + private currentBrief: string | null = null; + private currentHeadlines: NewsItem[] = []; + private onCloseCallback?: () => void; + private onShareStory?: (code: string, name: string) => void; + private onExportImage?: (code: string, name: string) => void; + private boundExportMenuClose: (() => void) | null = null; + private boundCitationClick: ((e: Event) => void) | null = null; + + constructor() { + this.overlay = document.createElement('div'); + this.overlay.className = 'country-brief-overlay'; + document.body.appendChild(this.overlay); + + this.overlay.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('country-brief-overlay')) this.hide(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.overlay.classList.contains('active')) this.hide(); + }); + } + + private countryFlag(code: string): string { + try { + return code + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)) + .join(''); + } catch { + return '🌍'; + } + } + + private levelColor(level: string): string { + const varMap: Record = { + critical: '--semantic-critical', + high: '--semantic-high', + elevated: '--semantic-elevated', + normal: '--semantic-normal', + low: '--semantic-low', + }; + return getCSSColor(varMap[level] || '--text-dim'); + } + + private levelBadge(level: string): string { + const color = this.levelColor(level); + return `${level.toUpperCase()}`; + } + + private trendIndicator(trend: string): string { + const arrow = trend === 'rising' ? '↗' : trend === 'falling' ? '↘' : '→'; + const cls = trend === 'rising' ? 'trend-up' : trend === 'falling' ? 'trend-down' : 'trend-stable'; + return `${arrow} ${trend}`; + } + + private scoreRing(score: number, level: string): string { + const color = this.levelColor(level); + const pct = Math.min(100, Math.max(0, score)); + const circumference = 2 * Math.PI * 42; + const dashOffset = circumference * (1 - pct / 100); + return ` +
+ + + + +
${score}
+
/ 100
+
`; + } + + private componentBars(components: CountryScore['components']): string { + const items = [ + { label: t('modals.countryBrief.components.unrest'), value: components.unrest, icon: '📢' }, + { label: t('modals.countryBrief.components.conflict'), value: components.conflict, icon: '⚔' }, + { label: t('modals.countryBrief.components.security'), value: components.security, icon: '🛡️' }, + { label: t('modals.countryBrief.components.information'), value: components.information, icon: '📡' }, + ]; + return items.map(({ label, value, icon }) => { + const pct = Math.min(100, Math.max(0, value)); + const color = pct >= 70 ? getCSSColor('--semantic-critical') : pct >= 50 ? getCSSColor('--semantic-high') : pct >= 30 ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-normal'); + return ` +
+ ${icon} + ${label} +
+ ${Math.round(value)} +
`; + }).join(''); + } + + private signalChips(signals: CountryBriefSignals): string { + const chips: string[] = []; + if (signals.protests > 0) chips.push(`📢 ${signals.protests} ${t('modals.countryBrief.signals.protests')}`); + if (signals.militaryFlights > 0) chips.push(`✈️ ${signals.militaryFlights} ${t('modals.countryBrief.signals.militaryAir')}`); + if (signals.militaryVessels > 0) chips.push(`⚓ ${signals.militaryVessels} ${t('modals.countryBrief.signals.militarySea')}`); + if (signals.outages > 0) chips.push(`🌐 ${signals.outages} ${t('modals.countryBrief.signals.outages')}`); + if (signals.earthquakes > 0) chips.push(`🌍 ${signals.earthquakes} ${t('modals.countryBrief.signals.earthquakes')}`); + if (signals.displacementOutflow > 0) { + const fmt = signals.displacementOutflow >= 1_000_000 + ? `${(signals.displacementOutflow / 1_000_000).toFixed(1)}M` + : `${(signals.displacementOutflow / 1000).toFixed(0)}K`; + chips.push(`🌊 ${fmt} ${t('modals.countryBrief.signals.displaced')}`); + } + if (signals.climateStress > 0) chips.push(`🌡️ ${t('modals.countryBrief.signals.climate')}`); + if (signals.conflictEvents > 0) chips.push(`⚔️ ${signals.conflictEvents} ${t('modals.countryBrief.signals.conflictEvents')}`); + chips.push(`📈 ${t('modals.countryBrief.loadingIndex')}`); + return chips.join(''); + } + + public setShareStoryHandler(handler: (code: string, name: string) => void): void { + this.onShareStory = handler; + } + + public setExportImageHandler(handler: (code: string, name: string) => void): void { + this.onExportImage = handler; + } + + public showLoading(): void { + this.currentCode = '__loading__'; + this.overlay.innerHTML = ` +
+
+
+ 🌍 + ${t('modals.countryBrief.identifying')} +
+
+ +
+
+
+
+
+
+ ${t('modals.countryBrief.locating')} +
+
+
`; + this.overlay.querySelector('.cb-close')?.addEventListener('click', () => this.hide()); + this.overlay.classList.add('active'); + } + + public show(country: string, code: string, score: CountryScore | null, signals: CountryBriefSignals): void { + this.currentCode = code; + this.currentName = country; + this.currentScore = score; + this.currentSignals = signals; + this.currentBrief = null; + this.currentHeadlines = []; + this.currentHeadlineCount = 0; + const flag = this.countryFlag(code); + + const tierBadge = !signals.isTier1 + ? `${t('modals.countryBrief.limitedCoverage')}` + : ''; + + this.overlay.innerHTML = ` +
+
+
+ ${flag} + ${escapeHtml(country)} + ${score ? this.levelBadge(score.level) : ''} + ${score ? this.trendIndicator(score.trend) : ''} + ${tierBadge} +
+
+ + +
+ + +
+ +
+
+
+
+
+ ${score ? ` +
+

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

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

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

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

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

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

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

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

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

+
+
+ +
+

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

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

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

') + .replace(/$/, '

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

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

') + .replace(/$/, '

'); + } + + public hide(): void { + this.overlay.classList.remove('active'); + this.currentCode = null; + this.onCloseCallback?.(); + } + + public onClose(cb: () => void): void { + this.onCloseCallback = cb; + } + + public isVisible(): boolean { + return this.overlay.classList.contains('active'); + } +} diff --git a/src/components/CountryTimeline.ts b/src/components/CountryTimeline.ts new file mode 100644 index 000000000..e4d6a3ca7 --- /dev/null +++ b/src/components/CountryTimeline.ts @@ -0,0 +1,285 @@ +import * as d3 from 'd3'; +import { escapeHtml } from '@/utils/sanitize'; +import { getCSSColor } from '@/utils'; +import { t } from '@/services/i18n'; + +export interface TimelineEvent { + timestamp: number; + lane: 'protest' | 'conflict' | 'natural' | 'military'; + label: string; + severity?: 'low' | 'medium' | 'high' | 'critical'; +} + +const LANES: TimelineEvent['lane'][] = ['protest', 'conflict', 'natural', 'military']; + +const LANE_COLORS: Record = { + protest: '#ffaa00', + conflict: '#ff4444', + natural: '#b478ff', + military: '#64b4ff', +}; + +const SEVERITY_RADIUS: Record = { + low: 4, + medium: 5, + high: 7, + critical: 9, +}; + +const MARGIN = { top: 20, right: 20, bottom: 30, left: 80 }; +const HEIGHT = 200; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +export class CountryTimeline { + private container: HTMLElement; + private svg: d3.Selection | null = null; + private tooltip: HTMLDivElement | null = null; + private resizeObserver: ResizeObserver | null = null; + private currentEvents: TimelineEvent[] = []; + + constructor(container: HTMLElement) { + this.container = container; + this.createTooltip(); + this.resizeObserver = new ResizeObserver(() => { + if (this.currentEvents.length > 0) this.render(this.currentEvents); + }); + this.resizeObserver.observe(this.container); + + window.addEventListener('theme-changed', () => { + // Re-create tooltip with new theme colors + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + this.createTooltip(); + // Re-render chart with new colors + if (this.currentEvents.length > 0) this.render(this.currentEvents); + }); + } + + private createTooltip(): void { + this.tooltip = document.createElement('div'); + Object.assign(this.tooltip.style, { + position: 'absolute', + pointerEvents: 'none', + background: getCSSColor('--bg'), + border: `1px solid ${getCSSColor('--border')}`, + borderRadius: '6px', + padding: '6px 10px', + fontSize: '12px', + color: getCSSColor('--text'), + zIndex: '9999', + display: 'none', + whiteSpace: 'nowrap', + boxShadow: `0 2px 8px ${getCSSColor('--shadow-color')}`, + }); + this.container.style.position = 'relative'; + this.container.appendChild(this.tooltip); + } + + render(events: TimelineEvent[]): void { + this.currentEvents = events; + if (this.svg) this.svg.remove(); + + const width = this.container.clientWidth; + if (width <= 0) return; + + const innerW = width - MARGIN.left - MARGIN.right; + const innerH = HEIGHT - MARGIN.top - MARGIN.bottom; + + this.svg = d3 + .select(this.container) + .append('svg') + .attr('width', width) + .attr('height', HEIGHT) + .attr('style', 'display:block;'); + + const g = this.svg + .append('g') + .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); + + const now = Date.now(); + const xScale = d3 + .scaleTime() + .domain([new Date(now - SEVEN_DAYS_MS), new Date(now)]) + .range([0, innerW]); + + const yScale = d3 + .scaleBand() + .domain(LANES) + .range([0, innerH]) + .padding(0.2); + + this.drawGrid(g, xScale, innerH); + this.drawAxes(g, xScale, yScale, innerH); + this.drawNowMarker(g, xScale, new Date(now), innerH); + this.drawEmptyLaneLabels(g, events, yScale, innerW); + this.drawEvents(g, events, xScale, yScale); + } + + private drawGrid( + g: d3.Selection, + xScale: d3.ScaleTime, + innerH: number, + ): void { + const ticks = xScale.ticks(6); + g.selectAll('.grid-line') + .data(ticks) + .join('line') + .attr('x1', (d) => xScale(d)) + .attr('x2', (d) => xScale(d)) + .attr('y1', 0) + .attr('y2', innerH) + .attr('stroke', getCSSColor('--border-subtle')) + .attr('stroke-width', 1); + } + + private drawAxes( + g: d3.Selection, + xScale: d3.ScaleTime, + yScale: d3.ScaleBand, + innerH: number, + ): void { + const xAxis = d3 + .axisBottom(xScale) + .ticks(6) + .tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue, i: number) => string); + + const xAxisG = g + .append('g') + .attr('transform', `translate(0,${innerH})`) + .call(xAxis); + + xAxisG.selectAll('text').attr('fill', getCSSColor('--text-dim')).attr('font-size', '10px'); + xAxisG.selectAll('line').attr('stroke', getCSSColor('--border')); + xAxisG.select('.domain').attr('stroke', getCSSColor('--border')); + + const laneLabels: Record = { + protest: 'Protest', + conflict: 'Conflict', + natural: 'Natural', + military: 'Military', + }; + + g.selectAll('.lane-label') + .data(LANES) + .join('text') + .attr('x', -10) + .attr('y', (d) => (yScale(d) ?? 0) + yScale.bandwidth() / 2) + .attr('text-anchor', 'end') + .attr('dominant-baseline', 'central') + .attr('fill', (d: TimelineEvent['lane']) => LANE_COLORS[d]) + .attr('font-size', '11px') + .attr('font-weight', '500') + .text((d: TimelineEvent['lane']) => laneLabels[d] || d); + } + + private drawNowMarker( + g: d3.Selection, + xScale: d3.ScaleTime, + now: Date, + innerH: number, + ): void { + const x = xScale(now); + g.append('line') + .attr('x1', x) + .attr('x2', x) + .attr('y1', 0) + .attr('y2', innerH) + .attr('stroke', getCSSColor('--text')) + .attr('stroke-width', 1) + .attr('stroke-dasharray', '4,3') + .attr('opacity', 0.6); + + g.append('text') + .attr('x', x) + .attr('y', -6) + .attr('text-anchor', 'middle') + .attr('fill', getCSSColor('--text-muted')) + .attr('font-size', '9px') + .text(t('components.countryTimeline.now')); + } + + private drawEmptyLaneLabels( + g: d3.Selection, + events: TimelineEvent[], + yScale: d3.ScaleBand, + innerW: number, + ): void { + const populatedLanes = new Set(events.map((e) => e.lane)); + const emptyLanes = LANES.filter((l) => !populatedLanes.has(l)); + + g.selectAll('.empty-label') + .data(emptyLanes) + .join('text') + .attr('x', innerW / 2) + .attr('y', (d) => (yScale(d) ?? 0) + yScale.bandwidth() / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('fill', getCSSColor('--text-ghost')) + .attr('font-size', '10px') + .attr('font-style', 'italic') + .text(t('components.countryTimeline.noEventsIn7Days')); + } + + private drawEvents( + g: d3.Selection, + events: TimelineEvent[], + xScale: d3.ScaleTime, + yScale: d3.ScaleBand, + ): void { + const tooltip = this.tooltip!; + const container = this.container; + const fmt = d3.timeFormat('%b %d, %H:%M'); + + g.selectAll('.event-circle') + .data(events) + .join('circle') + .attr('cx', (d) => xScale(new Date(d.timestamp))) + .attr('cy', (d) => (yScale(d.lane) ?? 0) + yScale.bandwidth() / 2) + .attr('r', (d) => SEVERITY_RADIUS[d.severity ?? 'medium'] ?? 5) + .attr('fill', (d) => LANE_COLORS[d.lane]) + .attr('opacity', 0.85) + .attr('cursor', 'pointer') + .attr('stroke', getCSSColor('--shadow-color')) + .attr('stroke-width', 0.5) + .on('mouseenter', function (event: MouseEvent, d: TimelineEvent) { + d3.select(this).attr('opacity', 1).attr('stroke', getCSSColor('--text')).attr('stroke-width', 1.5); + const dateStr = fmt(new Date(d.timestamp)); + tooltip.innerHTML = `${escapeHtml(d.label)}
${escapeHtml(dateStr)}`; + tooltip.style.display = 'block'; + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left + 12; + const y = event.clientY - rect.top - 10; + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + }) + .on('mousemove', function (event: MouseEvent) { + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left + 12; + const y = event.clientY - rect.top - 10; + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + }) + .on('mouseleave', function () { + d3.select(this).attr('opacity', 0.85).attr('stroke', getCSSColor('--shadow-color')).attr('stroke-width', 0.5); + tooltip.style.display = 'none'; + }); + } + + destroy(): void { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.svg) { + this.svg.remove(); + this.svg = null; + } + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + this.currentEvents = []; + } +} diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts new file mode 100644 index 000000000..f6d2bdeac --- /dev/null +++ b/src/components/DeckGLMap.ts @@ -0,0 +1,3852 @@ +/** + * DeckGLMap - WebGL-accelerated map visualization for desktop + * Uses deck.gl for high-performance rendering of large datasets + * Mobile devices gracefully degrade to the D3/SVG-based Map component + */ +import { MapboxOverlay } from '@deck.gl/mapbox'; +import type { Layer, LayersList, PickingInfo } from '@deck.gl/core'; +import { GeoJsonLayer, ScatterplotLayer, PathLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import maplibregl from 'maplibre-gl'; +import Supercluster from 'supercluster'; +import type { + MapLayers, + Hotspot, + NewsItem, + Earthquake, + InternetOutage, + RelatedAsset, + AssetType, + AisDisruptionEvent, + AisDensityZone, + CableAdvisory, + RepairShip, + SocialUnrestEvent, + AIDataCenter, + AirportDelayAlert, + MilitaryFlight, + MilitaryVessel, + MilitaryFlightCluster, + MilitaryVesselCluster, + NaturalEvent, + UcdpGeoEvent, + DisplacementFlow, + ClimateAnomaly, + MapProtestCluster, + MapTechHQCluster, + MapTechEventCluster, + MapDatacenterCluster, + CyberThreat, +} from '@/types'; +import { ArcLayer } from '@deck.gl/layers'; +import { HeatmapLayer } from '@deck.gl/aggregation-layers'; +import type { WeatherAlert } from '@/services/weather'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { debounce, rafSchedule, getCurrentTheme } from '@/utils/index'; +import { + INTEL_HOTSPOTS, + CONFLICT_ZONES, + MILITARY_BASES, + UNDERSEA_CABLES, + NUCLEAR_FACILITIES, + GAMMA_IRRADIATORS, + PIPELINES, + PIPELINE_COLORS, + STRATEGIC_WATERWAYS, + ECONOMIC_CENTERS, + AI_DATA_CENTERS, + SITE_VARIANT, + STARTUP_HUBS, + ACCELERATORS, + TECH_HQS, + CLOUD_REGIONS, + PORTS, + SPACEPORTS, + APT_GROUPS, + CRITICAL_MINERALS, + STOCK_EXCHANGES, + FINANCIAL_CENTERS, + CENTRAL_BANKS, + COMMODITY_HUBS, + GULF_INVESTMENTS, +} from '@/config'; +import type { GulfInvestment } from '@/types'; +import { MapPopup, type PopupType } from './MapPopup'; +import { + updateHotspotEscalation, + getHotspotEscalation, + setMilitaryData, + setCIIGetter, + setGeoAlertGetter, +} from '@/services/hotspot-escalation'; +import { getCountryScore } from '@/services/country-instability'; +import { getAlertsNearLocation } from '@/services/geo-convergence'; +import { getCountriesGeoJson, getCountryAtCoordinates } from '@/services/country-geometry'; + +export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; +export type DeckMapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; +type MapInteractionMode = 'flat' | '3d'; + +export interface CountryClickPayload { + lat: number; + lon: number; + code?: string; + name?: string; +} + +interface DeckMapState { + zoom: number; + pan: { x: number; y: number }; + view: DeckMapView; + layers: MapLayers; + timeRange: TimeRange; +} + +interface HotspotWithBreaking extends Hotspot { + hasBreaking?: boolean; +} + +interface TechEventMarker { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; +} + +// View presets with longitude, latitude, zoom +const VIEW_PRESETS: Record = { + global: { longitude: 0, latitude: 20, zoom: 1.5 }, + america: { longitude: -95, latitude: 38, zoom: 3 }, + mena: { longitude: 45, latitude: 28, zoom: 3.5 }, + eu: { longitude: 15, latitude: 50, zoom: 3.5 }, + asia: { longitude: 105, latitude: 35, zoom: 3 }, + latam: { longitude: -60, latitude: -15, zoom: 3 }, + africa: { longitude: 20, latitude: 5, zoom: 3 }, + oceania: { longitude: 135, latitude: -25, zoom: 3.5 }, +}; + +const MAP_INTERACTION_MODE: MapInteractionMode = + import.meta.env.VITE_MAP_INTERACTION_MODE === 'flat' ? 'flat' : '3d'; + +// Theme-aware basemap vector style URLs (English labels, no local scripts) +const DARK_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; +const LIGHT_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'; + +// Zoom thresholds for layer visibility and labels (matches old Map.ts) +// Zoom-dependent layer visibility and labels +const LAYER_ZOOM_THRESHOLDS: Partial> = { + bases: { minZoom: 3, showLabels: 5 }, + nuclear: { minZoom: 3 }, + conflicts: { minZoom: 1, showLabels: 3 }, + economic: { minZoom: 3 }, + natural: { minZoom: 1, showLabels: 2 }, + datacenters: { minZoom: 5 }, + irradiators: { minZoom: 4 }, + spaceports: { minZoom: 3 }, + gulfInvestments: { minZoom: 2, showLabels: 5 }, +}; +// Export for external use +export { LAYER_ZOOM_THRESHOLDS }; + +// Theme-aware overlay color function — refreshed each buildLayers() call +function getOverlayColors() { + const isLight = getCurrentTheme() === 'light'; + return { + // Threat dots: IDENTICAL in both modes (user locked decision) + hotspotHigh: [255, 68, 68, 200] as [number, number, number, number], + hotspotElevated: [255, 165, 0, 200] as [number, number, number, number], + hotspotLow: [255, 255, 0, 180] as [number, number, number, number], + + // Conflict zone fills: more transparent in light mode + conflict: isLight + ? [255, 0, 0, 60] as [number, number, number, number] + : [255, 0, 0, 100] as [number, number, number, number], + + // Infrastructure/category markers: darker variants in light mode for map readability + base: [0, 150, 255, 200] as [number, number, number, number], + nuclear: isLight + ? [180, 120, 0, 220] as [number, number, number, number] + : [255, 215, 0, 200] as [number, number, number, number], + datacenter: isLight + ? [13, 148, 136, 200] as [number, number, number, number] + : [0, 255, 200, 180] as [number, number, number, number], + cable: [0, 200, 255, 150] as [number, number, number, number], + cableHighlight: [255, 100, 100, 200] as [number, number, number, number], + earthquake: [255, 100, 50, 200] as [number, number, number, number], + vesselMilitary: [255, 100, 100, 220] as [number, number, number, number], + flightMilitary: [255, 50, 50, 220] as [number, number, number, number], + protest: [255, 150, 0, 200] as [number, number, number, number], + outage: [255, 50, 50, 180] as [number, number, number, number], + weather: [100, 150, 255, 180] as [number, number, number, number], + startupHub: isLight + ? [22, 163, 74, 220] as [number, number, number, number] + : [0, 255, 150, 200] as [number, number, number, number], + techHQ: [100, 200, 255, 200] as [number, number, number, number], + accelerator: isLight + ? [180, 120, 0, 220] as [number, number, number, number] + : [255, 200, 0, 200] as [number, number, number, number], + cloudRegion: [150, 100, 255, 180] as [number, number, number, number], + stockExchange: isLight + ? [20, 120, 200, 220] as [number, number, number, number] + : [80, 200, 255, 210] as [number, number, number, number], + financialCenter: isLight + ? [0, 150, 110, 215] as [number, number, number, number] + : [0, 220, 150, 200] as [number, number, number, number], + centralBank: isLight + ? [180, 120, 0, 220] as [number, number, number, number] + : [255, 210, 80, 210] as [number, number, number, number], + commodityHub: isLight + ? [190, 95, 40, 220] as [number, number, number, number] + : [255, 150, 80, 200] as [number, number, number, number], + gulfInvestmentSA: [0, 168, 107, 220] as [number, number, number, number], + gulfInvestmentUAE: [255, 0, 100, 220] as [number, number, number, number], + ucdpStateBased: [255, 50, 50, 200] as [number, number, number, number], + ucdpNonState: [255, 165, 0, 200] as [number, number, number, number], + ucdpOneSided: [255, 255, 0, 200] as [number, number, number, number], + }; +} +// Initialize and refresh on every buildLayers() call +let COLORS = getOverlayColors(); + +// SVG icons as data URLs for different marker shapes +const MARKER_ICONS = { + // Square - for datacenters + square: 'data:image/svg+xml;base64,' + btoa(``), + // Diamond - for hotspots + diamond: 'data:image/svg+xml;base64,' + btoa(``), + // Triangle up - for military bases + triangleUp: 'data:image/svg+xml;base64,' + btoa(``), + // Hexagon - for nuclear + hexagon: 'data:image/svg+xml;base64,' + btoa(``), + // Circle - fallback + circle: 'data:image/svg+xml;base64,' + btoa(``), + // Star - for special markers + star: 'data:image/svg+xml;base64,' + btoa(``), +}; + +export class DeckGLMap { + private static readonly MAX_CLUSTER_LEAVES = 200; + + private container: HTMLElement; + private deckOverlay: MapboxOverlay | null = null; + private maplibreMap: maplibregl.Map | null = null; + private state: DeckMapState; + private popup: MapPopup; + + // Data stores + private hotspots: HotspotWithBreaking[]; + private earthquakes: Earthquake[] = []; + private weatherAlerts: WeatherAlert[] = []; + private outages: InternetOutage[] = []; + private cyberThreats: CyberThreat[] = []; + private aisDisruptions: AisDisruptionEvent[] = []; + private aisDensity: AisDensityZone[] = []; + private cableAdvisories: CableAdvisory[] = []; + private repairShips: RepairShip[] = []; + private protests: SocialUnrestEvent[] = []; + private militaryFlights: MilitaryFlight[] = []; + private militaryFlightClusters: MilitaryFlightCluster[] = []; + private militaryVessels: MilitaryVessel[] = []; + private militaryVesselClusters: MilitaryVesselCluster[] = []; + private naturalEvents: NaturalEvent[] = []; + private firmsFireData: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }> = []; + private techEvents: TechEventMarker[] = []; + private flightDelays: AirportDelayAlert[] = []; + private news: NewsItem[] = []; + private newsLocations: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }> = []; + private newsLocationFirstSeen = new Map(); + private ucdpEvents: UcdpGeoEvent[] = []; + private displacementFlows: DisplacementFlow[] = []; + private climateAnomalies: ClimateAnomaly[] = []; + + // Country highlight state + private countryGeoJsonLoaded = false; + private countryHoverSetup = false; + private highlightedCountryCode: string | null = null; + + // Callbacks + private onHotspotClick?: (hotspot: Hotspot) => void; + private onTimeRangeChange?: (range: TimeRange) => void; + private onCountryClick?: (country: CountryClickPayload) => void; + private onLayerChange?: (layer: keyof MapLayers, enabled: boolean) => void; + private onStateChange?: (state: DeckMapState) => void; + + // Highlighted assets + private highlightedAssets: Record> = { + pipeline: new Set(), + cable: new Set(), + datacenter: new Set(), + base: new Set(), + nuclear: new Set(), + }; + + private renderScheduled = false; + private renderPaused = false; + private renderPending = false; + private webglLost = false; + private resizeObserver: ResizeObserver | null = null; + + private layerCache: Map = new Map(); + private lastZoomThreshold = 0; + private protestSC: Supercluster | null = null; + private techHQSC: Supercluster | null = null; + private techEventSC: Supercluster | null = null; + private datacenterSC: Supercluster | null = null; + private protestClusters: MapProtestCluster[] = []; + private techHQClusters: MapTechHQCluster[] = []; + private techEventClusters: MapTechEventCluster[] = []; + private datacenterClusters: MapDatacenterCluster[] = []; + private lastSCZoom = -1; + private lastSCBoundsKey = ''; + private lastSCMask = ''; + private protestSuperclusterSource: SocialUnrestEvent[] = []; + private newsPulseIntervalId: ReturnType | null = null; + private readonly startupTime = Date.now(); + private lastCableHighlightSignature = ''; + private lastPipelineHighlightSignature = ''; + private debouncedRebuildLayers: () => void; + private rafUpdateLayers: () => void; + private moveTimeoutId: ReturnType | null = null; + + constructor(container: HTMLElement, initialState: DeckMapState) { + this.container = container; + this.state = initialState; + this.hotspots = [...INTEL_HOTSPOTS]; + + this.rebuildTechHQSupercluster(); + this.rebuildDatacenterSupercluster(); + + this.debouncedRebuildLayers = debounce(() => { + if (this.renderPaused || this.webglLost) return; + this.maplibreMap?.resize(); + this.deckOverlay?.setProps({ layers: this.buildLayers() }); + }, 150); + this.rafUpdateLayers = rafSchedule(() => { + if (this.renderPaused || this.webglLost) return; + this.deckOverlay?.setProps({ layers: this.buildLayers() }); + }); + + this.setupDOM(); + this.popup = new MapPopup(container); + + window.addEventListener('theme-changed', (e: Event) => { + const theme = (e as CustomEvent).detail?.theme as 'dark' | 'light'; + if (theme) { + this.switchBasemap(theme); + this.render(); // Rebuilds Deck.GL layers with new theme-aware colors + } + }); + + this.initMapLibre(); + + this.maplibreMap?.on('load', () => { + this.initDeck(); + this.loadCountryBoundaries(); + this.render(); + }); + + this.setupResizeObserver(); + + this.createControls(); + this.createTimeSlider(); + this.createLayerToggles(); + this.createLegend(); + } + + private setupDOM(): void { + const wrapper = document.createElement('div'); + wrapper.className = 'deckgl-map-wrapper'; + wrapper.id = 'deckglMapWrapper'; + wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;'; + + // MapLibre container - deck.gl renders directly into MapLibre via MapboxOverlay + const mapContainer = document.createElement('div'); + mapContainer.id = 'deckgl-basemap'; + mapContainer.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;'; + wrapper.appendChild(mapContainer); + + this.container.appendChild(wrapper); + } + + private initMapLibre(): void { + const preset = VIEW_PRESETS[this.state.view]; + const initialTheme = getCurrentTheme(); + + this.maplibreMap = new maplibregl.Map({ + container: 'deckgl-basemap', + style: initialTheme === 'light' ? LIGHT_STYLE : DARK_STYLE, + center: [preset.longitude, preset.latitude], + zoom: preset.zoom, + renderWorldCopies: false, + attributionControl: false, + interactive: true, + ...(MAP_INTERACTION_MODE === 'flat' + ? { + maxPitch: 0, + pitchWithRotate: false, + dragRotate: false, + touchPitch: false, + } + : {}), + }); + + const canvas = this.maplibreMap.getCanvas(); + canvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault(); + this.webglLost = true; + console.warn('[DeckGLMap] WebGL context lost — will restore when browser recovers'); + }); + canvas.addEventListener('webglcontextrestored', () => { + this.webglLost = false; + console.info('[DeckGLMap] WebGL context restored'); + this.maplibreMap?.triggerRepaint(); + }); + } + + private initDeck(): void { + if (!this.maplibreMap) return; + + this.deckOverlay = new MapboxOverlay({ + interleaved: true, + layers: this.buildLayers(), + getTooltip: (info: PickingInfo) => this.getTooltip(info), + onClick: (info: PickingInfo) => this.handleClick(info), + pickingRadius: 10, + useDevicePixels: window.devicePixelRatio > 2 ? 2 : true, + onError: (error: Error) => console.warn('[DeckGLMap] Render error (non-fatal):', error.message), + }); + + this.maplibreMap.addControl(this.deckOverlay as unknown as maplibregl.IControl); + + this.maplibreMap.on('movestart', () => { + if (this.moveTimeoutId) { + clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = null; + } + }); + + this.maplibreMap.on('moveend', () => { + this.lastSCZoom = -1; + this.rafUpdateLayers(); + }); + + this.maplibreMap.on('move', () => { + if (this.moveTimeoutId) clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = setTimeout(() => { + this.lastSCZoom = -1; + this.rafUpdateLayers(); + }, 100); + }); + + this.maplibreMap.on('zoom', () => { + if (this.moveTimeoutId) clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = setTimeout(() => { + this.lastSCZoom = -1; + this.rafUpdateLayers(); + }, 100); + }); + + this.maplibreMap.on('zoomend', () => { + const currentZoom = Math.floor(this.maplibreMap?.getZoom() || 2); + const thresholdCrossed = Math.abs(currentZoom - this.lastZoomThreshold) >= 1; + if (thresholdCrossed) { + this.lastZoomThreshold = currentZoom; + this.debouncedRebuildLayers(); + } + }); + } + + private setupResizeObserver(): void { + this.resizeObserver = new ResizeObserver(() => { + if (this.maplibreMap) { + this.maplibreMap.resize(); + } + }); + this.resizeObserver.observe(this.container); + } + + + private getSetSignature(set: Set): string { + return [...set].sort().join('|'); + } + + private hasRecentNews(now = Date.now()): boolean { + for (const ts of this.newsLocationFirstSeen.values()) { + if (now - ts < 30_000) return true; + } + return false; + } + + private getTimeRangeMs(range: TimeRange = this.state.timeRange): number { + const ranges: Record = { + '1h': 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '48h': 48 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + 'all': Infinity, + }; + return ranges[range]; + } + + private parseTime(value: Date | string | number | undefined | null): number | null { + if (value == null) return null; + const ts = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(ts) ? ts : null; + } + + private filterByTime( + items: T[], + getTime: (item: T) => Date | string | number | undefined | null + ): T[] { + if (this.state.timeRange === 'all') return items; + const cutoff = Date.now() - this.getTimeRangeMs(); + return items.filter((item) => { + const ts = this.parseTime(getTime(item)); + return ts == null ? true : ts >= cutoff; + }); + } + + private getFilteredProtests(): SocialUnrestEvent[] { + return this.filterByTime(this.protests, (event) => event.time); + } + + private filterMilitaryFlightClustersByTime(clusters: MilitaryFlightCluster[]): MilitaryFlightCluster[] { + return clusters + .map((cluster) => { + const flights = this.filterByTime(cluster.flights ?? [], (flight) => flight.lastSeen); + if (flights.length === 0) return null; + return { + ...cluster, + flights, + flightCount: flights.length, + }; + }) + .filter((cluster): cluster is MilitaryFlightCluster => cluster !== null); + } + + private filterMilitaryVesselClustersByTime(clusters: MilitaryVesselCluster[]): MilitaryVesselCluster[] { + return clusters + .map((cluster) => { + const vessels = this.filterByTime(cluster.vessels ?? [], (vessel) => vessel.lastAisUpdate); + if (vessels.length === 0) return null; + return { + ...cluster, + vessels, + vesselCount: vessels.length, + }; + }) + .filter((cluster): cluster is MilitaryVesselCluster => cluster !== null); + } + + private rebuildProtestSupercluster(source: SocialUnrestEvent[] = this.getFilteredProtests()): void { + this.protestSuperclusterSource = source; + const points = source.map((p, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] as [number, number] }, + properties: { + index: i, + country: p.country, + severity: p.severity, + eventType: p.eventType, + validated: Boolean(p.validated), + fatalities: Number.isFinite(p.fatalities) ? Number(p.fatalities) : 0, + }, + })); + this.protestSC = new Supercluster({ + radius: 60, + maxZoom: 14, + map: (props: Record) => ({ + index: Number(props.index ?? 0), + country: String(props.country ?? ''), + maxSeverityRank: props.severity === 'high' ? 2 : props.severity === 'medium' ? 1 : 0, + riotCount: props.eventType === 'riot' ? 1 : 0, + highSeverityCount: props.severity === 'high' ? 1 : 0, + verifiedCount: props.validated ? 1 : 0, + totalFatalities: Number(props.fatalities ?? 0) || 0, + }), + reduce: (acc: Record, props: Record) => { + acc.maxSeverityRank = Math.max(Number(acc.maxSeverityRank ?? 0), Number(props.maxSeverityRank ?? 0)); + acc.riotCount = Number(acc.riotCount ?? 0) + Number(props.riotCount ?? 0); + acc.highSeverityCount = Number(acc.highSeverityCount ?? 0) + Number(props.highSeverityCount ?? 0); + acc.verifiedCount = Number(acc.verifiedCount ?? 0) + Number(props.verifiedCount ?? 0); + acc.totalFatalities = Number(acc.totalFatalities ?? 0) + Number(props.totalFatalities ?? 0); + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.protestSC.load(points); + this.lastSCZoom = -1; + } + + private rebuildTechHQSupercluster(): void { + const points = TECH_HQS.map((h, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [h.lon, h.lat] as [number, number] }, + properties: { + index: i, + city: h.city, + country: h.country, + type: h.type, + }, + })); + this.techHQSC = new Supercluster({ + radius: 50, + maxZoom: 14, + map: (props: Record) => ({ + index: Number(props.index ?? 0), + city: String(props.city ?? ''), + country: String(props.country ?? ''), + faangCount: props.type === 'faang' ? 1 : 0, + unicornCount: props.type === 'unicorn' ? 1 : 0, + publicCount: props.type === 'public' ? 1 : 0, + }), + reduce: (acc: Record, props: Record) => { + acc.faangCount = Number(acc.faangCount ?? 0) + Number(props.faangCount ?? 0); + acc.unicornCount = Number(acc.unicornCount ?? 0) + Number(props.unicornCount ?? 0); + acc.publicCount = Number(acc.publicCount ?? 0) + Number(props.publicCount ?? 0); + if (!acc.city && props.city) acc.city = props.city; + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.techHQSC.load(points); + this.lastSCZoom = -1; + } + + private rebuildTechEventSupercluster(): void { + const points = this.techEvents.map((e, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] as [number, number] }, + properties: { + index: i, + location: e.location, + country: e.country, + daysUntil: e.daysUntil, + }, + })); + this.techEventSC = new Supercluster({ + radius: 50, + maxZoom: 14, + map: (props: Record) => { + const daysUntil = Number(props.daysUntil ?? Number.MAX_SAFE_INTEGER); + return { + index: Number(props.index ?? 0), + location: String(props.location ?? ''), + country: String(props.country ?? ''), + soonestDaysUntil: Number.isFinite(daysUntil) ? daysUntil : Number.MAX_SAFE_INTEGER, + soonCount: Number.isFinite(daysUntil) && daysUntil <= 14 ? 1 : 0, + }; + }, + reduce: (acc: Record, props: Record) => { + acc.soonestDaysUntil = Math.min( + Number(acc.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER), + Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER), + ); + acc.soonCount = Number(acc.soonCount ?? 0) + Number(props.soonCount ?? 0); + if (!acc.location && props.location) acc.location = props.location; + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.techEventSC.load(points); + this.lastSCZoom = -1; + } + + private rebuildDatacenterSupercluster(): void { + const activeDCs = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + const points = activeDCs.map((dc, i) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [dc.lon, dc.lat] as [number, number] }, + properties: { + index: i, + country: dc.country, + chipCount: dc.chipCount, + powerMW: dc.powerMW ?? 0, + status: dc.status, + }, + })); + this.datacenterSC = new Supercluster({ + radius: 70, + maxZoom: 14, + map: (props: Record) => ({ + index: Number(props.index ?? 0), + country: String(props.country ?? ''), + totalChips: Number(props.chipCount ?? 0) || 0, + totalPowerMW: Number(props.powerMW ?? 0) || 0, + existingCount: props.status === 'existing' ? 1 : 0, + plannedCount: props.status === 'planned' ? 1 : 0, + }), + reduce: (acc: Record, props: Record) => { + acc.totalChips = Number(acc.totalChips ?? 0) + Number(props.totalChips ?? 0); + acc.totalPowerMW = Number(acc.totalPowerMW ?? 0) + Number(props.totalPowerMW ?? 0); + acc.existingCount = Number(acc.existingCount ?? 0) + Number(props.existingCount ?? 0); + acc.plannedCount = Number(acc.plannedCount ?? 0) + Number(props.plannedCount ?? 0); + if (!acc.country && props.country) acc.country = props.country; + }, + }); + this.datacenterSC.load(points); + this.lastSCZoom = -1; + } + + private updateClusterData(): void { + const zoom = Math.floor(this.maplibreMap?.getZoom() ?? 2); + const bounds = this.maplibreMap?.getBounds(); + if (!bounds) return; + const bbox: [number, number, number, number] = [ + bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth(), + ]; + const boundsKey = `${bbox[0].toFixed(4)}:${bbox[1].toFixed(4)}:${bbox[2].toFixed(4)}:${bbox[3].toFixed(4)}`; + const layers = this.state.layers; + const useProtests = layers.protests && this.protestSuperclusterSource.length > 0; + const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs; + const useTechEvents = SITE_VARIANT === 'tech' && layers.techEvents && this.techEvents.length > 0; + const useDatacenterClusters = layers.datacenters && zoom < 5; + const layerMask = `${Number(useProtests)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`; + if (zoom === this.lastSCZoom && boundsKey === this.lastSCBoundsKey && layerMask === this.lastSCMask) return; + this.lastSCZoom = zoom; + this.lastSCBoundsKey = boundsKey; + this.lastSCMask = layerMask; + + if (useProtests && this.protestSC) { + this.protestClusters = this.protestSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.protestSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => this.protestSuperclusterSource[l.properties.index]).filter((x): x is SocialUnrestEvent => !!x); + const maxSeverityRank = Number(props.maxSeverityRank ?? 0); + const maxSev = maxSeverityRank >= 2 ? 'high' : maxSeverityRank === 1 ? 'medium' : 'low'; + const riotCount = Number(props.riotCount ?? 0); + const highSeverityCount = Number(props.highSeverityCount ?? 0); + const verifiedCount = Number(props.verifiedCount ?? 0); + const totalFatalities = Number(props.totalFatalities ?? 0); + const clusterCount = Number(f.properties.point_count ?? items.length); + const latestRiotEventTimeMs = items.reduce((max, it) => { + if (it.eventType !== 'riot' || it.sourceType === 'gdelt') return max; + const ts = it.time.getTime(); + return Number.isFinite(ts) ? Math.max(max, ts) : max; + }, 0); + return { + id: `pc-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + country: String(props.country ?? items[0]?.country ?? ''), + maxSeverity: maxSev as 'low' | 'medium' | 'high', + hasRiot: riotCount > 0, + latestRiotEventTimeMs: latestRiotEventTimeMs || undefined, + totalFatalities, + riotCount, + highSeverityCount, + verifiedCount, + sampled: items.length < clusterCount, + }; + } + const item = this.protestSuperclusterSource[f.properties.index]!; + return { + id: `pp-${f.properties.index}`, lat: item.lat, lon: item.lon, + count: 1, items: [item], country: item.country, + maxSeverity: item.severity, hasRiot: item.eventType === 'riot', + latestRiotEventTimeMs: + item.eventType === 'riot' && item.sourceType !== 'gdelt' && Number.isFinite(item.time.getTime()) + ? item.time.getTime() + : undefined, + totalFatalities: item.fatalities ?? 0, + riotCount: item.eventType === 'riot' ? 1 : 0, + highSeverityCount: item.severity === 'high' ? 1 : 0, + verifiedCount: item.validated ? 1 : 0, + sampled: false, + }; + }); + } else { + this.protestClusters = []; + } + + if (useTechHQ && this.techHQSC) { + this.techHQClusters = this.techHQSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.techHQSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => TECH_HQS[l.properties.index]).filter(Boolean) as typeof TECH_HQS; + const faangCount = Number(props.faangCount ?? 0); + const unicornCount = Number(props.unicornCount ?? 0); + const publicCount = Number(props.publicCount ?? 0); + const clusterCount = Number(f.properties.point_count ?? items.length); + const primaryType = faangCount >= unicornCount && faangCount >= publicCount + ? 'faang' + : unicornCount >= publicCount + ? 'unicorn' + : 'public'; + return { + id: `hc-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + city: String(props.city ?? items[0]?.city ?? ''), + country: String(props.country ?? items[0]?.country ?? ''), + primaryType, + faangCount, + unicornCount, + publicCount, + sampled: items.length < clusterCount, + }; + } + const item = TECH_HQS[f.properties.index]!; + return { + id: `hp-${f.properties.index}`, lat: item.lat, lon: item.lon, + count: 1, items: [item], city: item.city, country: item.country, + primaryType: item.type, + faangCount: item.type === 'faang' ? 1 : 0, + unicornCount: item.type === 'unicorn' ? 1 : 0, + publicCount: item.type === 'public' ? 1 : 0, + sampled: false, + }; + }); + } else { + this.techHQClusters = []; + } + + if (useTechEvents && this.techEventSC) { + this.techEventClusters = this.techEventSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.techEventSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => this.techEvents[l.properties.index]).filter((x): x is TechEventMarker => !!x); + const clusterCount = Number(f.properties.point_count ?? items.length); + const soonestDaysUntil = Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER); + const soonCount = Number(props.soonCount ?? 0); + return { + id: `ec-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + location: String(props.location ?? items[0]?.location ?? ''), + country: String(props.country ?? items[0]?.country ?? ''), + soonestDaysUntil: Number.isFinite(soonestDaysUntil) ? soonestDaysUntil : Number.MAX_SAFE_INTEGER, + soonCount, + sampled: items.length < clusterCount, + }; + } + const item = this.techEvents[f.properties.index]!; + return { + id: `ep-${f.properties.index}`, lat: item.lat, lon: item.lng, + count: 1, items: [item], location: item.location, country: item.country, + soonestDaysUntil: item.daysUntil, + soonCount: item.daysUntil <= 14 ? 1 : 0, + sampled: false, + }; + }); + } else { + this.techEventClusters = []; + } + + if (useDatacenterClusters && this.datacenterSC) { + const activeDCs = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + this.datacenterClusters = this.datacenterSC.getClusters(bbox, zoom).map(f => { + const coords = f.geometry.coordinates as [number, number]; + if (f.properties.cluster) { + const props = f.properties as Record; + const leaves = this.datacenterSC!.getLeaves(f.properties.cluster_id!, DeckGLMap.MAX_CLUSTER_LEAVES); + const items = leaves.map(l => activeDCs[l.properties.index]).filter((x): x is AIDataCenter => !!x); + const clusterCount = Number(f.properties.point_count ?? items.length); + const existingCount = Number(props.existingCount ?? 0); + const plannedCount = Number(props.plannedCount ?? 0); + const totalChips = Number(props.totalChips ?? 0); + const totalPowerMW = Number(props.totalPowerMW ?? 0); + return { + id: `dc-${f.properties.cluster_id}`, + lat: coords[1], lon: coords[0], + count: clusterCount, + items, + region: String(props.country ?? items[0]?.country ?? ''), + country: String(props.country ?? items[0]?.country ?? ''), + totalChips, + totalPowerMW, + majorityExisting: existingCount >= Math.max(1, clusterCount / 2), + existingCount, + plannedCount, + sampled: items.length < clusterCount, + }; + } + const item = activeDCs[f.properties.index]!; + return { + id: `dp-${f.properties.index}`, lat: item.lat, lon: item.lon, + count: 1, items: [item], region: item.country, country: item.country, + totalChips: item.chipCount, totalPowerMW: item.powerMW ?? 0, + majorityExisting: item.status === 'existing', + existingCount: item.status === 'existing' ? 1 : 0, + plannedCount: item.status === 'planned' ? 1 : 0, + sampled: false, + }; + }); + } else { + this.datacenterClusters = []; + } + } + + + + + private isLayerVisible(layerKey: keyof MapLayers): boolean { + const threshold = LAYER_ZOOM_THRESHOLDS[layerKey]; + if (!threshold) return true; + const zoom = this.maplibreMap?.getZoom() || 2; + return zoom >= threshold.minZoom; + } + + private buildLayers(): LayersList { + const startTime = performance.now(); + // Refresh theme-aware overlay colors on each rebuild + COLORS = getOverlayColors(); + const layers: (Layer | null | false)[] = []; + const { layers: mapLayers } = this.state; + const filteredEarthquakes = this.filterByTime(this.earthquakes, (eq) => eq.time); + const filteredNaturalEvents = this.filterByTime(this.naturalEvents, (event) => event.date); + const filteredWeatherAlerts = this.filterByTime(this.weatherAlerts, (alert) => alert.onset); + const filteredOutages = this.filterByTime(this.outages, (outage) => outage.pubDate); + const filteredCableAdvisories = this.filterByTime(this.cableAdvisories, (advisory) => advisory.reported); + const filteredFlightDelays = this.filterByTime(this.flightDelays, (delay) => delay.updatedAt); + const filteredMilitaryFlights = this.filterByTime(this.militaryFlights, (flight) => flight.lastSeen); + const filteredMilitaryVessels = this.filterByTime(this.militaryVessels, (vessel) => vessel.lastAisUpdate); + const filteredMilitaryFlightClusters = this.filterMilitaryFlightClustersByTime(this.militaryFlightClusters); + const filteredMilitaryVesselClusters = this.filterMilitaryVesselClustersByTime(this.militaryVesselClusters); + const filteredUcdpEvents = this.filterByTime(this.ucdpEvents, (event) => event.date_start); + + // Undersea cables layer + if (mapLayers.cables) { + layers.push(this.createCablesLayer()); + } + + // Pipelines layer + if (mapLayers.pipelines) { + layers.push(this.createPipelinesLayer()); + } + + // Conflict zones layer + if (mapLayers.conflicts) { + layers.push(this.createConflictZonesLayer()); + } + + // Military bases layer — hidden at low zoom (E: progressive disclosure) + ghost + if (mapLayers.bases && this.isLayerVisible('bases')) { + layers.push(this.createBasesLayer()); + layers.push(this.createGhostLayer('bases-layer', MILITARY_BASES, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Nuclear facilities layer — hidden at low zoom + ghost + if (mapLayers.nuclear && this.isLayerVisible('nuclear')) { + layers.push(this.createNuclearLayer()); + layers.push(this.createGhostLayer('nuclear-layer', NUCLEAR_FACILITIES.filter(f => f.status !== 'decommissioned'), d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Gamma irradiators layer — hidden at low zoom + if (mapLayers.irradiators && this.isLayerVisible('irradiators')) { + layers.push(this.createIrradiatorsLayer()); + } + + // Spaceports layer — hidden at low zoom + if (mapLayers.spaceports && this.isLayerVisible('spaceports')) { + layers.push(this.createSpaceportsLayer()); + } + + // Hotspots layer (all hotspots including high/breaking, with pulse + ghost) + if (mapLayers.hotspots) { + layers.push(...this.createHotspotsLayers()); + } + + // Datacenters layer - SQUARE icons at zoom >= 5, cluster dots at zoom < 5 + const currentZoom = this.maplibreMap?.getZoom() || 2; + if (mapLayers.datacenters) { + if (currentZoom >= 5) { + layers.push(this.createDatacentersLayer()); + } else { + layers.push(...this.createDatacenterClusterLayers()); + } + } + + // Earthquakes layer + ghost for easier picking + if (mapLayers.natural && filteredEarthquakes.length > 0) { + layers.push(this.createEarthquakesLayer(filteredEarthquakes)); + layers.push(this.createGhostLayer('earthquakes-layer', filteredEarthquakes, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Natural events layer + if (mapLayers.natural && filteredNaturalEvents.length > 0) { + layers.push(this.createNaturalEventsLayer(filteredNaturalEvents)); + } + + // Satellite fires layer (NASA FIRMS) + if (mapLayers.fires && this.firmsFireData.length > 0) { + layers.push(this.createFiresLayer()); + } + + // Weather alerts layer + if (mapLayers.weather && filteredWeatherAlerts.length > 0) { + layers.push(this.createWeatherLayer(filteredWeatherAlerts)); + } + + // Internet outages layer + ghost for easier picking + if (mapLayers.outages && filteredOutages.length > 0) { + layers.push(this.createOutagesLayer(filteredOutages)); + layers.push(this.createGhostLayer('outages-layer', filteredOutages, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // Cyber threat IOC layer + if (mapLayers.cyberThreats && this.cyberThreats.length > 0) { + layers.push(this.createCyberThreatsLayer()); + layers.push(this.createGhostLayer('cyber-threats-layer', this.cyberThreats, d => [d.lon, d.lat], { radiusMinPixels: 12 })); + } + + // AIS density layer + if (mapLayers.ais && this.aisDensity.length > 0) { + layers.push(this.createAisDensityLayer()); + } + + // AIS disruptions layer (spoofing/jamming) + if (mapLayers.ais && this.aisDisruptions.length > 0) { + layers.push(this.createAisDisruptionsLayer()); + } + + // Strategic ports layer (shown with AIS) + if (mapLayers.ais) { + layers.push(this.createPortsLayer()); + } + + // Cable advisories layer (shown with cables) + if (mapLayers.cables && filteredCableAdvisories.length > 0) { + layers.push(this.createCableAdvisoriesLayer(filteredCableAdvisories)); + } + + // Repair ships layer (shown with cables) + if (mapLayers.cables && this.repairShips.length > 0) { + layers.push(this.createRepairShipsLayer()); + } + + // Flight delays layer + if (mapLayers.flights && filteredFlightDelays.length > 0) { + layers.push(this.createFlightDelaysLayer(filteredFlightDelays)); + } + + // Protests layer (Supercluster-based deck.gl layers) + if (mapLayers.protests && this.protests.length > 0) { + layers.push(...this.createProtestClusterLayers()); + } + + // Military vessels layer + if (mapLayers.military && filteredMilitaryVessels.length > 0) { + layers.push(this.createMilitaryVesselsLayer(filteredMilitaryVessels)); + } + + // Military vessel clusters layer + if (mapLayers.military && filteredMilitaryVesselClusters.length > 0) { + layers.push(this.createMilitaryVesselClustersLayer(filteredMilitaryVesselClusters)); + } + + // Military flights layer + if (mapLayers.military && filteredMilitaryFlights.length > 0) { + layers.push(this.createMilitaryFlightsLayer(filteredMilitaryFlights)); + } + + // Military flight clusters layer + if (mapLayers.military && filteredMilitaryFlightClusters.length > 0) { + layers.push(this.createMilitaryFlightClustersLayer(filteredMilitaryFlightClusters)); + } + + // Strategic waterways layer + if (mapLayers.waterways) { + layers.push(this.createWaterwaysLayer()); + } + + // Economic centers layer — hidden at low zoom + if (mapLayers.economic && this.isLayerVisible('economic')) { + layers.push(this.createEconomicCentersLayer()); + } + + // Finance variant layers + if (mapLayers.stockExchanges) { + layers.push(this.createStockExchangesLayer()); + } + if (mapLayers.financialCenters) { + layers.push(this.createFinancialCentersLayer()); + } + if (mapLayers.centralBanks) { + layers.push(this.createCentralBanksLayer()); + } + if (mapLayers.commodityHubs) { + layers.push(this.createCommodityHubsLayer()); + } + + // Critical minerals layer + if (mapLayers.minerals) { + layers.push(this.createMineralsLayer()); + } + + // APT Groups layer (geopolitical variant only - always shown, no toggle) + if (SITE_VARIANT !== 'tech') { + layers.push(this.createAPTGroupsLayer()); + } + + // UCDP georeferenced events layer + if (mapLayers.ucdpEvents && filteredUcdpEvents.length > 0) { + layers.push(this.createUcdpEventsLayer(filteredUcdpEvents)); + } + + // Displacement flows arc layer + if (mapLayers.displacement && this.displacementFlows.length > 0) { + layers.push(this.createDisplacementArcsLayer()); + } + + // Climate anomalies heatmap layer + if (mapLayers.climate && this.climateAnomalies.length > 0) { + layers.push(this.createClimateHeatmapLayer()); + } + + // Tech variant layers (Supercluster-based deck.gl layers for HQs and events) + if (SITE_VARIANT === 'tech') { + if (mapLayers.startupHubs) { + layers.push(this.createStartupHubsLayer()); + } + if (mapLayers.techHQs) { + layers.push(...this.createTechHQClusterLayers()); + } + if (mapLayers.accelerators) { + layers.push(this.createAcceleratorsLayer()); + } + if (mapLayers.cloudRegions) { + layers.push(this.createCloudRegionsLayer()); + } + if (mapLayers.techEvents && this.techEvents.length > 0) { + layers.push(...this.createTechEventClusterLayers()); + } + } + + // Gulf FDI investments layer + if (mapLayers.gulfInvestments) { + layers.push(this.createGulfInvestmentsLayer()); + } + + // News geo-locations (always shown if data exists) + if (this.newsLocations.length > 0) { + layers.push(...this.createNewsLocationsLayer()); + } + + const result = layers.filter(Boolean) as LayersList; + const elapsed = performance.now() - startTime; + if (import.meta.env.DEV && elapsed > 16) { + console.warn(`[DeckGLMap] buildLayers took ${elapsed.toFixed(2)}ms (>16ms budget), ${result.length} layers`); + } + return result; + } + + // Layer creation methods + private createCablesLayer(): PathLayer { + const highlightedCables = this.highlightedAssets.cable; + const cacheKey = 'cables-layer'; + const cached = this.layerCache.get(cacheKey) as PathLayer | undefined; + const highlightSignature = this.getSetSignature(highlightedCables); + if (cached && highlightSignature === this.lastCableHighlightSignature) return cached; + + const layer = new PathLayer({ + id: cacheKey, + data: UNDERSEA_CABLES, + getPath: (d) => d.points, + getColor: (d) => + highlightedCables.has(d.id) ? COLORS.cableHighlight : COLORS.cable, + getWidth: (d) => highlightedCables.has(d.id) ? 3 : 1, + widthMinPixels: 1, + widthMaxPixels: 5, + pickable: true, + updateTriggers: { highlighted: highlightSignature }, + }); + + this.lastCableHighlightSignature = highlightSignature; + this.layerCache.set(cacheKey, layer); + return layer; + } + + private createPipelinesLayer(): PathLayer { + const highlightedPipelines = this.highlightedAssets.pipeline; + const cacheKey = 'pipelines-layer'; + const cached = this.layerCache.get(cacheKey) as PathLayer | undefined; + const highlightSignature = this.getSetSignature(highlightedPipelines); + if (cached && highlightSignature === this.lastPipelineHighlightSignature) return cached; + + const layer = new PathLayer({ + id: cacheKey, + data: PIPELINES, + getPath: (d) => d.points, + getColor: (d) => { + if (highlightedPipelines.has(d.id)) { + return [255, 100, 100, 200] as [number, number, number, number]; + } + const colorKey = d.type as keyof typeof PIPELINE_COLORS; + const hex = PIPELINE_COLORS[colorKey] || '#666666'; + return this.hexToRgba(hex, 150); + }, + getWidth: (d) => highlightedPipelines.has(d.id) ? 3 : 1.5, + widthMinPixels: 1, + widthMaxPixels: 4, + pickable: true, + updateTriggers: { highlighted: highlightSignature }, + }); + + this.lastPipelineHighlightSignature = highlightSignature; + this.layerCache.set(cacheKey, layer); + return layer; + } + + private createConflictZonesLayer(): GeoJsonLayer { + const cacheKey = 'conflict-zones-layer'; + + const geojsonData = { + type: 'FeatureCollection' as const, + features: CONFLICT_ZONES.map(zone => ({ + type: 'Feature' as const, + properties: { id: zone.id, name: zone.name, intensity: zone.intensity }, + geometry: { + type: 'Polygon' as const, + coordinates: [zone.coords], + }, + })), + }; + + const layer = new GeoJsonLayer({ + id: cacheKey, + data: geojsonData, + filled: true, + stroked: true, + getFillColor: () => COLORS.conflict, + getLineColor: () => getCurrentTheme() === 'light' + ? [255, 0, 0, 120] as [number, number, number, number] + : [255, 0, 0, 180] as [number, number, number, number], + getLineWidth: 2, + lineWidthMinPixels: 1, + pickable: true, + }); + return layer; + } + + private createBasesLayer(): IconLayer { + const highlightedBases = this.highlightedAssets.base; + + // Base colors by operator type - semi-transparent for layering + // F: Fade in bases as you zoom — subtle at zoom 3, full at zoom 5+ + const zoom = this.maplibreMap?.getZoom() || 3; + const alphaScale = Math.min(1, (zoom - 2.5) / 2.5); // 0.2 at zoom 3, 1.0 at zoom 5 + const a = Math.round(160 * Math.max(0.3, alphaScale)); + + const getBaseColor = (type: string): [number, number, number, number] => { + switch (type) { + case 'us-nato': return [68, 136, 255, a]; + case 'russia': return [255, 68, 68, a]; + case 'china': return [255, 136, 68, a]; + case 'uk': return [68, 170, 255, a]; + case 'france': return [0, 85, 164, a]; + case 'india': return [255, 153, 51, a]; + case 'japan': return [188, 0, 45, a]; + default: return [136, 136, 136, a]; + } + }; + + // Military bases: TRIANGLE icons - color by operator, semi-transparent + return new IconLayer({ + id: 'bases-layer', + data: MILITARY_BASES, + getPosition: (d) => [d.lon, d.lat], + getIcon: () => 'triangleUp', + iconAtlas: MARKER_ICONS.triangleUp, + iconMapping: { triangleUp: { x: 0, y: 0, width: 32, height: 32, mask: true } }, + getSize: (d) => highlightedBases.has(d.id) ? 16 : 11, + getColor: (d) => { + if (highlightedBases.has(d.id)) { + return [255, 100, 100, 220] as [number, number, number, number]; + } + return getBaseColor(d.type); + }, + sizeScale: 1, + sizeMinPixels: 6, + sizeMaxPixels: 16, + pickable: true, + }); + } + + private createNuclearLayer(): IconLayer { + const highlightedNuclear = this.highlightedAssets.nuclear; + const data = NUCLEAR_FACILITIES.filter(f => f.status !== 'decommissioned'); + + // Nuclear: HEXAGON icons - yellow/orange color, semi-transparent + return new IconLayer({ + id: 'nuclear-layer', + data, + getPosition: (d) => [d.lon, d.lat], + getIcon: () => 'hexagon', + iconAtlas: MARKER_ICONS.hexagon, + iconMapping: { hexagon: { x: 0, y: 0, width: 32, height: 32, mask: true } }, + getSize: (d) => highlightedNuclear.has(d.id) ? 15 : 11, + getColor: (d) => { + if (highlightedNuclear.has(d.id)) { + return [255, 100, 100, 220] as [number, number, number, number]; + } + if (d.status === 'contested') { + return [255, 50, 50, 200] as [number, number, number, number]; + } + return [255, 220, 0, 200] as [number, number, number, number]; // Semi-transparent yellow + }, + sizeScale: 1, + sizeMinPixels: 6, + sizeMaxPixels: 15, + pickable: true, + }); + } + + private createIrradiatorsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'irradiators-layer', + data: GAMMA_IRRADIATORS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: [255, 100, 255, 180] as [number, number, number, number], // Magenta + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createSpaceportsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'spaceports-layer', + data: SPACEPORTS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: [200, 100, 255, 200] as [number, number, number, number], // Purple + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createPortsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'ports-layer', + data: PORTS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: (d) => { + // Color by port type (matching old Map.ts icons) + switch (d.type) { + case 'naval': return [100, 150, 255, 200] as [number, number, number, number]; // Blue - ⚓ + case 'oil': return [255, 140, 0, 200] as [number, number, number, number]; // Orange - 🛢️ + case 'lng': return [255, 200, 50, 200] as [number, number, number, number]; // Yellow - 🛢️ + case 'container': return [0, 200, 255, 180] as [number, number, number, number]; // Cyan - 🏭 + case 'mixed': return [150, 200, 150, 180] as [number, number, number, number]; // Green + case 'bulk': return [180, 150, 120, 180] as [number, number, number, number]; // Brown + default: return [0, 200, 255, 160] as [number, number, number, number]; + } + }, + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createFlightDelaysLayer(delays: AirportDelayAlert[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'flight-delays-layer', + data: delays, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + if (d.severity === 'GDP') return 15000; // Ground Delay Program + if (d.severity === 'GS') return 12000; // Ground Stop + return 8000; + }, + getFillColor: (d) => { + if (d.severity === 'GS') return [255, 50, 50, 200] as [number, number, number, number]; // Red for ground stops + if (d.severity === 'GDP') return [255, 150, 0, 200] as [number, number, number, number]; // Orange for delays + return [255, 200, 100, 180] as [number, number, number, number]; // Yellow + }, + radiusMinPixels: 4, + radiusMaxPixels: 15, + pickable: true, + }); + } + + private createGhostLayer(id: string, data: T[], getPosition: (d: T) => [number, number], opts: { radiusMinPixels?: number } = {}): ScatterplotLayer { + return new ScatterplotLayer({ + id: `${id}-ghost`, + data, + getPosition, + getRadius: 1, + radiusMinPixels: opts.radiusMinPixels ?? 12, + getFillColor: [0, 0, 0, 0], + pickable: true, + }); + } + + + private createDatacentersLayer(): IconLayer { + const highlightedDC = this.highlightedAssets.datacenter; + const data = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + + // Datacenters: SQUARE icons - purple color, semi-transparent for layering + return new IconLayer({ + id: 'datacenters-layer', + data, + getPosition: (d) => [d.lon, d.lat], + getIcon: () => 'square', + iconAtlas: MARKER_ICONS.square, + iconMapping: { square: { x: 0, y: 0, width: 32, height: 32, mask: true } }, + getSize: (d) => highlightedDC.has(d.id) ? 14 : 10, + getColor: (d) => { + if (highlightedDC.has(d.id)) { + return [255, 100, 100, 200] as [number, number, number, number]; + } + if (d.status === 'planned') { + return [136, 68, 255, 100] as [number, number, number, number]; // Transparent for planned + } + return [136, 68, 255, 140] as [number, number, number, number]; // ~55% opacity + }, + sizeScale: 1, + sizeMinPixels: 6, + sizeMaxPixels: 14, + pickable: true, + }); + } + + private createEarthquakesLayer(earthquakes: Earthquake[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'earthquakes-layer', + data: earthquakes, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => Math.pow(2, d.magnitude) * 1000, + getFillColor: (d) => { + const mag = d.magnitude; + if (mag >= 6) return [255, 0, 0, 200] as [number, number, number, number]; + if (mag >= 5) return [255, 100, 0, 200] as [number, number, number, number]; + return COLORS.earthquake; + }, + radiusMinPixels: 4, + radiusMaxPixels: 30, + pickable: true, + }); + } + + private createNaturalEventsLayer(events: NaturalEvent[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'natural-events-layer', + data: events, + getPosition: (d: NaturalEvent) => [d.lon, d.lat], + getRadius: (d: NaturalEvent) => d.title.startsWith('🔴') ? 20000 : d.title.startsWith('🟠') ? 15000 : 8000, + getFillColor: (d: NaturalEvent) => { + if (d.title.startsWith('🔴')) return [255, 0, 0, 220] as [number, number, number, number]; + if (d.title.startsWith('🟠')) return [255, 140, 0, 200] as [number, number, number, number]; + return [255, 150, 50, 180] as [number, number, number, number]; + }, + radiusMinPixels: 5, + radiusMaxPixels: 18, + pickable: true, + }); + } + + private createFiresLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'fires-layer', + data: this.firmsFireData, + getPosition: (d: (typeof this.firmsFireData)[0]) => [d.lon, d.lat], + getRadius: (d: (typeof this.firmsFireData)[0]) => Math.min(d.frp * 200, 30000) || 5000, + getFillColor: (d: (typeof this.firmsFireData)[0]) => { + if (d.brightness > 400) return [255, 30, 0, 220] as [number, number, number, number]; + if (d.brightness > 350) return [255, 140, 0, 200] as [number, number, number, number]; + return [255, 220, 50, 180] as [number, number, number, number]; + }, + radiusMinPixels: 3, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createWeatherLayer(alerts: WeatherAlert[]): ScatterplotLayer { + // Filter weather alerts that have centroid coordinates + const alertsWithCoords = alerts.filter(a => a.centroid && a.centroid.length === 2); + + return new ScatterplotLayer({ + id: 'weather-layer', + data: alertsWithCoords, + getPosition: (d) => d.centroid as [number, number], // centroid is [lon, lat] + getRadius: 25000, + getFillColor: (d) => { + if (d.severity === 'Extreme') return [255, 0, 0, 200] as [number, number, number, number]; + if (d.severity === 'Severe') return [255, 100, 0, 180] as [number, number, number, number]; + if (d.severity === 'Moderate') return [255, 170, 0, 160] as [number, number, number, number]; + return COLORS.weather; + }, + radiusMinPixels: 8, + radiusMaxPixels: 20, + pickable: true, + }); + } + + private createOutagesLayer(outages: InternetOutage[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'outages-layer', + data: outages, + getPosition: (d) => [d.lon, d.lat], + getRadius: 20000, + getFillColor: COLORS.outage, + radiusMinPixels: 6, + radiusMaxPixels: 18, + pickable: true, + }); + } + + private createCyberThreatsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'cyber-threats-layer', + data: this.cyberThreats, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + switch (d.severity) { + case 'critical': return 22000; + case 'high': return 17000; + case 'medium': return 13000; + default: return 9000; + } + }, + getFillColor: (d) => { + switch (d.severity) { + case 'critical': return [255, 61, 0, 225] as [number, number, number, number]; + case 'high': return [255, 102, 0, 205] as [number, number, number, number]; + case 'medium': return [255, 176, 0, 185] as [number, number, number, number]; + default: return [255, 235, 59, 170] as [number, number, number, number]; + } + }, + radiusMinPixels: 6, + radiusMaxPixels: 18, + pickable: true, + stroked: true, + getLineColor: [255, 255, 255, 160] as [number, number, number, number], + lineWidthMinPixels: 1, + }); + } + + private createAisDensityLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'ais-density-layer', + data: this.aisDensity, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 4000 + d.intensity * 8000, + getFillColor: (d) => { + const intensity = Math.min(Math.max(d.intensity, 0.15), 1); + const isCongested = (d.deltaPct || 0) >= 15; + const alpha = Math.round(40 + intensity * 160); + // Orange for congested areas, cyan for normal traffic + if (isCongested) { + return [255, 183, 3, alpha] as [number, number, number, number]; // #ffb703 + } + return [0, 209, 255, alpha] as [number, number, number, number]; // #00d1ff + }, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createAisDisruptionsLayer(): ScatterplotLayer { + // AIS spoofing/jamming events + return new ScatterplotLayer({ + id: 'ais-disruptions-layer', + data: this.aisDisruptions, + getPosition: (d) => [d.lon, d.lat], + getRadius: 12000, + getFillColor: (d) => { + // Color by severity/type + if (d.severity === 'high' || d.type === 'spoofing') { + return [255, 50, 50, 220] as [number, number, number, number]; // Red + } + if (d.severity === 'medium') { + return [255, 150, 0, 200] as [number, number, number, number]; // Orange + } + return [255, 200, 100, 180] as [number, number, number, number]; // Yellow + }, + radiusMinPixels: 6, + radiusMaxPixels: 14, + pickable: true, + stroked: true, + getLineColor: [255, 255, 255, 150] as [number, number, number, number], + lineWidthMinPixels: 1, + }); + } + + private createCableAdvisoriesLayer(advisories: CableAdvisory[]): ScatterplotLayer { + // Cable fault/maintenance advisories + return new ScatterplotLayer({ + id: 'cable-advisories-layer', + data: advisories, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: (d) => { + if (d.severity === 'fault') { + return [255, 50, 50, 220] as [number, number, number, number]; // Red for faults + } + return [255, 200, 0, 200] as [number, number, number, number]; // Yellow for maintenance + }, + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + stroked: true, + getLineColor: [0, 200, 255, 200] as [number, number, number, number], // Cyan outline (cable color) + lineWidthMinPixels: 2, + }); + } + + private createRepairShipsLayer(): ScatterplotLayer { + // Cable repair ships + return new ScatterplotLayer({ + id: 'repair-ships-layer', + data: this.repairShips, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: [0, 255, 200, 200] as [number, number, number, number], // Teal + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createMilitaryVesselsLayer(vessels: MilitaryVessel[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-vessels-layer', + data: vessels, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: COLORS.vesselMilitary, + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createMilitaryVesselClustersLayer(clusters: MilitaryVesselCluster[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-vessel-clusters-layer', + data: clusters, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 15000 + (d.vesselCount || 1) * 3000, + getFillColor: (d) => { + // Vessel types: 'exercise' | 'deployment' | 'transit' | 'unknown' + const activity = d.activityType || 'unknown'; + if (activity === 'exercise' || activity === 'deployment') return [255, 100, 100, 200] as [number, number, number, number]; + if (activity === 'transit') return [255, 180, 100, 180] as [number, number, number, number]; + return [200, 150, 150, 160] as [number, number, number, number]; + }, + radiusMinPixels: 8, + radiusMaxPixels: 25, + pickable: true, + }); + } + + private createMilitaryFlightsLayer(flights: MilitaryFlight[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-flights-layer', + data: flights, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: COLORS.flightMilitary, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createMilitaryFlightClustersLayer(clusters: MilitaryFlightCluster[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'military-flight-clusters-layer', + data: clusters, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 15000 + (d.flightCount || 1) * 3000, + getFillColor: (d) => { + const activity = d.activityType || 'unknown'; + if (activity === 'exercise' || activity === 'patrol') return [100, 150, 255, 200] as [number, number, number, number]; + if (activity === 'transport') return [255, 200, 100, 180] as [number, number, number, number]; + return [150, 150, 200, 160] as [number, number, number, number]; + }, + radiusMinPixels: 8, + radiusMaxPixels: 25, + pickable: true, + }); + } + + private createWaterwaysLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'waterways-layer', + data: STRATEGIC_WATERWAYS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: [100, 150, 255, 180] as [number, number, number, number], + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createEconomicCentersLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'economic-centers-layer', + data: ECONOMIC_CENTERS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: [255, 215, 0, 180] as [number, number, number, number], + radiusMinPixels: 4, + radiusMaxPixels: 10, + pickable: true, + }); + } + + private createStockExchangesLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'stock-exchanges-layer', + data: STOCK_EXCHANGES, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.tier === 'mega' ? 18000 : d.tier === 'major' ? 14000 : 11000, + getFillColor: (d) => { + if (d.tier === 'mega') return [255, 215, 80, 220] as [number, number, number, number]; + if (d.tier === 'major') return COLORS.stockExchange; + return [140, 210, 255, 190] as [number, number, number, number]; + }, + radiusMinPixels: 5, + radiusMaxPixels: 14, + pickable: true, + }); + } + + private createFinancialCentersLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'financial-centers-layer', + data: FINANCIAL_CENTERS, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.type === 'global' ? 17000 : d.type === 'regional' ? 13000 : 10000, + getFillColor: (d) => { + if (d.type === 'global') return COLORS.financialCenter; + if (d.type === 'regional') return [0, 190, 130, 185] as [number, number, number, number]; + return [0, 150, 110, 165] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createCentralBanksLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'central-banks-layer', + data: CENTRAL_BANKS, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.type === 'major' ? 15000 : d.type === 'supranational' ? 17000 : 12000, + getFillColor: (d) => { + if (d.type === 'major') return COLORS.centralBank; + if (d.type === 'supranational') return [255, 235, 140, 220] as [number, number, number, number]; + return [235, 180, 80, 185] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createCommodityHubsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'commodity-hubs-layer', + data: COMMODITY_HUBS, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => d.type === 'exchange' ? 14000 : d.type === 'port' ? 12000 : 10000, + getFillColor: (d) => { + if (d.type === 'exchange') return COLORS.commodityHub; + if (d.type === 'port') return [80, 170, 255, 190] as [number, number, number, number]; + return [255, 110, 80, 185] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: 11, + pickable: true, + }); + } + + private createAPTGroupsLayer(): ScatterplotLayer { + // APT Groups - cyber threat actor markers (geopolitical variant only) + // Made subtle to avoid visual clutter - small orange dots + return new ScatterplotLayer({ + id: 'apt-groups-layer', + data: APT_GROUPS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: [255, 140, 0, 140] as [number, number, number, number], // Subtle orange + radiusMinPixels: 4, + radiusMaxPixels: 8, + pickable: true, + stroked: false, // No outline - cleaner look + }); + } + + private createMineralsLayer(): ScatterplotLayer { + // Critical minerals projects + return new ScatterplotLayer({ + id: 'minerals-layer', + data: CRITICAL_MINERALS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 8000, + getFillColor: (d) => { + // Color by mineral type + switch (d.mineral) { + case 'Lithium': return [0, 200, 255, 200] as [number, number, number, number]; // Cyan + case 'Cobalt': return [100, 100, 255, 200] as [number, number, number, number]; // Blue + case 'Rare Earths': return [255, 100, 200, 200] as [number, number, number, number]; // Pink + case 'Nickel': return [100, 255, 100, 200] as [number, number, number, number]; // Green + default: return [200, 200, 200, 200] as [number, number, number, number]; // Gray + } + }, + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + // Tech variant layers + private createStartupHubsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'startup-hubs-layer', + data: STARTUP_HUBS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 10000, + getFillColor: COLORS.startupHub, + radiusMinPixels: 5, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createAcceleratorsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'accelerators-layer', + data: ACCELERATORS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 6000, + getFillColor: COLORS.accelerator, + radiusMinPixels: 3, + radiusMaxPixels: 8, + pickable: true, + }); + } + + private createCloudRegionsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'cloud-regions-layer', + data: CLOUD_REGIONS, + getPosition: (d) => [d.lon, d.lat], + getRadius: 12000, + getFillColor: COLORS.cloudRegion, + radiusMinPixels: 4, + radiusMaxPixels: 12, + pickable: true, + }); + } + + private createProtestClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'protest-clusters-layer', + data: this.protestClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 15000 + d.count * 2000, + radiusMinPixels: 6, + radiusMaxPixels: 22, + getFillColor: d => { + if (d.hasRiot) return [220, 40, 40, 200] as [number, number, number, number]; + if (d.maxSeverity === 'high') return [255, 80, 60, 180] as [number, number, number, number]; + if (d.maxSeverity === 'medium') return [255, 160, 40, 160] as [number, number, number, number]; + return [255, 220, 80, 140] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom, getFillColor: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('protest-clusters-layer', this.protestClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.protestClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'protest-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + const pulseClusters = this.protestClusters.filter(c => c.maxSeverity === 'high' || c.hasRiot); + if (pulseClusters.length > 0) { + const pulse = 1.0 + 0.8 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 400)); + layers.push(new ScatterplotLayer({ + id: 'protest-clusters-pulse', + data: pulseClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 15000 + d.count * 2000, + radiusScale: pulse, + radiusMinPixels: 8, + radiusMaxPixels: 30, + stroked: true, + filled: false, + getLineColor: d => d.hasRiot ? [220, 40, 40, 120] as [number, number, number, number] : [255, 80, 60, 100] as [number, number, number, number], + lineWidthMinPixels: 1.5, + pickable: false, + updateTriggers: { radiusScale: this.pulseTime }, + })); + } + + return layers; + } + + private createTechHQClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + const zoom = this.maplibreMap?.getZoom() || 2; + + layers.push(new ScatterplotLayer({ + id: 'tech-hq-clusters-layer', + data: this.techHQClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 10000 + d.count * 1500, + radiusMinPixels: 5, + radiusMaxPixels: 18, + getFillColor: d => { + if (d.primaryType === 'faang') return [0, 220, 120, 200] as [number, number, number, number]; + if (d.primaryType === 'unicorn') return [255, 100, 200, 180] as [number, number, number, number]; + return [80, 160, 255, 180] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('tech-hq-clusters-layer', this.techHQClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.techHQClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'tech-hq-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + if (zoom >= 3) { + const singles = this.techHQClusters.filter(c => c.count === 1); + if (singles.length > 0) { + layers.push(new TextLayer({ + id: 'tech-hq-clusters-label', + data: singles, + getText: d => d.items[0]?.company ?? '', + getPosition: d => [d.lon, d.lat], + getSize: 11, + getColor: [220, 220, 220, 200], + getPixelOffset: [0, 12], + pickable: false, + fontFamily: 'system-ui, sans-serif', + })); + } + } + + return layers; + } + + private createTechEventClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'tech-event-clusters-layer', + data: this.techEventClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 10000 + d.count * 1500, + radiusMinPixels: 5, + radiusMaxPixels: 18, + getFillColor: d => { + if (d.soonestDaysUntil <= 14) return [255, 220, 50, 200] as [number, number, number, number]; + return [80, 140, 255, 180] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('tech-event-clusters-layer', this.techEventClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.techEventClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'tech-event-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + return layers; + } + + private createDatacenterClusterLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'datacenter-clusters-layer', + data: this.datacenterClusters, + getPosition: d => [d.lon, d.lat], + getRadius: d => 15000 + d.count * 2000, + radiusMinPixels: 6, + radiusMaxPixels: 20, + getFillColor: d => { + if (d.majorityExisting) return [160, 80, 255, 180] as [number, number, number, number]; + return [80, 160, 255, 180] as [number, number, number, number]; + }, + pickable: true, + updateTriggers: { getRadius: this.lastSCZoom }, + })); + + layers.push(this.createGhostLayer('datacenter-clusters-layer', this.datacenterClusters, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const multiClusters = this.datacenterClusters.filter(c => c.count > 1); + if (multiClusters.length > 0) { + layers.push(new TextLayer({ + id: 'datacenter-clusters-badge', + data: multiClusters, + getText: d => String(d.count), + getPosition: d => [d.lon, d.lat], + background: true, + getBackgroundColor: [0, 0, 0, 180], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + return layers; + } + + private createHotspotsLayers(): Layer[] { + const zoom = this.maplibreMap?.getZoom() || 2; + const zoomScale = Math.min(1, (zoom - 1) / 3); + const maxPx = 6 + Math.round(14 * zoomScale); + const baseOpacity = zoom < 2.5 ? 0.5 : zoom < 4 ? 0.7 : 1.0; + const layers: Layer[] = []; + + layers.push(new ScatterplotLayer({ + id: 'hotspots-layer', + data: this.hotspots, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + const score = d.escalationScore || 1; + return 10000 + score * 5000; + }, + getFillColor: (d) => { + const score = d.escalationScore || 1; + const a = Math.round((score >= 4 ? 200 : score >= 2 ? 200 : 180) * baseOpacity); + if (score >= 4) return [255, 68, 68, a] as [number, number, number, number]; + if (score >= 2) return [255, 165, 0, a] as [number, number, number, number]; + return [255, 255, 0, a] as [number, number, number, number]; + }, + radiusMinPixels: 4, + radiusMaxPixels: maxPx, + pickable: true, + stroked: true, + getLineColor: (d) => + d.hasBreaking ? [255, 255, 255, 255] as [number, number, number, number] : [0, 0, 0, 0] as [number, number, number, number], + lineWidthMinPixels: 2, + })); + + layers.push(this.createGhostLayer('hotspots-layer', this.hotspots, d => [d.lon, d.lat], { radiusMinPixels: 14 })); + + const highHotspots = this.hotspots.filter(h => h.level === 'high' || h.hasBreaking); + if (highHotspots.length > 0) { + const pulse = 1.0 + 0.8 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 400)); + layers.push(new ScatterplotLayer({ + id: 'hotspots-pulse', + data: highHotspots, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => { + const score = d.escalationScore || 1; + return 10000 + score * 5000; + }, + radiusScale: pulse, + radiusMinPixels: 6, + radiusMaxPixels: 30, + stroked: true, + filled: false, + getLineColor: (d) => { + const a = Math.round(120 * baseOpacity); + return d.hasBreaking ? [255, 50, 50, a] as [number, number, number, number] : [255, 165, 0, a] as [number, number, number, number]; + }, + lineWidthMinPixels: 1.5, + pickable: false, + updateTriggers: { radiusScale: this.pulseTime }, + })); + + } + + return layers; + } + + private createGulfInvestmentsLayer(): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'gulf-investments-layer', + data: GULF_INVESTMENTS, + getPosition: (d: GulfInvestment) => [d.lon, d.lat], + getRadius: (d: GulfInvestment) => { + if (!d.investmentUSD) return 20000; + if (d.investmentUSD >= 50000) return 70000; + if (d.investmentUSD >= 10000) return 55000; + if (d.investmentUSD >= 1000) return 40000; + return 25000; + }, + getFillColor: (d: GulfInvestment) => + d.investingCountry === 'SA' ? COLORS.gulfInvestmentSA : COLORS.gulfInvestmentUAE, + getLineColor: [255, 255, 255, 80] as [number, number, number, number], + lineWidthMinPixels: 1, + radiusMinPixels: 5, + radiusMaxPixels: 28, + pickable: true, + }); + } + + private pulseTime = 0; + + private canPulse(now = Date.now()): boolean { + return now - this.startupTime > 60_000; + } + + private hasRecentRiot(now = Date.now(), windowMs = 2 * 60 * 60 * 1000): boolean { + const hasRecentClusterRiot = this.protestClusters.some(c => + c.hasRiot && c.latestRiotEventTimeMs != null && (now - c.latestRiotEventTimeMs) < windowMs + ); + if (hasRecentClusterRiot) return true; + + // Fallback to raw protests because syncPulseAnimation can run before cluster data refreshes. + return this.protests.some((p) => { + if (p.eventType !== 'riot' || p.sourceType === 'gdelt') return false; + const ts = p.time.getTime(); + return Number.isFinite(ts) && (now - ts) < windowMs; + }); + } + + private needsPulseAnimation(now = Date.now()): boolean { + return this.hasRecentNews(now) + || this.hasRecentRiot(now) + || this.hotspots.some(h => h.hasBreaking); + } + + private syncPulseAnimation(now = Date.now()): void { + if (this.renderPaused) { + if (this.newsPulseIntervalId !== null) this.stopPulseAnimation(); + return; + } + const shouldPulse = this.canPulse(now) && this.needsPulseAnimation(now); + if (shouldPulse && this.newsPulseIntervalId === null) { + this.startPulseAnimation(); + } else if (!shouldPulse && this.newsPulseIntervalId !== null) { + this.stopPulseAnimation(); + } + } + + private startPulseAnimation(): void { + if (this.newsPulseIntervalId !== null) return; + const PULSE_UPDATE_INTERVAL_MS = 500; + + this.newsPulseIntervalId = setInterval(() => { + const now = Date.now(); + if (!this.needsPulseAnimation(now)) { + this.pulseTime = now; + this.stopPulseAnimation(); + this.rafUpdateLayers(); + return; + } + this.pulseTime = now; + this.rafUpdateLayers(); + }, PULSE_UPDATE_INTERVAL_MS); + } + + private stopPulseAnimation(): void { + if (this.newsPulseIntervalId !== null) { + clearInterval(this.newsPulseIntervalId); + this.newsPulseIntervalId = null; + } + } + + private createNewsLocationsLayer(): ScatterplotLayer[] { + const zoom = this.maplibreMap?.getZoom() || 2; + const alphaScale = zoom < 2.5 ? 0.4 : zoom < 4 ? 0.7 : 1.0; + const filteredNewsLocations = this.filterByTime(this.newsLocations, (location) => location.timestamp); + const THREAT_RGB: Record = { + critical: [239, 68, 68], + high: [249, 115, 22], + medium: [234, 179, 8], + low: [34, 197, 94], + info: [59, 130, 246], + }; + const THREAT_ALPHA: Record = { + critical: 220, + high: 190, + medium: 160, + low: 120, + info: 80, + }; + + const now = this.pulseTime || Date.now(); + const PULSE_DURATION = 30_000; + + const layers: ScatterplotLayer[] = [ + new ScatterplotLayer({ + id: 'news-locations-layer', + data: filteredNewsLocations, + getPosition: (d) => [d.lon, d.lat], + getRadius: 18000, + getFillColor: (d) => { + const rgb = THREAT_RGB[d.threatLevel] || [59, 130, 246]; + const a = Math.round((THREAT_ALPHA[d.threatLevel] || 120) * alphaScale); + return [...rgb, a] as [number, number, number, number]; + }, + radiusMinPixels: 3, + radiusMaxPixels: 12, + pickable: true, + }), + ]; + + const recentNews = filteredNewsLocations.filter(d => { + const firstSeen = this.newsLocationFirstSeen.get(d.title); + return firstSeen && (now - firstSeen) < PULSE_DURATION; + }); + + if (recentNews.length > 0) { + const pulse = 1.0 + 1.5 * (0.5 + 0.5 * Math.sin(now / 318)); + + layers.push(new ScatterplotLayer({ + id: 'news-pulse-layer', + data: recentNews, + getPosition: (d) => [d.lon, d.lat], + getRadius: 18000, + radiusScale: pulse, + radiusMinPixels: 6, + radiusMaxPixels: 30, + pickable: false, + stroked: true, + filled: false, + getLineColor: (d) => { + const rgb = THREAT_RGB[d.threatLevel] || [59, 130, 246]; + const firstSeen = this.newsLocationFirstSeen.get(d.title) || now; + const age = now - firstSeen; + const fadeOut = Math.max(0, 1 - age / PULSE_DURATION); + const a = Math.round(150 * fadeOut * alphaScale); + return [...rgb, a] as [number, number, number, number]; + }, + lineWidthMinPixels: 1.5, + updateTriggers: { pulseTime: now }, + })); + } + + return layers; + } + + private getTooltip(info: PickingInfo): { html: string } | null { + if (!info.object) return null; + + const rawLayerId = info.layer?.id || ''; + const layerId = rawLayerId.endsWith('-ghost') ? rawLayerId.slice(0, -6) : rawLayerId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = info.object as any; + const text = (value: unknown): string => escapeHtml(String(value ?? '')); + + switch (layerId) { + case 'hotspots-layer': + return { html: `
${text(obj.name)}
${text(obj.subtext)}
` }; + case 'earthquakes-layer': + return { html: `
M${(obj.magnitude || 0).toFixed(1)} ${t('components.deckgl.tooltip.earthquake')}
${text(obj.place)}
` }; + case 'military-vessels-layer': + return { html: `
${text(obj.name)}
${text(obj.operatorCountry)}
` }; + case 'military-flights-layer': + return { html: `
${text(obj.callsign || obj.registration || t('components.deckgl.tooltip.militaryAircraft'))}
${text(obj.type)}
` }; + case 'military-vessel-clusters-layer': + return { html: `
${text(obj.name || t('components.deckgl.tooltip.vesselCluster'))}
${obj.vesselCount || 0} ${t('components.deckgl.tooltip.vessels')}
${text(obj.activityType)}
` }; + case 'military-flight-clusters-layer': + return { html: `
${text(obj.name || t('components.deckgl.tooltip.flightCluster'))}
${obj.flightCount || 0} ${t('components.deckgl.tooltip.aircraft')}
${text(obj.activityType)}
` }; + case 'protests-layer': + return { html: `
${text(obj.title)}
${text(obj.country)}
` }; + case 'protest-clusters-layer': + if (obj.count === 1) { + const item = obj.items?.[0]; + return { html: `
${text(item?.title || t('components.deckgl.tooltip.protest'))}
${text(item?.city || item?.country || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.protestsCount', { count: String(obj.count) })}
${text(obj.country)}
` }; + case 'tech-hq-clusters-layer': + if (obj.count === 1) { + const hq = obj.items?.[0]; + return { html: `
${text(hq?.company || '')}
${text(hq?.city || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.techHQsCount', { count: String(obj.count) })}
${text(obj.city)}
` }; + case 'tech-event-clusters-layer': + if (obj.count === 1) { + const ev = obj.items?.[0]; + return { html: `
${text(ev?.title || '')}
${text(ev?.location || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.techEventsCount', { count: String(obj.count) })}
${text(obj.location)}
` }; + case 'datacenter-clusters-layer': + if (obj.count === 1) { + const dc = obj.items?.[0]; + return { html: `
${text(dc?.name || '')}
${text(dc?.owner || '')}
` }; + } + return { html: `
${t('components.deckgl.tooltip.dataCentersCount', { count: String(obj.count) })}
${text(obj.country)}
` }; + case 'bases-layer': + return { html: `
${text(obj.name)}
${text(obj.country)}
` }; + case 'nuclear-layer': + return { html: `
${text(obj.name)}
${text(obj.type)}
` }; + case 'datacenters-layer': + return { html: `
${text(obj.name)}
${text(obj.owner)}
` }; + case 'cables-layer': + return { html: `
${text(obj.name)}
${t('components.deckgl.tooltip.underseaCable')}
` }; + case 'pipelines-layer': { + const pipelineType = String(obj.type || '').toLowerCase(); + const pipelineTypeLabel = pipelineType === 'oil' + ? t('popups.pipeline.types.oil') + : pipelineType === 'gas' + ? t('popups.pipeline.types.gas') + : pipelineType === 'products' + ? t('popups.pipeline.types.products') + : `${text(obj.type)} ${t('components.deckgl.tooltip.pipeline')}`; + return { html: `
${text(obj.name)}
${pipelineTypeLabel}
` }; + } + case 'conflict-zones-layer': { + const props = obj.properties || obj; + return { html: `
${text(props.name)}
${t('components.deckgl.tooltip.conflictZone')}
` }; + } + case 'natural-events-layer': + return { html: `
${text(obj.title)}
${text(obj.category || t('components.deckgl.tooltip.naturalEvent'))}
` }; + case 'ais-density-layer': + return { html: `
${t('components.deckgl.layers.shipTraffic')}
${t('popups.intensity')}: ${text(obj.intensity)}
` }; + case 'waterways-layer': + return { html: `
${text(obj.name)}
${t('components.deckgl.layers.strategicWaterways')}
` }; + case 'economic-centers-layer': + return { html: `
${text(obj.name)}
${text(obj.country)}
` }; + case 'stock-exchanges-layer': + return { html: `
${text(obj.shortName)}
${text(obj.city)}, ${text(obj.country)}
` }; + case 'financial-centers-layer': + return { html: `
${text(obj.name)}
${text(obj.type)} ${t('components.deckgl.tooltip.financialCenter')}
` }; + case 'central-banks-layer': + return { html: `
${text(obj.shortName)}
${text(obj.city)}, ${text(obj.country)}
` }; + case 'commodity-hubs-layer': + return { html: `
${text(obj.name)}
${text(obj.type)} · ${text(obj.city)}
` }; + case 'startup-hubs-layer': + return { html: `
${text(obj.city)}
${text(obj.country)}
` }; + case 'tech-hqs-layer': + return { html: `
${text(obj.company)}
${text(obj.city)}
` }; + case 'accelerators-layer': + return { html: `
${text(obj.name)}
${text(obj.city)}
` }; + case 'cloud-regions-layer': + return { html: `
${text(obj.provider)}
${text(obj.region)}
` }; + case 'tech-events-layer': + return { html: `
${text(obj.title)}
${text(obj.location)}
` }; + case 'irradiators-layer': + return { html: `
${text(obj.name)}
${text(obj.type || t('components.deckgl.layers.gammaIrradiators'))}
` }; + case 'spaceports-layer': + return { html: `
${text(obj.name)}
${text(obj.country || t('components.deckgl.layers.spaceports'))}
` }; + case 'ports-layer': { + const typeIcon = obj.type === 'naval' ? '⚓' : obj.type === 'oil' || obj.type === 'lng' ? '🛢️' : '🏭'; + return { html: `
${typeIcon} ${text(obj.name)}
${text(obj.type || t('components.deckgl.tooltip.port'))} - ${text(obj.country)}
` }; + } + case 'flight-delays-layer': + return { html: `
${text(obj.airport)}
${text(obj.severity)}: ${text(obj.reason)}
` }; + case 'apt-groups-layer': + return { html: `
${text(obj.name)}
${text(obj.aka)}
${t('popups.sponsor')}: ${text(obj.sponsor)}
` }; + case 'minerals-layer': + return { html: `
${text(obj.name)}
${text(obj.mineral)} - ${text(obj.country)}
${text(obj.operator)}
` }; + case 'ais-disruptions-layer': + return { html: `
AIS ${text(obj.type || t('components.deckgl.tooltip.disruption'))}
${text(obj.severity)} ${t('popups.severity')}
${text(obj.description)}
` }; + case 'cable-advisories-layer': { + const cableName = UNDERSEA_CABLES.find(c => c.id === obj.cableId)?.name || obj.cableId; + return { html: `
${text(cableName)}
${text(obj.severity || t('components.deckgl.tooltip.advisory'))}
${text(obj.description)}
` }; + } + case 'repair-ships-layer': + return { html: `
${text(obj.name || t('components.deckgl.tooltip.repairShip'))}
${text(obj.status)}
` }; + case 'weather-layer': { + const areaDesc = typeof obj.areaDesc === 'string' ? obj.areaDesc : ''; + const area = areaDesc ? `
${text(areaDesc.slice(0, 50))}${areaDesc.length > 50 ? '...' : ''}` : ''; + return { html: `
${text(obj.event || t('components.deckgl.layers.weatherAlerts'))}
${text(obj.severity)}${area}
` }; + } + case 'outages-layer': + return { html: `
${text(obj.asn || t('components.deckgl.tooltip.internetOutage'))}
${text(obj.country)}
` }; + case 'cyber-threats-layer': + return { html: `
${t('popups.cyberThreat.title')}
${text(obj.severity || t('components.deckgl.tooltip.medium'))} · ${text(obj.country || t('popups.unknown'))}
` }; + case 'news-locations-layer': + return { html: `
📰 ${t('components.deckgl.tooltip.news')}
${text(obj.title?.slice(0, 80) || '')}
` }; + case 'gulf-investments-layer': { + const inv = obj as GulfInvestment; + const flag = inv.investingCountry === 'SA' ? '🇸🇦' : '🇦🇪'; + const usd = inv.investmentUSD != null + ? (inv.investmentUSD >= 1000 ? `$${(inv.investmentUSD / 1000).toFixed(1)}B` : `$${inv.investmentUSD}M`) + : t('components.deckgl.tooltip.undisclosed'); + const stake = inv.stakePercent != null ? `
${text(String(inv.stakePercent))}% ${t('components.deckgl.tooltip.stake')}` : ''; + return { + html: `
+ ${flag} ${text(inv.assetName)}
+ ${text(inv.investingEntity)}
+ ${text(inv.targetCountry)} · ${text(inv.sector)}
+ ${usd}${stake}
+ ${text(inv.status)} +
`, + }; + } + default: + return null; + } + } + + private handleClick(info: PickingInfo): void { + if (!info.object) { + // Empty map click → country detection + if (info.coordinate && this.onCountryClick) { + const [lon, lat] = info.coordinate as [number, number]; + const country = this.resolveCountryFromCoordinate(lon, lat); + this.onCountryClick({ + lat, + lon, + ...(country ? { code: country.code, name: country.name } : {}), + }); + } + return; + } + + const rawClickLayerId = info.layer?.id || ''; + const layerId = rawClickLayerId.endsWith('-ghost') ? rawClickLayerId.slice(0, -6) : rawClickLayerId; + + // Hotspots show popup with related news + if (layerId === 'hotspots-layer') { + const hotspot = info.object as Hotspot; + const relatedNews = this.getRelatedNews(hotspot); + this.popup.show({ + type: 'hotspot', + data: hotspot, + relatedNews, + x: info.x, + y: info.y, + }); + this.popup.loadHotspotGdeltContext(hotspot); + this.onHotspotClick?.(hotspot); + return; + } + + // Handle cluster layers with single/multi logic + if (layerId === 'protest-clusters-layer') { + const cluster = info.object as MapProtestCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'protest', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'protestCluster', + data: { + items: cluster.items, + country: cluster.country, + count: cluster.count, + riotCount: cluster.riotCount, + highSeverityCount: cluster.highSeverityCount, + verifiedCount: cluster.verifiedCount, + totalFatalities: cluster.totalFatalities, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + if (layerId === 'tech-hq-clusters-layer') { + const cluster = info.object as MapTechHQCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'techHQ', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'techHQCluster', + data: { + items: cluster.items, + city: cluster.city, + country: cluster.country, + count: cluster.count, + faangCount: cluster.faangCount, + unicornCount: cluster.unicornCount, + publicCount: cluster.publicCount, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + if (layerId === 'tech-event-clusters-layer') { + const cluster = info.object as MapTechEventCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'techEvent', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'techEventCluster', + data: { + items: cluster.items, + location: cluster.location, + country: cluster.country, + count: cluster.count, + soonCount: cluster.soonCount, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + if (layerId === 'datacenter-clusters-layer') { + const cluster = info.object as MapDatacenterCluster; + if (cluster.count === 1 && cluster.items[0]) { + this.popup.show({ type: 'datacenter', data: cluster.items[0], x: info.x, y: info.y }); + } else { + this.popup.show({ + type: 'datacenterCluster', + data: { + items: cluster.items, + region: cluster.region || cluster.country, + country: cluster.country, + count: cluster.count, + totalChips: cluster.totalChips, + totalPowerMW: cluster.totalPowerMW, + existingCount: cluster.existingCount, + plannedCount: cluster.plannedCount, + sampled: cluster.sampled, + }, + x: info.x, + y: info.y, + }); + } + return; + } + + // Map layer IDs to popup types + const layerToPopupType: Record = { + 'conflict-zones-layer': 'conflict', + 'bases-layer': 'base', + 'nuclear-layer': 'nuclear', + 'irradiators-layer': 'irradiator', + 'datacenters-layer': 'datacenter', + 'cables-layer': 'cable', + 'pipelines-layer': 'pipeline', + 'earthquakes-layer': 'earthquake', + 'weather-layer': 'weather', + 'outages-layer': 'outage', + 'cyber-threats-layer': 'cyberThreat', + 'protests-layer': 'protest', + 'military-flights-layer': 'militaryFlight', + 'military-vessels-layer': 'militaryVessel', + 'military-vessel-clusters-layer': 'militaryVesselCluster', + 'military-flight-clusters-layer': 'militaryFlightCluster', + 'natural-events-layer': 'natEvent', + 'waterways-layer': 'waterway', + 'economic-centers-layer': 'economic', + 'stock-exchanges-layer': 'stockExchange', + 'financial-centers-layer': 'financialCenter', + 'central-banks-layer': 'centralBank', + 'commodity-hubs-layer': 'commodityHub', + 'spaceports-layer': 'spaceport', + 'ports-layer': 'port', + 'flight-delays-layer': 'flight', + 'startup-hubs-layer': 'startupHub', + 'tech-hqs-layer': 'techHQ', + 'accelerators-layer': 'accelerator', + 'cloud-regions-layer': 'cloudRegion', + 'tech-events-layer': 'techEvent', + 'apt-groups-layer': 'apt', + 'minerals-layer': 'mineral', + 'ais-disruptions-layer': 'ais', + 'cable-advisories-layer': 'cable-advisory', + 'repair-ships-layer': 'repair-ship', + }; + + const popupType = layerToPopupType[layerId]; + if (!popupType) return; + + // For GeoJSON layers, the data is in properties + let data = info.object; + if (layerId === 'conflict-zones-layer' && info.object.properties) { + // Find the full conflict zone data from config + const conflictId = info.object.properties.id; + const fullConflict = CONFLICT_ZONES.find(c => c.id === conflictId); + if (fullConflict) data = fullConflict; + } + + // Get click coordinates relative to container + const x = info.x ?? 0; + const y = info.y ?? 0; + + this.popup.show({ + type: popupType, + data: data, + x, + y, + }); + } + + // Utility methods + private hexToRgba(hex: string, alpha: number): [number, number, number, number] { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result && result[1] && result[2] && result[3]) { + return [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16), + alpha, + ]; + } + return [100, 100, 100, alpha]; + } + + // UI Creation methods + private createControls(): void { + const controls = document.createElement('div'); + controls.className = 'map-controls deckgl-controls'; + controls.innerHTML = ` +
+ + + +
+
+ +
+ `; + + this.container.appendChild(controls); + + // Bind events - use event delegation for reliability + controls.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('zoom-in')) this.zoomIn(); + else if (target.classList.contains('zoom-out')) this.zoomOut(); + else if (target.classList.contains('zoom-reset')) this.resetView(); + }); + + const viewSelect = controls.querySelector('.view-select') as HTMLSelectElement; + viewSelect.value = this.state.view; + viewSelect.addEventListener('change', () => { + this.setView(viewSelect.value as DeckMapView); + }); + } + + private createTimeSlider(): void { + const slider = document.createElement('div'); + slider.className = 'time-slider deckgl-time-slider'; + slider.innerHTML = ` +
+ + + + + + +
+ `; + + this.container.appendChild(slider); + + slider.querySelectorAll('.time-btn').forEach(btn => { + btn.addEventListener('click', () => { + const range = (btn as HTMLElement).dataset.range as TimeRange; + this.setTimeRange(range); + }); + }); + } + + private updateTimeSliderButtons(): void { + const slider = this.container.querySelector('.deckgl-time-slider'); + if (!slider) return; + slider.querySelectorAll('.time-btn').forEach((btn) => { + const range = (btn as HTMLElement).dataset.range as TimeRange | undefined; + btn.classList.toggle('active', range === this.state.timeRange); + }); + } + + private createLayerToggles(): void { + const toggles = document.createElement('div'); + toggles.className = 'layer-toggles deckgl-layer-toggles'; + + const layerConfig = SITE_VARIANT === 'tech' + ? [ + { key: 'startupHubs', label: t('components.deckgl.layers.startupHubs'), icon: '🚀' }, + { key: 'techHQs', label: t('components.deckgl.layers.techHQs'), icon: '🏢' }, + { key: 'accelerators', label: t('components.deckgl.layers.accelerators'), icon: '⚡' }, + { key: 'cloudRegions', label: t('components.deckgl.layers.cloudRegions'), icon: '☁' }, + { key: 'datacenters', label: t('components.deckgl.layers.aiDataCenters'), icon: '🖥' }, + { key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '🔌' }, + { key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '📡' }, + { key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '🛡' }, + { key: 'techEvents', label: t('components.deckgl.layers.techEvents'), icon: '📅' }, + { key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' }, + { key: 'fires', label: t('components.deckgl.layers.fires'), icon: '🔥' }, + ] + : SITE_VARIANT === 'finance' + ? [ + { key: 'stockExchanges', label: t('components.deckgl.layers.stockExchanges'), icon: '🏛' }, + { key: 'financialCenters', label: t('components.deckgl.layers.financialCenters'), icon: '💰' }, + { key: 'centralBanks', label: t('components.deckgl.layers.centralBanks'), icon: '🏦' }, + { key: 'commodityHubs', label: t('components.deckgl.layers.commodityHubs'), icon: '📦' }, + { key: 'gulfInvestments', label: t('components.deckgl.layers.gulfInvestments'), icon: '🌐' }, + { key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '🔌' }, + { key: 'pipelines', label: t('components.deckgl.layers.pipelines'), icon: '🛢' }, + { key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '📡' }, + { key: 'weather', label: t('components.deckgl.layers.weatherAlerts'), icon: '⛈' }, + { key: 'economic', label: t('components.deckgl.layers.economicCenters'), icon: '💰' }, + { key: 'waterways', label: t('components.deckgl.layers.strategicWaterways'), icon: '⚓' }, + { key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' }, + { key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '🛡' }, + ] + : [ + { key: 'hotspots', label: t('components.deckgl.layers.intelHotspots'), icon: '🎯' }, + { key: 'conflicts', label: t('components.deckgl.layers.conflictZones'), icon: '⚔' }, + { key: 'bases', label: t('components.deckgl.layers.militaryBases'), icon: '🏛' }, + { key: 'nuclear', label: t('components.deckgl.layers.nuclearSites'), icon: '☢' }, + { key: 'irradiators', label: t('components.deckgl.layers.gammaIrradiators'), icon: '⚠' }, + { key: 'spaceports', label: t('components.deckgl.layers.spaceports'), icon: '🚀' }, + { key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '🔌' }, + { key: 'pipelines', label: t('components.deckgl.layers.pipelines'), icon: '🛢' }, + { key: 'datacenters', label: t('components.deckgl.layers.aiDataCenters'), icon: '🖥' }, + { key: 'military', label: t('components.deckgl.layers.militaryActivity'), icon: '✈' }, + { key: 'ais', label: t('components.deckgl.layers.shipTraffic'), icon: '🚢' }, + { key: 'flights', label: t('components.deckgl.layers.flightDelays'), icon: '✈' }, + { key: 'protests', label: t('components.deckgl.layers.protests'), icon: '📢' }, + { key: 'ucdpEvents', label: t('components.deckgl.layers.ucdpEvents'), icon: '⚔' }, + { key: 'displacement', label: t('components.deckgl.layers.displacementFlows'), icon: '👥' }, + { key: 'climate', label: t('components.deckgl.layers.climateAnomalies'), icon: '🌫' }, + { key: 'weather', label: t('components.deckgl.layers.weatherAlerts'), icon: '⛈' }, + { key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '📡' }, + { key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '🛡' }, + { key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' }, + { key: 'fires', label: t('components.deckgl.layers.fires'), icon: '🔥' }, + { key: 'waterways', label: t('components.deckgl.layers.strategicWaterways'), icon: '⚓' }, + { key: 'economic', label: t('components.deckgl.layers.economicCenters'), icon: '💰' }, + { key: 'minerals', label: t('components.deckgl.layers.criticalMinerals'), icon: '💎' }, + ]; + + toggles.innerHTML = ` +
+ ${t('components.deckgl.layersTitle')} + + +
+
+ ${layerConfig.map(({ key, label, icon }) => ` + + `).join('')} +
+ `; + + this.container.appendChild(toggles); + + // Bind toggle events + toggles.querySelectorAll('.layer-toggle input').forEach(input => { + input.addEventListener('change', () => { + const layer = (input as HTMLInputElement).closest('.layer-toggle')?.getAttribute('data-layer') as keyof MapLayers; + if (layer) { + this.state.layers[layer] = (input as HTMLInputElement).checked; + this.render(); + this.onLayerChange?.(layer, (input as HTMLInputElement).checked); + } + }); + }); + + // Help button + const helpBtn = toggles.querySelector('.layer-help-btn'); + helpBtn?.addEventListener('click', () => this.showLayerHelp()); + + // Collapse toggle + const collapseBtn = toggles.querySelector('.toggle-collapse'); + const toggleList = toggles.querySelector('.toggle-list'); + + // Manual scroll: intercept wheel, prevent map zoom, scroll the list ourselves + if (toggleList) { + toggles.addEventListener('wheel', (e) => { + e.stopPropagation(); + e.preventDefault(); + toggleList.scrollTop += e.deltaY; + }, { passive: false }); + toggles.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: false }); + } + collapseBtn?.addEventListener('click', () => { + toggleList?.classList.toggle('collapsed'); + if (collapseBtn) collapseBtn.innerHTML = toggleList?.classList.contains('collapsed') ? '▶' : '▼'; + }); + } + + /** Show layer help popup explaining each layer */ + private showLayerHelp(): void { + const existing = this.container.querySelector('.layer-help-popup'); + if (existing) { + existing.remove(); + return; + } + + const popup = document.createElement('div'); + popup.className = 'layer-help-popup'; + + const label = (layerKey: string): string => t(`components.deckgl.layers.${layerKey}`).toUpperCase(); + const staticLabel = (labelKey: string): string => t(`components.deckgl.layerHelp.labels.${labelKey}`).toUpperCase(); + const helpItem = (layerLabel: string, descriptionKey: string): string => + `
${layerLabel} ${t(`components.deckgl.layerHelp.descriptions.${descriptionKey}`)}
`; + const helpSection = (titleKey: string, items: string[], noteKey?: string): string => ` +
+
${t(`components.deckgl.layerHelp.sections.${titleKey}`)}
+ ${items.join('')} + ${noteKey ? `
${t(`components.deckgl.layerHelp.notes.${noteKey}`)}
` : ''} +
+ `; + const helpHeader = ` +
+ ${t('components.deckgl.layerHelp.title')} + +
+ `; + + const techHelpContent = ` + ${helpHeader} +
+ ${helpSection('techEcosystem', [ + helpItem(label('startupHubs'), 'techStartupHubs'), + helpItem(label('cloudRegions'), 'techCloudRegions'), + helpItem(label('techHQs'), 'techHQs'), + helpItem(label('accelerators'), 'techAccelerators'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCables'), + helpItem(label('aiDataCenters'), 'infraDatacenters'), + helpItem(label('internetOutages'), 'infraOutages'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsTech'), + helpItem(label('weatherAlerts'), 'weatherAlerts'), + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(staticLabel('countries'), 'countriesOverlay'), + ])} +
+ `; + + const financeHelpContent = ` + ${helpHeader} +
+ ${helpSection('financeCore', [ + helpItem(label('stockExchanges'), 'financeExchanges'), + helpItem(label('financialCenters'), 'financeCenters'), + helpItem(label('centralBanks'), 'financeCentralBanks'), + helpItem(label('commodityHubs'), 'financeCommodityHubs'), + ])} + ${helpSection('infrastructureRisk', [ + helpItem(label('underseaCables'), 'financeCables'), + helpItem(label('pipelines'), 'financePipelines'), + helpItem(label('internetOutages'), 'financeOutages'), + helpItem(label('cyberThreats'), 'financeCyberThreats'), + ])} + ${helpSection('macroContext', [ + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(label('strategicWaterways'), 'macroWaterways'), + helpItem(label('weatherAlerts'), 'weatherAlertsMarket'), + helpItem(label('naturalEvents'), 'naturalEventsMacro'), + ])} +
+ `; + + const fullHelpContent = ` + ${helpHeader} +
+ ${helpSection('timeFilter', [ + helpItem(staticLabel('timeRecent'), 'timeRecent'), + helpItem(staticLabel('timeExtended'), 'timeExtended'), + ], 'timeAffects')} + ${helpSection('geopolitical', [ + helpItem(label('conflictZones'), 'geoConflicts'), + helpItem(label('intelHotspots'), 'geoHotspots'), + helpItem(staticLabel('sanctions'), 'geoSanctions'), + helpItem(label('protests'), 'geoProtests'), + ])} + ${helpSection('militaryStrategic', [ + helpItem(label('militaryBases'), 'militaryBases'), + helpItem(label('nuclearSites'), 'militaryNuclear'), + helpItem(label('gammaIrradiators'), 'militaryIrradiators'), + helpItem(label('militaryActivity'), 'militaryActivity'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCablesFull'), + helpItem(label('pipelines'), 'infraPipelinesFull'), + helpItem(label('internetOutages'), 'infraOutages'), + helpItem(label('aiDataCenters'), 'infraDatacentersFull'), + ])} + ${helpSection('transport', [ + helpItem(staticLabel('shipping'), 'transportShipping'), + helpItem(label('flightDelays'), 'transportDelays'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsFull'), + helpItem(label('weatherAlerts'), 'weatherAlerts'), + helpItem(label('economicCenters'), 'economicCenters'), + ])} + ${helpSection('labels', [ + helpItem(staticLabel('countries'), 'countriesOverlay'), + helpItem(label('strategicWaterways'), 'waterwaysLabels'), + ])} +
+ `; + + popup.innerHTML = SITE_VARIANT === 'tech' + ? techHelpContent + : SITE_VARIANT === 'finance' + ? financeHelpContent + : fullHelpContent; + + popup.querySelector('.layer-help-close')?.addEventListener('click', () => popup.remove()); + + // Prevent scroll events from propagating to map + const content = popup.querySelector('.layer-help-content'); + if (content) { + content.addEventListener('wheel', (e) => e.stopPropagation(), { passive: false }); + content.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: false }); + } + + // Close on click outside + setTimeout(() => { + const closeHandler = (e: MouseEvent) => { + if (!popup.contains(e.target as Node)) { + popup.remove(); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 100); + + this.container.appendChild(popup); + } + + private createLegend(): void { + const legend = document.createElement('div'); + legend.className = 'map-legend deckgl-legend'; + + // SVG shapes for different marker types + const shapes = { + circle: (color: string) => ``, + triangle: (color: string) => ``, + square: (color: string) => ``, + hexagon: (color: string) => ``, + }; + + const isLight = getCurrentTheme() === 'light'; + const legendItems = SITE_VARIANT === 'tech' + ? [ + { shape: shapes.circle(isLight ? 'rgb(22, 163, 74)' : 'rgb(0, 255, 150)'), label: t('components.deckgl.legend.startupHub') }, + { shape: shapes.circle('rgb(100, 200, 255)'), label: t('components.deckgl.legend.techHQ') }, + { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 200, 0)'), label: t('components.deckgl.legend.accelerator') }, + { shape: shapes.circle('rgb(150, 100, 255)'), label: t('components.deckgl.legend.cloudRegion') }, + { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') }, + ] + : SITE_VARIANT === 'finance' + ? [ + { shape: shapes.circle('rgb(255, 215, 80)'), label: t('components.deckgl.legend.stockExchange') }, + { shape: shapes.circle('rgb(0, 220, 150)'), label: t('components.deckgl.legend.financialCenter') }, + { shape: shapes.hexagon('rgb(255, 210, 80)'), label: t('components.deckgl.legend.centralBank') }, + { shape: shapes.square('rgb(255, 150, 80)'), label: t('components.deckgl.legend.commodityHub') }, + { shape: shapes.triangle('rgb(80, 170, 255)'), label: t('components.deckgl.legend.waterway') }, + ] + : [ + { shape: shapes.circle('rgb(255, 68, 68)'), label: t('components.deckgl.legend.highAlert') }, + { shape: shapes.circle('rgb(255, 165, 0)'), label: t('components.deckgl.legend.elevated') }, + { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 255, 0)'), label: t('components.deckgl.legend.monitoring') }, + { shape: shapes.triangle('rgb(68, 136, 255)'), label: t('components.deckgl.legend.base') }, + { shape: shapes.hexagon(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 220, 0)'), label: t('components.deckgl.legend.nuclear') }, + { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') }, + ]; + + legend.innerHTML = ` + ${t('components.deckgl.legend.title')} + ${legendItems.map(({ shape, label }) => `${shape}${label}`).join('')} + `; + + this.container.appendChild(legend); + } + + // Public API methods (matching MapComponent interface) + public render(): void { + if (this.renderPaused) { + this.renderPending = true; + return; + } + if (this.renderScheduled) return; + this.renderScheduled = true; + + requestAnimationFrame(() => { + this.renderScheduled = false; + this.updateLayers(); + }); + } + + public setRenderPaused(paused: boolean): void { + if (this.renderPaused === paused) return; + this.renderPaused = paused; + if (paused) { + this.stopPulseAnimation(); + return; + } + + this.syncPulseAnimation(); + if (!paused && this.renderPending) { + this.renderPending = false; + this.render(); + } + } + + private updateLayers(): void { + if (this.renderPaused || this.webglLost) return; + const startTime = performance.now(); + if (this.deckOverlay) { + this.deckOverlay.setProps({ layers: this.buildLayers() }); + } + const elapsed = performance.now() - startTime; + if (import.meta.env.DEV && elapsed > 16) { + console.warn(`[DeckGLMap] updateLayers took ${elapsed.toFixed(2)}ms (>16ms budget)`); + } + } + + public setView(view: DeckMapView): void { + this.state.view = view; + const preset = VIEW_PRESETS[view]; + + if (this.maplibreMap) { + this.maplibreMap.flyTo({ + center: [preset.longitude, preset.latitude], + zoom: preset.zoom, + duration: 1000, + }); + } + + const viewSelect = this.container.querySelector('.view-select') as HTMLSelectElement; + if (viewSelect) viewSelect.value = view; + + this.onStateChange?.(this.state); + } + + public setZoom(zoom: number): void { + this.state.zoom = zoom; + if (this.maplibreMap) { + this.maplibreMap.setZoom(zoom); + } + } + + public setCenter(lat: number, lon: number, zoom?: number): void { + if (this.maplibreMap) { + this.maplibreMap.flyTo({ + center: [lon, lat], + ...(zoom != null && { zoom }), + duration: 500, + }); + } + } + + public getCenter(): { lat: number; lon: number } | null { + if (this.maplibreMap) { + const center = this.maplibreMap.getCenter(); + return { lat: center.lat, lon: center.lng }; + } + return null; + } + + public setTimeRange(range: TimeRange): void { + this.state.timeRange = range; + this.rebuildProtestSupercluster(); + this.onTimeRangeChange?.(range); + this.updateTimeSliderButtons(); + this.render(); // Debounced + } + + public getTimeRange(): TimeRange { + return this.state.timeRange; + } + + public setLayers(layers: MapLayers): void { + this.state.layers = layers; + this.render(); // Debounced + + // Update toggle checkboxes + Object.entries(layers).forEach(([key, value]) => { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${key}"] input`) as HTMLInputElement; + if (toggle) toggle.checked = value; + }); + } + + public getState(): DeckMapState { + return { ...this.state }; + } + + // Zoom controls - public for external access + public zoomIn(): void { + if (this.maplibreMap) { + this.maplibreMap.zoomIn(); + } + } + + public zoomOut(): void { + if (this.maplibreMap) { + this.maplibreMap.zoomOut(); + } + } + + private resetView(): void { + this.setView('global'); + } + + private createUcdpEventsLayer(events: UcdpGeoEvent[]): ScatterplotLayer { + return new ScatterplotLayer({ + id: 'ucdp-events-layer', + data: events, + getPosition: (d) => [d.longitude, d.latitude], + getRadius: (d) => Math.max(4000, Math.sqrt(d.deaths_best || 1) * 3000), + getFillColor: (d) => { + switch (d.type_of_violence) { + case 'state-based': return COLORS.ucdpStateBased; + case 'non-state': return COLORS.ucdpNonState; + case 'one-sided': return COLORS.ucdpOneSided; + default: return COLORS.ucdpStateBased; + } + }, + radiusMinPixels: 3, + radiusMaxPixels: 20, + pickable: false, + }); + } + + private createDisplacementArcsLayer(): ArcLayer { + const withCoords = this.displacementFlows.filter(f => f.originLat != null && f.asylumLat != null); + const top50 = withCoords.slice(0, 50); + const maxCount = Math.max(1, ...top50.map(f => f.refugees)); + return new ArcLayer({ + id: 'displacement-arcs-layer', + data: top50, + getSourcePosition: (d) => [d.originLon!, d.originLat!], + getTargetPosition: (d) => [d.asylumLon!, d.asylumLat!], + getSourceColor: getCurrentTheme() === 'light' ? [50, 80, 180, 220] : [100, 150, 255, 180], + getTargetColor: getCurrentTheme() === 'light' ? [20, 150, 100, 220] : [100, 255, 200, 180], + getWidth: (d) => Math.max(1, (d.refugees / maxCount) * 8), + widthMinPixels: 1, + widthMaxPixels: 8, + pickable: false, + }); + } + + private createClimateHeatmapLayer(): HeatmapLayer { + return new HeatmapLayer({ + id: 'climate-heatmap-layer', + data: this.climateAnomalies, + getPosition: (d) => [d.lon, d.lat], + getWeight: (d) => Math.abs(d.tempDelta) + Math.abs(d.precipDelta) * 0.1, + radiusPixels: 40, + intensity: 0.6, + threshold: 0.15, + opacity: 0.45, + colorRange: [ + [68, 136, 255], + [100, 200, 255], + [255, 255, 100], + [255, 200, 50], + [255, 100, 50], + [255, 50, 50], + ], + pickable: false, + }); + } + + // Data setters - all use render() for debouncing + public setEarthquakes(earthquakes: Earthquake[]): void { + this.earthquakes = earthquakes; + this.render(); + } + + public setWeatherAlerts(alerts: WeatherAlert[]): void { + this.weatherAlerts = alerts; + const withCentroid = alerts.filter(a => a.centroid && a.centroid.length === 2).length; + console.log(`[DeckGLMap] Weather alerts: ${alerts.length} total, ${withCentroid} with coordinates`); + this.render(); + } + + public setOutages(outages: InternetOutage[]): void { + this.outages = outages; + this.render(); + } + + public setCyberThreats(threats: CyberThreat[]): void { + this.cyberThreats = threats; + this.render(); + } + + public setAisData(disruptions: AisDisruptionEvent[], density: AisDensityZone[]): void { + this.aisDisruptions = disruptions; + this.aisDensity = density; + this.render(); + } + + public setCableActivity(advisories: CableAdvisory[], repairShips: RepairShip[]): void { + this.cableAdvisories = advisories; + this.repairShips = repairShips; + this.render(); + } + + public setProtests(events: SocialUnrestEvent[]): void { + this.protests = events; + this.rebuildProtestSupercluster(); + this.render(); + this.syncPulseAnimation(); + } + + public setFlightDelays(delays: AirportDelayAlert[]): void { + this.flightDelays = delays; + this.render(); + } + + public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { + this.militaryFlights = flights; + this.militaryFlightClusters = clusters; + this.render(); + } + + public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { + this.militaryVessels = vessels; + this.militaryVesselClusters = clusters; + this.render(); + } + + public setNaturalEvents(events: NaturalEvent[]): void { + this.naturalEvents = events; + this.render(); + } + + public setFires(fires: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }>): void { + this.firmsFireData = fires; + this.render(); + } + + public setTechEvents(events: TechEventMarker[]): void { + this.techEvents = events; + this.rebuildTechEventSupercluster(); + this.render(); + } + + public setUcdpEvents(events: UcdpGeoEvent[]): void { + this.ucdpEvents = events; + this.render(); + } + + public setDisplacementFlows(flows: DisplacementFlow[]): void { + this.displacementFlows = flows; + this.render(); + } + + public setClimateAnomalies(anomalies: ClimateAnomaly[]): void { + this.climateAnomalies = anomalies; + this.render(); + } + + public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { + const now = Date.now(); + for (const d of data) { + if (!this.newsLocationFirstSeen.has(d.title)) { + this.newsLocationFirstSeen.set(d.title, now); + } + } + for (const [key, ts] of this.newsLocationFirstSeen) { + if (now - ts > 60_000) this.newsLocationFirstSeen.delete(key); + } + this.newsLocations = data; + this.render(); + + this.syncPulseAnimation(now); + } + + public updateHotspotActivity(news: NewsItem[]): void { + this.news = news; // Store for related news lookup + + // Update hotspot "breaking" indicators based on recent news + const breakingKeywords = new Set(); + const recentNews = news.filter(n => + Date.now() - n.pubDate.getTime() < 2 * 60 * 60 * 1000 // Last 2 hours + ); + + // Count matches per hotspot for escalation tracking + const matchCounts = new Map(); + + recentNews.forEach(item => { + this.hotspots.forEach(hotspot => { + if (hotspot.keywords.some(kw => + item.title.toLowerCase().includes(kw.toLowerCase()) + )) { + breakingKeywords.add(hotspot.id); + matchCounts.set(hotspot.id, (matchCounts.get(hotspot.id) || 0) + 1); + } + }); + }); + + this.hotspots.forEach(h => { + h.hasBreaking = breakingKeywords.has(h.id); + const matchCount = matchCounts.get(h.id) || 0; + // Calculate a simple velocity metric (matches per hour normalized) + const velocity = matchCount > 0 ? matchCount / 2 : 0; // 2 hour window + updateHotspotEscalation(h.id, matchCount, h.hasBreaking || false, velocity); + }); + + this.render(); + this.syncPulseAnimation(); + } + + /** Get news items related to a hotspot by keyword matching */ + private getRelatedNews(hotspot: Hotspot): NewsItem[] { + // High-priority conflict keywords that indicate the news is really about another topic + const conflictTopics = ['gaza', 'ukraine', 'russia', 'israel', 'iran', 'china', 'taiwan', 'korea', 'syria']; + + return this.news + .map((item) => { + const titleLower = item.title.toLowerCase(); + const matchedKeywords = hotspot.keywords.filter((kw) => titleLower.includes(kw.toLowerCase())); + + if (matchedKeywords.length === 0) return null; + + // Check if this news mentions other hotspot conflict topics + const conflictMatches = conflictTopics.filter(t => + titleLower.includes(t) && !hotspot.keywords.some(k => k.toLowerCase().includes(t)) + ); + + // If article mentions a major conflict topic that isn't this hotspot, deprioritize heavily + if (conflictMatches.length > 0) { + // Only include if it ALSO has a strong local keyword (city name, agency) + const strongLocalMatch = matchedKeywords.some(kw => + kw.toLowerCase() === hotspot.name.toLowerCase() || + hotspot.agencies?.some(a => titleLower.includes(a.toLowerCase())) + ); + if (!strongLocalMatch) return null; + } + + // Score: more keyword matches = more relevant + const score = matchedKeywords.length; + return { item, score }; + }) + .filter((x): x is { item: NewsItem; score: number } => x !== null) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .map(x => x.item); + } + + public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void { + setMilitaryData(flights, vessels); + } + + public getHotspotDynamicScore(hotspotId: string) { + return getHotspotEscalation(hotspotId); + } + + /** Get military flight clusters for rendering/analysis */ + public getMilitaryFlightClusters(): MilitaryFlightCluster[] { + return this.militaryFlightClusters; + } + + /** Get military vessel clusters for rendering/analysis */ + public getMilitaryVesselClusters(): MilitaryVesselCluster[] { + return this.militaryVesselClusters; + } + + public highlightAssets(assets: RelatedAsset[] | null): void { + // Clear previous highlights + Object.values(this.highlightedAssets).forEach(set => set.clear()); + + if (assets) { + assets.forEach(asset => { + this.highlightedAssets[asset.type].add(asset.id); + }); + } + + this.render(); // Debounced + } + + public setOnHotspotClick(callback: (hotspot: Hotspot) => void): void { + this.onHotspotClick = callback; + } + + public setOnTimeRangeChange(callback: (range: TimeRange) => void): void { + this.onTimeRangeChange = callback; + } + + public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean) => void): void { + this.onLayerChange = callback; + } + + public setOnStateChange(callback: (state: DeckMapState) => void): void { + this.onStateChange = callback; + } + + public getHotspotLevels(): Record { + const levels: Record = {}; + this.hotspots.forEach(h => { + levels[h.name] = h.level || 'low'; + }); + return levels; + } + + public setHotspotLevels(levels: Record): void { + this.hotspots.forEach(h => { + if (levels[h.name]) { + h.level = levels[h.name] as 'low' | 'elevated' | 'high'; + } + }); + this.render(); // Debounced + } + + public initEscalationGetters(): void { + setCIIGetter(getCountryScore); + setGeoAlertGetter(getAlertsNearLocation); + } + + // UI visibility methods + public hideLayerToggle(layer: keyof MapLayers): void { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (toggle) (toggle as HTMLElement).style.display = 'none'; + } + + public setLayerLoading(layer: keyof MapLayers, loading: boolean): void { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (toggle) toggle.classList.toggle('loading', loading); + } + + public setLayerReady(layer: keyof MapLayers, hasData: boolean): void { + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (!toggle) return; + + toggle.classList.remove('loading'); + // Match old Map.ts behavior: set 'active' only when layer enabled AND has data + if (this.state.layers[layer] && hasData) { + toggle.classList.add('active'); + } else { + toggle.classList.remove('active'); + } + } + + public flashAssets(assetType: AssetType, ids: string[]): void { + // Temporarily highlight assets + ids.forEach(id => this.highlightedAssets[assetType].add(id)); + this.render(); + + setTimeout(() => { + ids.forEach(id => this.highlightedAssets[assetType].delete(id)); + this.render(); + }, 3000); + } + + // Enable layer programmatically + public enableLayer(layer: keyof MapLayers): void { + if (!this.state.layers[layer]) { + this.state.layers[layer] = true; + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"] input`) as HTMLInputElement; + if (toggle) toggle.checked = true; + this.render(); + this.onLayerChange?.(layer, true); + } + } + + // Toggle layer on/off programmatically + public toggleLayer(layer: keyof MapLayers): void { + console.log(`[DeckGLMap.toggleLayer] ${layer}: ${this.state.layers[layer]} -> ${!this.state.layers[layer]}`); + this.state.layers[layer] = !this.state.layers[layer]; + const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"] input`) as HTMLInputElement; + if (toggle) toggle.checked = this.state.layers[layer]; + this.render(); + this.onLayerChange?.(layer, this.state.layers[layer]); + } + + // Get center coordinates for programmatic popup positioning + private getContainerCenter(): { x: number; y: number } { + const rect = this.container.getBoundingClientRect(); + return { x: rect.width / 2, y: rect.height / 2 }; + } + + // Project lat/lon to screen coordinates without moving the map + private projectToScreen(lat: number, lon: number): { x: number; y: number } | null { + if (!this.maplibreMap) return null; + const point = this.maplibreMap.project([lon, lat]); + return { x: point.x, y: point.y }; + } + + // Trigger click methods - show popup at item location without moving the map + public triggerHotspotClick(id: string): void { + const hotspot = this.hotspots.find(h => h.id === id); + if (!hotspot) return; + + // Get screen position for popup + const screenPos = this.projectToScreen(hotspot.lat, hotspot.lon); + const { x, y } = screenPos || this.getContainerCenter(); + + // Get related news and show popup + const relatedNews = this.getRelatedNews(hotspot); + this.popup.show({ + type: 'hotspot', + data: hotspot, + relatedNews, + x, + y, + }); + this.popup.loadHotspotGdeltContext(hotspot); + this.onHotspotClick?.(hotspot); + } + + public triggerConflictClick(id: string): void { + const conflict = CONFLICT_ZONES.find(c => c.id === id); + if (conflict) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(conflict.center[1], conflict.center[0]); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'conflict', data: conflict, x, y }); + } + } + + public triggerBaseClick(id: string): void { + const base = MILITARY_BASES.find(b => b.id === id); + if (base) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(base.lat, base.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'base', data: base, x, y }); + } + } + + public triggerPipelineClick(id: string): void { + const pipeline = PIPELINES.find(p => p.id === id); + if (pipeline && pipeline.points.length > 0) { + const midIdx = Math.floor(pipeline.points.length / 2); + const midPoint = pipeline.points[midIdx]; + // Don't pan - show popup at projected screen position or center + const screenPos = midPoint ? this.projectToScreen(midPoint[1], midPoint[0]) : null; + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'pipeline', data: pipeline, x, y }); + } + } + + public triggerCableClick(id: string): void { + const cable = UNDERSEA_CABLES.find(c => c.id === id); + if (cable && cable.points.length > 0) { + const midIdx = Math.floor(cable.points.length / 2); + const midPoint = cable.points[midIdx]; + // Don't pan - show popup at projected screen position or center + const screenPos = midPoint ? this.projectToScreen(midPoint[1], midPoint[0]) : null; + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'cable', data: cable, x, y }); + } + } + + public triggerDatacenterClick(id: string): void { + const dc = AI_DATA_CENTERS.find(d => d.id === id); + if (dc) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(dc.lat, dc.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'datacenter', data: dc, x, y }); + } + } + + public triggerNuclearClick(id: string): void { + const facility = NUCLEAR_FACILITIES.find(n => n.id === id); + if (facility) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(facility.lat, facility.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'nuclear', data: facility, x, y }); + } + } + + public triggerIrradiatorClick(id: string): void { + const irradiator = GAMMA_IRRADIATORS.find(i => i.id === id); + if (irradiator) { + // Don't pan - show popup at projected screen position or center + const screenPos = this.projectToScreen(irradiator.lat, irradiator.lon); + const { x, y } = screenPos || this.getContainerCenter(); + this.popup.show({ type: 'irradiator', data: irradiator, x, y }); + } + } + + public flashLocation(lat: number, lon: number, durationMs = 2000): void { + // Don't pan - project coordinates to screen position + const screenPos = this.projectToScreen(lat, lon); + if (!screenPos) return; + + // Flash effect by temporarily adding a highlight at the location + const flashMarker = document.createElement('div'); + flashMarker.className = 'flash-location-marker'; + flashMarker.style.cssText = ` + position: absolute; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + border: 2px solid #fff; + animation: flash-pulse 0.5s ease-out infinite; + pointer-events: none; + z-index: 1000; + left: ${screenPos.x}px; + top: ${screenPos.y}px; + transform: translate(-50%, -50%); + `; + + // Add animation keyframes if not present + if (!document.getElementById('flash-animation-styles')) { + const style = document.createElement('style'); + style.id = 'flash-animation-styles'; + style.textContent = ` + @keyframes flash-pulse { + 0% { transform: translate(-50%, -50%) scale(1); opacity: 1; } + 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; } + } + `; + document.head.appendChild(style); + } + + const wrapper = this.container.querySelector('.deckgl-map-wrapper'); + if (wrapper) { + wrapper.appendChild(flashMarker); + setTimeout(() => flashMarker.remove(), durationMs); + } + } + + // --- Country click + highlight --- + + public setOnCountryClick(cb: (country: CountryClickPayload) => void): void { + this.onCountryClick = cb; + } + + private resolveCountryFromCoordinate(lon: number, lat: number): { code: string; name: string } | null { + const fromGeometry = getCountryAtCoordinates(lat, lon); + if (fromGeometry) return fromGeometry; + if (!this.maplibreMap || !this.countryGeoJsonLoaded) return null; + try { + const point = this.maplibreMap.project([lon, lat]); + const features = this.maplibreMap.queryRenderedFeatures(point, { layers: ['country-interactive'] }); + const properties = (features?.[0]?.properties ?? {}) as Record; + const code = typeof properties['ISO3166-1-Alpha-2'] === 'string' + ? properties['ISO3166-1-Alpha-2'].trim().toUpperCase() + : ''; + const name = typeof properties.name === 'string' + ? properties.name.trim() + : ''; + if (!code || !name) return null; + return { code, name }; + } catch { + return null; + } + } + + private loadCountryBoundaries(): void { + if (!this.maplibreMap || this.countryGeoJsonLoaded) return; + this.countryGeoJsonLoaded = true; + + getCountriesGeoJson() + .then((geojson) => { + if (!this.maplibreMap || !geojson) return; + this.maplibreMap.addSource('country-boundaries', { + type: 'geojson', + data: geojson, + }); + this.maplibreMap.addLayer({ + id: 'country-interactive', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0, + }, + }); + this.maplibreMap.addLayer({ + id: 'country-hover-fill', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.06, + }, + filter: ['==', ['get', 'name'], ''], + }); + this.maplibreMap.addLayer({ + id: 'country-highlight-fill', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.12, + }, + filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], + }); + this.maplibreMap.addLayer({ + id: 'country-highlight-border', + type: 'line', + source: 'country-boundaries', + paint: { + 'line-color': '#3b82f6', + 'line-width': 1.5, + 'line-opacity': 0.5, + }, + filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], + }); + + if (!this.countryHoverSetup) this.setupCountryHover(); + this.updateCountryLayerPaint(getCurrentTheme()); + if (this.highlightedCountryCode) this.highlightCountry(this.highlightedCountryCode); + console.log('[DeckGLMap] Country boundaries loaded'); + }) + .catch((err) => console.warn('[DeckGLMap] Failed to load country boundaries:', err)); + } + + private setupCountryHover(): void { + if (!this.maplibreMap || this.countryHoverSetup) return; + this.countryHoverSetup = true; + const map = this.maplibreMap; + let hoveredName: string | null = null; + + map.on('mousemove', (e) => { + if (!this.onCountryClick) return; + const features = map.queryRenderedFeatures(e.point, { layers: ['country-interactive'] }); + const name = features?.[0]?.properties?.name as string | undefined; + + if (name && name !== hoveredName) { + hoveredName = name; + map.setFilter('country-hover-fill', ['==', ['get', 'name'], name]); + map.getCanvas().style.cursor = 'pointer'; + } else if (!name && hoveredName) { + hoveredName = null; + map.setFilter('country-hover-fill', ['==', ['get', 'name'], '']); + map.getCanvas().style.cursor = ''; + } + }); + + map.on('mouseout', () => { + if (hoveredName) { + hoveredName = null; + map.setFilter('country-hover-fill', ['==', ['get', 'name'], '']); + map.getCanvas().style.cursor = ''; + } + }); + } + + public highlightCountry(code: string): void { + this.highlightedCountryCode = code; + if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; + const filter: maplibregl.FilterSpecification = ['==', ['get', 'ISO3166-1-Alpha-2'], code]; + try { + this.maplibreMap.setFilter('country-highlight-fill', filter); + this.maplibreMap.setFilter('country-highlight-border', filter); + } catch { /* layer not ready yet */ } + } + + public clearCountryHighlight(): void { + this.highlightedCountryCode = null; + if (!this.maplibreMap) return; + const noMatch: maplibregl.FilterSpecification = ['==', ['get', 'ISO3166-1-Alpha-2'], '']; + try { + this.maplibreMap.setFilter('country-highlight-fill', noMatch); + this.maplibreMap.setFilter('country-highlight-border', noMatch); + } catch { /* layer not ready */ } + } + + private switchBasemap(theme: 'dark' | 'light'): void { + if (!this.maplibreMap) return; + this.maplibreMap.setStyle(theme === 'light' ? LIGHT_STYLE : DARK_STYLE); + // setStyle() replaces all sources/layers — reset guard so country layers are re-added + this.countryGeoJsonLoaded = false; + this.maplibreMap.once('style.load', () => { + this.loadCountryBoundaries(); + }); + } + + private updateCountryLayerPaint(theme: 'dark' | 'light'): void { + if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; + const hoverOpacity = theme === 'light' ? 0.10 : 0.06; + const highlightOpacity = theme === 'light' ? 0.18 : 0.12; + try { + this.maplibreMap.setPaintProperty('country-hover-fill', 'fill-opacity', hoverOpacity); + this.maplibreMap.setPaintProperty('country-highlight-fill', 'fill-opacity', highlightOpacity); + } catch { /* layers may not be ready */ } + } + + public destroy(): void { + if (this.moveTimeoutId) { + clearTimeout(this.moveTimeoutId); + this.moveTimeoutId = null; + } + + this.stopPulseAnimation(); + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + + this.layerCache.clear(); + + this.deckOverlay?.finalize(); + this.maplibreMap?.remove(); + + this.container.innerHTML = ''; + } +} diff --git a/src/components/DisplacementPanel.ts b/src/components/DisplacementPanel.ts new file mode 100644 index 000000000..fc35f2de5 --- /dev/null +++ b/src/components/DisplacementPanel.ts @@ -0,0 +1,165 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import type { UnhcrSummary, CountryDisplacement } from '@/types'; +import { formatPopulation } from '@/services/unhcr'; +import { t } from '@/services/i18n'; + +type DisplacementTab = 'origins' | 'hosts'; + +export class DisplacementPanel extends Panel { + private data: UnhcrSummary | null = null; + private activeTab: DisplacementTab = 'origins'; + private onCountryClick?: (lat: number, lon: number) => void; + + constructor() { + super({ + id: 'displacement', + title: t('panels.displacement'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.displacement.infoTooltip'), + }); + this.showLoading(t('common.loadingDisplacement')); + } + + public setCountryClickHandler(handler: (lat: number, lon: number) => void): void { + this.onCountryClick = handler; + } + + public setData(data: UnhcrSummary): void { + this.data = data; + this.setCount(data.countries.length); + this.renderContent(); + } + + private renderContent(): void { + if (!this.data) return; + + const g = this.data.globalTotals; + + const stats = [ + { label: t('components.displacement.refugees'), value: formatPopulation(g.refugees), cls: 'disp-stat-refugees' }, + { label: t('components.displacement.asylumSeekers'), value: formatPopulation(g.asylumSeekers), cls: 'disp-stat-asylum' }, + { label: t('components.displacement.idps'), value: formatPopulation(g.idps), cls: 'disp-stat-idps' }, + { label: t('components.displacement.total'), value: formatPopulation(g.total), cls: 'disp-stat-total' }, + ]; + + const statsHtml = stats.map(s => + `
+ ${s.value} + ${s.label} +
` + ).join(''); + + const tabsHtml = ` +
+ + +
+ `; + + let countries: CountryDisplacement[]; + if (this.activeTab === 'origins') { + countries = [...this.data.countries] + .filter(c => c.refugees + c.asylumSeekers > 0) + .sort((a, b) => (b.refugees + b.asylumSeekers) - (a.refugees + a.asylumSeekers)); + } else { + countries = [...this.data.countries] + .filter(c => (c.hostTotal || 0) > 0) + .sort((a, b) => (b.hostTotal || 0) - (a.hostTotal || 0)); + } + + const displayed = countries.slice(0, 30); + let tableHtml: string; + + if (displayed.length === 0) { + tableHtml = `
${t('common.noDataShort')}
`; + } else { + const rows = displayed.map(c => { + const hostTotal = c.hostTotal || 0; + const count = this.activeTab === 'origins' ? c.refugees + c.asylumSeekers : hostTotal; + const total = this.activeTab === 'origins' ? c.totalDisplaced : hostTotal; + const badgeCls = total >= 1_000_000 ? 'disp-crisis' + : total >= 500_000 ? 'disp-high' + : total >= 100_000 ? 'disp-elevated' + : ''; + const badgeLabel = total >= 1_000_000 ? t('components.displacement.badges.crisis') + : total >= 500_000 ? t('components.displacement.badges.high') + : total >= 100_000 ? t('components.displacement.badges.elevated') + : ''; + const badgeHtml = badgeLabel + ? `${badgeLabel}` + : ''; + + return ` + ${escapeHtml(c.name)} + ${badgeHtml} + ${formatPopulation(count)} + `; + }).join(''); + + tableHtml = ` + + + + + + + + + ${rows} +
${t('components.displacement.country')}${t('components.displacement.status')}${t('components.displacement.count')}
`; + } + + this.setContent(` +
+
${statsHtml}
+ ${tabsHtml} + ${tableHtml} +
+ + `); + + this.content.querySelectorAll('.disp-tab').forEach(btn => { + btn.addEventListener('click', () => { + this.activeTab = (btn as HTMLElement).dataset.tab as DisplacementTab; + this.renderContent(); + }); + }); + + this.content.querySelectorAll('.disp-row').forEach(el => { + el.addEventListener('click', () => { + const lat = Number((el as HTMLElement).dataset.lat); + const lon = Number((el as HTMLElement).dataset.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) this.onCountryClick?.(lat, lon); + }); + }); + } +} diff --git a/src/components/DownloadBanner.ts b/src/components/DownloadBanner.ts new file mode 100644 index 000000000..442bcb0ec --- /dev/null +++ b/src/components/DownloadBanner.ts @@ -0,0 +1,188 @@ +import { isDesktopRuntime } from '@/services/runtime'; +import { t } from '@/services/i18n'; +import { isMobileDevice } from '@/utils'; + +const STORAGE_KEY = 'wm-download-banner-dismissed'; +const SHOW_DELAY_MS = 12_000; +let bannerScheduled = false; + +export function maybeShowDownloadBanner(): void { + if (bannerScheduled) return; + if (isDesktopRuntime()) return; + if (isMobileDevice()) return; + if (localStorage.getItem(STORAGE_KEY)) return; + + bannerScheduled = true; + setTimeout(() => { + if (localStorage.getItem(STORAGE_KEY)) return; + const panel = buildPanel(); + document.body.appendChild(panel); + requestAnimationFrame(() => { + requestAnimationFrame(() => panel.classList.add('wm-dl-show')); + }); + }, SHOW_DELAY_MS); +} + +function dismiss(panel: HTMLElement): void { + localStorage.setItem(STORAGE_KEY, '1'); + panel.classList.remove('wm-dl-show'); + panel.addEventListener('transitionend', () => panel.remove(), { once: true }); +} + +type Platform = 'macos-arm64' | 'macos-x64' | 'macos' | 'windows' | 'linux' | 'unknown'; + +function detectPlatform(): Platform { + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return 'windows'; + if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux'; + if (/Mac/i.test(ua)) { + // WebGL renderer can reveal Apple Silicon vs Intel GPU + try { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl') as WebGLRenderingContext | null; + if (gl) { + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + if (dbg) { + const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); + if (/Apple M/i.test(renderer)) return 'macos-arm64'; + if (/Intel/i.test(renderer)) return 'macos-x64'; + } + } + } catch { /* ignore */ } + // Can't determine architecture — show both Mac options + return 'macos'; + } + return 'unknown'; +} + +interface DlButton { cls: string; href: string; label: string } + +function allButtons(): DlButton[] { + return [ + { cls: 'mac', href: '/api/download?platform=macos-arm64', label: `\uF8FF ${t('modals.downloadBanner.macSilicon')}` }, + { cls: 'mac', href: '/api/download?platform=macos-x64', label: `\uF8FF ${t('modals.downloadBanner.macIntel')}` }, + { cls: 'win', href: '/api/download?platform=windows-exe', label: `\u229E ${t('modals.downloadBanner.windows')}` }, + { cls: 'linux', href: '/api/download?platform=linux-appimage', label: `\u{1F427} ${t('modals.downloadBanner.linux')}` }, + ]; +} + +function buttonsForPlatform(p: Platform): DlButton[] { + const buttons = allButtons(); + switch (p) { + case 'macos-arm64': return buttons.filter(b => b.href.includes('macos-arm64')); + case 'macos-x64': return buttons.filter(b => b.href.includes('macos-x64')); + case 'macos': return buttons.filter(b => b.cls === 'mac'); + case 'windows': return buttons.filter(b => b.cls === 'win'); + case 'linux': return buttons.filter(b => b.cls === 'linux'); + default: return buttons; + } +} + +function renderButtons(container: HTMLElement, buttons: DlButton[], panel: HTMLElement): void { + container.innerHTML = buttons + .map(b => `${b.label}`) + .join(''); + container.querySelectorAll('.wm-dl-btn').forEach(btn => + btn.addEventListener('click', () => dismiss(panel)) + ); +} + +function buildPanel(): HTMLElement { + const platform = detectPlatform(); + const primaryButtons = buttonsForPlatform(platform); + const buttons = allButtons(); + const showToggle = platform !== 'unknown' && primaryButtons.length < buttons.length; + + const el = document.createElement('div'); + el.className = 'wm-dl-panel'; + el.innerHTML = ` + +
+
\u{1F5A5} ${t('modals.downloadBanner.title')}
+ +
+
${t('modals.downloadBanner.description')}
+
+ ${showToggle ? `` : ''} + `; + + const btnsContainer = el.querySelector('.wm-dl-btns') as HTMLElement; + renderButtons(btnsContainer, primaryButtons, el); + + el.querySelector('.wm-dl-close')!.addEventListener('click', () => dismiss(el)); + + const toggle = el.querySelector('.wm-dl-toggle'); + if (toggle) { + let showingAll = false; + toggle.addEventListener('click', () => { + showingAll = !showingAll; + renderButtons(btnsContainer, showingAll ? buttons : primaryButtons, el); + toggle.textContent = showingAll + ? t('modals.downloadBanner.showLess') + : t('modals.downloadBanner.showAllPlatforms'); + }); + } + + return el; +} diff --git a/src/components/ETFFlowsPanel.ts b/src/components/ETFFlowsPanel.ts new file mode 100644 index 000000000..54304f348 --- /dev/null +++ b/src/components/ETFFlowsPanel.ts @@ -0,0 +1,161 @@ +import { Panel } from './Panel'; +import { t } from '@/services/i18n'; +import { escapeHtml } from '@/utils/sanitize'; + +interface ETFData { + ticker: string; + issuer: string; + price: number; + priceChange: number; + volume: number; + avgVolume: number; + volumeRatio: number; + direction: 'inflow' | 'outflow' | 'neutral'; + estFlow: number; +} + +interface ETFFlowsResult { + timestamp: string; + summary: { + etfCount: number; + totalVolume: number; + totalEstFlow: number; + netDirection: string; + inflowCount: number; + outflowCount: number; + }; + etfs: ETFData[]; + unavailable?: boolean; +} + +function formatVolume(v: number): string { + if (Math.abs(v) >= 1e9) return `${(v / 1e9).toFixed(1)}B`; + if (Math.abs(v) >= 1e6) return `${(v / 1e6).toFixed(1)}M`; + if (Math.abs(v) >= 1e3) return `${(v / 1e3).toFixed(0)}K`; + return v.toLocaleString(); +} + +function flowClass(direction: string): string { + if (direction === 'inflow') return 'flow-inflow'; + if (direction === 'outflow') return 'flow-outflow'; + return 'flow-neutral'; +} + +function changeClass(val: number): string { + if (val > 0.1) return 'change-positive'; + if (val < -0.1) return 'change-negative'; + return 'change-neutral'; +} + +export class ETFFlowsPanel extends Panel { + private data: ETFFlowsResult | null = null; + private loading = true; + private error: string | null = null; + private refreshInterval: ReturnType | null = null; + + constructor() { + super({ id: 'etf-flows', title: t('panels.etfFlows'), showCount: false }); + void this.fetchData(); + this.refreshInterval = setInterval(() => this.fetchData(), 3 * 60000); + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + private async fetchData(): Promise { + try { + const res = await fetch('/api/etf-flows'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + this.data = await res.json(); + this.error = null; + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to fetch'; + } finally { + this.loading = false; + this.renderPanel(); + } + } + + private isUpstreamUnavailable(): boolean { + return this.data?.unavailable === true; + } + + private renderPanel(): void { + if (this.loading) { + this.showLoading(t('common.loadingEtfData')); + return; + } + + if (this.error || !this.data) { + this.showError(this.error || t('common.noDataShort')); + return; + } + + if (this.isUpstreamUnavailable()) { + this.showError(t('common.upstreamUnavailable')); + return; + } + + const d = this.data; + if (!d.etfs.length) { + this.setContent('
ETF data temporarily unavailable
'); + return; + } + + const s = d.summary; + const dirClass = s.netDirection.includes('INFLOW') ? 'flow-inflow' : s.netDirection.includes('OUTFLOW') ? 'flow-outflow' : 'flow-neutral'; + + const rows = d.etfs.map(etf => ` + + ${escapeHtml(etf.ticker)} + ${escapeHtml(etf.issuer)} + ${etf.direction === 'inflow' ? '+' : etf.direction === 'outflow' ? '-' : ''}$${formatVolume(Math.abs(etf.estFlow))} + ${formatVolume(etf.volume)} + ${etf.priceChange > 0 ? '+' : ''}${etf.priceChange.toFixed(2)}% + + `).join(''); + + const html = ` +
+
+
+ Net Flow + ${escapeHtml(s.netDirection)} +
+
+ Est. Flow + $${formatVolume(Math.abs(s.totalEstFlow))} +
+
+ Total Vol + ${formatVolume(s.totalVolume)} +
+
+ ETFs + ${s.inflowCount}↑ ${s.outflowCount}↓ +
+
+
+ + + + + + + + + + + ${rows} +
TickerIssuerEst. FlowVolumeChange
+
+
+ `; + + this.setContent(html); + } +} diff --git a/src/components/EconomicPanel.ts b/src/components/EconomicPanel.ts index 43bff0ea5..6f032029e 100644 --- a/src/components/EconomicPanel.ts +++ b/src/components/EconomicPanel.ts @@ -1,57 +1,127 @@ +import { Panel } from './Panel'; import type { FredSeries } from '@/services/fred'; +import { t } from '@/services/i18n'; +import type { OilAnalytics } from '@/services/oil-analytics'; +import type { SpendingSummary } from '@/services/usa-spending'; import { getChangeClass, formatChange } from '@/services/fred'; +import { formatOilValue, getTrendIndicator, getTrendColor } from '@/services/oil-analytics'; +import { formatAwardAmount, getAwardTypeIcon } from '@/services/usa-spending'; +import { escapeHtml } from '@/utils/sanitize'; -export class EconomicPanel { - private container: HTMLElement; - private data: FredSeries[] = []; - private isLoading = true; +type TabId = 'indicators' | 'oil' | 'spending'; + +export class EconomicPanel extends Panel { + private fredData: FredSeries[] = []; + private oilData: OilAnalytics | null = null; + private spendingData: SpendingSummary | null = null; private lastUpdate: Date | null = null; + private activeTab: TabId = 'indicators'; - constructor(container: HTMLElement) { - this.container = container; - this.render(); + constructor() { + super({ id: 'economic', title: t('panels.economic') }); } public update(data: FredSeries[]): void { - this.data = data; - this.isLoading = false; + this.fredData = data; this.lastUpdate = new Date(); this.render(); } - public setLoading(loading: boolean): void { - this.isLoading = loading; + public updateOil(data: OilAnalytics): void { + this.oilData = data; this.render(); } + public updateSpending(data: SpendingSummary): void { + this.spendingData = data; + this.render(); + } + + public setLoading(loading: boolean): void { + if (loading) { + this.showLoading(); + } + } + private render(): void { - if (this.isLoading) { - this.container.innerHTML = ` -
-
- ECONOMIC INDICATORS - FRED -
-
Loading economic data...
-
- `; - return; + const hasOil = this.oilData && (this.oilData.wtiPrice || this.oilData.brentPrice); + const hasSpending = this.spendingData && this.spendingData.awards.length > 0; + + // Build tabs HTML + const tabsHtml = ` +
+ + ${hasOil ? ` + + ` : ''} + ${hasSpending ? ` + + ` : ''} +
+ `; + + let contentHtml = ''; + + switch (this.activeTab) { + case 'indicators': + contentHtml = this.renderIndicators(); + break; + case 'oil': + contentHtml = this.renderOil(); + break; + case 'spending': + contentHtml = this.renderSpending(); + break; } - if (this.data.length === 0) { - this.container.innerHTML = ` -
-
- ECONOMIC INDICATORS - FRED -
-
No data available
-
- `; - return; + const updateTime = this.lastUpdate + ? this.lastUpdate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : ''; + + this.setContent(` + ${tabsHtml} +
+ ${contentHtml} +
+ + `); + + // Bind tab click events + this.content.querySelectorAll('.economic-tab').forEach(tab => { + tab.addEventListener('click', (e) => { + const tabId = (e.target as HTMLElement).dataset.tab as TabId; + if (tabId) { + this.activeTab = tabId; + this.render(); + } + }); + }); + } + + private getSourceLabel(): string { + switch (this.activeTab) { + case 'indicators': return 'FRED'; + case 'oil': return 'EIA'; + case 'spending': return 'USASpending.gov'; + } + } + + private renderIndicators(): string { + if (this.fredData.length === 0) { + return `
${t('components.economic.noIndicatorData')}
`; } - const indicatorsHtml = this.data.map(series => { + return ` +
+ ${this.fredData.map(series => { const changeClass = getChangeClass(series.change); const changeStr = formatChange(series.change, series.unit); const arrow = series.change !== null @@ -59,38 +129,92 @@ export class EconomicPanel { : ''; return ` -
-
- ${series.name} - ${series.id} -
-
- ${series.value !== null ? series.value : 'N/A'}${series.unit} - ${arrow} ${changeStr} -
-
${series.date}
-
- `; - }).join(''); +
+
+ ${escapeHtml(series.name)} + ${escapeHtml(series.id)} +
+
+ ${escapeHtml(String(series.value !== null ? series.value : 'N/A'))}${escapeHtml(series.unit)} + ${escapeHtml(arrow)} ${escapeHtml(changeStr)} +
+
${escapeHtml(series.date)}
+
+ `; + }).join('')} +
+ `; + } - const updateTime = this.lastUpdate - ? this.lastUpdate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : ''; + private renderOil(): string { + if (!this.oilData) { + return `
${t('components.economic.noOilDataRetry')}
`; + } - this.container.innerHTML = ` -
-
- ECONOMIC INDICATORS - FRED • ${updateTime} -
-
- ${indicatorsHtml} -
+ const metrics = [ + this.oilData.wtiPrice, + this.oilData.brentPrice, + this.oilData.usProduction, + this.oilData.usInventory, + ].filter(Boolean); + + if (metrics.length === 0) { + return `
${t('components.economic.noOilMetrics')}
`; + } + + return ` +
+ ${metrics.map(metric => { + if (!metric) return ''; + const trendIcon = getTrendIndicator(metric.trend); + const trendColor = getTrendColor(metric.trend, metric.name.includes('Production')); + + return ` +
+
+ ${escapeHtml(metric.name)} +
+
+ ${escapeHtml(formatOilValue(metric.current, metric.unit))} ${escapeHtml(metric.unit)} + + ${escapeHtml(trendIcon)} ${escapeHtml(String(metric.changePct > 0 ? '+' : ''))}${escapeHtml(String(metric.changePct))}% + +
+
${t('components.economic.vsPreviousWeek')}
+
+ `; + }).join('')}
`; } - public getElement(): HTMLElement { - return this.container; + private renderSpending(): string { + if (!this.spendingData || this.spendingData.awards.length === 0) { + return `
${t('components.economic.noSpending')}
`; + } + + const { awards, totalAmount, periodStart, periodEnd } = this.spendingData; + + return ` +
+
+ ${escapeHtml(formatAwardAmount(totalAmount))} ${t('components.economic.in')} ${escapeHtml(String(awards.length))} ${t('components.economic.awards')} + ${escapeHtml(periodStart)} – ${escapeHtml(periodEnd)} +
+
+
+ ${awards.slice(0, 8).map(award => ` +
+
+ ${escapeHtml(getAwardTypeIcon(award.awardType))} + ${escapeHtml(formatAwardAmount(award.amount))} +
+
${escapeHtml(award.recipientName)}
+
${escapeHtml(award.agency)}
+ ${award.description ? `
${escapeHtml(award.description.slice(0, 100))}${award.description.length > 100 ? '...' : ''}
` : ''} +
+ `).join('')} +
+ `; } } diff --git a/src/components/GdeltIntelPanel.ts b/src/components/GdeltIntelPanel.ts new file mode 100644 index 000000000..16cd5a980 --- /dev/null +++ b/src/components/GdeltIntelPanel.ts @@ -0,0 +1,114 @@ +import { Panel } from './Panel'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { + getIntelTopics, + fetchTopicIntelligence, + formatArticleDate, + extractDomain, + type GdeltArticle, + type IntelTopic, + type TopicIntelligence, +} from '@/services/gdelt-intel'; + +export class GdeltIntelPanel extends Panel { + private activeTopic: IntelTopic = getIntelTopics()[0]!; + private topicData = new Map(); + private tabsEl: HTMLElement | null = null; + + constructor() { + super({ + id: 'gdelt-intel', + title: t('panels.gdeltIntel'), + showCount: true, + trackActivity: true, + infoTooltip: t('components.gdeltIntel.infoTooltip'), + }); + this.createTabs(); + this.loadActiveTopic(); + } + + private createTabs(): void { + this.tabsEl = document.createElement('div'); + this.tabsEl.className = 'gdelt-intel-tabs'; + + getIntelTopics().forEach(topic => { + const tab = document.createElement('button'); + tab.className = `gdelt-intel-tab ${topic.id === this.activeTopic.id ? 'active' : ''}`; + tab.dataset.topicId = topic.id; + tab.title = topic.description; + tab.innerHTML = `${topic.icon}${escapeHtml(topic.name)}`; + + tab.addEventListener('click', () => this.selectTopic(topic)); + this.tabsEl!.appendChild(tab); + }); + + this.element.insertBefore(this.tabsEl, this.content); + } + + private selectTopic(topic: IntelTopic): void { + if (topic.id === this.activeTopic.id) return; + + this.activeTopic = topic; + + this.tabsEl?.querySelectorAll('.gdelt-intel-tab').forEach(tab => { + tab.classList.toggle('active', (tab as HTMLElement).dataset.topicId === topic.id); + }); + + const cached = this.topicData.get(topic.id); + if (cached && Date.now() - cached.fetchedAt.getTime() < 5 * 60 * 1000) { + this.renderArticles(cached.articles); + } else { + this.loadActiveTopic(); + } + } + + private async loadActiveTopic(): Promise { + this.showLoading(); + + try { + const data = await fetchTopicIntelligence(this.activeTopic); + this.topicData.set(this.activeTopic.id, data); + this.renderArticles(data.articles); + this.setCount(data.articles.length); + } catch (error) { + console.error('[GdeltIntelPanel] Load error:', error); + this.showError(t('common.failedIntelFeed')); + } + } + + private renderArticles(articles: GdeltArticle[]): void { + if (articles.length === 0) { + this.content.innerHTML = `
${t('components.gdelt.empty')}
`; + return; + } + + const html = articles.map(article => this.renderArticle(article)).join(''); + this.content.innerHTML = `
${html}
`; + } + + private renderArticle(article: GdeltArticle): string { + const domain = article.source || extractDomain(article.url); + const timeAgo = formatArticleDate(article.date); + const toneClass = article.tone ? (article.tone < -2 ? 'tone-negative' : article.tone > 2 ? 'tone-positive' : '') : ''; + + return ` + +
+ ${escapeHtml(domain)} + ${escapeHtml(timeAgo)} +
+
${escapeHtml(article.title)}
+
+ `; + } + + public async refresh(): Promise { + await this.loadActiveTopic(); + } + + public async refreshAll(): Promise { + this.topicData.clear(); + await this.loadActiveTopic(); + } +} diff --git a/src/components/GeoHubsPanel.ts b/src/components/GeoHubsPanel.ts new file mode 100644 index 000000000..0bc28772c --- /dev/null +++ b/src/components/GeoHubsPanel.ts @@ -0,0 +1,123 @@ +import { Panel } from './Panel'; +import type { GeoHubActivity } from '@/services/geo-activity'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { getCSSColor } from '@/utils'; + +const COUNTRY_FLAGS: Record = { + 'USA': '🇺🇸', 'Russia': '🇷🇺', 'China': '🇨🇳', 'UK': '🇬🇧', 'Belgium': '🇧🇪', + 'Israel': '🇮🇱', 'Iran': '🇮🇷', 'Ukraine': '🇺🇦', 'Taiwan': '🇹🇼', 'Japan': '🇯🇵', + 'South Korea': '🇰🇷', 'North Korea': '🇰🇵', 'India': '🇮🇳', 'Saudi Arabia': '🇸🇦', + 'Turkey': '🇹🇷', 'France': '🇫🇷', 'Germany': '🇩🇪', 'Egypt': '🇪🇬', 'Pakistan': '🇵🇰', + 'Palestine': '🇵🇸', 'Yemen': '🇾🇪', 'Syria': '🇸🇾', 'Lebanon': '🇱🇧', + 'Sudan': '🇸🇩', 'Ethiopia': '🇪🇹', 'Myanmar': '🇲🇲', 'Austria': '🇦🇹', + 'International': '🌐', +}; + +const TYPE_ICONS: Record = { + capital: '🏛️', + conflict: '⚔️', + strategic: '⚓', + organization: '🏢', +}; + +const TYPE_LABELS: Record = { + capital: 'Capital', + conflict: 'Conflict Zone', + strategic: 'Strategic', + organization: 'Organization', +}; + +export class GeoHubsPanel extends Panel { + private activities: GeoHubActivity[] = []; + private onHubClick?: (hub: GeoHubActivity) => void; + + constructor() { + super({ + id: 'geo-hubs', + title: t('panels.geoHubs'), + showCount: true, + infoTooltip: t('components.geoHubs.infoTooltip', { + highColor: getCSSColor('--semantic-critical'), + elevatedColor: getCSSColor('--semantic-high'), + lowColor: getCSSColor('--text-dim'), + }), + }); + } + + public setOnHubClick(handler: (hub: GeoHubActivity) => void): void { + this.onHubClick = handler; + } + + public setActivities(activities: GeoHubActivity[]): void { + this.activities = activities.slice(0, 10); + this.setCount(this.activities.length); + this.render(); + } + + private getFlag(country: string): string { + return COUNTRY_FLAGS[country] || '🌐'; + } + + private getTypeIcon(type: string): string { + return TYPE_ICONS[type] || '📍'; + } + + private getTypeLabel(type: string): string { + return TYPE_LABELS[type] || type; + } + + private render(): void { + if (this.activities.length === 0) { + this.showError(t('common.noActiveGeoHubs')); + return; + } + + const html = this.activities.map((hub, index) => { + const trendIcon = hub.trend === 'rising' ? '↑' : hub.trend === 'falling' ? '↓' : ''; + const breakingTag = hub.hasBreaking ? 'ALERT' : ''; + const topStory = hub.topStories[0]; + + return ` +
+
${index + 1}
+ +
+
+ ${escapeHtml(hub.name)} + ${this.getFlag(hub.country)} + ${breakingTag} +
+
+ ${hub.newsCount} ${hub.newsCount === 1 ? t('components.geoHubs.story') : t('components.geoHubs.stories')} + ${trendIcon ? `${trendIcon}` : ''} + ${this.getTypeIcon(hub.type)} ${this.getTypeLabel(hub.type)} +
+
+
${Math.round(hub.score)}
+
+ ${topStory ? ` + + ${escapeHtml(topStory.title.length > 80 ? topStory.title.slice(0, 77) + '...' : topStory.title)} + + ` : ''} + `; + }).join(''); + + this.setContent(html); + this.bindEvents(); + } + + private bindEvents(): void { + const items = this.content.querySelectorAll('.geo-hub-item'); + items.forEach((item) => { + item.addEventListener('click', () => { + const hubId = item.dataset.hubId; + const hub = this.activities.find(a => a.hubId === hubId); + if (hub && this.onHubClick) { + this.onHubClick(hub); + } + }); + }); + } +} diff --git a/src/components/InsightsPanel.ts b/src/components/InsightsPanel.ts new file mode 100644 index 000000000..18ca18b75 --- /dev/null +++ b/src/components/InsightsPanel.ts @@ -0,0 +1,633 @@ +import { Panel } from './Panel'; +import { mlWorker } from '@/services/ml-worker'; +import { generateSummary } from '@/services/summarization'; +import { parallelAnalysis, type AnalyzedHeadline } from '@/services/parallel-analysis'; +import { signalAggregator, logSignalSummary, type RegionalConvergence } from '@/services/signal-aggregator'; +import { focalPointDetector } from '@/services/focal-point-detector'; +import { ingestNewsForCII } from '@/services/country-instability'; +import { getTheaterPostureSummaries } from '@/services/military-surge'; +import { isMobileDevice } from '@/utils'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { SITE_VARIANT } from '@/config'; +import { getPersistentCache, setPersistentCache } from '@/services/persistent-cache'; +import { t } from '@/services/i18n'; +import type { ClusteredEvent, FocalPoint, MilitaryFlight } from '@/types'; + +export class InsightsPanel extends Panel { + private isHidden = false; + private lastBriefUpdate = 0; + private cachedBrief: string | null = null; + private lastMissedStories: AnalyzedHeadline[] = []; + private lastConvergenceZones: RegionalConvergence[] = []; + private lastFocalPoints: FocalPoint[] = []; + private lastMilitaryFlights: MilitaryFlight[] = []; + private static readonly BRIEF_COOLDOWN_MS = 120000; // 2 min cooldown (API has limits) + private static readonly BRIEF_CACHE_KEY = 'summary:world-brief'; + + constructor() { + super({ + id: 'insights', + title: t('panels.insights'), + showCount: false, + infoTooltip: t('components.insights.infoTooltip'), + }); + + if (isMobileDevice()) { + this.hide(); + this.isHidden = true; + } + } + + public setMilitaryFlights(flights: MilitaryFlight[]): void { + this.lastMilitaryFlights = flights; + } + + private getTheaterPostureContext(): string { + if (this.lastMilitaryFlights.length === 0) { + return ''; + } + + const postures = getTheaterPostureSummaries(this.lastMilitaryFlights); + const significant = postures.filter( + (p) => p.postureLevel === 'critical' || p.postureLevel === 'elevated' || p.strikeCapable + ); + + if (significant.length === 0) { + return ''; + } + + const lines = significant.map((p) => { + const parts: string[] = []; + parts.push(`${p.theaterName}: ${p.totalAircraft} aircraft`); + parts.push(`(${p.postureLevel.toUpperCase()})`); + if (p.strikeCapable) parts.push('STRIKE CAPABLE'); + parts.push(`- ${p.summary}`); + if (p.targetNation) parts.push(`Focus: ${p.targetNation}`); + return parts.join(' '); + }); + + return `\n\nCRITICAL MILITARY POSTURE:\n${lines.join('\n')}`; + } + + + private async loadBriefFromCache(): Promise { + if (this.cachedBrief) return false; + const entry = await getPersistentCache<{ summary: string }>(InsightsPanel.BRIEF_CACHE_KEY); + if (!entry?.data?.summary) return false; + this.cachedBrief = entry.data.summary; + this.lastBriefUpdate = entry.updatedAt; + return true; + } + // High-priority military/conflict keywords (huge boost) + private static readonly MILITARY_KEYWORDS = [ + 'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops', + 'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship', + 'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack', + ]; + + // Violence/casualty keywords (huge boost - human cost stories) + private static readonly VIOLENCE_KEYWORDS = [ + 'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter', + 'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution', + 'crackdown', 'violent', 'clashes', 'gunfire', 'shooting', + ]; + + // Civil unrest keywords (high boost) + private static readonly UNREST_KEYWORDS = [ + 'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots', + 'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow', + 'coup', 'martial law', 'curfew', 'shutdown', 'blackout', + ]; + + // Geopolitical flashpoints (major boost) + private static readonly FLASHPOINT_KEYWORDS = [ + 'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv', + 'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus', + 'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner', + ]; + + // Crisis keywords (moderate boost) + private static readonly CRISIS_KEYWORDS = [ + 'crisis', 'emergency', 'catastrophe', 'disaster', 'collapse', 'humanitarian', + 'sanctions', 'ultimatum', 'threat', 'retaliation', 'escalation', 'tensions', + 'breaking', 'urgent', 'developing', 'exclusive', + ]; + + // Business/tech context that should REDUCE score (demote business news with military words) + private static readonly DEMOTE_KEYWORDS = [ + 'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue', + 'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation', + ]; + + private getImportanceScore(cluster: ClusteredEvent): number { + let score = 0; + const titleLower = cluster.primaryTitle.toLowerCase(); + + // Source confirmation (base signal) + score += cluster.sourceCount * 10; + + // Violence/casualty keywords: highest priority (+100 base, +25 per match) + // "Pools of blood" type stories should always surface + const violenceMatches = InsightsPanel.VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (violenceMatches.length > 0) { + score += 100 + (violenceMatches.length * 25); + } + + // Military keywords: highest priority (+80 base, +20 per match) + const militaryMatches = InsightsPanel.MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (militaryMatches.length > 0) { + score += 80 + (militaryMatches.length * 20); + } + + // Civil unrest: high priority (+70 base, +18 per match) + const unrestMatches = InsightsPanel.UNREST_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (unrestMatches.length > 0) { + score += 70 + (unrestMatches.length * 18); + } + + // Flashpoint keywords: high priority (+60 base, +15 per match) + const flashpointMatches = InsightsPanel.FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (flashpointMatches.length > 0) { + score += 60 + (flashpointMatches.length * 15); + } + + // COMBO BONUS: Violence/unrest + flashpoint location = critical story + // e.g., "Iran protests" + "blood" = huge boost + if ((violenceMatches.length > 0 || unrestMatches.length > 0) && flashpointMatches.length > 0) { + score *= 1.5; // 50% bonus for flashpoint unrest + } + + // Crisis keywords: moderate priority (+30 base, +10 per match) + const crisisMatches = InsightsPanel.CRISIS_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (crisisMatches.length > 0) { + score += 30 + (crisisMatches.length * 10); + } + + // Demote business/tech news that happens to contain military words + const demoteMatches = InsightsPanel.DEMOTE_KEYWORDS.filter(kw => titleLower.includes(kw)); + if (demoteMatches.length > 0) { + score *= 0.3; // Heavy penalty for business context + } + + // Velocity multiplier + const velMultiplier: Record = { + 'viral': 3, + 'spike': 2.5, + 'elevated': 1.5, + 'normal': 1 + }; + score *= velMultiplier[cluster.velocity?.level ?? 'normal'] ?? 1; + + // Alert bonus + if (cluster.isAlert) score += 50; + + // Recency bonus (decay over 12 hours) + const ageMs = Date.now() - cluster.firstSeen.getTime(); + const ageHours = ageMs / 3600000; + const recencyMultiplier = Math.max(0.5, 1 - (ageHours / 12)); + score *= recencyMultiplier; + + return score; + } + + private selectTopStories(clusters: ClusteredEvent[], maxCount: number): ClusteredEvent[] { + // Score ALL clusters first - high-scoring stories override source requirements + const allScored = clusters + .map(c => ({ cluster: c, score: this.getImportanceScore(c) })); + + // Filter: require at least 2 sources OR alert OR elevated velocity OR high score + // High score (>100) means critical keywords were matched - don't require multi-source + const candidates = allScored.filter(({ cluster: c, score }) => + c.sourceCount >= 2 || + c.isAlert || + (c.velocity && c.velocity.level !== 'normal') || + score > 100 // Critical stories bypass source requirement + ); + + // Sort by score + const scored = candidates.sort((a, b) => b.score - a.score); + + // Select with source diversity (max 3 from same primary source) + const selected: ClusteredEvent[] = []; + const sourceCount = new Map(); + const MAX_PER_SOURCE = 3; + + for (const { cluster } of scored) { + const source = cluster.primarySource; + const count = sourceCount.get(source) || 0; + + if (count < MAX_PER_SOURCE) { + selected.push(cluster); + sourceCount.set(source, count + 1); + } + + if (selected.length >= maxCount) break; + } + + return selected; + } + + private setProgress(step: number, total: number, message: string): void { + const percent = Math.round((step / total) * 100); + this.setContent(` +
+
+
+
+
+ Step ${step}/${total} + ${message} +
+
+ `); + } + + public async updateInsights(clusters: ClusteredEvent[]): Promise { + if (this.isHidden) return; + + if (clusters.length === 0) { + this.setDataBadge('unavailable'); + this.setContent('
Waiting for news data...
'); + return; + } + + const totalSteps = 4; + + try { + // Step 1: Filter and rank stories by composite importance score + this.setProgress(1, totalSteps, 'Ranking important stories...'); + + const importantClusters = this.selectTopStories(clusters, 8); + + // Run parallel multi-perspective analysis in background (logs to console) + // This analyzes ALL clusters, not just the keyword-filtered ones + const parallelPromise = parallelAnalysis.analyzeHeadlines(clusters).then(report => { + this.lastMissedStories = report.missedByKeywords; + const suggestions = parallelAnalysis.getSuggestedImprovements(); + if (suggestions.length > 0) { + console.log('%c💡 Improvement Suggestions:', 'color: #f59e0b; font-weight: bold'); + suggestions.forEach(s => console.log(` • ${s}`)); + } + }).catch(err => { + console.warn('[ParallelAnalysis] Error:', err); + }); + + // Get geographic signal correlations (geopolitical variant only) + // Tech variant focuses on tech news, not military/protest signals + let signalSummary: ReturnType; + let focalSummary: ReturnType; + + if (SITE_VARIANT === 'full') { + signalSummary = signalAggregator.getSummary(); + this.lastConvergenceZones = signalSummary.convergenceZones; + if (signalSummary.totalSignals > 0) { + logSignalSummary(); + } + + // Run focal point detection (correlates news entities with map signals) + focalSummary = focalPointDetector.analyze(clusters, signalSummary); + this.lastFocalPoints = focalSummary.focalPoints; + if (focalSummary.focalPoints.length > 0) { + focalPointDetector.logSummary(); + // Ingest news for CII BEFORE signaling (so CII has data when it calculates) + ingestNewsForCII(clusters); + // Signal CII to refresh now that focal points AND news data are available + window.dispatchEvent(new CustomEvent('focal-points-ready')); + } + } else { + // Tech variant: no geopolitical signals, just summarize tech news + signalSummary = { + timestamp: new Date(), + totalSignals: 0, + byType: {} as Record, + convergenceZones: [], + topCountries: [], + aiContext: '', + }; + focalSummary = { + focalPoints: [], + aiContext: '', + timestamp: new Date(), + topCountries: [], + topCompanies: [], + }; + this.lastConvergenceZones = []; + this.lastFocalPoints = []; + } + + if (importantClusters.length === 0) { + this.setContent('
No breaking or multi-source stories yet
'); + return; + } + + const titles = importantClusters.map(c => c.primaryTitle); + + // Step 2: Analyze sentiment (browser-based, fast) + this.setProgress(2, totalSteps, 'Analyzing sentiment...'); + let sentiments: Array<{ label: string; score: number }> | null = null; + + if (mlWorker.isAvailable) { + sentiments = await mlWorker.classifySentiment(titles).catch(() => null); + } + + // Step 3: Generate World Brief (with cooldown) + const loadedFromPersistentCache = await this.loadBriefFromCache(); + let worldBrief = this.cachedBrief; + const now = Date.now(); + + let usedCachedBrief = loadedFromPersistentCache; + if (!worldBrief || now - this.lastBriefUpdate > InsightsPanel.BRIEF_COOLDOWN_MS) { + this.setProgress(3, totalSteps, 'Generating world brief...'); + + // Pass focal point context + theater posture to AI for correlation-aware summarization + // Tech variant: no geopolitical context, just tech news summarization + const theaterContext = SITE_VARIANT === 'full' ? this.getTheaterPostureContext() : ''; + const geoContext = SITE_VARIANT === 'full' + ? (focalSummary.aiContext || signalSummary.aiContext) + theaterContext + : ''; + const result = await generateSummary(titles, (_step, _total, msg) => { + // Show sub-progress for summarization + this.setProgress(3, totalSteps, `Generating brief: ${msg}`); + }, geoContext); + + if (result) { + worldBrief = result.summary; + this.cachedBrief = worldBrief; + this.lastBriefUpdate = now; + usedCachedBrief = false; + void setPersistentCache(InsightsPanel.BRIEF_CACHE_KEY, { summary: worldBrief }); + console.log(`[InsightsPanel] Brief generated${result.cached ? ' (cached)' : ''}${geoContext ? ' (with geo context)' : ''}`); + } + } else { + usedCachedBrief = true; + this.setProgress(3, totalSteps, 'Using cached brief...'); + } + + this.setDataBadge(worldBrief ? (usedCachedBrief ? 'cached' : 'live') : 'unavailable'); + + // Step 4: Wait for parallel analysis to complete + this.setProgress(4, totalSteps, 'Multi-perspective analysis...'); + await parallelPromise; + + this.renderInsights(importantClusters, sentiments, worldBrief); + } catch (error) { + console.error('[InsightsPanel] Error:', error); + this.setContent('
Analysis failed - retrying...
'); + } + } + + private renderInsights( + clusters: ClusteredEvent[], + sentiments: Array<{ label: string; score: number }> | null, + worldBrief: string | null + ): void { + const briefHtml = worldBrief ? this.renderWorldBrief(worldBrief) : ''; + const focalPointsHtml = this.renderFocalPoints(); + const convergenceHtml = this.renderConvergenceZones(); + const sentimentOverview = this.renderSentimentOverview(sentiments); + const breakingHtml = this.renderBreakingStories(clusters, sentiments); + const statsHtml = this.renderStats(clusters); + const missedHtml = this.renderMissedStories(); + + this.setContent(` + ${briefHtml} + ${focalPointsHtml} + ${convergenceHtml} + ${sentimentOverview} + ${statsHtml} +
+
BREAKING & CONFIRMED
+ ${breakingHtml} +
+ ${missedHtml} + `); + } + + private renderWorldBrief(brief: string): string { + return ` +
+
${SITE_VARIANT === 'tech' ? '🚀 TECH BRIEF' : '🌍 WORLD BRIEF'}
+
${escapeHtml(brief)}
+
+ `; + } + + private renderBreakingStories( + clusters: ClusteredEvent[], + sentiments: Array<{ label: string; score: number }> | null + ): string { + return clusters.map((cluster, i) => { + const sentiment = sentiments?.[i]; + const sentimentClass = sentiment?.label === 'negative' ? 'negative' : + sentiment?.label === 'positive' ? 'positive' : 'neutral'; + + const badges: string[] = []; + + if (cluster.sourceCount >= 3) { + badges.push(`✓ ${cluster.sourceCount} sources`); + } else if (cluster.sourceCount >= 2) { + badges.push(`${cluster.sourceCount} sources`); + } + + if (cluster.velocity && cluster.velocity.level !== 'normal') { + const velIcon = cluster.velocity.trend === 'rising' ? '↑' : ''; + badges.push(`${velIcon}+${cluster.velocity.sourcesPerHour}/hr`); + } + + if (cluster.isAlert) { + badges.push('⚠ ALERT'); + } + + return ` +
+
+ + ${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''} +
+ ${badges.length > 0 ? `
${badges.join('')}
` : ''} +
+ `; + }).join(''); + } + + private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string { + if (!sentiments || sentiments.length === 0) { + return ''; + } + + const negative = sentiments.filter(s => s.label === 'negative').length; + const positive = sentiments.filter(s => s.label === 'positive').length; + const neutral = sentiments.length - negative - positive; + + const total = sentiments.length; + const negPct = Math.round((negative / total) * 100); + const neuPct = Math.round((neutral / total) * 100); + const posPct = 100 - negPct - neuPct; + + let toneLabel = 'Mixed'; + let toneClass = 'neutral'; + if (negative > positive + neutral) { + toneLabel = 'Negative'; + toneClass = 'negative'; + } else if (positive > negative + neutral) { + toneLabel = 'Positive'; + toneClass = 'positive'; + } + + return ` +
+
+
+
+
+
+
+ ${negative} + ${neutral} + ${positive} +
+
Overall: ${toneLabel}
+
+ `; + } + + private renderStats(clusters: ClusteredEvent[]): string { + const multiSource = clusters.filter(c => c.sourceCount >= 2).length; + const fastMoving = clusters.filter(c => c.velocity && c.velocity.level !== 'normal').length; + const alerts = clusters.filter(c => c.isAlert).length; + + return ` +
+
+ ${multiSource} + Multi-source +
+
+ ${fastMoving} + Fast-moving +
+ ${alerts > 0 ? ` +
+ ${alerts} + Alerts +
+ ` : ''} +
+ `; + } + + private renderMissedStories(): string { + if (this.lastMissedStories.length === 0) { + return ''; + } + + const storiesHtml = this.lastMissedStories.slice(0, 3).map(story => { + const topPerspective = story.perspectives + .filter(p => p.name !== 'keywords') + .sort((a, b) => b.score - a.score)[0]; + + const perspectiveName = topPerspective?.name ?? 'ml'; + const perspectiveScore = topPerspective?.score ?? 0; + + return ` +
+
+ + ${escapeHtml(story.title.slice(0, 80))}${story.title.length > 80 ? '...' : ''} +
+
+ 🔬 ${perspectiveName}: ${(perspectiveScore * 100).toFixed(0)}% +
+
+ `; + }).join(''); + + return ` +
+
🎯 ML DETECTED
+ ${storiesHtml} +
+ `; + } + + private renderConvergenceZones(): string { + if (this.lastConvergenceZones.length === 0) { + return ''; + } + + const zonesHtml = this.lastConvergenceZones.slice(0, 3).map(zone => { + const signalIcons: Record = { + internet_outage: '🌐', + military_flight: '✈️', + military_vessel: '🚢', + protest: '🪧', + ais_disruption: '⚓', + }; + + const icons = zone.signalTypes.map(t => signalIcons[t] || '📍').join(''); + + return ` +
+
${icons} ${escapeHtml(zone.region)}
+
${escapeHtml(zone.description)}
+
${zone.signalTypes.length} signal types • ${zone.totalSignals} events
+
+ `; + }).join(''); + + return ` +
+
📍 GEOGRAPHIC CONVERGENCE
+ ${zonesHtml} +
+ `; + } + + private renderFocalPoints(): string { + // Only show focal points that have both news AND signals (true correlations) + const correlatedFPs = this.lastFocalPoints.filter( + fp => fp.newsMentions > 0 && fp.signalCount > 0 + ).slice(0, 5); + + if (correlatedFPs.length === 0) { + return ''; + } + + const signalIcons: Record = { + internet_outage: '🌐', + military_flight: '✈️', + military_vessel: '⚓', + protest: '📢', + ais_disruption: '🚢', + }; + + const focalPointsHtml = correlatedFPs.map(fp => { + const urgencyClass = fp.urgency; + const icons = fp.signalTypes.map(t => signalIcons[t] || '').join(' '); + const topHeadline = fp.topHeadlines[0]; + const headlineText = topHeadline?.title?.slice(0, 60) || ''; + const headlineUrl = sanitizeUrl(topHeadline?.url || ''); + + return ` +
+
+ ${escapeHtml(fp.displayName)} + ${fp.urgency.toUpperCase()} +
+
${icons}
+
+ ${fp.newsMentions} news • ${fp.signalCount} signals +
+ ${headlineText && headlineUrl ? `"${escapeHtml(headlineText)}..."` : ''} +
+ `; + }).join(''); + + return ` +
+
🎯 FOCAL POINTS
+ ${focalPointsHtml} +
+ `; + } +} diff --git a/src/components/IntelligenceGapBadge.ts b/src/components/IntelligenceGapBadge.ts new file mode 100644 index 000000000..0a700246a --- /dev/null +++ b/src/components/IntelligenceGapBadge.ts @@ -0,0 +1,489 @@ +import { getRecentSignals, type CorrelationSignal } from '@/services/correlation'; +import { getRecentAlerts, type UnifiedAlert } from '@/services/cross-module-integration'; +import { t } from '@/services/i18n'; +import { getSignalContext } from '@/utils/analysis-constants'; +import { escapeHtml } from '@/utils/sanitize'; + +const LOW_COUNT_THRESHOLD = 3; +const MAX_VISIBLE_FINDINGS = 10; +const SORT_TIME_TOLERANCE_MS = 60000; +const REFRESH_INTERVAL_MS = 10000; +const ALERT_HOURS = 6; +const STORAGE_KEY = 'worldmonitor-intel-findings'; + +type FindingSource = 'signal' | 'alert'; + +interface UnifiedFinding { + id: string; + source: FindingSource; + type: string; + title: string; + description: string; + confidence: number; + priority: 'critical' | 'high' | 'medium' | 'low'; + timestamp: Date; + original: CorrelationSignal | UnifiedAlert; +} + +export class IntelligenceFindingsBadge { + private badge: HTMLElement; + private dropdown: HTMLElement; + private isOpen = false; + private refreshInterval: ReturnType | null = null; + private lastFindingCount = 0; + private onSignalClick: ((signal: CorrelationSignal) => void) | null = null; + private onAlertClick: ((alert: UnifiedAlert) => void) | null = null; + private findings: UnifiedFinding[] = []; + private boundCloseDropdown = () => this.closeDropdown(); + private audio: HTMLAudioElement | null = null; + private audioEnabled = true; + private enabled: boolean; + private contextMenu: HTMLElement | null = null; + + constructor() { + this.enabled = IntelligenceFindingsBadge.getStoredEnabledState(); + + this.badge = document.createElement('button'); + this.badge.className = 'intel-findings-badge'; + this.badge.title = t('components.intelligenceFindings.badgeTitle'); + this.badge.innerHTML = '🎯0'; + + this.dropdown = document.createElement('div'); + this.dropdown.className = 'intel-findings-dropdown'; + + this.badge.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleDropdown(); + }); + + this.badge.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showContextMenu(e.clientX, e.clientY); + }); + + // Event delegation for finding items and "more" link + this.dropdown.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + // Handle "more findings" click - show all in modal + if (target.closest('.findings-more')) { + e.stopPropagation(); + this.showAllFindings(); + this.closeDropdown(); + return; + } + + // Handle individual finding click + const item = target.closest('.finding-item'); + if (!item) return; + e.stopPropagation(); + const id = item.getAttribute('data-finding-id'); + const finding = this.findings.find(f => f.id === id); + if (!finding) return; + + if (finding.source === 'signal' && this.onSignalClick) { + this.onSignalClick(finding.original as CorrelationSignal); + } else if (finding.source === 'alert' && this.onAlertClick) { + this.onAlertClick(finding.original as UnifiedAlert); + } + this.closeDropdown(); + }); + + if (this.enabled) { + document.addEventListener('click', this.boundCloseDropdown); + this.mount(); + this.initAudio(); + this.update(); + this.startRefresh(); + } + } + + private initAudio(): void { + this.audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleQYjfKapmWswEjCJvuPQfSoXZZ+3qqBJESSP0unGaxMJVYiytrFeLhR6p8znrFUXRW+bs7V3Qx1hn8Xjp1cYPnegprhkMCFmoLi1k0sZTYGlqqlUIA=='); + this.audio.volume = 0.3; + } + + private playSound(): void { + if (this.audioEnabled && this.audio) { + this.audio.currentTime = 0; + this.audio.play().catch(() => {}); + } + } + + public setOnSignalClick(handler: (signal: CorrelationSignal) => void): void { + this.onSignalClick = handler; + } + + public setOnAlertClick(handler: (alert: UnifiedAlert) => void): void { + this.onAlertClick = handler; + } + + public static getStoredEnabledState(): boolean { + return localStorage.getItem(STORAGE_KEY) !== 'hidden'; + } + + public isEnabled(): boolean { + return this.enabled; + } + + public setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + + if (enabled) { + localStorage.removeItem(STORAGE_KEY); + document.addEventListener('click', this.boundCloseDropdown); + this.mount(); + this.initAudio(); + this.update(); + this.startRefresh(); + } else { + localStorage.setItem(STORAGE_KEY, 'hidden'); + document.removeEventListener('click', this.boundCloseDropdown); + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + this.closeDropdown(); + this.dismissContextMenu(); + this.badge.remove(); + } + } + + private showContextMenu(x: number, y: number): void { + this.dismissContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'intel-findings-context-menu'; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + menu.innerHTML = '
Hide Intelligence Findings
'; + + menu.querySelector('.context-menu-item')!.addEventListener('click', (e) => { + e.stopPropagation(); + this.setEnabled(false); + this.dismissContextMenu(); + }); + + const dismiss = () => this.dismissContextMenu(); + document.addEventListener('click', dismiss, { once: true }); + + this.contextMenu = menu; + document.body.appendChild(menu); + } + + private dismissContextMenu(): void { + if (this.contextMenu) { + this.contextMenu.remove(); + this.contextMenu = null; + } + } + + private mount(): void { + const headerRight = document.querySelector('.header-right'); + if (headerRight) { + this.badge.appendChild(this.dropdown); + headerRight.insertBefore(this.badge, headerRight.firstChild); + } + } + + private startRefresh(): void { + this.refreshInterval = setInterval(() => this.update(), REFRESH_INTERVAL_MS); + } + + public update(): void { + this.findings = this.mergeFindings(); + const count = this.findings.length; + + const countEl = this.badge.querySelector('.findings-count'); + if (countEl) { + countEl.textContent = String(count); + } + + // Pulse animation and sound when new findings arrive + if (count > this.lastFindingCount && this.lastFindingCount > 0) { + this.badge.classList.add('pulse'); + setTimeout(() => this.badge.classList.remove('pulse'), 1000); + this.playSound(); + } + this.lastFindingCount = count; + + // Update badge status based on priority + const hasCritical = this.findings.some(f => f.priority === 'critical'); + const hasHigh = this.findings.some(f => f.priority === 'high' || f.confidence >= 0.7); + + this.badge.classList.remove('status-none', 'status-low', 'status-high'); + if (count === 0) { + this.badge.classList.add('status-none'); + this.badge.title = t('components.intelligenceFindings.none'); + } else if (hasCritical || hasHigh) { + this.badge.classList.add('status-high'); + this.badge.title = t('components.intelligenceFindings.reviewRecommended', { count: String(count) }); + } else if (count <= LOW_COUNT_THRESHOLD) { + this.badge.classList.add('status-low'); + this.badge.title = t('components.intelligenceFindings.count', { count: String(count) }); + } else { + this.badge.classList.add('status-high'); + this.badge.title = t('components.intelligenceFindings.reviewRecommended', { count: String(count) }); + } + + this.renderDropdown(); + } + + private mergeFindings(): UnifiedFinding[] { + const signals = getRecentSignals(); + const alerts = getRecentAlerts(ALERT_HOURS); + + const signalFindings: UnifiedFinding[] = signals.map(s => ({ + id: `signal-${s.id}`, + source: 'signal' as FindingSource, + type: s.type, + title: s.title, + description: s.description, + confidence: s.confidence, + priority: s.confidence >= 0.7 ? 'high' as const : s.confidence >= 0.5 ? 'medium' as const : 'low' as const, + timestamp: s.timestamp, + original: s, + })); + + const alertFindings: UnifiedFinding[] = alerts.map(a => ({ + id: `alert-${a.id}`, + source: 'alert' as FindingSource, + type: a.type, + title: a.title, + description: a.summary, + confidence: this.priorityToConfidence(a.priority), + priority: a.priority, + timestamp: a.timestamp, + original: a, + })); + + // Merge and sort by timestamp (newest first), then by priority + return [...signalFindings, ...alertFindings].sort((a, b) => { + const timeDiff = b.timestamp.getTime() - a.timestamp.getTime(); + if (Math.abs(timeDiff) < SORT_TIME_TOLERANCE_MS) { + return this.priorityScore(b.priority) - this.priorityScore(a.priority); + } + return timeDiff; + }); + } + + private priorityToConfidence(priority: string): number { + const map: Record = { critical: 95, high: 80, medium: 60, low: 40 }; + return map[priority] ?? 50; + } + + private priorityScore(priority: string): number { + const map: Record = { critical: 4, high: 3, medium: 2, low: 1 }; + return map[priority] ?? 0; + } + + private renderDropdown(): void { + if (this.findings.length === 0) { + this.dropdown.innerHTML = ` +
+ ${t('components.intelligenceFindings.title')} + ${t('components.intelligenceFindings.monitoring')} +
+
+
+ 📡 + ${t('components.intelligenceFindings.scanning')} +
+
+ `; + return; + } + + const criticalCount = this.findings.filter(f => f.priority === 'critical').length; + const highCount = this.findings.filter(f => f.priority === 'high' || f.confidence >= 70).length; + + let statusClass = 'moderate'; + let statusText = t('components.intelligenceFindings.detected', { count: String(this.findings.length) }); + if (criticalCount > 0) { + statusClass = 'critical'; + statusText = t('components.intelligenceFindings.critical', { count: String(criticalCount) }); + } else if (highCount > 0) { + statusClass = 'high'; + statusText = t('components.intelligenceFindings.highPriority', { count: String(highCount) }); + } + + const findingsHtml = this.findings.slice(0, MAX_VISIBLE_FINDINGS).map(finding => { + const timeAgo = this.formatTimeAgo(finding.timestamp); + const icon = this.getTypeIcon(finding.type); + const priorityClass = finding.priority; + const insight = this.getInsight(finding); + + return ` +
+
+ ${icon} ${escapeHtml(finding.title)} + ${t(`components.intelligenceFindings.priority.${finding.priority}`)} +
+
${escapeHtml(finding.description)}
+
+ ${escapeHtml(insight)} + ${timeAgo} +
+
+ `; + }).join(''); + + const moreCount = this.findings.length - MAX_VISIBLE_FINDINGS; + this.dropdown.innerHTML = ` +
+ ${t('components.intelligenceFindings.title')} + ${statusText} +
+
+
+ ${findingsHtml} +
+ ${moreCount > 0 ? `
${t('components.intelligenceFindings.more', { count: String(moreCount) })}
` : ''} +
+ `; + } + + private getInsight(finding: UnifiedFinding): string { + if (finding.source === 'signal') { + const context = getSignalContext((finding.original as CorrelationSignal).type); + return context.actionableInsight.split('.')[0] || ''; + } + // For alerts, provide actionable insight based on type and severity + const alert = finding.original as UnifiedAlert; + if (alert.type === 'cii_spike') { + const cii = alert.components.ciiChange; + if (cii && cii.change >= 30) return t('components.intelligenceFindings.insights.criticalDestabilization'); + if (cii && cii.change >= 20) return t('components.intelligenceFindings.insights.significantShift'); + return t('components.intelligenceFindings.insights.developingSituation'); + } + if (alert.type === 'convergence') return t('components.intelligenceFindings.insights.convergence'); + if (alert.type === 'cascade') return t('components.intelligenceFindings.insights.cascade'); + return t('components.intelligenceFindings.insights.review'); + } + + private getTypeIcon(type: string): string { + const icons: Record = { + // Correlation signals + breaking_surge: '🔥', + silent_divergence: '🔇', + flow_price_divergence: '📊', + explained_market_move: '💡', + prediction_leads_news: '🔮', + geo_convergence: '🌍', + hotspot_escalation: '⚠️', + news_leads_markets: '📰', + velocity_spike: '📈', + keyword_spike: '📊', + convergence: '🔀', + triangulation: '🔺', + flow_drop: '⬇️', + sector_cascade: '🌊', + // Unified alerts + cii_spike: '🔴', + cascade: '⚡', + composite: '🔗', + }; + return icons[type] || '📌'; + } + + private formatTimeAgo(date: Date): string { + const ms = Date.now() - date.getTime(); + if (ms < 60000) return t('components.intelligenceFindings.time.justNow'); + if (ms < 3600000) return t('components.intelligenceFindings.time.minutesAgo', { count: String(Math.floor(ms / 60000)) }); + if (ms < 86400000) return t('components.intelligenceFindings.time.hoursAgo', { count: String(Math.floor(ms / 3600000)) }); + return t('components.intelligenceFindings.time.daysAgo', { count: String(Math.floor(ms / 86400000)) }); + } + + private toggleDropdown(): void { + this.isOpen = !this.isOpen; + this.dropdown.classList.toggle('open', this.isOpen); + this.badge.classList.toggle('active', this.isOpen); + if (this.isOpen) { + this.update(); + } + } + + private closeDropdown(): void { + this.isOpen = false; + this.dropdown.classList.remove('open'); + this.badge.classList.remove('active'); + } + + private showAllFindings(): void { + // Create modal overlay + const overlay = document.createElement('div'); + overlay.className = 'findings-modal-overlay'; + + const findingsHtml = this.findings.map(finding => { + const timeAgo = this.formatTimeAgo(finding.timestamp); + const icon = this.getTypeIcon(finding.type); + const insight = this.getInsight(finding); + + return ` +
+
+ ${icon} ${escapeHtml(finding.title)} + ${t(`components.intelligenceFindings.priority.${finding.priority}`)} +
+
${escapeHtml(finding.description)}
+
+ ${escapeHtml(insight)} + ${timeAgo} +
+
+ `; + }).join(''); + + overlay.innerHTML = ` +
+
+ 🎯 ${t('components.intelligenceFindings.all', { count: String(this.findings.length) })} + +
+
+ ${findingsHtml} +
+
+ `; + + // Add click handlers + overlay.querySelector('.findings-modal-close')?.addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (e) => { + if ((e.target as HTMLElement).classList.contains('findings-modal-overlay')) { + overlay.remove(); + } + }); + + // Handle clicking individual items + overlay.querySelectorAll('.findings-modal-item').forEach(item => { + item.addEventListener('click', () => { + const id = item.getAttribute('data-finding-id'); + const finding = this.findings.find(f => f.id === id); + if (!finding) return; + + if (finding.source === 'signal' && this.onSignalClick) { + this.onSignalClick(finding.original as CorrelationSignal); + overlay.remove(); + } else if (finding.source === 'alert' && this.onAlertClick) { + this.onAlertClick(finding.original as UnifiedAlert); + overlay.remove(); + } + }); + }); + + document.body.appendChild(overlay); + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + document.removeEventListener('click', this.boundCloseDropdown); + this.badge.remove(); + } +} + +// Re-export with old name for backwards compatibility +export { IntelligenceFindingsBadge as IntelligenceGapBadge }; diff --git a/src/components/InvestmentsPanel.ts b/src/components/InvestmentsPanel.ts new file mode 100644 index 000000000..1342efcbe --- /dev/null +++ b/src/components/InvestmentsPanel.ts @@ -0,0 +1,229 @@ +import { Panel } from './Panel'; +import { GULF_INVESTMENTS } from '@/config/gulf-fdi'; +import type { + GulfInvestment, + GulfInvestmentSector, + GulfInvestorCountry, + GulfInvestingEntity, + GulfInvestmentStatus, +} from '@/types'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; + +interface InvestmentFilters { + investingCountry: GulfInvestorCountry | 'ALL'; + sector: GulfInvestmentSector | 'ALL'; + entity: GulfInvestingEntity | 'ALL'; + status: GulfInvestmentStatus | 'ALL'; + search: string; +} + +const SECTOR_LABELS: Record = { + ports: 'Ports', + pipelines: 'Pipelines', + energy: 'Energy', + datacenters: 'Data Centers', + airports: 'Airports', + railways: 'Railways', + telecoms: 'Telecoms', + water: 'Water', + logistics: 'Logistics', + mining: 'Mining', + 'real-estate': 'Real Estate', + manufacturing: 'Manufacturing', +}; + +const STATUS_COLORS: Record = { + 'operational': '#22c55e', + 'under-construction': '#f59e0b', + 'announced': '#60a5fa', + 'rumoured': '#a78bfa', + 'cancelled': '#ef4444', + 'divested': '#6b7280', +}; + +const FLAG: Record = { + SA: '🇸🇦', + UAE: '🇦🇪', +}; + +function formatUSD(usd?: number): string { + if (usd === undefined) return 'Undisclosed'; + if (usd >= 100000) return `$${(usd / 1000).toFixed(0)}B`; + if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}B`; + return `$${usd.toLocaleString()}M`; +} + +export class InvestmentsPanel extends Panel { + private filters: InvestmentFilters = { + investingCountry: 'ALL', + sector: 'ALL', + entity: 'ALL', + status: 'ALL', + search: '', + }; + private sortKey: keyof GulfInvestment = 'assetName'; + private sortAsc = true; + private onInvestmentClick?: (inv: GulfInvestment) => void; + + constructor(onInvestmentClick?: (inv: GulfInvestment) => void) { + super({ + id: 'gcc-investments', + title: t('panels.gccInvestments'), + showCount: true, + infoTooltip: t('components.investments.infoTooltip'), + }); + this.onInvestmentClick = onInvestmentClick; + this.render(); + } + + private getFiltered(): GulfInvestment[] { + const { investingCountry, sector, entity, status, search } = this.filters; + const q = search.toLowerCase(); + + return GULF_INVESTMENTS + .filter(inv => { + if (investingCountry !== 'ALL' && inv.investingCountry !== investingCountry) return false; + if (sector !== 'ALL' && inv.sector !== sector) return false; + if (entity !== 'ALL' && inv.investingEntity !== entity) return false; + if (status !== 'ALL' && inv.status !== status) return false; + if (q && !inv.assetName.toLowerCase().includes(q) + && !inv.targetCountry.toLowerCase().includes(q) + && !inv.description.toLowerCase().includes(q) + && !inv.investingEntity.toLowerCase().includes(q)) return false; + return true; + }) + .sort((a, b) => { + const key = this.sortKey; + const av = a[key] ?? ''; + const bv = b[key] ?? ''; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return this.sortAsc ? cmp : -cmp; + }); + } + + private render(): void { + const filtered = this.getFiltered(); + + // Build unique entity list for dropdown + const entities = Array.from(new Set(GULF_INVESTMENTS.map(i => i.investingEntity))).sort(); + const sectors = Array.from(new Set(GULF_INVESTMENTS.map(i => i.sector))).sort(); + + const sortArrow = (key: keyof GulfInvestment) => + this.sortKey === key ? (this.sortAsc ? ' ↑' : ' ↓') : ''; + + const rows = filtered.map(inv => { + const statusColor = STATUS_COLORS[inv.status] || '#6b7280'; + const flag = FLAG[inv.investingCountry] || ''; + const sector = SECTOR_LABELS[inv.sector] || inv.sector; + return ` + + + ${flag} + ${escapeHtml(inv.assetName)} +
${escapeHtml(inv.investingEntity)}
+ + ${escapeHtml(inv.targetCountry)} + ${escapeHtml(sector)} + ${escapeHtml(inv.status)} + ${escapeHtml(formatUSD(inv.investmentUSD))} + ${inv.yearAnnounced ?? inv.yearOperational ?? '—'} + `; + }).join(''); + + const html = ` +
+ + + + + +
+
+ + + + + + + + + + + + ${rows || ''} +
Asset${sortArrow('assetName')}Country${sortArrow('targetCountry')}Sector${sortArrow('sector')}Status${sortArrow('status')}Investment${sortArrow('investmentUSD')}Year${sortArrow('yearAnnounced')}
No investments match filters
+
`; + + this.setContent(html); + if (this.countEl) this.countEl.textContent = String(filtered.length); + + this.attachListeners(); + } + + private attachListeners(): void { + const content = this.content; + + // Search input + const searchEl = content.querySelector('.fdi-search'); + searchEl?.addEventListener('input', () => { + this.filters.search = searchEl.value; + this.render(); + }); + + // Filter dropdowns + content.querySelectorAll('.fdi-filter').forEach(sel => { + sel.addEventListener('change', () => { + const key = sel.dataset.filter as keyof InvestmentFilters; + (this.filters as unknown as Record)[key] = sel.value; + this.render(); + }); + }); + + // Sort headers + content.querySelectorAll('.fdi-sort').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.sort as keyof GulfInvestment; + if (this.sortKey === key) { + this.sortAsc = !this.sortAsc; + } else { + this.sortKey = key; + this.sortAsc = true; + } + this.render(); + }); + }); + + // Row click → fly to map + content.querySelectorAll('.fdi-row').forEach(row => { + row.addEventListener('click', () => { + const inv = GULF_INVESTMENTS.find(i => i.id === row.dataset.id); + if (inv && this.onInvestmentClick) { + this.onInvestmentClick(inv); + } + }); + }); + } +} diff --git a/src/components/LanguageSelector.ts b/src/components/LanguageSelector.ts new file mode 100644 index 000000000..bb508da02 --- /dev/null +++ b/src/components/LanguageSelector.ts @@ -0,0 +1,106 @@ +import { LANGUAGES, changeLanguage, getCurrentLanguage } from '../services/i18n'; + +export class LanguageSelector { + private element: HTMLElement; + private isOpen = false; + private currentLang: string; + + constructor() { + this.currentLang = getCurrentLanguage(); + this.element = document.createElement('div'); + this.element.className = 'custom-lang-selector'; + this.render(); + this.setupEventListeners(); + } + + public getElement(): HTMLElement { + return this.element; + } + + private getFlagUrl(langCode: string): string { + const map: Record = { + en: 'gb', + ar: 'sa', + zh: 'cn', + fr: 'fr', + de: 'de', + es: 'es', + it: 'it', + pl: 'pl', + pt: 'pt', + nl: 'nl', + sv: 'se', + ru: 'ru', + ja: 'jp' + }; + const countryCode = map[langCode] || langCode; + return `https://flagcdn.com/24x18/${countryCode}.png`; + } + + private render(): void { + const currentLangObj = LANGUAGES.find(l => l.code === this.currentLang) || LANGUAGES[0]; + + this.element.innerHTML = ` + + + `; + } + + private setupEventListeners(): void { + const btn = this.element.querySelector('.lang-selector-btn'); + const dropdown = this.element.querySelector('.lang-dropdown'); + + if (!btn || !dropdown) return; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggle(); + }); + + this.element.querySelectorAll('.lang-option').forEach(option => { + option.addEventListener('click', (e) => { + const code = (e.currentTarget as HTMLElement).dataset.code; + if (code && code !== this.currentLang) { + changeLanguage(code); + } + this.close(); + }); + }); + + document.addEventListener('click', (e) => { + if (!this.element.contains(e.target as Node)) { + this.close(); + } + }); + } + + private toggle(): void { + this.isOpen = !this.isOpen; + const dropdown = this.element.querySelector('.lang-dropdown'); + if (this.isOpen) { + dropdown?.classList.remove('hidden'); + this.element.classList.add('open'); + } else { + dropdown?.classList.add('hidden'); + this.element.classList.remove('open'); + } + } + + private close(): void { + this.isOpen = false; + const dropdown = this.element.querySelector('.lang-dropdown'); + dropdown?.classList.add('hidden'); + this.element.classList.remove('open'); + } +} diff --git a/src/components/LiveNewsPanel.ts b/src/components/LiveNewsPanel.ts new file mode 100644 index 000000000..c0c321a41 --- /dev/null +++ b/src/components/LiveNewsPanel.ts @@ -0,0 +1,702 @@ +import { Panel } from './Panel'; +import { fetchLiveVideoId } from '@/services/live-news'; +import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime'; +import { t } from '../services/i18n'; + +// YouTube IFrame Player API types +type YouTubePlayer = { + mute(): void; + unMute(): void; + playVideo(): void; + pauseVideo(): void; + loadVideoById(videoId: string): void; + cueVideoById(videoId: string): void; + getIframe?(): HTMLIFrameElement; + destroy(): void; +}; + +type YouTubePlayerConstructor = new ( + elementId: string | HTMLElement, + options: { + videoId: string; + host?: string; + playerVars: Record; + events: { + onReady: () => void; + onError?: (event: { data: number }) => void; + }; + }, +) => YouTubePlayer; + +type YouTubeNamespace = { + Player: YouTubePlayerConstructor; +}; + +declare global { + interface Window { + YT?: YouTubeNamespace; + onYouTubeIframeAPIReady?: () => void; + } +} + +interface LiveChannel { + id: string; + name: string; + handle: string; // YouTube channel handle (e.g., @bloomberg) + fallbackVideoId?: string; // Fallback if no live stream detected + videoId?: string; // Dynamically fetched live video ID + isLive?: boolean; + useFallbackOnly?: boolean; // Skip auto-detection, always use fallback +} + +const SITE_VARIANT = import.meta.env.VITE_VARIANT || 'full'; + +// Full variant: World news channels (24/7 live streams) +const FULL_LIVE_CHANNELS: LiveChannel[] = [ + { id: 'bloomberg', name: 'Bloomberg', handle: '@Bloomberg', fallbackVideoId: 'iEpJwprxDdk' }, + { id: 'sky', name: 'SkyNews', handle: '@SkyNews', fallbackVideoId: 'YDvsBbKfLPA' }, + { id: 'euronews', name: 'Euronews', handle: '@euabortnews', fallbackVideoId: 'pykpO5kQJ98' }, + { id: 'dw', name: 'DW', handle: '@DWNews', fallbackVideoId: 'LuKwFajn37U' }, + { id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' }, + { id: 'france24', name: 'France24', handle: '@FRANCE24English', fallbackVideoId: 'Ap-UM1O9RBU' }, + { id: 'alarabiya', name: 'AlArabiya', handle: '@AlArabiya', fallbackVideoId: 'n7eQejkXbnM', useFallbackOnly: true }, + { id: 'aljazeera', name: 'AlJazeera', handle: '@AlJazeeraEnglish', fallbackVideoId: 'gCNeDWCI0vo', useFallbackOnly: true }, +]; + +// Tech variant: Tech & business channels +const TECH_LIVE_CHANNELS: LiveChannel[] = [ + { id: 'bloomberg', name: 'Bloomberg', handle: '@Bloomberg', fallbackVideoId: 'iEpJwprxDdk' }, + { id: 'yahoo', name: 'Yahoo Finance', handle: '@YahooFinance', fallbackVideoId: 'KQp-e_XQnDE' }, + { id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' }, + { id: 'nasa', name: 'NASA TV', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true }, +]; + +const LIVE_CHANNELS = SITE_VARIANT === 'tech' ? TECH_LIVE_CHANNELS : FULL_LIVE_CHANNELS; + +export class LiveNewsPanel extends Panel { + private static apiPromise: Promise | null = null; + private activeChannel: LiveChannel = LIVE_CHANNELS[0]!; + private channelSwitcher: HTMLElement | null = null; + private isMuted = true; + private isPlaying = true; + private wasPlayingBeforeIdle = true; + private muteBtn: HTMLButtonElement | null = null; + private liveBtn: HTMLButtonElement | null = null; + private idleTimeout: ReturnType | null = null; + private readonly IDLE_PAUSE_MS = 5 * 60 * 1000; // 5 minutes + private boundVisibilityHandler!: () => void; + private boundIdleResetHandler!: () => void; + + // YouTube Player API state + private player: YouTubePlayer | null = null; + private playerContainer: HTMLDivElement | null = null; + private playerElement: HTMLDivElement | null = null; + private playerElementId: string; + private isPlayerReady = false; + private currentVideoId: string | null = null; + private readonly youtubeOrigin: string | null; + private forceFallbackVideoForNextInit = false; + + // Desktop fallback: embed via cloud bridge page to avoid YouTube 153. + // Starts false — try native JS API first; switches to true on Error 153. + private useDesktopEmbedProxy = false; + private desktopEmbedIframe: HTMLIFrameElement | null = null; + private desktopEmbedRenderToken = 0; + private boundMessageHandler!: (e: MessageEvent) => void; + + constructor() { + super({ id: 'live-news', title: t('panels.liveNews') }); + this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin(); + this.playerElementId = `live-news-player-${Date.now()}`; + this.element.classList.add('panel-wide'); + this.createLiveButton(); + this.createMuteButton(); + this.createChannelSwitcher(); + this.setupBridgeMessageListener(); + this.renderPlayer(); + this.setupIdleDetection(); + } + + private get embedOrigin(): string { + try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; } + } + + private setupBridgeMessageListener(): void { + this.boundMessageHandler = (e: MessageEvent) => { + if (e.source !== this.desktopEmbedIframe?.contentWindow) return; + const expected = this.embedOrigin; + if (e.origin !== expected && e.origin !== 'http://127.0.0.1:46123') return; + const msg = e.data; + if (!msg || typeof msg !== 'object' || !msg.type) return; + if (msg.type === 'yt-ready') { + this.isPlayerReady = true; + this.syncDesktopEmbedState(); + } else if (msg.type === 'yt-error') { + const code = Number(msg.code ?? 0); + if (code === 153 && this.activeChannel.fallbackVideoId && + this.activeChannel.videoId !== this.activeChannel.fallbackVideoId) { + this.activeChannel.videoId = this.activeChannel.fallbackVideoId; + this.renderDesktopEmbed(true); + } else { + this.showEmbedError(this.activeChannel, code); + } + } + }; + window.addEventListener('message', this.boundMessageHandler); + } + + private static resolveYouTubeOrigin(): string | null { + const fallbackOrigin = SITE_VARIANT === 'tech' + ? 'https://worldmonitor.app' + : 'https://worldmonitor.app'; + + try { + const { protocol, origin, host } = window.location; + if (protocol === 'http:' || protocol === 'https:') { + // Desktop webviews commonly run from tauri.localhost which can trigger + // YouTube embed restrictions. Use canonical public origin instead. + if (host === 'tauri.localhost' || host.endsWith('.tauri.localhost')) { + return fallbackOrigin; + } + return origin; + } + if (protocol === 'tauri:' || protocol === 'asset:') { + return fallbackOrigin; + } + } catch { + // Ignore invalid location values. + } + return fallbackOrigin; + } + + private setupIdleDetection(): void { + // Suspend idle timer when hidden, resume when visible + this.boundVisibilityHandler = () => { + if (document.hidden) { + // Suspend idle timer so background playback isn't killed + if (this.idleTimeout) clearTimeout(this.idleTimeout); + } else { + this.resumeFromIdle(); + this.boundIdleResetHandler(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + + // Track user activity to detect idle (pauses after 5 min inactivity) + this.boundIdleResetHandler = () => { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + this.idleTimeout = setTimeout(() => this.pauseForIdle(), this.IDLE_PAUSE_MS); + }; + + ['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => { + document.addEventListener(event, this.boundIdleResetHandler, { passive: true }); + }); + + // Start the idle timer + this.boundIdleResetHandler(); + } + + private pauseForIdle(): void { + if (this.isPlaying) { + this.wasPlayingBeforeIdle = true; + this.isPlaying = false; + this.updateLiveIndicator(); + } + this.destroyPlayer(); + } + + private destroyPlayer(): void { + if (this.player) { + this.player.destroy(); + this.player = null; + } + + this.desktopEmbedIframe = null; + this.desktopEmbedRenderToken += 1; + this.isPlayerReady = false; + this.currentVideoId = null; + + // Clear the container to remove player/iframe + if (this.playerContainer) { + this.playerContainer.innerHTML = ''; + + if (!this.useDesktopEmbedProxy) { + // Recreate player element for JS API mode + this.playerElement = document.createElement('div'); + this.playerElement.id = this.playerElementId; + this.playerContainer.appendChild(this.playerElement); + } else { + this.playerElement = null; + } + } + } + + private resumeFromIdle(): void { + if (this.wasPlayingBeforeIdle && !this.isPlaying) { + this.isPlaying = true; + this.updateLiveIndicator(); + void this.initializePlayer(); + } + } + + private createLiveButton(): void { + this.liveBtn = document.createElement('button'); + this.liveBtn.className = 'live-indicator-btn'; + this.liveBtn.title = 'Toggle playback'; + this.updateLiveIndicator(); + this.liveBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.togglePlayback(); + }); + + const header = this.element.querySelector('.panel-header'); + header?.appendChild(this.liveBtn); + } + + private updateLiveIndicator(): void { + if (!this.liveBtn) return; + this.liveBtn.innerHTML = this.isPlaying + ? 'Live' + : 'Paused'; + this.liveBtn.classList.toggle('paused', !this.isPlaying); + } + + private togglePlayback(): void { + this.isPlaying = !this.isPlaying; + this.wasPlayingBeforeIdle = this.isPlaying; + this.updateLiveIndicator(); + this.syncPlayerState(); + } + + private createMuteButton(): void { + this.muteBtn = document.createElement('button'); + this.muteBtn.className = 'live-mute-btn'; + this.muteBtn.title = 'Toggle sound'; + this.updateMuteIcon(); + this.muteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleMute(); + }); + + const header = this.element.querySelector('.panel-header'); + header?.appendChild(this.muteBtn); + } + + private updateMuteIcon(): void { + if (!this.muteBtn) return; + this.muteBtn.innerHTML = this.isMuted + ? '' + : ''; + this.muteBtn.classList.toggle('unmuted', !this.isMuted); + } + + private toggleMute(): void { + this.isMuted = !this.isMuted; + this.updateMuteIcon(); + this.syncPlayerState(); + } + + private createChannelSwitcher(): void { + this.channelSwitcher = document.createElement('div'); + this.channelSwitcher.className = 'live-news-switcher'; + + LIVE_CHANNELS.forEach(channel => { + const btn = document.createElement('button'); + btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`; + btn.dataset.channelId = channel.id; + btn.textContent = channel.name; + btn.addEventListener('click', () => this.switchChannel(channel)); + this.channelSwitcher!.appendChild(btn); + }); + + this.element.insertBefore(this.channelSwitcher, this.content); + } + + private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise { + const useFallbackVideo = channel.useFallbackOnly || forceFallback; + const liveVideoId = useFallbackVideo ? null : await fetchLiveVideoId(channel.handle); + channel.videoId = liveVideoId || channel.fallbackVideoId; + channel.isLive = !!liveVideoId; + } + + private async switchChannel(channel: LiveChannel): Promise { + if (channel.id === this.activeChannel.id) return; + + this.activeChannel = channel; + + this.channelSwitcher?.querySelectorAll('.live-channel-btn').forEach(btn => { + const btnEl = btn as HTMLElement; + btnEl.classList.toggle('active', btnEl.dataset.channelId === channel.id); + if (btnEl.dataset.channelId === channel.id) { + btnEl.classList.add('loading'); + } + }); + + await this.resolveChannelVideo(channel); + + this.channelSwitcher?.querySelectorAll('.live-channel-btn').forEach(btn => { + const btnEl = btn as HTMLElement; + btnEl.classList.remove('loading'); + if (btnEl.dataset.channelId === channel.id && !channel.videoId) { + btnEl.classList.add('offline'); + } + }); + + if (!channel.videoId) { + this.showOfflineMessage(channel); + return; + } + + if (this.useDesktopEmbedProxy) { + this.renderDesktopEmbed(true); + return; + } + + if (!this.player) { + this.ensurePlayerContainer(); + void this.initializePlayer(); + return; + } + + this.syncPlayerState(); + } + + private showOfflineMessage(channel: LiveChannel): void { + this.content.innerHTML = ` +
+
📺
+
${channel.name} is not currently live
+ +
+ `; + } + + private showEmbedError(channel: LiveChannel, errorCode: number): void { + const watchUrl = channel.videoId + ? `https://www.youtube.com/watch?v=${encodeURIComponent(channel.videoId)}` + : `https://www.youtube.com/${channel.handle}`; + + this.content.innerHTML = ` +
+
!
+
${channel.name} cannot be embedded in this app (YouTube ${errorCode})
+ Open on YouTube +
+ `; + } + + private renderPlayer(): void { + this.ensurePlayerContainer(); + void this.initializePlayer(); + } + + private ensurePlayerContainer(): void { + this.content.innerHTML = ''; + this.playerContainer = document.createElement('div'); + this.playerContainer.className = 'live-news-player'; + + if (!this.useDesktopEmbedProxy) { + this.playerElement = document.createElement('div'); + this.playerElement.id = this.playerElementId; + this.playerContainer.appendChild(this.playerElement); + } else { + this.playerElement = null; + } + + this.content.appendChild(this.playerContainer); + } + + private buildDesktopEmbedPath(videoId: string, origin?: string): string { + const params = new URLSearchParams({ + videoId, + autoplay: this.isPlaying ? '1' : '0', + mute: this.isMuted ? '1' : '0', + }); + if (origin) params.set('origin', origin); + return `/api/youtube/embed?${params.toString()}`; + } + + + + private postToEmbed(msg: Record): void { + if (!this.desktopEmbedIframe?.contentWindow) return; + this.desktopEmbedIframe.contentWindow.postMessage(msg, this.embedOrigin); + } + + private syncDesktopEmbedState(): void { + this.postToEmbed({ type: this.isPlaying ? 'play' : 'pause' }); + this.postToEmbed({ type: this.isMuted ? 'mute' : 'unmute' }); + } + + private renderDesktopEmbed(force = false): void { + if (!this.useDesktopEmbedProxy) return; + void this.renderDesktopEmbedAsync(force); + } + + private async renderDesktopEmbedAsync(force = false): Promise { + const videoId = this.activeChannel.videoId; + if (!videoId) { + this.showOfflineMessage(this.activeChannel); + return; + } + + // Only recreate iframe when video ID changes (not for play/mute toggling). + if (!force && this.currentVideoId === videoId && this.desktopEmbedIframe) { + this.syncDesktopEmbedState(); + return; + } + + const renderToken = ++this.desktopEmbedRenderToken; + this.currentVideoId = videoId; + this.isPlayerReady = true; + + // Always recreate if container was removed from DOM (e.g. showEmbedError replaced content). + if (!this.playerContainer || !this.playerContainer.parentElement) { + this.ensurePlayerContainer(); + } + + if (!this.playerContainer) { + return; + } + + this.playerContainer.innerHTML = ''; + + // Always use cloud URL for iframe embeds — the local sidecar requires + // an Authorization header that iframe src requests cannot carry. + const remoteBase = getRemoteApiBaseUrl(); + const embedUrl = `${remoteBase}${this.buildDesktopEmbedPath(videoId)}`; + + if (renderToken !== this.desktopEmbedRenderToken) { + return; + } + + const iframe = document.createElement('iframe'); + iframe.className = 'live-news-embed-frame'; + iframe.src = embedUrl; + iframe.title = `${this.activeChannel.name} live feed`; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = '0'; + iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen'; + iframe.allowFullscreen = true; + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + iframe.setAttribute('loading', 'eager'); + + this.playerContainer.appendChild(iframe); + this.desktopEmbedIframe = iframe; + } + + private static loadYouTubeApi(): Promise { + if (LiveNewsPanel.apiPromise) return LiveNewsPanel.apiPromise; + + LiveNewsPanel.apiPromise = new Promise((resolve) => { + if (window.YT?.Player) { + resolve(); + return; + } + + const existingScript = document.querySelector( + 'script[data-youtube-iframe-api="true"]', + ); + + if (existingScript) { + if (window.YT?.Player) { + resolve(); + return; + } + const previousReady = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + previousReady?.(); + resolve(); + }; + return; + } + + const previousReady = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + previousReady?.(); + resolve(); + }; + + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.async = true; + script.dataset.youtubeIframeApi = 'true'; + script.onerror = () => { + console.warn('[LiveNews] YouTube IFrame API failed to load (ad blocker or network issue)'); + LiveNewsPanel.apiPromise = null; + script.remove(); + resolve(); + }; + document.head.appendChild(script); + }); + + return LiveNewsPanel.apiPromise; + } + + private async initializePlayer(): Promise { + if (!this.useDesktopEmbedProxy && this.player) return; + + const useFallbackVideo = this.activeChannel.useFallbackOnly || this.forceFallbackVideoForNextInit; + this.forceFallbackVideoForNextInit = false; + await this.resolveChannelVideo(this.activeChannel, useFallbackVideo); + + if (!this.activeChannel.videoId) { + this.showOfflineMessage(this.activeChannel); + return; + } + + if (this.useDesktopEmbedProxy) { + this.renderDesktopEmbed(true); + return; + } + + await LiveNewsPanel.loadYouTubeApi(); + if (this.player || !this.playerElement || !window.YT?.Player) return; + + this.player = new window.YT!.Player(this.playerElement, { + host: 'https://www.youtube-nocookie.com', + videoId: this.activeChannel.videoId, + playerVars: { + autoplay: this.isPlaying ? 1 : 0, + mute: this.isMuted ? 1 : 0, + rel: 0, + playsinline: 1, + enablejsapi: 1, + ...(this.youtubeOrigin + ? { + origin: this.youtubeOrigin, + widget_referrer: this.youtubeOrigin, + } + : {}), + }, + events: { + onReady: () => { + this.isPlayerReady = true; + this.currentVideoId = this.activeChannel.videoId || null; + const iframe = this.player?.getIframe?.(); + if (iframe) iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + this.syncPlayerState(); + }, + onError: (event) => { + const errorCode = Number(event?.data ?? 0); + + // Retry once with known fallback stream. + if ( + errorCode === 153 && + this.activeChannel.fallbackVideoId && + this.activeChannel.videoId !== this.activeChannel.fallbackVideoId + ) { + this.destroyPlayer(); + this.forceFallbackVideoForNextInit = true; + this.ensurePlayerContainer(); + void this.initializePlayer(); + return; + } + + // Desktop-specific last resort: switch to cloud bridge embed. + if (errorCode === 153 && isDesktopRuntime()) { + this.useDesktopEmbedProxy = true; + this.destroyPlayer(); + this.ensurePlayerContainer(); + this.renderDesktopEmbed(true); + return; + } + + this.destroyPlayer(); + this.showEmbedError(this.activeChannel, errorCode); + }, + }, + }); + } + + private syncPlayerState(): void { + if (this.useDesktopEmbedProxy) { + const videoId = this.activeChannel.videoId; + if (videoId && this.currentVideoId !== videoId) { + this.renderDesktopEmbed(true); + } else { + this.syncDesktopEmbedState(); + } + return; + } + + if (!this.player || !this.isPlayerReady) return; + + const videoId = this.activeChannel.videoId; + if (!videoId) return; + + // Handle channel switch + const isNewVideo = this.currentVideoId !== videoId; + if (isNewVideo) { + this.currentVideoId = videoId; + if (!this.playerElement || !document.getElementById(this.playerElementId)) { + this.ensurePlayerContainer(); + void this.initializePlayer(); + return; + } + if (this.isPlaying) { + this.player.loadVideoById(videoId); + } else { + this.player.cueVideoById(videoId); + } + } + + if (this.isMuted) { + this.player.mute?.(); + } else { + this.player.unMute?.(); + } + + if (this.isPlaying) { + if (isNewVideo) { + // WKWebView loses user gesture context after await. + // Pause then play after a delay — mimics the manual workaround. + this.player.pauseVideo(); + setTimeout(() => { + if (this.player && this.isPlaying) { + this.player.mute?.(); + this.player.playVideo?.(); + // Restore mute state after play starts + if (!this.isMuted) { + setTimeout(() => { this.player?.unMute?.(); }, 500); + } + } + }, 800); + } else { + this.player.playVideo?.(); + } + } else { + this.player.pauseVideo?.(); + } + } + + public refresh(): void { + this.syncPlayerState(); + } + + public destroy(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + window.removeEventListener('message', this.boundMessageHandler); + ['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => { + document.removeEventListener(event, this.boundIdleResetHandler); + }); + + if (this.player) { + this.player.destroy(); + this.player = null; + } + this.desktopEmbedIframe = null; + this.isPlayerReady = false; + this.playerContainer = null; + this.playerElement = null; + + super.destroy(); + } +} diff --git a/src/components/LiveWebcamsPanel.ts b/src/components/LiveWebcamsPanel.ts new file mode 100644 index 000000000..8e3fde4c1 --- /dev/null +++ b/src/components/LiveWebcamsPanel.ts @@ -0,0 +1,340 @@ +import { Panel } from './Panel'; +import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime'; +import { t } from '../services/i18n'; + +type WebcamRegion = 'middle-east' | 'europe' | 'asia' | 'americas'; + +interface WebcamFeed { + id: string; + city: string; + country: string; + region: WebcamRegion; + channelHandle: string; + fallbackVideoId: string; +} + +// Verified YouTube live stream IDs — validated Feb 2026 via title cross-check. +// IDs may rotate; update when stale. +const WEBCAM_FEEDS: WebcamFeed[] = [ + // Middle East — Jerusalem & Tehran adjacent (conflict hotspots) + { id: 'jerusalem', city: 'Jerusalem', country: 'Israel', region: 'middle-east', channelHandle: '@TheWesternWall', fallbackVideoId: 'UyduhBUpO7Q' }, + { id: 'tehran', city: 'Tehran', country: 'Iran', region: 'middle-east', channelHandle: '@IranHDCams', fallbackVideoId: '-zGuR1qVKrU' }, + { id: 'tel-aviv', city: 'Tel Aviv', country: 'Israel', region: 'middle-east', channelHandle: '@IsraelLiveCam', fallbackVideoId: '-VLcYT5QBrY' }, + { id: 'mecca', city: 'Mecca', country: 'Saudi Arabia', region: 'middle-east', channelHandle: '@MakkahLive', fallbackVideoId: 'DEcpmPUbkDQ' }, + // Europe + { id: 'kyiv', city: 'Kyiv', country: 'Ukraine', region: 'europe', channelHandle: '@DWNews', fallbackVideoId: '-Q7FuPINDjA' }, + { id: 'odessa', city: 'Odessa', country: 'Ukraine', region: 'europe', channelHandle: '@UkraineLiveCam', fallbackVideoId: 'e2gC37ILQmk' }, + { id: 'paris', city: 'Paris', country: 'France', region: 'europe', channelHandle: '@PalaisIena', fallbackVideoId: 'OzYp4NRZlwQ' }, + { id: 'st-petersburg', city: 'St. Petersburg', country: 'Russia', region: 'europe', channelHandle: '@SPBLiveCam', fallbackVideoId: 'CjtIYbmVfck' }, + { id: 'london', city: 'London', country: 'UK', region: 'europe', channelHandle: '@EarthCam', fallbackVideoId: 'Lxqcg1qt0XU' }, + // Americas + { id: 'washington', city: 'Washington DC', country: 'USA', region: 'americas', channelHandle: '@AxisCommunications', fallbackVideoId: '1wV9lLe14aU' }, + { id: 'new-york', city: 'New York', country: 'USA', region: 'americas', channelHandle: '@EarthCam', fallbackVideoId: '4qyZLflp-sI' }, + { id: 'los-angeles', city: 'Los Angeles', country: 'USA', region: 'americas', channelHandle: '@VeniceVHotel', fallbackVideoId: 'EO_1LWqsCNE' }, + { id: 'miami', city: 'Miami', country: 'USA', region: 'americas', channelHandle: '@FloridaLiveCams', fallbackVideoId: '5YCajRjvWCg' }, + // Asia-Pacific — Taipei first (strait hotspot), then Shanghai, Tokyo, Seoul + { id: 'taipei', city: 'Taipei', country: 'Taiwan', region: 'asia', channelHandle: '@JackyWuTaipei', fallbackVideoId: 'z_fY1pj1VBw' }, + { id: 'shanghai', city: 'Shanghai', country: 'China', region: 'asia', channelHandle: '@SkylineWebcams', fallbackVideoId: '76EwqI5XZIc' }, + { id: 'tokyo', city: 'Tokyo', country: 'Japan', region: 'asia', channelHandle: '@TokyoLiveCam4K', fallbackVideoId: '4pu9sF5Qssw' }, + { id: 'seoul', city: 'Seoul', country: 'South Korea', region: 'asia', channelHandle: '@UNvillage_live', fallbackVideoId: '-JhoMGoAfFc' }, + { id: 'sydney', city: 'Sydney', country: 'Australia', region: 'asia', channelHandle: '@WebcamSydney', fallbackVideoId: '7pcL-0Wo77U' }, +]; + +const MAX_GRID_CELLS = 4; + +type ViewMode = 'grid' | 'single'; +type RegionFilter = 'all' | WebcamRegion; + +export class LiveWebcamsPanel extends Panel { + private viewMode: ViewMode = 'grid'; + private regionFilter: RegionFilter = 'all'; + private activeFeed: WebcamFeed = WEBCAM_FEEDS[0]!; + private toolbar: HTMLElement | null = null; + private iframes: HTMLIFrameElement[] = []; + private observer: IntersectionObserver | null = null; + private isVisible = false; + private idleTimeout: ReturnType | null = null; + private boundIdleResetHandler!: () => void; + private boundVisibilityHandler!: () => void; + private readonly IDLE_PAUSE_MS = 5 * 60 * 1000; + private isIdle = false; + + constructor() { + super({ id: 'live-webcams', title: t('panels.liveWebcams') }); + this.element.classList.add('panel-wide'); + this.createToolbar(); + this.setupIntersectionObserver(); + this.setupIdleDetection(); + this.render(); + } + + private get filteredFeeds(): WebcamFeed[] { + if (this.regionFilter === 'all') return WEBCAM_FEEDS; + return WEBCAM_FEEDS.filter(f => f.region === this.regionFilter); + } + + private static readonly ALL_GRID_IDS = ['jerusalem', 'tehran', 'kyiv', 'washington']; + + private get gridFeeds(): WebcamFeed[] { + if (this.regionFilter === 'all') { + return LiveWebcamsPanel.ALL_GRID_IDS + .map(id => WEBCAM_FEEDS.find(f => f.id === id)!) + .filter(Boolean); + } + return this.filteredFeeds.slice(0, MAX_GRID_CELLS); + } + + private createToolbar(): void { + this.toolbar = document.createElement('div'); + this.toolbar.className = 'webcam-toolbar'; + + const regionGroup = document.createElement('div'); + regionGroup.className = 'webcam-toolbar-group'; + + const regions: { key: RegionFilter; label: string }[] = [ + { key: 'all', label: 'ALL' }, + { key: 'middle-east', label: 'MIDEAST' }, + { key: 'europe', label: 'EUROPE' }, + { key: 'americas', label: 'AMERICAS' }, + { key: 'asia', label: 'ASIA' }, + ]; + + regions.forEach(({ key, label }) => { + const btn = document.createElement('button'); + btn.className = `webcam-region-btn${key === this.regionFilter ? ' active' : ''}`; + btn.dataset.region = key; + btn.textContent = label; + btn.addEventListener('click', () => this.setRegionFilter(key)); + regionGroup.appendChild(btn); + }); + + const viewGroup = document.createElement('div'); + viewGroup.className = 'webcam-toolbar-group'; + + const gridBtn = document.createElement('button'); + gridBtn.className = `webcam-view-btn${this.viewMode === 'grid' ? ' active' : ''}`; + gridBtn.dataset.mode = 'grid'; + gridBtn.innerHTML = ''; + gridBtn.title = 'Grid view'; + gridBtn.addEventListener('click', () => this.setViewMode('grid')); + + const singleBtn = document.createElement('button'); + singleBtn.className = `webcam-view-btn${this.viewMode === 'single' ? ' active' : ''}`; + singleBtn.dataset.mode = 'single'; + singleBtn.innerHTML = ''; + singleBtn.title = 'Single view'; + singleBtn.addEventListener('click', () => this.setViewMode('single')); + + viewGroup.appendChild(gridBtn); + viewGroup.appendChild(singleBtn); + + this.toolbar.appendChild(regionGroup); + this.toolbar.appendChild(viewGroup); + this.element.insertBefore(this.toolbar, this.content); + } + + private setRegionFilter(filter: RegionFilter): void { + if (filter === this.regionFilter) return; + this.regionFilter = filter; + this.toolbar?.querySelectorAll('.webcam-region-btn').forEach(btn => { + (btn as HTMLElement).classList.toggle('active', (btn as HTMLElement).dataset.region === filter); + }); + const feeds = this.filteredFeeds; + if (feeds.length > 0 && !feeds.includes(this.activeFeed)) { + this.activeFeed = feeds[0]!; + } + this.render(); + } + + private setViewMode(mode: ViewMode): void { + if (mode === this.viewMode) return; + this.viewMode = mode; + this.toolbar?.querySelectorAll('.webcam-view-btn').forEach(btn => { + (btn as HTMLElement).classList.toggle('active', (btn as HTMLElement).dataset.mode === mode); + }); + this.render(); + } + + private buildEmbedUrl(videoId: string): string { + if (isDesktopRuntime()) { + const remoteBase = getRemoteApiBaseUrl(); + const params = new URLSearchParams({ + videoId, + autoplay: '1', + mute: '1', + }); + return `${remoteBase}/api/youtube/embed?${params.toString()}`; + } + return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&modestbranding=1&playsinline=1&rel=0`; + } + + private createIframe(feed: WebcamFeed): HTMLIFrameElement { + const iframe = document.createElement('iframe'); + iframe.className = 'webcam-iframe'; + iframe.src = this.buildEmbedUrl(feed.fallbackVideoId); + iframe.title = `${feed.city} live webcam`; + iframe.allow = 'autoplay; encrypted-media; picture-in-picture'; + iframe.allowFullscreen = true; + iframe.referrerPolicy = 'strict-origin-when-cross-origin'; + iframe.setAttribute('loading', 'lazy'); + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + return iframe; + } + + private render(): void { + this.destroyIframes(); + + if (!this.isVisible || this.isIdle) { + this.content.innerHTML = '
Webcams paused
'; + return; + } + + if (this.viewMode === 'grid') { + this.renderGrid(); + } else { + this.renderSingle(); + } + } + + private renderGrid(): void { + this.content.innerHTML = ''; + this.content.className = 'panel-content webcam-content'; + + const grid = document.createElement('div'); + grid.className = 'webcam-grid'; + + this.gridFeeds.forEach(feed => { + const cell = document.createElement('div'); + cell.className = 'webcam-cell'; + cell.addEventListener('click', () => { + this.activeFeed = feed; + this.setViewMode('single'); + }); + + const label = document.createElement('div'); + label.className = 'webcam-cell-label'; + label.innerHTML = `${feed.city.toUpperCase()}`; + + const iframe = this.createIframe(feed); + cell.appendChild(iframe); + cell.appendChild(label); + grid.appendChild(cell); + this.iframes.push(iframe); + }); + + this.content.appendChild(grid); + } + + private renderSingle(): void { + this.content.innerHTML = ''; + this.content.className = 'panel-content webcam-content'; + + const wrapper = document.createElement('div'); + wrapper.className = 'webcam-single'; + + const iframe = this.createIframe(this.activeFeed); + wrapper.appendChild(iframe); + this.iframes.push(iframe); + + const switcher = document.createElement('div'); + switcher.className = 'webcam-switcher'; + + const backBtn = document.createElement('button'); + backBtn.className = 'webcam-feed-btn webcam-back-btn'; + backBtn.innerHTML = ' Grid'; + backBtn.addEventListener('click', () => this.setViewMode('grid')); + switcher.appendChild(backBtn); + + this.filteredFeeds.forEach(feed => { + const btn = document.createElement('button'); + btn.className = `webcam-feed-btn${feed.id === this.activeFeed.id ? ' active' : ''}`; + btn.textContent = feed.city; + btn.addEventListener('click', () => { + this.activeFeed = feed; + this.render(); + }); + switcher.appendChild(btn); + }); + + this.content.appendChild(wrapper); + this.content.appendChild(switcher); + } + + private destroyIframes(): void { + this.iframes.forEach(iframe => { + iframe.src = 'about:blank'; + iframe.remove(); + }); + this.iframes = []; + } + + private setupIntersectionObserver(): void { + this.observer = new IntersectionObserver( + (entries) => { + const wasVisible = this.isVisible; + this.isVisible = entries.some(e => e.isIntersecting); + if (this.isVisible && !wasVisible && !this.isIdle) { + this.render(); + } else if (!this.isVisible && wasVisible) { + this.destroyIframes(); + } + }, + { threshold: 0.1 } + ); + this.observer.observe(this.element); + } + + private setupIdleDetection(): void { + this.boundVisibilityHandler = () => { + if (document.hidden) { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + } else { + if (this.isIdle) { + this.isIdle = false; + if (this.isVisible) this.render(); + } + this.boundIdleResetHandler(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + + this.boundIdleResetHandler = () => { + if (this.idleTimeout) clearTimeout(this.idleTimeout); + if (this.isIdle) { + this.isIdle = false; + if (this.isVisible) this.render(); + } + this.idleTimeout = setTimeout(() => { + this.isIdle = true; + this.destroyIframes(); + this.content.innerHTML = '
Webcams paused — move mouse to resume
'; + }, this.IDLE_PAUSE_MS); + }; + + ['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => { + document.addEventListener(event, this.boundIdleResetHandler, { passive: true }); + }); + + this.boundIdleResetHandler(); + } + + public refresh(): void { + if (this.isVisible && !this.isIdle) { + this.render(); + } + } + + public destroy(): void { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + ['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => { + document.removeEventListener(event, this.boundIdleResetHandler); + }); + this.observer?.disconnect(); + this.destroyIframes(); + super.destroy(); + } +} diff --git a/src/components/MacroSignalsPanel.ts b/src/components/MacroSignalsPanel.ts new file mode 100644 index 000000000..90fb0f6a2 --- /dev/null +++ b/src/components/MacroSignalsPanel.ts @@ -0,0 +1,177 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; + +interface MacroSignalData { + timestamp: string; + verdict: string; + bullishCount: number; + totalCount: number; + signals: { + liquidity: { status: string; value: number | null; sparkline: number[] }; + flowStructure: { status: string; btcReturn5: number | null; qqqReturn5: number | null }; + macroRegime: { status: string; qqqRoc20: number | null; xlpRoc20: number | null }; + technicalTrend: { status: string; btcPrice: number | null; sma50: number | null; sma200: number | null; vwap30d: number | null; mayerMultiple: number | null; sparkline: number[] }; + hashRate: { status: string; change30d: number | null }; + miningCost: { status: string }; + fearGreed: { status: string; value: number | null; history: Array<{ value: number; date: string }> }; + }; + meta: { qqqSparkline: number[] }; + unavailable?: boolean; +} + +function sparklineSvg(data: number[], width = 80, height = 24, color = '#4fc3f7'): string { + if (!data || data.length < 2) return ''; + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + const points = data.map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - ((v - min) / range) * (height - 2) - 1; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ``; +} + +function donutGaugeSvg(value: number | null, size = 48): string { + if (value === null) return 'N/A'; + const v = Math.max(0, Math.min(100, value)); + const r = (size - 6) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference - (v / 100) * circumference; + let color = '#f44336'; + if (v >= 75) color = '#4caf50'; + else if (v >= 50) color = '#ff9800'; + else if (v >= 25) color = '#ff5722'; + return ` + + + ${v} + `; +} + +function statusBadgeClass(status: string): string { + const s = status.toUpperCase(); + if (['BULLISH', 'RISK-ON', 'GROWING', 'PROFITABLE', 'ALIGNED', 'NORMAL', 'EXTREME GREED', 'GREED'].includes(s)) return 'badge-bullish'; + if (['BEARISH', 'DEFENSIVE', 'DECLINING', 'SQUEEZE', 'PASSIVE GAP', 'EXTREME FEAR', 'FEAR'].includes(s)) return 'badge-bearish'; + return 'badge-neutral'; +} + +function formatNum(v: number | null, suffix = '%'): string { + if (v === null) return 'N/A'; + const sign = v > 0 ? '+' : ''; + return `${sign}${v.toFixed(1)}${suffix}`; +} + +export class MacroSignalsPanel extends Panel { + private data: MacroSignalData | null = null; + private loading = true; + private error: string | null = null; + + private refreshInterval: ReturnType | null = null; + + constructor() { + super({ id: 'macro-signals', title: t('panels.macroSignals'), showCount: false }); + void this.fetchData(); + this.refreshInterval = setInterval(() => this.fetchData(), 3 * 60000); + } + + public destroy(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + private async fetchData(): Promise { + try { + const res = await fetch('/api/macro-signals'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + this.data = await res.json(); + this.error = null; + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to fetch'; + } finally { + this.loading = false; + this.renderPanel(); + } + } + + private renderPanel(): void { + if (this.loading) { + this.showLoading(t('common.computingSignals')); + return; + } + + if (this.error || !this.data) { + this.showError(this.error || t('common.noDataShort')); + return; + } + + if (this.data.unavailable) { + this.showError(t('common.upstreamUnavailable')); + return; + } + + const d = this.data; + const s = d.signals; + + const verdictClass = d.verdict === 'BUY' ? 'verdict-buy' : d.verdict === 'CASH' ? 'verdict-cash' : 'verdict-unknown'; + + const html = ` +
+
+ Overall + ${escapeHtml(d.verdict)} + ${d.bullishCount}/${d.totalCount} bullish +
+
+ ${this.renderSignalCard('Liquidity', s.liquidity.status, formatNum(s.liquidity.value), sparklineSvg(s.liquidity.sparkline, 60, 20, '#4fc3f7'), 'JPY 30d ROC', 'https://www.tradingview.com/symbols/JPYUSD/')} + ${this.renderSignalCard('Flow', s.flowStructure.status, `BTC ${formatNum(s.flowStructure.btcReturn5)} / QQQ ${formatNum(s.flowStructure.qqqReturn5)}`, '', '5d returns', null)} + ${this.renderSignalCard('Regime', s.macroRegime.status, `QQQ ${formatNum(s.macroRegime.qqqRoc20)} / XLP ${formatNum(s.macroRegime.xlpRoc20)}`, sparklineSvg(d.meta.qqqSparkline, 60, 20, '#ab47bc'), '20d ROC', 'https://www.tradingview.com/symbols/QQQ/')} + ${this.renderSignalCard('BTC Trend', s.technicalTrend.status, `$${s.technicalTrend.btcPrice?.toLocaleString() ?? 'N/A'}`, sparklineSvg(s.technicalTrend.sparkline, 60, 20, '#ff9800'), `SMA50: $${s.technicalTrend.sma50?.toLocaleString() ?? '-'} | VWAP: $${s.technicalTrend.vwap30d?.toLocaleString() ?? '-'} | Mayer: ${s.technicalTrend.mayerMultiple ?? '-'}`, 'https://www.tradingview.com/symbols/BTCUSD/')} + ${this.renderSignalCard('Hash Rate', s.hashRate.status, formatNum(s.hashRate.change30d), '', '30d change', 'https://mempool.space/mining')} + ${this.renderSignalCard('Mining', s.miningCost.status, '', '', 'Hashprice model', null)} + ${this.renderFearGreedCard(s.fearGreed)} +
+
+ `; + + this.setContent(html); + } + + private renderSignalCard(name: string, status: string, value: string, sparkline: string, detail: string, link: string | null): string { + const badgeClass = statusBadgeClass(status); + return ` + + `; + } + + private renderFearGreedCard(fg: MacroSignalData['signals']['fearGreed']): string { + const badgeClass = statusBadgeClass(fg.status); + return ` +
+
+ Fear & Greed + ${escapeHtml(fg.status)} +
+
+ ${donutGaugeSvg(fg.value)} +
+ +
+ `; + } +} diff --git a/src/components/Map.ts b/src/components/Map.ts index 7b29fad6e..ab926527e 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -1,7 +1,13 @@ import * as d3 from 'd3'; import * as topojson from 'topojson-client'; +import { escapeHtml } from '@/utils/sanitize'; +import { getCSSColor } from '@/utils'; import type { Topology, GeometryCollection } from 'topojson-specification'; -import type { MapLayers, Hotspot, NewsItem, Earthquake, InternetOutage, RelatedAsset, AssetType, AisDisruptionEvent, AisDensityZone, CableAdvisory, RepairShip, SocialUnrestEvent } from '@/types'; +import type { Feature, Geometry } from 'geojson'; +import type { MapLayers, Hotspot, NewsItem, Earthquake, InternetOutage, RelatedAsset, AssetType, AisDisruptionEvent, AisDensityZone, CableAdvisory, RepairShip, SocialUnrestEvent, AirportDelayAlert, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, NaturalEvent, CyberThreat } from '@/types'; +import type { TechHubActivity } from '@/services/tech-activity'; +import type { GeoHubActivity } from '@/services/geo-activity'; +import { getNaturalEventIcon } from '@/services/eonet'; import type { WeatherAlert } from '@/services/weather'; import { getSeverityColor } from '@/services/weather'; import { @@ -17,14 +23,37 @@ import { SANCTIONED_COUNTRIES, STRATEGIC_WATERWAYS, APT_GROUPS, - COUNTRY_LABELS, ECONOMIC_CENTERS, AI_DATA_CENTERS, + PORTS, + SPACEPORTS, + CRITICAL_MINERALS, + SITE_VARIANT, + // Tech variant data + STARTUP_HUBS, + ACCELERATORS, + TECH_HQS, + CLOUD_REGIONS, + // Finance variant data + STOCK_EXCHANGES, + FINANCIAL_CENTERS, + CENTRAL_BANKS, + COMMODITY_HUBS, } from '@/config'; import { MapPopup } from './MapPopup'; +import { + updateHotspotEscalation, + getHotspotEscalation, + setMilitaryData, + setCIIGetter, + setGeoAlertGetter, +} from '@/services/hotspot-escalation'; +import { getCountryScore } from '@/services/country-instability'; +import { getAlertsNearLocation } from '@/services/geo-convergence'; +import { t } from '@/services/i18n'; export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; -export type MapView = 'global' | 'us' | 'mena'; +export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; interface MapState { zoom: number; @@ -38,15 +67,22 @@ interface HotspotWithBreaking extends Hotspot { hasBreaking?: boolean; } -interface WorldTopology extends Topology { - objects: { - countries: GeometryCollection; - }; +interface TechEventMarker { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; } -interface USTopology extends Topology { +interface WorldTopology extends Topology { objects: { - states: GeometryCollection; + countries: GeometryCollection; }; } @@ -55,19 +91,26 @@ export class MapComponent { Record > = { bases: { minZoom: 3, showLabels: 5 }, - nuclear: { minZoom: 2, showLabels: 4 }, + nuclear: { minZoom: 2 }, conflicts: { minZoom: 1, showLabels: 3 }, - economic: { minZoom: 2, showLabels: 4 }, - earthquakes: { minZoom: 1, showLabels: 2 }, + economic: { minZoom: 2 }, + natural: { minZoom: 1, showLabels: 2 }, }; private container: HTMLElement; private svg: d3.Selection; private wrapper: HTMLElement; private overlays: HTMLElement; + private clusterCanvas: HTMLCanvasElement; + private clusterGl: WebGLRenderingContext | null = null; private state: MapState; private worldData: WorldTopology | null = null; - private usData: USTopology | null = null; + private countryFeatures: Feature[] | null = null; + private baseLayerGroup: d3.Selection | null = null; + private dynamicLayerGroup: d3.Selection | null = null; + private baseRendered = false; + private baseWidth = 0; + private baseHeight = 0; private hotspots: HotspotWithBreaking[]; private earthquakes: Earthquake[] = []; private weatherAlerts: WeatherAlert[] = []; @@ -77,7 +120,19 @@ export class MapComponent { private cableAdvisories: CableAdvisory[] = []; private repairShips: RepairShip[] = []; private protests: SocialUnrestEvent[] = []; + private flightDelays: AirportDelayAlert[] = []; + private militaryFlights: MilitaryFlight[] = []; + private militaryFlightClusters: MilitaryFlightCluster[] = []; + private militaryVessels: MilitaryVessel[] = []; + private militaryVesselClusters: MilitaryVesselCluster[] = []; + private naturalEvents: NaturalEvent[] = []; + private firmsFireData: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }> = []; + private techEvents: TechEventMarker[] = []; + private techActivities: TechHubActivity[] = []; + private geoActivities: GeoHubActivity[] = []; private news: NewsItem[] = []; + private onTechHubClick?: (hub: TechHubActivity) => void; + private onGeoHubClick?: (hub: GeoHubActivity) => void; private popup: MapPopup; private onHotspotClick?: (hotspot: Hotspot) => void; private onTimeRangeChange?: (range: TimeRange) => void; @@ -91,6 +146,11 @@ export class MapComponent { base: new Set(), nuclear: new Set(), }; + private boundVisibilityHandler!: () => void; + private renderScheduled = false; + private lastRenderTime = 0; + private readonly MIN_RENDER_INTERVAL_MS = 100; + private healthCheckIntervalId: ReturnType | null = null; constructor(container: HTMLElement, initialState: MapState) { this.container = container; @@ -106,6 +166,11 @@ export class MapComponent { svgElement.id = 'mapSvg'; this.wrapper.appendChild(svgElement); + this.clusterCanvas = document.createElement('canvas'); + this.clusterCanvas.className = 'map-cluster-canvas'; + this.clusterCanvas.id = 'mapClusterCanvas'; + this.wrapper.appendChild(this.clusterCanvas); + // Overlays inside wrapper so they transform together on zoom/pan this.overlays = document.createElement('div'); this.overlays.id = 'mapOverlays'; @@ -116,13 +181,54 @@ export class MapComponent { container.appendChild(this.createTimeSlider()); container.appendChild(this.createLayerToggles()); container.appendChild(this.createLegend()); - container.appendChild(this.createTimestamp()); + this.healthCheckIntervalId = setInterval(() => this.runHealthCheck(), 30000); this.svg = d3.select(svgElement); + this.baseLayerGroup = this.svg.append('g').attr('class', 'map-base'); + this.dynamicLayerGroup = this.svg.append('g').attr('class', 'map-dynamic'); this.popup = new MapPopup(container); + this.initClusterRenderer(); this.setupZoomHandlers(); this.loadMapData(); + this.setupResizeObserver(); + + window.addEventListener('theme-changed', () => { + this.baseRendered = false; + this.render(); + }); + } + + private setupResizeObserver(): void { + let lastWidth = 0; + let lastHeight = 0; + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0 && (width !== lastWidth || height !== lastHeight)) { + lastWidth = width; + lastHeight = height; + requestAnimationFrame(() => this.render()); + } + } + }); + resizeObserver.observe(this.container); + + // Re-render when page becomes visible again (after browser throttling) + this.boundVisibilityHandler = () => { + if (!document.hidden) { + requestAnimationFrame(() => this.render()); + } + }; + document.addEventListener('visibilitychange', this.boundVisibilityHandler); + } + + public destroy(): void { + document.removeEventListener('visibilitychange', this.boundVisibilityHandler); + if (this.healthCheckIntervalId) { + clearInterval(this.healthCheckIntervalId); + this.healthCheckIntervalId = null; + } } private createControls(): HTMLElement { @@ -227,23 +333,230 @@ export class MapComponent { toggles.className = 'layer-toggles'; toggles.id = 'layerToggles'; - const layers: (keyof MapLayers)[] = ['conflicts', 'bases', 'cables', 'pipelines', 'hotspots', 'ais', 'earthquakes', 'weather', 'nuclear', 'irradiators', 'outages', 'datacenters', 'sanctions', 'economic', 'countries', 'waterways', 'protests']; - const layerLabels: Partial> = { - ais: 'AIS', + // Variant-aware layer buttons + const fullLayers: (keyof MapLayers)[] = [ + 'conflicts', 'hotspots', 'sanctions', 'protests', // geopolitical + 'bases', 'nuclear', 'irradiators', // military/strategic + 'military', // military tracking (flights + vessels) + 'cables', 'pipelines', 'outages', 'datacenters', // infrastructure + // cyberThreats is intentionally hidden on SVG/mobile fallback (DeckGL desktop only) + 'ais', 'flights', // transport + 'natural', 'weather', // natural + 'economic', // economic + 'waterways', // labels + ]; + const techLayers: (keyof MapLayers)[] = [ + 'cables', 'datacenters', 'outages', // tech infrastructure + 'startupHubs', 'cloudRegions', 'accelerators', 'techHQs', 'techEvents', // tech ecosystem + 'natural', 'weather', // natural events + 'economic', // economic/geographic + ]; + const financeLayers: (keyof MapLayers)[] = [ + 'stockExchanges', 'financialCenters', 'centralBanks', 'commodityHubs', // finance ecosystem + 'cables', 'pipelines', 'outages', // infrastructure + 'sanctions', 'economic', 'waterways', // geopolitical/economic + 'natural', 'weather', // natural events + ]; + const layers = SITE_VARIANT === 'tech' ? techLayers : SITE_VARIANT === 'finance' ? financeLayers : fullLayers; + const layerLabelKeys: Partial> = { + hotspots: 'components.deckgl.layers.intelHotspots', + conflicts: 'components.deckgl.layers.conflictZones', + bases: 'components.deckgl.layers.militaryBases', + nuclear: 'components.deckgl.layers.nuclearSites', + irradiators: 'components.deckgl.layers.gammaIrradiators', + military: 'components.deckgl.layers.militaryActivity', + cables: 'components.deckgl.layers.underseaCables', + pipelines: 'components.deckgl.layers.pipelines', + outages: 'components.deckgl.layers.internetOutages', + datacenters: 'components.deckgl.layers.aiDataCenters', + ais: 'components.deckgl.layers.shipTraffic', + flights: 'components.deckgl.layers.flightDelays', + natural: 'components.deckgl.layers.naturalEvents', + weather: 'components.deckgl.layers.weatherAlerts', + economic: 'components.deckgl.layers.economicCenters', + waterways: 'components.deckgl.layers.strategicWaterways', + startupHubs: 'components.deckgl.layers.startupHubs', + cloudRegions: 'components.deckgl.layers.cloudRegions', + accelerators: 'components.deckgl.layers.accelerators', + techHQs: 'components.deckgl.layers.techHQs', + techEvents: 'components.deckgl.layers.techEvents', + stockExchanges: 'components.deckgl.layers.stockExchanges', + financialCenters: 'components.deckgl.layers.financialCenters', + centralBanks: 'components.deckgl.layers.centralBanks', + commodityHubs: 'components.deckgl.layers.commodityHubs', + gulfInvestments: 'components.deckgl.layers.gulfInvestments', + }; + const getLayerLabel = (layer: keyof MapLayers): string => { + if (layer === 'sanctions') return t('components.deckgl.layerHelp.labels.sanctions'); + const key = layerLabelKeys[layer]; + return key ? t(key) : layer; }; layers.forEach((layer) => { const btn = document.createElement('button'); btn.className = `layer-toggle ${this.state.layers[layer] ? 'active' : ''}`; btn.dataset.layer = layer; - btn.textContent = layerLabels[layer] || layer; + btn.textContent = getLayerLabel(layer); btn.addEventListener('click', () => this.toggleLayer(layer)); toggles.appendChild(btn); }); + // Add help button + const helpBtn = document.createElement('button'); + helpBtn.className = 'layer-help-btn'; + helpBtn.textContent = '?'; + helpBtn.title = t('components.deckgl.layerGuide'); + helpBtn.addEventListener('click', () => this.showLayerHelp()); + toggles.appendChild(helpBtn); + return toggles; } + private showLayerHelp(): void { + const existing = this.container.querySelector('.layer-help-popup'); + if (existing) { + existing.remove(); + return; + } + + const popup = document.createElement('div'); + popup.className = 'layer-help-popup'; + + const label = (layerKey: string): string => t(`components.deckgl.layers.${layerKey}`).toUpperCase(); + const staticLabel = (labelKey: string): string => t(`components.deckgl.layerHelp.labels.${labelKey}`).toUpperCase(); + const helpItem = (layerLabel: string, descriptionKey: string): string => + `
${layerLabel} ${t(`components.deckgl.layerHelp.descriptions.${descriptionKey}`)}
`; + const helpSection = (titleKey: string, items: string[], noteKey?: string): string => ` +
+
${t(`components.deckgl.layerHelp.sections.${titleKey}`)}
+ ${items.join('')} + ${noteKey ? `
${t(`components.deckgl.layerHelp.notes.${noteKey}`)}
` : ''} +
+ `; + const helpHeader = ` +
+ ${t('components.deckgl.layerHelp.title')} + +
+ `; + + const techHelpContent = ` + ${helpHeader} +
+ ${helpSection('techEcosystem', [ + helpItem(label('startupHubs'), 'techStartupHubs'), + helpItem(label('cloudRegions'), 'techCloudRegions'), + helpItem(label('techHQs'), 'techHQs'), + helpItem(label('accelerators'), 'techAccelerators'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCables'), + helpItem(label('aiDataCenters'), 'infraDatacenters'), + helpItem(label('internetOutages'), 'infraOutages'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsTech'), + helpItem(label('weatherAlerts'), 'weatherAlerts'), + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(staticLabel('countries'), 'countriesOverlay'), + ])} +
+ `; + + const financeHelpContent = ` + ${helpHeader} +
+ ${helpSection('financeCore', [ + helpItem(label('stockExchanges'), 'financeExchanges'), + helpItem(label('financialCenters'), 'financeCenters'), + helpItem(label('centralBanks'), 'financeCentralBanks'), + helpItem(label('commodityHubs'), 'financeCommodityHubs'), + ])} + ${helpSection('infrastructureRisk', [ + helpItem(label('underseaCables'), 'financeCables'), + helpItem(label('pipelines'), 'financePipelines'), + helpItem(label('internetOutages'), 'financeOutages'), + helpItem(label('cyberThreats'), 'financeCyberThreats'), + ])} + ${helpSection('macroContext', [ + helpItem(label('economicCenters'), 'economicCenters'), + helpItem(label('strategicWaterways'), 'macroWaterways'), + helpItem(label('weatherAlerts'), 'weatherAlertsMarket'), + helpItem(label('naturalEvents'), 'naturalEventsMacro'), + ])} +
+ `; + + const fullHelpContent = ` + ${helpHeader} +
+ ${helpSection('timeFilter', [ + helpItem(staticLabel('timeRecent'), 'timeRecent'), + helpItem(staticLabel('timeExtended'), 'timeExtended'), + ], 'timeAffects')} + ${helpSection('geopolitical', [ + helpItem(label('conflictZones'), 'geoConflicts'), + helpItem(label('intelHotspots'), 'geoHotspots'), + helpItem(staticLabel('sanctions'), 'geoSanctions'), + helpItem(label('protests'), 'geoProtests'), + ])} + ${helpSection('militaryStrategic', [ + helpItem(label('militaryBases'), 'militaryBases'), + helpItem(label('nuclearSites'), 'militaryNuclear'), + helpItem(label('gammaIrradiators'), 'militaryIrradiators'), + helpItem(label('militaryActivity'), 'militaryActivity'), + ])} + ${helpSection('infrastructure', [ + helpItem(label('underseaCables'), 'infraCablesFull'), + helpItem(label('pipelines'), 'infraPipelinesFull'), + helpItem(label('internetOutages'), 'infraOutages'), + helpItem(label('aiDataCenters'), 'infraDatacentersFull'), + ])} + ${helpSection('transport', [ + helpItem(staticLabel('shipping'), 'transportShipping'), + helpItem(label('flightDelays'), 'transportDelays'), + ])} + ${helpSection('naturalEconomic', [ + helpItem(label('naturalEvents'), 'naturalEventsFull'), + helpItem(label('weatherAlerts'), 'weatherAlerts'), + helpItem(label('economicCenters'), 'economicCenters'), + ])} + ${helpSection('labels', [ + helpItem(staticLabel('countries'), 'countriesOverlay'), + helpItem(label('strategicWaterways'), 'waterwaysLabels'), + ])} +
+ `; + + popup.innerHTML = SITE_VARIANT === 'tech' + ? techHelpContent + : SITE_VARIANT === 'finance' + ? financeHelpContent + : fullHelpContent; + + popup.querySelector('.layer-help-close')?.addEventListener('click', () => popup.remove()); + + // Prevent scroll events from propagating to map + const content = popup.querySelector('.layer-help-content'); + if (content) { + content.addEventListener('wheel', (e) => e.stopPropagation(), { passive: false }); + content.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: false }); + } + + // Close on click outside + setTimeout(() => { + const closeHandler = (e: MouseEvent) => { + if (!popup.contains(e.target as Node)) { + popup.remove(); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 100); + + this.container.appendChild(popup); + } + private syncLayerButtons(): void { this.container.querySelectorAll('.layer-toggle').forEach((btn) => { const layer = btn.dataset.layer as keyof MapLayers | undefined; @@ -255,29 +568,51 @@ export class MapComponent { private createLegend(): HTMLElement { const legend = document.createElement('div'); legend.className = 'map-legend'; - legend.innerHTML = ` -
HIGH ALERT
-
ELEVATED
-
MONITORING
-
CONFLICT
-
EARTHQUAKE
-
APT
- `; + + if (SITE_VARIANT === 'tech') { + // Tech variant legend + legend.innerHTML = ` +
${escapeHtml(t('components.deckgl.layers.techHQs').toUpperCase())}
+
${escapeHtml(t('components.deckgl.layers.startupHubs').toUpperCase())}
+
${escapeHtml(t('components.deckgl.layers.cloudRegions').toUpperCase())}
+
📅${escapeHtml(t('components.deckgl.layers.techEvents').toUpperCase())}
+
💾${escapeHtml(t('components.deckgl.layers.aiDataCenters').toUpperCase())}
+ `; + } else { + // Geopolitical variant legend + legend.innerHTML = ` +
${escapeHtml(t('popups.hotspot.levels.high').toUpperCase())}
+
${escapeHtml(t('popups.hotspot.levels.elevated').toUpperCase())}
+
${escapeHtml(t('popups.monitoring').toUpperCase())}
+
${escapeHtml(t('modals.search.types.conflict').toUpperCase())}
+
${escapeHtml(t('modals.search.types.earthquake').toUpperCase())}
+
APT
+ `; + } return legend; } - private createTimestamp(): HTMLElement { - const timestamp = document.createElement('div'); - timestamp.className = 'map-timestamp'; - timestamp.id = 'mapTimestamp'; - this.updateTimestamp(timestamp); - setInterval(() => this.updateTimestamp(timestamp), 60000); - return timestamp; - } + private runHealthCheck(): void { + // Skip if page is hidden (no need to check while user isn't looking) + if (document.hidden) return; + + const svgNode = this.svg.node(); + if (!svgNode) return; + + // Verify base layer exists and has content + const baseGroup = svgNode.querySelector('.map-base'); + const countryCount = baseGroup?.querySelectorAll('.country').length ?? 0; - private updateTimestamp(el: HTMLElement): void { - const now = new Date(); - el.innerHTML = `LAST UPDATE: ${now.toUTCString().replace('GMT', 'UTC')}`; + // If we have country data but no rendered countries, something is wrong + if (this.countryFeatures && this.countryFeatures.length > 0 && countryCount === 0) { + console.warn('[Map] Health check: Base layer missing countries, initiating recovery'); + this.baseRendered = false; + // Also check if d3 selection is stale + if (baseGroup && this.baseLayerGroup?.node() !== baseGroup) { + console.warn('[Map] Health check: Stale d3 selection detected'); + } + this.render(); + } } private setupZoomHandlers(): void { @@ -285,6 +620,14 @@ export class MapComponent { let lastPos = { x: 0, y: 0 }; let lastTouchDist = 0; let lastTouchCenter = { x: 0, y: 0 }; + const shouldIgnoreInteractionStart = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) return false; + return Boolean( + target.closest( + '.map-controls, .time-slider, .layer-toggles, .map-legend, .layer-help-popup, .map-popup, button, select, input, textarea, a' + ) + ); + }; // Wheel zoom with smooth delta this.container.addEventListener( @@ -317,6 +660,7 @@ export class MapComponent { // Mouse drag for panning this.container.addEventListener('mousedown', (e) => { + if (shouldIgnoreInteractionStart(e.target)) return; if (e.button === 0) { // Left click isDragging = true; lastPos = { x: e.clientX, y: e.clientY }; @@ -347,6 +691,7 @@ export class MapComponent { // Touch events for mobile and trackpad this.container.addEventListener('touchstart', (e) => { + if (shouldIgnoreInteractionStart(e.target)) return; const touch1 = e.touches[0]; const touch2 = e.touches[1]; @@ -417,78 +762,199 @@ export class MapComponent { private async loadMapData(): Promise { try { - const [worldResponse, usResponse] = await Promise.all([ - fetch(MAP_URLS.world), - fetch(MAP_URLS.us), - ]); - + const worldResponse = await fetch(MAP_URLS.world); this.worldData = await worldResponse.json(); - this.usData = await usResponse.json(); - + if (this.worldData) { + const countries = topojson.feature( + this.worldData, + this.worldData.objects.countries + ); + this.countryFeatures = 'features' in countries ? countries.features : [countries]; + } + this.baseRendered = false; this.render(); + // Re-render after layout stabilizes to catch full container width + requestAnimationFrame(() => requestAnimationFrame(() => this.render())); } catch (e) { console.error('Failed to load map data:', e); } } + private initClusterRenderer(): void { + // WebGL clustering disabled - just get context for clearing canvas + const gl = this.clusterCanvas.getContext('webgl'); + if (!gl) return; + this.clusterGl = gl; + } + + private clearClusterCanvas(): void { + if (!this.clusterGl) return; + this.clusterGl.clearColor(0, 0, 0, 0); + this.clusterGl.clear(this.clusterGl.COLOR_BUFFER_BIT); + } + + private renderClusterLayer(_projection: d3.GeoProjection): void { + // WebGL clustering disabled - all layers use HTML markers for visual fidelity + // (severity colors, emoji icons, magnitude sizing, animations) + this.wrapper.classList.toggle('cluster-active', false); + this.clearClusterCanvas(); + } + + public scheduleRender(): void { + if (this.renderScheduled) return; + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderScheduled = false; + this.render(); + }); + } + public render(): void { + const now = performance.now(); + if (now - this.lastRenderTime < this.MIN_RENDER_INTERVAL_MS) { + this.scheduleRender(); + return; + } + this.lastRenderTime = now; + const width = this.container.clientWidth; const height = this.container.clientHeight; + // Skip render if container has no dimensions (tab throttled, hidden, etc.) + if (width === 0 || height === 0) { + return; + } + // Simple viewBox matching container - keeps SVG and overlays aligned this.svg.attr('viewBox', `0 0 ${width} ${height}`); - this.svg.selectAll('*').remove(); - // Background - this.svg - .append('rect') - .attr('width', width) - .attr('height', height) - .attr('fill', '#020a08'); + // CRITICAL: Always refresh d3 selections from actual DOM to prevent stale references + // D3 selections can become stale if the DOM is modified externally + const svgNode = this.svg.node(); + if (!svgNode) return; + + // Query DOM directly for layer groups + const existingBase = svgNode.querySelector('.map-base') as SVGGElement | null; + const existingDynamic = svgNode.querySelector('.map-dynamic') as SVGGElement | null; + + // Recreate layer groups if missing or if d3 selections are stale + const baseStale = !existingBase || this.baseLayerGroup?.node() !== existingBase; + const dynamicStale = !existingDynamic || this.dynamicLayerGroup?.node() !== existingDynamic; + + if (baseStale || dynamicStale) { + // Clear any orphaned groups and create fresh ones + svgNode.querySelectorAll('.map-base, .map-dynamic').forEach(el => el.remove()); + this.baseLayerGroup = this.svg.append('g').attr('class', 'map-base'); + this.dynamicLayerGroup = this.svg.append('g').attr('class', 'map-dynamic'); + this.baseRendered = false; + console.warn('[Map] Layer groups recreated - baseStale:', baseStale, 'dynamicStale:', dynamicStale); + } + + // Double-check selections are valid after recreation + if (!this.baseLayerGroup?.node() || !this.dynamicLayerGroup?.node()) { + console.error('[Map] Failed to create layer groups'); + return; + } - // Grid - this.renderGrid(width, height); + // Check if base layer has actual country content (not just empty group) + const countryCount = this.baseLayerGroup.node()!.querySelectorAll('.country').length; + const shouldRenderBase = !this.baseRendered || countryCount === 0 || width !== this.baseWidth || height !== this.baseHeight; - // Setup projection - const projection = this.getProjection(width, height); - const path = d3.geoPath().projection(projection); + // Debug: log when base layer needs re-render + if (shouldRenderBase && countryCount === 0 && this.baseRendered) { + console.warn('[Map] Base layer missing countries, forcing re-render. countryFeatures:', this.countryFeatures?.length ?? 'null'); + } - // Graticule - this.renderGraticule(path); + if (shouldRenderBase) { + this.baseWidth = width; + this.baseHeight = height; + // Use native DOM clear for guaranteed effect + const baseNode = this.baseLayerGroup.node()!; + while (baseNode.firstChild) baseNode.removeChild(baseNode.firstChild); + + // Background - extend well beyond viewBox to cover pan/zoom transforms + // 3x size in each direction ensures no black bars when panning + this.baseLayerGroup + .append('rect') + .attr('x', -width) + .attr('y', -height) + .attr('width', width * 3) + .attr('height', height * 3) + .attr('fill', getCSSColor('--map-bg')); + + // Grid + this.renderGrid(this.baseLayerGroup, width, height); + + // Setup projection for base elements + const baseProjection = this.getProjection(width, height); + const basePath = d3.geoPath().projection(baseProjection); + + // Graticule + this.renderGraticule(this.baseLayerGroup, basePath); + + // Countries + this.renderCountries(this.baseLayerGroup, basePath); + this.baseRendered = true; + } + + // Always rebuild dynamic layer - use native DOM clear for reliability + const dynamicNode = this.dynamicLayerGroup.node()!; + while (dynamicNode.firstChild) dynamicNode.removeChild(dynamicNode.firstChild); + // Create overlays-svg group for SVG-based overlays (military tracks, etc.) + this.dynamicLayerGroup.append('g').attr('class', 'overlays-svg'); + + // Setup projection for dynamic elements + const projection = this.getProjection(width, height); - // Countries - this.renderCountries(path); + // Update country fills (sanctions toggle without rebuilding geometry) + this.updateCountryFills(); - // Layers (show on global and mena views) - const showGlobalLayers = this.state.view === 'global' || this.state.view === 'mena'; - if (this.state.layers.cables && showGlobalLayers) { + // Render dynamic map layers + if (this.state.layers.cables) { this.renderCables(projection); } - if (this.state.layers.pipelines && showGlobalLayers) { + if (this.state.layers.pipelines) { this.renderPipelines(projection); } - if (this.state.layers.conflicts && showGlobalLayers) { + if (this.state.layers.conflicts) { this.renderConflicts(projection); } - if (this.state.layers.ais && showGlobalLayers) { + if (this.state.layers.ais) { this.renderAisDensity(projection); } - if (this.state.layers.sanctions && showGlobalLayers) { - this.renderSanctions(); - } + // GPU-accelerated cluster markers (LOD) + this.renderClusterLayer(projection); // Overlays this.renderOverlays(projection); + // POST-RENDER VERIFICATION: Ensure base layer actually rendered + // This catches silent failures where d3 operations didn't stick + if (this.baseRendered && this.countryFeatures && this.countryFeatures.length > 0) { + const verifyCount = this.baseLayerGroup?.node()?.querySelectorAll('.country').length ?? 0; + if (verifyCount === 0) { + console.error('[Map] POST-RENDER: Countries failed to render despite baseRendered=true. Forcing full rebuild.'); + this.baseRendered = false; + // Schedule a retry on next frame instead of immediate recursion + requestAnimationFrame(() => this.render()); + return; + } + } + this.applyTransform(); } - private renderGrid(width: number, height: number, yStart = 0): void { - const gridGroup = this.svg.append('g').attr('class', 'grid'); + private renderGrid( + group: d3.Selection, + width: number, + height: number, + yStart = 0 + ): void { + const gridGroup = group.append('g').attr('class', 'grid'); for (let x = 0; x < width; x += 20) { gridGroup @@ -497,7 +963,7 @@ export class MapComponent { .attr('y1', yStart) .attr('x2', x) .attr('y2', yStart + height) - .attr('stroke', '#0a2a20') + .attr('stroke', getCSSColor('--map-grid')) .attr('stroke-width', 0.5); } @@ -508,81 +974,67 @@ export class MapComponent { .attr('y1', y) .attr('x2', width) .attr('y2', y) - .attr('stroke', '#0a2a20') + .attr('stroke', getCSSColor('--map-grid')) .attr('stroke-width', 0.5); } } private getProjection(width: number, height: number): d3.GeoProjection { - if (this.state.view === 'global' || this.state.view === 'mena') { - // Scale by width to fill horizontally, center vertically - return d3 - .geoEquirectangular() - .scale(width / (2 * Math.PI)) - .center([0, 0]) - .translate([width / 2, height / 2]); - } + // Equirectangular with cropped latitude range (72°N to 56°S = 128°) + // Shows Greenland/Iceland while trimming extreme polar regions + const LAT_NORTH = 72; // Includes Greenland (extends to ~83°N but 72 shows most) + const LAT_SOUTH = -56; // Just below Tierra del Fuego + const LAT_RANGE = LAT_NORTH - LAT_SOUTH; // 128° + const LAT_CENTER = (LAT_NORTH + LAT_SOUTH) / 2; // 8°N + + // Scale to fit: 360° longitude in width, 128° latitude in height + const scaleForWidth = width / (2 * Math.PI); + const scaleForHeight = height / (LAT_RANGE * Math.PI / 180); + const scale = Math.min(scaleForWidth, scaleForHeight); return d3 - .geoAlbersUsa() - .scale(width * 1.3) + .geoEquirectangular() + .scale(scale) + .center([0, LAT_CENTER]) .translate([width / 2, height / 2]); } - private renderGraticule(path: d3.GeoPath): void { + private renderGraticule( + group: d3.Selection, + path: d3.GeoPath + ): void { const graticule = d3.geoGraticule(); - this.svg + group .append('path') .datum(graticule()) .attr('class', 'graticule') .attr('d', path) .attr('fill', 'none') - .attr('stroke', '#1a5045') + .attr('stroke', getCSSColor('--map-stroke')) .attr('stroke-width', 0.4); } - private renderCountries(path: d3.GeoPath): void { - if ((this.state.view === 'global' || this.state.view === 'mena') && this.worldData) { - const countries = topojson.feature( - this.worldData, - this.worldData.objects.countries - ); - - const features = 'features' in countries ? countries.features : [countries]; - - this.svg - .selectAll('.country') - .data(features) - .enter() - .append('path') - .attr('class', 'country') - .attr('d', path as unknown as string) - .attr('fill', '#0d3028') - .attr('stroke', '#1a8060') - .attr('stroke-width', 0.7); - } else if (this.state.view === 'us' && this.usData) { - const states = topojson.feature( - this.usData, - this.usData.objects.states - ); - - const features = 'features' in states ? states.features : [states]; + private renderCountries( + group: d3.Selection, + path: d3.GeoPath + ): void { + if (!this.countryFeatures) return; - this.svg - .selectAll('.state') - .data(features) - .enter() - .append('path') - .attr('class', 'state') - .attr('d', path as unknown as string) - .attr('fill', '#0d3028') - .attr('stroke', '#1a8060') - .attr('stroke-width', 0.7); - } + group + .selectAll('.country') + .data(this.countryFeatures) + .enter() + .append('path') + .attr('class', 'country') + .attr('d', path as unknown as string) + .attr('fill', getCSSColor('--map-country')) + .attr('stroke', getCSSColor('--map-stroke')) + .attr('stroke-width', 0.7); } private renderCables(projection: d3.GeoProjection): void { - const cableGroup = this.svg.append('g').attr('class', 'cables'); + if (!this.dynamicLayerGroup) return; + const cableGroup = this.dynamicLayerGroup.append('g').attr('class', 'cables'); UNDERSEA_CABLES.forEach((cable) => { const lineGenerator = d3 @@ -617,7 +1069,8 @@ export class MapComponent { } private renderPipelines(projection: d3.GeoProjection): void { - const pipelineGroup = this.svg.append('g').attr('class', 'pipelines'); + if (!this.dynamicLayerGroup) return; + const pipelineGroup = this.dynamicLayerGroup.append('g').attr('class', 'pipelines'); PIPELINES.forEach((pipeline) => { const lineGenerator = d3 @@ -626,7 +1079,7 @@ export class MapComponent { .y((d) => projection(d)?.[1] ?? 0) .curve(d3.curveCardinal.tension(0.5)); - const color = PIPELINE_COLORS[pipeline.type] || '#888888'; + const color = PIPELINE_COLORS[pipeline.type] || getCSSColor('--text-dim'); const opacity = 0.85; const dashArray = pipeline.status === 'construction' ? '4,2' : 'none'; @@ -662,7 +1115,8 @@ export class MapComponent { } private renderConflicts(projection: d3.GeoProjection): void { - const conflictGroup = this.svg.append('g').attr('class', 'conflicts'); + if (!this.dynamicLayerGroup) return; + const conflictGroup = this.dynamicLayerGroup.append('g').attr('class', 'conflicts'); CONFLICT_ZONES.forEach((zone) => { const points = zone.coords @@ -679,84 +1133,122 @@ export class MapComponent { }); } - private renderConflictLabels(projection: d3.GeoProjection): void { - CONFLICT_ZONES.forEach((zone) => { - const centerPos = projection(zone.center as [number, number]); - if (!centerPos) return; - - const div = document.createElement('div'); - div.className = 'conflict-label-overlay'; - div.style.left = `${centerPos[0]}px`; - div.style.top = `${centerPos[1]}px`; - div.textContent = zone.name; - - div.addEventListener('click', (e) => { - e.stopPropagation(); - const rect = this.container.getBoundingClientRect(); - this.popup.show({ - type: 'conflict', - data: zone, - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - }); - - this.overlays.appendChild(div); - }); - } - private renderSanctions(): void { - if (!this.worldData) return; + private updateCountryFills(): void { + if (!this.baseLayerGroup || !this.countryFeatures) return; const sanctionColors: Record = { severe: 'rgba(255, 0, 0, 0.35)', high: 'rgba(255, 100, 0, 0.25)', moderate: 'rgba(255, 200, 0, 0.2)', }; + const defaultFill = getCSSColor('--map-country'); + const useSanctions = this.state.layers.sanctions; - this.svg.selectAll('.country').each(function () { + this.baseLayerGroup.selectAll('.country').each(function (datum) { const el = d3.select(this); - const id = el.datum() as { id?: number }; + const id = datum as { id?: number }; + if (!useSanctions) { + el.attr('fill', defaultFill); + return; + } if (id?.id !== undefined && SANCTIONED_COUNTRIES[id.id]) { const level = SANCTIONED_COUNTRIES[id.id]; if (level) { - el.attr('fill', sanctionColors[level] || '#0a2018'); + el.attr('fill', sanctionColors[level] || defaultFill); + return; } } + el.attr('fill', defaultFill); }); } - private renderOverlays(projection: d3.GeoProjection): void { - this.overlays.innerHTML = ''; - - const isGlobalOrMena = this.state.view === 'global' || this.state.view === 'mena'; - - // Global/MENA only overlays - if (isGlobalOrMena) { - // Country labels (rendered first so they appear behind other overlays) - if (this.state.layers.countries) { - this.renderCountryLabels(projection); + // Generic marker clustering - groups markers within pixelRadius into clusters + // groupKey function ensures only items with same key can cluster (e.g., same city) + private clusterMarkers( + items: T[], + projection: d3.GeoProjection, + pixelRadius: number, + getGroupKey?: (item: T) => string + ): Array<{ items: T[]; center: [number, number]; pos: [number, number] }> { + const clusters: Array<{ items: T[]; center: [number, number]; pos: [number, number] }> = []; + const assigned = new Set(); + + for (let i = 0; i < items.length; i++) { + if (assigned.has(i)) continue; + + const item = items[i]!; + if (!Number.isFinite(item.lat) || !Number.isFinite(item.lon)) continue; + const pos = projection([item.lon, item.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) continue; + + const cluster: T[] = [item]; + assigned.add(i); + const itemKey = getGroupKey?.(item); + + // Find nearby items (must share same group key if provided) + for (let j = i + 1; j < items.length; j++) { + if (assigned.has(j)) continue; + const other = items[j]!; + + // Skip if different group keys (e.g., different cities) + if (getGroupKey && getGroupKey(other) !== itemKey) continue; + + if (!Number.isFinite(other.lat) || !Number.isFinite(other.lon)) continue; + const otherPos = projection([other.lon, other.lat]); + if (!otherPos || !Number.isFinite(otherPos[0]) || !Number.isFinite(otherPos[1])) continue; + + const dx = pos[0] - otherPos[0]; + const dy = pos[1] - otherPos[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist <= pixelRadius) { + cluster.push(other); + assigned.add(j); + } } - // Conflict zone labels (HTML overlay with counter-scaling) - if (this.state.layers.conflicts) { - this.renderConflictLabels(projection); + // Calculate cluster center + let sumLat = 0, sumLon = 0; + for (const c of cluster) { + sumLat += c.lat; + sumLon += c.lon; } + const centerLat = sumLat / cluster.length; + const centerLon = sumLon / cluster.length; + const centerPos = projection([centerLon, centerLat]); + const finalPos = (centerPos && Number.isFinite(centerPos[0]) && Number.isFinite(centerPos[1])) + ? centerPos : pos; + + clusters.push({ + items: cluster, + center: [centerLon, centerLat], + pos: finalPos, + }); + } - // Strategic waterways - if (this.state.layers.waterways) { - this.renderWaterways(projection); - } + return clusters; + } - if (this.state.layers.ais) { - this.renderAisDisruptions(projection); - } + private renderOverlays(projection: d3.GeoProjection): void { + this.overlays.innerHTML = ''; + + // Strategic waterways + if (this.state.layers.waterways) { + this.renderWaterways(projection); + } + + if (this.state.layers.ais) { + this.renderAisDisruptions(projection); + this.renderPorts(projection); + } - // APT groups + // APT groups (geopolitical variant only) + if (SITE_VARIANT !== 'tech') { this.renderAPTMarkers(projection); } - // Nuclear facilities + // Nuclear facilities (always HTML - shapes convey status) if (this.state.layers.nuclear) { NUCLEAR_FACILITIES.forEach((facility) => { const pos = projection([facility.lon, facility.lat]); @@ -769,11 +1261,6 @@ export class MapComponent { div.style.top = `${pos[1]}px`; div.title = `${facility.name} (${facility.type})`; - const label = document.createElement('div'); - label.className = 'nuclear-label'; - label.textContent = facility.name; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -789,7 +1276,7 @@ export class MapComponent { }); } - // Gamma irradiators (IAEA DIIF) + // Gamma irradiators (IAEA DIIF) - no labels, click to see details if (this.state.layers.irradiators) { GAMMA_IRRADIATORS.forEach((irradiator) => { const pos = projection([irradiator.lon, irradiator.lat]); @@ -801,11 +1288,6 @@ export class MapComponent { div.style.top = `${pos[1]}px`; div.title = `${irradiator.city}, ${irradiator.country}`; - const label = document.createElement('div'); - label.className = 'irradiator-label'; - label.textContent = irradiator.city; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -850,7 +1332,7 @@ export class MapComponent { }); } - // Hotspots + // Hotspots (always HTML - level colors and BREAKING badges) if (this.state.layers.hotspots) { this.hotspots.forEach((spot) => { const pos = projection([spot.lon, spot.lat]); @@ -861,19 +1343,8 @@ export class MapComponent { div.style.left = `${pos[0]}px`; div.style.top = `${pos[1]}px`; - const breakingBadge = spot.hasBreaking - ? '
BREAKING
' - : ''; - - const subtextHtml = spot.subtext - ? `
${spot.subtext}
` - : ''; - div.innerHTML = ` - ${breakingBadge} -
-
${spot.name}
- ${subtextHtml} +
`; div.addEventListener('click', (e) => { @@ -887,6 +1358,7 @@ export class MapComponent { x: e.clientX - rect.left, y: e.clientY - rect.top, }); + this.popup.loadHotspotGdeltContext(spot); this.onHotspotClick?.(spot); }); @@ -894,7 +1366,7 @@ export class MapComponent { }); } - // Military bases + // Military bases (always HTML - nation colors matter) if (this.state.layers.bases) { MILITARY_BASES.forEach((base) => { const pos = projection([base.lon, base.lat]); @@ -926,9 +1398,9 @@ export class MapComponent { }); } - // Earthquakes - if (this.state.layers.earthquakes) { - console.log('[Map] Rendering earthquakes. Total:', this.earthquakes.length, 'Layer enabled:', this.state.layers.earthquakes); + // Earthquakes (magnitude-based sizing) - part of NATURAL layer + if (this.state.layers.natural) { + console.log('[Map] Rendering earthquakes. Total:', this.earthquakes.length, 'Layer enabled:', this.state.layers.natural); const filteredQuakes = this.filterByTime(this.earthquakes); console.log('[Map] After time filter:', filteredQuakes.length, 'earthquakes. TimeRange:', this.state.timeRange); let rendered = 0; @@ -970,7 +1442,7 @@ export class MapComponent { console.log('[Map] Actually rendered', rendered, 'earthquake markers'); } - // Economic Centers + // Economic Centers (always HTML - emoji icons for type distinction) if (this.state.layers.economic) { ECONOMIC_CENTERS.forEach((center) => { const pos = projection([center.lon, center.lat]); @@ -985,11 +1457,7 @@ export class MapComponent { icon.className = 'economic-icon'; icon.textContent = center.type === 'exchange' ? '📈' : center.type === 'central-bank' ? '🏛' : '💰'; div.appendChild(icon); - - const label = document.createElement('div'); - label.className = 'economic-label'; - label.textContent = center.name; - div.appendChild(label); + div.title = center.name; div.addEventListener('click', (e) => { e.stopPropagation(); @@ -1006,7 +1474,7 @@ export class MapComponent { }); } - // Weather Alerts + // Weather Alerts (severity icons) if (this.state.layers.weather) { this.weatherAlerts.forEach((alert) => { if (!alert.centroid) return; @@ -1024,11 +1492,6 @@ export class MapComponent { icon.textContent = '⚠'; div.appendChild(icon); - const label = document.createElement('div'); - label.className = 'weather-label'; - label.textContent = alert.event; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -1044,7 +1507,7 @@ export class MapComponent { }); } - // Internet Outages + // Internet Outages (severity colors) if (this.state.layers.outages) { this.outages.forEach((outage) => { const pos = projection([outage.lon, outage.lat]); @@ -1081,7 +1544,7 @@ export class MapComponent { } // Cable advisories & repair ships - if (this.state.layers.cables && isGlobalOrMena) { + if (this.state.layers.cables) { this.cableAdvisories.forEach((advisory) => { const pos = projection([advisory.lon, advisory.lat]); if (!pos) return; @@ -1149,9 +1612,10 @@ export class MapComponent { }); } - // AI Data Centers + // AI Data Centers (always HTML - 🖥️ icons, filter to ≥10k GPUs) + const MIN_GPU_COUNT = 10000; if (this.state.layers.datacenters) { - AI_DATA_CENTERS.forEach((dc) => { + AI_DATA_CENTERS.filter(dc => (dc.chipCount || 0) >= MIN_GPU_COUNT).forEach((dc) => { const pos = projection([dc.lon, dc.lat]); if (!pos) return; @@ -1166,11 +1630,6 @@ export class MapComponent { icon.textContent = '🖥️'; div.appendChild(icon); - const label = document.createElement('div'); - label.className = 'datacenter-label'; - label.textContent = dc.owner; - div.appendChild(label); - div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); @@ -1186,39 +1645,33 @@ export class MapComponent { }); } - // Protests / Social Unrest Events - if (this.state.layers.protests) { - this.protests.forEach((event) => { - const pos = projection([event.lon, event.lat]); + // Spaceports (🚀 icon) + if (this.state.layers.spaceports) { + SPACEPORTS.forEach((port) => { + const pos = projection([port.lon, port.lat]); if (!pos) return; const div = document.createElement('div'); - div.className = `protest-marker ${event.severity} ${event.eventType}`; + div.className = `spaceport-marker ${port.status}`; div.style.left = `${pos[0]}px`; div.style.top = `${pos[1]}px`; const icon = document.createElement('div'); - icon.className = 'protest-icon'; - icon.textContent = event.eventType === 'riot' ? '🔥' : event.eventType === 'strike' ? '✊' : '📢'; + icon.className = 'spaceport-icon'; + icon.textContent = '🚀'; div.appendChild(icon); - if (this.state.zoom >= 4) { - const label = document.createElement('div'); - label.className = 'protest-label'; - label.textContent = event.city || event.country; - div.appendChild(label); - } - - if (event.validated) { - div.classList.add('validated'); - } + const label = document.createElement('div'); + label.className = 'spaceport-label'; + label.textContent = port.name; + div.appendChild(label); div.addEventListener('click', (e) => { e.stopPropagation(); const rect = this.container.getBoundingClientRect(); this.popup.show({ - type: 'protest', - data: event, + type: 'spaceport', + data: port, x: e.clientX - rect.left, y: e.clientY - rect.top, }); @@ -1227,101 +1680,971 @@ export class MapComponent { this.overlays.appendChild(div); }); } - } - - private renderCountryLabels(projection: d3.GeoProjection): void { - COUNTRY_LABELS.forEach((country) => { - const pos = projection([country.lon, country.lat]); - if (!pos) return; - - const div = document.createElement('div'); - div.className = 'country-label'; - div.style.left = `${pos[0]}px`; - div.style.top = `${pos[1]}px`; - div.textContent = country.name; - div.dataset.countryId = String(country.id); - this.overlays.appendChild(div); - }); - } + // Critical Minerals (💎 icon) + if (this.state.layers.minerals) { + CRITICAL_MINERALS.forEach((mine) => { + const pos = projection([mine.lon, mine.lat]); + if (!pos) return; - private renderWaterways(projection: d3.GeoProjection): void { - STRATEGIC_WATERWAYS.forEach((waterway) => { - const pos = projection([waterway.lon, waterway.lat]); - if (!pos) return; + const div = document.createElement('div'); + div.className = `mineral-marker ${mine.status}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; - const div = document.createElement('div'); - div.className = 'waterway-marker'; - div.style.left = `${pos[0]}px`; - div.style.top = `${pos[1]}px`; - div.title = waterway.name; + const icon = document.createElement('div'); + icon.className = 'mineral-icon'; + // Select icon based on mineral type + icon.textContent = mine.mineral === 'Lithium' ? '🔋' : mine.mineral === 'Rare Earths' ? '🧲' : '💎'; + div.appendChild(icon); - const diamond = document.createElement('div'); - diamond.className = 'waterway-diamond'; - div.appendChild(diamond); + const label = document.createElement('div'); + label.className = 'mineral-label'; + label.textContent = `${mine.mineral} - ${mine.name}`; + div.appendChild(label); - div.addEventListener('click', (e) => { - e.stopPropagation(); - const rect = this.container.getBoundingClientRect(); - this.popup.show({ - type: 'waterway', - data: waterway, - x: e.clientX - rect.left, - y: e.clientY - rect.top, + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'mineral', + data: mine, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); }); - }); - this.overlays.appendChild(div); - }); - } + this.overlays.appendChild(div); + }); + } - private renderAisDisruptions(projection: d3.GeoProjection): void { - this.aisDisruptions.forEach((event) => { - const pos = projection([event.lon, event.lat]); - if (!pos) return; + // === TECH VARIANT LAYERS === - const div = document.createElement('div'); - div.className = `ais-disruption-marker ${event.severity} ${event.type}`; - div.style.left = `${pos[0]}px`; - div.style.top = `${pos[1]}px`; + // Startup Hubs (🚀 icon by tier) + if (this.state.layers.startupHubs) { + STARTUP_HUBS.forEach((hub) => { + const pos = projection([hub.lon, hub.lat]); + if (!pos) return; - const icon = document.createElement('div'); - icon.className = 'ais-disruption-icon'; - icon.textContent = event.type === 'gap_spike' ? '🛰️' : '🚢'; - div.appendChild(icon); + const div = document.createElement('div'); + div.className = `startup-hub-marker ${hub.tier}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; - const label = document.createElement('div'); - label.className = 'ais-disruption-label'; - label.textContent = event.name; - div.appendChild(label); + const icon = document.createElement('div'); + icon.className = 'startup-hub-icon'; + icon.textContent = hub.tier === 'mega' ? '🦄' : hub.tier === 'major' ? '🚀' : '💡'; + div.appendChild(icon); - div.addEventListener('click', (e) => { - e.stopPropagation(); - const rect = this.container.getBoundingClientRect(); - this.popup.show({ - type: 'ais', - data: event, - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - }); + if (this.state.zoom >= 2 || hub.tier === 'mega') { + const label = document.createElement('div'); + label.className = 'startup-hub-label'; + label.textContent = hub.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'startupHub', + data: hub, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Cloud Regions (☁️ icons by provider) + if (this.state.layers.cloudRegions) { + CLOUD_REGIONS.forEach((region) => { + const pos = projection([region.lon, region.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `cloud-region-marker ${region.provider}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'cloud-region-icon'; + // Provider-specific icons + const icons: Record = { aws: '🟠', gcp: '🔵', azure: '🟣', cloudflare: '🟡' }; + icon.textContent = icons[region.provider] || '☁️'; + div.appendChild(icon); + + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'cloud-region-label'; + label.textContent = region.provider.toUpperCase(); + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'cloudRegion', + data: region, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Tech HQs (🏢 icons by company type) - with clustering by city + if (this.state.layers.techHQs) { + // Cluster radius depends on zoom - tighter clustering when zoomed out + const clusterRadius = this.state.zoom >= 4 ? 15 : this.state.zoom >= 3 ? 25 : 40; + // Group by city to prevent clustering companies from different cities + const clusters = this.clusterMarkers(TECH_HQS, projection, clusterRadius, hq => hq.city); + + clusters.forEach((cluster) => { + if (cluster.items.length === 0) return; + const div = document.createElement('div'); + const isCluster = cluster.items.length > 1; + const primaryItem = cluster.items[0]!; // Use first item for styling + + div.className = `tech-hq-marker ${primaryItem.type} ${isCluster ? 'cluster' : ''}`; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'tech-hq-icon'; + + if (isCluster) { + // Show count for clusters + const unicornCount = cluster.items.filter(h => h.type === 'unicorn').length; + const faangCount = cluster.items.filter(h => h.type === 'faang').length; + icon.textContent = faangCount > 0 ? '🏛️' : unicornCount > 0 ? '🦄' : '🏢'; + + const badge = document.createElement('div'); + badge.className = 'cluster-badge'; + badge.textContent = String(cluster.items.length); + div.appendChild(badge); + + div.title = cluster.items.map(h => h.company).join(', '); + } else { + icon.textContent = primaryItem.type === 'faang' ? '🏛️' : primaryItem.type === 'unicorn' ? '🦄' : '🏢'; + } + div.appendChild(icon); + + // Show label at higher zoom or for single FAANG markers + if (!isCluster && (this.state.zoom >= 3 || primaryItem.type === 'faang')) { + const label = document.createElement('div'); + label.className = 'tech-hq-label'; + label.textContent = primaryItem.company; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + if (isCluster) { + // Show cluster popup with list of companies + this.popup.show({ + type: 'techHQCluster', + data: { items: cluster.items, city: primaryItem.city, country: primaryItem.country }, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } else { + this.popup.show({ + type: 'techHQ', + data: primaryItem, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }); + + this.overlays.appendChild(div); + }); + } + + // Accelerators (🎯 icons) + if (this.state.layers.accelerators) { + ACCELERATORS.forEach((acc) => { + const pos = projection([acc.lon, acc.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `accelerator-marker ${acc.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'accelerator-icon'; + icon.textContent = acc.type === 'accelerator' ? '🎯' : acc.type === 'incubator' ? '🔬' : '🎨'; + div.appendChild(icon); + + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'accelerator-label'; + label.textContent = acc.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'accelerator', + data: acc, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Tech Events / Conferences (📅 icons) - with clustering + if (this.state.layers.techEvents && this.techEvents.length > 0) { + const mapWidth = this.container.clientWidth; + const mapHeight = this.container.clientHeight; + + // Map events to have lon property for clustering, filter visible + const visibleEvents = this.techEvents + .map(e => ({ ...e, lon: e.lng })) + .filter(e => { + const pos = projection([e.lon, e.lat]); + return pos && pos[0] >= 0 && pos[0] <= mapWidth && pos[1] >= 0 && pos[1] <= mapHeight; + }); + + const clusterRadius = this.state.zoom >= 4 ? 15 : this.state.zoom >= 3 ? 25 : 40; + // Group by location to prevent clustering events from different cities + const clusters = this.clusterMarkers(visibleEvents, projection, clusterRadius, e => e.location); + + clusters.forEach((cluster) => { + if (cluster.items.length === 0) return; + const div = document.createElement('div'); + const isCluster = cluster.items.length > 1; + const primaryEvent = cluster.items[0]!; + const hasUpcomingSoon = cluster.items.some(e => e.daysUntil <= 14); + + div.className = `tech-event-marker ${hasUpcomingSoon ? 'upcoming-soon' : ''} ${isCluster ? 'cluster' : ''}`; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + + if (isCluster) { + const badge = document.createElement('div'); + badge.className = 'cluster-badge'; + badge.textContent = String(cluster.items.length); + div.appendChild(badge); + div.title = cluster.items.map(e => e.title).join(', '); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + if (isCluster) { + this.popup.show({ + type: 'techEventCluster', + data: { items: cluster.items, location: primaryEvent.location, country: primaryEvent.country }, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } else { + this.popup.show({ + type: 'techEvent', + data: primaryEvent, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }); + + this.overlays.appendChild(div); + }); + } + + // Stock Exchanges (🏛️ icon by tier) + if (this.state.layers.stockExchanges) { + STOCK_EXCHANGES.forEach((exchange) => { + const pos = projection([exchange.lon, exchange.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = exchange.tier === 'mega' ? '🏛️' : exchange.tier === 'major' ? '📊' : '📈'; + const div = document.createElement('div'); + div.className = `map-marker exchange-marker tier-${exchange.tier}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = exchange.tier === 'mega' ? '50' : '40'; + div.textContent = icon; + div.title = `${exchange.shortName} (${exchange.city})`; + + if ((this.state.zoom >= 2 && exchange.tier === 'mega') || this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = exchange.shortName; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'stockExchange', + data: exchange, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Financial Centers (💰 icon by type) + if (this.state.layers.financialCenters) { + FINANCIAL_CENTERS.forEach((center) => { + const pos = projection([center.lon, center.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = center.type === 'global' ? '💰' : center.type === 'regional' ? '🏦' : '🏝️'; + const div = document.createElement('div'); + div.className = `map-marker financial-center-marker type-${center.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = center.type === 'global' ? '45' : '35'; + div.textContent = icon; + div.title = `${center.name} Financial Center`; + + if ((this.state.zoom >= 2 && center.type === 'global') || this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = center.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'financialCenter', + data: center, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Central Banks (🏛️ icon by type) + if (this.state.layers.centralBanks) { + CENTRAL_BANKS.forEach((bank) => { + const pos = projection([bank.lon, bank.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = bank.type === 'supranational' ? '🌐' : bank.type === 'major' ? '🏛️' : '🏦'; + const div = document.createElement('div'); + div.className = `map-marker central-bank-marker type-${bank.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = bank.type === 'supranational' ? '48' : bank.type === 'major' ? '42' : '38'; + div.textContent = icon; + div.title = `${bank.shortName} - ${bank.name}`; + + if ((this.state.zoom >= 2 && (bank.type === 'major' || bank.type === 'supranational')) || this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = bank.shortName; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'centralBank', + data: bank, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Commodity Hubs (⛽ icon by type) + if (this.state.layers.commodityHubs) { + COMMODITY_HUBS.forEach((hub) => { + const pos = projection([hub.lon, hub.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + + const icon = hub.type === 'exchange' ? '📦' : hub.type === 'port' ? '🚢' : '⛽'; + const div = document.createElement('div'); + div.className = `map-marker commodity-hub-marker type-${hub.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = '38'; + div.textContent = icon; + div.title = `${hub.name} (${hub.city})`; + + if (this.state.zoom >= 3) { + const label = document.createElement('span'); + label.className = 'marker-label'; + label.textContent = hub.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'commodityHub', + data: hub, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Tech Hub Activity Markers (shows activity heatmap for tech hubs with news activity) + if (SITE_VARIANT === 'tech' && this.techActivities.length > 0) { + this.techActivities.forEach((activity) => { + const pos = projection([activity.lon, activity.lat]); + if (!pos) return; + + // Only show markers for hubs with actual activity + if (activity.newsCount === 0) return; + + const div = document.createElement('div'); + div.className = `tech-activity-marker ${activity.activityLevel}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = activity.activityLevel === 'high' ? '60' : activity.activityLevel === 'elevated' ? '50' : '40'; + div.title = `${activity.city}: ${activity.newsCount} stories`; + + div.addEventListener('click', (e) => { + e.stopPropagation(); + this.onTechHubClick?.(activity); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'techActivity', + data: activity, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + + // Add label for high/elevated activity hubs at sufficient zoom + if ((activity.activityLevel === 'high' || (activity.activityLevel === 'elevated' && this.state.zoom >= 2)) && this.state.zoom >= 1.5) { + const label = document.createElement('div'); + label.className = 'tech-activity-label'; + label.textContent = activity.city; + label.style.left = `${pos[0]}px`; + label.style.top = `${pos[1] + 14}px`; + this.overlays.appendChild(label); + } + }); + } + + // Geo Hub Activity Markers (shows activity heatmap for geopolitical hubs - full variant) + if (SITE_VARIANT === 'full' && this.geoActivities.length > 0) { + this.geoActivities.forEach((activity) => { + const pos = projection([activity.lon, activity.lat]); + if (!pos) return; + + // Only show markers for hubs with actual activity + if (activity.newsCount === 0) return; + + const div = document.createElement('div'); + div.className = `geo-activity-marker ${activity.activityLevel}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.style.zIndex = activity.activityLevel === 'high' ? '60' : activity.activityLevel === 'elevated' ? '50' : '40'; + div.title = `${activity.name}: ${activity.newsCount} stories`; + + div.addEventListener('click', (e) => { + e.stopPropagation(); + this.onGeoHubClick?.(activity); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'geoActivity', + data: activity, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Protests / Social Unrest Events (severity colors + icons) - with clustering + // Filter to show only significant events on map (all events still used for CII analysis) + if (this.state.layers.protests) { + const significantProtests = this.protests.filter((event) => { + // Only show riots and high severity (red markers) + // All protests still counted in CII analysis + return event.eventType === 'riot' || event.severity === 'high'; + }); + + const clusterRadius = this.state.zoom >= 4 ? 12 : this.state.zoom >= 3 ? 20 : 35; + const clusters = this.clusterMarkers(significantProtests, projection, clusterRadius, p => p.country); + + clusters.forEach((cluster) => { + if (cluster.items.length === 0) return; + const div = document.createElement('div'); + const isCluster = cluster.items.length > 1; + const primaryEvent = cluster.items[0]!; + const hasRiot = cluster.items.some(e => e.eventType === 'riot'); + const hasHighSeverity = cluster.items.some(e => e.severity === 'high'); + + div.className = `protest-marker ${hasHighSeverity ? 'high' : primaryEvent.severity} ${hasRiot ? 'riot' : primaryEvent.eventType} ${isCluster ? 'cluster' : ''}`; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'protest-icon'; + icon.textContent = hasRiot ? '🔥' : primaryEvent.eventType === 'strike' ? '✊' : '📢'; + div.appendChild(icon); + + if (isCluster) { + const badge = document.createElement('div'); + badge.className = 'cluster-badge'; + badge.textContent = String(cluster.items.length); + div.appendChild(badge); + div.title = `${primaryEvent.country}: ${cluster.items.length} ${t('popups.events')}`; + } else { + div.title = `${primaryEvent.city || primaryEvent.country} - ${primaryEvent.eventType} (${primaryEvent.severity})`; + if (primaryEvent.validated) { + div.classList.add('validated'); + } + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + if (isCluster) { + this.popup.show({ + type: 'protestCluster', + data: { items: cluster.items, country: primaryEvent.country }, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } else { + this.popup.show({ + type: 'protest', + data: primaryEvent, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + } + }); + + this.overlays.appendChild(div); + }); + } + + // Flight Delays (delay severity colors + ✈️ icons) + if (this.state.layers.flights) { + this.flightDelays.forEach((delay) => { + const pos = projection([delay.lon, delay.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `flight-delay-marker ${delay.severity}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'flight-delay-icon'; + icon.textContent = delay.delayType === 'ground_stop' ? '🛑' : delay.severity === 'severe' ? '✈️' : '🛫'; + div.appendChild(icon); + + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'flight-delay-label'; + label.textContent = `${delay.iata} ${delay.avgDelayMinutes > 0 ? `+${delay.avgDelayMinutes}m` : ''}`; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'flight', + data: delay, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Military Tracking (flights and vessels) + if (this.state.layers.military) { + // Render individual flights + this.militaryFlights.forEach((flight) => { + const pos = projection([flight.lon, flight.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-flight-marker ${flight.operator} ${flight.aircraftType}${flight.isInteresting ? ' interesting' : ''}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + // Crosshair icon - rotates with heading + const icon = document.createElement('div'); + icon.className = `military-flight-icon ${flight.aircraftType}`; + icon.style.transform = `rotate(${flight.heading}deg)`; + // CSS handles the crosshair rendering + div.appendChild(icon); + + // Show callsign at higher zoom levels + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'military-flight-label'; + label.textContent = flight.callsign; + div.appendChild(label); + } + + // Show altitude indicator + if (flight.altitude > 0) { + const alt = document.createElement('div'); + alt.className = 'military-flight-altitude'; + alt.textContent = `FL${Math.round(flight.altitude / 100)}`; + div.appendChild(alt); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryFlight', + data: flight, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + + // Render flight track if available + if (flight.track && flight.track.length > 1 && this.state.zoom >= 2) { + const trackLine = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + const points = flight.track + .map((p) => { + const pt = projection([p[1], p[0]]); + return pt ? `${pt[0]},${pt[1]}` : null; + }) + .filter(Boolean) + .join(' '); + + if (points) { + trackLine.setAttribute('points', points); + trackLine.setAttribute('class', `military-flight-track ${flight.operator}`); + trackLine.setAttribute('fill', 'none'); + trackLine.setAttribute('stroke-width', '1.5'); + trackLine.setAttribute('stroke-dasharray', '4,2'); + this.dynamicLayerGroup?.select('.overlays-svg').append(() => trackLine); + } + } + }); + + // Render flight clusters + this.militaryFlightClusters.forEach((cluster) => { + const pos = projection([cluster.lon, cluster.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-cluster-marker flight-cluster ${cluster.activityType || 'unknown'}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const count = document.createElement('div'); + count.className = 'cluster-count'; + count.textContent = String(cluster.flightCount); + div.appendChild(count); + + const label = document.createElement('div'); + label.className = 'cluster-label'; + label.textContent = cluster.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryFlightCluster', + data: cluster, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + + // Military Vessels (warships, carriers, submarines) + // Render individual vessels + this.militaryVessels.forEach((vessel) => { + const pos = projection([vessel.lon, vessel.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-vessel-marker ${vessel.operator} ${vessel.vesselType}${vessel.isDark ? ' dark-vessel' : ''}${vessel.isInteresting ? ' interesting' : ''}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = `military-vessel-icon ${vessel.vesselType}`; + icon.style.transform = `rotate(${vessel.heading}deg)`; + // CSS handles the diamond/anchor rendering + div.appendChild(icon); + + // Dark vessel warning indicator + if (vessel.isDark) { + const darkIndicator = document.createElement('div'); + darkIndicator.className = 'dark-vessel-indicator'; + darkIndicator.textContent = '⚠️'; + darkIndicator.title = 'AIS Signal Lost'; + div.appendChild(darkIndicator); + } + + // Show vessel name at higher zoom + if (this.state.zoom >= 3) { + const label = document.createElement('div'); + label.className = 'military-vessel-label'; + label.textContent = vessel.name; + div.appendChild(label); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryVessel', + data: vessel, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + + // Render vessel track if available + if (vessel.track && vessel.track.length > 1 && this.state.zoom >= 2) { + const trackLine = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + const points = vessel.track + .map((p) => { + const pt = projection([p[1], p[0]]); + return pt ? `${pt[0]},${pt[1]}` : null; + }) + .filter(Boolean) + .join(' '); + + if (points) { + trackLine.setAttribute('points', points); + trackLine.setAttribute('class', `military-vessel-track ${vessel.operator}`); + trackLine.setAttribute('fill', 'none'); + trackLine.setAttribute('stroke-width', '2'); + this.dynamicLayerGroup?.select('.overlays-svg').append(() => trackLine); + } + } + }); + + // Render vessel clusters + this.militaryVesselClusters.forEach((cluster) => { + const pos = projection([cluster.lon, cluster.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `military-cluster-marker vessel-cluster ${cluster.activityType || 'unknown'}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const count = document.createElement('div'); + count.className = 'cluster-count'; + count.textContent = String(cluster.vesselCount); + div.appendChild(count); + + const label = document.createElement('div'); + label.className = 'cluster-label'; + label.textContent = cluster.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'militaryVesselCluster', + data: cluster, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Natural Events (NASA EONET) - part of NATURAL layer + if (this.state.layers.natural) { + this.naturalEvents.forEach((event) => { + const pos = projection([event.lon, event.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `nat-event-marker ${event.category}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'nat-event-icon'; + icon.textContent = getNaturalEventIcon(event.category); + div.appendChild(icon); + + if (this.state.zoom >= 2) { + const label = document.createElement('div'); + label.className = 'nat-event-label'; + label.textContent = event.title.length > 25 ? event.title.slice(0, 25) + '…' : event.title; + div.appendChild(label); + } + + if (event.magnitude) { + const mag = document.createElement('div'); + mag.className = 'nat-event-magnitude'; + mag.textContent = `${event.magnitude}${event.magnitudeUnit ? ` ${event.magnitudeUnit}` : ''}`; + div.appendChild(mag); + } + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'natEvent', + data: event, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + // Satellite Fires (NASA FIRMS) - separate fires layer + if (this.state.layers.fires) { + this.firmsFireData.forEach((fire) => { + const pos = projection([fire.lon, fire.lat]); + if (!pos) return; + + const color = fire.brightness > 400 ? getCSSColor('--semantic-critical') : fire.brightness > 350 ? getCSSColor('--semantic-high') : getCSSColor('--semantic-elevated'); + const size = Math.max(4, Math.min(10, (fire.frp || 1) * 0.5)); + + const dot = document.createElement('div'); + dot.className = 'fire-dot'; + dot.style.left = `${pos[0]}px`; + dot.style.top = `${pos[1]}px`; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.backgroundColor = color; + dot.title = `${fire.region} — ${Math.round(fire.brightness)}K, ${fire.frp}MW`; + + this.overlays.appendChild(dot); + }); + } + } + + private renderWaterways(projection: d3.GeoProjection): void { + STRATEGIC_WATERWAYS.forEach((waterway) => { + const pos = projection([waterway.lon, waterway.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = 'waterway-marker'; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + div.title = waterway.name; + + const diamond = document.createElement('div'); + diamond.className = 'waterway-diamond'; + div.appendChild(diamond); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'waterway', + data: waterway, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); + }); + } + + private renderAisDisruptions(projection: d3.GeoProjection): void { + this.aisDisruptions.forEach((event) => { + const pos = projection([event.lon, event.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `ais-disruption-marker ${event.severity} ${event.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'ais-disruption-icon'; + icon.textContent = event.type === 'gap_spike' ? '🛰️' : '🚢'; + div.appendChild(icon); + + const label = document.createElement('div'); + label.className = 'ais-disruption-label'; + label.textContent = event.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'ais', + data: event, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); this.overlays.appendChild(div); }); } private renderAisDensity(projection: d3.GeoProjection): void { - const densityGroup = this.svg.append('g').attr('class', 'ais-density'); + if (!this.dynamicLayerGroup) return; + const densityGroup = this.dynamicLayerGroup.append('g').attr('class', 'ais-density'); this.aisDensity.forEach((zone) => { const pos = projection([zone.lon, zone.lat]); if (!pos) return; const intensity = Math.min(Math.max(zone.intensity, 0.15), 1); - const radius = 18 + intensity * 45; + const radius = 4 + intensity * 8; // Small dots (4-12px) const isCongested = zone.deltaPct >= 15; - const color = isCongested ? '#ffb703' : '#00d1ff'; - const fillOpacity = 0.08 + intensity * 0.22; + const color = isCongested ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-info'); + const fillOpacity = 0.15 + intensity * 0.25; // More visible individual dots densityGroup .append('circle') @@ -1331,9 +2654,42 @@ export class MapComponent { .attr('r', radius) .attr('fill', color) .attr('fill-opacity', fillOpacity) - .attr('stroke', color) - .attr('stroke-opacity', 0.35) - .attr('stroke-width', 1); + .attr('stroke', 'none'); + }); + } + + private renderPorts(projection: d3.GeoProjection): void { + PORTS.forEach((port) => { + const pos = projection([port.lon, port.lat]); + if (!pos) return; + + const div = document.createElement('div'); + div.className = `port-marker port-${port.type}`; + div.style.left = `${pos[0]}px`; + div.style.top = `${pos[1]}px`; + + const icon = document.createElement('div'); + icon.className = 'port-icon'; + icon.textContent = port.type === 'naval' ? '⚓' : port.type === 'oil' || port.type === 'lng' ? '🛢️' : '🏭'; + div.appendChild(icon); + + const label = document.createElement('div'); + label.className = 'port-label'; + label.textContent = port.name; + div.appendChild(label); + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'port', + data: port, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + }); + + this.overlays.appendChild(div); }); } @@ -1348,7 +2704,7 @@ export class MapComponent { div.style.top = `${pos[1]}px`; div.innerHTML = `
-
${apt.name}
+
${escapeHtml(apt.name)}
`; div.addEventListener('click', (e) => { @@ -1454,21 +2810,78 @@ export class MapComponent { spot.level = 'low'; spot.status = 'Monitoring'; } + + // Update dynamic escalation score + const velocity = matchedCount > 0 ? score / matchedCount : 0; + updateHotspotEscalation(spot.id, matchedCount, hasBreaking, velocity); }); this.render(); } + public flashLocation(lat: number, lon: number, durationMs = 2000): void { + const width = this.container.clientWidth; + const height = this.container.clientHeight; + if (!width || !height) return; + + const projection = this.getProjection(width, height); + const pos = projection([lon, lat]); + if (!pos) return; + + const flash = document.createElement('div'); + flash.className = 'map-flash'; + flash.style.left = `${pos[0]}px`; + flash.style.top = `${pos[1]}px`; + flash.style.setProperty('--flash-duration', `${durationMs}ms`); + this.overlays.appendChild(flash); + + window.setTimeout(() => { + flash.remove(); + }, durationMs); + } + + public initEscalationGetters(): void { + setCIIGetter(getCountryScore); + setGeoAlertGetter(getAlertsNearLocation); + } + + public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void { + setMilitaryData(flights, vessels); + } + + public getHotspotDynamicScore(hotspotId: string) { + return getHotspotEscalation(hotspotId); + } + public setView(view: MapView): void { this.state.view = view; - // Reset zoom when changing views for better UX - this.state.zoom = view === 'mena' ? 2.5 : 1; - this.state.pan = view === 'mena' ? { x: -180, y: 60 } : { x: 0, y: 0 }; + + // Region-specific zoom and pan settings + // Pan: +x = west, -x = east, +y = north, -y = south + const viewSettings: Record = { + global: { zoom: 1, pan: { x: 0, y: 0 } }, + america: { zoom: 1.8, pan: { x: 180, y: 30 } }, + mena: { zoom: 3.5, pan: { x: -100, y: 50 } }, + eu: { zoom: 2.4, pan: { x: -30, y: 100 } }, + asia: { zoom: 2.0, pan: { x: -320, y: 40 } }, + latam: { zoom: 2.0, pan: { x: 120, y: -100 } }, + africa: { zoom: 2.2, pan: { x: -40, y: -30 } }, + oceania: { zoom: 2.2, pan: { x: -420, y: -100 } }, + }; + + const settings = viewSettings[view]; + this.state.zoom = settings.zoom; + this.state.pan = settings.pan; this.applyTransform(); this.render(); } + private static readonly ASYNC_DATA_LAYERS: Set = new Set([ + 'natural', 'weather', 'outages', 'ais', 'protests', 'flights', 'military', 'techEvents', + ]); + public toggleLayer(layer: keyof MapLayers): void { + console.log(`[Map.toggleLayer] ${layer}: ${this.state.layers[layer]} -> ${!this.state.layers[layer]}`); this.state.layers[layer] = !this.state.layers[layer]; if (this.state.layers[layer]) { const thresholds = MapComponent.LAYER_ZOOM_THRESHOLDS[layer]; @@ -1481,17 +2894,55 @@ export class MapComponent { delete this.layerZoomOverrides[layer]; } - const btn = document.querySelector(`[data-layer="${layer}"]`); - btn?.classList.toggle('active'); + const btn = this.container.querySelector(`[data-layer="${layer}"]`); + const isEnabled = this.state.layers[layer]; + const isAsyncLayer = MapComponent.ASYNC_DATA_LAYERS.has(layer); + + if (isEnabled && isAsyncLayer) { + // Async layers: start in loading state, will be set to active when data arrives + btn?.classList.remove('active'); + btn?.classList.add('loading'); + } else { + // Static layers or disabling: toggle active immediately + btn?.classList.toggle('active', isEnabled); + btn?.classList.remove('loading'); + } this.onLayerChange?.(layer, this.state.layers[layer]); - this.render(); + // Defer render to next frame to avoid blocking the click handler + requestAnimationFrame(() => this.render()); } public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean) => void): void { this.onLayerChange = callback; } + public hideLayerToggle(layer: keyof MapLayers): void { + const btn = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (btn) { + (btn as HTMLElement).style.display = 'none'; + } + } + + public setLayerLoading(layer: keyof MapLayers, loading: boolean): void { + const btn = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (btn) { + btn.classList.toggle('loading', loading); + } + } + + public setLayerReady(layer: keyof MapLayers, hasData: boolean): void { + const btn = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); + if (!btn) return; + + btn.classList.remove('loading'); + if (this.state.layers[layer] && hasData) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + } + public onStateChanged(callback: (state: MapState) => void): void { this.onStateChange = callback; } @@ -1509,7 +2960,12 @@ export class MapComponent { public reset(): void { this.state.zoom = 1; this.state.pan = { x: 0, y: 0 }; - this.applyTransform(); + if (this.state.view !== 'global') { + this.state.view = 'global'; + this.render(); + } else { + this.applyTransform(); + } } public triggerHotspotClick(id: string): void { @@ -1530,6 +2986,7 @@ export class MapComponent { x: pos[0], y: pos[1], }); + this.popup.loadHotspotGdeltContext(hotspot); this.onHotspotClick?.(hotspot); } @@ -1695,15 +3152,11 @@ export class MapComponent { const zoom = this.state.zoom; const width = this.container.clientWidth; const height = this.container.clientHeight; - const mapHeight = width / 2; // Equirectangular 2:1 ratio - // Horizontal: at zoom 1, no pan. At higher zooms, allow proportional pan - const maxPanX = ((zoom - 1) / zoom) * (width / 2); - - // Vertical: allow panning to see poles if map extends beyond container - const extraVertical = Math.max(0, (mapHeight - height) / 2); - const zoomPanY = ((zoom - 1) / zoom) * (height / 2); - const maxPanY = extraVertical + zoomPanY; + // Allow generous panning - maps should be explorable + // Scale limits with zoom to allow reaching edges at higher zoom + const maxPanX = (width / 2) * Math.max(1, zoom * 0.8); + const maxPanY = (height / 2) * Math.max(1, zoom * 0.8); this.state.pan.x = Math.max(-maxPanX, Math.min(maxPanX, this.state.pan.x)); this.state.pan.y = Math.max(-maxPanY, Math.min(maxPanY, this.state.pan.y)); @@ -1712,7 +3165,17 @@ export class MapComponent { private applyTransform(): void { this.clampPan(); const zoom = this.state.zoom; - this.wrapper.style.transform = `scale(${zoom}) translate(${this.state.pan.x}px, ${this.state.pan.y}px)`; + const width = this.container.clientWidth; + const height = this.container.clientHeight; + + // With transform-origin: 0 0, we need to offset to keep center in view + // Formula: translate first to re-center, then scale + const centerOffsetX = (width / 2) * (1 - zoom); + const centerOffsetY = (height / 2) * (1 - zoom); + const tx = centerOffsetX + this.state.pan.x * zoom; + const ty = centerOffsetY + this.state.pan.y * zoom; + + this.wrapper.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`; // Set CSS variable for counter-scaling labels/markers // Labels: max 1.5x scale, so counter-scale = min(1.5, zoom) / zoom @@ -1766,13 +3229,13 @@ export class MapComponent { } private updateLabelVisibility(zoom: number): void { - const labels = this.overlays.querySelectorAll('.hotspot-label, .earthquake-label, .nuclear-label, .weather-label, .apt-label'); + const labels = this.overlays.querySelectorAll('.hotspot-label, .earthquake-label, .weather-label, .apt-label'); const labelRects: { el: Element; rect: DOMRect; priority: number }[] = []; // Collect all label bounds with priority labels.forEach((label) => { const el = label as HTMLElement; - const parent = el.closest('.hotspot, .earthquake-marker, .nuclear-marker, .weather-marker, .apt-marker'); + const parent = el.closest('.hotspot, .earthquake-marker, .weather-marker, .apt-marker'); // Assign priority based on parent type and level let priority = 1; @@ -1787,9 +3250,6 @@ export class MapComponent { if (parent.classList.contains('extreme')) priority = 5; else if (parent.classList.contains('severe')) priority = 4; else priority = 2; - } else if (parent?.classList.contains('nuclear-marker')) { - if (parent.classList.contains('contested')) priority = 5; - else priority = 3; } // Reset visibility first @@ -1856,20 +3316,53 @@ export class MapComponent { public setZoom(zoom: number): void { this.state.zoom = Math.max(1, Math.min(10, zoom)); this.applyTransform(); + // Ensure base layer is intact after zoom change + this.ensureBaseLayerIntact(); + } + + private ensureBaseLayerIntact(): void { + // Query DOM directly instead of relying on cached d3 selection + const svgNode = this.svg.node(); + const domBaseGroup = svgNode?.querySelector('.map-base'); + const selectionNode = this.baseLayerGroup?.node(); + + // Check for stale selection (d3 reference doesn't match DOM) + if (domBaseGroup && selectionNode !== domBaseGroup) { + console.warn('[Map] Stale base layer selection detected, forcing full rebuild'); + this.baseRendered = false; + this.render(); + return; + } + + // Check for missing countries + const countryCount = domBaseGroup?.querySelectorAll('.country').length ?? 0; + if (countryCount === 0 && this.countryFeatures && this.countryFeatures.length > 0) { + console.warn('[Map] Base layer missing countries, triggering recovery render'); + this.baseRendered = false; + this.render(); + } } public setCenter(lat: number, lon: number): void { + console.log('[Map] setCenter called:', { lat, lon }); const width = this.container.clientWidth; const height = this.container.clientHeight; const projection = this.getProjection(width, height); const pos = projection([lon, lat]); + console.log('[Map] projected pos:', pos, 'container:', { width, height }, 'zoom:', this.state.zoom); if (!pos) return; - const zoom = this.state.zoom; + // Pan formula: after applyTransform() computes tx = centerOffset + pan*zoom, + // and transform is translate(tx,ty) scale(zoom), to center on pos: + // pos*zoom + tx = width/2 → tx = width/2 - pos*zoom + // Solving: (width/2)(1-zoom) + pan*zoom = width/2 - pos*zoom + // → pan = width/2 - pos (independent of zoom) this.state.pan = { - x: width / (2 * zoom) - pos[0], - y: height / (2 * zoom) - pos[1], + x: width / 2 - pos[0], + y: height / 2 - pos[1], }; this.applyTransform(); + // Ensure base layer is intact after pan + this.ensureBaseLayerIntact(); } public setLayers(layers: MapLayers): void { @@ -1916,6 +3409,65 @@ export class MapComponent { this.render(); } + public setFlightDelays(delays: AirportDelayAlert[]): void { + this.flightDelays = delays; + this.render(); + } + + public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { + this.militaryFlights = flights; + this.militaryFlightClusters = clusters; + this.render(); + } + + public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { + this.militaryVessels = vessels; + this.militaryVesselClusters = clusters; + this.render(); + } + + public setNaturalEvents(events: NaturalEvent[]): void { + this.naturalEvents = events; + this.render(); + } + + public setFires(fires: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }>): void { + this.firmsFireData = fires; + this.render(); + } + + public setTechEvents(events: TechEventMarker[]): void { + this.techEvents = events; + this.render(); + } + + public setCyberThreats(_threats: CyberThreat[]): void { + // SVG/mobile fallback intentionally does not render this layer to stay lightweight. + } + + public setNewsLocations(_data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { + // SVG fallback: news locations rendered as simple circles + // For now, skip on SVG map to keep mobile lightweight + } + + public setTechActivity(activities: TechHubActivity[]): void { + this.techActivities = activities; + this.render(); + } + + public setOnTechHubClick(handler: (hub: TechHubActivity) => void): void { + this.onTechHubClick = handler; + } + + public setGeoActivity(activities: GeoHubActivity[]): void { + this.geoActivities = activities; + this.render(); + } + + public setOnGeoHubClick(handler: (hub: GeoHubActivity) => void): void { + this.onGeoHubClick = handler; + } + private getCableAdvisory(cableId: string): CableAdvisory | undefined { const advisories = this.cableAdvisories.filter((advisory) => advisory.cableId === cableId); return advisories.reduce((latest, advisory) => { diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts new file mode 100644 index 000000000..2f391e1ba --- /dev/null +++ b/src/components/MapContainer.ts @@ -0,0 +1,552 @@ +/** + * MapContainer - Conditional map renderer + * Renders DeckGLMap (WebGL) on desktop, fallback to D3/SVG MapComponent on mobile + */ +import { isMobileDevice } from '@/utils'; +import { MapComponent } from './Map'; +import { DeckGLMap, type DeckMapView, type CountryClickPayload } from './DeckGLMap'; +import type { + MapLayers, + Hotspot, + NewsItem, + Earthquake, + InternetOutage, + RelatedAsset, + AssetType, + AisDisruptionEvent, + AisDensityZone, + CableAdvisory, + RepairShip, + SocialUnrestEvent, + AirportDelayAlert, + MilitaryFlight, + MilitaryVessel, + MilitaryFlightCluster, + MilitaryVesselCluster, + NaturalEvent, + UcdpGeoEvent, + DisplacementFlow, + ClimateAnomaly, + CyberThreat, +} from '@/types'; +import type { WeatherAlert } from '@/services/weather'; + +export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; +export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; + +export interface MapContainerState { + zoom: number; + pan: { x: number; y: number }; + view: MapView; + layers: MapLayers; + timeRange: TimeRange; +} + +interface TechEventMarker { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; +} + +/** + * Unified map interface that delegates to either DeckGLMap or MapComponent + * based on device capabilities + */ +export class MapContainer { + private container: HTMLElement; + private isMobile: boolean; + private deckGLMap: DeckGLMap | null = null; + private svgMap: MapComponent | null = null; + private initialState: MapContainerState; + private useDeckGL: boolean; + + constructor(container: HTMLElement, initialState: MapContainerState) { + this.container = container; + this.initialState = initialState; + this.isMobile = isMobileDevice(); + + // Use deck.gl on desktop with WebGL support, SVG on mobile + this.useDeckGL = !this.isMobile && this.hasWebGLSupport(); + + this.init(); + } + + private hasWebGLSupport(): boolean { + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + return !!gl; + } catch { + return false; + } + } + + private init(): void { + if (this.useDeckGL) { + console.log('[MapContainer] Initializing deck.gl map (desktop mode)'); + this.container.classList.add('deckgl-mode'); + this.deckGLMap = new DeckGLMap(this.container, { + ...this.initialState, + view: this.initialState.view as DeckMapView, + }); + } else { + console.log('[MapContainer] Initializing SVG map (mobile/fallback mode)'); + this.container.classList.add('svg-mode'); + this.svgMap = new MapComponent(this.container, this.initialState); + } + } + + // Unified public API - delegates to active map implementation + public render(): void { + if (this.useDeckGL) { + this.deckGLMap?.render(); + } else { + this.svgMap?.render(); + } + } + + public setView(view: MapView): void { + if (this.useDeckGL) { + this.deckGLMap?.setView(view as DeckMapView); + } else { + this.svgMap?.setView(view); + } + } + + public setZoom(zoom: number): void { + if (this.useDeckGL) { + this.deckGLMap?.setZoom(zoom); + } else { + this.svgMap?.setZoom(zoom); + } + } + + public setCenter(lat: number, lon: number, zoom?: number): void { + if (this.useDeckGL) { + this.deckGLMap?.setCenter(lat, lon, zoom); + } else { + this.svgMap?.setCenter(lat, lon); + if (zoom != null) this.svgMap?.setZoom(zoom); + } + } + + public getCenter(): { lat: number; lon: number } | null { + if (this.useDeckGL) { + return this.deckGLMap?.getCenter() ?? null; + } + return this.svgMap?.getCenter() ?? null; + } + + public setTimeRange(range: TimeRange): void { + if (this.useDeckGL) { + this.deckGLMap?.setTimeRange(range); + } else { + this.svgMap?.setTimeRange(range); + } + } + + public getTimeRange(): TimeRange { + if (this.useDeckGL) { + return this.deckGLMap?.getTimeRange() ?? '7d'; + } + return this.svgMap?.getTimeRange() ?? '7d'; + } + + public setLayers(layers: MapLayers): void { + if (this.useDeckGL) { + this.deckGLMap?.setLayers(layers); + } else { + this.svgMap?.setLayers(layers); + } + } + + public getState(): MapContainerState { + if (this.useDeckGL) { + const state = this.deckGLMap?.getState(); + return state ? { ...state, view: state.view as MapView } : this.initialState; + } + return this.svgMap?.getState() ?? this.initialState; + } + + // Data setters + public setEarthquakes(earthquakes: Earthquake[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setEarthquakes(earthquakes); + } else { + this.svgMap?.setEarthquakes(earthquakes); + } + } + + public setWeatherAlerts(alerts: WeatherAlert[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setWeatherAlerts(alerts); + } else { + this.svgMap?.setWeatherAlerts(alerts); + } + } + + public setOutages(outages: InternetOutage[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setOutages(outages); + } else { + this.svgMap?.setOutages(outages); + } + } + + public setAisData(disruptions: AisDisruptionEvent[], density: AisDensityZone[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setAisData(disruptions, density); + } else { + this.svgMap?.setAisData(disruptions, density); + } + } + + public setCableActivity(advisories: CableAdvisory[], repairShips: RepairShip[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setCableActivity(advisories, repairShips); + } else { + this.svgMap?.setCableActivity(advisories, repairShips); + } + } + + public setProtests(events: SocialUnrestEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setProtests(events); + } else { + this.svgMap?.setProtests(events); + } + } + + public setFlightDelays(delays: AirportDelayAlert[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setFlightDelays(delays); + } else { + this.svgMap?.setFlightDelays(delays); + } + } + + public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { + if (this.useDeckGL) { + this.deckGLMap?.setMilitaryFlights(flights, clusters); + } else { + this.svgMap?.setMilitaryFlights(flights, clusters); + } + } + + public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { + if (this.useDeckGL) { + this.deckGLMap?.setMilitaryVessels(vessels, clusters); + } else { + this.svgMap?.setMilitaryVessels(vessels, clusters); + } + } + + public setNaturalEvents(events: NaturalEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setNaturalEvents(events); + } else { + this.svgMap?.setNaturalEvents(events); + } + } + + public setFires(fires: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }>): void { + if (this.useDeckGL) { + this.deckGLMap?.setFires(fires); + } else { + this.svgMap?.setFires(fires); + } + } + + public setTechEvents(events: TechEventMarker[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setTechEvents(events); + } else { + this.svgMap?.setTechEvents(events); + } + } + + public setUcdpEvents(events: UcdpGeoEvent[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setUcdpEvents(events); + } + } + + public setDisplacementFlows(flows: DisplacementFlow[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setDisplacementFlows(flows); + } + } + + public setClimateAnomalies(anomalies: ClimateAnomaly[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setClimateAnomalies(anomalies); + } + } + + public setCyberThreats(threats: CyberThreat[]): void { + if (this.useDeckGL) { + this.deckGLMap?.setCyberThreats(threats); + } else { + this.svgMap?.setCyberThreats(threats); + } + } + + public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { + if (this.useDeckGL) { + this.deckGLMap?.setNewsLocations(data); + } else { + this.svgMap?.setNewsLocations(data); + } + } + + public updateHotspotActivity(news: NewsItem[]): void { + if (this.useDeckGL) { + this.deckGLMap?.updateHotspotActivity(news); + } else { + this.svgMap?.updateHotspotActivity(news); + } + } + + public updateMilitaryForEscalation(flights: MilitaryFlight[], vessels: MilitaryVessel[]): void { + if (this.useDeckGL) { + this.deckGLMap?.updateMilitaryForEscalation(flights, vessels); + } else { + this.svgMap?.updateMilitaryForEscalation(flights, vessels); + } + } + + public getHotspotDynamicScore(hotspotId: string) { + if (this.useDeckGL) { + return this.deckGLMap?.getHotspotDynamicScore(hotspotId); + } + return this.svgMap?.getHotspotDynamicScore(hotspotId); + } + + public highlightAssets(assets: RelatedAsset[] | null): void { + if (this.useDeckGL) { + this.deckGLMap?.highlightAssets(assets); + } else { + this.svgMap?.highlightAssets(assets); + } + } + + // Callback setters - MapComponent uses different names + public onHotspotClicked(callback: (hotspot: Hotspot) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnHotspotClick(callback); + } else { + this.svgMap?.onHotspotClicked(callback); + } + } + + public onTimeRangeChanged(callback: (range: TimeRange) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnTimeRangeChange(callback); + } else { + this.svgMap?.onTimeRangeChanged(callback); + } + } + + public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnLayerChange(callback); + } else { + this.svgMap?.setOnLayerChange(callback); + } + } + + public onStateChanged(callback: (state: MapContainerState) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnStateChange((state) => { + callback({ ...state, view: state.view as MapView }); + }); + } else { + this.svgMap?.onStateChanged(callback); + } + } + + public getHotspotLevels(): Record { + if (this.useDeckGL) { + return this.deckGLMap?.getHotspotLevels() ?? {}; + } + return this.svgMap?.getHotspotLevels() ?? {}; + } + + public setHotspotLevels(levels: Record): void { + if (this.useDeckGL) { + this.deckGLMap?.setHotspotLevels(levels); + } else { + this.svgMap?.setHotspotLevels(levels); + } + } + + public initEscalationGetters(): void { + if (this.useDeckGL) { + this.deckGLMap?.initEscalationGetters(); + } else { + this.svgMap?.initEscalationGetters(); + } + } + + // UI visibility methods + public hideLayerToggle(layer: keyof MapLayers): void { + if (this.useDeckGL) { + this.deckGLMap?.hideLayerToggle(layer); + } else { + this.svgMap?.hideLayerToggle(layer); + } + } + + public setLayerLoading(layer: keyof MapLayers, loading: boolean): void { + if (this.useDeckGL) { + this.deckGLMap?.setLayerLoading(layer, loading); + } else { + this.svgMap?.setLayerLoading(layer, loading); + } + } + + public setLayerReady(layer: keyof MapLayers, hasData: boolean): void { + if (this.useDeckGL) { + this.deckGLMap?.setLayerReady(layer, hasData); + } else { + this.svgMap?.setLayerReady(layer, hasData); + } + } + + public flashAssets(assetType: AssetType, ids: string[]): void { + if (this.useDeckGL) { + this.deckGLMap?.flashAssets(assetType, ids); + } + // SVG map doesn't have flashAssets - only supported in deck.gl mode + } + + // Layer enable/disable and trigger methods + public enableLayer(layer: keyof MapLayers): void { + if (this.useDeckGL) { + this.deckGLMap?.enableLayer(layer); + } else { + this.svgMap?.enableLayer(layer); + } + } + + public triggerHotspotClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerHotspotClick(id); + } else { + this.svgMap?.triggerHotspotClick(id); + } + } + + public triggerConflictClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerConflictClick(id); + } else { + this.svgMap?.triggerConflictClick(id); + } + } + + public triggerBaseClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerBaseClick(id); + } else { + this.svgMap?.triggerBaseClick(id); + } + } + + public triggerPipelineClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerPipelineClick(id); + } else { + this.svgMap?.triggerPipelineClick(id); + } + } + + public triggerCableClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerCableClick(id); + } else { + this.svgMap?.triggerCableClick(id); + } + } + + public triggerDatacenterClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerDatacenterClick(id); + } else { + this.svgMap?.triggerDatacenterClick(id); + } + } + + public triggerNuclearClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerNuclearClick(id); + } else { + this.svgMap?.triggerNuclearClick(id); + } + } + + public triggerIrradiatorClick(id: string): void { + if (this.useDeckGL) { + this.deckGLMap?.triggerIrradiatorClick(id); + } else { + this.svgMap?.triggerIrradiatorClick(id); + } + } + + public flashLocation(lat: number, lon: number, durationMs?: number): void { + if (this.useDeckGL) { + this.deckGLMap?.flashLocation(lat, lon, durationMs); + } else { + this.svgMap?.flashLocation(lat, lon, durationMs); + } + } + + // Country click + highlight (deck.gl only) + public onCountryClicked(callback: (country: CountryClickPayload) => void): void { + if (this.useDeckGL) { + this.deckGLMap?.setOnCountryClick(callback); + } + } + + public highlightCountry(code: string): void { + if (this.useDeckGL) { + this.deckGLMap?.highlightCountry(code); + } + } + + public clearCountryHighlight(): void { + if (this.useDeckGL) { + this.deckGLMap?.clearCountryHighlight(); + } + } + + public setRenderPaused(paused: boolean): void { + if (this.useDeckGL) { + this.deckGLMap?.setRenderPaused(paused); + } + } + + // Utility methods + public isDeckGLMode(): boolean { + return this.useDeckGL; + } + + public isMobileMode(): boolean { + return this.isMobile; + } + + public destroy(): void { + if (this.useDeckGL) { + this.deckGLMap?.destroy(); + } else { + this.svgMap?.destroy(); + } + } +} diff --git a/src/components/MapPopup.ts b/src/components/MapPopup.ts index 01f0c64b9..b026cb580 100644 --- a/src/components/MapPopup.ts +++ b/src/components/MapPopup.ts @@ -1,12 +1,123 @@ -import type { ConflictZone, Hotspot, Earthquake, NewsItem, MilitaryBase, StrategicWaterway, APTGroup, NuclearFacility, EconomicCenter, GammaIrradiator, Pipeline, UnderseaCable, CableAdvisory, RepairShip, InternetOutage, AIDataCenter, AisDisruptionEvent, SocialUnrestEvent } from '@/types'; +import type { ConflictZone, Hotspot, Earthquake, NewsItem, MilitaryBase, StrategicWaterway, APTGroup, NuclearFacility, EconomicCenter, GammaIrradiator, Pipeline, UnderseaCable, CableAdvisory, RepairShip, InternetOutage, AIDataCenter, AisDisruptionEvent, SocialUnrestEvent, AirportDelayAlert, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, NaturalEvent, Port, Spaceport, CriticalMineralProject, CyberThreat } from '@/types'; import type { WeatherAlert } from '@/services/weather'; import { UNDERSEA_CABLES } from '@/config'; +import type { StartupHub, Accelerator, TechHQ, CloudRegion } from '@/config/tech-geo'; +import type { TechHubActivity } from '@/services/tech-activity'; +import type { GeoHubActivity } from '@/services/geo-activity'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { isMobileDevice, getCSSColor } from '@/utils'; +import { t } from '@/services/i18n'; +import { fetchHotspotContext, formatArticleDate, extractDomain, type GdeltArticle } from '@/services/gdelt-intel'; +import { getNaturalEventIcon } from '@/services/eonet'; +import { getHotspotEscalation, getEscalationChange24h } from '@/services/hotspot-escalation'; -export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'ais' | 'protest'; +export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub'; + +interface TechEventPopupData { + id: string; + title: string; + location: string; + lat: number; + lng: number; + country: string; + startDate: string; + endDate: string; + url: string | null; + daysUntil: number; +} + +interface TechHQClusterData { + items: TechHQ[]; + city: string; + country: string; + count?: number; + faangCount?: number; + unicornCount?: number; + publicCount?: number; + sampled?: boolean; +} + +interface TechEventClusterData { + items: TechEventPopupData[]; + location: string; + country: string; + count?: number; + soonCount?: number; + sampled?: boolean; +} + +// Finance popup data types +interface StockExchangePopupData { + id: string; + name: string; + shortName: string; + city: string; + country: string; + tier: string; + marketCap?: number; + tradingHours?: string; + timezone?: string; + description?: string; +} + +interface FinancialCenterPopupData { + id: string; + name: string; + city: string; + country: string; + type: string; + gfciRank?: number; + specialties?: string[]; + description?: string; +} + +interface CentralBankPopupData { + id: string; + name: string; + shortName: string; + city: string; + country: string; + type: string; + currency?: string; + description?: string; +} + +interface CommodityHubPopupData { + id: string; + name: string; + city: string; + country: string; + type: string; + commodities?: string[]; + description?: string; +} + +interface ProtestClusterData { + items: SocialUnrestEvent[]; + country: string; + count?: number; + riotCount?: number; + highSeverityCount?: number; + verifiedCount?: number; + totalFatalities?: number; + sampled?: boolean; +} + +interface DatacenterClusterData { + items: AIDataCenter[]; + region: string; + country: string; + count?: number; + totalChips?: number; + totalPowerMW?: number; + existingCount?: number; + plannedCount?: number; + sampled?: boolean; +} interface PopupData { type: PopupType; - data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent; + data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData; relatedNews?: NewsItem[]; x: number; y: number; @@ -18,6 +129,11 @@ export class MapPopup { private onClose?: () => void; private cableAdvisories: CableAdvisory[] = []; private repairShips: RepairShip[] = []; + private isMobileSheet = false; + private sheetTouchStartY: number | null = null; + private sheetCurrentOffset = 0; + private readonly mobileDismissThreshold = 96; + private outsideListenerTimeoutId: number | null = null; constructor(container: HTMLElement) { this.container = container; @@ -26,40 +142,185 @@ export class MapPopup { public show(data: PopupData): void { this.hide(); + this.isMobileSheet = isMobileDevice(); this.popup = document.createElement('div'); - this.popup.className = 'map-popup'; + this.popup.className = this.isMobileSheet ? 'map-popup map-popup-sheet' : 'map-popup'; const content = this.renderContent(data); - this.popup.innerHTML = content; + this.popup.innerHTML = this.isMobileSheet + ? `${content}` + : content; - // Position popup - const maxX = this.container.clientWidth - 400; - const maxY = this.container.clientHeight - 300; - this.popup.style.left = `${Math.min(data.x + 20, maxX)}px`; - this.popup.style.top = `${Math.min(data.y - 20, maxY)}px`; + // Get container's viewport position for absolute positioning + const containerRect = this.container.getBoundingClientRect(); + + if (this.isMobileSheet) { + this.popup.style.left = ''; + this.popup.style.top = ''; + this.popup.style.transform = ''; + } else { + this.positionDesktopPopup(data, containerRect); + } - this.container.appendChild(this.popup); + // Append to body to avoid container overflow clipping + document.body.appendChild(this.popup); // Close button handler this.popup.querySelector('.popup-close')?.addEventListener('click', () => this.hide()); + this.popup.querySelector('.map-popup-sheet-handle')?.addEventListener('click', () => this.hide()); + + if (this.isMobileSheet) { + this.popup.addEventListener('touchstart', this.handleSheetTouchStart, { passive: true }); + this.popup.addEventListener('touchmove', this.handleSheetTouchMove, { passive: false }); + this.popup.addEventListener('touchend', this.handleSheetTouchEnd); + this.popup.addEventListener('touchcancel', this.handleSheetTouchEnd); + requestAnimationFrame(() => this.popup?.classList.add('open')); + } // Click outside to close - setTimeout(() => { + if (this.outsideListenerTimeoutId !== null) { + window.clearTimeout(this.outsideListenerTimeoutId); + } + this.outsideListenerTimeoutId = window.setTimeout(() => { document.addEventListener('click', this.handleOutsideClick); - }, 100); + document.addEventListener('touchstart', this.handleOutsideClick); + document.addEventListener('keydown', this.handleEscapeKey); + this.outsideListenerTimeoutId = null; + }, 0); + } + + private positionDesktopPopup(data: PopupData, containerRect: DOMRect): void { + if (!this.popup) return; + + const popupWidth = 380; + const bottomBuffer = 50; // Buffer from viewport bottom + const topBuffer = 60; // Header height + + // Temporarily append popup off-screen to measure actual height + this.popup.style.visibility = 'hidden'; + this.popup.style.top = '0'; + this.popup.style.left = '-9999px'; + document.body.appendChild(this.popup); + const popupHeight = this.popup.offsetHeight; + document.body.removeChild(this.popup); + this.popup.style.visibility = ''; + + // Convert container-relative coords to viewport coords + const viewportX = containerRect.left + data.x; + const viewportY = containerRect.top + data.y; + + // Horizontal positioning (viewport-relative) + const maxX = window.innerWidth - popupWidth - 20; + let left = viewportX + 20; + if (left > maxX) { + // Position to the left of click if it would overflow right + left = Math.max(10, viewportX - popupWidth - 20); + } + + // Vertical positioning - prefer below click, but flip above if needed + const availableBelow = window.innerHeight - viewportY - bottomBuffer; + const availableAbove = viewportY - topBuffer; + + let top: number; + if (availableBelow >= popupHeight) { + // Enough space below - position below click + top = viewportY + 10; + } else if (availableAbove >= popupHeight) { + // Not enough below, but enough above - position above click + top = viewportY - popupHeight - 10; + } else { + // Limited space both ways - position at top buffer + top = topBuffer; + } + + // CRITICAL: Ensure popup stays within viewport vertically + top = Math.max(topBuffer, top); + const maxTop = window.innerHeight - popupHeight - bottomBuffer; + if (maxTop > topBuffer) { + top = Math.min(top, maxTop); + } + + this.popup.style.left = `${left}px`; + this.popup.style.top = `${top}px`; } - private handleOutsideClick = (e: MouseEvent) => { + private handleOutsideClick = (e: Event) => { if (this.popup && !this.popup.contains(e.target as Node)) { this.hide(); } }; + private handleEscapeKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + this.hide(); + } + }; + + private handleSheetTouchStart = (e: TouchEvent): void => { + if (!this.popup || !this.isMobileSheet || e.touches.length !== 1) return; + + const target = e.target as HTMLElement | null; + const popupBody = this.popup.querySelector('.popup-body'); + if (target?.closest('.popup-body') && popupBody && popupBody.scrollTop > 0) { + this.sheetTouchStartY = null; + return; + } + + this.sheetTouchStartY = e.touches[0]?.clientY ?? null; + this.sheetCurrentOffset = 0; + this.popup.classList.add('dragging'); + }; + + private handleSheetTouchMove = (e: TouchEvent): void => { + if (!this.popup || !this.isMobileSheet || this.sheetTouchStartY === null) return; + + const currentY = e.touches[0]?.clientY; + if (currentY == null) return; + + const delta = Math.max(0, currentY - this.sheetTouchStartY); + if (delta <= 0) return; + + this.sheetCurrentOffset = delta; + this.popup.style.transform = `translate3d(0, ${delta}px, 0)`; + e.preventDefault(); + }; + + private handleSheetTouchEnd = (): void => { + if (!this.popup || !this.isMobileSheet || this.sheetTouchStartY === null) return; + + const shouldDismiss = this.sheetCurrentOffset >= this.mobileDismissThreshold; + this.popup.classList.remove('dragging'); + this.sheetTouchStartY = null; + + if (shouldDismiss) { + this.hide(); + return; + } + + this.sheetCurrentOffset = 0; + this.popup.style.transform = ''; + this.popup.classList.add('open'); + }; + public hide(): void { + if (this.outsideListenerTimeoutId !== null) { + window.clearTimeout(this.outsideListenerTimeoutId); + this.outsideListenerTimeoutId = null; + } + if (this.popup) { + this.popup.removeEventListener('touchstart', this.handleSheetTouchStart); + this.popup.removeEventListener('touchmove', this.handleSheetTouchMove); + this.popup.removeEventListener('touchend', this.handleSheetTouchEnd); + this.popup.removeEventListener('touchcancel', this.handleSheetTouchEnd); this.popup.remove(); this.popup = null; + this.isMobileSheet = false; + this.sheetTouchStartY = null; + this.sheetCurrentOffset = 0; document.removeEventListener('click', this.handleOutsideClick); + document.removeEventListener('touchstart', this.handleOutsideClick); + document.removeEventListener('keydown', this.handleEscapeKey); this.onClose?.(); } } @@ -89,6 +350,8 @@ export class MapPopup { return this.renderWaterwayPopup(data.data as StrategicWaterway); case 'apt': return this.renderAPTPopup(data.data as APTGroup); + case 'cyberThreat': + return this.renderCyberThreatPopup(data.data as CyberThreat); case 'nuclear': return this.renderNuclearPopup(data.data as NuclearFacility); case 'economic': @@ -107,10 +370,54 @@ export class MapPopup { return this.renderOutagePopup(data.data as InternetOutage); case 'datacenter': return this.renderDatacenterPopup(data.data as AIDataCenter); + case 'datacenterCluster': + return this.renderDatacenterClusterPopup(data.data as DatacenterClusterData); case 'ais': return this.renderAisPopup(data.data as AisDisruptionEvent); case 'protest': return this.renderProtestPopup(data.data as SocialUnrestEvent); + case 'protestCluster': + return this.renderProtestClusterPopup(data.data as ProtestClusterData); + case 'flight': + return this.renderFlightPopup(data.data as AirportDelayAlert); + case 'militaryFlight': + return this.renderMilitaryFlightPopup(data.data as MilitaryFlight); + case 'militaryVessel': + return this.renderMilitaryVesselPopup(data.data as MilitaryVessel); + case 'militaryFlightCluster': + return this.renderMilitaryFlightClusterPopup(data.data as MilitaryFlightCluster); + case 'militaryVesselCluster': + return this.renderMilitaryVesselClusterPopup(data.data as MilitaryVesselCluster); + case 'natEvent': + return this.renderNaturalEventPopup(data.data as NaturalEvent); + case 'port': + return this.renderPortPopup(data.data as Port); + case 'spaceport': + return this.renderSpaceportPopup(data.data as Spaceport); + case 'mineral': + return this.renderMineralPopup(data.data as CriticalMineralProject); + case 'startupHub': + return this.renderStartupHubPopup(data.data as StartupHub); + case 'cloudRegion': + return this.renderCloudRegionPopup(data.data as CloudRegion); + case 'techHQ': + return this.renderTechHQPopup(data.data as TechHQ); + case 'accelerator': + return this.renderAcceleratorPopup(data.data as Accelerator); + case 'techEvent': + return this.renderTechEventPopup(data.data as TechEventPopupData); + case 'techHQCluster': + return this.renderTechHQClusterPopup(data.data as TechHQClusterData); + case 'techEventCluster': + return this.renderTechEventClusterPopup(data.data as TechEventClusterData); + case 'stockExchange': + return this.renderStockExchangePopup(data.data as StockExchangePopupData); + case 'financialCenter': + return this.renderFinancialCenterPopup(data.data as FinancialCenterPopupData); + case 'centralBank': + return this.renderCentralBankPopup(data.data as CentralBankPopupData); + case 'commodityHub': + return this.renderCommodityHubPopup(data.data as CommodityHubPopupData); default: return ''; } @@ -118,47 +425,47 @@ export class MapPopup { private renderConflictPopup(conflict: ConflictZone): string { const severityClass = conflict.intensity === 'high' ? 'high' : conflict.intensity === 'medium' ? 'medium' : 'low'; - const severityLabel = conflict.intensity?.toUpperCase() || 'UNKNOWN'; + const severityLabel = escapeHtml(conflict.intensity?.toUpperCase() || t('popups.unknown').toUpperCase()); return ` - +
`; } private renderProtestPopup(event: SocialUnrestEvent): string { - const severityClass = event.severity; - const severityLabel = event.severity.toUpperCase(); - const eventTypeLabel = event.eventType.replace('_', ' ').toUpperCase(); + const severityClass = escapeHtml(event.severity); + const severityLabel = escapeHtml(event.severity.toUpperCase()); + const eventTypeLabel = escapeHtml(event.eventType.replace('_', ' ').toUpperCase()); const icon = event.eventType === 'riot' ? '🔥' : event.eventType === 'strike' ? '✊' : '📢'; - const sourceLabel = event.sourceType === 'acled' ? 'ACLED (verified)' : 'GDELT'; - const validatedBadge = event.validated ? 'VERIFIED' : ''; + const sourceLabel = event.sourceType === 'acled' ? t('popups.protest.acledVerified') : t('popups.protest.gdelt'); + const validatedBadge = event.validated ? `${t('popups.verified')}` : ''; const fatalitiesSection = event.fatalities - ? `` + ? `` : ''; const actorsSection = event.actors?.length - ? `` + ? `` : ''; const tagsSection = event.tags?.length - ? `` + ? `` : ''; const relatedHotspots = event.relatedHotspots?.length - ? `` + ? `` : ''; return ` @@ -417,56 +923,224 @@ export class MapPopup {
`; } + private renderProtestClusterPopup(data: ProtestClusterData): string { + const totalCount = data.count ?? data.items.length; + const riots = data.riotCount ?? data.items.filter(e => e.eventType === 'riot').length; + const highSeverity = data.highSeverityCount ?? data.items.filter(e => e.severity === 'high').length; + const verified = data.verifiedCount ?? data.items.filter(e => e.validated).length; + const totalFatalities = data.totalFatalities ?? data.items.reduce((sum, e) => sum + (e.fatalities || 0), 0); + + const sortedItems = [...data.items].sort((a, b) => { + const severityOrder: Record = { high: 0, medium: 1, low: 2 }; + const typeOrder: Record = { riot: 0, civil_unrest: 1, strike: 2, demonstration: 3, protest: 4 }; + const sevDiff = (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3); + if (sevDiff !== 0) return sevDiff; + return (typeOrder[a.eventType] ?? 5) - (typeOrder[b.eventType] ?? 5); + }); + + const listItems = sortedItems.slice(0, 10).map(event => { + const icon = event.eventType === 'riot' ? '🔥' : event.eventType === 'strike' ? '✊' : '📢'; + const sevClass = event.severity; + const dateStr = event.time.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const city = event.city ? escapeHtml(event.city) : ''; + const title = event.title ? `: ${escapeHtml(event.title.slice(0, 40))}${event.title.length > 40 ? '...' : ''}` : ''; + return `
  • ${icon} ${dateStr}${city ? ` • ${city}` : ''}${title}
  • `; + }).join(''); + + const renderedCount = Math.min(10, data.items.length); + const remainingCount = Math.max(0, totalCount - renderedCount); + const moreCount = remainingCount > 0 ? `
  • +${remainingCount} ${t('popups.moreEvents')}
  • ` : ''; + const headerClass = highSeverity > 0 ? 'high' : riots > 0 ? 'medium' : 'low'; + + return ` + + + `; + } + + private renderFlightPopup(delay: AirportDelayAlert): string { + const severityClass = escapeHtml(delay.severity); + const severityLabel = escapeHtml(delay.severity.toUpperCase()); + const delayTypeLabels: Record = { + 'ground_stop': t('popups.flight.groundStop'), + 'ground_delay': t('popups.flight.groundDelay'), + 'departure_delay': t('popups.flight.departureDelay'), + 'arrival_delay': t('popups.flight.arrivalDelay'), + 'general': t('popups.flight.delaysReported'), + }; + const delayTypeLabel = delayTypeLabels[delay.delayType] || t('popups.flight.delays'); + const icon = delay.delayType === 'ground_stop' ? '🛑' : delay.severity === 'severe' ? '✈️' : '🛫'; + const sourceLabels: Record = { + 'faa': t('popups.flight.sources.faa'), + 'eurocontrol': t('popups.flight.sources.eurocontrol'), + 'computed': t('popups.flight.sources.computed'), + }; + const sourceLabel = sourceLabels[delay.source] || escapeHtml(delay.source); + const regionLabels: Record = { + 'americas': t('popups.flight.regions.americas'), + 'europe': t('popups.flight.regions.europe'), + 'apac': t('popups.flight.regions.apac'), + 'mena': t('popups.flight.regions.mena'), + 'africa': t('popups.flight.regions.africa'), + }; + const regionLabel = regionLabels[delay.region] || escapeHtml(delay.region); + + const avgDelaySection = delay.avgDelayMinutes > 0 + ? `` + : ''; + const reasonSection = delay.reason + ? `` + : ''; + const cancelledSection = delay.cancelledFlights + ? `` + : ''; + + return ` + + + `; + } + private renderAPTPopup(apt: APTGroup): string { return ` + `; + } + + + private renderCyberThreatPopup(threat: CyberThreat): string { + const severityClass = escapeHtml(threat.severity); + const sourceLabels: Record = { + feodo: 'Feodo Tracker', + urlhaus: 'URLhaus', + c2intel: 'C2 Intel Feeds', + otx: 'AlienVault OTX', + abuseipdb: 'AbuseIPDB', + }; + const sourceLabel = sourceLabels[threat.source] || threat.source; + const typeLabel = threat.type.replace(/_/g, ' ').toUpperCase(); + const tags = (threat.tags || []).slice(0, 6); + + return ` + + `; } private renderNuclearPopup(facility: NuclearFacility): string { const typeLabels: Record = { - 'plant': 'POWER PLANT', - 'enrichment': 'ENRICHMENT', - 'weapons': 'WEAPONS COMPLEX', - 'research': 'RESEARCH', + 'plant': t('popups.nuclear.types.plant'), + 'enrichment': t('popups.nuclear.types.enrichment'), + 'weapons': t('popups.nuclear.types.weapons'), + 'research': t('popups.nuclear.types.research'), }; const statusColors: Record = { 'active': 'elevated', @@ -483,28 +1157,28 @@ export class MapPopup { `; } private renderEconomicPopup(center: EconomicCenter): string { const typeLabels: Record = { - 'exchange': 'STOCK EXCHANGE', - 'central-bank': 'CENTRAL BANK', - 'financial-hub': 'FINANCIAL HUB', + 'exchange': t('popups.economic.types.exchange'), + 'central-bank': t('popups.economic.types.centralBank'), + 'financial-hub': t('popups.economic.types.financialHub'), }; const typeIcons: Record = { 'exchange': '📈', @@ -513,32 +1187,39 @@ export class MapPopup { }; const marketStatus = center.marketHours ? this.getMarketStatus(center.marketHours) : null; + const marketStatusLabel = marketStatus + ? marketStatus === 'open' + ? t('popups.open') + : marketStatus === 'closed' + ? t('popups.economic.closed') + : t('popups.unknown') + : ''; return ` `; } + private renderCablePopup(cable: UnderseaCable): string { const advisory = this.getLatestCableAdvisory(cable.id); const repairShip = this.getPriorityRepairShip(cable.id); - const statusLabel = advisory ? (advisory.severity === 'fault' ? 'FAULT' : 'DEGRADED') : 'ACTIVE'; + const statusLabel = advisory ? (advisory.severity === 'fault' ? t('popups.cable.fault') : t('popups.cable.degraded')) : t('popups.cable.active'); const statusBadge = advisory ? (advisory.severity === 'fault' ? 'high' : 'elevated') : 'low'; const repairEta = repairShip?.eta || advisory?.repairEta; + const cableName = escapeHtml(cable.name.toUpperCase()); + const safeStatusLabel = escapeHtml(statusLabel); + const safeRepairEta = repairEta ? escapeHtml(repairEta) : ''; + const advisoryTitle = advisory ? escapeHtml(advisory.title) : ''; + const advisoryImpact = advisory ? escapeHtml(advisory.impact) : ''; + const advisoryDescription = advisory ? escapeHtml(advisory.description) : ''; + const repairShipName = repairShip ? escapeHtml(repairShip.name) : ''; + const repairShipNote = repairShip ? escapeHtml(repairShip.note || t('popups.repairShip.note')) : ''; return ` `; } @@ -699,65 +1391,75 @@ export class MapPopup { private renderCableAdvisoryPopup(advisory: CableAdvisory): string { const cable = UNDERSEA_CABLES.find((item) => item.id === advisory.cableId); const timeAgo = this.getTimeAgo(advisory.reported); - const statusLabel = advisory.severity === 'fault' ? 'FAULT' : 'DEGRADED'; + const statusLabel = advisory.severity === 'fault' ? t('popups.cable.fault') : t('popups.cable.degraded'); + const cableName = escapeHtml(cable?.name.toUpperCase() || advisory.cableId.toUpperCase()); + const advisoryTitle = escapeHtml(advisory.title); + const advisoryImpact = escapeHtml(advisory.impact); + const advisoryEta = advisory.repairEta ? escapeHtml(advisory.repairEta) : ''; + const advisoryDescription = escapeHtml(advisory.description); return ` `; } private renderRepairShipPopup(ship: RepairShip): string { const cable = UNDERSEA_CABLES.find((item) => item.id === ship.cableId); + const shipName = escapeHtml(ship.name.toUpperCase()); + const cableLabel = escapeHtml(cable?.name || ship.cableId); + const shipEta = escapeHtml(ship.eta); + const shipOperator = ship.operator ? escapeHtml(ship.operator) : ''; + const shipNote = escapeHtml(ship.note || t('popups.repairShip.description')); return ` `; } @@ -784,44 +1486,45 @@ export class MapPopup { 'partial': 'low', }; const severityLabels: Record = { - 'total': 'TOTAL BLACKOUT', - 'major': 'MAJOR OUTAGE', - 'partial': 'PARTIAL DISRUPTION', + 'total': t('popups.outage.levels.total'), + 'major': t('popups.outage.levels.major'), + 'partial': t('popups.outage.levels.partial'), }; const timeAgo = this.getTimeAgo(outage.pubDate); + const severityClass = escapeHtml(outage.severity); return ` -