Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import {
InvestmentsPanel,
LanguageSelector,
} from '@/components';
import { GatraSOCDashboardPanel } from '@/panels/gatra-soc-panel';
import { refreshGatraData } from '@/gatra/connector';
import type { SearchResult } from '@/components/SearchModal';
import { collectStoryData } from '@/services/story-data';
import { renderStoryToCanvas } from '@/services/story-renderer';
Expand Down Expand Up @@ -196,6 +198,7 @@ export class App {
private pendingDeepLinkCountry: string | null = null;
private briefRequestToken = 0;
private readonly isDesktopApp = isDesktopRuntime();
private readonly isLocalDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
private readonly UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
private updateCheckIntervalId: ReturnType<typeof setInterval> | null = null;
private clockIntervalId: ReturnType<typeof setInterval> | null = null;
Expand Down Expand Up @@ -1833,33 +1836,43 @@ export class App {
this.container.innerHTML = `
<div class="header">
<div class="header-left">
${ /* Variant switcher — on localhost/desktop, switch locally via localStorage */ ''}
<div class="variant-switcher">
<a href="${this.isDesktopApp ? '#' : (SITE_VARIANT === 'full' ? '#' : 'https://worldmonitor.app')}"
<a href="${this.isDesktopApp || this.isLocalDev ? '#' : (SITE_VARIANT === 'full' ? '#' : 'https://worldmonitor.app')}"
class="variant-option ${SITE_VARIANT === 'full' ? 'active' : ''}"
data-variant="full"
${!this.isDesktopApp && SITE_VARIANT !== 'full' ? 'target="_blank" rel="noopener"' : ''}
${!(this.isDesktopApp || this.isLocalDev) && SITE_VARIANT !== 'full' ? 'target="_blank" rel="noopener"' : ''}
title="${t('header.world')}${SITE_VARIANT === 'full' ? ` ${t('common.currentVariant')}` : ''}">
<span class="variant-icon">🌍</span>
<span class="variant-label">${t('header.world')}</span>
</a>
<span class="variant-divider"></span>
<a href="${this.isDesktopApp ? '#' : (SITE_VARIANT === 'tech' ? '#' : 'https://tech.worldmonitor.app')}"
<a href="${this.isDesktopApp || this.isLocalDev ? '#' : (SITE_VARIANT === 'tech' ? '#' : 'https://tech.worldmonitor.app')}"
class="variant-option ${SITE_VARIANT === 'tech' ? 'active' : ''}"
data-variant="tech"
${!this.isDesktopApp && SITE_VARIANT !== 'tech' ? 'target="_blank" rel="noopener"' : ''}
${!(this.isDesktopApp || this.isLocalDev) && SITE_VARIANT !== 'tech' ? 'target="_blank" rel="noopener"' : ''}
title="${t('header.tech')}${SITE_VARIANT === 'tech' ? ` ${t('common.currentVariant')}` : ''}">
<span class="variant-icon">💻</span>
<span class="variant-label">${t('header.tech')}</span>
</a>
<span class="variant-divider"></span>
<a href="${this.isDesktopApp ? '#' : (SITE_VARIANT === 'finance' ? '#' : 'https://finance.worldmonitor.app')}"
<a href="${this.isDesktopApp || this.isLocalDev ? '#' : (SITE_VARIANT === 'finance' ? '#' : 'https://finance.worldmonitor.app')}"
class="variant-option ${SITE_VARIANT === 'finance' ? 'active' : ''}"
data-variant="finance"
${!this.isDesktopApp && SITE_VARIANT !== 'finance' ? 'target="_blank" rel="noopener"' : ''}
${!(this.isDesktopApp || this.isLocalDev) && SITE_VARIANT !== 'finance' ? 'target="_blank" rel="noopener"' : ''}
title="${t('header.finance')}${SITE_VARIANT === 'finance' ? ` ${t('common.currentVariant')}` : ''}">
<span class="variant-icon">📈</span>
<span class="variant-label">${t('header.finance')}</span>
</a>
<span class="variant-divider"></span>
<a href="${this.isDesktopApp || this.isLocalDev ? '#' : (SITE_VARIANT === 'cyber' ? '#' : 'https://cyber.worldmonitor.app')}"
class="variant-option ${SITE_VARIANT === 'cyber' ? 'active' : ''}"
data-variant="cyber"
${!(this.isDesktopApp || this.isLocalDev) && SITE_VARIANT !== 'cyber' ? 'target="_blank" rel="noopener"' : ''}
title="${t('header.cyber')}${SITE_VARIANT === 'cyber' ? ` ${t('common.currentVariant')}` : ''}">
<span class="variant-icon">🛡️</span>
<span class="variant-label">${t('header.cyber')}</span>
</a>
</div>
<span class="logo">MONITOR</span><span class="version">v${__APP_VERSION__}</span>${BETA_MODE ? '<span class="beta-badge">BETA</span>' : ''}
<a href="https://x.com/eliehabib" target="_blank" rel="noopener" class="credit-link">
Expand Down Expand Up @@ -2375,6 +2388,12 @@ export class App {
this.panels['etf-flows'] = new ETFFlowsPanel();
this.panels['stablecoins'] = new StablecoinPanel();

// GATRA SOC Panel (cyber variant)
if (SITE_VARIANT === 'cyber') {
const gatraPanel = new GatraSOCDashboardPanel();
this.panels['gatra-soc'] = gatraPanel;
}

// AI Insights Panel (desktop only - hides itself on mobile)
const insightsPanel = new InsightsPanel();
this.panels['insights'] = insightsPanel;
Expand Down Expand Up @@ -2687,8 +2706,8 @@ export class App {
// Sources modal
this.setupSourcesModal();

// Variant switcher: switch variant locally on desktop (reload with new config)
if (this.isDesktopApp) {
// Variant switcher: switch variant locally on desktop or localhost (reload with new config)
if (this.isDesktopApp || this.isLocalDev) {
this.container.querySelectorAll<HTMLAnchorElement>('.variant-option').forEach(link => {
link.addEventListener('click', (e) => {
const variant = link.dataset.variant;
Expand Down Expand Up @@ -3174,6 +3193,11 @@ export class App {
tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) });
}

// GATRA SOC data (cyber variant)
if (SITE_VARIANT === 'cyber' && this.mapLayers.gatraAlerts) {
tasks.push({ name: 'gatra', task: runGuarded('gatra', () => this.loadGatraData()) });
}

// Use allSettled to ensure all tasks complete and search index always updates
const results = await Promise.allSettled(tasks.map(t => t.task));

Expand Down Expand Up @@ -3209,6 +3233,9 @@ export class App {
case 'cyberThreats':
await this.loadCyberThreats();
break;
case 'gatraAlerts':
await this.loadGatraData();
break;
case 'ais':
await this.loadAisSignals();
break;
Expand Down Expand Up @@ -4088,6 +4115,20 @@ export class App {
}
}

private async loadGatraData(): Promise<void> {
try {
const snap = await refreshGatraData();
const gatraPanel = this.panels['gatra-soc'] as GatraSOCDashboardPanel | undefined;
gatraPanel?.refresh();
if (this.mapLayers.gatraAlerts) {
this.map?.setGatraAlerts(snap.alerts);
}
dataFreshness.recordUpdate('gatra' as DataSourceId, snap.alerts.length);
} catch (error) {
console.error('[App] GATRA data load failed:', error);
}
}

private async loadAisSignals(): Promise<void> {
try {
const { disruptions, density } = await fetchAisSignals();
Expand Down Expand Up @@ -4668,5 +4709,10 @@ export class App {
this.cyberThreatsCache = null;
return this.loadCyberThreats();
}, 10 * 60 * 1000, () => CYBER_LAYER_ENABLED && this.mapLayers.cyberThreats);

// GATRA SOC data (60s refresh, cyber variant only)
if (SITE_VARIANT === 'cyber') {
this.scheduleRefresh('gatra', () => this.loadGatraData(), 60 * 1000, () => this.mapLayers.gatraAlerts);
}
}
}
31 changes: 31 additions & 0 deletions src/components/DeckGLMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ import type {
MapDatacenterCluster,
CyberThreat,
CableHealthRecord,
GatraAlert,
} from '@/types';
import type { AirportDelayAlert } from '@/services/aviation';
import type { DisplacementFlow } from '@/services/displacement';
import type { Earthquake } from '@/services/earthquakes';
import type { ClimateAnomaly } from '@/services/climate';
import { ArcLayer } from '@deck.gl/layers';
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
import { createGatraAlertsLayers as buildGatraLayers } from '@/layers/gatra-alerts-layer';
import type { WeatherAlert } from '@/services/weather';
import { escapeHtml } from '@/utils/sanitize';
import { t } from '@/services/i18n';
Expand Down Expand Up @@ -268,6 +270,7 @@ export class DeckGLMap {
private ucdpEvents: UcdpGeoEvent[] = [];
private displacementFlows: DisplacementFlow[] = [];
private climateAnomalies: ClimateAnomaly[] = [];
private gatraAlerts: GatraAlert[] = [];

// Country highlight state
private countryGeoJsonLoaded = false;
Expand Down Expand Up @@ -1140,6 +1143,11 @@ export class DeckGLMap {
layers.push(this.createGulfInvestmentsLayer());
}

// GATRA SOC alerts layer
if (mapLayers.gatraAlerts && this.gatraAlerts.length > 0) {
layers.push(...this.createGatraAlertsLayers());
}

// News geo-locations (always shown if data exists)
if (this.newsLocations.length > 0) {
layers.push(...this.createNewsLocationsLayer());
Expand Down Expand Up @@ -2449,6 +2457,8 @@ export class DeckGLMap {
return { html: `<div class="deckgl-tooltip"><strong>${text(obj.asn || t('components.deckgl.tooltip.internetOutage'))}</strong><br/>${text(obj.country)}</div>` };
case 'cyber-threats-layer':
return { html: `<div class="deckgl-tooltip"><strong>${t('popups.cyberThreat.title')}</strong><br/>${text(obj.severity || t('components.deckgl.tooltip.medium'))} · ${text(obj.country || t('popups.unknown'))}</div>` };
case 'gatra-alerts-layer':
return { html: `<div class="deckgl-tooltip"><strong>GATRA ${text(obj.severity?.toUpperCase() || 'ALERT')}</strong><br/>${text(obj.mitreId || '')} ${text(obj.mitreName || '')}<br/>${text(obj.locationName || '')} · ${text(obj.infrastructure || '')}<br/>${obj.confidence != null ? obj.confidence + '% confidence' : ''}</div>` };
case 'news-locations-layer':
return { html: `<div class="deckgl-tooltip"><strong>📰 ${t('components.deckgl.tooltip.news')}</strong><br/>${text(obj.title?.slice(0, 80) || '')}</div>` };
case 'gulf-investments-layer': {
Expand Down Expand Up @@ -2786,6 +2796,18 @@ export class DeckGLMap {
{ key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '&#127755;' },
{ key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '&#128737;' },
]
: SITE_VARIANT === 'cyber'
? [
{ key: 'gatraAlerts', label: t('components.deckgl.layers.gatraAlerts'), icon: '&#128737;' },
{ key: 'cyberThreats', label: t('components.deckgl.layers.cyberThreats'), icon: '&#128274;' },
{ key: 'conflicts', label: t('components.deckgl.layers.conflictZones'), icon: '&#9876;' },
{ key: 'cables', label: t('components.deckgl.layers.underseaCables'), icon: '&#128268;' },
{ key: 'datacenters', label: t('components.deckgl.layers.aiDataCenters'), icon: '&#128421;' },
{ key: 'military', label: t('components.deckgl.layers.militaryActivity'), icon: '&#9992;' },
{ key: 'outages', label: t('components.deckgl.layers.internetOutages'), icon: '&#128225;' },
{ key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '&#127755;' },
{ key: 'fires', label: t('components.deckgl.layers.fires'), icon: '&#128293;' },
]
: [
{ key: 'hotspots', label: t('components.deckgl.layers.intelHotspots'), icon: '&#127919;' },
{ key: 'conflicts', label: t('components.deckgl.layers.conflictZones'), icon: '&#9876;' },
Expand Down Expand Up @@ -3257,6 +3279,10 @@ export class DeckGLMap {
});
}

private createGatraAlertsLayers(): Layer[] {
return buildGatraLayers(this.gatraAlerts, this.pulseTime || Date.now());
}

// Data setters - all use render() for debouncing
public setEarthquakes(earthquakes: Earthquake[]): void {
this.earthquakes = earthquakes;
Expand All @@ -3280,6 +3306,11 @@ export class DeckGLMap {
this.render();
}

public setGatraAlerts(alerts: GatraAlert[]): void {
this.gatraAlerts = alerts;
this.render();
Comment on lines +3309 to +3311

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire GATRA alert data into the new map layer path

The new setGatraAlerts entry point is never called anywhere in src, so this.gatraAlerts remains empty and the added render guard (mapLayers.gatraAlerts && this.gatraAlerts.length > 0) keeps the GATRA layer from ever drawing markers even when the toggle is enabled.

Useful? React with 👍 / 👎.

}

public setAisData(disruptions: AisDisruptionEvent[], density: AisDensityZone[]): void {
this.aisDisruptions = disruptions;
this.aisDensity = density;
Expand Down
Loading