/** * 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, PolygonLayer } from '@deck.gl/layers'; import maplibregl from 'maplibre-gl'; import type { StyleSpecification } from 'maplibre-gl'; import { FALLBACK_DARK_STYLE, FALLBACK_LIGHT_STYLE, getMapProvider, getMapTheme, isLightMapTheme } from '@/config/basemap'; import { getStyleForProvider } from '@/config/basemap-styles'; import Supercluster from 'supercluster'; import type { MapLayers, Hotspot, NewsItem, InternetOutage, RelatedAsset, AssetType, AisDisruptionEvent, AisDensityZone, CableAdvisory, RepairShip, SocialUnrestEvent, AIDataCenter, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, NaturalEvent, UcdpGeoEvent, MapProtestCluster, MapTechHQCluster, MapTechEventCluster, MapDatacenterCluster, CyberThreat, CableHealthRecord, MilitaryBaseEnriched, } from '@/types'; import { fetchMilitaryBases, type MilitaryBaseCluster as ServerBaseCluster } from '@/services/military-bases'; import type { AirportDelayAlert, PositionSample } from '@/services/aviation'; import { fetchAircraftPositions } from '@/services/aviation'; import { type IranEvent, getIranEventColor, getIranEventRadius } from '@/services/conflict'; import { getMilitaryBaseColor } from '@/config/military-base-colors'; import { getMineralColor } from '@/config/mineral-colors'; import { getWindColor } from '@/config/wind-colors'; import { CII_LEVEL_COLORS, type CiiLevel } from '@/config/cii-colors'; import type { GpsJamHex } from '@/services/gps-interference'; import { fetchImageryScenes } from '@/services/imagery'; import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server'; import type { TrafficAnomaly as ProtoTrafficAnomaly, DdosLocationHit } from '@/generated/client/worldmonitor/infrastructure/v1/service_client'; import type { DisplacementFlow } from '@/services/displacement'; import type { Earthquake } from '@/services/earthquakes'; import type { ClimateAnomaly } from '@/services/climate'; import type { RadiationObservation } from '@/services/radiation'; import { ArcLayer } from '@deck.gl/layers'; import type { WeatherAlert } from '@/services/weather'; import { escapeHtml } from '@/utils/sanitize'; import { derivePipelinePublicBadge, type PipelineEvidenceInput, type PipelinePublicBadge, } from '@/shared/pipeline-evidence'; import { getCachedPipelineRegistries } from '@/shared/pipeline-registry-store'; import { deriveStoragePublicBadge, type StorageEvidenceInput, type StoragePublicBadge, } from '@/shared/storage-evidence'; import { getCachedStorageFacilityRegistry } from '@/shared/storage-facility-registry-store'; import { getCachedFuelShortageRegistry } from '@/shared/fuel-shortage-registry-store'; // getCountryCentroid is imported lower in the file alongside other // country-geometry helpers; don't re-import it here. import { tokenizeForMatch, matchKeyword, matchesAnyKeyword, findMatchingKeywords } from '@/utils/keyword-match'; import { t } from '@/services/i18n'; import { debounce, rafSchedule, getCurrentTheme } from '@/utils/index'; import { isInputPending, scheduleYield } from '@/utils/after-paint'; import { showLayerWarning } from '@/utils/layer-warning'; import { localizeMapLabels } from '@/utils/map-locale'; import { createCountryHoverQueryController, resolveCountryForPointerInteraction, shouldRenderTradeAnimationFrame, shouldRunInputSensitiveMapWork, type CountryHoverQueryController, } from './map/input-delay-interactions'; import { getCachedMilitaryBases, preloadMilitaryBases } from '@/services/military-base-config'; import { INTEL_HOTSPOTS, CONFLICT_ZONES, GAMMA_IRRADIATORS, PIPELINES, PIPELINE_COLORS, STRATEGIC_WATERWAYS, SITE_VARIANT, PORTS, STOCK_EXCHANGES, FINANCIAL_CENTERS, CENTRAL_BANKS, COMMODITY_HUBS, GULF_INVESTMENTS, MINING_SITES, PROCESSING_PLANTS, COMMODITY_PORTS as COMMODITY_GEO_PORTS, } from '@/config'; // Tech-geo + ai-datacenters + geo-map tables imported directly so their chunks stay // off the eager @/config barrel and load only with this lazy renderer (#4404). import { STARTUP_HUBS, ACCELERATORS, TECH_HQS, CLOUD_REGIONS } from '@/config/tech-geo'; import { AI_DATA_CENTERS } from '@/config/ai-datacenters'; import { UNDERSEA_CABLES, NUCLEAR_FACILITIES, ECONOMIC_CENTERS, SPACEPORTS, CRITICAL_MINERALS, SANCTIONED_COUNTRIES_ALPHA2 } from '@/config/geo-map'; import type { GulfInvestment } from '@/types'; import { resolveTradeRouteSegments, TRADE_ROUTES as TRADE_ROUTES_LIST, type TradeRouteSegment, type TradeRouteStatus } from '@/config/trade-routes'; import type { ScenarioVisualState } from '@/config/scenario-templates'; import { getLayersForVariant, resolveLayerLabel, bindLayerSearch, getLayerExplanation, hasCuratedLayerExplanation, type MapVariant, } from '@/config/map-layer-definitions'; import { renderLayerExplanationCard } from '@/utils/layer-explanation-card'; import { getAuthState, subscribeAuthState } from '@/services/auth-state'; import { onEntitlementChange } from '@/services/entitlements'; import { hasPremiumAccess } from '@/services/panel-gating'; import { trackGateHit } from '@/services/analytics'; import { MapPopup, type PopupType } from './MapPopup'; import { renderMilitaryVesselTooltipHtml } from './deckgl-tooltip-renderers'; import type { GetChokepointStatusResponse } from '@/services/supply-chain'; import type { ChinaCorridorControlTower } from '../../shared/china-corridor-control-towers'; import { projectChinaCorridorOverlay, type ChinaCorridorOverlayProjection, } from './map/china-corridor-overlay'; import { updateHotspotEscalation, getHotspotEscalation, setMilitaryData, setCIIGetter, setGeoAlertGetter, } from '@/services/hotspot-escalation'; import { getCachedCountryScoreValue } from '@/services/cached-risk-scores'; import { getAlertsNearLocation } from '@/services/geo-convergence'; import type { PositiveGeoEvent } from '@/services/positive-events-geo'; import type { KindnessPoint } from '@/services/kindness-data'; import type { HappinessData } from '@/services/happiness-data'; import type { RenewableInstallation } from '@/services/renewable-installations'; import type { SpeciesRecovery } from '@/services/conservation-data'; import { getCountriesGeoJson, getCountryAtCoordinates, getCountryBbox, getCountryCentroid } from '@/services/country-geometry'; import type { DiseaseOutbreakItem } from '@/services/disease-outbreaks'; import type { FeatureCollection, Geometry } from 'geojson'; import type { ResilienceRankingItem } from '@/services/resilience'; import { RESILIENCE_CHOROPLETH_COLORS, buildResilienceChoroplethMap, formatResilienceChoroplethLevel, normalizeExclusiveChoropleths, } from './resilience-choropleth-utils'; import { formatResilienceServerLevel } from './resilience-widget-utils'; import { isAllowedPreviewUrl } from '@/utils/imagery-preview'; import { pinWebcam, isPinned } from '@/services/webcams/pinned-store'; import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client'; import { fetchWebcamImage } from '@/services/webcams'; import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; import { summarizeRenderTiming, formatRenderTiming } from '@/components/map/render-timing'; import { DeferredHeavyCommit } from '@/components/map/deferred-layer-commit'; import { type BBox, type BoundedFeature, culledIndices, geometryBounds, simplifyGeometry, zoomToSimplifyTolerance, } from '@/components/map/conflict-zone-cull'; import { createCountryClickGestureTracker, finishCountryClickGesture, markCountryClickDrag, refreshCountryClickDragSuppression, shouldSuppressCountryClick, startCountryClickGesture, updateCountryClickGestureDrag, type CountryClickGestureTracker, } from './map-interaction-guard'; 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 DeckGLMapOptions { chrome?: boolean; } 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'; const HAPPY_DARK_STYLE = '/map-styles/happy-dark.json'; const HAPPY_LIGHT_STYLE = '/map-styles/happy-light.json'; const isHappyVariant = SITE_VARIANT === 'happy'; // 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], cableFault: [255, 50, 50, 220] as [number, number, number, number], cableDegraded: [255, 165, 0, 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], protest: [255, 150, 0, 200] as [number, number, number, number], outage: [255, 50, 50, 180] as [number, number, number, number], trafficAnomaly: [255, 160, 0, 200] as [number, number, number, number], ddosHit: [180, 0, 255, 200] 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(``), // Airplane silhouette - top-down with wings and tail (pointing north, rotated by trackDeg) plane: 'data:image/svg+xml;base64,' + btoa(``), }; const BASES_ICON_MAPPING = { triangleUp: { x: 0, y: 0, width: 32, height: 32, mask: true } }; const NUCLEAR_ICON_MAPPING = { hexagon: { x: 0, y: 0, width: 32, height: 32, mask: true } }; const DATACENTER_ICON_MAPPING = { square: { x: 0, y: 0, width: 32, height: 32, mask: true } }; const AIRCRAFT_ICON_MAPPING = { plane: { x: 0, y: 0, width: 32, height: 32, mask: true } }; const CONFLICT_COUNTRY_ISO: Record = { iran: ['IR'], ukraine: ['UA'], sudan: ['SD'], myanmar: ['MM'], }; // Altitude-based color gradient matching Wingbits' color scheme. // Transitions cyan (sea level) → yellow-green → orange → red (cruise altitude). const ALTITUDE_COLOR_STOPS: Array<{ alt: number; r: number; g: number; b: number }> = [ { alt: 0, r: 0, g: 217, b: 255 }, { alt: 5000, r: 50, g: 250, b: 160 }, { alt: 10000, r: 200, g: 230, b: 60 }, { alt: 20000, r: 255, g: 165, b: 30 }, { alt: 30000, r: 255, g: 100, b: 35 }, { alt: 40000, r: 235, g: 50, b: 55 }, { alt: 45000, r: 210, g: 40, b: 70 }, ]; function altitudeToColor(altFt: number): [number, number, number] { const stops = ALTITUDE_COLOR_STOPS; const alt = Number.isFinite(altFt) ? altFt : 0; if (alt <= stops[0]!.alt) return [stops[0]!.r, stops[0]!.g, stops[0]!.b]; const last = stops[stops.length - 1]!; if (alt >= last.alt) return [last.r, last.g, last.b]; for (let i = 1; i < stops.length; i++) { const hi = stops[i]!; const lo = stops[i - 1]!; if (alt <= hi.alt) { const t = (alt - lo.alt) / (hi.alt - lo.alt); return [ Math.round(lo.r + (hi.r - lo.r) * t), Math.round(lo.g + (hi.g - lo.g) * t), Math.round(lo.b + (hi.b - lo.b) * t), ]; } } return [last.r, last.g, last.b]; // unreachable: exhaustive bracket search above satisfies TS } function ensureClosedRing(ring: [number, number][]): [number, number][] { if (ring.length < 2) return ring; const first = ring[0]!; const last = ring[ring.length - 1]!; if (first[0] === last[0] && first[1] === last[1]) return ring; return [...ring, first]; } /** Module-level Map from routeId → waypoint IDs. Built once, reused across all layer renders. */ const ROUTE_WAYPOINTS_MAP = new Map( TRADE_ROUTES_LIST.map(r => [r.id, r.waypoints]), ); interface TripData { path: [number, number][]; phase: number; color: [number, number, number, number]; width: number; } type HighlightedMarker = { id: string; lon: number; lat: number; name: string; score: number }; /** GpsJamHex with its H3 cell boundary precomputed once at ingestion (see setGpsJamming). */ type GpsJamHexWithPolygon = GpsJamHex & { polygon: [number, number][] }; interface BypassArcDatum { source: [number, number]; target: [number, number]; } function interpolateGreatCircle( start: [number, number], end: [number, number], numPoints: number, ): [number, number][] { const toRad = (d: number) => d * Math.PI / 180; const toDeg = (r: number) => r * 180 / Math.PI; const [lon1, lat1] = [toRad(start[0]), toRad(start[1])]; const [lon2, lat2] = [toRad(end[0]), toRad(end[1])]; const d = 2 * Math.asin(Math.sqrt( Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lon2 - lon1) / 2) ** 2, )); if (d < 1e-10) return [start, end]; const points: [number, number][] = []; for (let i = 0; i <= numPoints; i++) { const f = i / numPoints; const A = Math.sin((1 - f) * d) / Math.sin(d); const B = Math.sin(f * d) / Math.sin(d); const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2); const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2); const z = A * Math.sin(lat1) + B * Math.sin(lat2); points.push([toDeg(Math.atan2(y, x)), toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)))]); } return points; } function positionAlongPath(path: [number, number][], progress: number): [number, number] { if (path.length === 0) return [0, 0]; if (path.length === 1) return path[0]!; const clamped = Math.max(0, Math.min(1, progress)); const scaled = clamped * (path.length - 1); const index = Math.min(path.length - 2, Math.floor(scaled)); const fraction = scaled - index; const [lonA, latA] = path[index]!; const [lonB, latB] = path[index + 1]!; // Unwrap longitude across the antimeridian so the lerp takes the short way: // great-circle samples can straddle ±180 (e.g. 176 → -176), and a raw lerp // would sweep the dot across the whole map to ~0°E for that segment (#4396). let deltaLon = lonB - lonA; if (deltaLon > 180) deltaLon -= 360; else if (deltaLon < -180) deltaLon += 360; let lon = lonA + deltaLon * fraction; if (lon > 180) lon -= 360; else if (lon < -180) lon += 360; return [lon, latA + (latB - latA) * fraction]; } const PREFERS_REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; const TRADE_ANIMATION_CYCLE = 1000; const TRADE_ANIMATION_SPEED = 0.3; const TRADE_ANIMATION_MAX_DELTA_MS = 100; const TRADE_GC_INTERPOLATION_POINTS = 20; const CHOKEPOINT_PULSE_FREQ = 0.01; const CHOKEPOINT_PULSE_AMP = 0.3; function stableTradeRoutePhase(routeId: string): number { let hash = 2166136261; for (let i = 0; i < routeId.length; i++) { const codeUnit = routeId.charCodeAt(i); hash ^= codeUnit & 0xff; hash = Math.imul(hash, 16777619); hash ^= (codeUnit >> 8) & 0xff; hash = Math.imul(hash, 16777619); } return ((hash >>> 0) / 0x100000000) * TRADE_ANIMATION_CYCLE; } // Process-wide guard so the window error listener for the deck.gl/maplibre // interleaved-mode render race is installed exactly once even if a hot-reload // or recreateWithFallback rebuilds the map. let __deckInterleavedRaceFilterInstalled = false; const DECK_INTERLEAVED_RACE_MESSAGE_RE = /Cannot read properties of null \(reading 'id'\)|null is not an object \(evaluating '[\w.]+\.id'\)/; const DECK_INTERLEAVED_RACE_SOURCE_RE = /(?:^|[/(])deck-stack-[A-Za-z0-9_-]+\.js/; /** * Swallow the well-known deck.gl 9.x + maplibre-gl 5.x interleaved-mode race: * * Uncaught TypeError: Cannot read properties of null (reading 'id') * at DeckRenderer._drawLayers (deck-stack-*.js) * at LayerManager.renderLayers * at MapLibre painter.renderLayer (maplibre-*.js) * * Trigger: setProps({layers}) → deck _resolveLayers calls maplibre.removeLayer * for a layer that's being swapped → maplibre schedules a triggerRepaint that * fires the next frame → that repaint runs deck's `render()` via maplibre's * custom-layer hook → deck iterates the layer list and hits a layer that was * finalized between resolveLayers and renderLayers. * * MapboxOverlay's own onError is bypassed because maplibre — not deck — owns * the render-loop callstack here (deck doesn't see the throw, so onError is * never invoked). The next frame renders cleanly with no user-visible * artifact, so swallowing here is safe. * * Sentry's beforeSend in main.ts already filters this exact pattern for * telemetry, but the browser still logs "Uncaught TypeError" to the console * — this listener suppresses that. * * Narrow on BOTH the message shape AND deck-stack chunk evidence so an * unrelated null-id crash in first-party code still surfaces. Some browsers * surface the exception through Sentry's rAF wrapper, so ev.filename can point * at sentry-*.js while ev.error.stack still contains deck-stack-*.js. */ function installDeckInterleavedRaceFilter(): void { if (__deckInterleavedRaceFilterInstalled) return; __deckInterleavedRaceFilterInstalled = true; window.addEventListener('error', (ev) => { const msg = ev.error?.message ?? ev.message ?? ''; const file = ev.filename ?? ''; const stack = typeof ev.error?.stack === 'string' ? ev.error.stack : ''; const source = `${file}\n${stack}`; if ( DECK_INTERLEAVED_RACE_MESSAGE_RE.test(msg) && DECK_INTERLEAVED_RACE_SOURCE_RE.test(source) ) { ev.preventDefault(); ev.stopImmediatePropagation(); if (import.meta.env.DEV) { console.warn('[DeckGLMap] swallowed interleaved-mode render race (deck.gl/maplibre)'); } } }, { capture: true }); } 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; private isResizing = false; private savedTopLat: number | null = null; private correctingCenter = false; // Data stores private hotspots: HotspotWithBreaking[]; private earthquakes: Earthquake[] = []; private weatherAlerts: WeatherAlert[] = []; private outages: InternetOutage[] = []; private trafficAnomalies: ProtoTrafficAnomaly[] = []; private ddosLocations: DdosLocationHit[] = []; private cyberThreats: CyberThreat[] = []; private aptGroups: import('@/types').APTGroup[] = []; private aptGroupsLoaded = false; private _unsubscribeAuthState: (() => void) | null = null; private _unsubscribeEntitlement: (() => void) | null = null; private aptGroupsLayerFailed = false; private satelliteImageryLayerFailed = false; private iranEvents: IranEvent[] = []; private aisDisruptions: AisDisruptionEvent[] = []; private aisDensity: AisDensityZone[] = []; private liveTankers: Array<{ mmsi: string; lat: number; lon: number; speed: number; shipType: number; name: string }> = []; private liveTankersAbort: AbortController | null = null; private liveTankersTimer: ReturnType | null = null; private cableAdvisories: CableAdvisory[] = []; private repairShips: RepairShip[] = []; private healthByCableId: Record = {}; private protests: SocialUnrestEvent[] = []; private militaryFlights: MilitaryFlight[] = []; private militaryFlightClusters: MilitaryFlightCluster[] = []; private activeFlightTrails = new Set(); private clearTrailsBtn: HTMLButtonElement | null = null; private militaryVessels: MilitaryVessel[] = []; private militaryVesselClusters: MilitaryVesselCluster[] = []; private serverBases: MilitaryBaseEnriched[] = []; private serverBaseClusters: ServerBaseCluster[] = []; private serverBasesLoaded = false; private baseConfigLoadPending = false; 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 aircraftPositions: PositionSample[] = []; private aircraftFetchTimer: ReturnType | null = null; 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 gpsJammingHexes: GpsJamHexWithPolygon[] = []; private gpsJammingLoadSeq = 0; private climateAnomalies: ClimateAnomaly[] = []; private radiationObservations: RadiationObservation[] = []; private diseaseOutbreaks: DiseaseOutbreakItem[] = []; private tradeRouteSegments: TradeRouteSegment[] = resolveTradeRouteSegments(); private tradeTrips: TripData[] = []; private tradeAnimationTime = 0; private tradeAnimationFrame: number | null = null; private tradeAnimationFrameCount = 0; private tradeReducedMotionMedia: MediaQueryList | null = null; private storedChokepointData: GetChokepointStatusResponse | null = null; private highlightedRouteIds: Set = new Set(); private highlightedMarkers: HighlightedMarker[] = []; private bypassArcData: BypassArcDatum[] = []; private scenarioState: ScenarioVisualState | null = null; private selectedChinaCorridorOverlay: ChinaCorridorOverlayProjection | null = null; private affectedIso2Set: Set = new Set(); private positiveEvents: PositiveGeoEvent[] = []; private kindnessPoints: KindnessPoint[] = []; private imageryScenes: ImageryScene[] = []; private imagerySearchTimer: ReturnType | null = null; private imagerySearchVersion = 0; // Phase 8 overlay data private happinessScores: Map = new Map(); private happinessYear = 0; private happinessSource = ''; private speciesRecoveryZones: Array = []; private renewableInstallations: RenewableInstallation[] = []; private webcamData: Array = []; private countriesGeoJsonData: FeatureCollection | null = null; private conflictZoneGeoJson: GeoJSON.FeatureCollection | null = null; // #4561: all zone features + their precomputed bounds, built once (cheap — no // tessellation); the viewport cull filters this into conflictZoneGeoJson. private conflictZoneBounded: BoundedFeature[] | null = null; // Keyed on the culled feature-index set + simplify tolerance (the FC's actual // content), NOT the viewport — so a pan that leaves the visible set unchanged // returns the same FeatureCollection ref and Object.is short-circuits the // deck.gl re-tessellation (#4561 review P2). private conflictZoneContentKey: string | null = null; // #4601: countries features + precomputed bounds, built once (cheap — no // tessellation); the viewport cull filters this for the choropleth layers, // mirroring conflictZoneBounded. Invalidated with countriesGeoJsonData. private countriesBounded: BoundedFeature[] | null = null; private culledCountriesGeoJson: GeoJSON.FeatureCollection | null = null; private culledCountriesContentKey = ''; // CII choropleth data private ciiScoresMap: Map = new Map(); private ciiScoresVersion = 0; private resilienceScoresMap: ReturnType = new Map(); private resilienceScoresVersion = 0; // Country highlight state private countryGeoJsonLoaded = false; private countryHoverSetup = false; private highlightedCountryCode: string | null = null; private hoveredCountryIso2: string | null = null; private hoveredCountryName: string | null = null; // Callbacks private onHotspotClick?: (hotspot: Hotspot) => void; private onTradeArcClick?: (segment: TradeRouteSegment, waypoints: string[], x: number, y: number) => void; private onTimeRangeChange?: (range: TimeRange) => void; private onCountryClick?: (country: CountryClickPayload) => void; private onMapContextMenu?: (payload: { lat: number; lon: number; screenX: number; screenY: number; countryCode?: string; countryName?: string }) => void; private readonly countryClickGesture: CountryClickGestureTracker = createCountryClickGestureTracker(); private readonly handleCountryClickPointerDown = (e: PointerEvent): void => { if (e.pointerType === 'mouse' && e.button !== 0) return; if (e.isPrimary === false) return; startCountryClickGesture(this.countryClickGesture, { x: e.clientX, y: e.clientY }); }; private readonly handleCountryClickPointerMove = (e: PointerEvent): void => { if (e.isPrimary === false) return; updateCountryClickGestureDrag(this.countryClickGesture, { x: e.clientX, y: e.clientY }); }; private readonly handleCountryClickPointerEnd = (): void => { finishCountryClickGesture(this.countryClickGesture); }; private readonly markCountryDragGesture = (): void => { markCountryClickDrag(this.countryClickGesture); }; private readonly refreshCountryDragSuppression = (): void => { refreshCountryClickDragSuppression(this.countryClickGesture); }; private attachMapLibreInteractionHandlers(): void { if (!this.maplibreMap) return; const canvas = this.maplibreMap.getCanvas(); canvas.addEventListener('contextmenu', this.handleContextMenu); canvas.addEventListener('pointerdown', this.handleCountryClickPointerDown); canvas.addEventListener('pointermove', this.handleCountryClickPointerMove); canvas.addEventListener('pointerup', this.handleCountryClickPointerEnd); canvas.addEventListener('pointercancel', this.handleCountryClickPointerEnd); this.maplibreMap.on('dragstart', this.markCountryDragGesture); this.maplibreMap.on('dragend', this.refreshCountryDragSuppression); } private detachMapLibreInteractionHandlers(): void { if (!this.maplibreMap) return; const canvas = this.maplibreMap.getCanvas(); this.maplibreMap.off('dragstart', this.markCountryDragGesture); this.maplibreMap.off('dragend', this.refreshCountryDragSuppression); canvas.removeEventListener('contextmenu', this.handleContextMenu); canvas.removeEventListener('pointerdown', this.handleCountryClickPointerDown); canvas.removeEventListener('pointermove', this.handleCountryClickPointerMove); canvas.removeEventListener('pointerup', this.handleCountryClickPointerEnd); canvas.removeEventListener('pointercancel', this.handleCountryClickPointerEnd); } private readonly handleContextMenu = (e: MouseEvent): void => { e.preventDefault(); if (!this.onMapContextMenu || !this.maplibreMap) return; const rect = this.container.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const lngLat = this.maplibreMap.unproject([x, y]); if (!Number.isFinite(lngLat.lng)) return; const country = resolveCountryForPointerInteraction( { code: this.hoveredCountryIso2, name: this.hoveredCountryName }, this.hoverQueryThrottle?.isPending() ?? false, () => this.resolveCountryFromCoordinate(lngLat.lng, lngLat.lat), ); this.onMapContextMenu({ lat: lngLat.lat, lon: lngLat.lng, screenX: e.clientX, screenY: e.clientY, countryCode: country?.code, countryName: country?.name, }); }; private onLayerChange?: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void; private onStateChange?: (state: DeckMapState) => void; private onAircraftPositionsUpdate?: (positions: PositionSample[]) => void; // Highlighted assets private highlightedAssets: Record> = { pipeline: new Set(), cable: new Set(), datacenter: new Set(), base: new Set(), nuclear: new Set(), }; private renderRafId: number | null = null; private renderPaused = false; private renderPending = false; private webglLost = false; /** Stable empty GeoJSON for the immediate (pre-defer) frame of a heavy layer (#4558). */ private readonly emptyHeavyData: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; /** * Two-phase heavy-layer commit (#4558). On the interaction-attributed frame * heavy layers render their previously-committed (or empty) data; the real * data is committed on a deferred (yielded) frame so the deck.gl tessellation * lands off the measured frame. The gate skips the defer when data is unchanged. */ private readonly heavyGate = new DeferredHeavyCommit({ // Yield the deferred heavy-layer flush off the interaction frame via // scheduler.yield (ahead of clamped timers, behind queued input) — #5042 U4. schedule: (fn) => scheduleYield(fn), isAlive: () => !this.renderPaused && !this.webglLost && !!this.maplibreMap, onCommit: () => this.updateLayers(true), }); private destroyed = false; private usedFallbackStyle = false; private readonly chrome: boolean; private initPromise: Promise = Promise.resolve(); private styleLoadTimeoutId: ReturnType | null = null; private tileMonitorGeneration = 0; 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 datacenterSCSource: AIDataCenter[] = []; 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 dayNightIntervalId: ReturnType | null = null; private cachedNightPolygon: [number, number][] | null = null; private radarRefreshIntervalId: ReturnType | null = null; private radarActive = false; private radarTileUrl = ''; // Drop duplicate `once('idle', applyRadarLayer)` registrations when // the source isn't loaded yet. Without this, both the style.load // callback and the 5-minute refresh can register listeners in the // same load window — they'd all fire on the next idle and call // setTiles back-to-back. Idempotent today but wasteful. private radarIdlePending = false; private readonly startupTime = Date.now(); private basemapSwitchSeq = 0; private lastCableHighlightSignature = ''; private lastCableHealthSignature = ''; private lastPipelineHighlightSignature = ''; private debouncedRebuildLayers: (() => void) & { cancel(): void }; private debouncedFetchBases: (() => void) & { cancel(): void }; private debouncedFetchAircraft: (() => void) & { cancel(): void }; private rafUpdateLayers: (() => void) & { cancel(): void }; private handleThemeChange: () => void; private handleMapThemeChange: () => void; private readonly handleTradeMotionPreferenceChange = (): void => { if (this.prefersReducedTradeMotion()) { this.stopTradeAnimation(); } else if (this.state.layers.tradeRoutes && !this.renderPaused) { this.startTradeAnimation(); } this.render(); }; private moveTimeoutId: ReturnType | null = null; /** Target center set eagerly by setView() so getCenter() returns the correct * destination before moveend fires, preventing stale intermediate coords * from being written to the URL during flyTo. Cleared on moveend. */ private pendingCenter: { lat: number; lon: number } | null = null; private lastAircraftFetchCenter: [number, number] | null = null; private lastAircraftFetchZoom = -1; private aircraftFetchSeq = 0; constructor(container: HTMLElement, initialState: DeckMapState, options: DeckGLMapOptions = {}) { this.container = container; this.chrome = options.chrome ?? true; this.state = { ...initialState, pan: { ...initialState.pan }, layers: normalizeExclusiveChoropleths(initialState.layers, null), }; this.hotspots = [...INTEL_HOTSPOTS]; this.debouncedRebuildLayers = debounce(() => { if (this.renderPaused || this.webglLost || !this.maplibreMap) return; this.maplibreMap.resize(); this.updateLayers(); }, 150); this.debouncedFetchBases = debounce(() => this.fetchServerBases(), 300); this.debouncedFetchAircraft = debounce(() => this.fetchViewportAircraft(), 500); this.rafUpdateLayers = rafSchedule(() => { if (this.renderPaused || this.webglLost || !this.maplibreMap) return; this.updateLayers(); }); this.setupDOM(); this.popup = new MapPopup(container); this.handleThemeChange = () => { if (isHappyVariant) { void this.switchBasemap(); return; } const provider = getMapProvider(); const mapTheme = getMapTheme(provider); const paintTheme = isLightMapTheme(mapTheme) ? 'light' as const : 'dark' as const; this.updateCountryLayerPaint(paintTheme); this.render(); }; window.addEventListener('theme-changed', this.handleThemeChange); this.handleMapThemeChange = () => { void this.switchBasemap(); }; window.addEventListener('map-theme-changed', this.handleMapThemeChange); this.tradeReducedMotionMedia = window.matchMedia(PREFERS_REDUCED_MOTION_QUERY); this.tradeReducedMotionMedia.addEventListener('change', this.handleTradeMotionPreferenceChange); this.initPromise = this.initMapLibre(); if (this.chrome) { this.createControls(); this.createTimeSlider(); this.createLayerToggles(); this.createLegend(); } // Start day/night timer only if layer is initially enabled if (this.state.layers.dayNight) { this.startDayNightTimer(); } if (this.state.layers.weather) { this.startWeatherRadar(); } // Kick off lazy APT load if cyberThreats is already on at init (e.g. from URL/localStorage) if (this.state.layers.cyberThreats && SITE_VARIANT !== 'tech' && SITE_VARIANT !== 'happy') { this.loadAptGroups(); } } private startDayNightTimer(): void { if (this.dayNightIntervalId) return; this.cachedNightPolygon = this.computeNightPolygon(); this.dayNightIntervalId = setInterval(() => { this.cachedNightPolygon = this.computeNightPolygon(); this.render(); }, 5 * 60 * 1000); } private stopDayNightTimer(): void { if (this.dayNightIntervalId) { clearInterval(this.dayNightIntervalId); this.dayNightIntervalId = null; } this.cachedNightPolygon = null; } private startWeatherRadar(): void { this.radarActive = true; this.fetchAndApplyRadar(); if (!this.radarRefreshIntervalId) { this.radarRefreshIntervalId = setInterval(() => this.fetchAndApplyRadar(), 5 * 60 * 1000); } } private stopWeatherRadar(): void { this.radarActive = false; if (this.radarRefreshIntervalId) { clearInterval(this.radarRefreshIntervalId); this.radarRefreshIntervalId = null; } this.removeRadarLayer(); } private fetchAndApplyRadar(): void { fetch('https://api.rainviewer.com/public/weather-maps.json') .then(r => r.json()) .then((data: { host: string; radar: { past: Array<{ path: string }> } }) => { const past = data.radar?.past; const latest = past?.[past.length - 1]; if (!latest) return; this.radarTileUrl = `${data.host}${latest.path}/256/{z}/{x}/{y}/6/1_1.png`; this.applyRadarLayer(); }) .catch((err) => console.warn('[DeckGLMap] weather radar fetch failed:', err?.message || err)); } private applyRadarLayer(): void { if (!this.maplibreMap || !this.radarActive || !this.radarTileUrl) return; if (!this.maplibreMap.isStyleLoaded()) { this.maplibreMap.once('style.load', () => this.applyRadarLayer()); return; } try { const existing = this.maplibreMap.getSource('weather-radar') as (maplibregl.RasterTileSource & { setTiles: (tiles: string[]) => void }) | undefined; if (existing) { // Guard against the source existing in the style registry while // its underlying texture is mid-load or being torn down. Calling // setTiles in that window triggers a render-frame crash inside // MapLibre at fa() / texture.bind() (Sentry WORLDMONITOR-P6: // Firefox 149, hit on the 5-minute radar refresh interval). // isSourceLoaded(id) is MapLibre's official "tiles fetched + // applied to GL state" check; defer to the next idle if false. if (!this.maplibreMap.isSourceLoaded('weather-radar')) { if (!this.radarIdlePending) { this.radarIdlePending = true; this.maplibreMap.once('idle', () => { this.radarIdlePending = false; this.applyRadarLayer(); }); } return; } existing.setTiles([this.radarTileUrl]); return; } this.maplibreMap.addSource('weather-radar', { type: 'raster', tiles: [this.radarTileUrl], tileSize: 256, attribution: '© RainViewer', }); const beforeId = this.maplibreMap.getLayer('country-interactive') ? 'country-interactive' : undefined; this.maplibreMap.addLayer({ id: 'weather-radar-layer', type: 'raster', source: 'weather-radar', paint: { 'raster-opacity': 0.65 }, }, beforeId); } catch (err) { console.warn('[DeckGLMap] radar layer apply failed:', (err as Error)?.message); } } private removeRadarLayer(): void { if (!this.maplibreMap) return; try { if (this.maplibreMap.getLayer('weather-radar-layer')) this.maplibreMap.removeLayer('weather-radar-layer'); if (this.maplibreMap.getSource('weather-radar')) this.maplibreMap.removeSource('weather-radar'); } catch { /* ignore */ } } 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); const attribution = document.createElement('div'); attribution.className = 'map-attribution'; setTrustedHtml(attribution, trustedHtml(isHappyVariant ? '© CARTO © OpenStreetMap' : '© Protomaps © OpenStreetMap', "legacy direct innerHTML migration")); wrapper.appendChild(attribution); this.container.appendChild(wrapper); } /** * Resolves once the initial MapLibre construction has settled (success, or a * guarded teardown that bailed before constructing the map). Rejects if map * construction throws (e.g. a WebGL init failure), letting * MapContainer.createDeckGLMap fall back to the SVG renderer — the failure * path that the fire-and-forget `void this.initMapLibre()` would otherwise * swallow into an unhandled rejection + blank map. */ public whenReady(): Promise { return this.initPromise; } private async initMapLibre(): Promise { if (maplibregl.getRTLTextPluginStatus() === 'unavailable') { maplibregl.setRTLTextPlugin( '/mapbox-gl-rtl-text.min.js', true, ); } const { mapTheme: initialMapTheme, style: primaryStyle } = await this.resolveInitialBasemapStyle(); // The component can be torn down (renderer switch) while the style import // above is in flight; bail before constructing a MapLibre map that destroy() // can no longer reach — it would orphan a live WebGL context, its listeners // and the 10s styleLoadTimeoutId. if (this.destroyed) return; const preset = VIEW_PRESETS[this.state.view]; if (!isHappyVariant && typeof primaryStyle === 'string' && !primaryStyle.includes('pmtiles')) { this.usedFallbackStyle = true; const attr = this.container.querySelector('.map-attribution'); if (attr) setTrustedHtml(attr, trustedHtml('© OpenFreeMap © OpenStreetMap', "legacy direct innerHTML migration")); } const basemapEl = document.getElementById('deckgl-basemap'); if (!basemapEl) return; this.maplibreMap = new maplibregl.Map({ container: basemapEl, style: primaryStyle, center: [preset.longitude, preset.latitude], // Fresh-load default = view preset zoom. Any zoom set during the deferred // renderer window is re-applied by MapContainer.rehydrateActiveMap() after // mount, so seeding from this.state.zoom here only undershoots the preset // on the default desktop view. zoom: preset.zoom, renderWorldCopies: false, attributionControl: false, interactive: true, canvasContextAttributes: { powerPreference: 'high-performance' }, ...(MAP_INTERACTION_MODE === 'flat' ? { maxPitch: 0, pitchWithRotate: false, dragRotate: false, touchPitch: false, } : {}), }); const recreateWithFallback = () => { if (this.usedFallbackStyle) return; this.usedFallbackStyle = true; const fallback = isLightMapTheme(initialMapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE; console.warn(`[DeckGLMap] Primary basemap failed, recreating with fallback: ${fallback}`); const attr = this.container.querySelector('.map-attribution'); if (attr) setTrustedHtml(attr, trustedHtml('© OpenFreeMap © OpenStreetMap', "legacy direct innerHTML migration")); this.detachMapLibreInteractionHandlers(); this.maplibreMap?.remove(); const fallbackEl = document.getElementById('deckgl-basemap'); if (!fallbackEl) return; this.maplibreMap = new maplibregl.Map({ container: fallbackEl, style: fallback, center: [preset.longitude, preset.latitude], zoom: preset.zoom, renderWorldCopies: false, attributionControl: false, interactive: true, canvasContextAttributes: { powerPreference: 'high-performance' }, ...(MAP_INTERACTION_MODE === 'flat' ? { maxPitch: 0, pitchWithRotate: false, dragRotate: false, touchPitch: false, } : {}), }); this.maplibreMap.on('load', () => { this.attachMapLibreInteractionHandlers(); localizeMapLabels(this.maplibreMap); this.initDeck(); this.loadCountryBoundaries(); this.fetchServerBases(); this.render(); }); }; this.maplibreMap.on('load', () => { localizeMapLabels(this.maplibreMap); this.initDeck(); this.loadCountryBoundaries(); this.fetchServerBases(); this.render(); }); let tileLoadOk = false; let tileErrorCount = 0; this.maplibreMap.on('error', (e: { error?: Error; message?: string }) => { const msg = e.error?.message ?? e.message ?? ''; console.warn('[DeckGLMap] map error:', msg); if (msg.includes('Failed to fetch') || msg.includes('AJAXError') || msg.includes('CORS') || msg.includes('NetworkError') || msg.includes('403') || msg.includes('Forbidden')) { tileErrorCount++; if (!tileLoadOk && tileErrorCount >= 2) { recreateWithFallback(); } } }); this.maplibreMap.on('data', (e: { dataType?: string }) => { if (e.dataType === 'source') { tileLoadOk = true; if (this.styleLoadTimeoutId) { clearTimeout(this.styleLoadTimeoutId); this.styleLoadTimeoutId = null; } } }); this.styleLoadTimeoutId = setTimeout(() => { this.styleLoadTimeoutId = null; if (!tileLoadOk) recreateWithFallback(); }, 10000); 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(); }); // Pin top edge during drag-resize: correct center shift synchronously // inside MapLibre's own resize() call (before it renders the frame). this.maplibreMap.on('move', () => { if (this.correctingCenter || !this.isResizing || !this.maplibreMap) return; if (this.savedTopLat === null) return; const w = this.maplibreMap.getCanvas().clientWidth; if (w <= 0) return; const currentTop = this.maplibreMap.unproject([w / 2, 0]).lat; const delta = this.savedTopLat - currentTop; if (Math.abs(delta) > 1e-6) { this.correctingCenter = true; const c = this.maplibreMap.getCenter(); const clampedLat = Math.max(-90, Math.min(90, c.lat + delta)); this.maplibreMap.jumpTo({ center: [c.lng, clampedLat] }); this.correctingCenter = false; // Do NOT update savedTopLat — keep the original mousedown position // so every frame targets the exact same geographic anchor. } }); this.attachMapLibreInteractionHandlers(); } private initDeck(): void { if (!this.maplibreMap) return; installDeckInterleavedRaceFilter(); this.deckOverlay = new MapboxOverlay({ interleaved: true, layers: this.buildLayers(true), 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); if (error.message.includes('apt-groups-layer')) { this.aptGroupsLayerFailed = true; } if (error.message.includes('satellite-imagery-layer')) { this.satelliteImageryLayerFailed = true; console.warn('[DeckGLMap] Satellite imagery layer failed (likely Intel GPU driver incompatibility) — rebuilding layer stack without it'); this.updateLayers(); } }, }); 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.pendingCenter = null; this.lastSCZoom = -1; this.rafUpdateLayers(); this.debouncedFetchBases(); this.debouncedFetchAircraft(); this.state.zoom = this.maplibreMap?.getZoom() ?? this.state.zoom; this.onStateChange?.(this.getState()); if (this.state.layers.satellites) { if (this.imagerySearchTimer) clearTimeout(this.imagerySearchTimer); this.imagerySearchTimer = setTimeout(() => this.fetchImageryForViewport(), 500); } }); 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(); } this.state.zoom = this.maplibreMap?.getZoom() ?? this.state.zoom; this.onStateChange?.(this.getState()); }); } public setIsResizing(value: boolean): void { this.isResizing = value; if (value && this.maplibreMap) { const w = this.maplibreMap.getCanvas().clientWidth; if (w > 0) { this.savedTopLat = this.maplibreMap.unproject([w / 2, 0]).lat; } } else { this.savedTopLat = null; } } private async resolveInitialBasemapStyle(): Promise<{ mapTheme: string; style: StyleSpecification | string }> { if (isHappyVariant) { const mapTheme = getCurrentTheme(); return { mapTheme, style: mapTheme === 'light' ? HAPPY_LIGHT_STYLE : HAPPY_DARK_STYLE, }; } for (let attempt = 0; attempt < 10; attempt++) { const provider = getMapProvider(); const mapTheme = getMapTheme(provider); const style = await getStyleForProvider(provider, mapTheme); const currentProvider = getMapProvider(); const currentMapTheme = getMapTheme(currentProvider); if (provider === currentProvider && mapTheme === currentMapTheme) { return { mapTheme, style }; } } const provider = getMapProvider(); const mapTheme = getMapTheme(provider); console.warn('[DeckGLMap] Map provider changed repeatedly during startup; using latest provider state'); return { mapTheme, style: provider === 'carto' ? await getStyleForProvider(provider, mapTheme) : (isLightMapTheme(mapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE), }; } public resize(): void { this.maplibreMap?.resize(); } 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 _timeFilterCache = new WeakMap(); private filterByTimeCached( items: T[], getTime: (item: T) => Date | string | number | undefined | null ): T[] { const min = Math.floor(Date.now() / 60000); const range = this.state.timeRange; const cached = this._timeFilterCache.get(items as object); if (cached && cached.min === min && cached.range === range) return cached.result as T[]; const result = this.filterByTime(items, getTime); this._timeFilterCache.set(items as object, { min, range, result }); return result; } private filterMilitaryFlightClustersByTimeCached(clusters: MilitaryFlightCluster[]): MilitaryFlightCluster[] { const min = Math.floor(Date.now() / 60000); const range = this.state.timeRange; const cached = this._timeFilterCache.get(clusters as object); if (cached && cached.min === min && cached.range === range) return cached.result as MilitaryFlightCluster[]; const result = this.filterMilitaryFlightClustersByTime(clusters); this._timeFilterCache.set(clusters as object, { min, range, result }); return result; } private filterMilitaryVesselClustersByTimeCached(clusters: MilitaryVesselCluster[]): MilitaryVesselCluster[] { const min = Math.floor(Date.now() / 60000); const range = this.state.timeRange; const cached = this._timeFilterCache.get(clusters as object); if (cached && cached.min === min && cached.range === range) return cached.result as MilitaryVesselCluster[]; const result = this.filterMilitaryVesselClustersByTime(clusters); this._timeFilterCache.set(clusters as object, { min, range, result }); return result; } 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, sourceType: p.sourceType, validated: Boolean(p.validated), fatalities: Number.isFinite(p.fatalities) ? Number(p.fatalities) : 0, timeMs: p.time.getTime(), }, })); 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, riotTimeMs: props.eventType === 'riot' && props.sourceType !== 'gdelt' && Number.isFinite(Number(props.timeMs)) ? Number(props.timeMs) : 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); const accRiot = Number(acc.riotTimeMs ?? 0); const propRiot = Number(props.riotTimeMs ?? 0); acc.riotTimeMs = Number.isFinite(propRiot) ? Math.max(accRiot, propRiot) : accRiot; 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'); this.datacenterSCSource = activeDCs; 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 (useTechHQ && !this.techHQSC) this.rebuildTechHQSupercluster(); if (useDatacenterClusters && !this.datacenterSC) this.rebuildDatacenterSupercluster(); 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 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 ?? 0); const riotTimeMs = Number(props.riotTimeMs ?? 0); return { id: `pc-${f.properties.cluster_id}`, _clusterId: f.properties.cluster_id!, lat: coords[1], lon: coords[0], count: clusterCount, items: [] as SocialUnrestEvent[], country: String(props.country ?? ''), maxSeverity: maxSev as 'low' | 'medium' | 'high', hasRiot: riotCount > 0, latestRiotEventTimeMs: riotTimeMs || undefined, totalFatalities, riotCount, highSeverityCount, verifiedCount, sampled: clusterCount > DeckGLMap.MAX_CLUSTER_LEAVES, }; } 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 faangCount = Number(props.faangCount ?? 0); const unicornCount = Number(props.unicornCount ?? 0); const publicCount = Number(props.publicCount ?? 0); const clusterCount = Number(f.properties.point_count ?? 0); const primaryType = faangCount >= unicornCount && faangCount >= publicCount ? 'faang' : unicornCount >= publicCount ? 'unicorn' : 'public'; return { id: `hc-${f.properties.cluster_id}`, _clusterId: f.properties.cluster_id!, lat: coords[1], lon: coords[0], count: clusterCount, items: [] as import('@/config/tech-geo').TechHQ[], city: String(props.city ?? ''), country: String(props.country ?? ''), primaryType, faangCount, unicornCount, publicCount, sampled: clusterCount > DeckGLMap.MAX_CLUSTER_LEAVES, }; } 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 clusterCount = Number(f.properties.point_count ?? 0); const soonestDaysUntil = Number(props.soonestDaysUntil ?? Number.MAX_SAFE_INTEGER); const soonCount = Number(props.soonCount ?? 0); return { id: `ec-${f.properties.cluster_id}`, _clusterId: f.properties.cluster_id!, lat: coords[1], lon: coords[0], count: clusterCount, items: [] as TechEventMarker[], location: String(props.location ?? ''), country: String(props.country ?? ''), soonestDaysUntil: Number.isFinite(soonestDaysUntil) ? soonestDaysUntil : Number.MAX_SAFE_INTEGER, soonCount, sampled: clusterCount > DeckGLMap.MAX_CLUSTER_LEAVES, }; } 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 = this.datacenterSCSource; 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 clusterCount = Number(f.properties.point_count ?? 0); 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}`, _clusterId: f.properties.cluster_id!, lat: coords[1], lon: coords[0], count: clusterCount, items: [] as AIDataCenter[], region: String(props.country ?? ''), country: String(props.country ?? ''), totalChips, totalPowerMW, majorityExisting: existingCount >= Math.max(1, clusterCount / 2), existingCount, plannedCount, sampled: clusterCount > DeckGLMap.MAX_CLUSTER_LEAVES, }; } 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(deferHeavy = false): 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 = mapLayers.natural ? this.filterByTimeCached(this.earthquakes, (eq) => eq.occurredAt) : []; const filteredNaturalEvents = mapLayers.natural ? this.filterByTimeCached(this.naturalEvents, (event) => event.date) : []; // Disease outbreaks are sparse-by-nature — WHO Disease Outbreak News // publishes 1-2 alerts/week, CDC HAN alerts are infrequent, and the // upstream ThinkGlobalHealth tracker carries 90 days of ProMED items. // Applying the global time-range filter (max '7d' in the dropdown) // wholesale-zeroes the layer when the most recent WHO/CDC update is // 8+ days old, which is normal for these sources. Show all items in // the cache; the seeder's TTL + per-source lookback already bound // freshness at write time. PR #3593: production saw 50 valid records // cached but 0 rendered because the newest CDC item was 11d old. const filteredDiseaseOutbreaks = mapLayers.diseaseOutbreaks ? this.diseaseOutbreaks : []; const filteredRadiationObservations = mapLayers.radiationWatch ? this.filterByTimeCached(this.radiationObservations, (obs) => obs.observedAt) : []; const filteredPositiveEvents = mapLayers.positiveEvents ? this.filterByTimeCached(this.positiveEvents, (e) => e.timestamp) : []; const filteredIranEvents = mapLayers.iranAttacks ? this.filterByTimeCached(this.iranEvents, (e) => e.timestamp) : []; const filteredFirmsFireData = mapLayers.fires ? this.filterByTimeCached(this.firmsFireData, (d) => d.acq_date) : []; const filteredTrafficAnomalies = mapLayers.outages ? this.filterByTimeCached(this.trafficAnomalies, (a) => a.startDate) : []; const filteredKindnessPoints = mapLayers.kindness ? this.filterByTimeCached(this.kindnessPoints, (p) => p.timestamp) : []; const filteredImageryScenes = mapLayers.satellites ? this.filterByTimeCached(this.imageryScenes, (s) => s.datetime) : []; const filteredWeatherAlerts = mapLayers.weather ? this.filterByTimeCached(this.weatherAlerts, (alert) => alert.onset) : []; const filteredOutages = mapLayers.outages ? this.filterByTimeCached(this.outages, (outage) => outage.pubDate) : []; const filteredCableAdvisories = mapLayers.cables ? this.filterByTimeCached(this.cableAdvisories, (advisory) => advisory.reported) : []; const filteredFlightDelays = mapLayers.flights ? this.filterByTimeCached(this.flightDelays, (delay) => delay.updatedAt) : []; const filteredMilitaryFlights = mapLayers.military ? this.filterByTimeCached(this.militaryFlights, (flight) => flight.lastSeen) : []; const filteredMilitaryVessels = mapLayers.military ? this.filterByTimeCached(this.militaryVessels, (vessel) => vessel.lastAisUpdate) : []; const filteredMilitaryFlightClusters = mapLayers.military ? this.filterMilitaryFlightClustersByTimeCached(this.militaryFlightClusters) : []; const filteredMilitaryVesselClusters = mapLayers.military ? this.filterMilitaryVesselClustersByTimeCached(this.militaryVesselClusters) : []; // UCDP is a historical dataset (events aged months); time-range filter always zeroes it out const filteredUcdpEvents = mapLayers.ucdpEvents ? this.ucdpEvents : []; // Day/night overlay (rendered first as background) if (mapLayers.dayNight) { if (!this.dayNightIntervalId) this.startDayNightTimer(); layers.push(this.createDayNightLayer()); } else { if (this.dayNightIntervalId) this.stopDayNightTimer(); this.layerCache.delete('day-night-layer'); } if (this.selectedChinaCorridorOverlay) { layers.push(...this.createChinaCorridorSelectionLayers(this.selectedChinaCorridorOverlay)); } // Undersea cables layer if (mapLayers.cables) { layers.push(this.createCablesLayer()); } else { this.layerCache.delete('cables-layer'); } // Pipelines layer — Redis-backed evidence registry (seed-pipelines-{gas,oil}.mjs), // colored by derived publicBadge. Available on every variant that toggles // `pipelines: true`. createEnergyPipelinesLayer falls back to the legacy // static `PIPELINES` layer (createPipelinesLayer below) when the bootstrap // hasn't hydrated yet, so the static layer is a real fallback — not dead // code despite an earlier comment claiming it was retired in the gap #3B // rollout. Removing createPipelinesLayer would leave the map blank on // cold loads / variant switches before the first hydrate. if (mapLayers.pipelines) { layers.push(this.createEnergyPipelinesLayer()); } else { this.layerCache.delete('pipelines-layer'); } // Storage facilities layer. Registry is seeded weekly by // scripts/seed-storage-facilities.mjs; colors by derived publicBadge // identical to the panel's evidence deriver so first-paint map dots match // panel status exactly. Available on any variant with // `mapLayers.storageFacilities: true` (plan §R/#3 decision B). if (mapLayers.storageFacilities) { const storageLayer = this.createEnergyStorageLayer(); if (storageLayer) layers.push(storageLayer); } else { this.layerCache.delete('storage-facilities-layer'); } // Fuel shortage pins. One pin per active shortage placed at the country // centroid. Color by severity; click opens the FuelShortagePanel drawer // via event. Available on any variant with `mapLayers.fuelShortages: true` // (plan §R/#3 decision B). if (mapLayers.fuelShortages) { const shortageLayer = this.createEnergyShortagePinsLayer(); if (shortageLayer) layers.push(shortageLayer); } else { this.layerCache.delete('fuel-shortages-layer'); } // Live tanker positions inside chokepoint bounding boxes. AIS ship type // 80-89 (tanker class). Refreshed every 60s; one Map // fetch per layer-tick. deckGLOnly per src/config/map-layer-definitions.ts. // Powered by the relay's tankerReports field (added in PR 3 U7 alongside // the existing military-only candidateReports). Energy Atlas parity-push. if (mapLayers.liveTankers) { // Start (or keep) the refresh loop while the layer is on. The // ensure helper handles the "first time on" kick + the 60s // setInterval; idempotent so calling it on every layers update is // safe. Render immediately if we already have data; the interval // re-renders when fresh data arrives. this.ensureLiveTankersLoop(); if (this.liveTankers.length > 0) { layers.push(this.createLiveTankersLayer()); } } else { // Layer toggled off → tear down the timer so we stop hitting the // relay even when the map is still on screen. this.stopLiveTankersLoop(); this.layerCache.delete('live-tankers-layer'); } // Conflict zones layer — heavy GeoJson tessellation routed through the // two-phase commit so it lands off the interaction frame (#4558). if (mapLayers.conflicts) { layers.push(this.createConflictZonesLayer( this.resolveHeavyData('conflict', () => this.buildConflictZoneGeoJson(), deferHeavy, this.emptyHeavyData), )); } // Military bases layer — hidden at low zoom (E: progressive disclosure) + clusters if (mapLayers.bases && this.isLayerVisible('bases')) { layers.push(this.createBasesLayer()); layers.push(...this.createBasesClusterLayer()); } layers.push(this.createEmptyGhost('bases-layer')); // Nuclear facilities layer — hidden at low zoom if (mapLayers.nuclear && this.isLayerVisible('nuclear')) { layers.push(this.createNuclearLayer()); } layers.push(this.createEmptyGhost('nuclear-layer')); // 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 if (mapLayers.natural && filteredEarthquakes.length > 0) { layers.push(this.createEarthquakesLayer(filteredEarthquakes)); } layers.push(this.createEmptyGhost('earthquakes-layer')); // Natural events layers (non-TC scatter + TC tracks/cones/centers) if (mapLayers.natural && filteredNaturalEvents.length > 0) { layers.push(...this.createNaturalEventsLayers(filteredNaturalEvents)); } if (mapLayers.radiationWatch && filteredRadiationObservations.length > 0) { layers.push(this.createRadiationLayer(filteredRadiationObservations)); } layers.push(this.createEmptyGhost('radiation-watch-layer')); // Disease outbreaks layer if (mapLayers.diseaseOutbreaks && filteredDiseaseOutbreaks.length > 0) { layers.push(this.createDiseaseOutbreaksLayer(filteredDiseaseOutbreaks)); } layers.push(this.createEmptyGhost('disease-outbreaks-layer')); // Satellite fires layer (NASA FIRMS) if (mapLayers.fires && filteredFirmsFireData.length > 0) { layers.push(this.createFiresLayer(filteredFirmsFireData)); } // Iran events layer if (mapLayers.iranAttacks && filteredIranEvents.length > 0) { layers.push(this.createIranEventsLayer(filteredIranEvents)); layers.push(this.createGhostLayer('iran-events-layer', filteredIranEvents, d => [d.longitude, d.latitude], { radiusMinPixels: 12 })); } // Weather alerts layer if (mapLayers.weather && filteredWeatherAlerts.length > 0) { layers.push(this.createWeatherLayer(filteredWeatherAlerts)); } // Internet outages layer if (mapLayers.outages && filteredOutages.length > 0) { layers.push(this.createOutagesLayer(filteredOutages)); } layers.push(this.createEmptyGhost('outages-layer')); if (mapLayers.outages && filteredTrafficAnomalies.length > 0) { layers.push(this.createTrafficAnomaliesLayer(filteredTrafficAnomalies)); } layers.push(this.createEmptyGhost('traffic-anomalies-layer')); if (mapLayers.outages && this.ddosLocations.length > 0) { layers.push(this.createDdosLocationsLayer(this.ddosLocations)); } layers.push(this.createEmptyGhost('ddos-locations-layer')); // Cyber threat IOC layer if (mapLayers.cyberThreats && this.cyberThreats.length > 0) { layers.push(this.createCyberThreatsLayer()); } layers.push(this.createEmptyGhost('cyber-threats-layer')); // 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()); } // GPS/GNSS jamming layer if (mapLayers.gpsJamming && this.gpsJammingHexes.length > 0) { layers.push(this.createGpsJammingLayer()); } // 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()); } // Aviation layer (flight delays + NOTAM closures + aircraft positions) if (mapLayers.flights && filteredFlightDelays.length > 0) { layers.push(this.createFlightDelaysLayer(filteredFlightDelays)); const closures = filteredFlightDelays.filter(d => d.delayType === 'closure'); if (closures.length > 0) { layers.push(this.createNotamOverlayLayer(closures)); } } // Aircraft positions layer (live tracking, under flights toggle) if (mapLayers.flights && this.aircraftPositions.length > 0) { layers.push(this.createAircraftPositionsLayer()); } // 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 flight trails (rendered beneath dots) if (mapLayers.military && this.activeFlightTrails.size > 0 && filteredMilitaryFlights.length > 0) { layers.push(this.createMilitaryFlightTrailsLayer(filteredMilitaryFlights)); } // 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()); } // Commodity variant layers — mine sites, processing plants, export ports if (mapLayers.miningSites) { layers.push(this.createMiningSitesLayer()); } if (mapLayers.processingPlants) { layers.push(this.createProcessingPlantsLayer()); } if (mapLayers.commodityPorts) { layers.push(this.createCommodityPortsLayer()); } // APT Groups layer — loaded lazily when cyberThreats layer is enabled if (mapLayers.cyberThreats && SITE_VARIANT !== 'tech' && SITE_VARIANT !== 'happy' && this.aptGroups.length > 0 && !this.aptGroupsLayerFailed) { 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()); } // Trade routes layer if (mapLayers.tradeRoutes) { layers.push(this.createTradeRoutesLayer()); layers.push(this.createTradeRouteTripsLayer()); layers.push(this.createTradeChokepointsLayer()); const hlMarkers = this.createHighlightedChokepointMarkers(); if (hlMarkers) layers.push(hlMarkers); const bypassArcs = this.createBypassArcsLayer(); if (bypassArcs) layers.push(bypassArcs); this.startTradeAnimation(); } else { this.stopTradeAnimation(); this.layerCache.delete('trade-routes-layer'); this.layerCache.delete('trade-route-trips-layer'); this.layerCache.delete('trade-chokepoints-layer'); this.layerCache.delete('highlighted-chokepoint-markers'); this.layerCache.delete('bypass-arcs-layer'); } // 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()); } // Positive events layer (happy variant) if (mapLayers.positiveEvents && filteredPositiveEvents.length > 0) { layers.push(...this.createPositiveEventsLayers(filteredPositiveEvents)); } // Kindness layer (happy variant -- green baseline pulses + real kindness events) if (mapLayers.kindness && filteredKindnessPoints.length > 0) { layers.push(...this.createKindnessLayers(filteredKindnessPoints)); } // Phase 8: Happiness choropleth (rendered below point markers) if (mapLayers.happiness) { const choropleth = this.createHappinessChoroplethLayer(); if (choropleth) layers.push(choropleth); } // CII choropleth (country instability heat-map) if (mapLayers.ciiChoropleth) { const ciiLayer = this.createCIIChoroplethLayer(); if (ciiLayer) layers.push(ciiLayer); } if (mapLayers.resilienceScore) { const resilienceLayer = this.createResilienceChoroplethLayer(); if (resilienceLayer) layers.push(resilienceLayer); } // Sanctions choropleth if (mapLayers.sanctions) { const sanctionsLayer = this.createSanctionsChoroplethLayer(); if (sanctionsLayer) layers.push(sanctionsLayer); } // Scenario heat layer (affected countries tint) const scenarioHeat = this.scenarioState ? this.createScenarioHeatLayer() : null; if (scenarioHeat) layers.push(scenarioHeat); // Phase 8: Species recovery zones if (mapLayers.speciesRecovery && this.speciesRecoveryZones.length > 0) { layers.push(this.createSpeciesRecoveryLayer()); } // Phase 8: Renewable energy installations if (mapLayers.renewableInstallations && this.renewableInstallations.length > 0) { layers.push(this.createRenewableInstallationsLayer()); } if (mapLayers.satellites && filteredImageryScenes.length > 0 && !this.satelliteImageryLayerFailed) { layers.push(this.createImageryFootprintLayer(filteredImageryScenes)); } // Webcam layer (server-side clustered markers) if (mapLayers.webcams && this.webcamData.length > 0) { layers.push(new ScatterplotLayer({ id: 'webcam-layer', data: this.webcamData, getPosition: (d) => [d.lng, d.lat], getRadius: (d) => ('count' in d ? Math.min(8 + d.count * 0.5, 24) : 6), getFillColor: (d) => ('count' in d ? [0, 212, 255, 180] : [255, 215, 0, 200]) as [number, number, number, number], radiusUnits: 'pixels', pickable: true, })); } // 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); const healthSignature = Object.keys(this.healthByCableId).sort().join(','); if (cached && highlightSignature === this.lastCableHighlightSignature && healthSignature === this.lastCableHealthSignature) return cached; const health = this.healthByCableId; const layer = new PathLayer({ id: cacheKey, data: UNDERSEA_CABLES, getPath: (d) => d.points, getColor: (d) => { if (highlightedCables.has(d.id)) return COLORS.cableHighlight; const h = health[d.id]; if (h?.status === 'fault') return COLORS.cableFault; if (h?.status === 'degraded') return COLORS.cableDegraded; return COLORS.cable; }, getWidth: (d) => { if (highlightedCables.has(d.id)) return 3; const h = health[d.id]; if (h?.status === 'fault') return 2.5; if (h?.status === 'degraded') return 2; return 1; }, widthMinPixels: 1, widthMaxPixels: 5, pickable: true, updateTriggers: { highlighted: highlightSignature, health: healthSignature }, }); this.lastCableHighlightSignature = highlightSignature; this.lastCableHealthSignature = healthSignature; 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; } // Energy-variant override for the pipelines map layer. Instead of the // static PIPELINES config (colored by oil/gas type), this reads the // evidence-backed pipeline registries seeded by scripts/seed-pipelines- // {gas,oil}.mjs and colors each path by its derived publicBadge — // flowing/reduced/offline/disputed. Click dispatches an // `open-pipeline-detail` window event that PipelineStatusPanel listens // for to open its drawer. Falls back to the static layer if bootstrap // hasn't hydrated yet (e.g. variant switch before the fetch completes). private createEnergyPipelinesLayer(): PathLayer { const cacheKey = 'pipelines-layer'; const highlightedPipelines = this.highlightedAssets.pipeline; const highlightSignature = this.getSetSignature(highlightedPipelines); interface RawEntry { id?: string; name?: string; commodityType?: string; startPoint?: { lat?: number; lon?: number }; endPoint?: { lat?: number; lon?: number }; waypoints?: Array<{ lat?: number; lon?: number }>; operator?: string; evidence?: PipelineEvidenceInput; } interface EnergyPipeline { id: string; name: string; operator: string; commodityType: string; points: Array<[number, number]>; badge: PipelinePublicBadge; } // Read through the shared store instead of getHydratedData directly — // getHydratedData is single-use (deletes on first read), and this same // data is also consumed by PipelineStatusPanel. The store memoizes so // both consumers see identical data regardless of mount order. const { gas, oil } = getCachedPipelineRegistries() as { gas: { pipelines?: Record } | undefined; oil: { pipelines?: Record } | undefined; }; const rawEntries: RawEntry[] = [ ...Object.values(gas?.pipelines ?? {}), ...Object.values(oil?.pipelines ?? {}), ]; // Bootstrap not hydrated yet → fall back to the static layer so the // map always has some representation of the pipelines toggle. if (rawEntries.length === 0) return this.createPipelinesLayer(); const data: EnergyPipeline[] = rawEntries .map(raw => { const id = typeof raw.id === 'string' ? raw.id : ''; if (!id) return null; const start = raw.startPoint; const end = raw.endPoint; if (!start || !end || typeof start.lat !== 'number' || typeof start.lon !== 'number' || typeof end.lat !== 'number' || typeof end.lon !== 'number') return null; const points: Array<[number, number]> = [[start.lon, start.lat]]; if (Array.isArray(raw.waypoints)) { for (const wp of raw.waypoints) { if (wp && typeof wp.lat === 'number' && typeof wp.lon === 'number') { points.push([wp.lon, wp.lat]); } } } points.push([end.lon, end.lat]); return { id, name: raw.name || id, operator: raw.operator || '', commodityType: raw.commodityType || 'gas', points, badge: derivePipelinePublicBadge(raw.evidence), } as EnergyPipeline; }) .filter((p): p is EnergyPipeline => p != null); const HIGHLIGHT_COLOR: [number, number, number, number] = [255, 100, 100, 240]; const badgeColor = (b: PipelinePublicBadge): [number, number, number, number] => { switch (b) { case 'flowing': return [46, 204, 113, 200]; // green case 'reduced': return [243, 156, 18, 220]; // amber case 'offline': return [231, 76, 60, 230]; // red case 'disputed': return [155, 89, 182, 220]; // purple } }; const layer = new PathLayer({ id: cacheKey, data, getPath: d => d.points, getColor: d => highlightedPipelines.has(d.id) ? HIGHLIGHT_COLOR : badgeColor(d.badge), getWidth: d => { if (highlightedPipelines.has(d.id)) return 4; return (d.badge === 'offline' || d.badge === 'disputed') ? 3 : 2; }, widthMinPixels: 1.5, widthMaxPixels: 6, pickable: true, // updateTriggers make DeckGL recompute per-path getColor/getWidth // when the highlight set changes; without this, flashAssets() / // highlightAssets() would have no visible effect on the energy layer. updateTriggers: { getColor: highlightSignature, getWidth: highlightSignature, }, onClick: info => { const obj = info?.object as EnergyPipeline | undefined; if (!obj?.id) return false; // Emit an event; PipelineStatusPanel listens and opens its drawer. // Cross-component coupling stays loose — no direct reference to the // panel class, and if the panel isn't mounted the event is a no-op. try { window.dispatchEvent(new CustomEvent('energy:open-pipeline-detail', { detail: { pipelineId: obj.id }, })); } catch { // Non-browser / tauri edge cases — silent no-op. } return true; }, }); // Intentionally NOT caching this layer: the underlying registries can // update via setCachedPipelineRegistries() when the panel's RPC lands, // and cached layers keyed only on highlightSignature would serve stale // data. With ~25 critical-asset pipelines, rebuild cost per render is // trivial (far cheaper than a stale-data UI bug). return layer; } /** * Storage facilities scatterplot layer (energy variant only). Reads * through the shared store so this layer and StorageFacilityMapPanel * both see the same bootstrap-hot registry without racing on * getHydratedData's single-use drain. * * Dot radius = log(capacity) so Ras Laffan (77 Mtpa) visually dominates * Chiren (6.5 TWh) without blowing out small sites to invisibility. * Color = derived publicBadge, same deriver as the server handler. */ private createEnergyStorageLayer(): ScatterplotLayer | null { const cacheKey = 'storage-facilities-layer'; interface RawEntry { id?: string; name?: string; operator?: string; facilityType?: string; country?: string; location?: { lat?: number; lon?: number }; capacityTwh?: number; capacityMb?: number; capacityMtpa?: number; evidence?: StorageEvidenceInput; } interface EnergyStorageDot { id: string; name: string; operator: string; facilityType: string; country: string; position: [number, number]; capacityDisplay: string; radius: number; badge: StoragePublicBadge; } const { registry } = getCachedStorageFacilityRegistry() as { registry: { facilities?: Record } | undefined; }; const rawEntries: RawEntry[] = Object.values(registry?.facilities ?? {}); if (rawEntries.length === 0) return null; const data: EnergyStorageDot[] = rawEntries .map(raw => { const id = typeof raw.id === 'string' ? raw.id : ''; if (!id) return null; const loc = raw.location; if (!loc || typeof loc.lat !== 'number' || typeof loc.lon !== 'number') return null; // Capacity → radius. Each facility type has its own unit, so // normalize to a common "relative size" before log — Mtpa is // already the largest numerically; TWh and Mb are comparable. let cap = 0; let capDisplay = '—'; if (raw.facilityType === 'ugs' && typeof raw.capacityTwh === 'number' && raw.capacityTwh > 0) { cap = raw.capacityTwh; capDisplay = `${raw.capacityTwh.toFixed(1)} TWh`; } else if ((raw.facilityType === 'spr' || raw.facilityType === 'crude_tank_farm') && typeof raw.capacityMb === 'number' && raw.capacityMb > 0) { cap = raw.capacityMb; capDisplay = `${raw.capacityMb.toLocaleString()} Mb`; } else if ((raw.facilityType === 'lng_export' || raw.facilityType === 'lng_import') && typeof raw.capacityMtpa === 'number' && raw.capacityMtpa > 0) { cap = raw.capacityMtpa; capDisplay = `${raw.capacityMtpa.toFixed(1)} Mtpa`; } // log-scale radius so small sites stay visible; floor + ceiling to // keep hit targets reasonable at all zoom levels. const radius = Math.max(6000, Math.min(26000, 5000 + Math.log(Math.max(cap, 1)) * 5500)); return { id, name: raw.name || id, operator: raw.operator || '', facilityType: raw.facilityType || 'unknown', country: raw.country || '', position: [loc.lon, loc.lat] as [number, number], capacityDisplay: capDisplay, radius, badge: deriveStoragePublicBadge(raw.evidence), } as EnergyStorageDot; }) .filter((d): d is EnergyStorageDot => d != null); const badgeColor = (b: StoragePublicBadge): [number, number, number, number] => { switch (b) { case 'operational': return [46, 204, 113, 220]; // green case 'reduced': return [243, 156, 18, 230]; // amber case 'offline': return [231, 76, 60, 240]; // red case 'disputed': return [155, 89, 182, 230]; // purple } }; return new ScatterplotLayer({ id: cacheKey, data, getPosition: d => d.position, getFillColor: d => badgeColor(d.badge), getRadius: d => d.radius, stroked: true, getLineColor: [255, 255, 255, 200], lineWidthMinPixels: 1, radiusMinPixels: 5, radiusMaxPixels: 28, pickable: true, onClick: info => { const obj = info?.object as EnergyStorageDot | undefined; if (!obj?.id) return false; // Dispatch to StorageFacilityMapPanel — same loose-coupling // pattern as the pipelines layer. try { window.dispatchEvent(new CustomEvent('energy:open-storage-facility-detail', { detail: { facilityId: obj.id }, })); } catch { // Silent no-op on non-browser runtimes. } return true; }, }); } /** * Fuel shortage pins (energy variant only). One dot per active shortage * placed at the country centroid. Color by severity (confirmed = red, * watch = amber). Click dispatches 'energy:open-fuel-shortage-detail' * which FuelShortagePanel listens for. * * Multiple shortages in the same country stack with a small angular * offset so they don't render as one overlapping dot. */ private createEnergyShortagePinsLayer(): ScatterplotLayer | null { const cacheKey = 'fuel-shortages-layer'; interface RawEntry { id?: string; country?: string; product?: string; severity?: string; shortDescription?: string; resolvedAt?: string | null; } interface ShortagePin { id: string; country: string; product: string; severity: string; description: string; position: [number, number]; } const { registry } = getCachedFuelShortageRegistry() as { registry: { shortages?: Record } | undefined; }; // Exclude resolved shortages — a pin on the map is a claim of an // ACTIVE crisis, and rendering resolved entries as active inflates // severity counts and shows stale crisis data. Classifier writes // resolvedAt as ISO string on resolution; raw seed uses null. const rawEntries: RawEntry[] = Object.values(registry?.shortages ?? {}) .filter(s => !s.resolvedAt); if (rawEntries.length === 0) return null; // Stack multiple shortages per country by offsetting longitudes. const perCountryCount = new Map(); const data: ShortagePin[] = rawEntries .map(raw => { const id = typeof raw.id === 'string' ? raw.id : ''; if (!id) return null; const country = raw.country; if (typeof country !== 'string' || country.length !== 2) return null; const centroid = getCountryCentroid(country); if (!centroid) return null; const idx = perCountryCount.get(country) ?? 0; perCountryCount.set(country, idx + 1); // ~0.8° offset per additional pin in the same country. const offsetLon = idx === 0 ? 0 : (idx * 0.8 * (idx % 2 === 0 ? 1 : -1)); return { id, country, product: raw.product || '', severity: raw.severity || 'watch', description: raw.shortDescription || '', position: [centroid.lon + offsetLon, centroid.lat] as [number, number], }; }) .filter((d): d is ShortagePin => d != null); const severityColor = (sev: string): [number, number, number, number] => { switch (sev) { case 'confirmed': return [231, 76, 60, 240]; // red case 'watch': return [243, 156, 18, 230]; // amber default: return [127, 140, 141, 200]; // grey } }; return new ScatterplotLayer({ id: cacheKey, data, getPosition: d => d.position, getFillColor: d => severityColor(d.severity), // Confirmed pins slightly larger than watch to pre-attentively indicate weight. getRadius: d => d.severity === 'confirmed' ? 55000 : 38000, stroked: true, getLineColor: [255, 255, 255, 230], lineWidthMinPixels: 1.5, radiusMinPixels: 7, radiusMaxPixels: 24, pickable: true, onClick: info => { const obj = info?.object as ShortagePin | undefined; if (!obj?.id) return false; try { window.dispatchEvent(new CustomEvent('energy:open-fuel-shortage-detail', { detail: { shortageId: obj.id }, })); } catch { // Silent no-op on non-browser runtimes. } return true; }, }); } /** * All conflict-zone features paired with their bounds, built once and cached. * Cheap (feature construction + a bounds scan — no tessellation); invalidated * with the culled cache on countries-geometry load. The heavy per-zone cost * (country-multipolygon tessellation) is only paid for the culled subset in * `buildConflictZoneGeoJson`. */ private buildAllConflictZoneFeatures(): BoundedFeature[] { if (this.conflictZoneBounded) return this.conflictZoneBounded; const bounded: BoundedFeature[] = []; // A feature with no computable bounds (empty/degenerate geometry) is // intentionally excluded — deck.gl skips such geometry anyway, and it can't // be viewport-tested. CONFLICT_ZONES + country features are always valid, so // this is a safety guard, not an expected drop. const push = (feature: GeoJSON.Feature): void => { const bounds = geometryBounds(feature.geometry); if (bounds) bounded.push({ bounds, feature }); }; for (const zone of CONFLICT_ZONES) { const isoCodes = CONFLICT_COUNTRY_ISO[zone.id]; let usedCountryGeometry = false; if (isoCodes?.length && this.countriesGeoJsonData) { for (const feature of this.countriesGeoJsonData.features) { const code = feature.properties?.['ISO3166-1-Alpha-2']; if (typeof code !== 'string' || !isoCodes.includes(code)) continue; push({ type: 'Feature', properties: { id: zone.id, name: zone.name, intensity: zone.intensity }, geometry: feature.geometry, }); usedCountryGeometry = true; } } if (usedCountryGeometry) continue; push({ type: 'Feature', properties: { id: zone.id, name: zone.name, intensity: zone.intensity }, geometry: { type: 'Polygon', coordinates: [ensureClosedRing(zone.coords)] }, }); } this.conflictZoneBounded = bounded; return bounded; } /** * #4561: bound the conflict-zone tessellation to the current viewport. Only * zones whose bounds intersect the (padded) viewport are handed to deck.gl, so * the synchronous tessellation cost scales with what's on screen — not the * global zone set. Cached by a quantized viewport key so a small pan reuses the * previous cull; `moveend` already re-runs `buildLayers`, refreshing the key. * At world/low zoom every zone is included (culling can't reduce the count * there — that case is bounded by low-zoom simplification, U2). */ private buildConflictZoneGeoJson(): GeoJSON.FeatureCollection { const bounded = this.buildAllConflictZoneFeatures(); const mapBounds = this.maplibreMap?.getBounds(); if (!mapBounds) { const key = 'preinit'; if (key === this.conflictZoneContentKey && this.conflictZoneGeoJson) { return this.conflictZoneGeoJson; } // Pre-map-init: no viewport to cull against — render all (prior behavior). this.conflictZoneGeoJson = { type: 'FeatureCollection', features: bounded.map((b) => b.feature) }; this.conflictZoneContentKey = key; return this.conflictZoneGeoJson; } // getBounds() lng stays in the zone-data lng range only because both map // inits set renderWorldCopies: false — off-copy zones genuinely aren't drawn, // so culling them is correct. Flipping that flag would need the cull to // handle wrapped longitudes (#4561 review P3-a). const viewport: BBox = [ mapBounds.getWest(), mapBounds.getSouth(), mapBounds.getEast(), mapBounds.getNorth(), ]; const zoom = this.maplibreMap?.getZoom() ?? 2; const tolerance = zoomToSimplifyTolerance(zoom); // The cull (bbox tests over the static zone set) is cheap and runs every // build; the content key = culled index set + tolerance captures exactly what // the FeatureCollection contains, so an unchanged visible set reuses the // cached FC ref and the deck.gl re-tessellation short-circuits (review P2). const indices = culledIndices(bounded, viewport); const contentKey = `${tolerance.toFixed(4)}:${indices.join(',')}`; if (contentKey === this.conflictZoneContentKey && this.conflictZoneGeoJson) { return this.conflictZoneGeoJson; } // U2: at world/low zoom the cull can't reduce the count, so RDP-simplify the // (invisible-at-this-zoom) sub-pixel vertices to bound tessellation. New // geometry objects — never mutate the shared country geometry. const features: GeoJSON.Feature[] = indices.map((i) => { const feature = bounded[i]?.feature; if (!feature) return null; return tolerance > 0 ? { ...feature, geometry: simplifyGeometry(feature.geometry, tolerance) } : feature; }).filter((f): f is GeoJSON.Feature => f !== null); this.conflictZoneGeoJson = { type: 'FeatureCollection', features }; this.conflictZoneContentKey = contentKey; return this.conflictZoneGeoJson; } /** * Viewport-culled countries geojson for the choropleth layers (#4601). Mirrors the * conflict-zone cull (#4561): build the bounded set once, cull to the current * viewport, and content-key cache so an unchanged visible set reuses the FC ref * (Object.is short-circuits the deck.gl re-tessellation). Re-culled on pan/zoom via * updateLayers(). Cull-only — no RDP simplify, so country borders stay crisp; at * world/low zoom every country is visible so the full ref is returned unchanged. */ private getCulledCountriesGeoJson(): GeoJSON.FeatureCollection | null { const data = this.countriesGeoJsonData; if (!data) return null; if (!this.countriesBounded) { const bounded: BoundedFeature[] = []; for (const feature of data.features) { const bounds = geometryBounds(feature.geometry); if (bounds) bounded.push({ bounds, feature }); } this.countriesBounded = bounded; } const mapBounds = this.maplibreMap?.getBounds(); if (!mapBounds) return data; // pre-map-init: no viewport — render all (prior behavior) const viewport: BBox = [ mapBounds.getWest(), mapBounds.getSouth(), mapBounds.getEast(), mapBounds.getNorth(), ]; const indices = culledIndices(this.countriesBounded, viewport); // All countries visible (world/low zoom) — return the full ref, no new FC alloc. if (indices.length === this.countriesBounded.length) return data; const contentKey = indices.join(','); if (contentKey === this.culledCountriesContentKey && this.culledCountriesGeoJson) { return this.culledCountriesGeoJson; } const features = indices.map((i) => this.countriesBounded![i]!.feature); this.culledCountriesGeoJson = { type: 'FeatureCollection', features }; this.culledCountriesContentKey = contentKey; return this.culledCountriesGeoJson; } /** * Two-phase heavy-layer data (#4558). On the immediate (deferHeavy) frame, * stage the freshly-computed data and render the previously-committed value * (or `empty` on first build) so the heavy deck.gl tessellation is deferred; * the gate's deferred pass (deferHeavy=false) renders the committed real data. * Unchanged data short-circuits in the gate (no extra frame). */ private resolveHeavyData(key: string, compute: () => T, deferHeavy: boolean, empty: T): T { if (deferHeavy) { const real = compute(); this.heavyGate.stage(key, real); return (this.heavyGate.present(key) as T | undefined) ?? empty; } return (this.heavyGate.present(key) as T | undefined) ?? compute(); } private createConflictZonesLayer(data: GeoJSON.FeatureCollection): GeoJsonLayer { const cacheKey = this.countriesGeoJsonData ? 'conflict-zones-layer-country-geometry' : 'conflict-zones-layer'; const layer = new GeoJsonLayer({ id: cacheKey, data, 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 getBasesData(): MilitaryBaseEnriched[] { if (this.serverBasesLoaded) return this.serverBases; const bases = getCachedMilitaryBases() as MilitaryBaseEnriched[]; if (bases.length === 0) this.requestBaseConfigRender(); return bases; } private requestBaseConfigRender(): void { if (this.baseConfigLoadPending) return; this.baseConfigLoadPending = true; void preloadMilitaryBases() .then(() => { this.baseConfigLoadPending = false; if (!this.destroyed) this.render(); }) .catch((error) => { this.baseConfigLoadPending = false; console.warn('[DeckGLMap] Military base config unavailable:', error); }); } private createBasesLayer(): IconLayer { const highlightedBases = this.highlightedAssets.base; const zoom = this.maplibreMap?.getZoom() || 3; const alphaScale = Math.min(1, (zoom - 2.5) / 2.5); const a = Math.round(160 * Math.max(0.3, alphaScale)); const data = this.getBasesData(); return new IconLayer({ id: 'bases-layer', data, getPosition: (d) => [d.lon, d.lat], getIcon: () => 'triangleUp', iconAtlas: MARKER_ICONS.triangleUp, iconMapping: BASES_ICON_MAPPING, 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 getMilitaryBaseColor(d.type, a); }, sizeScale: 1, sizeMinPixels: 6, sizeMaxPixels: 16, pickable: true, }); } private createBasesClusterLayer(): Layer[] { if (this.serverBaseClusters.length === 0) return []; const zoom = this.maplibreMap?.getZoom() || 3; const alphaScale = Math.min(1, (zoom - 2.5) / 2.5); const a = Math.round(180 * Math.max(0.3, alphaScale)); const scatterLayer = new ScatterplotLayer({ id: 'bases-cluster-layer', data: this.serverBaseClusters, getPosition: (d) => [d.longitude, d.latitude], getRadius: (d) => Math.max(8000, Math.log2(d.count) * 6000), getFillColor: (d) => getMilitaryBaseColor(d.dominantType, a), radiusMinPixels: 10, radiusMaxPixels: 40, pickable: true, }); const textLayer = new TextLayer({ id: 'bases-cluster-text', data: this.serverBaseClusters, getPosition: (d) => [d.longitude, d.latitude], getText: (d) => String(d.count), getSize: 12, getColor: [255, 255, 255, 220], fontWeight: 'bold', getTextAnchor: 'middle', getAlignmentBaseline: 'center', }); return [scatterLayer, textLayer]; } 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: NUCLEAR_ICON_MAPPING, 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 === 'severe') return 15000; if (d.severity === 'major') return 12000; if (d.severity === 'moderate') return 10000; // 'unknown' = no telemetry (#3707). Keep the marker visible but // small so it doesn't compete with real alerts. if (d.severity === 'unknown') return 6000; return 8000; }, getFillColor: (d) => { if (d.severity === 'severe') return [255, 50, 50, 200] as [number, number, number, number]; if (d.severity === 'major') return [255, 150, 0, 200] as [number, number, number, number]; if (d.severity === 'moderate') return [255, 200, 100, 180] as [number, number, number, number]; // 'unknown' renders desaturated grey — distinct from the lighter grey // used for 'normal' so users can tell "no data" from "healthy". if (d.severity === 'unknown') return [120, 120, 130, 120] as [number, number, number, number]; return [180, 180, 180, 150] as [number, number, number, number]; }, radiusMinPixels: 4, radiusMaxPixels: 15, pickable: true, }); } private createNotamOverlayLayer(closures: AirportDelayAlert[]): ScatterplotLayer { return new ScatterplotLayer({ id: 'notam-overlay-layer', data: closures, getPosition: (d) => [d.lon, d.lat], getRadius: 55000, getFillColor: [255, 40, 40, 100] as [number, number, number, number], getLineColor: [255, 40, 40, 200] as [number, number, number, number], stroked: true, lineWidthMinPixels: 2, radiusMinPixels: 8, radiusMaxPixels: 40, pickable: true, }); } private createAircraftPositionsLayer(): IconLayer { return new IconLayer({ id: 'aircraft-positions-layer', data: this.aircraftPositions, getPosition: (d) => [d.lon, d.lat], getIcon: () => 'plane', iconAtlas: MARKER_ICONS.plane, iconMapping: AIRCRAFT_ICON_MAPPING, getSize: (d) => d.onGround ? 14 : 18, getColor: (d) => { if (d.onGround) return [120, 120, 120, 160] as [number, number, number, number]; const [r, g, b] = altitudeToColor(d.altitudeFt); return [r, g, b, 220] as [number, number, number, number]; }, getAngle: (d) => -d.trackDeg, sizeMinPixels: 8, sizeMaxPixels: 28, sizeScale: 1, pickable: true, billboard: false, }); } 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, }); } /** Empty sentinel layer — keeps a stable layer ID for deck.gl interleaved mode without rendering anything. */ private createEmptyGhost(id: string): ScatterplotLayer { return new ScatterplotLayer({ id: `${id}-ghost`, data: [], getPosition: () => [0, 0], visible: false }); } 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: DATACENTER_ICON_MAPPING, 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.location?.longitude ?? 0, d.location?.latitude ?? 0], getRadius: (d) => 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 createNaturalEventsLayers(events: NaturalEvent[]): Layer[] { const nonTC = events.filter(e => !e.stormName && !e.windKt); const cyclones = events.filter(e => e.stormName || e.windKt); const layers: Layer[] = []; if (nonTC.length > 0) { layers.push(new ScatterplotLayer({ id: 'natural-events-layer', data: nonTC, 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, })); } if (cyclones.length === 0) return layers; // Cone polygons (render first, underneath tracks) const coneData: { polygon: number[][]; stormName: string; _event: NaturalEvent }[] = []; for (const e of cyclones) { if (!e.conePolygon?.length) continue; for (const ring of e.conePolygon) { coneData.push({ polygon: ring, stormName: e.stormName || e.title, _event: e }); } } if (coneData.length > 0) { layers.push(new PolygonLayer({ id: 'storm-cone-layer', data: coneData, getPolygon: (d: { polygon: number[][] }) => d.polygon, getFillColor: [255, 255, 255, 30], getLineColor: [255, 255, 255, 80], lineWidthMinPixels: 1, pickable: true, })); } // Past track segments (per-segment wind coloring) const pastSegments: { path: [number, number][]; windKt: number; stormName: string; _event: NaturalEvent }[] = []; for (const e of cyclones) { if (!e.pastTrack?.length) continue; for (let i = 0; i < e.pastTrack.length - 1; i++) { const a = e.pastTrack[i]!; const b = e.pastTrack[i + 1]!; pastSegments.push({ path: [[a.lon, a.lat] as [number, number], [b.lon, b.lat] as [number, number]], windKt: b.windKt ?? a.windKt ?? 0, stormName: e.stormName || e.title, _event: e, }); } } if (pastSegments.length > 0) { layers.push(new PathLayer({ id: 'storm-past-track-layer', data: pastSegments, getPath: (d: { path: [number, number][] }) => d.path, getColor: (d: { windKt: number }) => getWindColor(d.windKt), getWidth: 3, widthUnits: 'pixels' as const, pickable: true, })); } // Forecast track const forecastPaths: { path: [number, number][]; stormName: string; _event: NaturalEvent }[] = []; for (const e of cyclones) { if (!e.forecastTrack?.length) continue; forecastPaths.push({ path: [[e.lon, e.lat] as [number, number], ...e.forecastTrack.map(p => [p.lon, p.lat] as [number, number])], stormName: e.stormName || e.title, _event: e, }); } if (forecastPaths.length > 0) { layers.push(new PathLayer({ id: 'storm-forecast-track-layer', data: forecastPaths, getPath: (d: { path: [number, number][] }) => d.path, getColor: [255, 100, 100, 200], getWidth: 2, widthUnits: 'pixels' as const, pickable: true, })); } // Storm center markers (on top) layers.push(new ScatterplotLayer({ id: 'storm-centers-layer', data: cyclones, getPosition: (d: NaturalEvent) => [d.lon, d.lat], getRadius: 15000, getFillColor: (d: NaturalEvent) => getWindColor(d.windKt ?? 0), getLineColor: [255, 255, 255, 200], lineWidthMinPixels: 2, stroked: true, radiusMinPixels: 6, radiusMaxPixels: 20, pickable: true, })); return layers; } private createFiresLayer(items: typeof this.firmsFireData): ScatterplotLayer { return new ScatterplotLayer({ id: 'fires-layer', data: items, 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 createIranEventsLayer(items: IranEvent[]): ScatterplotLayer { return new ScatterplotLayer({ id: 'iran-events-layer', data: items, getPosition: (d: IranEvent) => [d.longitude, d.latitude], getRadius: (d: IranEvent) => getIranEventRadius(d.severity), getFillColor: (d: IranEvent) => getIranEventColor(d), radiusMinPixels: 4, radiusMaxPixels: 16, 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 createTrafficAnomaliesLayer(anomalies: ProtoTrafficAnomaly[]): ScatterplotLayer { return new ScatterplotLayer({ id: 'traffic-anomalies-layer', data: anomalies.filter(a => a.latitude !== 0 || a.longitude !== 0), getPosition: (d) => [d.longitude, d.latitude], getRadius: 30000, getFillColor: COLORS.trafficAnomaly, radiusMinPixels: 5, radiusMaxPixels: 14, pickable: true, }); } private createDdosLocationsLayer(hits: DdosLocationHit[]): ScatterplotLayer { return new ScatterplotLayer({ id: 'ddos-locations-layer', data: hits.filter(h => h.latitude !== 0 || h.longitude !== 0), getPosition: (d) => [d.longitude, d.latitude], getRadius: (d) => 20000 + (d.percentage || 0) * 800, getFillColor: COLORS.ddosHit, radiusMinPixels: 5, radiusMaxPixels: 16, 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 createRadiationLayer(items: RadiationObservation[]): ScatterplotLayer { return new ScatterplotLayer({ id: 'radiation-watch-layer', data: items, getPosition: (d) => [d.lon, d.lat], getRadius: (d) => { const base = d.severity === 'spike' ? 26000 : 18000; if (d.corroborated) return base * 1.15; if (d.confidence === 'low') return base * 0.85; return base; }, getFillColor: (d) => ( d.severity === 'spike' ? [255, 48, 48, 220] : d.confidence === 'low' ? [255, 174, 0, 150] : [255, 174, 0, 200] ) as [number, number, number, number], getLineColor: [255, 255, 255, 200], stroked: true, lineWidthMinPixels: 2, radiusMinPixels: 6, radiusMaxPixels: 20, pickable: true, }); } private createDiseaseOutbreaksLayer(items: DiseaseOutbreakItem[]): ScatterplotLayer<{ lon: number; lat: number; item: DiseaseOutbreakItem }> { type Point = { lon: number; lat: number; item: DiseaseOutbreakItem }; const points: Point[] = []; for (const item of items) { if (Number.isFinite(item.lat) && item.lat !== 0 && Number.isFinite(item.lng) && item.lng !== 0) { points.push({ lon: item.lng, lat: item.lat, item }); } else { const centroid = getCountryCentroid(item.countryCode ?? ''); if (centroid) points.push({ lon: centroid.lon, lat: centroid.lat, item }); } } return new ScatterplotLayer({ id: 'disease-outbreaks-layer', data: points, getPosition: (d) => [d.lon, d.lat], getRadius: (d) => d.item.alertLevel === 'alert' ? 180000 : d.item.alertLevel === 'warning' ? 130000 : 90000, getFillColor: (d) => ( d.item.alertLevel === 'alert' ? [231, 76, 60, 200] : d.item.alertLevel === 'warning' ? [230, 126, 34, 190] : [241, 196, 15, 170] ) as [number, number, number, number], getLineColor: [255, 255, 255, 120], stroked: true, lineWidthMinPixels: 1, radiusMinPixels: 5, radiusMaxPixels: 22, pickable: true, }); } 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 createLiveTankersLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'live-tankers-layer', data: this.liveTankers, getPosition: (d) => [d.lon, d.lat], // Radius scales loosely with deadweight class: VLCC > Aframax > Handysize. // AIS ship type 80-89 covers all tanker subtypes; we have no DWT field // in the AIS message itself, so this is a constant fallback. Future // enhancement: enrich via a vessel-registry lookup. getRadius: 2500, getFillColor: (d) => { // Anchored (speed < 0.5 kn) — orange, signals waiting / loading / // potential congestion. Underway (speed >= 0.5 kn) — cyan, normal // transit. Unknown / missing speed — gray. if (!Number.isFinite(d.speed)) return [127, 140, 141, 200] as [number, number, number, number]; if (d.speed < 0.5) return [255, 183, 3, 220] as [number, number, number, number]; // amber return [0, 209, 255, 220] as [number, number, number, number]; // cyan }, radiusMinPixels: 3, radiusMaxPixels: 8, pickable: true, }); } /** * Idempotent: ensures the 60s tanker-refresh loop is running. Called * each time the layer is observed enabled in the layers update. First * call kicks an immediate load; subsequent calls no-op. Pairs with * stopLiveTankersLoop() in destroy() and on layer-disable. */ private ensureLiveTankersLoop(): void { if (this.liveTankersTimer !== null) return; // already running void this.loadLiveTankers(); this.liveTankersTimer = setInterval(() => { void this.loadLiveTankers(); }, 60_000); } /** * Stop the refresh loop and abort any in-flight fetch. Called when the * layer is toggled off (and from destroy()) to keep the relay traffic * scoped to active viewers. */ private stopLiveTankersLoop(): void { if (this.liveTankersTimer !== null) { clearInterval(this.liveTankersTimer); this.liveTankersTimer = null; } if (this.liveTankersAbort) { this.liveTankersAbort.abort(); this.liveTankersAbort = null; } } /** * Tanker loader — called externally (or on a 60s tick) to refresh * `this.liveTankers`. Imports lazily so the service module isn't pulled * into the bundle for variants where the layer is disabled. */ public async loadLiveTankers(): Promise { // Cancel any in-flight tick before starting another. Per skill // closure-scoped-state-teardown-order: don't null out the abort // controller before calling abort. if (this.liveTankersAbort) { this.liveTankersAbort.abort(); } const controller = new AbortController(); this.liveTankersAbort = controller; try { const { fetchLiveTankers } = await import('@/services/live-tankers'); // Thread the signal so the in-flight RPC actually cancels when a // newer tick starts (or the layer toggles off). Without this, a // slow older refresh can race-write stale data after a newer one // already populated this.liveTankers. const zones = await fetchLiveTankers(undefined, { signal: controller.signal }); // Drop the result if this controller was aborted mid-flight or if // a newer load has already replaced us. Without this guard, an // older fetch that completed despite signal.aborted (e.g. the // service returned cached data without checking the signal) would // overwrite the newer one's data. if (controller.signal.aborted || this.liveTankersAbort !== controller) { return; } const flat = zones.flatMap((z) => z.tankers).map((t) => ({ mmsi: t.mmsi, lat: t.lat, lon: t.lon, speed: t.speed, shipType: t.shipType, name: t.name, })); this.liveTankers = flat; this.updateLayers(); } catch { // Graceful: leave existing tankers in place; layer will continue // rendering last-known data until the next successful tick. } } private createGpsJammingLayer(): PolygonLayer { return new PolygonLayer({ id: 'gps-jamming-layer', data: this.gpsJammingHexes, getPolygon: (d: GpsJamHexWithPolygon) => d.polygon, getFillColor: (d: GpsJamHexWithPolygon) => { if (d.level === 'high') return [255, 80, 80, 180] as [number, number, number, number]; return [255, 180, 50, 140] as [number, number, number, number]; }, extruded: false, filled: true, stroked: true, getLineColor: [255, 255, 255, 80] as [number, number, number, number], getLineWidth: 1, lineWidthMinPixels: 1, 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: (d) => { if (d.usniSource) return [255, 160, 60, 160] as [number, number, number, number]; // Orange, lower alpha for USNI-only return COLORS.vesselMilitary; }, radiusMinPixels: 4, radiusMaxPixels: 10, pickable: true, stroked: true, getLineColor: (d) => { if (d.usniSource) return [255, 180, 80, 200] as [number, number, number, number]; // Orange outline return [0, 0, 0, 0] as [number, number, number, number]; // No outline for AIS }, lineWidthMinPixels: 2, }); } 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: (d) => { if (d.onGround) return [120, 120, 120, 160] as [number, number, number, number]; const [r, g, b] = altitudeToColor(d.altitude); return [r, g, b, 220] as [number, number, number, number]; }, radiusMinPixels: 4, radiusMaxPixels: 12, pickable: true, }); } private createMilitaryFlightTrailsLayer(flights: MilitaryFlight[]): PathLayer { const trailed = flights.filter(f => this.activeFlightTrails.has(f.hexCode.toLowerCase()) && f.track && f.track.length > 1); return new PathLayer({ id: 'military-flight-trails-layer', data: trailed, getPath: (d) => d.track!.map(([lat, lon]: [number, number]) => [lon, lat]), getColor: (d) => { const [r, g, b] = altitudeToColor(d.altitude); return [r, g, b, 140] as [number, number, number, number]; }, getWidth: 2, widthUnits: 'pixels' as const, pickable: false, }); } 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 async loadAptGroups(): Promise { const { APT_GROUPS } = await import('@/config/apt-groups'); this.aptGroups = APT_GROUPS; this.aptGroupsLoaded = true; this.render(); } 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: this.aptGroups, getPosition: (d) => [d.lon, d.lat], getRadius: 6000, getFillColor: [255, 140, 0, 140] as [number, number, number, number], radiusMinPixels: 4, radiusMaxPixels: 8, pickable: true, stroked: false, }); } 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, }); } // Commodity variant layers private createMiningSitesLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'mining-sites-layer', data: MINING_SITES, getPosition: (d) => [d.lon, d.lat], getRadius: (d) => d.status === 'producing' ? 10000 : d.status === 'development' ? 8000 : 6000, getFillColor: (d) => getMineralColor(d.mineral), radiusMinPixels: 5, radiusMaxPixels: 14, pickable: true, stroked: true, getLineColor: [255, 255, 255, 60] as [number, number, number, number], lineWidthMinPixels: 1, }); } private createProcessingPlantsLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'processing-plants-layer', data: PROCESSING_PLANTS, getPosition: (d) => [d.lon, d.lat], getRadius: 8000, getFillColor: (d) => { switch (d.type) { case 'smelter': return [255, 80, 30, 210] as [number, number, number, number]; case 'refinery': return [255, 160, 50, 200] as [number, number, number, number]; case 'separation': return [160, 100, 255, 200] as [number, number, number, number]; case 'processing': return [100, 200, 150, 200] as [number, number, number, number]; default: return [200, 150, 100, 200] as [number, number, number, number]; } }, radiusMinPixels: 5, radiusMaxPixels: 12, pickable: true, stroked: true, getLineColor: [255, 255, 255, 80] as [number, number, number, number], lineWidthMinPixels: 1, }); } private createCommodityPortsLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'commodity-ports-layer', data: COMMODITY_GEO_PORTS, getPosition: (d) => [d.lon, d.lat], getRadius: 12000, getFillColor: (d) => getMineralColor(d.commodities[0]), radiusMinPixels: 6, radiusMaxPixels: 14, pickable: true, stroked: true, getLineColor: [255, 255, 255, 100] as [number, number, number, number], lineWidthMinPixels: 1.5, }); } // 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 }, })); 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 }, })); } layers.push(this.createEmptyGhost('protest-clusters-layer')); 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 }, })); 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', })); } } layers.push(this.createEmptyGhost('tech-hq-clusters-layer')); 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 }, })); 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, })); } layers.push(this.createEmptyGhost('tech-event-clusters-layer')); 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 }, })); 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, })); } layers.push(this.createEmptyGhost('datacenter-clusters-layer')); 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, })); 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 }, })); } layers.push(this.createEmptyGhost('hotspots-layer')); 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) || this.positiveEvents.some(e => e.count > 10) || this.kindnessPoints.some(p => p.type === 'real'); } 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; // Steady-state pulse rebuild: skip when input is queued (#5042 U2). The // terminal settle branch above is never guarded — it must commit the // static end state. pulseTime still advances so no visual state is lost. if (shouldRunInputSensitiveMapWork(isInputPending)) 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 createPositiveEventsLayers(items: PositiveGeoEvent[]): Layer[] { const layers: Layer[] = []; const getCategoryColor = (category: string): [number, number, number, number] => { switch (category) { case 'nature-wildlife': case 'humanity-kindness': return [34, 197, 94, 200]; // green case 'science-health': case 'innovation-tech': case 'climate-wins': return [234, 179, 8, 200]; // gold case 'culture-community': return [139, 92, 246, 200]; // purple default: return [34, 197, 94, 200]; // green default } }; // Dot layer (tooltip on hover via getTooltip) layers.push(new ScatterplotLayer({ id: 'positive-events-layer', data: items, getPosition: (d: PositiveGeoEvent) => [d.lon, d.lat], getRadius: 12000, getFillColor: (d: PositiveGeoEvent) => getCategoryColor(d.category), radiusMinPixels: 5, radiusMaxPixels: 10, pickable: true, })); // Gentle pulse ring for significant events (count > 8) const significantEvents = items.filter(e => e.count > 8); if (significantEvents.length > 0) { const pulse = 1.0 + 0.4 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 800)); layers.push(new ScatterplotLayer({ id: 'positive-events-pulse', data: significantEvents, getPosition: (d: PositiveGeoEvent) => [d.lon, d.lat], getRadius: 15000, radiusScale: pulse, radiusMinPixels: 8, radiusMaxPixels: 24, stroked: true, filled: false, getLineColor: (d: PositiveGeoEvent) => getCategoryColor(d.category), lineWidthMinPixels: 1.5, pickable: false, updateTriggers: { radiusScale: this.pulseTime }, })); } return layers; } private createKindnessLayers(items: KindnessPoint[]): Layer[] { const layers: Layer[] = []; if (items.length === 0) return layers; // Dot layer (tooltip on hover via getTooltip) layers.push(new ScatterplotLayer({ id: 'kindness-layer', data: items, getPosition: (d: KindnessPoint) => [d.lon, d.lat], getRadius: 12000, getFillColor: [74, 222, 128, 200] as [number, number, number, number], radiusMinPixels: 5, radiusMaxPixels: 10, pickable: true, })); // Pulse for real events const pulse = 1.0 + 0.4 * (0.5 + 0.5 * Math.sin((this.pulseTime || Date.now()) / 800)); layers.push(new ScatterplotLayer({ id: 'kindness-pulse', data: items, getPosition: (d: KindnessPoint) => [d.lon, d.lat], getRadius: 14000, radiusScale: pulse, radiusMinPixels: 6, radiusMaxPixels: 18, stroked: true, filled: false, getLineColor: [74, 222, 128, 80] as [number, number, number, number], lineWidthMinPixels: 1, pickable: false, updateTriggers: { radiusScale: this.pulseTime }, })); return layers; } private createHappinessChoroplethLayer(): GeoJsonLayer | null { if (!this.countriesGeoJsonData || this.happinessScores.size === 0) return null; const scores = this.happinessScores; return new GeoJsonLayer({ id: 'happiness-choropleth-layer', data: this.getCulledCountriesGeoJson() ?? this.countriesGeoJsonData, filled: true, stroked: true, getFillColor: (feature: { properties?: Record }) => { const code = feature.properties?.['ISO3166-1-Alpha-2'] as string | undefined; const score = code ? scores.get(code) : undefined; if (score == null) return [0, 0, 0, 0] as [number, number, number, number]; const t = score / 10; return [ Math.round(40 + (1 - t) * 180), Math.round(180 + t * 60), Math.round(40 + (1 - t) * 100), 140, ] as [number, number, number, number]; }, getLineColor: [100, 100, 100, 60] as [number, number, number, number], getLineWidth: 1, lineWidthMinPixels: 0.5, pickable: true, updateTriggers: { getFillColor: [scores.size] }, }); } private static readonly CII_LEVEL_HEX: Record = { critical: '#b91c1c', high: '#dc2626', elevated: '#f59e0b', normal: '#eab308', low: '#22c55e', }; private createCIIChoroplethLayer(): GeoJsonLayer | null { if (!this.countriesGeoJsonData || this.ciiScoresMap.size === 0) return null; const scores = this.ciiScoresMap; const colors = CII_LEVEL_COLORS; return new GeoJsonLayer({ id: 'cii-choropleth-layer', data: this.getCulledCountriesGeoJson() ?? this.countriesGeoJsonData, filled: true, stroked: true, getFillColor: (feature: { properties?: Record }) => { const code = feature.properties?.['ISO3166-1-Alpha-2'] as string | undefined; const entry = code ? scores.get(code) : undefined; return entry ? (colors[entry.level as CiiLevel] ?? [0, 0, 0, 0]) : [0, 0, 0, 0]; }, getLineColor: [80, 80, 80, 80] as [number, number, number, number], getLineWidth: 1, lineWidthMinPixels: 0.5, pickable: true, updateTriggers: { getFillColor: [this.ciiScoresVersion] }, }); } private createResilienceChoroplethLayer(): GeoJsonLayer | null { if (!this.countriesGeoJsonData || this.resilienceScoresMap.size === 0) return null; const scores = this.resilienceScoresMap; return new GeoJsonLayer({ id: 'resilience-choropleth-layer', data: this.getCulledCountriesGeoJson() ?? this.countriesGeoJsonData, filled: true, stroked: true, getFillColor: (feature: { properties?: Record }) => { const code = feature.properties?.['ISO3166-1-Alpha-2'] as string | undefined; const entry = code ? scores.get(code) : undefined; return entry ? RESILIENCE_CHOROPLETH_COLORS[entry.level] : [0, 0, 0, 0]; }, getLineColor: [80, 80, 80, 80] as [number, number, number, number], getLineWidth: 1, lineWidthMinPixels: 0.5, pickable: true, updateTriggers: { getFillColor: [this.resilienceScoresVersion] }, }); } private createSanctionsChoroplethLayer(): GeoJsonLayer | null { if (!this.countriesGeoJsonData) return null; return new GeoJsonLayer({ id: 'sanctions-choropleth-layer', data: this.getCulledCountriesGeoJson() ?? this.countriesGeoJsonData, filled: true, stroked: false, getFillColor: (feature: { properties?: Record }) => { const code = feature.properties?.['ISO3166-1-Alpha-2'] as string | undefined; const level = code ? SANCTIONED_COUNTRIES_ALPHA2[code] : undefined; if (level === 'severe') return [255, 0, 0, 89] as [number, number, number, number]; if (level === 'high') return [255, 100, 0, 64] as [number, number, number, number]; if (level === 'moderate') return [255, 200, 0, 51] as [number, number, number, number]; return [0, 0, 0, 0] as [number, number, number, number]; }, pickable: false, }); } private createScenarioHeatLayer(): GeoJsonLayer | null { if (!this.affectedIso2Set.size || !this.countriesGeoJsonData) return null; return new GeoJsonLayer({ id: 'scenario-heat-layer', data: this.getCulledCountriesGeoJson() ?? this.countriesGeoJsonData, stroked: false, filled: true, extruded: false, pickable: false, getFillColor: (feature: { properties?: Record }) => { const code = feature.properties?.['ISO3166-1-Alpha-2'] as string | undefined; return (code && this.affectedIso2Set.has(code) ? [220, 60, 40, 80] : [0, 0, 0, 0]) as [number, number, number, number]; }, updateTriggers: { getFillColor: [this.scenarioState?.scenarioId ?? null] }, }); } private createSpeciesRecoveryLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'species-recovery-layer', data: this.speciesRecoveryZones, getPosition: (d: (typeof this.speciesRecoveryZones)[number]) => [d.recoveryZone.lon, d.recoveryZone.lat], getRadius: 50000, radiusMinPixels: 8, radiusMaxPixels: 25, getFillColor: [74, 222, 128, 120] as [number, number, number, number], stroked: true, getLineColor: [74, 222, 128, 200] as [number, number, number, number], lineWidthMinPixels: 1.5, pickable: true, }); } private createRenewableInstallationsLayer(): ScatterplotLayer { const typeColors: Record = { solar: [255, 200, 50, 200], wind: [100, 200, 255, 200], hydro: [0, 180, 180, 200], geothermal: [255, 150, 80, 200], }; const typeLineColors: Record = { solar: [255, 200, 50, 255], wind: [100, 200, 255, 255], hydro: [0, 180, 180, 255], geothermal: [255, 150, 80, 255], }; return new ScatterplotLayer({ id: 'renewable-installations-layer', data: this.renewableInstallations, getPosition: (d: RenewableInstallation) => [d.lon, d.lat], getRadius: 30000, radiusMinPixels: 5, radiusMaxPixels: 18, getFillColor: (d: RenewableInstallation) => typeColors[d.type] ?? [200, 200, 200, 200] as [number, number, number, number], stroked: true, getLineColor: (d: RenewableInstallation) => typeLineColors[d.type] ?? [200, 200, 200, 255] as [number, number, number, number], lineWidthMinPixels: 1, pickable: true, }); } private createImageryFootprintLayer(items: ImageryScene[]): PolygonLayer { return new PolygonLayer({ id: 'satellite-imagery-layer', data: items.filter(s => s.geometryGeojson), getPolygon: (d: ImageryScene) => { try { const geom = JSON.parse(d.geometryGeojson); if (geom.type === 'Polygon') return geom.coordinates[0]; return []; } catch { return []; } }, getFillColor: [0, 180, 255, 40] as [number, number, number, number], stroked: false, pickable: true, }); } private async fetchImageryForViewport(): Promise { const map = this.maplibreMap; if (!map) return; const bounds = map.getBounds(); const bbox = `${bounds.getWest().toFixed(4)},${bounds.getSouth().toFixed(4)},${bounds.getEast().toFixed(4)},${bounds.getNorth().toFixed(4)}`; const version = ++this.imagerySearchVersion; try { const scenes = await fetchImageryScenes({ bbox, limit: 20 }); if (version !== this.imagerySearchVersion) return; this.imageryScenes = scenes; this.render(); } catch { /* viewport fetch failed silently */ } } 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: renderMilitaryVesselTooltipHtml(obj, t) }; 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)}${obj.kind ? ` · ${text(obj.kind)}` : ''}
` }; case 'bases-cluster-layer': return { html: `
${obj.count} bases
` }; 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': { // Energy variant emits objects with {commodityType, badge}; other // variants emit the static-config shape {type}. Differentiate by // checking for the evidence-derived badge field. const hasBadge = typeof obj.badge === 'string'; const commodity = hasBadge ? String(obj.commodityType || '').toLowerCase() : String(obj.type || '').toLowerCase(); const commodityLabel = commodity === 'oil' ? t('popups.pipeline.types.oil') : commodity === 'gas' ? t('popups.pipeline.types.gas') : commodity === 'products' ? t('popups.pipeline.types.products') : `${text(commodity)} ${t('components.deckgl.tooltip.pipeline')}`.trim(); if (hasBadge) { const badge = String(obj.badge).toUpperCase(); return { html: `
${text(obj.name)}
${commodityLabel} · ${text(badge)}
` }; } return { html: `
${text(obj.name)}
${commodityLabel}
` }; } case 'storage-facilities-layer': { const typeLabel = { ugs: 'UGS', spr: 'SPR', lng_export: 'LNG export', lng_import: 'LNG import', crude_tank_farm: 'Crude hub', }[String(obj.facilityType)] ?? text(obj.facilityType); const badge = String(obj.badge || 'disputed').toUpperCase(); const cap = text(obj.capacityDisplay || '—'); return { html: `
${text(obj.name)}
${typeLabel} · ${text(obj.country)} · ${cap}
${text(badge)}
` }; } case 'fuel-shortages-layer': { const severity = String(obj.severity || 'watch').toUpperCase(); return { html: `
${text(obj.country)} · ${text(obj.product)}
${text(obj.description)}
${text(severity)}
` }; } 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 'storm-centers-layer': return { html: `
${text(obj.stormName || obj.title)}
${text(obj.classification || '')} ${obj.windKt ? obj.windKt + ` kt${obj.windAveragingPeriodMinutes ? ` (${obj.windAveragingPeriodMinutes}-minute mean)` : ''}` : ''}
` }; case 'storm-forecast-track-layer': return { html: `
${text(obj.stormName)}
${t('popups.naturalEvent.classification')}: Forecast Track
` }; case 'storm-past-track-layer': return { html: `
${text(obj.stormName)}
Past Track (${obj.windKt} kt)
` }; case 'storm-cone-layer': return { html: `
${text(obj.stormName)}
Forecast Cone
` }; 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 'disease-outbreaks-layer': { const item = (obj as { item: DiseaseOutbreakItem }).item; if (!item) return null; const lvlColor = item.alertLevel === 'alert' ? '#e74c3c' : item.alertLevel === 'warning' ? '#e67e22' : '#f1c40f'; const casesHtml = item.cases ? ` | ${item.cases} case${item.cases !== 1 ? 's' : ''}` : ''; const dateStr = new Date(item.publishedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const metaHtml = `
${text(item.sourceName || '')} | ${dateStr}${casesHtml}`; const summaryHtml = item.summary ? `
${text(item.summary.slice(0, 100))}${item.summary.length > 100 ? '…' : ''}` : ''; return { html: `
${text(item.alertLevel.toUpperCase())} ${text(item.disease)}
${text(item.location)}${summaryHtml}${metaHtml}
` }; } case 'radiation-watch-layer': { const severityLabel = obj.severity === 'spike' ? t('components.deckgl.layers.radiationSpike') : t('components.deckgl.layers.radiationElevated'); const delta = Number(obj.delta || 0); const confidence = String(obj.confidence || 'low').toUpperCase(); const corroboration = obj.corroborated ? 'CONFIRMED' : obj.conflictingSources ? 'CONFLICTING' : confidence; return { html: `
${severityLabel}
${text(obj.location)}
${Number(obj.value).toFixed(1)} ${text(obj.unit)} · ${delta >= 0 ? '+' : ''}${delta.toFixed(1)} vs baseline
${text(corroboration)}
` }; } 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.name)} (${text(obj.iata)})
${text(obj.severity)}: ${text(obj.reason)}
` }; case 'notam-overlay-layer': return { html: `
⚠ NOTAM CLOSURE
${text(obj.name)} (${text(obj.iata)})
${text((obj.reason || '').slice(0, 100))}
` }; case 'aircraft-positions-layer': return { html: `
${text(obj.callsign || obj.icao24)}
${obj.altitudeFt?.toLocaleString() ?? 0} ft · ${obj.groundSpeedKts ?? 0} kts · ${Math.round(obj.trackDeg ?? 0)}°
` }; 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 'mining-sites-layer': { const statusLabel = obj.status === 'producing' ? '⛏️ Producing' : obj.status === 'development' ? '🔧 Development' : '🔍 Exploration'; const outputStr = obj.annualOutput ? `
${text(obj.annualOutput)}` : ''; return { html: `
${text(obj.name)}
${text(obj.mineral)} · ${text(obj.country)}
${statusLabel}${outputStr}
` }; } case 'processing-plants-layer': { const typeLabel = obj.type === 'smelter' ? '🏭 Smelter' : obj.type === 'refinery' ? '⚗️ Refinery' : obj.type === 'separation' ? '🧪 Separation' : '🏗️ Processing'; const capacityStr = obj.capacityTpa ? `
${text(String((obj.capacityTpa / 1000).toFixed(0)))}k t/yr` : ''; const mineralLabel = obj.mineral ?? (Array.isArray(obj.materials) ? obj.materials.join(', ') : ''); return { html: `
${text(obj.name)}
${text(mineralLabel)} · ${text(obj.country)}
${typeLabel}${capacityStr}
` }; } case 'commodity-ports-layer': { const commoditiesStr = Array.isArray(obj.commodities) ? obj.commodities.join(', ') : ''; const volumeStr = obj.annualVolumeMt ? `
${text(String(obj.annualVolumeMt))}Mt/yr` : ''; return { html: `
⚓ ${text(obj.name)}
${text(obj.country)}
${text(commoditiesStr)}${volumeStr}
` }; } 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 'gps-jamming-layer': return { html: `
GPS Jamming
${text(obj.level)} · aircraft affected: ${Number(obj.pct).toFixed(1)}%
H3: ${text(obj.h3)}
` }; 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.title || t('components.deckgl.tooltip.internetOutage'))}
${text(obj.country)}
` }; case 'traffic-anomalies-layer': return { html: `
${text(obj.type || 'Traffic Anomaly')}
${text(obj.locationName || obj.asnName || '')}
` }; case 'ddos-locations-layer': return { html: `
DDoS: ${text(obj.countryName)}
${text(obj.percentage ? obj.percentage.toFixed(1) + '%' : '')}
` }; 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 'iran-events-layer': return { html: `
${t('components.deckgl.layers.iranAttacks')}: ${text(obj.category || '')}
${text((obj.title || '').slice(0, 80))}
` }; case 'news-locations-layer': return { html: `
📰 ${t('components.deckgl.tooltip.news')}
${text(obj.title?.slice(0, 80) || '')}
` }; case 'positive-events-layer': { const catLabel = obj.category ? obj.category.replace(/-/g, ' & ') : 'Positive Event'; const countInfo = obj.count > 1 ? `
${obj.count} sources reporting` : ''; return { html: `
${text(obj.name)}
${text(catLabel)}${countInfo}
` }; } case 'kindness-layer': return { html: `
${text(obj.name)}
` }; case 'happiness-choropleth-layer': { const hcName = obj.properties?.name ?? 'Unknown'; const hcCode = obj.properties?.['ISO3166-1-Alpha-2']; const hcScore = hcCode ? this.happinessScores.get(hcCode as string) : undefined; const hcScoreStr = hcScore != null ? hcScore.toFixed(1) : 'No data'; return { html: `
${text(hcName)}
Happiness: ${hcScoreStr}/10${hcScore != null ? `
${text(this.happinessSource)} (${this.happinessYear})` : ''}
` }; } case 'cii-choropleth-layer': { const ciiName = obj.properties?.name ?? 'Unknown'; const ciiCode = obj.properties?.['ISO3166-1-Alpha-2']; const ciiEntry = ciiCode ? this.ciiScoresMap.get(ciiCode as string) : undefined; if (!ciiEntry) return { html: `
${text(ciiName)}
No CII data
` }; const levelColor = DeckGLMap.CII_LEVEL_HEX[ciiEntry.level] ?? '#888'; return { html: `
${text(ciiName)}
CII: ${ciiEntry.score}/100
${text(ciiEntry.level)}
` }; } case 'resilience-choropleth-layer': { const resilienceName = obj.properties?.name ?? 'Unknown'; const resilienceCode = obj.properties?.['ISO3166-1-Alpha-2']; const resilienceEntry = resilienceCode ? this.resilienceScoresMap.get(resilienceCode as string) : undefined; if (!resilienceEntry) { return { html: `
${text(resilienceName)}
No resilience data
` }; } if (resilienceEntry.level === 'insufficient_data') { return { html: `
${text(resilienceName)}
Insufficient data
` }; } const [red, green, blue] = RESILIENCE_CHOROPLETH_COLORS[resilienceEntry.level]; const levelColor = `rgb(${red}, ${green}, ${blue})`; const visualBand = formatResilienceChoroplethLevel(resilienceEntry.level); const serverLevel = formatResilienceServerLevel(resilienceEntry.serverLevel); const confidenceNote = resilienceEntry.lowConfidence ? '
Low confidence' : resilienceEntry.outsideHeadlineRanking ? '
Outside headline ranking' : ''; return { html: `
${text(resilienceName)}
Resilience: ${resilienceEntry.overallScore.toFixed(1)}/100
Visual band: ${text(visualBand)}
API level: ${text(serverLevel)}${confidenceNote}
`, }; } case 'species-recovery-layer': { return { html: `
${text(obj.commonName)}
${text(obj.recoveryZone?.name ?? obj.region)}
Status: ${text(obj.recoveryStatus)}
` }; } case 'renewable-installations-layer': { const riTypeLabel = obj.type ? String(obj.type).charAt(0).toUpperCase() + String(obj.type).slice(1) : 'Renewable'; return { html: `
${text(obj.name)}
${riTypeLabel} · ${obj.capacityMW?.toLocaleString() ?? '?'} MW
${text(obj.country)} · ${obj.year}
` }; } 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)}
`, }; } case 'satellite-imagery-layer': { let imgHtml = `
🛰 ${text(obj.satellite)}
${text(obj.datetime)}
Res: ${Number(obj.resolutionM)}m \u00B7 ${text(obj.mode)}`; if (isAllowedPreviewUrl(obj.previewUrl)) { const safeHref = escapeHtml(new URL(obj.previewUrl).href); imgHtml += `
`; } imgHtml += '
'; return { html: imgHtml }; } case 'webcam-layer': { const label = 'count' in obj ? `${obj.count} webcams` : (obj.title || obj.name || 'Webcam'); return { html: `
${text(label)}
` }; } default: return null; } } private static readonly CHOROPLETH_LAYER_IDS = new Set([ 'cii-choropleth-layer', 'happiness-choropleth-layer', 'resilience-choropleth-layer', ]); private handleClick(info: PickingInfo): void { const isChoropleth = info.layer?.id ? DeckGLMap.CHOROPLETH_LAYER_IDS.has(info.layer.id) : false; if (!info.object || isChoropleth) { if (info.coordinate && this.onCountryClick) { if (this.shouldSuppressCountryClickAfterDrag()) return; const [lon, lat] = info.coordinate as [number, number]; let country: { code: string; name: string } | null = null; if (isChoropleth && info.object?.properties) { country = { code: info.object.properties['ISO3166-1-Alpha-2'] as string, name: info.object.properties.name as string }; } else { country = resolveCountryForPointerInteraction( { code: this.hoveredCountryIso2, name: this.hoveredCountryName }, this.hoverQueryThrottle?.isPending() ?? false, () => this.resolveCountryFromCoordinate(lon, lat), ); } // Only fire if we have a country — ocean/no-country clicks are silently ignored if (country?.code && country?.name) { this.onCountryClick({ lat, lon, 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.items.length === 0 && cluster._clusterId != null && this.protestSC) { try { const leaves = this.protestSC.getLeaves(cluster._clusterId, DeckGLMap.MAX_CLUSTER_LEAVES); cluster.items = leaves.map(l => this.protestSuperclusterSource[l.properties.index]).filter((x): x is SocialUnrestEvent => !!x); cluster.sampled = cluster.items.length < cluster.count; } catch (e) { console.warn('[DeckGLMap] stale protest cluster', cluster._clusterId, e); return; } } 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.items.length === 0 && cluster._clusterId != null && this.techHQSC) { try { const leaves = this.techHQSC.getLeaves(cluster._clusterId, DeckGLMap.MAX_CLUSTER_LEAVES); cluster.items = leaves.map(l => TECH_HQS[l.properties.index]).filter(Boolean) as typeof TECH_HQS; cluster.sampled = cluster.items.length < cluster.count; } catch (e) { console.warn('[DeckGLMap] stale techHQ cluster', cluster._clusterId, e); return; } } 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.items.length === 0 && cluster._clusterId != null && this.techEventSC) { try { const leaves = this.techEventSC.getLeaves(cluster._clusterId, DeckGLMap.MAX_CLUSTER_LEAVES); cluster.items = leaves.map(l => this.techEvents[l.properties.index]).filter((x): x is TechEventMarker => !!x); cluster.sampled = cluster.items.length < cluster.count; } catch (e) { console.warn('[DeckGLMap] stale techEvent cluster', cluster._clusterId, e); return; } } 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.items.length === 0 && cluster._clusterId != null && this.datacenterSC) { try { const leaves = this.datacenterSC.getLeaves(cluster._clusterId, DeckGLMap.MAX_CLUSTER_LEAVES); cluster.items = leaves.map(l => this.datacenterSCSource[l.properties.index]).filter((x): x is AIDataCenter => !!x); cluster.sampled = cluster.items.length < cluster.count; } catch (e) { console.warn('[DeckGLMap] stale datacenter cluster', cluster._clusterId, e); return; } } 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; } if (layerId === 'webcam-layer' && !('count' in info.object)) { this.showWebcamClickPopup(info.object as WebcamEntry, info.x, info.y); return; } if (layerId === 'trade-routes-layer') { const segment = info.object as TradeRouteSegment; if (!hasPremiumAccess(getAuthState())) { trackGateHit('trade-arc-intel'); return; } const waypoints = ROUTE_WAYPOINTS_MAP.get(segment.routeId) ?? []; this.popup.showRouteBreakdown(segment, waypoints, info.x, info.y); this.onTradeArcClick?.(segment, waypoints, info.x, 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', 'radiation-watch-layer': 'radiation', 'datacenters-layer': 'datacenter', 'cables-layer': 'cable', 'pipelines-layer': 'pipeline', 'earthquakes-layer': 'earthquake', 'weather-layer': 'weather', 'outages-layer': 'outage', 'cyber-threats-layer': 'cyberThreat', 'iran-events-layer': 'iranEvent', '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', 'storm-centers-layer': 'natEvent', 'storm-forecast-track-layer': 'natEvent', 'storm-past-track-layer': 'natEvent', 'storm-cone-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', 'notam-overlay-layer': 'flight', 'aircraft-positions-layer': 'aircraft', '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', 'gps-jamming-layer': 'gpsJamming', 'cable-advisories-layer': 'cable-advisory', 'repair-ships-layer': 'repair-ship', }; const popupType = layerToPopupType[layerId]; if (!popupType) return; // For synthetic storm layers, unwrap the backing NaturalEvent let data = info.object?._event ?? 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; } // Enrich iran events with related events from same location if (popupType === 'iranEvent' && data.locationName) { const clickedId = data.id; const normalizedLoc = data.locationName.trim().toLowerCase(); const related = this.iranEvents .filter(e => e.id !== clickedId && e.locationName && e.locationName.trim().toLowerCase() === normalizedLoc) .sort((a, b) => (Number(b.timestamp) || 0) - (Number(a.timestamp) || 0)) .slice(0, 5); data = { ...data, relatedEvents: related }; } // Get click coordinates relative to container const x = info.x ?? 0; const y = info.y ?? 0; // Toggle flight trail on military flight click if (popupType === 'militaryFlight') { const hex = (data as MilitaryFlight).hexCode; if (hex) this.toggleFlightTrail(hex); } this.popup.show({ type: popupType, data: data, x, y, }); // Async Wingbits live enrichment for any aircraft popup if (popupType === 'militaryFlight') { const hexCode = (data as { hexCode?: string }).hexCode; if (hexCode) this.popup.loadWingbitsLiveFlight(hexCode); } if (popupType === 'aircraft') { const icao24 = (data as { icao24?: string }).icao24; if (icao24) this.popup.loadWingbitsLiveFlight(icao24); } if (popupType === 'conflict') { this.popup.loadConflictHistory(data as import('@/types').ConflictZone); } } private async showWebcamClickPopup(webcam: WebcamEntry, x: number, y: number): Promise { // Remove any existing popup this.container.querySelector('.deckgl-webcam-popup')?.remove(); const popup = document.createElement('div'); popup.className = 'deckgl-webcam-popup'; popup.style.position = 'absolute'; popup.style.left = x + 'px'; popup.style.top = y + 'px'; popup.style.zIndex = '1000'; const titleEl = document.createElement('div'); titleEl.className = 'deckgl-webcam-popup-title'; titleEl.textContent = webcam.title || webcam.webcamId || ''; popup.appendChild(titleEl); const locationEl = document.createElement('div'); locationEl.className = 'deckgl-webcam-popup-location'; locationEl.textContent = webcam.country || ''; popup.appendChild(locationEl); const id = webcam.webcamId; // Fetch playerUrl for when user pins const imageData = await fetchWebcamImage(id).catch(() => null); const pinBtn = document.createElement('button'); pinBtn.className = 'webcam-pin-btn'; if (isPinned(id)) { pinBtn.classList.add('webcam-pin-btn--pinned'); pinBtn.textContent = '\u{1F4CC} Pinned'; pinBtn.disabled = true; } else { pinBtn.textContent = '\u{1F4CC} Pin'; pinBtn.addEventListener('click', (e) => { e.stopPropagation(); pinWebcam({ webcamId: id, title: webcam.title || imageData?.title || '', lat: webcam.lat, lng: webcam.lng, category: webcam.category || 'other', country: webcam.country || '', playerUrl: imageData?.playerUrl || '', }); pinBtn.classList.add('webcam-pin-btn--pinned'); pinBtn.textContent = '\u{1F4CC} Pinned'; pinBtn.disabled = true; }); } popup.appendChild(pinBtn); const cleanup = () => { popup.remove(); document.removeEventListener('click', closeHandler); clearTimeout(autoDismiss); }; const closeHandler = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) cleanup(); }; const autoDismiss = setTimeout(cleanup, 8000); setTimeout(() => document.addEventListener('click', closeHandler), 0); this.container.appendChild(popup); } // 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?.[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'; setTrustedHtml(controls, trustedHtml(`
`, "legacy direct innerHTML migration")); 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); }); // Clear flight trails button (hidden by default) this.clearTrailsBtn = document.createElement('button'); this.clearTrailsBtn.className = 'map-clear-trails-btn'; this.clearTrailsBtn.textContent = t('components.map.clearTrails'); this.clearTrailsBtn.style.display = 'none'; this.clearTrailsBtn.addEventListener('click', () => this.clearFlightTrails()); controls.appendChild(this.clearTrailsBtn); } private createTimeSlider(): void { const slider = document.createElement('div'); slider.className = 'time-slider deckgl-time-slider'; setTrustedHtml(slider, trustedHtml(`
`, "legacy direct innerHTML migration")); 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 layerDefs = getLayersForVariant((SITE_VARIANT || 'full') as MapVariant, 'flat'); const premiumUnlocked = hasPremiumAccess(getAuthState()); const layerConfig = layerDefs.map(def => ({ key: def.key, label: resolveLayerLabel(def, t), icon: def.icon, premium: def.premium, explainLabel: escapeHtml(`Explain ${resolveLayerLabel(def, t)} layer`), hasExplanation: hasCuratedLayerExplanation(def.key), })); setTrustedHtml(toggles, trustedHtml(`
${t('components.deckgl.layersTitle')}
${layerConfig.map(({ key, label, icon, premium, explainLabel, hasExplanation }) => { const isLocked = premium === 'locked' && !premiumUnlocked; const isEnhanced = premium === 'enhanced' && !premiumUnlocked; return `
`; }).join('')}
`, "legacy direct innerHTML migration")); const authorBadge = document.createElement('div'); authorBadge.className = 'map-author-badge'; authorBadge.textContent = '© Elie Habib · Someone™'; toggles.appendChild(authorBadge); this.container.appendChild(toggles); // Unlock premium layers when Pro status resolves. Pro can come from EITHER: // 1. Clerk role === 'pro' (subscribeAuthState fires on Clerk changes) // 2. Convex entitlement tier >= 1 (onEntitlementChange fires on Convex changes) // Subscribing to BOTH covers Dodo subscribers whose Pro flag arrives via // Convex (NOT via Clerk role). User-reported on energy.worldmonitor.app: // "Pro Monthly" in settings UI but Resilience layer still showed the lock // because subscribeAuthState alone never fires on Convex transitions. // // Whichever signal resolves Pro first does the unlock; the other becomes // a no-op (early-return when not Pro; no-op .remove on already-removed // class). queueMicrotask defers self-unsubscribe so both _unsubscribe* // assignments complete before the unsubscribe runs. Greptile P2 fix: // single helper instead of duplicated callback bodies. const unlockIfPro = (): void => { if (!hasPremiumAccess(getAuthState())) return; toggles.querySelectorAll('.layer-toggle-locked').forEach(label => { label.classList.remove('layer-toggle-locked'); const input = label.querySelector('input') as HTMLInputElement | null; if (input) input.disabled = false; const labelSpan = label.querySelector('.toggle-label'); if (labelSpan) labelSpan.textContent = labelSpan.textContent!.replace(' \uD83D\uDD12', ''); }); queueMicrotask(() => { this._unsubscribeAuthState?.(); this._unsubscribeAuthState = null; this._unsubscribeEntitlement?.(); this._unsubscribeEntitlement = null; }); }; this._unsubscribeAuthState = subscribeAuthState(() => unlockIfPro()); this._unsubscribeEntitlement = onEntitlementChange(() => unlockIfPro()); // 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) { const enabled = (input as HTMLInputElement).checked; const prevRadar = this.state.layers.weather; const prevCyber = this.state.layers.cyberThreats; if (enabled && (layer === 'resilienceScore' || layer === 'ciiChoropleth')) { const conflictingLayer = layer === 'resilienceScore' ? 'ciiChoropleth' : 'resilienceScore'; if (this.state.layers[conflictingLayer]) { this.state.layers[conflictingLayer] = false; const conflictingToggle = this.container.querySelector(`.layer-toggle[data-layer="${conflictingLayer}"] input`) as HTMLInputElement | null; if (conflictingToggle) conflictingToggle.checked = false; this.setLayerReady(conflictingLayer, false); this.onLayerChange?.(conflictingLayer, false, 'programmatic'); } } this.state.layers[layer] = enabled; if (layer === 'military' && !enabled) this.clearFlightTrails(); if (layer === 'flights') this.manageAircraftTimer(enabled); if (this.state.layers.weather && !prevRadar) this.startWeatherRadar(); else if (!this.state.layers.weather && prevRadar) this.stopWeatherRadar(); if (this.state.layers.cyberThreats && !prevCyber && !this.aptGroupsLoaded) this.loadAptGroups(); this.render(); this.updateLegend(); this.onLayerChange?.(layer, enabled, 'user'); this.enforceLayerLimit(); } }); }); this.enforceLayerLimit(); toggles.querySelectorAll('.layer-explain-btn').forEach(button => { button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const layer = (button as HTMLElement).getAttribute('data-layer') as keyof MapLayers | null; if (layer) this.showLayerExplanation(layer); }); }); // 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 }); } bindLayerSearch(toggles); const searchEl = toggles.querySelector('.layer-search') as HTMLElement | null; collapseBtn?.addEventListener('click', () => { toggleList?.classList.toggle('collapsed'); if (searchEl) searchEl.style.display = toggleList?.classList.contains('collapsed') ? 'none' : ''; if (collapseBtn) setTrustedHtml(collapseBtn, trustedHtml(toggleList?.classList.contains('collapsed') ? '▶' : '▼', "legacy direct innerHTML migration")); }); } private showLayerExplanation(layer: keyof MapLayers): void { const existing = this.container.querySelector('.layer-explanation-popup') as HTMLElement | null; if (existing?.dataset.layer === layer) { existing.remove(); this.container.querySelector(`.layer-explain-btn[data-layer="${layer}"]`)?.classList.remove('active'); return; } existing?.remove(); this.container.querySelector('.layer-help-popup')?.remove(); this.container.querySelectorAll('.layer-explain-btn.active').forEach(btn => btn.classList.remove('active')); const def = getLayersForVariant((SITE_VARIANT || 'full') as MapVariant, 'flat').find(item => item.key === layer); const layerLabel = def ? resolveLayerLabel(def, t) : String(layer); const explanation = getLayerExplanation(layer); const popup = document.createElement('div'); popup.className = 'layer-explanation-popup'; popup.dataset.layer = layer; setTrustedHtml(popup, trustedHtml( renderLayerExplanationCard(layerLabel, explanation), "static layer explanation metadata", )); const closePopup = (): void => { popup.remove(); this.container.querySelector(`.layer-explain-btn[data-layer="${layer}"]`)?.classList.remove('active'); }; popup.querySelector('.layer-explanation-close')?.addEventListener('click', closePopup); this.container.appendChild(popup); this.container.querySelector(`.layer-explain-btn[data-layer="${layer}"]`)?.classList.add('active'); } /** Show layer help popup explaining each layer */ private showLayerHelp(): void { const existing = this.container.querySelector('.layer-help-popup'); if (existing) { existing.remove(); return; } this.container.querySelector('.layer-explanation-popup')?.remove(); this.container.querySelectorAll('.layer-explain-btn.active').forEach(btn => btn.classList.remove('active')); 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'), helpItem(label('techEvents'), 'techEvents'), ])} ${helpSection('infrastructure', [ helpItem(label('underseaCables'), 'infraCables'), helpItem(label('aiDataCenters'), 'infraDatacenters'), helpItem(label('internetOutages'), 'infraOutages'), helpItem(label('cyberThreats'), 'techCyberThreats'), ])} ${helpSection('naturalEconomic', [ helpItem(label('naturalEvents'), 'naturalEventsTech'), helpItem(label('fires'), 'techFires'), helpItem(staticLabel('countries'), 'countriesOverlay'), helpItem(label('dayNight'), 'dayNight'), ])}
`; const financeHelpContent = ` ${helpHeader}
${helpSection('financeCore', [ helpItem(label('stockExchanges'), 'financeExchanges'), helpItem(label('financialCenters'), 'financeCenters'), helpItem(label('centralBanks'), 'financeCentralBanks'), helpItem(label('commodityHubs'), 'financeCommodityHubs'), helpItem(label('gulfInvestments'), 'financeGulfInvestments'), ])} ${helpSection('infrastructureRisk', [ helpItem(label('underseaCables'), 'financeCables'), helpItem(label('pipelines'), 'financePipelines'), helpItem(label('internetOutages'), 'financeOutages'), helpItem(label('cyberThreats'), 'financeCyberThreats'), helpItem(label('tradeRoutes'), 'tradeRoutes'), ])} ${helpSection('macroContext', [ helpItem(label('economicCenters'), 'economicCenters'), helpItem(label('strategicWaterways'), 'macroWaterways'), helpItem(label('weatherAlerts'), 'weatherAlertsMarket'), helpItem(label('naturalEvents'), 'naturalEventsMacro'), helpItem(label('dayNight'), 'dayNight'), ])}
`; 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'), helpItem(label('ucdpEvents'), 'geoUcdpEvents'), helpItem(label('displacementFlows'), 'geoDisplacement'), ])} ${helpSection('militaryStrategic', [ helpItem(label('militaryBases'), 'militaryBases'), helpItem(label('nuclearSites'), 'militaryNuclear'), helpItem(label('gammaIrradiators'), 'militaryIrradiators'), helpItem(label('militaryActivity'), 'militaryActivity'), helpItem(label('spaceports'), 'militarySpaceports'), ])} ${helpSection('infrastructure', [ helpItem(label('underseaCables'), 'infraCablesFull'), helpItem(label('pipelines'), 'infraPipelinesFull'), helpItem(label('internetOutages'), 'infraOutages'), helpItem(label('aiDataCenters'), 'infraDatacentersFull'), helpItem(label('cyberThreats'), 'infraCyberThreats'), ])} ${helpSection('transport', [ helpItem(label('shipTraffic'), 'transportShipping'), helpItem(label('tradeRoutes'), 'tradeRoutes'), helpItem(label('flightDelays'), 'transportDelays'), ])} ${helpSection('naturalEconomic', [ helpItem(label('naturalEvents'), 'naturalEventsFull'), helpItem(label('fires'), 'firesFull'), helpItem(label('weatherAlerts'), 'weatherAlerts'), helpItem(label('climateAnomalies'), 'climateAnomalies'), helpItem(label('economicCenters'), 'economicCenters'), helpItem(label('criticalMinerals'), 'mineralsFull'), ])} ${helpSection('overlays', [ helpItem(label('dayNight'), 'dayNight'), helpItem(staticLabel('countries'), 'countriesOverlay'), helpItem(label('strategicWaterways'), 'waterwaysLabels'), ])}
`; setTrustedHtml(popup, trustedHtml(SITE_VARIANT === 'tech' ? techHelpContent : SITE_VARIANT === 'finance' ? financeHelpContent : fullHelpContent, "legacy direct innerHTML migration")); 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 resilienceLegendItems: { shape: string; label: string; layerKey: keyof MapLayers }[] = [ { shape: shapes.square('rgb(239, 68, 68)'), label: 'Resilience: Very Low', layerKey: 'resilienceScore' }, { shape: shapes.square('rgb(249, 115, 22)'), label: 'Resilience: Low', layerKey: 'resilienceScore' }, { shape: shapes.square('rgb(234, 179, 8)'), label: 'Resilience: Moderate', layerKey: 'resilienceScore' }, { shape: shapes.square('rgb(132, 204, 22)'), label: 'Resilience: High', layerKey: 'resilienceScore' }, { shape: shapes.square('rgb(34, 197, 94)'), label: 'Resilience: Very High', layerKey: 'resilienceScore' }, ]; const legendItems: { shape: string; label: string; layerKey: keyof MapLayers }[] = SITE_VARIANT === 'tech' ? [ { shape: shapes.circle(isLight ? 'rgb(22, 163, 74)' : 'rgb(0, 255, 150)'), label: t('components.deckgl.legend.startupHub'), layerKey: 'startupHubs' }, { shape: shapes.circle('rgb(100, 200, 255)'), label: t('components.deckgl.legend.techHQ'), layerKey: 'techHQs' }, { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 200, 0)'), label: t('components.deckgl.legend.accelerator'), layerKey: 'accelerators' }, { shape: shapes.circle('rgb(150, 100, 255)'), label: t('components.deckgl.legend.cloudRegion'), layerKey: 'cloudRegions' }, { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter'), layerKey: 'datacenters' }, { shape: shapes.circle('rgb(231, 76, 60)'), label: t('components.deckgl.legend.diseaseAlert'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(230, 126, 34)'), label: t('components.deckgl.legend.diseaseWarning'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(241, 196, 15)'), label: t('components.deckgl.legend.diseaseWatch'), layerKey: 'diseaseOutbreaks' }, ...resilienceLegendItems, ] : SITE_VARIANT === 'finance' ? [ { shape: shapes.circle('rgb(255, 215, 80)'), label: t('components.deckgl.legend.stockExchange'), layerKey: 'stockExchanges' }, { shape: shapes.circle('rgb(0, 220, 150)'), label: t('components.deckgl.legend.financialCenter'), layerKey: 'financialCenters' }, { shape: shapes.hexagon('rgb(255, 210, 80)'), label: t('components.deckgl.legend.centralBank'), layerKey: 'centralBanks' }, { shape: shapes.square('rgb(255, 150, 80)'), label: t('components.deckgl.legend.commodityHub'), layerKey: 'commodityHubs' }, { shape: shapes.triangle('rgb(80, 170, 255)'), label: t('components.deckgl.legend.waterway'), layerKey: 'waterways' }, { shape: shapes.circle('rgb(231, 76, 60)'), label: t('components.deckgl.legend.diseaseAlert'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(230, 126, 34)'), label: t('components.deckgl.legend.diseaseWarning'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(241, 196, 15)'), label: t('components.deckgl.legend.diseaseWatch'), layerKey: 'diseaseOutbreaks' }, ...resilienceLegendItems, ] : SITE_VARIANT === 'happy' ? [ { shape: shapes.circle('rgb(34, 197, 94)'), label: 'Positive Event', layerKey: 'positiveEvents' }, { shape: shapes.circle('rgb(234, 179, 8)'), label: 'Breakthrough', layerKey: 'positiveEvents' }, { shape: shapes.circle('rgb(74, 222, 128)'), label: 'Act of Kindness', layerKey: 'kindness' }, { shape: shapes.circle('rgb(255, 100, 50)'), label: 'Natural Event', layerKey: 'natural' }, { shape: shapes.square('rgb(34, 180, 100)'), label: 'Happy Country', layerKey: 'happiness' }, { shape: shapes.circle('rgb(74, 222, 128)'), label: 'Species Recovery Zone', layerKey: 'speciesRecovery' }, { shape: shapes.circle('rgb(255, 200, 50)'), label: 'Renewable Installation', layerKey: 'renewableInstallations' }, { shape: shapes.circle('rgb(160, 100, 255)'), label: t('components.deckgl.legend.aircraft'), layerKey: 'flights' }, { shape: shapes.circle('rgb(231, 76, 60)'), label: t('components.deckgl.legend.diseaseAlert'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(230, 126, 34)'), label: t('components.deckgl.legend.diseaseWarning'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(241, 196, 15)'), label: t('components.deckgl.legend.diseaseWatch'), layerKey: 'diseaseOutbreaks' }, ...resilienceLegendItems, ] : SITE_VARIANT === 'commodity' ? [ { shape: shapes.hexagon(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 200, 0)'), label: t('components.deckgl.legend.commodityHub'), layerKey: 'commodityHubs' }, { shape: shapes.circle('rgb(180, 80, 80)'), label: t('components.deckgl.legend.miningSite'), layerKey: 'miningSites' }, { shape: shapes.square('rgb(80, 160, 220)'), label: t('components.deckgl.legend.commodityPort'), layerKey: 'commodityPorts' }, { shape: shapes.circle('rgb(255, 150, 50)'), label: t('components.deckgl.legend.pipeline'), layerKey: 'pipelines' }, { shape: shapes.triangle('rgb(80, 170, 255)'), label: t('components.deckgl.legend.waterway'), layerKey: 'waterways' }, { shape: shapes.circle('rgb(200, 100, 255)'), label: t('components.deckgl.legend.processingPlant'), layerKey: 'processingPlants' }, { shape: shapes.circle('rgb(231, 76, 60)'), label: t('components.deckgl.legend.diseaseAlert'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(230, 126, 34)'), label: t('components.deckgl.legend.diseaseWarning'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(241, 196, 15)'), label: t('components.deckgl.legend.diseaseWatch'), layerKey: 'diseaseOutbreaks' }, ...resilienceLegendItems, ] : [ { shape: shapes.circle('rgb(255, 68, 68)'), label: t('components.deckgl.legend.highAlert'), layerKey: 'hotspots' }, { shape: shapes.circle('rgb(255, 165, 0)'), label: t('components.deckgl.legend.elevated'), layerKey: 'hotspots' }, { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 255, 0)'), label: t('components.deckgl.legend.monitoring'), layerKey: 'hotspots' }, { shape: shapes.circle('rgb(255, 100, 100)'), label: t('components.deckgl.legend.conflict'), layerKey: 'conflicts' }, { shape: shapes.triangle('rgb(68, 136, 255)'), label: t('components.deckgl.legend.base'), layerKey: 'bases' }, { shape: shapes.hexagon(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 220, 0)'), label: t('components.deckgl.legend.nuclear'), layerKey: 'nuclear' }, { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter'), layerKey: 'datacenters' }, { shape: shapes.circle('rgb(160, 100, 255)'), label: t('components.deckgl.legend.aircraft'), layerKey: 'flights' }, { shape: shapes.circle('rgb(231, 76, 60)'), label: t('components.deckgl.legend.diseaseAlert'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(230, 126, 34)'), label: t('components.deckgl.legend.diseaseWarning'), layerKey: 'diseaseOutbreaks' }, { shape: shapes.circle('rgb(241, 196, 15)'), label: t('components.deckgl.legend.diseaseWatch'), layerKey: 'diseaseOutbreaks' }, ...resilienceLegendItems, ]; setTrustedHtml(legend, trustedHtml(` ${t('components.deckgl.legend.title')} ${legendItems.map(({ shape, label, layerKey }) => `${shape}${label}`).join('')} `, "legacy direct innerHTML migration")); // CII choropleth gradient legend (shown when layer is active) const ciiLegend = document.createElement('div'); ciiLegend.className = 'cii-choropleth-legend'; ciiLegend.id = 'ciiChoroplethLegend'; ciiLegend.style.display = this.state.layers.ciiChoropleth ? 'block' : 'none'; setTrustedHtml(ciiLegend, trustedHtml(` CII SCALE
031516681100
`, "legacy direct innerHTML migration")); legend.appendChild(ciiLegend); this.container.appendChild(legend); this.updateLegend(); } private updateLegend(): void { this.container.querySelectorAll('.legend-item[data-layer]').forEach(item => { const layerKey = item.dataset.layer; if (!layerKey || !(layerKey in this.state.layers)) return; item.style.display = this.state.layers[layerKey as keyof MapLayers] ? '' : 'none'; }); const ciiLegend = this.container.querySelector('#ciiChoroplethLegend'); if (ciiLegend) { ciiLegend.style.display = this.state.layers.ciiChoropleth ? 'block' : 'none'; } } // Public API methods (matching MapComponent interface) public render(): void { if (this.renderPaused) { this.renderPending = true; return; } if (this.renderRafId !== null) { cancelAnimationFrame(this.renderRafId); } this.renderRafId = requestAnimationFrame(() => { this.renderRafId = null; this.updateLayers(); }); } public setRenderPaused(paused: boolean): void { if (this.renderPaused === paused) return; this.renderPaused = paused; if (paused) { if (this.renderRafId !== null) { cancelAnimationFrame(this.renderRafId); this.renderRafId = null; this.renderPending = true; } this.stopPulseAnimation(); this.stopDayNightTimer(); this.stopTradeAnimation(); return; } this.syncPulseAnimation(); if (this.state.layers.dayNight) this.startDayNightTimer(); if (this.state.layers.tradeRoutes) this.startTradeAnimation(); if (!paused && this.renderPending) { this.renderPending = false; this.render(); } } private updateLayers(deferred = false): void { if (this.renderPaused || this.webglLost || !this.maplibreMap) return; const startTime = performance.now(); let jsBuild = 0; let layerCount = 0; try { const buildStart = performance.now(); // Immediate pass defers heavy data (deferHeavy=true); the gate's deferred // pass (deferred=true) commits the real heavy data (#4558). const built = this.buildLayers(!deferred); jsBuild = performance.now() - buildStart; layerCount = built.length; this.deckOverlay?.setProps({ layers: built }); } catch { /* map may be mid-teardown (null.getProjection) */ } if (import.meta.env.DEV) { // Attribute a slow frame to our JS build vs deck.gl's commit (#4558). // Sample total BEFORE triggerRepaint() so the deckCommit bucket isolates // setProps tessellation and doesn't absorb the repaint-schedule cost. const summary = summarizeRenderTiming({ total: performance.now() - startTime, jsBuild, layerCount }); if (summary.overBudget) console.warn(formatRenderTiming(summary)); } this.maplibreMap.triggerRepaint(); this.updateZoomHints(); } private updateZoomHints(): void { const toggleList = this.container.querySelector('.deckgl-layer-toggles .toggle-list'); if (!toggleList) return; for (const [key, enabled] of Object.entries(this.state.layers)) { const toggle = toggleList.querySelector(`.layer-toggle[data-layer="${key}"]`) as HTMLElement | null; if (!toggle) continue; const zoomHidden = !!enabled && !this.isLayerVisible(key as keyof MapLayers); toggle.classList.toggle('zoom-hidden', zoomHidden); } } public setView(view: DeckMapView, zoom?: number): void { const preset = VIEW_PRESETS[view]; if (!preset) return; this.state.view = view; // Eagerly write target zoom+center so getState()/getCenter() return the // correct destination before moveend fires. Without this a 250ms URL sync // reads the old cached zoom or an intermediate animated center and // overwrites URL params (e.g. ?view=mena&zoom=4 → wrong coords). this.state.zoom = zoom ?? preset.zoom; this.pendingCenter = { lat: preset.latitude, lon: preset.longitude }; if (this.maplibreMap) { this.maplibreMap.flyTo({ center: [preset.longitude, preset.latitude], zoom: this.state.zoom, duration: 1000, }); } const viewSelect = this.container.querySelector('.view-select') as HTMLSelectElement; if (viewSelect) viewSelect.value = view; this.onStateChange?.(this.getState()); } 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 fitCountry(code: string): void { const bbox = getCountryBbox(code); if (!bbox || !this.maplibreMap) return; const [minLon, minLat, maxLon, maxLat] = bbox; this.maplibreMap.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 40, duration: 800, maxZoom: 8, }); } public getCenter(): { lat: number; lon: number } | null { if (this.pendingCenter) return this.pendingCenter; if (this.maplibreMap) { const center = this.maplibreMap.getCenter(); return { lat: center.lat, lon: center.lng }; } return null; } public getBbox(): string | null { if (!this.maplibreMap) return null; const b = this.maplibreMap.getBounds(); return `${b.getWest().toFixed(4)},${b.getSouth().toFixed(4)},${b.getEast().toFixed(4)},${b.getNorth().toFixed(4)}`; } 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 { const prevRadar = this.state.layers.weather; const prevCyber = this.state.layers.cyberThreats; this.state.layers = normalizeExclusiveChoropleths(layers, this.state.layers); if (!this.state.layers.military) this.clearFlightTrails(); this.manageAircraftTimer(this.state.layers.flights); if (this.state.layers.weather && !prevRadar) this.startWeatherRadar(); else if (!this.state.layers.weather && prevRadar) this.stopWeatherRadar(); if (this.state.layers.cyberThreats && !prevCyber && !this.aptGroupsLoaded) this.loadAptGroups(); this.render(); // Debounced this.updateLegend(); Object.entries(this.state.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, pan: { ...this.state.pan }, layers: { ...this.state.layers }, }; } // 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(): ScatterplotLayer { return new ScatterplotLayer({ id: 'climate-heatmap-layer', data: this.climateAnomalies, getPosition: (d) => [d.lon, d.lat], getRadius: (d) => 14_000 + Math.min(80_000, (Math.abs(d.tempDelta) + Math.abs(d.precipDelta) * 0.1) * 18_000), getFillColor: (d) => { const weight = Math.abs(d.tempDelta) + Math.abs(d.precipDelta) * 0.1; if (weight >= 4) return [255, 50, 50, 120] as [number, number, number, number]; if (weight >= 2.5) return [255, 140, 50, 110] as [number, number, number, number]; if (weight >= 1.25) return [255, 220, 80, 95] as [number, number, number, number]; return [100, 200, 255, 85] as [number, number, number, number]; }, radiusMinPixels: 12, radiusMaxPixels: 72, stroked: false, pickable: false, }); } private createTradeRoutesLayer(): ArcLayer { const active: [number, number, number, number] = getCurrentTheme() === 'light' ? [30, 100, 180, 200] : [100, 200, 255, 160]; const disrupted: [number, number, number, number] = getCurrentTheme() === 'light' ? [200, 40, 40, 220] : [255, 80, 80, 200]; const highRisk: [number, number, number, number] = getCurrentTheme() === 'light' ? [200, 140, 20, 200] : [255, 180, 50, 180]; const scenario: [number, number, number, number] = getCurrentTheme() === 'light' ? [220, 100, 20, 230] : [255, 140, 50, 210]; const colorFor = (status: string): [number, number, number, number] => status === 'disrupted' ? disrupted : status === 'high_risk' ? highRisk : active; // When a scenario is active, override colors for routes that transit disrupted chokepoints. // ROUTE_WAYPOINTS_MAP is module-level so getColor() is O(1) per segment instead of O(n) per frame. const scenarioDisrupted = this.scenarioState ? new Set(this.scenarioState.disruptedChokepointIds) : null; const hlActive = this.highlightedRouteIds.size > 0; const hlIds = this.highlightedRouteIds; const dimColor = (c: [number, number, number, number]): [number, number, number, number] => [c[0], c[1], c[2], 40]; const getColor = (d: TradeRouteSegment): [number, number, number, number] => { let base: [number, number, number, number]; if (scenarioDisrupted && scenarioDisrupted.size > 0) { const waypoints = ROUTE_WAYPOINTS_MAP.get(d.routeId); if (waypoints && waypoints.some(wp => scenarioDisrupted.has(wp))) { base = scenario; } else if (!hasPremiumAccess(getAuthState())) { base = active; } else { base = colorFor(d.status); } } else if (!hasPremiumAccess(getAuthState())) { base = active; } else { base = colorFor(d.status); } if (hlActive && !hlIds.has(d.routeId)) return dimColor(base); return base; }; return new ArcLayer({ id: 'trade-routes-layer', data: this.tradeRouteSegments, getSourcePosition: (d) => d.sourcePosition, getTargetPosition: (d) => d.targetPosition, getSourceColor: getColor, getTargetColor: getColor, getWidth: (d) => { if (hlActive && hlIds.has(d.routeId)) return 6; return d.category === 'energy' ? 3 : 2; }, widthMinPixels: 1, widthMaxPixels: 8, greatCircle: true, pickable: true, }); } private buildTradeTrips(): void { const activeColor: [number, number, number, number] = [100, 200, 255, 140]; const disruptedColor: [number, number, number, number] = [255, 80, 80, 180]; const highRiskColor: [number, number, number, number] = [255, 180, 50, 160]; const scenarioColor: [number, number, number, number] = [255, 140, 50, 170]; const isPremium = hasPremiumAccess(getAuthState()); const scenarioDisrupted = this.scenarioState ? new Set(this.scenarioState.disruptedChokepointIds) : null; const hlActive = this.highlightedRouteIds.size > 0; const hlIds = this.highlightedRouteIds; const colorForRoute = (routeId: string, status: string): [number, number, number, number] => { let base: [number, number, number, number]; if (scenarioDisrupted && scenarioDisrupted.size > 0) { const waypoints = ROUTE_WAYPOINTS_MAP.get(routeId); if (waypoints && waypoints.some(wp => scenarioDisrupted.has(wp))) { base = scenarioColor; } else if (!isPremium) { base = activeColor; } else { base = status === 'disrupted' ? disruptedColor : status === 'high_risk' ? highRiskColor : activeColor; } } else if (!isPremium) { base = activeColor; } else { base = status === 'disrupted' ? disruptedColor : status === 'high_risk' ? highRiskColor : activeColor; } if (hlActive && !hlIds.has(routeId)) return [base[0], base[1], base[2], 40]; return base; }; const widthFor = (category: string): number => category === 'energy' ? 4 : category === 'container' ? 2.5 : 2; const routeGroups = new Map(); for (const seg of this.tradeRouteSegments) { const existing = routeGroups.get(seg.routeId); if (existing) existing.push(seg); else routeGroups.set(seg.routeId, [seg]); } const trips: TripData[] = []; for (const [, segments] of routeGroups) { const sorted = segments.sort((a, b) => a.segmentIndex - b.segmentIndex); const fullPath: [number, number][] = []; for (let i = 0; i < sorted.length; i++) { const seg = sorted[i]!; const arcPoints = interpolateGreatCircle( seg.sourcePosition, seg.targetPosition, TRADE_GC_INTERPOLATION_POINTS, ); if (i === 0) { fullPath.push(...arcPoints); } else { fullPath.push(...arcPoints.slice(1)); } } const first = sorted[0]!; trips.push({ path: fullPath, phase: stableTradeRoutePhase(first.routeId), color: colorForRoute(first.routeId, first.status), width: widthFor(first.category), }); } this.tradeTrips = trips; } private createTradeRouteTripsLayer(): ScatterplotLayer | null { if (this.prefersReducedTradeMotion()) return null; if (this.tradeTrips.length === 0) this.buildTradeTrips(); return new ScatterplotLayer({ id: 'trade-route-trips-layer', data: this.tradeTrips, getPosition: (d: TripData) => positionAlongPath( d.path, ((this.tradeAnimationTime + d.phase) % TRADE_ANIMATION_CYCLE) / TRADE_ANIMATION_CYCLE, ), // deck.gl preserves layer state by id across new layer instances; the // accessor closes over animation time, so this trigger keeps positions live. updateTriggers: { getPosition: [this.tradeAnimationTime] }, getFillColor: (d: TripData) => d.color, getRadius: (d: TripData) => d.width * 20_000, radiusMinPixels: 3, radiusMaxPixels: 9, pickable: false, }); } private prefersReducedTradeMotion(): boolean { return this.tradeReducedMotionMedia?.matches ?? window.matchMedia(PREFERS_REDUCED_MOTION_QUERY).matches; } private startTradeAnimation(): void { if (this.tradeAnimationFrame !== null) return; if (this.renderPaused || this.prefersReducedTradeMotion()) return; let lastTime = performance.now(); const animate = (now: number) => { if (this.destroyed) { this.tradeAnimationFrame = null; return; } const delta = Math.min(now - lastTime, TRADE_ANIMATION_MAX_DELTA_MS); lastTime = now; this.tradeAnimationTime = (this.tradeAnimationTime + delta * TRADE_ANIMATION_SPEED) % TRADE_ANIMATION_CYCLE; this.tradeAnimationFrame = requestAnimationFrame(animate); this.tradeAnimationFrameCount++; // Skip this decorative rebuild when input is queued so the pending // interaction handler runs sooner (#5042 U2). Frame-predictive and // discrete-only; graceful no-op where isInputPending is unsupported. if (shouldRenderTradeAnimationFrame(this.tradeAnimationFrameCount, isInputPending)) this.render(); }; this.tradeAnimationFrame = requestAnimationFrame(animate); } private stopTradeAnimation(): void { if (this.tradeAnimationFrame !== null) { cancelAnimationFrame(this.tradeAnimationFrame); this.tradeAnimationFrame = null; } // Reset so the every-other-frame render gate restarts at a deterministic // parity; otherwise a stop/start cycle can delay the first repaint a frame. this.tradeAnimationFrameCount = 0; } private createTradeChokepointsLayer(): ScatterplotLayer { const routeWaypointIds = new Set(); for (const seg of this.tradeRouteSegments) { const waypoints = ROUTE_WAYPOINTS_MAP.get(seg.routeId); if (waypoints) for (const wp of waypoints) routeWaypointIds.add(wp); } const chokepoints = STRATEGIC_WATERWAYS.filter(w => routeWaypointIds.has(w.id)); const isLight = getCurrentTheme() === 'light'; return new ScatterplotLayer({ id: 'trade-chokepoints-layer', data: chokepoints, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getFillColor: isLight ? [200, 140, 20, 200] : [255, 180, 50, 180], getLineColor: isLight ? [100, 70, 10, 255] : [255, 220, 120, 255], getRadius: 30000, stroked: true, lineWidthMinPixels: 1, radiusMinPixels: 4, radiusMaxPixels: 12, pickable: false, }); } private rebuildHighlightedMarkers(): void { if (this.highlightedRouteIds.size === 0) { this.highlightedMarkers = []; return; } const cpIds = new Set(); for (const routeId of this.highlightedRouteIds) { const waypoints = ROUTE_WAYPOINTS_MAP.get(routeId); if (waypoints) for (const wp of waypoints) cpIds.add(wp); } this.highlightedMarkers = STRATEGIC_WATERWAYS .filter(w => cpIds.has(w.id)) .map(w => { const score = this.storedChokepointData?.chokepoints?.find(cp => cp.id === w.id)?.disruptionScore ?? 0; return { id: w.id, lon: w.lon, lat: w.lat, name: w.name, score }; }); } private createHighlightedChokepointMarkers(): ScatterplotLayer | null { if (this.highlightedMarkers.length === 0) return null; const pulse = Math.sin(this.tradeAnimationTime * CHOKEPOINT_PULSE_FREQ) * CHOKEPOINT_PULSE_AMP + 1; return new ScatterplotLayer({ id: 'highlighted-chokepoint-markers', data: this.highlightedMarkers, getPosition: (d: HighlightedMarker) => [d.lon, d.lat], getRadius: (d: HighlightedMarker) => (d.score >= 70 ? 12000 : d.score > 30 ? 10000 : 8000) * pulse, getFillColor: (d: HighlightedMarker) => d.score >= 70 ? [255, 60, 60, 180] as [number, number, number, number] : d.score > 30 ? [255, 180, 50, 160] as [number, number, number, number] : [60, 200, 120, 140] as [number, number, number, number], radiusUnits: 'meters' as const, pickable: false, stroked: true, getLineColor: (d: HighlightedMarker) => d.score >= 70 ? [255, 80, 80, 255] as [number, number, number, number] : d.score > 30 ? [255, 200, 80, 255] as [number, number, number, number] : [80, 220, 140, 255] as [number, number, number, number], getLineWidth: 2, lineWidthUnits: 'pixels' as const, updateTriggers: { getRadius: [this.tradeAnimationTime], getFillColor: [this.storedChokepointData], }, }); } private createBypassArcsLayer(): ArcLayer | null { if (this.bypassArcData.length === 0) return null; return new ArcLayer({ id: 'bypass-arcs-layer', data: this.bypassArcData, getSourcePosition: (d: BypassArcDatum) => d.source, getTargetPosition: (d: BypassArcDatum) => d.target, getSourceColor: [60, 200, 120, 160], getTargetColor: [60, 200, 120, 160], getWidth: 3, widthMinPixels: 2, greatCircle: true, pickable: false, }); } private computeNightPolygon(): [number, number][] { const now = new Date(); const JD = now.getTime() / 86400000 + 2440587.5; const D = JD - 2451545.0; // Days since J2000.0 // Solar mean anomaly (radians) const g = ((357.529 + 0.98560028 * D) % 360) * Math.PI / 180; // Solar ecliptic longitude (degrees) const q = (280.459 + 0.98564736 * D) % 360; const L = q + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g); const LRad = L * Math.PI / 180; // Obliquity of ecliptic (radians) const eRad = (23.439 - 0.00000036 * D) * Math.PI / 180; // Solar declination (radians) const decl = Math.asin(Math.sin(eRad) * Math.sin(LRad)); // Solar right ascension (radians) const RA = Math.atan2(Math.cos(eRad) * Math.sin(LRad), Math.cos(LRad)); // Greenwich Mean Sidereal Time (degrees) const GMST = ((18.697374558 + 24.06570982441908 * D) % 24) * 15; // Sub-solar longitude (degrees, normalized to [-180, 180]) let sunLng = RA * 180 / Math.PI - GMST; sunLng = ((sunLng % 360) + 540) % 360 - 180; // Trace terminator line (1° steps for smooth curve at high zoom) const tanDecl = Math.tan(decl); const points: [number, number][] = []; // Near equinox (|tanDecl| ≈ 0), the terminator is nearly a great circle // through the poles — use a vertical line at the subsolar meridian ±90° if (Math.abs(tanDecl) < 1e-6) { for (let lat = -90; lat <= 90; lat += 1) { points.push([sunLng + 90, lat]); } for (let lat = 90; lat >= -90; lat -= 1) { points.push([sunLng - 90, lat]); } return points; } for (let lng = -180; lng <= 180; lng += 1) { const ha = (lng - sunLng) * Math.PI / 180; const lat = Math.atan(-Math.cos(ha) / tanDecl) * 180 / Math.PI; points.push([lng, lat]); } // Close polygon around the dark pole const darkPoleLat = decl > 0 ? -90 : 90; points.push([180, darkPoleLat]); points.push([-180, darkPoleLat]); return points; } private createDayNightLayer(): PolygonLayer { const nightPolygon = this.cachedNightPolygon ?? (this.cachedNightPolygon = this.computeNightPolygon()); const isLight = getCurrentTheme() === 'light'; return new PolygonLayer({ id: 'day-night-layer', data: [{ polygon: nightPolygon }], getPolygon: (d: { polygon: [number, number][] }) => d.polygon, getFillColor: isLight ? [0, 0, 40, 35] : [0, 0, 20, 55], filled: true, stroked: true, getLineColor: isLight ? [100, 100, 100, 40] : [200, 200, 255, 25], getLineWidth: 1, lineWidthUnits: 'pixels' as const, pickable: false, }); } private createChinaCorridorSelectionLayers( overlay: ChinaCorridorOverlayProjection, ): [PolygonLayer, ScatterplotLayer] { return [ new PolygonLayer({ id: `china-corridor-boundary-${overlay.id}`, data: [{ polygon: overlay.polygon }], getPolygon: (item: { polygon: [number, number][] }) => item.polygon, filled: true, stroked: true, getFillColor: [40, 165, 220, 35], getLineColor: [40, 190, 240, 230], getLineWidth: 2, lineWidthUnits: 'pixels' as const, pickable: false, }), new ScatterplotLayer({ id: `china-corridor-nodes-${overlay.id}`, data: overlay.nodes, getPosition: (item: { position: [number, number] }) => item.position, getFillColor: [255, 190, 70, 220], getLineColor: [20, 30, 40, 230], getRadius: 5, radiusUnits: 'pixels' as const, lineWidthUnits: 'pixels' as const, getLineWidth: 1, stroked: true, 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; this.render(); } public setImageryScenes(scenes: ImageryScene[]): void { this.imageryScenes = scenes; this.render(); } public setOutages(outages: InternetOutage[]): void { this.outages = outages; this.render(); } public setTrafficAnomalies(anomalies: ProtoTrafficAnomaly[]): void { this.trafficAnomalies = anomalies; this.render(); } public setDdosLocations(hits: DdosLocationHit[]): void { this.ddosLocations = hits; this.render(); } public setCyberThreats(threats: CyberThreat[]): void { this.cyberThreats = threats; this.render(); } public setIranEvents(events: IranEvent[]): void { this.iranEvents = events; 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 setCableHealth(healthMap: Record): void { this.healthByCableId = healthMap; this.layerCache.delete('cables-layer'); 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 setAircraftPositions(positions: PositionSample[]): void { this.aircraftPositions = positions; this.render(); } public setMilitaryFlights(flights: MilitaryFlight[], clusters: MilitaryFlightCluster[] = []): void { this.militaryFlights = flights; this.militaryFlightClusters = clusters; // Prune trails for aircraft no longer in the dataset if (this.activeFlightTrails.size > 0) { const currentHexes = new Set(flights.map(f => f.hexCode.toLowerCase())); for (const hex of this.activeFlightTrails) { if (!currentHexes.has(hex)) this.activeFlightTrails.delete(hex); } this.updateClearTrailsBtn(); } this.render(); } public toggleFlightTrail(hexCode: string): void { const key = hexCode.toLowerCase(); if (this.activeFlightTrails.has(key)) { this.activeFlightTrails.delete(key); } else { this.activeFlightTrails.add(key); } this.updateClearTrailsBtn(); this.render(); } public clearFlightTrails(): void { if (this.activeFlightTrails.size === 0) return; this.activeFlightTrails.clear(); this.updateClearTrailsBtn(); this.render(); } private updateClearTrailsBtn(): void { if (!this.clearTrailsBtn) return; this.clearTrailsBtn.style.display = this.activeFlightTrails.size > 0 ? '' : 'none'; } public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void { this.militaryVessels = vessels; this.militaryVesselClusters = clusters; this.render(); } private fetchServerBases(): void { if (!this.maplibreMap) return; const mapLayers = this.state.layers; if (!mapLayers.bases) return; const zoom = this.maplibreMap.getZoom(); if (zoom < 3) return; const bounds = this.maplibreMap.getBounds(); const sw = bounds.getSouthWest(); const ne = bounds.getNorthEast(); fetchMilitaryBases(sw.lat, sw.lng, ne.lat, ne.lng, zoom).then((result) => { if (!result) return; this.serverBases = result.bases; this.serverBaseClusters = result.clusters; this.serverBasesLoaded = true; this.render(); }).catch((err) => { console.error('[bases] fetch error', err); }); } private manageAircraftTimer(enabled: boolean): void { if (enabled) { if (!this.aircraftFetchTimer) { this.aircraftFetchTimer = setInterval(() => { this.lastAircraftFetchCenter = null; // force refresh on poll this.fetchViewportAircraft(); }, 120_000); // Match server cache TTL (120s anonymous OpenSky tier) this.debouncedFetchAircraft(); } } else { if (this.aircraftFetchTimer) { clearInterval(this.aircraftFetchTimer); this.aircraftFetchTimer = null; } this.aircraftPositions = []; } } private hasAircraftViewportChanged(): boolean { if (!this.maplibreMap) return false; if (!this.lastAircraftFetchCenter) return true; const center = this.maplibreMap.getCenter(); const zoom = this.maplibreMap.getZoom(); if (Math.abs(zoom - this.lastAircraftFetchZoom) >= 1) return true; const [prevLng, prevLat] = this.lastAircraftFetchCenter; // Threshold scales with zoom — higher zoom = smaller movement triggers fetch const threshold = Math.max(0.1, 2 / 2 ** Math.max(0, zoom - 3)); return Math.abs(center.lat - prevLat) > threshold || Math.abs(center.lng - prevLng) > threshold; } private fetchViewportAircraft(): void { if (!this.maplibreMap) return; if (!this.state.layers.flights) return; const zoom = this.maplibreMap.getZoom(); if (zoom < 2) { if (this.aircraftPositions.length > 0) { this.aircraftPositions = []; this.render(); } return; } if (!this.hasAircraftViewportChanged()) return; const bounds = this.maplibreMap.getBounds(); const sw = bounds.getSouthWest(); const ne = bounds.getNorthEast(); const seq = ++this.aircraftFetchSeq; this.setLayerLoading('flights', true); fetchAircraftPositions({ swLat: sw.lat, swLon: sw.lng, neLat: ne.lat, neLon: ne.lng, }).then((positions) => { if (seq !== this.aircraftFetchSeq) return; // discard stale response this.aircraftPositions = positions; this.onAircraftPositionsUpdate?.(positions); const center = this.maplibreMap?.getCenter(); if (center) { this.lastAircraftFetchCenter = [center.lng, center.lat]; this.lastAircraftFetchZoom = this.maplibreMap!.getZoom(); } this.setLayerReady('flights', positions.length > 0); this.render(); }).catch((err) => { console.error('[aircraft] fetch error', err); this.setLayerLoading('flights', false); }); } 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 setRadiationObservations(observations: RadiationObservation[]): void { this.radiationObservations = observations; this.render(); } public setWebcams(markers: Array): void { this.webcamData = markers; this.render(); } public async setGpsJamming(hexes: GpsJamHex[]): Promise { const seq = ++this.gpsJammingLoadSeq; if (hexes.length === 0) { this.gpsJammingHexes = []; this.render(); return; } try { const { cellToBoundary } = await import('h3-js'); if (seq !== this.gpsJammingLoadSeq) return; // Precompute each hex boundary once per data refresh (every ~5 min) // instead of calling cellToBoundary per hex on every buildLayers()/render. this.gpsJammingHexes = hexes.map(h => ({ ...h, polygon: cellToBoundary(h.h3, true) as [number, number][], })); this.render(); } catch (err) { if (seq !== this.gpsJammingLoadSeq) return; this.gpsJammingHexes = []; this.render(); console.warn('[DeckGLMap] failed to prepare GPS jamming polygons:', (err as Error)?.message); throw err; } } public setDiseaseOutbreaks(outbreaks: DiseaseOutbreakItem[]): void { this.diseaseOutbreaks = outbreaks; 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 setPositiveEvents(events: PositiveGeoEvent[]): void { this.positiveEvents = events; this.syncPulseAnimation(); this.render(); } public setKindnessData(points: KindnessPoint[]): void { this.kindnessPoints = points; this.syncPulseAnimation(); this.render(); } public setChokepointData(data: GetChokepointStatusResponse | null): void { this.popup.setChokepointData(data); this.storedChokepointData = data; this.rebuildHighlightedMarkers(); if (this.storedChokepointData) this.refreshTradeRouteStatus(this.storedChokepointData); } public setChinaCorridorSelection(corridor: ChinaCorridorControlTower | null): void { const overlay = corridor ? projectChinaCorridorOverlay(corridor) : null; this.selectedChinaCorridorOverlay = overlay; this.render(); if (!overlay || !this.maplibreMap) return; const [minLon, minLat, maxLon, maxLat] = overlay.bounds; this.maplibreMap.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 48, duration: 700, maxZoom: 6, }); } private refreshTradeRouteStatus(data: GetChokepointStatusResponse): void { const scoreMap = new Map(data.chokepoints.map(cp => [cp.id, cp.disruptionScore ?? 0])); const initialSegments = resolveTradeRouteSegments(); this.tradeRouteSegments = initialSegments.map(seg => { const waypoints = ROUTE_WAYPOINTS_MAP.get(seg.routeId) ?? []; const maxScore = waypoints.reduce((max, id) => Math.max(max, scoreMap.get(id) ?? 0), 0); const status: TradeRouteStatus = maxScore >= 70 ? 'disrupted' : maxScore > 30 ? 'high_risk' : 'active'; return { ...seg, status }; }); this.buildTradeTrips(); this.render(); } /** * Activate or deactivate a scenario visual overlay. * When active, trade route arcs transiting disrupted chokepoints shift to * an orange scenario color. Pass null to restore normal colors. */ public setScenarioState(state: ScenarioVisualState | null): void { this.scenarioState = state; this.affectedIso2Set = new Set(state?.affectedIso2s ?? []); this.buildTradeTrips(); this.render(); } public highlightRoute(routeIds: string[]): void { this.highlightedRouteIds = new Set(routeIds); this.rebuildHighlightedMarkers(); this.buildTradeTrips(); this.render(); } public clearHighlightedRoute(): void { if (this.highlightedRouteIds.size === 0) return; this.highlightedRouteIds.clear(); this.rebuildHighlightedMarkers(); this.buildTradeTrips(); this.render(); } public setBypassRoutes(corridors: Array<{fromPort: [number, number]; toPort: [number, number]}>): void { this.bypassArcData = corridors.map(c => ({ source: c.fromPort, target: c.toPort, })); this.render(); } public clearBypassRoutes(): void { if (this.bypassArcData.length === 0) return; this.bypassArcData = []; this.render(); } public zoomToRoutes(routeIds: string[]): void { if (!this.maplibreMap || routeIds.length === 0) return; const ids = new Set(routeIds); let minLng = 180, maxLng = -180, minLat = 90, maxLat = -90; let found = false; for (const seg of this.tradeRouteSegments) { if (!ids.has(seg.routeId)) continue; found = true; const [sLng, sLat] = seg.sourcePosition; const [tLng, tLat] = seg.targetPosition; if (sLng < minLng) minLng = sLng; if (sLng > maxLng) maxLng = sLng; if (sLat < minLat) minLat = sLat; if (sLat > maxLat) maxLat = sLat; if (tLng < minLng) minLng = tLng; if (tLng > maxLng) maxLng = tLng; if (tLat < minLat) minLat = tLat; if (tLat > maxLat) maxLat = tLat; } if (!found) return; this.maplibreMap.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, duration: 1000, }); } public setHappinessScores(data: HappinessData): void { this.happinessScores = data.scores; this.happinessYear = data.year; this.happinessSource = data.source; this.render(); } public setCIIScores(scores: Array<{ code: string; score: number; level: string }>): void { this.ciiScoresMap = new Map(scores.map(s => [s.code, { score: s.score, level: s.level }])); this.ciiScoresVersion++; this.render(); } public setResilienceRanking(items: ResilienceRankingItem[], greyedOut: ResilienceRankingItem[] = []): void { this.resilienceScoresMap = buildResilienceChoroplethMap(items, greyedOut); this.resilienceScoresVersion++; this.render(); } public setSpeciesRecoveryZones(species: SpeciesRecovery[]): void { this.speciesRecoveryZones = species.filter( (s): s is SpeciesRecovery & { recoveryZone: { name: string; lat: number; lon: number } } => s.recoveryZone != null ); this.render(); } public setRenewableInstallations(installations: RenewableInstallation[]): void { this.renewableInstallations = installations; this.render(); } 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 => { const tokens = tokenizeForMatch(item.title); this.hotspots.forEach(hotspot => { if (matchesAnyKeyword(tokens, hotspot.keywords)) { 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[] { const conflictTopics = ['gaza', 'ukraine', 'ukrainian', 'russia', 'russian', 'israel', 'israeli', 'iran', 'iranian', 'china', 'chinese', 'taiwan', 'taiwanese', 'korea', 'korean', 'syria', 'syrian']; return this.news .map((item) => { const tokens = tokenizeForMatch(item.title); const matchedKeywords = findMatchingKeywords(tokens, hotspot.keywords); if (matchedKeywords.length === 0) return null; const conflictMatches = conflictTopics.filter(t => matchKeyword(tokens, t) && !hotspot.keywords.some(k => k.toLowerCase().includes(t)) ); if (conflictMatches.length > 0) { const strongLocalMatch = matchedKeywords.some(kw => kw.toLowerCase() === hotspot.name.toLowerCase() || hotspot.agencies?.some(a => matchKeyword(tokens, a)) ); if (!strongLocalMatch) return null; } 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 => { if (asset?.type && this.highlightedAssets[asset.type]) { this.highlightedAssets[asset.type].add(asset.id); } }); } this.render(); // Debounced } public setOnHotspotClick(callback: (hotspot: Hotspot) => void): void { this.onHotspotClick = callback; } public setOnTradeArcClick(cb: (segment: TradeRouteSegment, waypoints: string[], x: number, y: number) => void): void { this.onTradeArcClick = cb; } public setOnTimeRangeChange(callback: (range: TimeRange) => void): void { this.onTimeRangeChange = callback; } public setOnLayerChange(callback: (layer: keyof MapLayers, enabled: boolean, source: 'user' | 'programmatic') => void): void { this.onLayerChange = callback; } public setOnStateChange(callback: (state: DeckMapState) => void): void { this.onStateChange = callback; } public setOnAircraftPositionsUpdate(callback: (positions: PositionSample[]) => void): void { this.onAircraftPositionsUpdate = 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(getCachedCountryScoreValue); setGeoAlertGetter(getAlertsNearLocation); } private layerWarningShown = false; private lastActiveLayerCount = 0; private enforceLayerLimit(): void { const WARN_THRESHOLD = 13; const togglesEl = this.container.querySelector('.deckgl-layer-toggles'); if (!togglesEl) return; const activeCount = Array.from(togglesEl.querySelectorAll('.layer-toggle input')) .filter(i => { const toggle = i.closest('.layer-toggle') as HTMLElement | null; const row = i.closest('.layer-toggle-row') as HTMLElement | null; return toggle?.style.display !== 'none' && row?.style.display !== 'none'; }) .filter(i => i.checked).length; const increasing = activeCount > this.lastActiveLayerCount; this.lastActiveLayerCount = activeCount; if (activeCount >= WARN_THRESHOLD && increasing && !this.layerWarningShown) { this.layerWarningShown = true; showLayerWarning(WARN_THRESHOLD); } else if (activeCount < WARN_THRESHOLD) { this.layerWarningShown = false; } } // UI visibility methods public hideLayerToggle(layer: keyof MapLayers): void { const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"]`); if (toggle) { const row = toggle.closest('.layer-toggle-row') as HTMLElement | null; const target = (row ?? toggle) as HTMLElement; target.style.display = 'none'; toggle.setAttribute('data-layer-hidden', ''); } } 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 { if (!this.highlightedAssets[assetType]) return; 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]) { if (layer === 'resilienceScore' && this.state.layers.ciiChoropleth) { this.state.layers.ciiChoropleth = false; const ciiToggle = this.container.querySelector(`.layer-toggle[data-layer="ciiChoropleth"] input`) as HTMLInputElement | null; if (ciiToggle) ciiToggle.checked = false; this.setLayerReady('ciiChoropleth', false); this.onLayerChange?.('ciiChoropleth', false, 'programmatic'); } else if (layer === 'ciiChoropleth' && this.state.layers.resilienceScore) { this.state.layers.resilienceScore = false; const resilienceToggle = this.container.querySelector(`.layer-toggle[data-layer="resilienceScore"] input`) as HTMLInputElement | null; if (resilienceToggle) resilienceToggle.checked = false; this.setLayerReady('resilienceScore', false); this.onLayerChange?.('resilienceScore', false, 'programmatic'); } this.state.layers[layer] = true; const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"] input`) as HTMLInputElement; if (toggle) toggle.checked = true; if (layer === 'weather') this.startWeatherRadar(); if (layer === 'cyberThreats' && !this.aptGroupsLoaded) this.loadAptGroups(); if (layer === 'flights') this.manageAircraftTimer(true); this.render(); this.updateLegend(); this.onLayerChange?.(layer, true, 'programmatic'); this.enforceLayerLimit(); } } // Toggle layer on/off programmatically public toggleLayer(layer: keyof MapLayers): void { const prevRadar = this.state.layers.weather; const prevCyber = this.state.layers.cyberThreats; const nextEnabled = !this.state.layers[layer]; if (nextEnabled && layer === 'resilienceScore' && this.state.layers.ciiChoropleth) { this.state.layers.ciiChoropleth = false; const ciiToggle = this.container.querySelector(`.layer-toggle[data-layer="ciiChoropleth"] input`) as HTMLInputElement | null; if (ciiToggle) ciiToggle.checked = false; this.setLayerReady('ciiChoropleth', false); this.onLayerChange?.('ciiChoropleth', false, 'programmatic'); } else if (nextEnabled && layer === 'ciiChoropleth' && this.state.layers.resilienceScore) { this.state.layers.resilienceScore = false; const resilienceToggle = this.container.querySelector(`.layer-toggle[data-layer="resilienceScore"] input`) as HTMLInputElement | null; if (resilienceToggle) resilienceToggle.checked = false; this.setLayerReady('resilienceScore', false); this.onLayerChange?.('resilienceScore', false, 'programmatic'); } this.state.layers[layer] = !this.state.layers[layer]; if (layer === 'military' && !this.state.layers[layer]) this.clearFlightTrails(); const toggle = this.container.querySelector(`.layer-toggle[data-layer="${layer}"] input`) as HTMLInputElement; if (toggle) toggle.checked = this.state.layers[layer]; if (this.state.layers.weather && !prevRadar) this.startWeatherRadar(); else if (!this.state.layers.weather && prevRadar) this.stopWeatherRadar(); if (this.state.layers.cyberThreats && !prevCyber && !this.aptGroupsLoaded) this.loadAptGroups(); if (layer === 'flights') this.manageAircraftTimer(this.state.layers.flights); this.render(); this.updateLegend(); this.onLayerChange?.(layer, this.state.layers[layer], 'programmatic'); this.enforceLayerLimit(); } // Update legend visibility based on which layers are currently active // 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 }; } // Pan to a chokepoint/waterway and open its popup (chokepoint deep-link target). // Unlike the trigger* methods below, this pans first so the waterway lands at // the container centre — which is where the popup is anchored. // // MapContainer can replay a queued chokepoint deep-link during the deferred // renderer window — after `new DeckGLMap()` but before initMapLibre() has // constructed maplibreMap, when setCenter() silently no-ops. Defer the whole // pan+popup until the renderer is ready instead of dropping the pan. public openChokepoint(id: string): void { const waterway = STRATEGIC_WATERWAYS.find(w => w.id === id || w.chokepointId === id); if (!waterway) return; const reveal = () => { if (this.destroyed || !this.maplibreMap) return; this.setCenter(waterway.lat, waterway.lon, 5); const { x, y } = this.getContainerCenter(); this.popup.show({ type: 'waterway', data: waterway, x, y }); }; if (this.maplibreMap) reveal(); else void this.whenReady().then(reveal).catch(() => {}); } // 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 }); this.popup.loadConflictHistory(conflict); } } public triggerBaseClick(id: string): void { const base = this.serverBases.find(b => b.id === id) || getCachedMilitaryBases().find(b => b.id === id); if (base) { const screenPos = this.projectToScreen(base.lat, base.lon); const { x, y } = screenPos || this.getContainerCenter(); this.popup.show({ type: 'base', data: base, x, y }); } else { this.requestBaseConfigRender(); } } 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 --- private shouldSuppressCountryClickAfterDrag(): boolean { return shouldSuppressCountryClick(this.countryClickGesture); } public setOnCountryClick(cb: (country: CountryClickPayload) => void): void { this.onCountryClick = cb; } public setOnMapContextMenu(cb: (payload: { lat: number; lon: number; screenX: number; screenY: number; countryCode?: string; countryName?: string }) => void): void { this.onMapContextMenu = 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 { if (!this.maplibreMap.getLayer('country-interactive')) return null; 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; if (this.maplibreMap.getSource('country-boundaries')) return; this.countriesGeoJsonData = geojson; this.conflictZoneGeoJson = null; this.conflictZoneBounded = null; this.conflictZoneContentKey = null; this.countriesBounded = null; this.culledCountriesGeoJson = null; this.culledCountriesContentKey = ''; 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, }, }); if (!this.chrome) { this.maplibreMap.addLayer({ id: 'country-embed-outline', type: 'line', source: 'country-boundaries', paint: { 'line-color': getCurrentTheme() === 'light' ? '#334155' : '#94a3b8', 'line-width': 0.8, 'line-opacity': getCurrentTheme() === 'light' ? 0.26 : 0.32, }, }); } this.maplibreMap.addLayer({ id: 'country-hover-fill', type: 'fill', source: 'country-boundaries', paint: { 'fill-color': '#ffffff', 'fill-opacity': 0.05, }, filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); this.maplibreMap.addLayer({ id: 'country-hover-border', type: 'line', source: 'country-boundaries', paint: { 'line-color': '#ffffff', 'line-width': 1.5, 'line-opacity': 0.22, }, filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); 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(); const paintProvider = getMapProvider(); const paintMapTheme = getMapTheme(paintProvider); this.updateCountryLayerPaint(isLightMapTheme(paintMapTheme) ? 'light' : 'dark'); if (this.highlightedCountryCode) this.highlightCountry(this.highlightedCountryCode); this.render(); }) .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 hoveredIso2: string | null = null; const clearHover = () => { this.hoveredCountryIso2 = null; this.hoveredCountryName = null; map.getCanvas().style.cursor = ''; if (!map.getLayer('country-hover-fill')) return; const noMatch = ['==', ['get', 'ISO3166-1-Alpha-2'], ''] as maplibregl.FilterSpecification; map.setFilter('country-hover-fill', noMatch); map.setFilter('country-hover-border', noMatch); }; // rAF-throttle the per-mousemove feature query (#5042 U3). The canvas is the // dominant input-delay surface and queryRenderedFeatures is synchronous, so // coalesce to at most one query per frame; the latest pointer position wins. const runHoverQuery = (point: maplibregl.Point) => { if (!this.onCountryClick) return; try { if (!map.getLayer('country-interactive')) return; const features = map.queryRenderedFeatures(point, { layers: ['country-interactive'] }); const props = features?.[0]?.properties; const iso2 = props?.['ISO3166-1-Alpha-2'] as string | undefined; const name = props?.['name'] as string | undefined; if (iso2 && iso2 !== hoveredIso2) { hoveredIso2 = iso2; this.hoveredCountryIso2 = iso2; this.hoveredCountryName = name ?? null; const filter = ['==', ['get', 'ISO3166-1-Alpha-2'], iso2] as maplibregl.FilterSpecification; map.setFilter('country-hover-fill', filter); map.setFilter('country-hover-border', filter); map.getCanvas().style.cursor = 'pointer'; } else if (!iso2 && hoveredIso2) { hoveredIso2 = null; clearHover(); } } catch { /* style not done loading during theme switch */ } }; const hoverQueryThrottle = createCountryHoverQueryController( rafSchedule, runHoverQuery, ); this.hoverQueryThrottle = hoverQueryThrottle; map.on('mousemove', (e) => { if (!this.onCountryClick) { hoverQueryThrottle.cancel(); return; } hoverQueryThrottle.queue(e.point); }); map.on('mouseout', () => { // Cancel a queued query so a deferred rAF cannot re-highlight a country the // pointer has already left (R8); clearHover stays immediate. hoverQueryThrottle.cancel(); if (hoveredIso2) { hoveredIso2 = null; try { clearHover(); } catch { /* style not done loading */ } } }); } private countryPulseRaf: number | null = null; private hoverQueryThrottle: CountryHoverQueryController | null = null; private getHighlightRestOpacity(): { fill: number; border: number } { const theme = isLightMapTheme(getMapTheme(getMapProvider())) ? 'light' : 'dark'; return { fill: theme === 'light' ? 0.18 : 0.12, border: 0.5 }; } public highlightCountry(code: string): void { this.highlightedCountryCode = code; if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; try { if (!this.maplibreMap.getLayer('country-highlight-fill')) return; const filter = ['==', ['get', 'ISO3166-1-Alpha-2'], code] as maplibregl.FilterSpecification; this.maplibreMap.setFilter('country-highlight-fill', filter); this.maplibreMap.setFilter('country-highlight-border', filter); this.pulseCountryHighlight(); } catch { /* style not yet loaded */ } } public clearCountryHighlight(): void { this.highlightedCountryCode = null; if (this.countryPulseRaf) { cancelAnimationFrame(this.countryPulseRaf); this.countryPulseRaf = null; } if (!this.maplibreMap) return; try { if (!this.maplibreMap.getLayer('country-highlight-fill')) return; const rest = this.getHighlightRestOpacity(); const noMatch = ['==', ['get', 'ISO3166-1-Alpha-2'], ''] as maplibregl.FilterSpecification; this.maplibreMap.setFilter('country-highlight-fill', noMatch); this.maplibreMap.setFilter('country-highlight-border', noMatch); this.maplibreMap.setPaintProperty('country-highlight-fill', 'fill-opacity', rest.fill); this.maplibreMap.setPaintProperty('country-highlight-border', 'line-opacity', rest.border); } catch { /* style unloaded or map torn down between panel close and highlight clear */ } } private pulseCountryHighlight(): void { if (this.countryPulseRaf) { cancelAnimationFrame(this.countryPulseRaf); this.countryPulseRaf = null; } const map = this.maplibreMap; if (!map) return; const rest = this.getHighlightRestOpacity(); const start = performance.now(); const duration = 3000; const step = (now: number) => { try { if (!map.getLayer('country-highlight-fill')) { this.countryPulseRaf = null; return; } } catch { this.countryPulseRaf = null; return; } const t = (now - start) / duration; if (t >= 1) { this.countryPulseRaf = null; map.setPaintProperty('country-highlight-fill', 'fill-opacity', rest.fill); map.setPaintProperty('country-highlight-border', 'line-opacity', rest.border); return; } const pulse = Math.sin(t * Math.PI * 3) ** 2; const fade = 1 - t * t; const fillOp = rest.fill + 0.25 * pulse * fade; const borderOp = rest.border + 0.5 * pulse * fade; map.setPaintProperty('country-highlight-fill', 'fill-opacity', fillOp); map.setPaintProperty('country-highlight-border', 'line-opacity', borderOp); this.countryPulseRaf = requestAnimationFrame(step); }; this.countryPulseRaf = requestAnimationFrame(step); } private async switchBasemap(): Promise { const map = this.maplibreMap; if (!map) return; const seq = ++this.basemapSwitchSeq; const provider = getMapProvider(); const mapTheme = getMapTheme(provider); const style = isHappyVariant ? (getCurrentTheme() === 'light' ? HAPPY_LIGHT_STYLE : HAPPY_DARK_STYLE) : (this.usedFallbackStyle && provider === 'auto') ? (isLightMapTheme(mapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE) : await getStyleForProvider(provider, mapTheme); if (this.maplibreMap !== map || seq !== this.basemapSwitchSeq) return; if (this.countryPulseRaf) { cancelAnimationFrame(this.countryPulseRaf); this.countryPulseRaf = null; } this.countryGeoJsonLoaded = false; map.setStyle(style, { diff: false }); map.once('style.load', () => { localizeMapLabels(this.maplibreMap); this.loadCountryBoundaries(); if (this.radarActive) this.applyRadarLayer(); const paintTheme = isLightMapTheme(mapTheme) ? 'light' as const : 'dark' as const; this.updateCountryLayerPaint(paintTheme); this.render(); }); if (!isHappyVariant && provider !== 'openfreemap' && !this.usedFallbackStyle) { this.monitorTileLoading(mapTheme); } } private monitorTileLoading(mapTheme: string): void { if (!this.maplibreMap) return; const gen = ++this.tileMonitorGeneration; let ok = false; let errCount = 0; let timeoutId: ReturnType | null = null; const map = this.maplibreMap; const cleanup = () => { map.off('error', onError); map.off('data', onData); if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } }; const onError = (e: { error?: Error; message?: string }) => { if (gen !== this.tileMonitorGeneration) { cleanup(); return; } const msg = e.error?.message ?? e.message ?? ''; if (msg.includes('Failed to fetch') || msg.includes('AJAXError') || msg.includes('CORS') || msg.includes('NetworkError') || msg.includes('403') || msg.includes('Forbidden')) { errCount++; if (!ok && errCount >= 2) { cleanup(); this.switchToFallbackStyle(mapTheme); } } }; const onData = (e: { dataType?: string }) => { if (gen !== this.tileMonitorGeneration) { cleanup(); return; } if (e.dataType === 'source') { ok = true; cleanup(); } }; map.on('error', onError); map.on('data', onData); timeoutId = setTimeout(() => { timeoutId = null; if (gen !== this.tileMonitorGeneration) return; cleanup(); if (!ok) this.switchToFallbackStyle(mapTheme); }, 10000); } private switchToFallbackStyle(mapTheme: string): void { if (this.usedFallbackStyle || !this.maplibreMap) return; this.usedFallbackStyle = true; const fallback = isLightMapTheme(mapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE; console.warn(`[DeckGLMap] Basemap tiles failed, falling back to OpenFreeMap: ${fallback}`); if (this.countryPulseRaf) { cancelAnimationFrame(this.countryPulseRaf); this.countryPulseRaf = null; } this.countryGeoJsonLoaded = false; this.maplibreMap.setStyle(fallback, { diff: false }); this.maplibreMap.once('style.load', () => { localizeMapLabels(this.maplibreMap); this.loadCountryBoundaries(); if (this.radarActive) this.applyRadarLayer(); const paintTheme = isLightMapTheme(mapTheme) ? 'light' as const : 'dark' as const; this.updateCountryLayerPaint(paintTheme); this.render(); }); } public reloadBasemap(): void { this.basemapSwitchSeq++; if (!this.maplibreMap) return; this.usedFallbackStyle = false; void this.switchBasemap(); } private updateCountryLayerPaint(theme: 'dark' | 'light'): void { if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; if (!this.maplibreMap.style || !this.maplibreMap.getLayer('country-hover-fill')) return; const hoverFillOpacity = theme === 'light' ? 0.08 : 0.05; const hoverBorderOpacity = theme === 'light' ? 0.35 : 0.22; const highlightOpacity = theme === 'light' ? 0.18 : 0.12; this.maplibreMap.setPaintProperty('country-hover-fill', 'fill-opacity', hoverFillOpacity); this.maplibreMap.setPaintProperty('country-hover-border', 'line-opacity', hoverBorderOpacity); this.maplibreMap.setPaintProperty('country-highlight-fill', 'fill-opacity', highlightOpacity); if (this.maplibreMap.getLayer('country-embed-outline')) { this.maplibreMap.setPaintProperty('country-embed-outline', 'line-color', theme === 'light' ? '#334155' : '#94a3b8'); this.maplibreMap.setPaintProperty('country-embed-outline', 'line-opacity', theme === 'light' ? 0.26 : 0.32); } } public destroy(): void { this.destroyed = true; this.stopTradeAnimation(); this.activeFlightTrails.clear(); this.clearTrailsBtn = null; this._unsubscribeAuthState?.(); this._unsubscribeAuthState = null; this._unsubscribeEntitlement?.(); this._unsubscribeEntitlement = null; window.removeEventListener('theme-changed', this.handleThemeChange); window.removeEventListener('map-theme-changed', this.handleMapThemeChange); this.tradeReducedMotionMedia?.removeEventListener('change', this.handleTradeMotionPreferenceChange); this.tradeReducedMotionMedia = null; this.debouncedRebuildLayers.cancel(); this.debouncedFetchBases.cancel(); this.debouncedFetchAircraft.cancel(); this.rafUpdateLayers.cancel(); this.hoverQueryThrottle?.cancel(); this.heavyGate.cancel(); if (this.renderRafId !== null) { cancelAnimationFrame(this.renderRafId); this.renderRafId = null; } if (this.countryPulseRaf !== null) { cancelAnimationFrame(this.countryPulseRaf); this.countryPulseRaf = null; } if (this.moveTimeoutId) { clearTimeout(this.moveTimeoutId); this.moveTimeoutId = null; } if (this.styleLoadTimeoutId) { clearTimeout(this.styleLoadTimeoutId); this.styleLoadTimeoutId = null; } this.stopPulseAnimation(); this.stopDayNightTimer(); this.stopWeatherRadar(); if (this.aircraftFetchTimer) { clearInterval(this.aircraftFetchTimer); this.aircraftFetchTimer = null; } this.stopLiveTankersLoop(); this.layerCache.clear(); this.deckOverlay?.finalize(); this.deckOverlay = null; this.detachMapLibreInteractionHandlers(); this.maplibreMap?.remove(); this.maplibreMap = null; setTrustedHtml(this.container, trustedHtml('', "legacy direct innerHTML migration")); } }