mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-01 00:17:29 +02:00
Cut fast-tier payload cost with zoom-aware sampling, row deltas, CCTV bbox columns, and MapLibre/motion polish; force viewport snapshot refetches so regional pans refill aircraft immediately. Co-authored-by: Cursor <cursoragent@cursor.com>
6642 lines
265 KiB
TypeScript
6642 lines
265 KiB
TypeScript
'use client';
|
||
|
||
import { API_BASE } from '@/lib/api';
|
||
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||
import Map, {
|
||
Source,
|
||
Layer,
|
||
MapRef,
|
||
ViewState,
|
||
Popup,
|
||
Marker,
|
||
MapLayerMouseEvent,
|
||
AttributionControl,
|
||
} from 'react-map-gl/maplibre';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
import { computeNightPolygon } from '@/utils/solarTerminator';
|
||
import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles';
|
||
import maplibregl from 'maplibre-gl';
|
||
import { AlertTriangle, Radio, Activity, Play, Satellite, ExternalLink, Info } from 'lucide-react';
|
||
import WikiImage from '@/components/WikiImage';
|
||
import FishingDestinationRoute from '@/components/map/FishingDestinationRoute';
|
||
import { useTheme } from '@/lib/ThemeContext';
|
||
import { PIN_CATEGORY_LABELS, PIN_CATEGORY_COLORS, type PinCategory } from '@/types/aiIntel';
|
||
import { getAllPinIcons } from '@/components/map/pinIcons';
|
||
import { AIIntelPinDetail } from '@/components/map/AIIntelPinDetail';
|
||
|
||
import {
|
||
svgPlaneCyan,
|
||
svgPlaneYellow,
|
||
svgPlaneOrange,
|
||
svgPlanePurple,
|
||
svgFighter,
|
||
svgHeli,
|
||
svgHeliCyan,
|
||
svgHeliDimCyan,
|
||
svgHeliOrange,
|
||
svgHeliPurple,
|
||
svgHeliSlate,
|
||
svgHeliAmber,
|
||
svgTanker,
|
||
svgRecon,
|
||
svgPlanePink,
|
||
svgPlaneAlertRed,
|
||
svgPlaneDarkBlue,
|
||
svgPlaneWhiteAlert,
|
||
svgHeliPink,
|
||
svgHeliAlertRed,
|
||
svgHeliDarkBlue,
|
||
svgHeliBlue,
|
||
svgHeliLime,
|
||
svgHeliWhiteAlert,
|
||
svgPlaneBlack,
|
||
svgHeliBlack,
|
||
svgDrone,
|
||
svgDataCenter,
|
||
svgPowerPlant,
|
||
svgRadioTower,
|
||
svgShipGray,
|
||
svgShipRed,
|
||
svgShipYellow,
|
||
svgShipBlue,
|
||
svgShipWhite,
|
||
svgShipPink,
|
||
svgShipGreyBlue,
|
||
svgShipAmber,
|
||
svgCarrier,
|
||
svgCctv,
|
||
svgSatDish,
|
||
svgLoRaSat,
|
||
svgScannerTower,
|
||
svgWarning,
|
||
svgThreat,
|
||
svgTriangleYellow,
|
||
svgTriangleRed,
|
||
svgTrianglePink,
|
||
svgTriangleGreen,
|
||
svgFireYellow,
|
||
svgFireOrange,
|
||
svgFireRed,
|
||
svgFireDarkRed,
|
||
svgFireClusterSmall,
|
||
svgFireClusterMed,
|
||
svgFireClusterLarge,
|
||
svgFireClusterXL,
|
||
svgPotusPlane,
|
||
svgPotusHeli,
|
||
svgAirlinerCyan,
|
||
svgAirlinerDimCyan,
|
||
svgAirlinerOrange,
|
||
svgAirlinerPurple,
|
||
svgAirlinerSlate,
|
||
svgAirlinerYellow,
|
||
svgAirlinerAmber,
|
||
svgAirlinerPink,
|
||
svgAirlinerRed,
|
||
svgAirlinerDarkBlue,
|
||
svgAirlinerBlue,
|
||
svgAirlinerLime,
|
||
svgAirlinerBlack,
|
||
svgAirlinerWhite,
|
||
svgTurbopropCyan,
|
||
svgTurbopropDimCyan,
|
||
svgTurbopropOrange,
|
||
svgTurbopropPurple,
|
||
svgTurbopropSlate,
|
||
svgTurbopropYellow,
|
||
svgTurbopropAmber,
|
||
svgTurbopropPink,
|
||
svgTurbopropRed,
|
||
svgTurbopropDarkBlue,
|
||
svgTurbopropBlue,
|
||
svgTurbopropLime,
|
||
svgTurbopropBlack,
|
||
svgTurbopropWhite,
|
||
svgBizjetCyan,
|
||
svgBizjetDimCyan,
|
||
svgBizjetOrange,
|
||
svgBizjetPurple,
|
||
svgBizjetSlate,
|
||
svgBizjetYellow,
|
||
svgBizjetAmber,
|
||
svgBizjetPink,
|
||
svgBizjetRed,
|
||
svgBizjetDarkBlue,
|
||
svgBizjetBlue,
|
||
svgBizjetLime,
|
||
svgBizjetBlack,
|
||
svgBizjetWhite,
|
||
svgAirlinerGrey,
|
||
svgTurbopropGrey,
|
||
svgBizjetGrey,
|
||
svgHeliGrey,
|
||
GROUNDED_ICON_MAP,
|
||
COLOR_MAP_COMMERCIAL,
|
||
COLOR_MAP_PRIVATE,
|
||
COLOR_MAP_JETS,
|
||
COLOR_MAP_MILITARY,
|
||
MIL_SPECIAL_MAP,
|
||
makeMilBaseSvg,
|
||
makeMilBaseCircleSvg,
|
||
MILBASE_ICON_SPECS,
|
||
makeVolcanoSvg,
|
||
VOLCANO_ICON_SPECS,
|
||
WEATHER_ICON_SPECS,
|
||
CT_ICON_SPECS,
|
||
} from '@/components/map/icons/AircraftIcons';
|
||
import { makeSatSvg, makeISSSvg, makeTrainSvg } from '@/components/map/icons/SatelliteIcons';
|
||
import { makeUfoSvg, makeUfoClusterSvg, makeWaterDropSvg, makeWaterDropClusterSvg } from '@/components/map/icons/OverlayIcons';
|
||
import { EMPTY_FC } from '@/components/map/mapConstants';
|
||
import { useImperativeSource } from '@/components/map/hooks/useImperativeSource';
|
||
import { useDynamicMapLayersWorker } from '@/components/map/hooks/useDynamicMapLayersWorker';
|
||
import { useStaticMapLayersWorker } from '@/components/map/hooks/useStaticMapLayersWorker';
|
||
import { applyDynamicLayerInterp } from '@/components/map/applyDynamicLayerInterp';
|
||
import {
|
||
ClusterCountLabels,
|
||
TrackedFlightLabels,
|
||
CarrierLabels,
|
||
TrackedYachtLabels,
|
||
UavLabels,
|
||
EarthquakeLabels,
|
||
ThreatMarkers,
|
||
TelegramOsintMarkers,
|
||
} from '@/components/map/MapMarkers';
|
||
import type { DashboardData, Flight, KiwiSDR, MaplibreViewerProps, Scanner, Ship, SigintSignal } from '@/types/dashboard';
|
||
import { useDataKeys } from '@/hooks/useDataStore';
|
||
import { useInterpolation } from '@/components/map/hooks/useInterpolation';
|
||
import { useClusterLabels } from '@/components/map/hooks/useClusterLabels';
|
||
import { spreadAlertItems } from '@/utils/alertSpread';
|
||
import {
|
||
applyTelegramAlertAvoidance,
|
||
telegramClusterKey,
|
||
telegramClusterNearNewsAlert,
|
||
telegramMapPinCoords,
|
||
} from '@/components/map/geoJSONBuilders';
|
||
|
||
import { useViewportBounds } from '@/components/map/hooks/useViewportBounds';
|
||
import { getLiveDataBounds } from '@/lib/liveDataViewport';
|
||
import { MeasurementLayers } from '@/components/map/layers/MeasurementLayers';
|
||
import { buildCctvProxyUrl } from '@/lib/cctvProxy';
|
||
import { sanitizeSubmarineCables } from '@/lib/submarineCables';
|
||
import { CctvFullscreenModal } from '@/components/MaplibreViewer/CctvFullscreenModal';
|
||
import { SatellitePopup } from '@/components/MaplibreViewer/popups/SatellitePopup';
|
||
import { ShipPopup } from '@/components/MaplibreViewer/popups/ShipPopup';
|
||
import { SigintPopup } from '@/components/MaplibreViewer/popups/SigintPopup';
|
||
import { CorrelationPopup } from '@/components/MaplibreViewer/popups/CorrelationPopup';
|
||
import { WastewaterPopup } from '@/components/MaplibreViewer/popups/WastewaterPopup';
|
||
import { MilitaryBasePopup } from '@/components/MaplibreViewer/popups/MilitaryBasePopup';
|
||
import { RegionDossierPanel } from '@/components/MaplibreViewer/popups/RegionDossierPanel';
|
||
import { GtRiskPopup } from '@/components/MaplibreViewer/popups/GtRiskPopup';
|
||
import { TelegramOsintPopup } from '@/components/MaplibreViewer/popups/TelegramOsintPopup';
|
||
import {
|
||
buildSentinelTileUrl,
|
||
hasSentinelCredentials,
|
||
getSentinelToken,
|
||
registerSentinelProtocol,
|
||
} from '@/lib/sentinelHub';
|
||
import {
|
||
buildEarthquakesGeoJSON,
|
||
buildJammingGeoJSON,
|
||
buildCorrelationsGeoJSON,
|
||
buildGtRiskGeoJSON,
|
||
buildTinygsGeoJSON,
|
||
buildShodanGeoJSON,
|
||
buildAIIntelGeoJSON,
|
||
type AIIntelPinData,
|
||
buildFrontlineGeoJSON,
|
||
buildUavGeoJSON,
|
||
buildSatellitesGeoJSON,
|
||
buildCarriersGeoJSON,
|
||
findSelectedEntity,
|
||
buildPredictiveGeoJSON,
|
||
buildProximityRingsGeoJSON,
|
||
buildUkraineAlertsGeoJSON,
|
||
buildUkraineAlertLabelsGeoJSON,
|
||
buildWeatherAlertsGeoJSON,
|
||
buildWeatherAlertLabelsGeoJSON,
|
||
buildSarAnomaliesGeoJSON,
|
||
buildSarAoisGeoJSON,
|
||
type FlightLayerConfig,
|
||
} from '@/components/map/geoJSONBuilders';
|
||
|
||
type ViewBounds = { south: number; west: number; north: number; east: number };
|
||
|
||
type DynamicRoute = {
|
||
orig_loc?: [number, number];
|
||
dest_loc?: [number, number];
|
||
origin_name?: string;
|
||
dest_name?: string;
|
||
};
|
||
|
||
type GeoExtras = {
|
||
lat?: number;
|
||
lng?: number;
|
||
lon?: number;
|
||
geometry?: { coordinates?: [number, number] };
|
||
};
|
||
|
||
type KiwiProps = Partial<KiwiSDR> & GeoExtras;
|
||
type ScannerProps = Partial<Scanner> & GeoExtras;
|
||
type SigintProps = Partial<SigintSignal> & GeoExtras;
|
||
type TrailPoint = { lng: number; lat: number; alt?: number; sog?: number; ts?: number };
|
||
type TrailKind = 'flight' | 'ship';
|
||
|
||
const FLIGHT_SELECTION_TYPES = new Set([
|
||
'flight',
|
||
'private_flight',
|
||
'military_flight',
|
||
'private_jet',
|
||
'tracked_flight',
|
||
]);
|
||
|
||
function parseTrailPoints(raw: unknown, kind: TrailKind): TrailPoint[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw
|
||
.map((p): TrailPoint | null => {
|
||
if (Array.isArray(p)) {
|
||
const lat = Number(p[0]);
|
||
const lng = Number(p[1]);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||
if (kind === 'ship') {
|
||
return { lat, lng, sog: Number(p[2]) || 0, ts: Number(p[3]) || 0 };
|
||
}
|
||
return { lat, lng, alt: Number(p[2]) || 0, ts: Number(p[3]) || 0 };
|
||
}
|
||
if (p && typeof p === 'object') {
|
||
const point = p as { lat?: number; lng?: number; alt?: number; sog?: number; ts?: number };
|
||
const lat = Number(point.lat);
|
||
const lng = Number(point.lng);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||
return {
|
||
lat,
|
||
lng,
|
||
alt: Number(point.alt) || 0,
|
||
sog: Number(point.sog) || 0,
|
||
ts: Number(point.ts) || 0,
|
||
};
|
||
}
|
||
return null;
|
||
})
|
||
.filter((p): p is TrailPoint => Boolean(p && (p.lat !== 0 || p.lng !== 0)));
|
||
}
|
||
|
||
function hasKnownRouteName(value?: string | null): boolean {
|
||
const normalized = String(value || '').trim().toUpperCase();
|
||
return Boolean(normalized && normalized !== 'UNKNOWN');
|
||
}
|
||
|
||
function flightHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>, dynamicRoute: DynamicRoute | null): boolean {
|
||
if (!entity) return false;
|
||
if (dynamicRoute?.orig_loc && dynamicRoute?.dest_loc) return true;
|
||
return flightPayloadHasKnownRoute(entity);
|
||
}
|
||
|
||
function flightPayloadHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>): boolean {
|
||
if (!entity) return false;
|
||
if (!('origin_loc' in entity) && !('origin_name' in entity)) return false;
|
||
const flight = entity as Flight;
|
||
return Boolean(
|
||
(flight.origin_loc && flight.dest_loc)
|
||
|| (hasKnownRouteName(flight.origin_name) && hasKnownRouteName(flight.dest_name)),
|
||
);
|
||
}
|
||
|
||
const MAP_EXTRA_DATA_KEYS = [
|
||
'air_quality',
|
||
'cctv',
|
||
'correlations',
|
||
'crowdthreat',
|
||
'malware_threats',
|
||
'telegram_osint',
|
||
'gt_risk',
|
||
'datacenters',
|
||
'firms_fires',
|
||
'fishing_activity',
|
||
'frontlines',
|
||
'gps_jamming',
|
||
'internet_outages',
|
||
'kiwisdr',
|
||
'military_bases',
|
||
'power_plants',
|
||
'psk_reporter',
|
||
'sar_anomalies',
|
||
'satellite_analysis',
|
||
'satellites',
|
||
'satnogs_stations',
|
||
'scanners',
|
||
'sigint',
|
||
'tinygs_satellites',
|
||
'trains',
|
||
'uap_sightings',
|
||
'ukraine_alerts',
|
||
'viirs_change_nodes',
|
||
'volcanoes',
|
||
'wastewater',
|
||
'weather_alerts',
|
||
] as const satisfies readonly (keyof DashboardData)[];
|
||
|
||
const VIIRS_TILE_TEMPLATES = [
|
||
// The older daily Day/Night Band path now 404s in GIBS. Black Marble is the
|
||
// current stable night-lights product and has a best-available endpoint.
|
||
'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png',
|
||
'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png',
|
||
];
|
||
|
||
function buildProbeRasterUrl(tileTemplate: string): string {
|
||
return tileTemplate
|
||
.replace('{z}', '0')
|
||
.replace('{y}', '0')
|
||
.replace('{x}', '0');
|
||
}
|
||
|
||
function probeRasterTile(url: string): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
let settled = false;
|
||
const finish = (ok: boolean) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
img.onload = null;
|
||
img.onerror = null;
|
||
resolve(ok);
|
||
};
|
||
img.onload = () => finish(true);
|
||
img.onerror = () => finish(false);
|
||
img.referrerPolicy = 'no-referrer';
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
function buildPolymarketUrl(prediction: { slug?: string; title?: string } | null | undefined): string {
|
||
const slug = String(prediction?.slug || '').trim();
|
||
if (slug) return `https://polymarket.com/event/${encodeURIComponent(slug)}`;
|
||
const title = String(prediction?.title || '').trim();
|
||
return title
|
||
? `https://polymarket.com/search?query=${encodeURIComponent(title)}`
|
||
: 'https://polymarket.com/markets';
|
||
}
|
||
|
||
const MaplibreViewer = ({
|
||
activeLayers,
|
||
activeFilters,
|
||
onEntityClick,
|
||
flyToLocation,
|
||
selectedEntity,
|
||
onMouseCoords,
|
||
onRightClick,
|
||
regionDossier,
|
||
regionDossierLoading,
|
||
onViewStateChange,
|
||
measureMode,
|
||
onMeasureClick,
|
||
measurePoints,
|
||
gibsDate,
|
||
gibsOpacity,
|
||
sentinelDate,
|
||
sentinelOpacity,
|
||
sentinelPreset,
|
||
viewBoundsRef,
|
||
setTrackedSdr,
|
||
setTrackedScanner,
|
||
shodanResults,
|
||
shodanStyle,
|
||
pinPlacementMode,
|
||
onPinPlaced,
|
||
sarAoiDropMode,
|
||
onSarAoiDropped,
|
||
sarAoiListVersion,
|
||
}: Omit<MaplibreViewerProps, 'data'>) => {
|
||
const coreData = useDataKeys([
|
||
'tracked_flights',
|
||
'commercial_flights',
|
||
'military_flights',
|
||
'private_flights',
|
||
'private_jets',
|
||
'news',
|
||
'ships',
|
||
'uavs',
|
||
'earthquakes',
|
||
'gdelt',
|
||
'liveuamap',
|
||
]);
|
||
const extraData = useDataKeys(MAP_EXTRA_DATA_KEYS);
|
||
const data = useMemo(() => ({ ...coreData, ...extraData }) as DashboardData, [coreData, extraData]);
|
||
const mapRef = useRef<MapRef>(null);
|
||
const mapInitRef = useRef(false);
|
||
const [mapReady, setMapReady] = useState(false);
|
||
const { theme } = useTheme();
|
||
const mapThemeStyle = useMemo<maplibregl.StyleSpecification>(
|
||
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
|
||
[theme],
|
||
);
|
||
|
||
const initialViewState = useMemo<ViewState>(
|
||
() => ({
|
||
longitude: 0,
|
||
latitude: 20,
|
||
zoom: 2,
|
||
bearing: 0,
|
||
pitch: 0,
|
||
padding: { top: 0, bottom: 0, left: 0, right: 0 },
|
||
}),
|
||
[],
|
||
);
|
||
const viewStateRef = useRef<ViewState>(initialViewState);
|
||
const [mapZoom, setMapZoom] = useState(initialViewState.zoom);
|
||
const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(new Set());
|
||
const [viirsResolvedTileTemplate, setViirsResolvedTileTemplate] = useState<string | null>(null);
|
||
const [isMapInteracting, setIsMapInteracting] = useState(false);
|
||
|
||
// Pin placement state
|
||
const [pendingPin, setPendingPin] = useState<{
|
||
lat: number;
|
||
lng: number;
|
||
entity: { entity_type: string; entity_id: string; entity_label: string } | null;
|
||
} | null>(null);
|
||
const [pinLabel, setPinLabel] = useState('');
|
||
const [pinNotes, setPinNotes] = useState('');
|
||
const [pinCategory, setPinCategory] = useState<PinCategory>('custom');
|
||
const [pinSaving, setPinSaving] = useState(false);
|
||
const [aiIntelPins, setAiIntelPins] = useState<AIIntelPinData[]>([]);
|
||
const [aiIntelRefreshTick, setAiIntelRefreshTick] = useState(0);
|
||
// Currently-open AI Intel pin detail popup (pin id)
|
||
const [openPinDetailId, setOpenPinDetailId] = useState<string | null>(null);
|
||
const pinLabelInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
// Force focus to the label input whenever the pin dialog opens — the
|
||
// maplibre canvas otherwise keeps focus and global hotkeys eat keystrokes.
|
||
useEffect(() => {
|
||
if (!pendingPin) return;
|
||
const t = setTimeout(() => pinLabelInputRef.current?.focus(), 50);
|
||
return () => clearTimeout(t);
|
||
}, [pendingPin]);
|
||
|
||
const handleSavePin = useCallback(async () => {
|
||
if (!pendingPin || !pinLabel.trim()) return;
|
||
setPinSaving(true);
|
||
try {
|
||
const body: Record<string, unknown> = {
|
||
lat: pendingPin.lat,
|
||
lng: pendingPin.lng,
|
||
label: pinLabel.trim(),
|
||
description: pinNotes.trim(),
|
||
source: 'user',
|
||
category: pinCategory,
|
||
};
|
||
if (pendingPin.entity) {
|
||
body.entity_attachment = pendingPin.entity;
|
||
}
|
||
await fetch(`${API_BASE}/api/ai/pins`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
setPendingPin(null);
|
||
setPinLabel('');
|
||
setPinNotes('');
|
||
setPinCategory('custom');
|
||
setAiIntelRefreshTick((t) => t + 1);
|
||
onPinPlaced?.();
|
||
} catch (err) {
|
||
console.error('Failed to save pin:', err);
|
||
}
|
||
setPinSaving(false);
|
||
}, [pendingPin, pinLabel, pinNotes, pinCategory, onPinPlaced]);
|
||
|
||
const showImageryReferenceOverlay =
|
||
activeLayers.highres_satellite ||
|
||
activeLayers.gibs_imagery ||
|
||
activeLayers.viirs_nightlights ||
|
||
activeLayers.sentinel_hub;
|
||
const imageryReferenceOverlayOpacity = activeLayers.viirs_nightlights ? 1 : 0.9;
|
||
const backendViewportSyncEnabled =
|
||
activeLayers.ships_military ||
|
||
activeLayers.ships_cargo ||
|
||
activeLayers.ships_civilian ||
|
||
activeLayers.ships_passenger ||
|
||
activeLayers.ships_tracked_yachts;
|
||
|
||
const { mapBounds, inView, updateBounds } = useViewportBounds(
|
||
mapRef,
|
||
viewBoundsRef as React.MutableRefObject<ViewBounds | null> | undefined,
|
||
backendViewportSyncEnabled,
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (backendViewportSyncEnabled) {
|
||
updateBounds();
|
||
}
|
||
}, [backendViewportSyncEnabled, updateBounds]);
|
||
|
||
const viirsProbeDayKey = new Date().toISOString().slice(0, 10);
|
||
useEffect(() => {
|
||
if (!activeLayers.viirs_nightlights) {
|
||
setViirsResolvedTileTemplate(null);
|
||
return undefined;
|
||
}
|
||
let cancelled = false;
|
||
|
||
const resolveViirsDate = async () => {
|
||
for (const tileTemplate of VIIRS_TILE_TEMPLATES) {
|
||
const ok = await probeRasterTile(buildProbeRasterUrl(tileTemplate));
|
||
if (cancelled) return;
|
||
if (ok) {
|
||
setViirsResolvedTileTemplate(tileTemplate);
|
||
return;
|
||
}
|
||
}
|
||
if (!cancelled) {
|
||
setViirsResolvedTileTemplate(VIIRS_TILE_TEMPLATES[0] ?? null);
|
||
}
|
||
};
|
||
|
||
void resolveViirsDate();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [activeLayers.viirs_nightlights, viirsProbeDayKey]);
|
||
|
||
const [dynamicRoute, setDynamicRoute] = useState<DynamicRoute | null>(null);
|
||
const [selectedTrailPoints, setSelectedTrailPoints] = useState<TrailPoint[]>([]);
|
||
const prevCallsign = useRef<string | null>(null);
|
||
|
||
// Oracle region intel for map entity popups
|
||
const [oracleIntel, setOracleIntel] = useState<{
|
||
found: boolean;
|
||
top_headline?: string;
|
||
oracle_score?: number;
|
||
tier?: string;
|
||
avg_sentiment?: number;
|
||
nearby_count?: number;
|
||
market?: { title: string; consensus_pct: number | null } | null;
|
||
} | null>(null);
|
||
|
||
// Global Incidents popup: dismiss no longer permanently removes alerts.
|
||
// Clicking × just deselects, allowing re-opening from the right-panel feed.
|
||
|
||
// --- Smooth interpolation via extracted hook ---
|
||
const {
|
||
interpFlight,
|
||
interpShip,
|
||
interpSat,
|
||
interpTick,
|
||
dtSeconds,
|
||
resetTimestamp,
|
||
} =
|
||
useInterpolation();
|
||
|
||
// Track when flight/ship/satellite data actually changes (new fetch arrived)
|
||
useEffect(() => {
|
||
resetTimestamp();
|
||
}, [
|
||
data?.commercial_flights,
|
||
data?.private_flights,
|
||
data?.military_flights,
|
||
data?.private_jets,
|
||
data?.tracked_flights,
|
||
data?.ships,
|
||
data?.satellites,
|
||
resetTimestamp,
|
||
]);
|
||
|
||
// --- Solar Terminator: recompute the night polygon every 60 seconds ---
|
||
const [nightGeoJSON, setNightGeoJSON] = useState<GeoJSON.FeatureCollection>(() =>
|
||
computeNightPolygon(),
|
||
);
|
||
useEffect(() => {
|
||
const timer = setInterval(() => setNightGeoJSON(computeNightPolygon()), 60000);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
let callsign = null;
|
||
let entityLat = 0;
|
||
let entityLng = 0;
|
||
if (selectedEntity && data) {
|
||
let entity = null;
|
||
if (selectedEntity.type === 'flight')
|
||
entity = data?.commercial_flights?.find((f) => f.icao24 === selectedEntity.id);
|
||
else if (selectedEntity.type === 'private_flight')
|
||
entity = data?.private_flights?.find((f) => f.icao24 === selectedEntity.id);
|
||
else if (selectedEntity.type === 'military_flight')
|
||
entity = data?.military_flights?.find((f) => f.icao24 === selectedEntity.id);
|
||
else if (selectedEntity.type === 'private_jet')
|
||
entity = data?.private_jets?.find((f) => f.icao24 === selectedEntity.id);
|
||
else if (selectedEntity.type === 'tracked_flight')
|
||
entity = data?.tracked_flights?.find((f) => f.icao24 === selectedEntity.id);
|
||
|
||
if (entity && entity.callsign) {
|
||
callsign = entity.callsign;
|
||
entityLat = entity.lat ?? 0;
|
||
entityLng = entity.lng ?? 0;
|
||
}
|
||
}
|
||
|
||
if (callsign && callsign !== prevCallsign.current) {
|
||
prevCallsign.current = callsign;
|
||
setDynamicRoute(null);
|
||
fetch(`${API_BASE}/api/route/${callsign}?lat=${entityLat}&lng=${entityLng}`)
|
||
.then((res) => res.json())
|
||
.then((routeData) => {
|
||
if (isMounted) setDynamicRoute(routeData);
|
||
})
|
||
.catch(() => {
|
||
if (isMounted) setDynamicRoute(null);
|
||
});
|
||
} else if (!callsign) {
|
||
prevCallsign.current = null;
|
||
if (isMounted) setDynamicRoute(null);
|
||
}
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}, [selectedEntity, data]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const entity = findSelectedEntity(selectedEntity, data);
|
||
if (!selectedEntity || !entity) {
|
||
setSelectedTrailPoints([]);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
const isFlight = FLIGHT_SELECTION_TYPES.has(selectedEntity.type);
|
||
const isShip = selectedEntity.type === 'ship';
|
||
if (!isFlight && !isShip) {
|
||
setSelectedTrailPoints([]);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
if (isFlight && flightPayloadHasKnownRoute(entity)) {
|
||
setSelectedTrailPoints([]);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
const kind: TrailKind = isShip ? 'ship' : 'flight';
|
||
const fallback = parseTrailPoints((entity as Flight | Ship).trail, kind);
|
||
if (fallback.length >= 2) {
|
||
setSelectedTrailPoints(fallback);
|
||
} else {
|
||
setSelectedTrailPoints([]);
|
||
}
|
||
|
||
const trailId = String(selectedEntity.id || '').trim();
|
||
if (!trailId) {
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
if (isShip && !/^\d+$/.test(trailId)) {
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
|
||
const endpoint = isShip
|
||
? `${API_BASE}/api/trail/ship/${encodeURIComponent(trailId)}`
|
||
: `${API_BASE}/api/trail/flight/${encodeURIComponent(trailId)}`;
|
||
const refreshSelectedTrail = () => {
|
||
fetch(endpoint, { cache: 'no-store' })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((payload) => {
|
||
if (cancelled || !payload) return;
|
||
const points = parseTrailPoints(payload.trail, kind);
|
||
setSelectedTrailPoints(points.length >= 2 ? points : fallback);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setSelectedTrailPoints(fallback);
|
||
});
|
||
};
|
||
refreshSelectedTrail();
|
||
const trailRefreshTimer = window.setInterval(refreshSelectedTrail, 30000);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(trailRefreshTimer);
|
||
};
|
||
}, [selectedEntity, data, dynamicRoute]);
|
||
|
||
// Fetch oracle region intel for entity popups
|
||
useEffect(() => {
|
||
if (!selectedEntity) {
|
||
setOracleIntel(null);
|
||
return;
|
||
}
|
||
const oracleTypes = ['military_base', 'liveuamap', 'gps_jamming', 'earthquake', 'conflict_zone'];
|
||
if (!oracleTypes.includes(selectedEntity.type)) {
|
||
setOracleIntel(null);
|
||
return;
|
||
}
|
||
const lat = selectedEntity.extra?.lat;
|
||
const lng = selectedEntity.extra?.lng;
|
||
if (lat == null || lng == null) {
|
||
setOracleIntel(null);
|
||
return;
|
||
}
|
||
let alive = true;
|
||
fetch(`${API_BASE}/api/oracle/region-intel?lat=${lat}&lng=${lng}`)
|
||
.then(r => r.json())
|
||
.then(d => { if (alive) setOracleIntel(d); })
|
||
.catch(() => { if (alive) setOracleIntel(null); });
|
||
return () => { alive = false; };
|
||
}, [selectedEntity]);
|
||
|
||
useEffect(() => {
|
||
if (flyToLocation && mapRef.current) {
|
||
mapRef.current.flyTo({
|
||
center: [flyToLocation.lng, flyToLocation.lat],
|
||
zoom: 8,
|
||
duration: 1500,
|
||
});
|
||
}
|
||
}, [flyToLocation]);
|
||
|
||
const earthquakesGeoJSON = useMemo(
|
||
() => (activeLayers.earthquakes ? buildEarthquakesGeoJSON(data?.earthquakes) : null),
|
||
[activeLayers.earthquakes, data?.earthquakes],
|
||
);
|
||
|
||
const jammingGeoJSON = useMemo(
|
||
() => (activeLayers.gps_jamming ? buildJammingGeoJSON(data?.gps_jamming) : null),
|
||
[activeLayers.gps_jamming, data?.gps_jamming],
|
||
);
|
||
|
||
const correlationsGeoJSON = useMemo(
|
||
() => {
|
||
if (!activeLayers.correlations && !activeLayers.contradictions) return null;
|
||
const alerts = data?.correlations?.filter((a) => {
|
||
if (a.type === 'contradiction') return activeLayers.contradictions;
|
||
return activeLayers.correlations;
|
||
});
|
||
return buildCorrelationsGeoJSON(alerts);
|
||
},
|
||
[activeLayers.correlations, activeLayers.contradictions, data?.correlations],
|
||
);
|
||
|
||
const gtRiskGeoJSON = useMemo(
|
||
() => (activeLayers.gt_risk ? buildGtRiskGeoJSON(data?.gt_risk) : null),
|
||
[activeLayers.gt_risk, data?.gt_risk],
|
||
);
|
||
|
||
const tinygsGeoJSON = useMemo(
|
||
() => {
|
||
void interpTick;
|
||
return activeLayers.tinygs ? buildTinygsGeoJSON(data?.tinygs_satellites, inView, interpSat) : null;
|
||
},
|
||
[activeLayers.tinygs, data?.tinygs_satellites, inView, interpSat, interpTick],
|
||
);
|
||
|
||
const shodanGeoJSON = useMemo(
|
||
() => (activeLayers.shodan_overlay ? buildShodanGeoJSON(shodanResults) : null),
|
||
[activeLayers.shodan_overlay, shodanResults],
|
||
);
|
||
|
||
// AI Intel layer — pins from OpenClaw and the AI co-pilot
|
||
useEffect(() => {
|
||
if (!activeLayers.ai_intel) return;
|
||
let cancelled = false;
|
||
const poll = async () => {
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/api/ai/pins/geojson`);
|
||
if (!resp.ok || cancelled) return;
|
||
const gj = await resp.json();
|
||
const pins = (gj.features || []).map((f: any) => ({
|
||
...f.properties,
|
||
lat: f.geometry?.coordinates?.[1],
|
||
lng: f.geometry?.coordinates?.[0],
|
||
}));
|
||
if (!cancelled) setAiIntelPins(pins);
|
||
} catch {}
|
||
};
|
||
poll();
|
||
const tid = setInterval(poll, 15_000); // poll every 15s
|
||
return () => { cancelled = true; clearInterval(tid); };
|
||
}, [activeLayers.ai_intel, aiIntelRefreshTick]);
|
||
const aiIntelGeoJSON = useMemo(
|
||
() => (activeLayers.ai_intel ? buildAIIntelGeoJSON(aiIntelPins, data) : null),
|
||
[activeLayers.ai_intel, aiIntelPins, data],
|
||
);
|
||
|
||
const ukraineAlertsGeoJSON = useMemo(
|
||
() => (activeLayers.ukraine_alerts ? buildUkraineAlertsGeoJSON(data?.ukraine_alerts) : null),
|
||
[activeLayers.ukraine_alerts, data?.ukraine_alerts],
|
||
);
|
||
|
||
const ukraineAlertLabelsGeoJSON = useMemo(
|
||
() => (activeLayers.ukraine_alerts ? buildUkraineAlertLabelsGeoJSON(data?.ukraine_alerts) : null),
|
||
[activeLayers.ukraine_alerts, data?.ukraine_alerts],
|
||
);
|
||
|
||
const weatherAlertsGeoJSON = useMemo(
|
||
() => (activeLayers.weather_alerts ? buildWeatherAlertsGeoJSON(data?.weather_alerts) : null),
|
||
[activeLayers.weather_alerts, data?.weather_alerts],
|
||
);
|
||
|
||
const weatherAlertLabelsGeoJSON = useMemo(
|
||
() => (activeLayers.weather_alerts ? buildWeatherAlertLabelsGeoJSON(data?.weather_alerts) : null),
|
||
[activeLayers.weather_alerts, data?.weather_alerts],
|
||
);
|
||
|
||
// Sentinel Hub — tile URL (only built when layer is active + credentials are set)
|
||
const sentinelTileUrl = useMemo(() => {
|
||
if (!activeLayers.sentinel_hub) return null;
|
||
if (!hasSentinelCredentials()) return null;
|
||
return buildSentinelTileUrl(sentinelPreset || 'TRUE-COLOR', sentinelDate || '');
|
||
}, [activeLayers.sentinel_hub, sentinelPreset, sentinelDate]);
|
||
|
||
// Register sentinel:// custom protocol for Process API tile fetching
|
||
useEffect(() => {
|
||
registerSentinelProtocol(maplibregl);
|
||
}, []);
|
||
|
||
// Pre-fetch Sentinel Hub token when layer is toggled on
|
||
useEffect(() => {
|
||
if (!activeLayers.sentinel_hub) return;
|
||
getSentinelToken().catch((err) => console.warn('Sentinel Hub token error:', err));
|
||
}, [activeLayers.sentinel_hub, sentinelPreset, sentinelDate]);
|
||
|
||
// Initialize images/sources as soon as the local style is available.
|
||
// Do not wait for remote basemap tiles to load, because blocked tile hosts
|
||
// would otherwise prevent the map "load" event from ever firing.
|
||
const initializeMap = useCallback((map: maplibregl.Map) => {
|
||
if (mapInitRef.current) return;
|
||
mapInitRef.current = true;
|
||
|
||
// Track which images are still loading so we can retry on styleimagemissing
|
||
const pendingImages: Record<string, string> = {};
|
||
|
||
const loadImg = (id: string, url: string) => {
|
||
if (!map.hasImage(id)) {
|
||
pendingImages[id] = url;
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.src = url;
|
||
img.onload = () => {
|
||
if (!map.hasImage(id)) map.addImage(id, img);
|
||
delete pendingImages[id];
|
||
};
|
||
}
|
||
};
|
||
|
||
// Suppress "image not found" warnings — retry when the async load finishes
|
||
map.on('styleimagemissing', (ev: maplibregl.MapStyleImageMissingEvent) => {
|
||
const id = ev.id;
|
||
const url = pendingImages[id];
|
||
if (url) {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.src = url;
|
||
img.onload = () => {
|
||
if (!map.hasImage(id)) map.addImage(id, img);
|
||
delete pendingImages[id];
|
||
};
|
||
}
|
||
});
|
||
|
||
// AI Intel teardrop pin icons — one per category color
|
||
for (const [id, url] of getAllPinIcons()) {
|
||
loadImg(id, url);
|
||
}
|
||
|
||
// Critical icons — needed immediately for default-on layers
|
||
loadImg('svgPlaneCyan', svgPlaneCyan);
|
||
loadImg('svgPlaneYellow', svgPlaneYellow);
|
||
loadImg('svgPlaneOrange', svgPlaneOrange);
|
||
loadImg('svgPlanePurple', svgPlanePurple);
|
||
loadImg('svgHeli', svgHeli);
|
||
loadImg('svgHeliCyan', svgHeliCyan);
|
||
loadImg('svgHeliDimCyan', svgHeliDimCyan);
|
||
loadImg('svgHeliOrange', svgHeliOrange);
|
||
loadImg('svgHeliPurple', svgHeliPurple);
|
||
loadImg('svgHeliSlate', svgHeliSlate);
|
||
loadImg('svgHeliAmber', svgHeliAmber);
|
||
loadImg('svgHeliBlue', svgHeliBlue);
|
||
loadImg('svgHeliLime', svgHeliLime);
|
||
loadImg('svgFighter', svgFighter);
|
||
loadImg('svgTanker', svgTanker);
|
||
loadImg('svgRecon', svgRecon);
|
||
loadImg('svgAirlinerCyan', svgAirlinerCyan);
|
||
loadImg('svgAirlinerDimCyan', svgAirlinerDimCyan);
|
||
loadImg('svgAirlinerOrange', svgAirlinerOrange);
|
||
loadImg('svgAirlinerPurple', svgAirlinerPurple);
|
||
loadImg('svgAirlinerSlate', svgAirlinerSlate);
|
||
loadImg('svgAirlinerYellow', svgAirlinerYellow);
|
||
loadImg('svgAirlinerAmber', svgAirlinerAmber);
|
||
loadImg('svgTurbopropCyan', svgTurbopropCyan);
|
||
loadImg('svgTurbopropDimCyan', svgTurbopropDimCyan);
|
||
loadImg('svgTurbopropOrange', svgTurbopropOrange);
|
||
loadImg('svgTurbopropPurple', svgTurbopropPurple);
|
||
loadImg('svgTurbopropSlate', svgTurbopropSlate);
|
||
loadImg('svgTurbopropYellow', svgTurbopropYellow);
|
||
loadImg('svgTurbopropAmber', svgTurbopropAmber);
|
||
loadImg('svgBizjetCyan', svgBizjetCyan);
|
||
loadImg('svgBizjetDimCyan', svgBizjetDimCyan);
|
||
loadImg('svgBizjetOrange', svgBizjetOrange);
|
||
loadImg('svgBizjetPurple', svgBizjetPurple);
|
||
loadImg('svgBizjetSlate', svgBizjetSlate);
|
||
loadImg('svgBizjetYellow', svgBizjetYellow);
|
||
loadImg('svgBizjetAmber', svgBizjetAmber);
|
||
loadImg('svgAirlinerGrey', svgAirlinerGrey);
|
||
loadImg('svgTurbopropGrey', svgTurbopropGrey);
|
||
loadImg('svgBizjetGrey', svgBizjetGrey);
|
||
loadImg('svgHeliGrey', svgHeliGrey);
|
||
loadImg('svgShipGray', svgShipGray);
|
||
loadImg('svgShipRed', svgShipRed);
|
||
loadImg('svgShipYellow', svgShipYellow);
|
||
loadImg('svgShipBlue', svgShipBlue);
|
||
loadImg('svgShipWhite', svgShipWhite);
|
||
loadImg('svgShipPink', svgShipPink);
|
||
loadImg('svgShipGreyBlue', svgShipGreyBlue);
|
||
loadImg('svgShipAmber', svgShipAmber);
|
||
loadImg('svgCarrier', svgCarrier);
|
||
loadImg('svgWarning', svgWarning);
|
||
loadImg('icon-threat', svgThreat);
|
||
|
||
// Deferred icons — for off-by-default layers and rare variants
|
||
// Loaded in next frame to avoid blocking initial map render
|
||
setTimeout(() => {
|
||
loadImg('svgRadioTower', svgRadioTower);
|
||
loadImg('svgSatDish', svgSatDish);
|
||
loadImg('svgLoRaSat', svgLoRaSat);
|
||
loadImg('svgScannerTower', svgScannerTower);
|
||
loadImg('svgPlanePink', svgPlanePink);
|
||
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
||
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
||
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
||
loadImg('svgPlaneBlack', svgPlaneBlack);
|
||
loadImg('svgHeliPink', svgHeliPink);
|
||
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
||
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
||
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
||
loadImg('svgHeliBlack', svgHeliBlack);
|
||
loadImg('svgPotusPlane', svgPotusPlane);
|
||
loadImg('svgPotusHeli', svgPotusHeli);
|
||
loadImg('svgAirlinerPink', svgAirlinerPink);
|
||
loadImg('svgAirlinerRed', svgAirlinerRed);
|
||
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
||
loadImg('svgAirlinerBlue', svgAirlinerBlue);
|
||
loadImg('svgAirlinerLime', svgAirlinerLime);
|
||
loadImg('svgAirlinerBlack', svgAirlinerBlack);
|
||
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
||
loadImg('svgTurbopropPink', svgTurbopropPink);
|
||
loadImg('svgTurbopropRed', svgTurbopropRed);
|
||
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
||
loadImg('svgTurbopropBlue', svgTurbopropBlue);
|
||
loadImg('svgTurbopropLime', svgTurbopropLime);
|
||
loadImg('svgTurbopropBlack', svgTurbopropBlack);
|
||
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
||
loadImg('svgBizjetPink', svgBizjetPink);
|
||
loadImg('svgBizjetRed', svgBizjetRed);
|
||
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
||
loadImg('svgBizjetBlue', svgBizjetBlue);
|
||
loadImg('svgBizjetLime', svgBizjetLime);
|
||
loadImg('svgBizjetBlack', svgBizjetBlack);
|
||
loadImg('svgBizjetWhite', svgBizjetWhite);
|
||
loadImg('svgDrone', svgDrone);
|
||
loadImg('svgCctv', svgCctv);
|
||
loadImg('icon-liveua-yellow', svgTriangleYellow);
|
||
loadImg('icon-liveua-red', svgTriangleRed);
|
||
loadImg('icon-aprs-triangle', svgTrianglePink);
|
||
loadImg('icon-mesh-triangle', svgTriangleGreen);
|
||
// FIRMS fire icons
|
||
loadImg('fire-yellow', svgFireYellow);
|
||
loadImg('fire-orange', svgFireOrange);
|
||
loadImg('fire-red', svgFireRed);
|
||
loadImg('fire-darkred', svgFireDarkRed);
|
||
loadImg('fire-cluster-sm', svgFireClusterSmall);
|
||
loadImg('fire-cluster-md', svgFireClusterMed);
|
||
loadImg('fire-cluster-lg', svgFireClusterLarge);
|
||
loadImg('fire-cluster-xl', svgFireClusterXL);
|
||
// Data center icon
|
||
loadImg('datacenter', svgDataCenter);
|
||
// Power plant icon
|
||
loadImg('power-plant', svgPowerPlant);
|
||
// Satellite mission-type icons
|
||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
||
loadImg('sat-sigint', makeSatSvg('#ffffff'));
|
||
loadImg('sat-nav', makeSatSvg('#4488ff'));
|
||
loadImg('sat-ew', makeSatSvg('#ff00ff'));
|
||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||
// ISS special icon (larger, with built-in halo ring)
|
||
loadImg('sat-iss', makeISSSvg());
|
||
// Train icons
|
||
loadImg('train-amtrak', makeTrainSvg('#ffffff'));
|
||
loadImg('train-fin', makeTrainSvg('#ffffff'));
|
||
// Military base icons (square with X or circle)
|
||
for (const spec of MILBASE_ICON_SPECS) {
|
||
loadImg(
|
||
spec.id,
|
||
spec.svg ?? (spec.shape === 'circle'
|
||
? makeMilBaseCircleSvg(spec.fill, spec.inner)
|
||
: makeMilBaseSvg(spec.fill, spec.inner)),
|
||
);
|
||
}
|
||
// Volcano icons (triangle cone)
|
||
for (const spec of VOLCANO_ICON_SPECS) {
|
||
loadImg(spec.id, makeVolcanoSvg(spec.fill));
|
||
}
|
||
// Weather alert icons
|
||
for (const spec of WEATHER_ICON_SPECS) {
|
||
loadImg(spec.id, spec.svg);
|
||
}
|
||
// CrowdThreat category icons
|
||
for (const spec of CT_ICON_SPECS) {
|
||
loadImg(spec.id, spec.svg);
|
||
}
|
||
// UAP (UFO) icons — individual + cluster
|
||
loadImg('ufo-icon', makeUfoSvg());
|
||
loadImg('ufo-cluster', makeUfoClusterSvg());
|
||
// Wastewater water drop icons — individual + cluster
|
||
loadImg('ww-clean', makeWaterDropSvg('#00e5ff'));
|
||
loadImg('ww-alert', makeWaterDropSvg('#ff2222', '#ff4444'));
|
||
loadImg('ww-stale', makeWaterDropSvg('#556677'));
|
||
loadImg('ww-cluster', makeWaterDropClusterSvg('#00e5ff'));
|
||
}, 0);
|
||
|
||
}, []);
|
||
|
||
// Load Images into the Map Style once loaded
|
||
const onMapLoad = useCallback((e: { target: maplibregl.Map }) => {
|
||
initializeMap(e.target);
|
||
setMapReady(true);
|
||
}, [initializeMap]);
|
||
|
||
const onMapStyleData = useCallback((e: { target: maplibregl.Map }) => {
|
||
initializeMap(e.target);
|
||
setMapReady(true);
|
||
}, [initializeMap]);
|
||
|
||
useEffect(() => {
|
||
const map = mapRef.current?.getMap();
|
||
if (map) {
|
||
initializeMap(map);
|
||
setMapReady(true);
|
||
}
|
||
}, [initializeMap, theme]);
|
||
|
||
// Build a set of tracked icao24s to exclude from other flight layers
|
||
const trackedIcaoSet = useMemo(() => {
|
||
const s = new Set<string>();
|
||
if (data?.tracked_flights) {
|
||
for (const t of data.tracked_flights) {
|
||
if (t.icao24) s.add(t.icao24.toLowerCase());
|
||
}
|
||
}
|
||
return s;
|
||
}, [data?.tracked_flights]);
|
||
|
||
// Satellite GeoJSON with interpolated positions
|
||
const satellitesGeoJSON = useMemo(
|
||
() => {
|
||
void interpTick;
|
||
return activeLayers.satellites ? buildSatellitesGeoJSON(data?.satellites, inView, interpSat) : null;
|
||
},
|
||
[activeLayers.satellites, data?.satellites, inView, interpSat, interpTick],
|
||
);
|
||
|
||
const commConfig = useMemo<FlightLayerConfig>(
|
||
() => ({
|
||
colorMap: COLOR_MAP_COMMERCIAL,
|
||
groundedMap: GROUNDED_ICON_MAP,
|
||
typeLabel: 'flight',
|
||
idPrefix: 'flight-',
|
||
useTrackHeading: true,
|
||
}),
|
||
[],
|
||
);
|
||
const privConfig = useMemo<FlightLayerConfig>(
|
||
() => ({
|
||
colorMap: COLOR_MAP_PRIVATE,
|
||
groundedMap: GROUNDED_ICON_MAP,
|
||
typeLabel: 'private_flight',
|
||
idPrefix: 'pflight-',
|
||
}),
|
||
[],
|
||
);
|
||
const jetsConfig = useMemo<FlightLayerConfig>(
|
||
() => ({
|
||
colorMap: COLOR_MAP_JETS,
|
||
groundedMap: GROUNDED_ICON_MAP,
|
||
typeLabel: 'private_jet',
|
||
idPrefix: 'pjet-',
|
||
}),
|
||
[],
|
||
);
|
||
const milConfig = useMemo<FlightLayerConfig>(
|
||
() => ({
|
||
colorMap: COLOR_MAP_MILITARY,
|
||
groundedMap: GROUNDED_ICON_MAP,
|
||
typeLabel: 'military_flight',
|
||
idPrefix: 'mflight-',
|
||
milSpecialMap: MIL_SPECIAL_MAP,
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const shipsLayerEnabled = backendViewportSyncEnabled;
|
||
const sigintLayerEnabled = activeLayers.sigint_meshtastic || activeLayers.sigint_aprs;
|
||
const globalIncidentsEnabled = activeLayers.global_incidents;
|
||
|
||
const dynamicCommercialFlights = activeLayers.flights ? data?.commercial_flights : undefined;
|
||
const dynamicPrivateFlights = activeLayers.private ? data?.private_flights : undefined;
|
||
const dynamicPrivateJets = activeLayers.jets ? data?.private_jets : undefined;
|
||
const dynamicMilitaryFlights = activeLayers.military ? data?.military_flights : undefined;
|
||
const dynamicTrackedFlights = activeLayers.tracked ? data?.tracked_flights : undefined;
|
||
const dynamicShips = shipsLayerEnabled ? data?.ships : undefined;
|
||
const dynamicSigint = sigintLayerEnabled ? data?.sigint : undefined;
|
||
|
||
const staticCctv = activeLayers.cctv ? data?.cctv : undefined;
|
||
const staticKiwisdr = activeLayers.kiwisdr ? data?.kiwisdr : undefined;
|
||
const staticPskReporter = activeLayers.psk_reporter ? data?.psk_reporter : undefined;
|
||
const staticSatnogsStations = activeLayers.satnogs ? data?.satnogs_stations : undefined;
|
||
const staticScanners = activeLayers.scanners ? data?.scanners : undefined;
|
||
const staticFirmsFires = activeLayers.firms ? data?.firms_fires : undefined;
|
||
const staticInternetOutages = activeLayers.internet_outages ? data?.internet_outages : undefined;
|
||
const staticDatacenters = activeLayers.datacenters ? data?.datacenters : undefined;
|
||
const staticPowerPlants = activeLayers.power_plants ? data?.power_plants : undefined;
|
||
const staticViirsChangeNodes = activeLayers.viirs_nightlights ? data?.viirs_change_nodes : undefined;
|
||
const staticMilitaryBases = activeLayers.military_bases ? data?.military_bases : undefined;
|
||
const staticGdelt = globalIncidentsEnabled ? data?.gdelt : undefined;
|
||
const staticLiveuamap = globalIncidentsEnabled ? data?.liveuamap : undefined;
|
||
const staticAirQuality = activeLayers.air_quality ? data?.air_quality : undefined;
|
||
const staticVolcanoes = activeLayers.volcanoes ? data?.volcanoes : undefined;
|
||
const staticFishingActivity = activeLayers.fishing_activity ? data?.fishing_activity : undefined;
|
||
const staticTrains = activeLayers.trains ? data?.trains : undefined;
|
||
const staticUapSightings = activeLayers.uap_sightings ? data?.uap_sightings : undefined;
|
||
const staticWastewater = activeLayers.wastewater ? data?.wastewater : undefined;
|
||
const staticCrowdthreat = activeLayers.crowdthreat ? data?.crowdthreat : undefined;
|
||
const staticMalwareThreats = activeLayers.malware_c2 ? data?.malware_threats?.threats : undefined;
|
||
const staticTelegramOsintPosts = activeLayers.telegram_osint
|
||
? data?.telegram_osint?.posts
|
||
: undefined;
|
||
|
||
const [submarineCablesGeoJSON, setSubmarineCablesGeoJSON] = useState<GeoJSON.FeatureCollection | null>(null);
|
||
useEffect(() => {
|
||
if (!activeLayers.submarine_cables) {
|
||
setSubmarineCablesGeoJSON(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
fetch('/data/submarine-cables.json')
|
||
.then((r) => r.json())
|
||
.then((geo) => {
|
||
if (!cancelled) setSubmarineCablesGeoJSON(sanitizeSubmarineCables(geo));
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setSubmarineCablesGeoJSON(null);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [activeLayers.submarine_cables]);
|
||
|
||
const dynamicMapLayers = useDynamicMapLayersWorker(
|
||
{
|
||
commercialFlights: dynamicCommercialFlights,
|
||
privateFlights: dynamicPrivateFlights,
|
||
privateJets: dynamicPrivateJets,
|
||
militaryFlights: dynamicMilitaryFlights,
|
||
trackedFlights: dynamicTrackedFlights,
|
||
ships: dynamicShips,
|
||
sigint: dynamicSigint,
|
||
commConfig,
|
||
privConfig,
|
||
jetsConfig,
|
||
milConfig,
|
||
},
|
||
[
|
||
dynamicCommercialFlights,
|
||
dynamicPrivateFlights,
|
||
dynamicPrivateJets,
|
||
dynamicMilitaryFlights,
|
||
dynamicTrackedFlights,
|
||
dynamicShips,
|
||
dynamicSigint,
|
||
commConfig,
|
||
privConfig,
|
||
jetsConfig,
|
||
milConfig,
|
||
],
|
||
{
|
||
bounds: mapBounds,
|
||
serverBboxScoped: getLiveDataBounds() !== null,
|
||
// Worker stamps base positions; main-thread applyDynamicLayerInterp
|
||
// dead-reckons between polls without rebuilding FeatureCollections.
|
||
dtSeconds: 0,
|
||
trackedIcaos: Array.from(trackedIcaoSet),
|
||
activeLayers: {
|
||
flights: activeLayers.flights,
|
||
private: activeLayers.private,
|
||
jets: activeLayers.jets,
|
||
military: activeLayers.military,
|
||
tracked: activeLayers.tracked,
|
||
ships_military: activeLayers.ships_military,
|
||
ships_cargo: activeLayers.ships_cargo,
|
||
ships_civilian: activeLayers.ships_civilian,
|
||
ships_passenger: activeLayers.ships_passenger,
|
||
ships_tracked_yachts: activeLayers.ships_tracked_yachts,
|
||
sigint_meshtastic: activeLayers.sigint_meshtastic,
|
||
sigint_aprs: activeLayers.sigint_aprs,
|
||
},
|
||
activeFilters: activeFilters || {},
|
||
},
|
||
[
|
||
mapBounds,
|
||
trackedIcaoSet,
|
||
activeLayers.flights,
|
||
activeLayers.private,
|
||
activeLayers.jets,
|
||
activeLayers.military,
|
||
activeLayers.tracked,
|
||
activeLayers.ships_military,
|
||
activeLayers.ships_cargo,
|
||
activeLayers.ships_civilian,
|
||
activeLayers.ships_passenger,
|
||
activeLayers.ships_tracked_yachts,
|
||
activeLayers.sigint_meshtastic,
|
||
activeLayers.sigint_aprs,
|
||
activeFilters,
|
||
],
|
||
);
|
||
|
||
const interpolatedDynamicMapLayers = useMemo(
|
||
() => {
|
||
void interpTick;
|
||
return applyDynamicLayerInterp(dynamicMapLayers, dtSeconds.current);
|
||
},
|
||
[dynamicMapLayers, interpTick, dtSeconds],
|
||
);
|
||
|
||
const {
|
||
commercialFlightsGeoJSON: commFlightsGeoJSON,
|
||
privateFlightsGeoJSON: privFlightsGeoJSON,
|
||
privateJetsGeoJSON: privJetsGeoJSON,
|
||
militaryFlightsGeoJSON: milFlightsGeoJSON,
|
||
trackedFlightsGeoJSON,
|
||
shipsGeoJSON,
|
||
meshtasticGeoJSON,
|
||
aprsGeoJSON,
|
||
} = interpolatedDynamicMapLayers;
|
||
|
||
const staticMapLayers = useStaticMapLayersWorker(
|
||
{
|
||
cctv: staticCctv,
|
||
kiwisdr: staticKiwisdr,
|
||
pskReporter: staticPskReporter,
|
||
satnogsStations: staticSatnogsStations,
|
||
scanners: staticScanners,
|
||
firmsFires: staticFirmsFires,
|
||
internetOutages: staticInternetOutages,
|
||
datacenters: staticDatacenters,
|
||
powerPlants: staticPowerPlants,
|
||
viirsChangeNodes: staticViirsChangeNodes,
|
||
militaryBases: staticMilitaryBases,
|
||
gdelt: staticGdelt,
|
||
liveuamap: staticLiveuamap,
|
||
airQuality: staticAirQuality,
|
||
volcanoes: staticVolcanoes,
|
||
fishingActivity: staticFishingActivity,
|
||
ships: data?.ships,
|
||
trains: staticTrains,
|
||
uapSightings: staticUapSightings,
|
||
wastewater: staticWastewater,
|
||
crowdthreat: staticCrowdthreat,
|
||
malwareThreats: staticMalwareThreats,
|
||
telegramOsintPosts: staticTelegramOsintPosts,
|
||
},
|
||
[
|
||
staticCctv,
|
||
staticKiwisdr,
|
||
staticPskReporter,
|
||
staticSatnogsStations,
|
||
staticScanners,
|
||
staticFirmsFires,
|
||
staticInternetOutages,
|
||
staticDatacenters,
|
||
staticPowerPlants,
|
||
staticViirsChangeNodes,
|
||
staticMilitaryBases,
|
||
staticGdelt,
|
||
staticLiveuamap,
|
||
staticAirQuality,
|
||
staticVolcanoes,
|
||
staticFishingActivity,
|
||
data?.ships,
|
||
staticTrains,
|
||
staticUapSightings,
|
||
staticWastewater,
|
||
staticCrowdthreat,
|
||
staticMalwareThreats,
|
||
staticTelegramOsintPosts,
|
||
mapZoom,
|
||
],
|
||
{
|
||
bounds: mapBounds,
|
||
activeLayers: {
|
||
cctv: activeLayers.cctv,
|
||
kiwisdr: activeLayers.kiwisdr,
|
||
psk_reporter: activeLayers.psk_reporter,
|
||
satnogs: activeLayers.satnogs,
|
||
scanners: activeLayers.scanners,
|
||
firms: activeLayers.firms,
|
||
internet_outages: activeLayers.internet_outages,
|
||
datacenters: activeLayers.datacenters,
|
||
power_plants: activeLayers.power_plants,
|
||
viirs_nightlights: activeLayers.viirs_nightlights,
|
||
military_bases: activeLayers.military_bases,
|
||
global_incidents: activeLayers.global_incidents,
|
||
air_quality: activeLayers.air_quality,
|
||
volcanoes: activeLayers.volcanoes,
|
||
fishing_activity: activeLayers.fishing_activity,
|
||
trains: activeLayers.trains,
|
||
uap_sightings: activeLayers.uap_sightings,
|
||
wastewater: activeLayers.wastewater,
|
||
crowdthreat: activeLayers.crowdthreat,
|
||
malware_c2: activeLayers.malware_c2,
|
||
telegram_osint: activeLayers.telegram_osint,
|
||
},
|
||
},
|
||
[
|
||
mapBounds,
|
||
activeLayers.cctv,
|
||
activeLayers.kiwisdr,
|
||
activeLayers.psk_reporter,
|
||
activeLayers.satnogs,
|
||
activeLayers.scanners,
|
||
activeLayers.firms,
|
||
activeLayers.internet_outages,
|
||
activeLayers.datacenters,
|
||
activeLayers.power_plants,
|
||
activeLayers.viirs_nightlights,
|
||
activeLayers.military_bases,
|
||
activeLayers.global_incidents,
|
||
activeLayers.air_quality,
|
||
activeLayers.volcanoes,
|
||
activeLayers.fishing_activity,
|
||
activeLayers.trains,
|
||
activeLayers.uap_sightings,
|
||
activeLayers.wastewater,
|
||
activeLayers.crowdthreat,
|
||
activeLayers.malware_c2,
|
||
activeLayers.telegram_osint,
|
||
],
|
||
);
|
||
|
||
const {
|
||
cctvGeoJSON,
|
||
kiwisdrGeoJSON,
|
||
pskReporterGeoJSON,
|
||
satnogsGeoJSON,
|
||
scannerGeoJSON,
|
||
firmsGeoJSON,
|
||
internetOutagesGeoJSON,
|
||
dataCentersGeoJSON,
|
||
powerPlantsGeoJSON,
|
||
viirsChangeNodesGeoJSON,
|
||
militaryBasesGeoJSON,
|
||
gdeltGeoJSON,
|
||
liveuaGeoJSON,
|
||
airQualityGeoJSON,
|
||
volcanoesGeoJSON,
|
||
fishingGeoJSON,
|
||
trainsGeoJSON,
|
||
uapSightingsGeoJSON,
|
||
wastewaterGeoJSON,
|
||
crowdthreatGeoJSON,
|
||
malwareGeoJSON,
|
||
telegramOsintGeoJSON,
|
||
} = staticMapLayers;
|
||
|
||
const telegramOsintGeoJSONPlaced = useMemo(
|
||
() => applyTelegramAlertAvoidance(telegramOsintGeoJSON, data?.news),
|
||
[telegramOsintGeoJSON, data?.news],
|
||
);
|
||
|
||
// Extract cluster label positions via shared hook
|
||
const shipClusters = useClusterLabels(mapRef, 'ships-clusters-layer', shipsGeoJSON);
|
||
const eqClusters = useClusterLabels(mapRef, 'eq-clusters-layer', earthquakesGeoJSON);
|
||
|
||
const carriersGeoJSON = useMemo(
|
||
() => (activeLayers.ships_military ? buildCarriersGeoJSON(data?.ships) : null),
|
||
[activeLayers.ships_military, data?.ships],
|
||
);
|
||
|
||
// SAR anomaly pins (Mode B) + AOI watchbox circles. AOIs render whenever
|
||
// the SAR layer is on; anomalies only appear when Mode B has produced
|
||
// something. The render path is fully imperative via useImperativeSource.
|
||
//
|
||
// AOIs come from their own endpoint (/api/sar/aois) rather than the
|
||
// dashboard payload because they're operator-managed metadata, not a
|
||
// polled feed — the list rarely changes and we don't want to bloat
|
||
// dashboard responses with it.
|
||
const [sarAoisList, setSarAoisList] = useState<
|
||
import('@/types/dashboard').SarAoi[]
|
||
>([]);
|
||
useEffect(() => {
|
||
if (!activeLayers.sar) return;
|
||
let cancelled = false;
|
||
const run = async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/sar/aois`, {
|
||
credentials: 'include',
|
||
});
|
||
if (!res.ok || cancelled) return;
|
||
const body = await res.json();
|
||
if (!cancelled && Array.isArray(body?.aois)) {
|
||
setSarAoisList(body.aois);
|
||
}
|
||
} catch {
|
||
// ignore — AOIs are a nice-to-have
|
||
}
|
||
};
|
||
run();
|
||
// Refresh every 2 minutes while the layer is on so operator edits
|
||
// propagate without a full page reload.
|
||
const iv = setInterval(run, 120_000);
|
||
return () => {
|
||
cancelled = true;
|
||
clearInterval(iv);
|
||
};
|
||
}, [activeLayers.sar, sarAoiListVersion]);
|
||
|
||
const sarAnomaliesGeoJSON = useMemo(
|
||
() => (activeLayers.sar ? buildSarAnomaliesGeoJSON(data?.sar_anomalies) : null),
|
||
[activeLayers.sar, data?.sar_anomalies],
|
||
);
|
||
const sarAoisGeoJSON = useMemo(
|
||
() => (activeLayers.sar ? buildSarAoisGeoJSON(sarAoisList) : null),
|
||
[activeLayers.sar, sarAoisList],
|
||
);
|
||
|
||
const getSelectedEntityLiveCoords = useCallback(
|
||
(entity: ReturnType<typeof findSelectedEntity>): [number, number] | null => {
|
||
if (!entity || entity.lat == null || entity.lng == null) return null;
|
||
switch (selectedEntity?.type) {
|
||
case 'ship':
|
||
return interpShip(entity);
|
||
case 'flight':
|
||
case 'private_flight':
|
||
case 'military_flight':
|
||
case 'private_jet':
|
||
case 'tracked_flight':
|
||
case 'uav':
|
||
return interpFlight(entity);
|
||
default:
|
||
return [entity.lng, entity.lat];
|
||
}
|
||
},
|
||
[interpFlight, interpShip, selectedEntity?.type],
|
||
);
|
||
|
||
const activeRouteGeoJSON = useMemo(() => {
|
||
void interpTick;
|
||
const entity = findSelectedEntity(selectedEntity, data);
|
||
if (!entity) return null;
|
||
|
||
const currentLoc = getSelectedEntityLiveCoords(entity) ?? [entity.lng, entity.lat];
|
||
let originLoc = 'origin_loc' in entity ? entity.origin_loc : null;
|
||
let destLoc = 'dest_loc' in entity ? entity.dest_loc : null;
|
||
let originName = 'origin_name' in entity ? entity.origin_name : '';
|
||
let destName = 'dest_name' in entity ? entity.dest_name : '';
|
||
|
||
if (dynamicRoute && dynamicRoute.orig_loc && dynamicRoute.dest_loc) {
|
||
originLoc = dynamicRoute.orig_loc;
|
||
destLoc = dynamicRoute.dest_loc;
|
||
originName = dynamicRoute.origin_name || originName;
|
||
destName = dynamicRoute.dest_name || destName;
|
||
}
|
||
|
||
if (!originLoc && !destLoc) return null;
|
||
|
||
const features: GeoJSON.Feature[] = [];
|
||
// Extract IATA codes from "IATA: Airport Name" format
|
||
const originCode = (originName || '').split(':')[0]?.trim() || '';
|
||
const destCode = (destName || '').split(':')[0]?.trim() || '';
|
||
|
||
if (originLoc) {
|
||
features.push({
|
||
type: 'Feature',
|
||
properties: { type: 'route-origin' },
|
||
geometry: { type: 'LineString', coordinates: [currentLoc, originLoc] },
|
||
});
|
||
features.push({
|
||
type: 'Feature',
|
||
properties: { type: 'airport', code: originCode, role: 'DEP' },
|
||
geometry: { type: 'Point', coordinates: originLoc },
|
||
});
|
||
}
|
||
if (destLoc) {
|
||
features.push({
|
||
type: 'Feature',
|
||
properties: { type: 'route-dest' },
|
||
geometry: { type: 'LineString', coordinates: [currentLoc, destLoc] },
|
||
});
|
||
features.push({
|
||
type: 'Feature',
|
||
properties: { type: 'airport', code: destCode, role: 'ARR' },
|
||
geometry: { type: 'Point', coordinates: destLoc },
|
||
});
|
||
}
|
||
|
||
if (features.length === 0) return null;
|
||
return { type: 'FeatureCollection' as const, features };
|
||
}, [selectedEntity, data, dynamicRoute, getSelectedEntityLiveCoords, interpTick]);
|
||
|
||
// Trail history GeoJSON: shows where the selected unknown-route aircraft or vessel has been.
|
||
const trailGeoJSON = useMemo(() => {
|
||
void interpTick;
|
||
const entity = findSelectedEntity(selectedEntity, data);
|
||
if (!entity || selectedTrailPoints.length < 2) return null;
|
||
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightPayloadHasKnownRoute(entity)) {
|
||
return null;
|
||
}
|
||
|
||
// Trails are loaded only for the selected asset to avoid open-map clutter.
|
||
const isShipTrail = selectedEntity?.type === 'ship';
|
||
const points = [...selectedTrailPoints];
|
||
|
||
const currentLoc = getSelectedEntityLiveCoords(entity);
|
||
if (currentLoc && points.length > 0) {
|
||
const lastPt = points[points.length - 1];
|
||
points.push({
|
||
lng: currentLoc[0],
|
||
lat: currentLoc[1],
|
||
alt: lastPt.alt,
|
||
sog: lastPt.sog,
|
||
ts: Date.now() / 1000,
|
||
});
|
||
}
|
||
|
||
if (points.length < 2) return null;
|
||
|
||
// Split into segments colored by altitude for gradient effect
|
||
// Color ramp: ground(magenta) → low(blue) → mid(cyan) → high(green) → very high(yellow) → max(orange/red)
|
||
const altToColor = (altM: number): string => {
|
||
const ft = altM / 0.3048;
|
||
if (ft < 1000) return '#ff44ff'; // magenta — ground/taxi
|
||
if (ft < 5000) return '#6366f1'; // indigo — low
|
||
if (ft < 15000) return '#22d3ee'; // cyan — mid climb/descent
|
||
if (ft < 25000) return '#22c55e'; // green — medium
|
||
if (ft < 35000) return '#eab308'; // yellow — high
|
||
return '#f97316'; // orange — cruise
|
||
};
|
||
|
||
const features: GeoJSON.Feature[] = [];
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
const a = points[i], b = points[i + 1];
|
||
const progress = i / (points.length - 1);
|
||
features.push({
|
||
type: 'Feature' as const,
|
||
properties: {
|
||
type: 'trail',
|
||
color: isShipTrail ? '#22d3ee' : altToColor(((a.alt ?? 0) + (b.alt ?? 0)) / 2),
|
||
opacity: 0.4 + progress * 0.5, // older segments more transparent
|
||
segIndex: i,
|
||
},
|
||
geometry: {
|
||
type: 'LineString' as const,
|
||
coordinates: [[a.lng, a.lat], [b.lng, b.lat]],
|
||
},
|
||
});
|
||
}
|
||
|
||
return { type: 'FeatureCollection' as const, features };
|
||
}, [selectedEntity, data, selectedTrailPoints, dynamicRoute, getSelectedEntityLiveCoords, interpTick]);
|
||
|
||
// Predictive vector GeoJSON: dotted line projecting ~5 min ahead based on heading + speed
|
||
// Skip when entity has a known route (origin+dest) — the route line already shows where it's going
|
||
const predictiveGeoJSON = useMemo(() => {
|
||
void interpTick;
|
||
const entity = findSelectedEntity(selectedEntity, data);
|
||
if (dynamicRoute?.orig_loc || dynamicRoute?.dest_loc) {
|
||
return null;
|
||
}
|
||
if (
|
||
entity &&
|
||
'dest_name' in entity &&
|
||
entity.dest_name &&
|
||
entity.dest_name !== 'UNKNOWN'
|
||
) {
|
||
return null;
|
||
}
|
||
const currentLoc = getSelectedEntityLiveCoords(entity);
|
||
if (!entity || !currentLoc) return buildPredictiveGeoJSON(entity);
|
||
return buildPredictiveGeoJSON({
|
||
...entity,
|
||
lng: currentLoc[0],
|
||
lat: currentLoc[1],
|
||
});
|
||
}, [selectedEntity, data, dynamicRoute, getSelectedEntityLiveCoords, interpTick]);
|
||
|
||
// Proximity range rings: 10nm, 50nm, 100nm around selected entity
|
||
const proximityRingsGeoJSON = useMemo(() => {
|
||
void interpTick;
|
||
const entity = findSelectedEntity(selectedEntity, data);
|
||
const currentLoc = getSelectedEntityLiveCoords(entity);
|
||
if (!currentLoc) return null;
|
||
return buildProximityRingsGeoJSON(currentLoc[1], currentLoc[0], [10, 50, 100]);
|
||
}, [selectedEntity, data, getSelectedEntityLiveCoords, interpTick]);
|
||
|
||
const spreadAlerts = useMemo(() => {
|
||
if (!data?.news) return [];
|
||
// Limit visible alerts by zoom: at low zoom show only top threats,
|
||
// at high zoom show more. Prevents map clutter with dozens of boxes.
|
||
const maxAlerts = mapZoom < 4 ? 6 : mapZoom < 6 ? 10 : 16;
|
||
const sorted = [...data.news].sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0));
|
||
return spreadAlertItems(sorted.slice(0, maxAlerts), mapZoom, dismissedAlerts);
|
||
}, [data?.news, mapZoom, dismissedAlerts]);
|
||
|
||
const uavGeoJSON = useMemo(
|
||
() => (activeLayers.military ? buildUavGeoJSON(data?.uavs, inView) : null),
|
||
[activeLayers.military, data?.uavs, inView],
|
||
);
|
||
|
||
// UAV range circles removed — real ADS-B drones don't have a fixed orbit center
|
||
|
||
const frontlineGeoJSON = useMemo(
|
||
() => (activeLayers.ukraine_frontline ? buildFrontlineGeoJSON(data?.frontlines) : null),
|
||
[activeLayers.ukraine_frontline, data?.frontlines],
|
||
);
|
||
|
||
// Interactive layer IDs for click handling
|
||
const activeInteractiveLayerIds = [
|
||
commFlightsGeoJSON && 'commercial-flights-layer',
|
||
privFlightsGeoJSON && 'private-flights-layer',
|
||
privJetsGeoJSON && 'private-jets-layer',
|
||
milFlightsGeoJSON && 'military-flights-layer',
|
||
shipsGeoJSON && 'ships-clusters-layer',
|
||
shipsGeoJSON && 'ships-layer',
|
||
carriersGeoJSON && 'carriers-layer',
|
||
trackedFlightsGeoJSON && 'tracked-flights-layer',
|
||
uavGeoJSON && 'uav-layer',
|
||
gdeltGeoJSON && 'gdelt-layer',
|
||
liveuaGeoJSON && 'liveuamap-layer',
|
||
frontlineGeoJSON && 'ukraine-frontline-layer',
|
||
earthquakesGeoJSON && 'earthquakes-layer',
|
||
satellitesGeoJSON && 'satellites-layer',
|
||
cctvGeoJSON && 'cctv-clusters',
|
||
cctvGeoJSON && 'cctv-cluster-count',
|
||
cctvGeoJSON && 'cctv-layer',
|
||
kiwisdrGeoJSON && 'kiwisdr-clusters',
|
||
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||
pskReporterGeoJSON && 'psk-reporter-clusters',
|
||
pskReporterGeoJSON && 'psk-reporter-layer',
|
||
satnogsGeoJSON && 'satnogs-clusters',
|
||
satnogsGeoJSON && 'satnogs-layer',
|
||
tinygsGeoJSON && 'tinygs-layer',
|
||
scannerGeoJSON && 'scanner-clusters',
|
||
scannerGeoJSON && 'scanner-layer',
|
||
internetOutagesGeoJSON && 'internet-outages-layer',
|
||
dataCentersGeoJSON && 'datacenters-layer',
|
||
powerPlantsGeoJSON && 'power-plants-layer',
|
||
viirsChangeNodesGeoJSON && 'viirs-change-nodes-layer',
|
||
shodanGeoJSON && 'shodan-clusters',
|
||
shodanGeoJSON && 'shodan-cluster-count',
|
||
shodanGeoJSON && 'shodan-layer',
|
||
militaryBasesGeoJSON && 'military-bases-layer',
|
||
firmsGeoJSON && 'firms-viirs-layer',
|
||
meshtasticGeoJSON && 'meshtastic-clusters',
|
||
meshtasticGeoJSON && 'meshtastic-cluster-count',
|
||
meshtasticGeoJSON && 'meshtastic-circles',
|
||
aprsGeoJSON && 'aprs-clusters',
|
||
aprsGeoJSON && 'aprs-cluster-count',
|
||
aprsGeoJSON && 'aprs-triangles',
|
||
ukraineAlertsGeoJSON && 'ukraine-alerts-fill',
|
||
weatherAlertsGeoJSON && 'weather-alerts-fill',
|
||
weatherAlertLabelsGeoJSON && 'weather-alert-icons',
|
||
airQualityGeoJSON && 'air-quality-layer',
|
||
volcanoesGeoJSON && 'volcanoes-layer',
|
||
fishingGeoJSON && 'fishing-clusters',
|
||
fishingGeoJSON && 'fishing-layer',
|
||
trainsGeoJSON && 'trains-layer',
|
||
uapSightingsGeoJSON && 'uap-sightings-cluster-bg',
|
||
uapSightingsGeoJSON && 'uap-sightings-clusters',
|
||
uapSightingsGeoJSON && 'uap-sightings-dot',
|
||
uapSightingsGeoJSON && 'uap-sightings-layer',
|
||
wastewaterGeoJSON && 'wastewater-cluster-bg',
|
||
wastewaterGeoJSON && 'wastewater-clusters',
|
||
wastewaterGeoJSON && 'wastewater-dot',
|
||
wastewaterGeoJSON && 'wastewater-layer',
|
||
crowdthreatGeoJSON && 'crowdthreat-layer',
|
||
malwareGeoJSON && 'malware-clusters',
|
||
malwareGeoJSON && 'malware-layer',
|
||
submarineCablesGeoJSON && 'submarine-cables-layer',
|
||
sarAnomaliesGeoJSON && 'sar-anomalies-layer',
|
||
sarAoisGeoJSON && 'sar-aois-fill',
|
||
aiIntelGeoJSON && 'ai-intel-clusters',
|
||
aiIntelGeoJSON && 'ai-intel-pin-layer',
|
||
correlationsGeoJSON && 'corr-rf-fill',
|
||
correlationsGeoJSON && 'corr-mil-fill',
|
||
correlationsGeoJSON && 'corr-infra-fill',
|
||
correlationsGeoJSON && 'corr-contra-fill',
|
||
correlationsGeoJSON && 'corr-analysis-fill',
|
||
gtRiskGeoJSON && 'gt-risk-heatmap',
|
||
].filter(Boolean) as string[];
|
||
|
||
useEffect(() => {
|
||
const map = mapRef.current?.getMap();
|
||
if (!map) return;
|
||
|
||
const emphasizedLayers = [
|
||
'uap-sightings-cluster-bg',
|
||
'uap-sightings-clusters',
|
||
'uap-sightings-dot',
|
||
'uap-sightings-layer',
|
||
'wastewater-cluster-bg',
|
||
'wastewater-clusters',
|
||
'wastewater-dot',
|
||
'wastewater-layer',
|
||
];
|
||
|
||
const moveEmphasizedLayersToTop = () => {
|
||
for (const layerId of emphasizedLayers) {
|
||
if (map.getLayer(layerId)) {
|
||
map.moveLayer(layerId);
|
||
}
|
||
}
|
||
};
|
||
|
||
const rafId = window.requestAnimationFrame(moveEmphasizedLayersToTop);
|
||
const timeoutId = window.setTimeout(moveEmphasizedLayersToTop, 120);
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(rafId);
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [activeLayers.uap_sightings, activeLayers.wastewater, theme]);
|
||
|
||
// --- Imperative source updates: bypass React reconciliation for GeoJSON layers ---
|
||
const mapForHook = mapReady ? mapRef.current : null;
|
||
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
|
||
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
|
||
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
|
||
useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON);
|
||
useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON);
|
||
useImperativeSource(mapForHook, 'uavs', uavGeoJSON);
|
||
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON);
|
||
useImperativeSource(mapForHook, 'tinygs', tinygsGeoJSON);
|
||
useImperativeSource(mapForHook, 'cctv', cctvGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'kiwisdr', kiwisdrGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'psk-reporter', pskReporterGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'satnogs', satnogsGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'scanners', scannerGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 900);
|
||
useImperativeSource(mapForHook, 'internet-outages', internetOutagesGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'datacenters', dataCentersGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'power-plants', powerPlantsGeoJSON, 140);
|
||
useImperativeSource(mapForHook, 'viirs-change-nodes', viirsChangeNodesGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'military-bases', militaryBasesGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'gdelt', gdeltGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'liveuamap', liveuaGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'air-quality-source', airQualityGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'volcanoes-source', volcanoesGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'fishing-source', fishingGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'uap-sightings-source', uapSightingsGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'wastewater-source', wastewaterGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'crowdthreat-source', crowdthreatGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'malware-source', malwareGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'telegram-osint-source', telegramOsintGeoJSONPlaced, 100);
|
||
useImperativeSource(mapForHook, 'submarine-cables-source', submarineCablesGeoJSON, 600);
|
||
useImperativeSource(mapForHook, 'ships', shipsGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'meshtastic-source', meshtasticGeoJSON, 60);
|
||
useImperativeSource(mapForHook, 'aprs-source', aprsGeoJSON, 60);
|
||
useImperativeSource(mapForHook, 'trains', trainsGeoJSON, 60);
|
||
useImperativeSource(mapForHook, 'sar-aois', sarAoisGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'sar-anomalies', sarAnomaliesGeoJSON, 120);
|
||
// Remaining reactive sources → imperative (avoids React reconciling FeatureCollections)
|
||
useImperativeSource(mapForHook, 'night-overlay', nightGeoJSON, 250);
|
||
useImperativeSource(mapForHook, 'frontlines', frontlineGeoJSON, 200);
|
||
useImperativeSource(mapForHook, 'earthquakes', earthquakesGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'gps-jamming', jammingGeoJSON, 150);
|
||
useImperativeSource(mapForHook, 'gt-risk-source', gtRiskGeoJSON, 150);
|
||
useImperativeSource(mapForHook, 'correlations', correlationsGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'shodan-overlay', shodanGeoJSON, 100);
|
||
useImperativeSource(mapForHook, 'ai-intel-source', aiIntelGeoJSON, 80);
|
||
useImperativeSource(mapForHook, 'ukraine-alerts-source', ukraineAlertsGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'ukraine-alert-labels-source', ukraineAlertLabelsGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'weather-alerts-source', weatherAlertsGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'weather-alert-labels-source', weatherAlertLabelsGeoJSON, 120);
|
||
useImperativeSource(mapForHook, 'carriers', carriersGeoJSON, 75);
|
||
useImperativeSource(mapForHook, 'active-route', activeRouteGeoJSON, 50);
|
||
useImperativeSource(mapForHook, 'flight-trail', trailGeoJSON, 40);
|
||
useImperativeSource(mapForHook, 'predictive-path', predictiveGeoJSON, 40);
|
||
useImperativeSource(mapForHook, 'proximity-rings', proximityRingsGeoJSON, 60);
|
||
|
||
const handleMouseMove = useCallback(
|
||
(evt: MapLayerMouseEvent) => {
|
||
if (onMouseCoords) onMouseCoords({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
||
},
|
||
[onMouseCoords],
|
||
);
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const opacityFilter: any = selectedEntity
|
||
? [
|
||
'case',
|
||
[
|
||
'all',
|
||
['==', ['get', 'type'], selectedEntity.type],
|
||
['==', ['get', 'id'], selectedEntity.id],
|
||
],
|
||
1.0,
|
||
0.0,
|
||
]
|
||
: 1.0;
|
||
|
||
return (
|
||
<div
|
||
className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint', 'gt_risk'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`}
|
||
style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined}
|
||
>
|
||
<Map
|
||
ref={mapRef}
|
||
reuseMaps
|
||
maxTileCacheSize={200}
|
||
fadeDuration={0}
|
||
style={{ width: '100%', height: '100%' }}
|
||
initialViewState={initialViewState}
|
||
onMoveStart={() => {
|
||
setIsMapInteracting((prev) => (prev ? prev : true));
|
||
}}
|
||
onMove={(evt) => {
|
||
viewStateRef.current = evt.viewState;
|
||
}}
|
||
onMoveEnd={() => {
|
||
setIsMapInteracting(false);
|
||
const currentViewState = viewStateRef.current;
|
||
setMapZoom((prevZoom) =>
|
||
Math.abs(prevZoom - currentViewState.zoom) > 0.01 ? currentViewState.zoom : prevZoom,
|
||
);
|
||
onViewStateChange?.({
|
||
zoom: currentViewState.zoom,
|
||
latitude: currentViewState.latitude,
|
||
});
|
||
updateBounds();
|
||
}}
|
||
onMouseMove={handleMouseMove}
|
||
onContextMenu={(evt) => {
|
||
evt.preventDefault();
|
||
onRightClick?.({ lat: evt.lngLat.lat, lng: evt.lngLat.lng });
|
||
}}
|
||
mapStyle={mapThemeStyle}
|
||
mapLib={maplibregl}
|
||
attributionControl={false}
|
||
onLoad={onMapLoad}
|
||
onStyleData={onMapStyleData}
|
||
onIdle={() => {
|
||
setIsMapInteracting(false);
|
||
updateBounds();
|
||
}}
|
||
interactiveLayerIds={activeInteractiveLayerIds}
|
||
onClick={(e) => {
|
||
// Measurement mode: place waypoints instead of selecting entities
|
||
if (measureMode && onMeasureClick) {
|
||
onMeasureClick({ lat: e.lngLat.lat, lng: e.lngLat.lng });
|
||
return;
|
||
}
|
||
// SAR AOI drop mode
|
||
if (sarAoiDropMode) {
|
||
onSarAoiDropped?.({ lat: e.lngLat.lat, lng: e.lngLat.lng });
|
||
return;
|
||
}
|
||
// Pin placement mode
|
||
if (pinPlacementMode) {
|
||
const clickedFeature = e.features?.[0];
|
||
const clickedProps = clickedFeature?.properties || {};
|
||
const isEntity = clickedFeature && clickedProps.type && clickedProps.id && !clickedProps.cluster;
|
||
setPendingPin({
|
||
lat: e.lngLat.lat,
|
||
lng: e.lngLat.lng,
|
||
entity: isEntity ? {
|
||
entity_type: String(clickedProps.type || ''),
|
||
entity_id: String(clickedProps.id || ''),
|
||
entity_label: String(clickedProps.name || clickedProps.callsign || clickedProps.label || ''),
|
||
} : null,
|
||
});
|
||
return;
|
||
}
|
||
// AI Intel pin click → open detail popup (takes precedence over entity selection)
|
||
if (e.features && e.features.length > 0) {
|
||
const aiPin = e.features.find(
|
||
(f) => f.layer?.id === 'ai-intel-pin-layer' && !(f.properties as Record<string, unknown> | null)?.cluster,
|
||
);
|
||
if (aiPin && aiPin.properties?.id) {
|
||
setOpenPinDetailId(String(aiPin.properties.id));
|
||
return;
|
||
}
|
||
}
|
||
if (selectedEntity) {
|
||
onEntityClick?.(null);
|
||
} else if (e.features && e.features.length > 0) {
|
||
// SAR AOI fill spans large polygons (often hundreds of km wide)
|
||
// and renders above entity layers. If an entity (flight, ship,
|
||
// SDR receiver, etc.) is also under the cursor, prefer it — the
|
||
// AOI should only win when the user clicks empty space inside it.
|
||
const nonAoiFeature = e.features.find(
|
||
(f) => f.layer?.id !== 'sar-aois-fill',
|
||
);
|
||
const feature = nonAoiFeature ?? e.features[0];
|
||
const props = feature.properties || {};
|
||
|
||
// If the clicked feature is a cluster, zoom into it instead of selecting an entity
|
||
if (props.cluster) {
|
||
const targetZoom = (mapRef.current?.getMap().getZoom() ?? mapZoom) + 2;
|
||
mapRef.current?.flyTo({
|
||
center: [e.lngLat.lng, e.lngLat.lat],
|
||
zoom: targetZoom,
|
||
duration: 500,
|
||
});
|
||
return;
|
||
}
|
||
onEntityClick?.({
|
||
id: props.id,
|
||
type: props.type,
|
||
name: props.name,
|
||
media_url: props.media_url,
|
||
extra: props,
|
||
});
|
||
} else {
|
||
onEntityClick?.(null);
|
||
}
|
||
}}
|
||
>
|
||
<AttributionControl
|
||
compact
|
||
customAttribution={[
|
||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a>',
|
||
'<a href="https://carto.com/attribution" target="_blank" rel="noopener">CARTO</a>',
|
||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol (ODbL)</a>',
|
||
'<a href="https://opensky-network.org" target="_blank" rel="noopener">OpenSky</a>',
|
||
'<a href="https://celestrak.org" target="_blank" rel="noopener">CelesTrak</a>',
|
||
'<a href="https://aisstream.io" target="_blank" rel="noopener">aisstream.io</a>',
|
||
'<a href="https://meshtastic.liamcottle.net" target="_blank" rel="noopener">Meshtastic map by Liam Cottle</a>',
|
||
'NASA · NOAA · USGS · GDELT',
|
||
'<a href="https://github.com/BigBodyCobain/Shadowbroker/blob/main/DATA-ATTRIBUTION.md" target="_blank" rel="noopener">full sources</a>',
|
||
]}
|
||
/>
|
||
{/* Esri World Imagery — high-res static satellite (zoom 0-18+) */}
|
||
{activeLayers.highres_satellite && (
|
||
<Source
|
||
id="esri-world-imagery"
|
||
type="raster"
|
||
tiles={[
|
||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||
]}
|
||
tileSize={256}
|
||
maxzoom={18}
|
||
attribution="Esri, Maxar, Earthstar Geographics"
|
||
>
|
||
<Layer
|
||
id="esri-world-imagery-layer"
|
||
type="raster"
|
||
beforeId="imagery-ceiling"
|
||
paint={{
|
||
'raster-opacity': 1,
|
||
'raster-fade-duration': 300,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* NASA GIBS MODIS Terra — daily satellite imagery overlay */}
|
||
{activeLayers.gibs_imagery && gibsDate && (
|
||
<Source
|
||
key={`gibs-${gibsDate}`}
|
||
id="gibs-modis"
|
||
type="raster"
|
||
tiles={[
|
||
`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/MODIS_Terra_CorrectedReflectance_TrueColor/default/${gibsDate}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg`,
|
||
]}
|
||
tileSize={256}
|
||
maxzoom={9}
|
||
>
|
||
<Layer
|
||
id="gibs-modis-layer"
|
||
type="raster"
|
||
beforeId="imagery-ceiling"
|
||
paint={{
|
||
'raster-opacity': gibsOpacity ?? 0.6,
|
||
'raster-fade-duration': 0,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* NASA GIBS VIIRS Night Lights — Black Marble night-lights overlay */}
|
||
{activeLayers.viirs_nightlights && viirsResolvedTileTemplate && (() => {
|
||
const viirsTileTemplate = viirsResolvedTileTemplate;
|
||
return (
|
||
<Source
|
||
key={`viirs-nl-${viirsTileTemplate}`}
|
||
id="viirs-nightlights"
|
||
type="raster"
|
||
tiles={[viirsTileTemplate]}
|
||
tileSize={256}
|
||
maxzoom={8}
|
||
>
|
||
<Layer
|
||
id="viirs-nightlights-layer"
|
||
type="raster"
|
||
beforeId="imagery-ceiling"
|
||
paint={{
|
||
'raster-opacity': 0.9,
|
||
'raster-fade-duration': 0,
|
||
}}
|
||
/>
|
||
</Source>
|
||
);
|
||
})()}
|
||
|
||
{/* Sentinel Hub — user-provided Copernicus CDSE WMTS tiles */}
|
||
{activeLayers.sentinel_hub && sentinelTileUrl && (
|
||
<Source
|
||
key={`sentinel-${sentinelDate}-${sentinelPreset}`}
|
||
id="sentinel-hub"
|
||
type="raster"
|
||
tiles={[sentinelTileUrl]}
|
||
tileSize={256}
|
||
minzoom={5}
|
||
maxzoom={14}
|
||
>
|
||
<Layer
|
||
id="sentinel-hub-layer"
|
||
type="raster"
|
||
beforeId="imagery-ceiling"
|
||
paint={{
|
||
'raster-opacity': sentinelOpacity ?? 0.6,
|
||
'raster-fade-duration': 0,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* Esri Reference Overlay — borders, labels, and places on top of imagery layers */}
|
||
{showImageryReferenceOverlay && (
|
||
<Source
|
||
id="esri-reference-overlay"
|
||
type="raster"
|
||
tiles={[
|
||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||
]}
|
||
tileSize={256}
|
||
maxzoom={18}
|
||
>
|
||
<Layer
|
||
id="esri-reference-overlay-layer"
|
||
type="raster"
|
||
paint={{
|
||
'raster-opacity': imageryReferenceOverlayOpacity,
|
||
'raster-fade-duration': 300,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* NASA FIRMS VIIRS — fire hotspot icons from FIRMS CSV feed */}
|
||
{/* firms-fires: data pushed imperatively via useImperativeSource */}
|
||
<Source
|
||
id="firms-fires"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={40}
|
||
clusterMaxZoom={10}
|
||
>
|
||
{/* Cluster fire icons — flame shape to differentiate from Global Incidents circles */}
|
||
<Layer
|
||
id="firms-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': [
|
||
'step',
|
||
['get', 'point_count'],
|
||
'fire-cluster-sm',
|
||
10,
|
||
'fire-cluster-md',
|
||
50,
|
||
'fire-cluster-lg',
|
||
200,
|
||
'fire-cluster-xl',
|
||
],
|
||
'icon-size': ['step', ['get', 'point_count'], 1.0, 10, 1.1, 50, 1.2, 200, 1.3],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': ['step', ['get', 'point_count'], 9, 10, 10, 50, 11, 200, 12],
|
||
'text-offset': [0, 0.15],
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||
'text-halo-width': 1.2,
|
||
}}
|
||
/>
|
||
{/* Individual fire icons — flame shape sized by FRP */}
|
||
<Layer
|
||
id="firms-viirs-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.4, 5, 0.6, 8, 0.8, 12, 1.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* SOLAR TERMINATOR — night overlay */}
|
||
{activeLayers.day_night && (
|
||
<Source id="night-overlay" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="night-overlay-layer"
|
||
type="fill"
|
||
paint={{
|
||
'fill-color': '#0a0e1a',
|
||
'fill-opacity': 0.35,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* ═══ GROUND OVERLAYS — rendered below ships, mesh, and flights ═══ */}
|
||
|
||
<Source id="frontlines" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="ukraine-frontline-layer"
|
||
type="fill"
|
||
paint={{
|
||
'fill-color': '#ff0000',
|
||
'fill-opacity': 0.3,
|
||
'fill-outline-color': '#ff5500',
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
<Source
|
||
id="earthquakes"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterMaxZoom={10}
|
||
clusterRadius={60}
|
||
>
|
||
{/* Earthquake cluster circles */}
|
||
<Layer
|
||
id="eq-clusters-layer"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-color': 'rgba(255, 170, 0, 0.85)',
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 5, 16, 10, 20, 20, 24],
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': 'rgba(255, 200, 0, 1.0)',
|
||
}}
|
||
/>
|
||
{/* Individual (unclustered) earthquake icons */}
|
||
<Layer
|
||
id="earthquakes-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'icon-threat',
|
||
'icon-size': 0.5,
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
paint={{ 'icon-opacity': 1.0 }}
|
||
/>
|
||
</Source>
|
||
|
||
{/* GPS Jamming Zones — red translucent grid squares */}
|
||
<Source id="gps-jamming" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="gps-jamming-fill"
|
||
type="fill"
|
||
paint={{
|
||
'fill-color': '#ff0040',
|
||
'fill-opacity': ['get', 'opacity'],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="gps-jamming-outline"
|
||
type="line"
|
||
paint={{
|
||
'line-color': '#ff0040',
|
||
'line-width': 1.5,
|
||
'line-opacity': 0.6,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="gps-jamming-label"
|
||
type="symbol"
|
||
layout={{
|
||
'text-field': [
|
||
'concat',
|
||
'GPS JAM ',
|
||
['to-string', ['round', ['*', 100, ['get', 'ratio']]]],
|
||
'%',
|
||
],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 8, 5, 10, 8, 12],
|
||
'text-allow-overlap': false,
|
||
'text-ignore-placement': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#ff4060',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Strategic Risk Heatmap — Bayesian posterior scores */}
|
||
<Source id="gt-risk-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="gt-risk-heatmap"
|
||
type="circle"
|
||
minzoom={2}
|
||
paint={{
|
||
'circle-radius': [
|
||
'interpolate',
|
||
['linear'],
|
||
['zoom'],
|
||
2,
|
||
['+', 6, ['*', 14, ['get', 'risk']]],
|
||
6,
|
||
['+', 10, ['*', 28, ['get', 'risk']]],
|
||
10,
|
||
['+', 14, ['*', 40, ['get', 'risk']]],
|
||
],
|
||
'circle-color': [
|
||
'interpolate',
|
||
['linear'],
|
||
['get', 'risk'],
|
||
0.15,
|
||
'#22c55e',
|
||
0.35,
|
||
'#84cc16',
|
||
0.5,
|
||
'#eab308',
|
||
0.65,
|
||
'#f97316',
|
||
0.8,
|
||
'#ef4444',
|
||
],
|
||
'circle-opacity': [
|
||
'interpolate',
|
||
['linear'],
|
||
['get', 'risk'],
|
||
0.15,
|
||
0.22,
|
||
0.8,
|
||
0.72,
|
||
],
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#fbbf24',
|
||
'circle-stroke-opacity': 0.35,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Correlation Alerts — Emergent Intelligence grid squares */}
|
||
<Source id="correlations" type="geojson" data={EMPTY_FC}>
|
||
{/* RF Anomaly — grey */}
|
||
<Layer
|
||
id="corr-rf-fill"
|
||
type="fill"
|
||
filter={['==', ['get', 'corr_type'], 'rf_anomaly']}
|
||
minzoom={3}
|
||
paint={{
|
||
'fill-color': '#6b7280',
|
||
'fill-opacity': ['get', 'opacity'],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-rf-outline"
|
||
type="line"
|
||
filter={['==', ['get', 'corr_type'], 'rf_anomaly']}
|
||
minzoom={3}
|
||
paint={{
|
||
'line-color': '#6b7280',
|
||
'line-width': 1.5,
|
||
'line-opacity': 0.6,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-rf-label"
|
||
type="symbol"
|
||
filter={['==', ['get', 'corr_type'], 'rf_anomaly']}
|
||
minzoom={3}
|
||
layout={{
|
||
'text-field': ['concat', 'RF ANOMALY\n', ['get', 'drivers']],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 7, 5, 9, 8, 11],
|
||
'text-allow-overlap': false,
|
||
'text-ignore-placement': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#9ca3af',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
{/* Military Buildup — red dashed */}
|
||
<Layer
|
||
id="corr-mil-fill"
|
||
type="fill"
|
||
filter={['==', ['get', 'corr_type'], 'military_buildup']}
|
||
minzoom={3}
|
||
paint={{
|
||
'fill-color': '#dc2626',
|
||
'fill-opacity': ['get', 'opacity'],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-mil-outline"
|
||
type="line"
|
||
filter={['==', ['get', 'corr_type'], 'military_buildup']}
|
||
minzoom={3}
|
||
paint={{
|
||
'line-color': '#dc2626',
|
||
'line-width': 2,
|
||
'line-opacity': 0.7,
|
||
'line-dasharray': [4, 2],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-mil-label"
|
||
type="symbol"
|
||
filter={['==', ['get', 'corr_type'], 'military_buildup']}
|
||
minzoom={3}
|
||
layout={{
|
||
'text-field': ['concat', 'MIL BUILDUP\n', ['get', 'drivers']],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 7, 5, 9, 8, 11],
|
||
'text-allow-overlap': false,
|
||
'text-ignore-placement': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#f87171',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
{/* Infrastructure Cascade — black */}
|
||
<Layer
|
||
id="corr-infra-fill"
|
||
type="fill"
|
||
filter={['==', ['get', 'corr_type'], 'infra_cascade']}
|
||
minzoom={3}
|
||
paint={{
|
||
'fill-color': '#1f2937',
|
||
'fill-opacity': ['get', 'opacity'],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-infra-outline"
|
||
type="line"
|
||
filter={['==', ['get', 'corr_type'], 'infra_cascade']}
|
||
minzoom={3}
|
||
paint={{
|
||
'line-color': '#374151',
|
||
'line-width': 1.5,
|
||
'line-opacity': 0.7,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-infra-label"
|
||
type="symbol"
|
||
filter={['==', ['get', 'corr_type'], 'infra_cascade']}
|
||
minzoom={3}
|
||
layout={{
|
||
'text-field': ['concat', 'INFRA CASCADE\n', ['get', 'drivers']],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 7, 5, 9, 8, 11],
|
||
'text-allow-overlap': false,
|
||
'text-ignore-placement': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#9ca3af',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
{/* Possible Contradiction — amber pulsing, hypothesis not verdict */}
|
||
<Layer
|
||
id="corr-contra-fill"
|
||
type="fill"
|
||
filter={['==', ['get', 'corr_type'], 'contradiction']}
|
||
minzoom={2}
|
||
paint={{
|
||
'fill-color': '#f59e0b',
|
||
'fill-opacity': ['get', 'opacity'],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-contra-outline"
|
||
type="line"
|
||
filter={['==', ['get', 'corr_type'], 'contradiction']}
|
||
minzoom={2}
|
||
paint={{
|
||
'line-color': '#f59e0b',
|
||
'line-width': 2,
|
||
'line-opacity': 0.7,
|
||
'line-dasharray': [6, 3],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="corr-contra-label"
|
||
type="symbol"
|
||
filter={['==', ['get', 'corr_type'], 'contradiction']}
|
||
minzoom={2}
|
||
layout={{
|
||
'text-field': ['concat', '? CONTRADICTION\n', ['get', 'context'], ' · ', ['get', 'drivers']],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 7, 5, 9, 8, 11],
|
||
'text-allow-overlap': false,
|
||
'text-ignore-placement': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#fbbf24',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
{/* Analysis Zone fill */}
|
||
<Layer
|
||
id="corr-analysis-fill"
|
||
type="fill"
|
||
filter={['==', ['get', 'corr_type'], 'analysis_zone']}
|
||
paint={{
|
||
'fill-color': ['match', ['get', 'zone_category'],
|
||
'contradiction', '#f59e0b',
|
||
'warning', '#ef4444',
|
||
'observation', '#3b82f6',
|
||
'hypothesis', '#a855f7',
|
||
'#06b6d4',
|
||
],
|
||
'fill-opacity': ['get', 'opacity'],
|
||
}}
|
||
/>
|
||
{/* Analysis Zone dashed outline */}
|
||
<Layer
|
||
id="corr-analysis-outline"
|
||
type="line"
|
||
filter={['==', ['get', 'corr_type'], 'analysis_zone']}
|
||
paint={{
|
||
'line-color': ['match', ['get', 'zone_category'],
|
||
'contradiction', '#f59e0b',
|
||
'warning', '#ef4444',
|
||
'observation', '#3b82f6',
|
||
'hypothesis', '#a855f7',
|
||
'#06b6d4',
|
||
],
|
||
'line-width': 1.5,
|
||
'line-dasharray': [4, 3],
|
||
'line-opacity': 0.7,
|
||
}}
|
||
/>
|
||
{/* Analysis Zone label */}
|
||
<Layer
|
||
id="corr-analysis-label"
|
||
type="symbol"
|
||
filter={['==', ['get', 'corr_type'], 'analysis_zone']}
|
||
minzoom={2}
|
||
layout={{
|
||
'text-field': ['concat', ['get', 'zone_title'], '\n', ['get', 'drivers']],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 7, 5, 9, 8, 11],
|
||
'text-allow-overlap': false,
|
||
'text-ignore-placement': false,
|
||
'text-max-width': 18,
|
||
}}
|
||
paint={{
|
||
'text-color': '#67e8f9',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* CCTV Cameras — clustered white dots */}
|
||
<Source
|
||
id="cctv"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={50}
|
||
clusterMaxZoom={14}
|
||
>
|
||
{/* Cluster circles — white, sized by count */}
|
||
<Layer
|
||
id="cctv-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-color': '#ffffff',
|
||
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24, 200, 30],
|
||
'circle-opacity': 0.8,
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#a0a0a0',
|
||
}}
|
||
/>
|
||
{/* Cluster count labels */}
|
||
<Layer
|
||
id="cctv-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-size': 12,
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#000000',
|
||
'text-halo-color': '#ffffff',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
{/* Individual camera dots */}
|
||
<Layer
|
||
id="cctv-layer"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-color': '#ffffff',
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 8, 4, 14, 6],
|
||
'circle-opacity': 0.9,
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#a0a0a0',
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* KiwiSDR Receivers — radio tower icons with pulse rings */}
|
||
<Source
|
||
id="kiwisdr"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={50}
|
||
clusterMaxZoom={14}
|
||
>
|
||
{/* Pulse ring behind clusters */}
|
||
<Layer
|
||
id="kiwisdr-cluster-pulse"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 26, 50, 32, 200, 40],
|
||
'circle-color': 'rgba(245, 158, 11, 0.08)',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(245, 158, 11, 0.35)',
|
||
'circle-blur': 0.4,
|
||
}}
|
||
/>
|
||
{/* Clusters — tower icon with count */}
|
||
<Layer
|
||
id="kiwisdr-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'svgRadioTower',
|
||
'icon-size': 0.9,
|
||
'icon-allow-overlap': true,
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.4],
|
||
'text-allow-overlap': true,
|
||
'text-font': ['Noto Sans Bold'],
|
||
}}
|
||
paint={{
|
||
'text-color': '#f59e0b',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
{/* Pulse ring behind individual towers */}
|
||
<Layer
|
||
id="kiwisdr-pulse"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 8, 10, 14, 14],
|
||
'circle-color': 'rgba(245, 158, 11, 0.06)',
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': 'rgba(245, 158, 11, 0.3)',
|
||
'circle-blur': 0.5,
|
||
}}
|
||
/>
|
||
{/* Individual tower icons */}
|
||
<Layer
|
||
id="kiwisdr-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'svgRadioTower',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 8, 0.8, 14, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* PSK Reporter — green HF digital mode spots with clustering */}
|
||
<Source
|
||
id="psk-reporter"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={50}
|
||
clusterMaxZoom={14}
|
||
>
|
||
{/* Pulse ring behind clusters */}
|
||
<Layer
|
||
id="psk-reporter-cluster-pulse"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
minzoom={4}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 26, 50, 32, 200, 40],
|
||
'circle-color': 'rgba(34, 197, 94, 0.08)',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(34, 197, 94, 0.35)',
|
||
'circle-blur': 0.4,
|
||
}}
|
||
/>
|
||
{/* Clusters — count */}
|
||
<Layer
|
||
id="psk-reporter-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
minzoom={4}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20, 200, 26],
|
||
'circle-color': 'rgba(34, 197, 94, 0.6)',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(34, 197, 94, 0.9)',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="psk-reporter-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
minzoom={4}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-size': 10,
|
||
'text-allow-overlap': true,
|
||
'text-font': ['Noto Sans Bold'],
|
||
}}
|
||
paint={{
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
{/* Individual spots — small green dots */}
|
||
<Layer
|
||
id="psk-reporter-pulse"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
minzoom={4}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 4, 8, 6, 14, 8],
|
||
'circle-color': 'rgba(34, 197, 94, 0.06)',
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': 'rgba(34, 197, 94, 0.3)',
|
||
'circle-blur': 0.5,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="psk-reporter-layer"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
minzoom={4}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2.5, 8, 4, 14, 6],
|
||
'circle-color': '#22c55e',
|
||
'circle-stroke-width': 0.5,
|
||
'circle-stroke-color': 'rgba(34, 197, 94, 0.8)',
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* SatNOGS Ground Stations — teal satellite dish icons with clustering */}
|
||
<Source
|
||
id="satnogs"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={50}
|
||
clusterMaxZoom={14}
|
||
>
|
||
<Layer
|
||
id="satnogs-cluster-pulse"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 26, 50, 32, 200, 40],
|
||
'circle-color': 'rgba(20, 184, 166, 0.08)',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(20, 184, 166, 0.35)',
|
||
'circle-blur': 0.4,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="satnogs-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'svgSatDish',
|
||
'icon-size': 0.9,
|
||
'icon-allow-overlap': true,
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.4],
|
||
'text-allow-overlap': true,
|
||
'text-font': ['Noto Sans Bold'],
|
||
}}
|
||
paint={{
|
||
'text-color': '#14b8a6',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="satnogs-pulse"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 8, 10, 14, 14],
|
||
'circle-color': 'rgba(20, 184, 166, 0.06)',
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': 'rgba(20, 184, 166, 0.3)',
|
||
'circle-blur': 0.5,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="satnogs-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'svgSatDish',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 8, 0.8, 14, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* TinyGS LoRa Satellites — purple satellite icons (no clustering, small count) */}
|
||
<Source id="tinygs" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="tinygs-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': 'svgLoRaSat',
|
||
'icon-size': 0.8,
|
||
'icon-allow-overlap': true,
|
||
'text-field': ['get', 'name'],
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.4],
|
||
'text-optional': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#c084fc',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Police Scanners (OpenMHZ) — red scanner icons with clusters */}
|
||
<Source
|
||
id="scanners"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={50}
|
||
clusterMaxZoom={14}
|
||
>
|
||
{/* Pulse ring behind clusters */}
|
||
<Layer
|
||
id="scanner-cluster-pulse"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 26, 50, 32, 200, 40],
|
||
'circle-color': 'rgba(220, 38, 38, 0.08)',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(220, 38, 38, 0.35)',
|
||
'circle-blur': 0.4,
|
||
}}
|
||
/>
|
||
{/* Cluster icons + count */}
|
||
<Layer
|
||
id="scanner-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'svgScannerTower',
|
||
'icon-size': 0.9,
|
||
'icon-allow-overlap': true,
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.4],
|
||
'text-allow-overlap': true,
|
||
'text-font': ['Noto Sans Bold'],
|
||
}}
|
||
paint={{
|
||
'text-color': '#dc2626',
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
{/* Pulse ring behind individual scanners */}
|
||
<Layer
|
||
id="scanner-pulse"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 8, 10, 14, 14],
|
||
'circle-color': 'rgba(220, 38, 38, 0.06)',
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': 'rgba(220, 38, 38, 0.3)',
|
||
'circle-blur': 0.5,
|
||
}}
|
||
/>
|
||
{/* Individual scanner icons */}
|
||
<Layer
|
||
id="scanner-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'svgScannerTower',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 8, 0.8, 14, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Internet Outages — region-level grey markers with % and labels */}
|
||
<Source id="internet-outages" type="geojson" data={EMPTY_FC}>
|
||
{/* Outer ring */}
|
||
<Layer
|
||
id="internet-outages-pulse"
|
||
type="circle"
|
||
paint={{
|
||
'circle-radius': [
|
||
'interpolate',
|
||
['linear'],
|
||
['get', 'severity'],
|
||
0,
|
||
14,
|
||
50,
|
||
18,
|
||
80,
|
||
22,
|
||
],
|
||
'circle-color': 'rgba(180, 180, 180, 0.1)',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': 'rgba(180, 180, 180, 0.35)',
|
||
}}
|
||
/>
|
||
{/* Inner solid circle — all grey, size conveys severity */}
|
||
<Layer
|
||
id="internet-outages-layer"
|
||
type="circle"
|
||
paint={{
|
||
'circle-radius': [
|
||
'interpolate',
|
||
['linear'],
|
||
['get', 'severity'],
|
||
0,
|
||
6,
|
||
50,
|
||
9,
|
||
80,
|
||
12,
|
||
],
|
||
'circle-color': '#888888',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': 'rgba(0, 0, 0, 0.6)',
|
||
'circle-opacity': 0.9,
|
||
}}
|
||
/>
|
||
{/* Severity % inside circle */}
|
||
<Layer
|
||
id="internet-outages-pct"
|
||
type="symbol"
|
||
layout={{
|
||
'text-field': [
|
||
'case',
|
||
['>', ['get', 'severity'], 0],
|
||
['concat', ['to-string', ['get', 'severity']], '%'],
|
||
'!',
|
||
],
|
||
'text-size': 9,
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-allow-overlap': true,
|
||
'text-ignore-placement': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
{/* Region name label below — grey */}
|
||
<Layer
|
||
id="internet-outages-label"
|
||
type="symbol"
|
||
layout={{
|
||
'text-field': ['get', 'region'],
|
||
'text-size': 10,
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-offset': [0, 1.8],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#aaaaaa',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Data Center positions */}
|
||
<Source
|
||
id="datacenters"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={30}
|
||
clusterMaxZoom={8}
|
||
>
|
||
{/* Cluster circles */}
|
||
<Layer
|
||
id="datacenters-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-color': '#7c3aed',
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20],
|
||
'circle-opacity': 0.7,
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#a78bfa',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="datacenters-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 10,
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#e9d5ff',
|
||
}}
|
||
/>
|
||
{/* Individual DC icons */}
|
||
<Layer
|
||
id="datacenters-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'datacenter',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 0.7, 10, 1.0],
|
||
'icon-allow-overlap': true,
|
||
'text-field': ['step', ['zoom'], '', 6, ['get', 'name']],
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-size': 9,
|
||
'text-offset': [0, 1.2],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#c4b5fd',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Power Plant positions */}
|
||
{powerPlantsGeoJSON && (
|
||
<Source id="power-plants" type="geojson" data={EMPTY_FC} cluster={true} clusterRadius={30} clusterMaxZoom={8}>
|
||
{/* Cluster circles */}
|
||
<Layer
|
||
id="power-plants-clusters"
|
||
type="circle"
|
||
minzoom={4}
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-color': '#92400e',
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20],
|
||
'circle-opacity': 0.7,
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#f59e0b',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="power-plants-cluster-count"
|
||
type="symbol"
|
||
minzoom={4}
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 10,
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#fde68a',
|
||
}}
|
||
/>
|
||
{/* Individual power plant icons */}
|
||
<Layer
|
||
id="power-plants-layer"
|
||
type="symbol"
|
||
minzoom={4}
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'power-plant',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 0.7, 10, 1.0],
|
||
'icon-allow-overlap': true,
|
||
'text-field': ['step', ['zoom'], '', 6, ['get', 'name']],
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-size': 9,
|
||
'text-offset': [0, 1.2],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#fbbf24',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* VIIRS Change Detection Nodes */}
|
||
{viirsChangeNodesGeoJSON && (
|
||
<Source id="viirs-change-nodes" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="viirs-change-nodes-layer"
|
||
type="circle"
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 8, 10, 12],
|
||
'circle-color': ['get', 'color'],
|
||
'circle-opacity': 0.85,
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': 'rgba(255,255,255,0.4)',
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* SAR AOIs — operator watchbox circles, drawn beneath anomaly pins */}
|
||
{sarAoisGeoJSON && (
|
||
<Source id="sar-aois" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="sar-aois-fill"
|
||
type="fill"
|
||
paint={{
|
||
'fill-color': [
|
||
'match',
|
||
['get', 'category'],
|
||
'conflict', '#ef4444',
|
||
'geohazard', '#f97316',
|
||
'infrastructure', '#06b6d4',
|
||
'geopolitical', '#a855f7',
|
||
'#eab308',
|
||
],
|
||
'fill-opacity': 0.08,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="sar-aois-outline"
|
||
type="line"
|
||
paint={{
|
||
'line-color': [
|
||
'match',
|
||
['get', 'category'],
|
||
'conflict', '#ef4444',
|
||
'geohazard', '#f97316',
|
||
'infrastructure', '#06b6d4',
|
||
'geopolitical', '#a855f7',
|
||
'#eab308',
|
||
],
|
||
'line-width': 1.2,
|
||
'line-opacity': 0.55,
|
||
'line-dasharray': [2, 2],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="sar-aois-label"
|
||
type="symbol"
|
||
minzoom={4}
|
||
layout={{
|
||
'text-field': ['get', 'name'],
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-size': 10,
|
||
'text-offset': [0, 0.5],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#fde68a',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* SAR Anomalies — Mode B pre-processed findings (OPERA/EGMS/GFM/EMS/UNOSAT) */}
|
||
{sarAnomaliesGeoJSON && (
|
||
<Source id="sar-anomalies" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="sar-anomalies-halo"
|
||
type="circle"
|
||
paint={{
|
||
'circle-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
2, 6, 6, 10, 10, 16,
|
||
],
|
||
'circle-color': ['get', 'color'],
|
||
'circle-opacity': 0.2,
|
||
'circle-blur': 0.6,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="sar-anomalies-layer"
|
||
type="circle"
|
||
paint={{
|
||
'circle-radius': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
2, 3, 6, 5, 10, 8,
|
||
],
|
||
'circle-color': ['get', 'color'],
|
||
'circle-opacity': 0.9,
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': '#000',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="sar-anomalies-label"
|
||
type="symbol"
|
||
minzoom={7}
|
||
layout={{
|
||
'text-field': ['get', 'title'],
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.2],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
'text-max-width': 12,
|
||
}}
|
||
paint={{
|
||
'text-color': '#fef3c7',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
)}
|
||
|
||
{/* Shodan — operator-triggered local overlay, clustered and clearly distinct */}
|
||
{(() => {
|
||
const sc = shodanStyle ?? { shape: 'circle' as const, color: '#16a34a', size: 'md' as const };
|
||
const sizeMap = { sm: [3, 4, 5] as const, md: [4, 6, 8] as const, lg: [6, 9, 12] as const };
|
||
const textSizeMap = { sm: 10, md: 14, lg: 20 };
|
||
const shapeGlyphs: Record<string, string> = { triangle: '▲', diamond: '◆', square: '■' };
|
||
const radii = sizeMap[sc.size] ?? sizeMap.md;
|
||
const isCircle = sc.shape === 'circle';
|
||
const labelOffset = isCircle ? 1.1 : (sc.size === 'lg' ? 1.6 : sc.size === 'sm' ? 0.9 : 1.2);
|
||
return (
|
||
<Source
|
||
id="shodan-overlay"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={42}
|
||
clusterMaxZoom={9}
|
||
>
|
||
{/* Cluster circles — always circles, inherit color */}
|
||
<Layer
|
||
id="shodan-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-color': sc.color,
|
||
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 22, 200, 26],
|
||
'circle-opacity': 0.8,
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': `${sc.color}66`,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="shodan-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 10,
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#ffffff',
|
||
}}
|
||
/>
|
||
{/* Individual markers — circle layer (hidden when non-circle shape) */}
|
||
{isCircle && (
|
||
<Layer
|
||
id="shodan-layer"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, radii[0], 6, radii[1], 10, radii[2]],
|
||
'circle-color': sc.color,
|
||
'circle-opacity': 0.9,
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': '#ffffff44',
|
||
}}
|
||
/>
|
||
)}
|
||
{/* Individual markers — symbol layer for triangle/diamond/square */}
|
||
{!isCircle && (
|
||
<Layer
|
||
id="shodan-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'text-field': shapeGlyphs[sc.shape] ?? '●',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': textSizeMap[sc.size] ?? 14,
|
||
'text-allow-overlap': true,
|
||
'text-ignore-placement': true,
|
||
}}
|
||
paint={{
|
||
'text-color': sc.color,
|
||
'text-halo-color': 'rgba(0,0,0,0.7)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
)}
|
||
{/* Labels */}
|
||
<Layer
|
||
id="shodan-labels"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'text-field': ['step', ['zoom'], '', 7, ['get', 'name']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 10,
|
||
'text-offset': [0, labelOffset],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': sc.color,
|
||
'text-halo-color': 'rgba(0,0,0,0.85)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
);
|
||
})()}
|
||
|
||
{/* AI Intel Layer — pins from OpenClaw / AI co-pilot (data via useImperativeSource) */}
|
||
<Source
|
||
id="ai-intel-source"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={40}
|
||
clusterMaxZoom={10}
|
||
>
|
||
<Layer
|
||
id="ai-intel-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-color': '#8b5cf6',
|
||
'circle-radius': ['step', ['get', 'point_count'], 14, 5, 18, 20, 22, 100, 28],
|
||
'circle-opacity': 0.85,
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#a78bfa66',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="ai-intel-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 12,
|
||
}}
|
||
paint={{ 'text-color': '#ffffff' }}
|
||
/>
|
||
<Layer
|
||
id="ai-intel-pin-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': ['concat', 'ai-pin-', ['get', 'category']],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.45, 6, 0.7, 10, 0.9, 14, 1.0],
|
||
'icon-anchor': 'bottom',
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'text-field': ['step', ['zoom'], '', 6, ['get', 'label']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 11,
|
||
'text-offset': [0, 0.5],
|
||
'text-anchor': 'top',
|
||
'text-optional': true,
|
||
}}
|
||
paint={{
|
||
'text-color': ['get', 'color'],
|
||
'text-halo-color': 'rgba(0,0,0,0.85)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Military Bases — per-country colors */}
|
||
<Source id="military-bases" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="military-bases-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 0.8, 10, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="military-bases-label"
|
||
type="symbol"
|
||
layout={{
|
||
'text-field': ['step', ['zoom'], '', 5, ['get', 'name']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.4],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': ['get', 'color'],
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Ukraine Air Raid Alerts — red/orange oblast polygons */}
|
||
<Source id="ukraine-alerts-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="ukraine-alerts-fill"
|
||
type="fill"
|
||
paint={{
|
||
'fill-color': ['get', 'color'],
|
||
'fill-opacity': 0.18,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="ukraine-alerts-outline"
|
||
type="line"
|
||
paint={{
|
||
'line-color': ['get', 'color'],
|
||
'line-width': 2.5,
|
||
'line-opacity': 0.8,
|
||
'line-dasharray': [6, 3],
|
||
}}
|
||
/>
|
||
</Source>
|
||
<Source id="ukraine-alert-labels-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="ukraine-alert-labels"
|
||
type="symbol"
|
||
layout={{
|
||
'text-field': ['concat', ['get', 'alert_label'], '\n', ['get', 'name_en']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 11,
|
||
'text-allow-overlap': false,
|
||
'text-max-width': 12,
|
||
}}
|
||
paint={{
|
||
'text-color': ['get', 'color'],
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Weather Alerts — severity-colored polygons with icon + label overlay */}
|
||
<Source id="weather-alerts-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="weather-alerts-fill"
|
||
type="fill"
|
||
paint={{
|
||
'fill-color': ['get', 'color'],
|
||
'fill-opacity': 0.12,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="weather-alerts-outline"
|
||
type="line"
|
||
paint={{
|
||
'line-color': ['get', 'color'],
|
||
'line-width': 2,
|
||
'line-opacity': 0.7,
|
||
'line-dasharray': [4, 3],
|
||
}}
|
||
/>
|
||
</Source>
|
||
<Source id="weather-alert-labels-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="weather-alert-icons"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': 1.1,
|
||
'icon-allow-overlap': true,
|
||
'icon-anchor': 'bottom',
|
||
'text-field': ['get', 'event'],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 11,
|
||
'text-offset': [0, 0.4],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
'text-max-width': 14,
|
||
}}
|
||
paint={{
|
||
'text-color': ['get', 'color'],
|
||
'text-halo-color': '#000000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Air Quality — AQI-colored circles */}
|
||
<Source id="air-quality-source" type="geojson" data={EMPTY_FC} cluster={true} clusterMaxZoom={8} clusterRadius={40}>
|
||
<Layer
|
||
id="air-quality-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20],
|
||
'circle-color': '#94a3b8',
|
||
'circle-opacity': 0.6,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="air-quality-layer"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5, 10, 8],
|
||
'circle-color': ['get', 'color'],
|
||
'circle-opacity': 0.75,
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#000',
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Volcanoes — activity-colored triangle icons */}
|
||
<Source id="volcanoes-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="volcanoes-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.7, 10, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="volcanoes-label"
|
||
type="symbol"
|
||
layout={{
|
||
'text-field': ['step', ['zoom'], '', 6, ['get', 'name']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 10,
|
||
'text-offset': [0, 1.2],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#f97316',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Fishing Activity — AIS-style ship clusters and icons */}
|
||
<Source
|
||
id="fishing-source"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterMaxZoom={6}
|
||
clusterRadius={50}
|
||
clusterProperties={{
|
||
cargo_count: ['+', ['case', ['==', ['get', 'shipCategory'], 'cargo'], 1, 0]],
|
||
passenger_count: ['+', ['case', ['==', ['get', 'shipCategory'], 'passenger'], 1, 0]],
|
||
military_count: ['+', ['case', ['==', ['get', 'shipCategory'], 'military'], 1, 0]],
|
||
yacht_count: ['+', ['case', ['==', ['get', 'shipCategory'], 'yacht'], 1, 0]],
|
||
civilian_count: ['+', ['case', ['==', ['get', 'shipCategory'], 'civilian'], 1, 0]],
|
||
}}
|
||
>
|
||
<Layer
|
||
id="fishing-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'svgShipBlue',
|
||
'icon-size': ['step', ['get', 'point_count'], 1.35, 10, 1.55, 50, 1.8, 250, 2.05, 1000, 2.3],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'icon-rotate': 90,
|
||
'icon-rotation-alignment': 'viewport',
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 0.98,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="fishing-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': ['step', ['get', 'point_count'], 10, 10, 11, 50, 12, 250, 13, 1000, 14],
|
||
'text-offset': [0, 0.82],
|
||
'text-anchor': 'center',
|
||
'text-allow-overlap': true,
|
||
'text-ignore-placement': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': 'rgba(0, 0, 0, 0.95)',
|
||
'text-halo-width': 1.8,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="fishing-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.4, 6, 0.65, 10, 0.9],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 0.85,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* UAP Sightings — purple UFO icons with detail labels */}
|
||
<Source id="uap-sightings-source" type="geojson" data={EMPTY_FC} cluster={true} clusterMaxZoom={10} clusterRadius={40}>
|
||
{/* Cluster glow — faint backdrop behind UFO icon */}
|
||
<Layer
|
||
id="uap-sightings-cluster-bg"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 14, 50, 18],
|
||
'circle-color': 'rgba(139, 92, 246, 0.10)',
|
||
'circle-stroke-width': 0,
|
||
'circle-stroke-color': 'transparent',
|
||
}}
|
||
/>
|
||
{/* Cluster UFO icon + count */}
|
||
<Layer
|
||
id="uap-sightings-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'ufo-cluster',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 0, 1.45, 2, 1.5, 4, 1.52, 6, 1.48, 8, 1.44, 10, 1.4],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 0, 10, 4, 11, 8, 12],
|
||
'text-offset': [0, 0.05],
|
||
'text-allow-overlap': true,
|
||
'text-ignore-placement': true,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 1,
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': 'rgba(88, 28, 135, 1)',
|
||
'text-halo-width': 2.4,
|
||
}}
|
||
/>
|
||
{/* Individual glow — faint backdrop behind UFO icon */}
|
||
<Layer
|
||
id="uap-sightings-dot"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 4, 10, 5],
|
||
'circle-color': 'rgba(139, 92, 246, 0.20)',
|
||
'circle-stroke-width': 0.75,
|
||
'circle-stroke-color': 'rgba(216, 180, 254, 0.25)',
|
||
}}
|
||
/>
|
||
{/* Individual UFO icon overlay */}
|
||
<Layer
|
||
id="uap-sightings-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'ufo-icon',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 1, 0.7, 3, 0.8, 6, 0.95, 10, 1.1, 14, 1.2],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'text-field': ['step', ['zoom'], '', 5, ['get', 'label']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 9, 10, 11],
|
||
'text-offset': [0, 2.0],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
'text-optional': true,
|
||
'text-max-width': 16,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 1,
|
||
'text-color': '#d8b4fe',
|
||
'text-halo-color': 'rgba(0,0,0,0.98)',
|
||
'text-halo-width': 1.25,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* WastewaterSCAN — pathogen surveillance network (water drops) */}
|
||
<Source id="wastewater-source" type="geojson" data={EMPTY_FC} cluster={true} clusterMaxZoom={10} clusterRadius={35}>
|
||
{/* Cluster glow — faint backdrop behind water drop icon */}
|
||
<Layer
|
||
id="wastewater-cluster-bg"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 14, 50, 18],
|
||
'circle-color': 'rgba(0, 229, 255, 0.10)',
|
||
'circle-stroke-width': 0,
|
||
'circle-stroke-color': 'transparent',
|
||
}}
|
||
/>
|
||
{/* Cluster water drop icon + count */}
|
||
<Layer
|
||
id="wastewater-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'ww-cluster',
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 0, 1.5, 2, 1.55, 4, 1.57, 6, 1.52, 8, 1.46, 10, 1.4],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 0, 10, 4, 11, 8, 12],
|
||
'text-offset': [0, 0.1],
|
||
'text-allow-overlap': true,
|
||
'text-ignore-placement': true,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 1,
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': 'rgba(0, 80, 100, 1)',
|
||
'text-halo-width': 2.4,
|
||
}}
|
||
/>
|
||
{/* Individual glow — faint backdrop behind water drop icon */}
|
||
<Layer
|
||
id="wastewater-dot"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 3, 8, 4, 12, 5, 16, 6],
|
||
'circle-color': ['case', ['>', ['get', 'alert_count'], 0], 'rgba(255, 34, 34, 0.20)', 'rgba(0, 229, 255, 0.20)'],
|
||
'circle-stroke-width': 0.75,
|
||
'circle-stroke-color': ['case', ['>', ['get', 'alert_count'], 0], 'rgba(255, 82, 82, 0.25)', 'rgba(128, 222, 234, 0.25)'],
|
||
}}
|
||
/>
|
||
{/* Individual water drop icon overlay */}
|
||
<Layer
|
||
id="wastewater-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': ['get', 'icon'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 1, 0.7, 3, 0.8, 6, 0.95, 10, 1.1, 14, 1.2],
|
||
'icon-allow-overlap': true,
|
||
'icon-ignore-placement': true,
|
||
'text-field': ['step', ['zoom'], '', 7, ['get', 'label']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 10, 11],
|
||
'text-offset': [0, 2.0],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
'text-optional': true,
|
||
'text-max-width': 16,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 1,
|
||
'text-color': ['case', ['>', ['get', 'alert_count'], 0], '#ff5252', '#80deea'],
|
||
'text-halo-color': 'rgba(0,0,0,0.98)',
|
||
'text-halo-width': 1.25,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* CrowdThreat — crowdsourced threat intelligence with category icons */}
|
||
<Source id="crowdthreat-source" type="geojson" data={EMPTY_FC} cluster={true} clusterMaxZoom={8} clusterRadius={40}>
|
||
<Layer
|
||
id="crowdthreat-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24],
|
||
'circle-color': 'rgba(239, 68, 68, 0.7)',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#ef4444',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="crowdthreat-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': '{point_count_abbreviated}',
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 12,
|
||
}}
|
||
paint={{
|
||
'text-color': '#ffffff',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="crowdthreat-layer"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.6, 6, 0.8, 10, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="crowdthreat-label"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'text-field': ['step', ['zoom'], '', 8, ['get', 'threat_type']],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 9,
|
||
'text-offset': [0, 1.6],
|
||
'text-anchor': 'top',
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#fca5a5',
|
||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Telegram OSINT — one pin per geocoded city; scroll posts in popup */}
|
||
<Source id="telegram-osint-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="telegram-osint-layer"
|
||
type="circle"
|
||
minzoom={4}
|
||
paint={{
|
||
'circle-radius': [
|
||
'interpolate',
|
||
['linear'],
|
||
['zoom'],
|
||
4,
|
||
['case', ['>', ['get', 'post_count'], 1], 14, 11],
|
||
8,
|
||
['case', ['>', ['get', 'post_count'], 1], 20, 16],
|
||
12,
|
||
['case', ['>', ['get', 'post_count'], 1], 26, 22],
|
||
],
|
||
'circle-color': '#ef4444',
|
||
'circle-stroke-width': 0,
|
||
'circle-stroke-color': '#fca5a5',
|
||
'circle-opacity': 0,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Malware C2 — abuse.ch Feodo + URLhaus */}
|
||
<Source id="malware-source" type="geojson" data={EMPTY_FC} cluster={true} clusterMaxZoom={6} clusterRadius={35}>
|
||
<Layer
|
||
id="malware-clusters"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-radius': ['step', ['get', 'point_count'], 12, 8, 16, 30, 22],
|
||
'circle-color': 'rgba(255, 61, 61, 0.75)',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#ff3d3d',
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="malware-layer"
|
||
type="circle"
|
||
filter={['!', ['has', 'point_count']]}
|
||
paint={{
|
||
'circle-radius': 5,
|
||
'circle-color': '#ff1744',
|
||
'circle-stroke-width': 1,
|
||
'circle-stroke-color': '#ff8a80',
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Submarine cables — static TeleGeography GeoJSON */}
|
||
<Source id="submarine-cables-source" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="submarine-cables-layer"
|
||
type="line"
|
||
paint={{
|
||
'line-color': '#eab308',
|
||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 1.2, 10, 2],
|
||
'line-opacity': 0.75,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Ships — rendered below flights (water surface level) */}
|
||
<Source
|
||
id="ships"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterMaxZoom={6}
|
||
clusterRadius={40}
|
||
>
|
||
{/* Clustered circles */}
|
||
<Layer
|
||
id="ships-clusters-layer"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{
|
||
'circle-opacity': opacityFilter,
|
||
'circle-stroke-opacity': opacityFilter,
|
||
'circle-color': 'rgba(30, 64, 175, 0.85)',
|
||
'circle-radius': [
|
||
'step',
|
||
['get', 'point_count'],
|
||
12,
|
||
10,
|
||
15,
|
||
100,
|
||
20,
|
||
1000,
|
||
25,
|
||
5000,
|
||
30,
|
||
],
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': 'rgba(59, 130, 246, 1.0)',
|
||
}}
|
||
/>
|
||
|
||
{/* Cluster count - rendered via HTML markers below */}
|
||
<Layer
|
||
id="ships-cluster-count-layer"
|
||
type="circle"
|
||
filter={['has', 'point_count']}
|
||
paint={{ 'circle-radius': 0, 'circle-opacity': 0 }}
|
||
/>
|
||
|
||
{/* Unclustered individual ships (Cargo, Tankers, etc.) */}
|
||
<Layer
|
||
id="ships-layer"
|
||
type="symbol"
|
||
minzoom={2}
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.35, 5, 0.55, 8, 0.8, 12, 1.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{
|
||
'icon-opacity': opacityFilter,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="carriers" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="carriers-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': 'svgCarrier',
|
||
'icon-size': 0.8,
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Meshtastic — green triangle clusters that break apart on zoom */}
|
||
<Source
|
||
id="meshtastic-source"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={42}
|
||
clusterMaxZoom={8}
|
||
>
|
||
<Layer
|
||
id="meshtastic-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'icon-mesh-triangle',
|
||
'icon-size': [
|
||
'step',
|
||
['get', 'point_count'],
|
||
1.1,
|
||
10,
|
||
1.35,
|
||
50,
|
||
1.65,
|
||
100,
|
||
1.95,
|
||
500,
|
||
2.3,
|
||
],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 0.95,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="meshtastic-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': ['get', 'point_count_abbreviated'],
|
||
'text-size': 11,
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-offset': [0, 0.05],
|
||
'text-anchor': 'center',
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#052e16',
|
||
'text-halo-color': '#86efac',
|
||
'text-halo-width': 0.8,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="meshtastic-circles"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'icon-mesh-triangle',
|
||
'icon-size': 0.7,
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 0.85,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="meshtastic-labels"
|
||
type="symbol"
|
||
minzoom={8}
|
||
layout={{
|
||
'text-field': ['get', 'callsign'],
|
||
'text-size': 9,
|
||
'text-offset': [0, 1.2],
|
||
'text-anchor': 'top',
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#86efac',
|
||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* APRS / JS8Call — pink triangles with clustering */}
|
||
<Source
|
||
id="aprs-source"
|
||
type="geojson"
|
||
data={EMPTY_FC}
|
||
cluster={true}
|
||
clusterRadius={42}
|
||
clusterMaxZoom={8}
|
||
>
|
||
<Layer
|
||
id="aprs-clusters"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'icon-image': 'icon-aprs-triangle',
|
||
'icon-size': [
|
||
'step',
|
||
['get', 'point_count'],
|
||
1.1,
|
||
10,
|
||
1.35,
|
||
50,
|
||
1.65,
|
||
100,
|
||
1.95,
|
||
500,
|
||
2.3,
|
||
],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 0.95,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="aprs-cluster-count"
|
||
type="symbol"
|
||
filter={['has', 'point_count']}
|
||
layout={{
|
||
'text-field': ['get', 'point_count_abbreviated'],
|
||
'text-size': 11,
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-offset': [0, 0.05],
|
||
'text-anchor': 'center',
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': '#4a0525',
|
||
'text-halo-color': '#f9a8d4',
|
||
'text-halo-width': 0.8,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="aprs-triangles"
|
||
type="symbol"
|
||
filter={['!', ['has', 'point_count']]}
|
||
layout={{
|
||
'icon-image': 'icon-aprs-triangle',
|
||
'icon-size': 0.7,
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'icon-opacity': 0.85,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="aprs-labels"
|
||
type="symbol"
|
||
minzoom={8}
|
||
layout={{
|
||
'text-field': ['get', 'callsign'],
|
||
'text-size': 9,
|
||
'text-offset': [0, 1.2],
|
||
'text-anchor': 'top',
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-allow-overlap': false,
|
||
}}
|
||
paint={{
|
||
'text-color': '#f9a8d4',
|
||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* ═══ FLIGHTS — always rendered above ground/ship/mesh layers ═══ */}
|
||
<Source id="commercial-flights" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="commercial-flights-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="private-flights" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="private-flights-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="private-jets" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="private-jets-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="military-flights" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="military-flights-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="active-route" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="active-route-layer"
|
||
type="line"
|
||
filter={['in', ['get', 'type'], ['literal', ['route-origin', 'route-dest']]]}
|
||
paint={{
|
||
'line-color': [
|
||
'match',
|
||
['get', 'type'],
|
||
'route-origin',
|
||
'#38bdf8',
|
||
'route-dest',
|
||
'#fcd34d',
|
||
'#ffffff',
|
||
],
|
||
'line-width': 2,
|
||
'line-dasharray': [2, 2],
|
||
'line-opacity': 0.8,
|
||
}}
|
||
/>
|
||
{/* Airport dots at origin/destination */}
|
||
<Layer
|
||
id="airport-dots"
|
||
type="circle"
|
||
filter={['==', ['get', 'type'], 'airport']}
|
||
paint={{
|
||
'circle-radius': 5,
|
||
'circle-color': [
|
||
'match',
|
||
['get', 'role'],
|
||
'DEP',
|
||
'#38bdf8',
|
||
'ARR',
|
||
'#fcd34d',
|
||
'#ffffff',
|
||
],
|
||
'circle-stroke-color': '#000',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-opacity': 0.9,
|
||
}}
|
||
/>
|
||
{/* IATA code labels at airports */}
|
||
<Layer
|
||
id="airport-labels"
|
||
type="symbol"
|
||
filter={['==', ['get', 'type'], 'airport']}
|
||
layout={{
|
||
'text-field': ['get', 'code'],
|
||
'text-font': ['Noto Sans Bold'],
|
||
'text-size': 11,
|
||
'text-offset': [0, -1.4],
|
||
'text-anchor': 'bottom',
|
||
'text-allow-overlap': true,
|
||
}}
|
||
paint={{
|
||
'text-color': [
|
||
'match',
|
||
['get', 'role'],
|
||
'DEP',
|
||
'#38bdf8',
|
||
'ARR',
|
||
'#fcd34d',
|
||
'#ffffff',
|
||
],
|
||
'text-halo-color': '#000',
|
||
'text-halo-width': 1.5,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Flight trail history (where the aircraft has been) — altitude-colored gradient */}
|
||
<Source id="flight-trail" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="flight-trail-layer"
|
||
type="line"
|
||
paint={{
|
||
'line-color': ['get', 'color'],
|
||
'line-width': 3,
|
||
'line-opacity': ['coalesce', ['get', 'opacity'], 0.7],
|
||
}}
|
||
layout={{
|
||
'line-cap': 'round',
|
||
'line-join': 'round',
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Predictive vector (where entity is heading — 5 min forward projection) */}
|
||
<Source id="predictive-path" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="predictive-path-layer"
|
||
type="line"
|
||
filter={['==', ['get', 'type'], 'predictive-line']}
|
||
paint={{
|
||
'line-color': '#22d3ee',
|
||
'line-width': 1.5,
|
||
'line-opacity': 0.4,
|
||
'line-dasharray': [4, 4],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="predictive-endpoint"
|
||
type="circle"
|
||
filter={['==', ['get', 'type'], 'predictive-endpoint']}
|
||
paint={{
|
||
'circle-radius': 4,
|
||
'circle-color': 'transparent',
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': '#22d3ee',
|
||
'circle-stroke-opacity': 0.4,
|
||
'circle-opacity': 0,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Proximity range rings (10nm, 50nm, 100nm around selected entity) */}
|
||
<Source id="proximity-rings" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="proximity-rings-layer"
|
||
type="line"
|
||
paint={{
|
||
'line-color': 'rgba(34, 211, 238, 0.15)',
|
||
'line-width': 1,
|
||
'line-dasharray': [6, 4],
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="proximity-rings-labels"
|
||
type="symbol"
|
||
layout={{
|
||
'symbol-placement': 'line',
|
||
'text-field': ['get', 'label'],
|
||
'text-size': 10,
|
||
'text-font': ['Noto Sans Regular'],
|
||
'text-offset': [0, -0.8],
|
||
}}
|
||
paint={{
|
||
'text-color': 'rgba(34, 211, 238, 0.35)',
|
||
'text-halo-color': '#000',
|
||
'text-halo-width': 1,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* GDELT & LiveUA — ground-level incidents, rendered below flights */}
|
||
<Source id="gdelt" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="gdelt-layer"
|
||
type="circle"
|
||
minzoom={4}
|
||
paint={{
|
||
'circle-radius': 5,
|
||
'circle-color': '#ff8c00',
|
||
'circle-stroke-color': '#ff0000',
|
||
'circle-stroke-width': 1,
|
||
'circle-opacity': 0.7,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="liveuamap" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="liveuamap-layer"
|
||
type="symbol"
|
||
minzoom={4}
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': 0.8,
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* tracked-flights & UAVs: rendered above ground layers (airborne) */}
|
||
<Source id="tracked-flights" type="geojson" data={EMPTY_FC}>
|
||
{/* Gold halo ring — POTUS aircraft only (Air Force One/Two, Marine One) */}
|
||
<Layer
|
||
id="tracked-flights-halo"
|
||
type="circle"
|
||
filter={[
|
||
'any',
|
||
['==', ['get', 'iconId'], 'svgPotusPlane'],
|
||
['==', ['get', 'iconId'], 'svgPotusHeli'],
|
||
]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 18, 8, 22, 12, 40],
|
||
'circle-color': 'transparent',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': 'gold',
|
||
'circle-stroke-opacity': opacityFilter,
|
||
'circle-opacity': 0,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="tracked-flights-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': [
|
||
'interpolate', ['linear'], ['zoom'],
|
||
5, ['case',
|
||
['==', ['get', 'iconId'], 'svgPotusPlane'], 1.3,
|
||
['==', ['get', 'iconId'], 'svgPotusHeli'], 1.3,
|
||
0.8,
|
||
],
|
||
8, ['case',
|
||
['==', ['get', 'iconId'], 'svgPotusPlane'], 1.6,
|
||
['==', ['get', 'iconId'], 'svgPotusHeli'], 1.6,
|
||
1.0,
|
||
],
|
||
12, ['case',
|
||
['==', ['get', 'iconId'], 'svgPotusPlane'], 2.6,
|
||
['==', ['get', 'iconId'], 'svgPotusHeli'], 2.6,
|
||
2.0,
|
||
],
|
||
],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
<Source id="uavs" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="uav-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 5, 0.8, 8, 1.0, 12, 2.0],
|
||
'icon-allow-overlap': true,
|
||
'icon-rotate': ['get', 'rotation'],
|
||
'icon-rotation-alignment': 'map',
|
||
}}
|
||
paint={{ 'icon-opacity': opacityFilter }}
|
||
/>
|
||
</Source>
|
||
|
||
{/* HTML labels for ship cluster counts (hidden when any entity popup is active) */}
|
||
{shipsGeoJSON && !selectedEntity && !isMapInteracting && (
|
||
<ClusterCountLabels clusters={shipClusters} prefix="sc" />
|
||
)}
|
||
|
||
{/* HTML labels for tracked flights — color-matched, zoom-gated for non-HVA */}
|
||
{trackedFlightsGeoJSON && !selectedEntity && !isMapInteracting && data?.tracked_flights && (
|
||
<TrackedFlightLabels
|
||
flights={data.tracked_flights}
|
||
zoom={mapZoom}
|
||
inView={inView}
|
||
interpFlight={interpFlight}
|
||
/>
|
||
)}
|
||
|
||
{/* HTML labels for carriers (orange names, with ESTIMATED badge for OSINT positions) */}
|
||
{carriersGeoJSON && !selectedEntity && !isMapInteracting && data?.ships && (
|
||
<CarrierLabels ships={data.ships} inView={inView} interpShip={interpShip} />
|
||
)}
|
||
|
||
{/* HTML labels for tracked yachts (pink owner names) */}
|
||
{shipsGeoJSON && activeLayers.ships_tracked_yachts && !selectedEntity && !isMapInteracting && data?.ships && (
|
||
<TrackedYachtLabels ships={data.ships} inView={inView} interpShip={interpShip} />
|
||
)}
|
||
|
||
{/* HTML labels for earthquake cluster counts (hidden when any entity popup is active) */}
|
||
{earthquakesGeoJSON && !selectedEntity && !isMapInteracting && (
|
||
<ClusterCountLabels clusters={eqClusters} prefix="eqc" />
|
||
)}
|
||
|
||
{/* HTML labels for UAVs (orange names) */}
|
||
{uavGeoJSON && !selectedEntity && !isMapInteracting && data?.uavs && (
|
||
<UavLabels uavs={data.uavs} inView={inView} zoom={mapZoom} />
|
||
)}
|
||
|
||
{/* HTML labels for earthquakes (yellow) - only show when zoomed in (~2000 miles = zoom ~5) */}
|
||
{earthquakesGeoJSON && !selectedEntity && !isMapInteracting && mapZoom >= 5 && data?.earthquakes && (
|
||
<EarthquakeLabels earthquakes={data.earthquakes} inView={inView} />
|
||
)}
|
||
|
||
{/* Maplibre HTML Custom Markers for high-importance Threat Overlays (highest z-index) */}
|
||
{activeLayers.global_incidents && !isMapInteracting && (
|
||
<ThreatMarkers
|
||
spreadAlerts={spreadAlerts}
|
||
zoom={mapZoom}
|
||
selectedEntity={selectedEntity}
|
||
onEntityClick={onEntityClick}
|
||
onDismiss={(alertKey: string) => {
|
||
setDismissedAlerts((prev) => new Set(prev).add(alertKey));
|
||
onEntityClick?.(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{activeLayers.telegram_osint && !isMapInteracting && telegramOsintGeoJSONPlaced?.features?.length ? (
|
||
<TelegramOsintMarkers
|
||
features={telegramOsintGeoJSONPlaced.features}
|
||
onEntityClick={onEntityClick}
|
||
/>
|
||
) : null}
|
||
|
||
{/* Satellite positions — mission-type icons */}
|
||
{/* satellites: data pushed imperatively */}
|
||
<Source id="satellites" type="geojson" data={EMPTY_FC}>
|
||
{/* Golden halo ring — ISS only */}
|
||
<Layer
|
||
id="satellites-iss-halo"
|
||
type="circle"
|
||
filter={['==', ['get', 'isISS'], true]}
|
||
paint={{
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 10, 3, 14, 6, 18, 10, 24],
|
||
'circle-color': 'transparent',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#ffdd00',
|
||
'circle-stroke-opacity': 0.8,
|
||
'circle-opacity': 0,
|
||
}}
|
||
/>
|
||
<Layer
|
||
id="satellites-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 0, 0.4, 3, 0.5, 6, 0.7, 10, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Train positions */}
|
||
<Source id="trains" type="geojson" data={EMPTY_FC}>
|
||
<Layer
|
||
id="trains-layer"
|
||
type="symbol"
|
||
layout={{
|
||
'icon-image': ['get', 'iconId'],
|
||
'icon-size': ['interpolate', ['linear'], ['zoom'], 0, 0.3, 4, 0.5, 8, 0.8, 12, 1.0],
|
||
'icon-allow-overlap': true,
|
||
}}
|
||
/>
|
||
</Source>
|
||
|
||
{/* Satellite click popup (with ISS live feed + maneuver alerts) */}
|
||
{selectedEntity?.type === 'satellite' &&
|
||
(() => {
|
||
const sat = data?.satellites?.find((s) => s.id === selectedEntity.id);
|
||
if (!sat) return null;
|
||
const maneuverAlert = data?.satellite_analysis?.maneuvers?.find(
|
||
(m) => m.norad_id === sat.id
|
||
);
|
||
return (
|
||
<SatellitePopup
|
||
sat={sat}
|
||
maneuverAlert={maneuverAlert}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* Correlation / Contradiction click popup */}
|
||
{selectedEntity?.type === 'correlation' &&
|
||
(() => {
|
||
const corrIndex = typeof selectedEntity.extra?.corr_index === 'number'
|
||
? selectedEntity.extra.corr_index
|
||
: parseInt(String(selectedEntity.id).replace('corr-', ''), 10);
|
||
const alert = data?.correlations?.[corrIndex];
|
||
if (!alert) return null;
|
||
return (
|
||
<CorrelationPopup
|
||
alert={alert}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* UAP Sighting popup */}
|
||
{selectedEntity?.type === 'uap_sighting' &&
|
||
(() => {
|
||
const props = selectedEntity.extra || {};
|
||
const sighting = data?.uap_sightings?.find((s) => s.id === selectedEntity.id);
|
||
const lat = sighting?.lat ?? props.lat;
|
||
const lng = sighting?.lng ?? props.lng;
|
||
if (lat == null || lng == null) return null;
|
||
const location = [props.city, props.state, props.country].filter(Boolean).join(', ') || 'Unknown location';
|
||
const count = props.count ?? 1;
|
||
const hasShape = props.shape && props.shape !== 'unknown';
|
||
const hasSummary = props.summary && props.summary !== 'Sighting reported' && !props.summary?.match(/^\d+ sighting\(s\) reported$/);
|
||
return (
|
||
<Popup longitude={lng} latitude={lat} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="320px">
|
||
<div className="map-popup bg-[#1a0a30] min-w-[220px]" style={{ borderColor: '#a855f766' }}>
|
||
<div className="map-popup-title pb-1 flex items-center gap-2" style={{ color: '#c084fc', borderBottom: '1px solid #a855f733' }}>
|
||
<span style={{ fontSize: 16 }}>👽</span>
|
||
<span>UAP Sighting</span>
|
||
<button onClick={() => onEntityClick?.(null)} className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
||
</div>
|
||
|
||
{/* Core details */}
|
||
<div className="map-popup-row">Location: <span className="text-white">{location}</span></div>
|
||
{props.date_time && <div className="map-popup-row">Date: <span className="text-white">{props.date_time}</span></div>}
|
||
{count > 1 && <div className="map-popup-row">Sightings: <span className="text-purple-300 font-bold">{count}</span></div>}
|
||
|
||
{/* Enriched details from NUFORC database */}
|
||
{(hasShape || props.duration) && (
|
||
<div className="mt-1.5 pt-1.5 border-t border-purple-500/20">
|
||
{hasShape && (
|
||
<div className="map-popup-row">Shape: <span className="text-purple-200 font-semibold">{props.shape_raw || props.shape}</span></div>
|
||
)}
|
||
{props.duration && (
|
||
<div className="map-popup-row">Duration: <span className="text-white">{props.duration}</span></div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Witness summary */}
|
||
{hasSummary && (
|
||
<div className="mt-1.5 pt-1.5 border-t border-purple-500/20">
|
||
<div className="text-[11px] font-mono tracking-widest text-purple-400/50 mb-1">WITNESS REPORT</div>
|
||
<div className="text-[10px] leading-relaxed" style={{ color: '#d8b4fe' }}>
|
||
“{props.summary}”
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-2 pt-1 border-t border-purple-500/10 text-[11px] tracking-wider" style={{ color: '#a855f799' }}>
|
||
{props.source || 'NUFORC'} — UAP SIGHTING REPORT
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Wastewater plant popup */}
|
||
{selectedEntity?.type === 'wastewater' &&
|
||
(() => {
|
||
const plant = data?.wastewater?.find((w) => w.id === selectedEntity.id);
|
||
if (!plant) return null;
|
||
return (
|
||
<WastewaterPopup
|
||
plant={plant}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* CrowdThreat popup */}
|
||
{selectedEntity?.type === 'crowdthreat' &&
|
||
(() => {
|
||
const props = selectedEntity.extra || {};
|
||
const ct = data?.crowdthreat?.find((c) => `ct-${c.id}` === selectedEntity.id);
|
||
const lat = ct?.lat ?? props.lat;
|
||
const lng = ct?.lng ?? props.lng;
|
||
if (lat == null || lng == null) return null;
|
||
const accent = props.category_colour || '#6b7280';
|
||
const location = [props.address || props.city, props.country].filter(Boolean).join(', ') || 'Unknown';
|
||
return (
|
||
<Popup longitude={lng} latitude={lat} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="320px">
|
||
<div className="map-popup min-w-[220px]" style={{ borderColor: `${accent}66`, background: 'var(--bg-secondary)' }}>
|
||
<div className="map-popup-title pb-1 flex items-center gap-2" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||
<span className="font-bold text-[11px] leading-tight flex-1">{props.title}</span>
|
||
<button onClick={() => onEntityClick?.(null)} className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0">✕</button>
|
||
</div>
|
||
{props.summary && (
|
||
<div className="text-[10px] text-white/80 leading-relaxed mt-1 mb-1.5">{props.summary}</div>
|
||
)}
|
||
<div className="map-popup-row">Category: <span className="font-semibold" style={{ color: accent }}>{props.category}</span></div>
|
||
{props.subcategory && <div className="map-popup-row">Subcategory: <span className="text-white">{props.subcategory}</span></div>}
|
||
{props.threat_type && <div className="map-popup-row">Type: <span className="text-white">{props.threat_type}</span></div>}
|
||
<div className="map-popup-row">Location: <span className="text-white">{location}</span></div>
|
||
{props.occurred && <div className="map-popup-row">Occurred: <span className="text-white">{props.occurred}</span></div>}
|
||
{props.timeago && <div className="map-popup-row">Reported: <span className="text-white">{props.timeago}</span></div>}
|
||
{props.verification && (
|
||
<div className="map-popup-row">Status: <span className={props.verification === 'approved' ? 'text-green-400 font-bold' : 'text-yellow-400'}>{props.verification.toUpperCase()}</span></div>
|
||
)}
|
||
{props.severity && (
|
||
<div className="map-popup-row">Severity: <span className="text-red-400 font-bold">{props.severity}</span></div>
|
||
)}
|
||
{props.source_url && (
|
||
<div className="mt-1.5 pt-1.5 border-t border-[var(--border-primary)]">
|
||
<a href={props.source_url} target="_blank" rel="noreferrer" className="text-[9px] font-bold underline" style={{ color: accent }}>View Source</a>
|
||
</div>
|
||
)}
|
||
<div className="mt-1.5 text-[11px] tracking-wider" style={{ color: `${accent}88` }}>
|
||
CROWDTHREAT — VERIFIED THREAT INTELLIGENCE
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Train click popup */}
|
||
{selectedEntity?.type === 'train' &&
|
||
(() => {
|
||
const train = data?.trains?.find((t) => t.id === selectedEntity.id);
|
||
if (!train) return null;
|
||
const isAmtrak = train.source === 'amtrak';
|
||
const sourceLabel = train.source_label || train.source.toUpperCase();
|
||
const subtitleParts = [sourceLabel];
|
||
if (train.operator && train.operator !== sourceLabel) subtitleParts.push(train.operator);
|
||
return (
|
||
<Popup
|
||
longitude={train.lng}
|
||
latitude={train.lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={12}
|
||
>
|
||
<div className="map-popup border border-orange-500/30">
|
||
<div className="flex justify-between items-start mb-0.5">
|
||
<div className={`map-popup-title ${isAmtrak ? 'text-[#ff8800]' : 'text-[#00aaff]'}`}>
|
||
{train.name}
|
||
</div>
|
||
<button onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2">✕</button>
|
||
</div>
|
||
<div className="map-popup-subtitle text-[#8899aa] border-b border-gray-700/50 pb-1">
|
||
{subtitleParts.join(' / ')}{train.number ? ` — #${train.number}` : ''}
|
||
</div>
|
||
{train.country && (
|
||
<div className="map-popup-row">
|
||
Country: <span className="text-white">{train.country}</span>
|
||
</div>
|
||
)}
|
||
{train.route && (
|
||
<div className="map-popup-row">
|
||
Route: <span className="text-white">{train.route}</span>
|
||
</div>
|
||
)}
|
||
{train.speed_kmh != null && (
|
||
<div className="map-popup-row">
|
||
Speed: <span className="text-[#44ff88]">{train.speed_kmh} km/h</span>
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row">
|
||
Status: <span className={train.status?.toLowerCase().includes('late') || train.status?.toLowerCase().includes('delay')
|
||
? 'text-red-400' : 'text-green-400'}>{train.status || 'Active'}</span>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* UAV click popup — real ADS-B detected drones */}
|
||
{selectedEntity?.type === 'uav' &&
|
||
(() => {
|
||
const uav = data?.uavs?.find((u) => u.id === selectedEntity.id);
|
||
if (!uav) return null;
|
||
return (
|
||
<Popup
|
||
longitude={uav.lng}
|
||
latitude={uav.lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={12}
|
||
>
|
||
<div className="map-popup border border-red-500/40">
|
||
<div className="map-popup-title text-[#ff4444]">{uav.callsign}</div>
|
||
<div className="map-popup-subtitle text-[#ff8844]">LIVE ADS-B TRANSPONDER</div>
|
||
{uav.aircraft_model && (
|
||
<div className="map-popup-row">
|
||
Model: <span className="text-white">{uav.aircraft_model}</span>
|
||
</div>
|
||
)}
|
||
{uav.uav_type && (
|
||
<div className="map-popup-row">
|
||
Classification: <span className="text-[#ffcc00]">{uav.uav_type}</span>
|
||
</div>
|
||
)}
|
||
{uav.country && (
|
||
<div className="map-popup-row">
|
||
Registration: <span className="text-white">{uav.country}</span>
|
||
</div>
|
||
)}
|
||
{uav.icao24 && (
|
||
<div className="map-popup-row">
|
||
ICAO: <span className="text-[#888]">{uav.icao24}</span>
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row">
|
||
Altitude: <span className="text-[#44ff88]">{uav.alt?.toLocaleString()} m</span>
|
||
</div>
|
||
{(uav.speed_knots ?? 0) > 0 && (
|
||
<div className="map-popup-row">
|
||
Speed: <span className="text-[#00e5ff]">{uav.speed_knots} kn</span>
|
||
</div>
|
||
)}
|
||
{uav.squawk && (
|
||
<div className="map-popup-row">
|
||
Squawk: <span className="text-[#888]">{uav.squawk}</span>
|
||
</div>
|
||
)}
|
||
{uav.wiki && (
|
||
<div className="mt-2 border-t border-[var(--border-primary)]/50 pt-2">
|
||
<WikiImage
|
||
wikiUrl={uav.wiki}
|
||
label={uav.callsign}
|
||
maxH="max-h-28"
|
||
accent="hover:border-red-500/50"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* KiwiSDR Receivers Popup */}
|
||
{selectedEntity?.type === 'kiwisdr' &&
|
||
(() => {
|
||
const receiver = data?.kiwisdr?.find(
|
||
(k) => k.name === selectedEntity.name || k.name === String(selectedEntity.id),
|
||
);
|
||
// use extra if available from the click event, otherwise fallback
|
||
const props = (selectedEntity.extra || receiver || {}) as KiwiProps;
|
||
const lat =
|
||
props.lat ??
|
||
selectedEntity.extra?.lat ??
|
||
selectedEntity.extra?.geometry?.coordinates?.[1];
|
||
const lng =
|
||
props.lon ??
|
||
props.lng ??
|
||
selectedEntity.extra?.lon ??
|
||
selectedEntity.extra?.geometry?.coordinates?.[0];
|
||
if (lat == null || lng == null) return null;
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={12}
|
||
>
|
||
<div
|
||
className="map-popup !border-amber-500/40"
|
||
style={{ borderWidth: 1, borderStyle: 'solid' }}
|
||
>
|
||
<div className="flex justify-between items-start mb-1">
|
||
<div className="map-popup-title text-amber-400">
|
||
{(props.name || 'UNKNOWN SDR RECEIVER').toUpperCase()}
|
||
</div>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="map-popup-subtitle text-amber-600/80 border-b border-amber-900/30 pb-1 flex items-center gap-1.5">
|
||
<Radio size={10} /> PUBLIC NETWORK RECEIVER
|
||
</div>
|
||
|
||
{props.location && (
|
||
<div className="map-popup-row mt-1">
|
||
Location: <span className="text-white">{props.location}</span>
|
||
</div>
|
||
)}
|
||
{props.users !== undefined && (
|
||
<div className="map-popup-row">
|
||
Active Users:{' '}
|
||
<span
|
||
className={
|
||
props.users >= (props.users_max || 4) ? 'text-red-400' : 'text-amber-400'
|
||
}
|
||
>
|
||
{props.users} / {props.users_max || '?'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{props.antenna && (
|
||
<div className="map-popup-row">
|
||
Antenna: <span className="text-[#888]">{props.antenna}</span>
|
||
</div>
|
||
)}
|
||
{props.bands && (
|
||
<div className="map-popup-row">
|
||
Bands:{' '}
|
||
<span className="text-cyan-400">
|
||
{(Number(props.bands.split('-')[0]) / 1e6).toFixed(0)}-
|
||
{(Number(props.bands.split('-')[1]) / 1e6).toFixed(0)} MHz
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-[var(--border-primary)]">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (setTrackedSdr) {
|
||
setTrackedSdr({
|
||
lat,
|
||
lon: lng,
|
||
name: props.name || 'Unknown',
|
||
url: props.url,
|
||
users: props.users,
|
||
users_max: props.users_max,
|
||
bands: props.bands,
|
||
antenna: props.antenna,
|
||
location: props.location,
|
||
});
|
||
}
|
||
onEntityClick?.(null);
|
||
}}
|
||
className="flex-1 text-center px-2 py-1.5 rounded bg-amber-950/40 border border-amber-500/30 hover:bg-amber-900/60 hover:border-amber-400 text-amber-400 text-[9px] font-mono tracking-widest transition-colors flex justify-center items-center gap-1.5"
|
||
>
|
||
<Activity size={10} /> TRACK
|
||
</button>
|
||
{props.url && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (setTrackedSdr) {
|
||
setTrackedSdr({
|
||
lat,
|
||
lon: lng,
|
||
name: props.name || 'Unknown',
|
||
url: props.url,
|
||
users: props.users,
|
||
users_max: props.users_max,
|
||
bands: props.bands,
|
||
antenna: props.antenna,
|
||
location: props.location,
|
||
});
|
||
}
|
||
onEntityClick?.(null);
|
||
}}
|
||
className="flex-1 text-center px-2 py-1.5 rounded bg-amber-500/20 border border-amber-500/50 hover:bg-amber-500/30 hover:border-amber-400 text-amber-300 text-[9px] font-mono tracking-widest transition-colors flex justify-center items-center gap-1.5"
|
||
>
|
||
<Play size={10} /> TUNE IN
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* SatNOGS Ground Station Popup */}
|
||
{selectedEntity?.type === 'satnogs_station' &&
|
||
(() => {
|
||
const props = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||
const lat = (props.lat as number) ?? selectedEntity.extra?.geometry?.coordinates?.[1];
|
||
const lng = (props.lng as number) ?? selectedEntity.extra?.geometry?.coordinates?.[0];
|
||
if (lat == null || lng == null) return null;
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={12}
|
||
>
|
||
<div
|
||
className="map-popup !border-teal-500/40"
|
||
style={{ borderWidth: 1, borderStyle: 'solid' }}
|
||
>
|
||
<div className="flex justify-between items-start mb-1">
|
||
<div className="map-popup-title text-teal-400">
|
||
{((props.name as string) || 'UNKNOWN STATION').toUpperCase()}
|
||
</div>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="map-popup-subtitle text-teal-600/80 border-b border-teal-900/30 pb-1 flex items-center gap-1.5">
|
||
<Satellite size={10} /> SATNOGS GROUND STATION
|
||
</div>
|
||
{String(props.antenna || '') !== '' && (
|
||
<div className="map-popup-row mt-1">
|
||
Antenna: <span className="text-[#888]">{String(props.antenna)}</span>
|
||
</div>
|
||
)}
|
||
{Number(props.observations || 0) > 0 && (
|
||
<div className="map-popup-row">
|
||
Observations: <span className="text-teal-400">{Number(props.observations).toLocaleString()}</span>
|
||
</div>
|
||
)}
|
||
{String(props.last_seen || '') !== '' && (
|
||
<div className="map-popup-row">
|
||
Last seen: <span className="text-[#888]">{new Date(String(props.last_seen)).toLocaleString()}</span>
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row text-[10px] text-[#555] mt-1">
|
||
{lat.toFixed(4)}°, {lng.toFixed(4)}°
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* TinyGS LoRa Satellite Popup */}
|
||
{selectedEntity?.type === 'tinygs_satellite' &&
|
||
(() => {
|
||
const props = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||
const lat = (props.lat as number) ?? selectedEntity.extra?.geometry?.coordinates?.[1];
|
||
const lng = (props.lng as number) ?? selectedEntity.extra?.geometry?.coordinates?.[0];
|
||
if (lat == null || lng == null) return null;
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={12}
|
||
>
|
||
<div
|
||
className="map-popup !border-purple-500/40"
|
||
style={{ borderWidth: 1, borderStyle: 'solid' }}
|
||
>
|
||
<div className="flex justify-between items-start mb-1">
|
||
<div className="map-popup-title text-purple-400">
|
||
{String(props.name || 'UNKNOWN SATELLITE').toUpperCase()}
|
||
</div>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="map-popup-subtitle text-purple-600/80 border-b border-purple-900/30 pb-1 flex items-center gap-1.5">
|
||
<Satellite size={10} /> LORA SATELLITE
|
||
{props.tinygs_confirmed ? (
|
||
<span className="text-green-400 text-[11px] ml-1">TINYGS LIVE</span>
|
||
) : props.sgp4_propagated ? (
|
||
<span className="text-purple-400 text-[11px] ml-1">SGP4 ORBIT</span>
|
||
) : null}
|
||
</div>
|
||
{Number(props.alt_km || 0) > 0 && (
|
||
<div className="map-popup-row mt-1">
|
||
Altitude: <span className="text-purple-400">{Number(props.alt_km).toFixed(0)} km</span>
|
||
</div>
|
||
)}
|
||
{String(props.modulation || '') !== '' && (
|
||
<div className="map-popup-row">
|
||
Modulation: <span className="text-purple-400">{String(props.modulation)}</span>
|
||
</div>
|
||
)}
|
||
{String(props.frequency || '') !== '' && (
|
||
<div className="map-popup-row">
|
||
Frequency: <span className="text-purple-400">{String(props.frequency)} MHz</span>
|
||
</div>
|
||
)}
|
||
{String(props.status || '') !== '' && (
|
||
<div className="map-popup-row">
|
||
Status: <span className="text-[#888]">{String(props.status)}</span>
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row text-[10px] text-[#555] mt-1">
|
||
{lat.toFixed(4)}°, {lng.toFixed(4)}°
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* CCTV popup removed — now handled by fullscreen OPTIC INTERCEPT modal */}
|
||
|
||
{/* Police Scanner click popup */}
|
||
{selectedEntity?.type === 'scanner' &&
|
||
(() => {
|
||
const props = (selectedEntity.extra || {}) as ScannerProps;
|
||
const lat = props.lat ?? selectedEntity.extra?.geometry?.coordinates?.[1];
|
||
const lng = props.lng ?? selectedEntity.extra?.geometry?.coordinates?.[0];
|
||
if (lat == null || lng == null) return null;
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={12}
|
||
>
|
||
<div
|
||
className="map-popup !border-red-500/40"
|
||
style={{ borderWidth: 1, borderStyle: 'solid' }}
|
||
>
|
||
<div className="flex justify-between items-start mb-1">
|
||
<div className="map-popup-title text-red-400">
|
||
{(props.name || 'UNKNOWN SYSTEM').toUpperCase()}
|
||
</div>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="map-popup-subtitle text-red-600/80 border-b border-red-900/30 pb-1 flex items-center gap-1.5">
|
||
<Radio size={10} /> TRUNKED RADIO SYSTEM
|
||
</div>
|
||
|
||
{(props.city || props.state) && (
|
||
<div className="map-popup-row mt-1">
|
||
Location:{' '}
|
||
<span className="text-white">
|
||
{[props.city, props.state].filter(Boolean).join(', ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row">
|
||
Active Listeners: <span className="text-red-400">{props.clientCount || 0}</span>
|
||
</div>
|
||
{props.description && (
|
||
<div className="map-popup-row">
|
||
<span className="text-[#888]">{String(props.description).slice(0, 120)}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-[var(--border-primary)]">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (setTrackedScanner) {
|
||
setTrackedScanner({
|
||
shortName: props.shortName || '',
|
||
name: props.name || '',
|
||
lat,
|
||
lng,
|
||
city: props.city || '',
|
||
state: props.state || '',
|
||
clientCount: props.clientCount || 0,
|
||
description: props.description || '',
|
||
});
|
||
}
|
||
onEntityClick?.(null);
|
||
}}
|
||
className="flex-1 text-center px-2 py-1.5 rounded bg-red-950/40 border border-red-500/30 hover:bg-red-900/60 hover:border-red-400 text-red-400 text-[9px] font-mono tracking-widest transition-colors flex justify-center items-center gap-1.5"
|
||
>
|
||
<Activity size={10} /> TRACK
|
||
</button>
|
||
<button
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
const sn = props.shortName || '';
|
||
if (setTrackedScanner) {
|
||
setTrackedScanner({
|
||
shortName: sn,
|
||
name: props.name || '',
|
||
lat,
|
||
lng,
|
||
city: props.city || '',
|
||
state: props.state || '',
|
||
clientCount: props.clientCount || 0,
|
||
description: props.description || '',
|
||
});
|
||
}
|
||
onEntityClick?.(null);
|
||
}}
|
||
className="flex-1 text-center px-2 py-1.5 rounded bg-red-500/20 border border-red-500/50 hover:bg-red-500/30 hover:border-red-400 text-red-300 text-[9px] font-mono tracking-widest transition-colors flex justify-center items-center gap-1.5"
|
||
>
|
||
<Play size={10} /> OPEN PLAYER
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* SIGINT signal click popup */}
|
||
{selectedEntity?.type === 'sigint' &&
|
||
(() => {
|
||
const props = (selectedEntity.extra || {}) as SigintProps;
|
||
const sig = data?.sigint?.find(
|
||
(s) => `${s.source}:${s.callsign}` === selectedEntity.id,
|
||
);
|
||
const d = sig || props;
|
||
const lat = sig?.lat ?? props.geometry?.coordinates?.[1];
|
||
const lng = sig?.lng ?? props.geometry?.coordinates?.[0];
|
||
if (lat == null || lng == null) return null;
|
||
return (
|
||
<SigintPopup
|
||
data={d}
|
||
lat={lat}
|
||
lng={lng}
|
||
kiwisdrs={data?.kiwisdr || []}
|
||
setTrackedSdr={setTrackedSdr}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* Ship / carrier click popup */}
|
||
{selectedEntity?.type === 'ship' &&
|
||
(() => {
|
||
const ship = data?.ships?.find((s, i: number) => {
|
||
return (
|
||
(s.mmsi || s.name || `ship-${i}`) === selectedEntity.id ||
|
||
(s.mmsi || s.name || `carrier-${i}`) === selectedEntity.id
|
||
);
|
||
});
|
||
if (!ship) return null;
|
||
const [iLng, iLat] = interpShip(ship);
|
||
return (
|
||
<ShipPopup
|
||
ship={ship}
|
||
longitude={iLng}
|
||
latitude={iLat}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* SAR anomaly click popup */}
|
||
{selectedEntity?.type === 'sar_anomaly' &&
|
||
(() => {
|
||
const extra = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||
const anomaly = data?.sar_anomalies?.find(
|
||
(a) => a.anomaly_id === selectedEntity.id,
|
||
);
|
||
const a = anomaly || extra;
|
||
const lat = typeof a.lat === 'number' ? a.lat : Number(extra.center_lat);
|
||
const lng =
|
||
typeof (a as { lon?: number }).lon === 'number'
|
||
? (a as { lon: number }).lon
|
||
: Number(extra.center_lon);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||
const kind = String(a.kind || extra.kind || 'anomaly');
|
||
const title = String(a.title || extra.title || `SAR ${kind}`);
|
||
const summary = String(a.summary || extra.summary || '');
|
||
const solver = String(a.solver || extra.solver || '');
|
||
const constellation = String(
|
||
(a as { source_constellation?: string }).source_constellation ||
|
||
extra.source_constellation ||
|
||
'',
|
||
);
|
||
const magnitude = Number(a.magnitude ?? extra.magnitude ?? 0);
|
||
const unit = String(a.magnitude_unit || extra.magnitude_unit || '');
|
||
const confidence = Number(a.confidence ?? extra.confidence ?? 0);
|
||
const lastSeen = Number(a.last_seen ?? extra.last_seen ?? 0);
|
||
const provenance = String(a.provenance_url || extra.provenance_url || '');
|
||
const aoiId = String(a.aoi_id || extra.aoi_id || '');
|
||
const color = String(extra.color || '#eab308');
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
className="threat-popup"
|
||
maxWidth="320px"
|
||
>
|
||
<div
|
||
className="map-popup bg-zinc-950/95 text-amber-100 min-w-[240px]"
|
||
style={{ border: `1px solid ${color}66` }}
|
||
>
|
||
<div
|
||
className="map-popup-title flex items-center justify-between gap-2 border-b pb-1"
|
||
style={{ borderColor: `${color}33`, color }}
|
||
>
|
||
<span className="font-semibold">{title}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-amber-200/60 hover:text-amber-100"
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{summary && (
|
||
<div className="map-popup-row text-[11px] text-amber-100/80 leading-snug">
|
||
{summary}
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row text-[11px]">
|
||
Kind:{' '}
|
||
<span className="text-amber-200 font-mono">
|
||
{kind.replace(/_/g, ' ')}
|
||
</span>
|
||
</div>
|
||
{solver && (
|
||
<div className="map-popup-row text-[11px]">
|
||
Solver: <span className="text-amber-200">{solver}</span>
|
||
</div>
|
||
)}
|
||
{constellation && (
|
||
<div className="map-popup-row text-[11px]">
|
||
Source: <span className="text-amber-200">{constellation}</span>
|
||
</div>
|
||
)}
|
||
{magnitude !== 0 && (
|
||
<div className="map-popup-row text-[11px]">
|
||
Magnitude:{' '}
|
||
<span className="text-amber-200">
|
||
{magnitude.toFixed(3)} {unit}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row text-[11px]">
|
||
Confidence:{' '}
|
||
<span className="text-amber-200">{(confidence * 100).toFixed(0)}%</span>
|
||
</div>
|
||
{lastSeen > 0 && (
|
||
<div className="map-popup-row text-[11px]">
|
||
Last seen:{' '}
|
||
<span className="text-amber-200">
|
||
{new Date(lastSeen * 1000).toISOString().replace('T', ' ').slice(0, 19)}Z
|
||
</span>
|
||
</div>
|
||
)}
|
||
{aoiId && (
|
||
<div className="map-popup-row text-[11px]">
|
||
AOI: <span className="text-amber-200 font-mono">{aoiId}</span>
|
||
</div>
|
||
)}
|
||
{provenance && (
|
||
<div className="map-popup-row text-[11px]">
|
||
<a
|
||
href={provenance}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-cyan-300 hover:text-cyan-200 underline"
|
||
>
|
||
Provenance ↗
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* SAR AOI click popup — operator watchbox details */}
|
||
{selectedEntity?.type === 'sar_aoi' &&
|
||
(() => {
|
||
const extra = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||
const lat = Number(extra.center_lat);
|
||
const lng = Number(extra.center_lon);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||
const name = String(extra.name || selectedEntity.id);
|
||
const description = String(extra.description || '');
|
||
const category = String(extra.category || 'watchlist');
|
||
const radius = Number(extra.radius_km || 0);
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
className="threat-popup"
|
||
maxWidth="300px"
|
||
>
|
||
<div className="map-popup bg-zinc-950/95 border border-amber-400/40 text-amber-100 min-w-[220px]">
|
||
<div className="map-popup-title flex items-center justify-between gap-2 text-amber-300 border-b border-amber-400/20 pb-1">
|
||
<span>AOI · {name}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-amber-200/60 hover:text-amber-100"
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{description && (
|
||
<div className="map-popup-row text-[11px] text-amber-100/80 leading-snug">
|
||
{description}
|
||
</div>
|
||
)}
|
||
<div className="map-popup-row text-[11px]">
|
||
Category:{' '}
|
||
<span className="text-amber-200 font-mono">{category}</span>
|
||
</div>
|
||
<div className="map-popup-row text-[11px]">
|
||
Radius: <span className="text-amber-200">{radius.toFixed(0)} km</span>
|
||
</div>
|
||
<div className="map-popup-row text-[11px]">
|
||
Center:{' '}
|
||
<span className="text-amber-200 font-mono">
|
||
{lat.toFixed(3)}, {lng.toFixed(3)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Data Center click popup */}
|
||
{selectedEntity?.type === 'datacenter' &&
|
||
(() => {
|
||
const dc = data?.datacenters?.find((_, i: number) => `dc-${i}` === selectedEntity.id);
|
||
if (!dc) return null;
|
||
// Check if any internet outage is in the same country
|
||
const outagesInCountry = (data?.internet_outages || []).filter(
|
||
(o) =>
|
||
o.country_name &&
|
||
dc.country &&
|
||
o.country_name.toLowerCase() === dc.country.toLowerCase(),
|
||
);
|
||
return (
|
||
<Popup
|
||
longitude={dc.lng}
|
||
latitude={dc.lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
className="threat-popup"
|
||
maxWidth="280px"
|
||
>
|
||
<div className="map-popup bg-[#1a1035] border border-violet-400/40 text-[#e9d5ff] min-w-[200px]">
|
||
<div className="map-popup-title text-violet-400 border-b border-violet-400/20 pb-1">
|
||
{dc.name}
|
||
</div>
|
||
{dc.company && (
|
||
<div className="map-popup-row">
|
||
Operator: <span className="text-[#c4b5fd]">{dc.company}</span>
|
||
</div>
|
||
)}
|
||
{dc.street && (
|
||
<div className="map-popup-row">
|
||
Address:{' '}
|
||
<span className="text-white">
|
||
{dc.street}
|
||
{dc.zip ? ` ${dc.zip}` : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{dc.city && (
|
||
<div className="map-popup-row">
|
||
Location:{' '}
|
||
<span className="text-white">
|
||
{dc.city}
|
||
{dc.country ? `, ${dc.country}` : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{!dc.city && dc.country && (
|
||
<div className="map-popup-row">
|
||
Country: <span className="text-white">{dc.country}</span>
|
||
</div>
|
||
)}
|
||
{outagesInCountry.length > 0 && (
|
||
<div className="mt-1.5 px-2 py-1 bg-red-500/15 border border-red-400/40 rounded text-[10px] text-[#ff6b6b]">
|
||
OUTAGE IN REGION —{' '}
|
||
{outagesInCountry.map((o) => `${o.region_name} (${o.severity}%)`).join(', ')}
|
||
</div>
|
||
)}
|
||
<div className="mt-1.5 text-[9px] text-violet-600 tracking-wider">
|
||
DATA CENTER
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Power Plant click popup */}
|
||
{selectedEntity?.type === 'power_plant' && (() => {
|
||
const pp = data?.power_plants?.find((_: any, i: number) => `pp-${i}` === selectedEntity.id);
|
||
if (!pp) return null;
|
||
return (
|
||
<Popup
|
||
longitude={pp.lng}
|
||
latitude={pp.lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
className="threat-popup"
|
||
maxWidth="280px"
|
||
>
|
||
<div className="map-popup bg-[#1a0f00] border border-amber-400/40 text-[#fde68a] min-w-[200px]">
|
||
<div className="map-popup-title text-amber-400 border-b border-amber-400/20 pb-1">
|
||
{pp.name}
|
||
</div>
|
||
{pp.fuel_type && (
|
||
<div className="map-popup-row">
|
||
Fuel: <span className="text-[#fbbf24]">{pp.fuel_type}</span>
|
||
</div>
|
||
)}
|
||
{pp.capacity_mw != null && (
|
||
<div className="map-popup-row">
|
||
Capacity: <span className="text-white">{pp.capacity_mw.toLocaleString()} MW</span>
|
||
</div>
|
||
)}
|
||
{pp.owner && (
|
||
<div className="map-popup-row">
|
||
Operator: <span className="text-white">{pp.owner}</span>
|
||
</div>
|
||
)}
|
||
{pp.country && (
|
||
<div className="map-popup-row">
|
||
Country: <span className="text-white">{pp.country}</span>
|
||
</div>
|
||
)}
|
||
<div className="mt-1.5 text-[9px] text-amber-600 tracking-wider">
|
||
POWER PLANT
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* VIIRS Change Node click popup */}
|
||
{selectedEntity?.type === 'viirs_change_node' && (() => {
|
||
const node = data?.viirs_change_nodes?.find(
|
||
(_: any, i: number) => `viirs-${i}` === selectedEntity.id
|
||
);
|
||
if (!node) return null;
|
||
const isLoss = node.mean_change_pct < 0;
|
||
return (
|
||
<Popup
|
||
longitude={node.lng}
|
||
latitude={node.lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
className="threat-popup"
|
||
maxWidth="280px"
|
||
>
|
||
<div className="map-popup bg-black/90 border border-cyan-500/30 text-white min-w-[200px]">
|
||
<div className="map-popup-title text-cyan-400 border-b border-cyan-500/20 pb-1 tracking-wider">
|
||
VIIRS NIGHT LIGHTS
|
||
</div>
|
||
<div className="map-popup-row">
|
||
Region: <span className="text-white">{node.aoi_name}</span>
|
||
</div>
|
||
<div className="map-popup-row">
|
||
Change: <span className={`text-lg font-bold ${isLoss ? 'text-red-400' : 'text-green-400'}`}>
|
||
{isLoss ? '' : '+'}{node.mean_change_pct.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
<div className="map-popup-row">
|
||
Severity: <span className="text-white uppercase">{node.severity.replace('_', ' ')}</span>
|
||
</div>
|
||
<div className="mt-1.5 text-[9px] text-cyan-600 tracking-wider">
|
||
{isLoss ? 'LIGHTS WENT DARK' : 'LIGHTS INCREASED'}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{selectedEntity?.type === 'military_base' &&
|
||
(() => {
|
||
const base = data?.military_bases?.find(
|
||
(_, i: number) => `milbase-${i}` === selectedEntity.id,
|
||
);
|
||
if (!base) return null;
|
||
return (
|
||
<MilitaryBasePopup
|
||
base={base}
|
||
oracleIntel={oracleIntel}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* Ukraine Air Raid Alert popup */}
|
||
{selectedEntity?.type === 'ukraine_alert' &&
|
||
(() => {
|
||
const alert = data?.ukraine_alerts?.find((a) => String(a.id) === String(selectedEntity.id));
|
||
if (!alert) return null;
|
||
const accent = alert.color || '#ef4444';
|
||
const geom = alert.geometry;
|
||
const coords = geom?.type === 'Polygon' ? geom.coordinates?.[0]?.[0] : geom?.type === 'MultiPolygon' ? geom.coordinates?.[0]?.[0]?.[0] : null;
|
||
if (!coords) return null;
|
||
const started = alert.started_at ? new Date(alert.started_at) : null;
|
||
const durationMin = started ? Math.round((Date.now() - started.getTime()) / 60000) : null;
|
||
const durationStr = durationMin != null ? (durationMin >= 60 ? `${Math.floor(durationMin / 60)}h ${durationMin % 60}m` : `${durationMin}m`) : '';
|
||
const alertLabel = ({ air_raid: 'AIR RAID', artillery_shelling: 'SHELLING', urban_fights: 'URBAN COMBAT', chemical: 'CHEMICAL', nuclear: 'NUCLEAR' } as Record<string, string>)[alert.alert_type] || alert.alert_type.toUpperCase();
|
||
return (
|
||
<Popup longitude={coords[0]} latitude={coords[1]} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="300px">
|
||
<div className="map-popup bg-[#1a1035] min-w-[220px]" style={{ borderColor: `${accent}66` }}>
|
||
<div className="map-popup-title pb-1" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||
{alertLabel}
|
||
</div>
|
||
<div className="map-popup-row text-white text-[11px]">{alert.name_en || alert.location_title}</div>
|
||
<div className="map-popup-row">Oblast: <span className="text-white">{alert.location_title}</span></div>
|
||
{started && <div className="map-popup-row">Since: <span className="text-white">{started.toLocaleTimeString()}</span></div>}
|
||
{durationStr && <div className="map-popup-row">Duration: <span style={{ color: accent }}>{durationStr}</span></div>}
|
||
<div className="mt-1.5 text-[9px] tracking-wider text-gray-500">UKRAINE AIR RAID — ALERTS.IN.UA</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Weather Alert popup */}
|
||
{selectedEntity?.type === 'weather_alert' &&
|
||
(() => {
|
||
const alert = data?.weather_alerts?.find((a) => a.id === selectedEntity.id);
|
||
if (!alert) return null;
|
||
const sevColors: Record<string, string> = { Extreme: '#ef4444', Severe: '#f97316', Moderate: '#eab308', Minor: '#3b82f6' };
|
||
const accent = sevColors[alert.severity] || '#3b82f6';
|
||
const geom = alert.geometry;
|
||
const coords = geom?.type === 'Polygon' ? geom.coordinates?.[0]?.[0] : geom?.type === 'MultiPolygon' ? geom.coordinates?.[0]?.[0]?.[0] : null;
|
||
if (!coords) return null;
|
||
return (
|
||
<Popup longitude={coords[0]} latitude={coords[1]} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="300px">
|
||
<div className="map-popup bg-[#1a1035] min-w-[220px]" style={{ borderColor: `${accent}66` }}>
|
||
<div className="map-popup-title pb-1" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||
{alert.event}
|
||
</div>
|
||
<div className="map-popup-row text-white text-[10px] leading-snug">{alert.headline}</div>
|
||
<div className="map-popup-row">Severity: <span style={{ color: accent }}>{alert.severity}</span></div>
|
||
{alert.expires && <div className="map-popup-row">Expires: <span className="text-white">{new Date(alert.expires).toLocaleString()}</span></div>}
|
||
<div className="mt-1 text-[9px] text-gray-400 leading-snug max-h-[60px] overflow-hidden">{alert.description}</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Air Quality popup */}
|
||
{selectedEntity?.type === 'air_quality' &&
|
||
(() => {
|
||
const station = data?.air_quality?.find((s) => `aq-${s.id}` === selectedEntity.id);
|
||
if (!station) return null;
|
||
const aqiColors: Record<string, string> = { Good: '#22c55e', Moderate: '#eab308', 'Unhealthy (Sensitive)': '#f97316', Unhealthy: '#ef4444', 'Very Unhealthy': '#a855f7', Hazardous: '#7f1d1d' };
|
||
const label = station.aqi <= 50 ? 'Good' : station.aqi <= 100 ? 'Moderate' : station.aqi <= 150 ? 'Unhealthy (Sensitive)' : station.aqi <= 200 ? 'Unhealthy' : station.aqi <= 300 ? 'Very Unhealthy' : 'Hazardous';
|
||
const accent = aqiColors[label] || '#22c55e';
|
||
return (
|
||
<Popup longitude={station.lng} latitude={station.lat} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="260px">
|
||
<div className="map-popup bg-[#1a1035] min-w-[180px]" style={{ borderColor: `${accent}66` }}>
|
||
<div className="map-popup-title pb-1" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||
{station.name}
|
||
</div>
|
||
<div className="map-popup-row">AQI: <span style={{ color: accent, fontWeight: 'bold' }}>{station.aqi}</span> <span className="text-gray-400">({label})</span></div>
|
||
<div className="map-popup-row">PM2.5: <span className="text-white">{station.pm25} µg/m³</span></div>
|
||
{station.country && <div className="map-popup-row">Country: <span className="text-white">{station.country}</span></div>}
|
||
<div className="mt-1.5 text-[9px] tracking-wider text-gray-500">AIR QUALITY — OPENAQ</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Earthquake popup */}
|
||
{selectedEntity?.type === 'earthquake' &&
|
||
(() => {
|
||
const extra = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||
const idx = Number(selectedEntity.id);
|
||
const eq = Number.isFinite(idx)
|
||
? data?.earthquakes?.[idx]
|
||
: data?.earthquakes?.find((e) => e.id === String(selectedEntity.id));
|
||
const lat = typeof eq?.lat === 'number' ? eq.lat : Number(extra.lat);
|
||
const lng = typeof eq?.lng === 'number' ? eq.lng : Number(extra.lng);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||
const mag = eq?.mag ?? Number(extra.mag);
|
||
const place = eq?.place || String(extra.place || selectedEntity.name || 'Unknown location');
|
||
const accent = mag >= 6 ? '#ef4444' : mag >= 4.5 ? '#f97316' : '#eab308';
|
||
return (
|
||
<Popup
|
||
longitude={lng}
|
||
latitude={lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
className="threat-popup"
|
||
maxWidth="280px"
|
||
>
|
||
<div className="map-popup bg-[#1a1035] min-w-[200px]" style={{ borderColor: `${accent}66` }}>
|
||
<div className="map-popup-title pb-1" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||
M{Number.isFinite(mag) ? mag.toFixed(1) : '?'} — EARTHQUAKE
|
||
</div>
|
||
<div className="map-popup-row">
|
||
Location: <span className="text-white">{place}</span>
|
||
</div>
|
||
<div className="map-popup-row">
|
||
Coords:{' '}
|
||
<span className="text-white font-mono">
|
||
{lat.toFixed(3)}, {lng.toFixed(3)}
|
||
</span>
|
||
</div>
|
||
{oracleIntel?.found && (
|
||
<div className="mt-2 pt-2 border-t border-yellow-500/20">
|
||
<div className="text-[10px] font-mono text-yellow-500/80 tracking-wider mb-1">REGION INTEL</div>
|
||
<div className="text-[10px] font-mono text-white/70">
|
||
ORACLE: {oracleIntel.tier}
|
||
{oracleIntel.avg_sentiment != null && (
|
||
<span className="text-gray-400">
|
||
{' '}
|
||
· SENT {oracleIntel.avg_sentiment > 0 ? '+' : ''}
|
||
{oracleIntel.avg_sentiment.toFixed(2)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="mt-1.5 text-[9px] tracking-wider" style={{ color: `${accent}99` }}>
|
||
SEISMIC — USGS
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Volcano popup */}
|
||
{selectedEntity?.type === 'volcano' &&
|
||
(() => {
|
||
const idx = parseInt(String(selectedEntity.id).replace('volcano-', ''), 10);
|
||
const volcano = data?.volcanoes?.[idx];
|
||
if (!volcano) return null;
|
||
const now = new Date().getFullYear();
|
||
const yearsAgo = volcano.last_eruption_year ? now - volcano.last_eruption_year : null;
|
||
const accent = yearsAgo !== null && yearsAgo <= 50 ? '#ef4444' : yearsAgo !== null && yearsAgo <= 500 ? '#f97316' : '#6b7280';
|
||
return (
|
||
<Popup longitude={volcano.lng} latitude={volcano.lat} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="260px">
|
||
<div className="map-popup bg-[#1a1035] min-w-[180px]" style={{ borderColor: `${accent}66` }}>
|
||
<div className="map-popup-title pb-1" style={{ color: accent, borderBottom: `1px solid ${accent}33` }}>
|
||
{volcano.name}
|
||
</div>
|
||
<div className="map-popup-row">Type: <span className="text-white">{volcano.type}</span></div>
|
||
<div className="map-popup-row">Country: <span className="text-white">{volcano.country}</span></div>
|
||
{volcano.region && <div className="map-popup-row">Region: <span className="text-white">{volcano.region}</span></div>}
|
||
<div className="map-popup-row">Elevation: <span className="text-white">{volcano.elevation?.toLocaleString()}m</span></div>
|
||
<div className="map-popup-row">Last Eruption: <span className="text-white">{volcano.last_eruption_year || 'Unknown'}</span></div>
|
||
<div className="mt-1.5 text-[9px] tracking-wider" style={{ color: `${accent}99` }}>
|
||
VOLCANO — SMITHSONIAN GVP
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Fishing Event popup — cross-references with AIS when available */}
|
||
{selectedEntity?.type === 'fishing_event' &&
|
||
(() => {
|
||
const event = data?.fishing_activity?.find((e) => (e.id || '') === selectedEntity.id);
|
||
if (!event) return null;
|
||
// Cross-reference with AIS ships by vessel name
|
||
const vesselNameUpper = (event.vessel_name || '').toUpperCase().trim();
|
||
const aisMatch = vesselNameUpper && data?.ships?.find((s) => {
|
||
const shipName = (s.name || '').toUpperCase().trim();
|
||
return shipName && (shipName === vesselNameUpper || shipName.includes(vesselNameUpper) || vesselNameUpper.includes(shipName));
|
||
});
|
||
return (
|
||
<Popup longitude={event.lng} latitude={event.lat} closeButton={false} closeOnClick={false} onClose={() => onEntityClick?.(null)} className="threat-popup" maxWidth="320px">
|
||
<div className="map-popup bg-[#1a1035] min-w-[220px]" style={{ borderColor: '#0ea5e966' }}>
|
||
<div className="flex justify-between items-start">
|
||
<div className="map-popup-title pb-1 flex-1" style={{ color: '#0ea5e9', borderBottom: '1px solid #0ea5e933' }}>
|
||
{event.vessel_name}
|
||
</div>
|
||
<button onClick={() => onEntityClick?.(null)} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2 shrink-0">✕</button>
|
||
</div>
|
||
<div className="map-popup-row">Flag: <span className="text-white">{event.vessel_flag || 'Unknown'}</span></div>
|
||
<div className="map-popup-row">Activity: <span className="text-cyan-400 capitalize">{event.type}</span></div>
|
||
<div className="map-popup-row">Duration: <span className="text-white">{event.duration_hrs}h</span></div>
|
||
{event.start && <div className="map-popup-row">Start: <span className="text-white">{new Date(event.start).toLocaleDateString()}</span></div>}
|
||
|
||
{/* AIS cross-reference data */}
|
||
{aisMatch && (
|
||
<div className="mt-2 pt-2 border-t border-cyan-500/20">
|
||
<div className="text-[11px] font-mono text-cyan-400 tracking-wider mb-1">AIS CROSS-REFERENCE</div>
|
||
{aisMatch.mmsi && <div className="map-popup-row">MMSI: <span className="text-white">{aisMatch.mmsi}</span></div>}
|
||
{aisMatch.callsign && <div className="map-popup-row">Callsign: <span className="text-white">{aisMatch.callsign}</span></div>}
|
||
{aisMatch.type && <div className="map-popup-row">Vessel Type: <span className="text-cyan-400 uppercase">{aisMatch.type}</span></div>}
|
||
{aisMatch.destination && <div className="map-popup-row">Destination: <span className="text-cyan-400">{aisMatch.destination}</span></div>}
|
||
{aisMatch.sog > 0 && <div className="map-popup-row">Speed: <span className="text-white">{aisMatch.sog} kts</span></div>}
|
||
{aisMatch.cog > 0 && <div className="map-popup-row">Course: <span className="text-white">{Math.round(aisMatch.cog)}°</span></div>}
|
||
{aisMatch.country && <div className="map-popup-row">Country: <span className="text-white">{aisMatch.country}</span></div>}
|
||
{aisMatch.imo && <div className="map-popup-row">IMO: <span className="text-white">{aisMatch.imo}</span></div>}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-1.5 text-[11px] tracking-wider text-gray-500">
|
||
FISHING — GLOBAL FISHING WATCH
|
||
{aisMatch && <span className="text-cyan-500"> + AIS</span>}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* Fishing vessel → AIS destination route line */}
|
||
{selectedEntity?.type === 'fishing_event' &&
|
||
(() => {
|
||
const event = data?.fishing_activity?.find((e) => (e.id || '') === selectedEntity.id);
|
||
if (!event) return null;
|
||
const vesselNameUpper = (event.vessel_name || '').toUpperCase().trim();
|
||
if (!vesselNameUpper) return null;
|
||
const aisMatch = data?.ships?.find((s) => {
|
||
const shipName = (s.name || '').toUpperCase().trim();
|
||
return shipName && (shipName === vesselNameUpper || shipName.includes(vesselNameUpper) || vesselNameUpper.includes(shipName));
|
||
});
|
||
const dest = aisMatch?.destination;
|
||
if (!dest || dest === 'UNKNOWN') return null;
|
||
return <FishingDestinationRoute vesselLat={event.lat} vesselLng={event.lng} destination={dest} />;
|
||
})()}
|
||
|
||
{(() => {
|
||
if (selectedEntity?.type !== 'gt_risk' || !selectedEntity.extra) return null;
|
||
const props = selectedEntity.extra as Record<string, unknown>;
|
||
const lat = Number(props.lat);
|
||
const lng = Number(props.lng);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||
return (
|
||
<GtRiskPopup
|
||
region={String(props.region || props.name || selectedEntity.id)}
|
||
risk={Number(props.risk ?? 0)}
|
||
financial={Number(props.financial ?? 0)}
|
||
unrest={Number(props.unrest ?? 0)}
|
||
conflict={Number(props.conflict ?? 0)}
|
||
contagion={Number(props.contagion ?? 0)}
|
||
interpretation={String(props.interpretation || '')}
|
||
lat={lat}
|
||
lng={lng}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{(() => {
|
||
if (selectedEntity?.type !== 'telegram_osint' || !data?.telegram_osint?.posts) return null;
|
||
const allPosts = data.telegram_osint.posts;
|
||
const clusterPosts = allPosts.filter((p) => {
|
||
if (!p.coords || p.coords.length < 2) return false;
|
||
const key = telegramClusterKey(p.coords[0], p.coords[1]);
|
||
return key === selectedEntity.id || p.id === selectedEntity.id;
|
||
});
|
||
const anchor = clusterPosts[0]?.coords;
|
||
if (!anchor || anchor.length < 2) return null;
|
||
const avoidAlert = telegramClusterNearNewsAlert(anchor[0], anchor[1], data?.news);
|
||
const [pinLat, pinLng] = telegramMapPinCoords(anchor[0], anchor[1], avoidAlert);
|
||
return (
|
||
<TelegramOsintPopup
|
||
posts={clusterPosts}
|
||
lat={pinLat}
|
||
lng={pinLng}
|
||
onClose={() => onEntityClick?.(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{(() => {
|
||
if (selectedEntity?.type !== 'gdelt' || !data?.gdelt) return null;
|
||
const item = data.gdelt.find(
|
||
(g) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id,
|
||
);
|
||
if (!item?.geometry?.coordinates) return null;
|
||
return (
|
||
<Popup
|
||
longitude={item.geometry.coordinates[0]}
|
||
latitude={item.geometry.coordinates[1]}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={15}
|
||
>
|
||
<div className="bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-orange-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,140,0,0.4)] pointer-events-auto overflow-hidden w-[440px]">
|
||
<div className="p-2 border-b border-orange-500/30 bg-orange-950/40 flex justify-between items-center">
|
||
<h2 className="text-[11px] tracking-widest font-bold text-orange-400 flex items-center gap-1">
|
||
<AlertTriangle size={13} className="text-orange-400" /> NEWS ON THE GROUND
|
||
</h2>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="p-3 flex flex-col gap-2">
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[10px]">LOCATION</span>
|
||
<span className="text-white text-[12px] font-bold text-right ml-2 break-words max-w-[260px]">
|
||
{item.properties?.name || 'UNKNOWN REGION'}
|
||
</span>
|
||
</div>
|
||
{/* Enriched GDELT fields */}
|
||
{item.properties?.event_date && (
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[10px]">DATE</span>
|
||
<span className="text-white text-[11px] font-bold">
|
||
{String(item.properties.event_date).replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{((item.properties?.actors?.length ?? 0) > 0) && (
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[10px]">ACTORS</span>
|
||
<span className="text-orange-300 text-[11px] font-bold text-right ml-2 max-w-[280px] truncate">
|
||
{item.properties.actors!.join(' vs ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{item.properties?.goldstein != null && item.properties.goldstein !== 0 && (
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[10px]">INTENSITY</span>
|
||
<span className={`text-[11px] font-bold ${item.properties.goldstein <= -5 ? 'text-red-400' : item.properties.goldstein <= -2 ? 'text-orange-400' : 'text-yellow-400'}`}>
|
||
{item.properties.goldstein > 0 ? '+' : ''}{item.properties.goldstein} Goldstein
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3 border-b border-[var(--border-primary)] pb-1">
|
||
<div className="flex-1 flex justify-between items-center">
|
||
<span className="text-[var(--text-muted)] text-[10px]">EVENTS</span>
|
||
<span className="text-white text-[11px] font-bold">{item.properties?.count || 1}</span>
|
||
</div>
|
||
{(item.properties?.num_sources ?? 0) > 0 && (
|
||
<div className="flex-1 flex justify-between items-center">
|
||
<span className="text-[var(--text-muted)] text-[10px]">SOURCES</span>
|
||
<span className="text-white text-[11px] font-bold">{item.properties.num_sources}</span>
|
||
</div>
|
||
)}
|
||
{(item.properties?.num_articles ?? 0) > 0 && (
|
||
<div className="flex-1 flex justify-between items-center">
|
||
<span className="text-[var(--text-muted)] text-[10px]">ARTICLES</span>
|
||
<span className="text-white text-[11px] font-bold">{item.properties.num_articles}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-col gap-1 mt-1">
|
||
<span className="text-[var(--text-muted)] text-[10px]">
|
||
LATEST REPORTS: ({(item.properties?._urls_list || []).length})
|
||
</span>
|
||
<div className="flex flex-col gap-2 max-h-[320px] overflow-y-auto styled-scrollbar mt-1">
|
||
{(() => {
|
||
const urls: string[] = item.properties?._urls_list || [];
|
||
const headlines: string[] = item.properties?._headlines_list || [];
|
||
const snippets: string[] = item.properties?._snippets_list || [];
|
||
if (urls.length === 0)
|
||
return (
|
||
<span className="text-[var(--text-muted)] text-[11px]">
|
||
No articles available.
|
||
</span>
|
||
);
|
||
return urls.map((url: string, idx: number) => {
|
||
const headline = headlines[idx] || '';
|
||
const snippet = snippets[idx] || '';
|
||
let domain = '';
|
||
try {
|
||
domain = new URL(url).hostname.replace('www.', '');
|
||
} catch {
|
||
domain = '';
|
||
}
|
||
return (
|
||
<a
|
||
key={idx}
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="block py-2 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
||
style={{ pointerEvents: 'all' }}
|
||
>
|
||
<span className="text-orange-400 text-[13px] font-bold leading-snug group-hover:text-orange-300 block">
|
||
{headline || domain || 'View Article'}
|
||
</span>
|
||
{snippet && (
|
||
<span className="text-[var(--text-secondary)] text-[11px] leading-relaxed block mt-1">
|
||
{snippet}
|
||
</span>
|
||
)}
|
||
{domain && (
|
||
<span className="text-[var(--text-muted)] text-[10px] block mt-1">
|
||
{domain}
|
||
</span>
|
||
)}
|
||
</a>
|
||
);
|
||
});
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{selectedEntity?.type === 'liveuamap' &&
|
||
data?.liveuamap?.find((l) => String(l.id) === String(selectedEntity.id)) &&
|
||
(() => {
|
||
const item = data.liveuamap.find((l) => String(l.id) === String(selectedEntity.id));
|
||
if (!item) return null;
|
||
return (
|
||
<Popup
|
||
longitude={item.lng}
|
||
latitude={item.lat}
|
||
closeButton={false}
|
||
closeOnClick={false}
|
||
onClose={() => onEntityClick?.(null)}
|
||
anchor="bottom"
|
||
offset={15}
|
||
>
|
||
<div className="bg-[var(--bg-secondary)]/90 backdrop-blur-md border border-yellow-800 rounded-lg flex flex-col z-[100] font-mono shadow-[0_4px_30px_rgba(255,255,0,0.3)] pointer-events-auto overflow-hidden w-[280px]">
|
||
<div className="p-2 border-b border-yellow-500/30 bg-yellow-950/40 flex justify-between items-center">
|
||
<h2 className="text-[10px] tracking-widest font-bold text-yellow-400 flex items-center gap-1">
|
||
<AlertTriangle size={12} className="text-yellow-400" /> REGIONAL TACTICAL
|
||
EVENT
|
||
</h2>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="p-3 flex flex-col gap-2">
|
||
<div className="flex flex-col gap-1 border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-yellow-400 text-[10px] font-bold leading-tight">
|
||
{item.title}
|
||
</span>
|
||
</div>
|
||
{item.description && (
|
||
<div className="text-[9px] text-white/70 leading-relaxed border-b border-[var(--border-primary)] pb-1.5">
|
||
{item.description}
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[9px]">REGION</span>
|
||
<span className="text-white text-[9px] font-bold">{item.region || 'Unknown'}</span>
|
||
</div>
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[9px]">TIME</span>
|
||
<span className="text-white text-[9px] font-bold">
|
||
{item.date || (item.timestamp ? new Date(Number(item.timestamp) * 1000).toLocaleString() : 'UNKNOWN')}
|
||
</span>
|
||
</div>
|
||
{item.category && (
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[9px]">TYPE</span>
|
||
<span className="text-yellow-300 text-[9px] font-bold">{item.category}</span>
|
||
</div>
|
||
)}
|
||
{item.source && (
|
||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-1">
|
||
<span className="text-[var(--text-muted)] text-[9px]">SOURCE</span>
|
||
<span className="text-white/60 text-[9px]">{item.source}</span>
|
||
</div>
|
||
)}
|
||
{item.link && (
|
||
<div className="flex justify-between items-center mt-1">
|
||
<a
|
||
href={item.link}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-yellow-400 hover:text-yellow-300 text-[9px] font-bold underline"
|
||
>
|
||
View Source Report
|
||
</a>
|
||
</div>
|
||
)}
|
||
{oracleIntel?.found && (
|
||
<div className="mt-2 pt-2 border-t border-cyan-500/20">
|
||
<div className="text-[11px] font-mono text-cyan-400 tracking-wider mb-1">ORACLE INTEL</div>
|
||
<div className="text-[11px] font-mono text-cyan-300/80">
|
||
<span className={oracleIntel.tier === 'CRITICAL' ? 'text-red-400' : oracleIntel.tier === 'ELEVATED' ? 'text-yellow-400' : 'text-green-400'}>
|
||
{oracleIntel.tier}
|
||
</span>
|
||
{' // '}
|
||
<span className={oracleIntel.avg_sentiment != null && oracleIntel.avg_sentiment < -0.05 ? 'text-red-400' : 'text-gray-400'}>
|
||
{oracleIntel.avg_sentiment != null ? `${oracleIntel.avg_sentiment > 0 ? '+' : ''}${oracleIntel.avg_sentiment.toFixed(2)} SENT` : ''}
|
||
</span>
|
||
{oracleIntel.market && (
|
||
<span className="text-purple-400">{' // '}{oracleIntel.market.consensus_pct}%</span>
|
||
)}
|
||
</div>
|
||
{oracleIntel.top_headline && (
|
||
<div className="text-[10px] text-white/60 mt-0.5 truncate">{oracleIntel.top_headline}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
);
|
||
})()}
|
||
|
||
{/* ── THREAT INTERCEPT — fullscreen intelligence dossier modal ── */}
|
||
{(() => {
|
||
if (selectedEntity?.type !== 'news' || !data?.news) return null;
|
||
const item = data.news.find((n: any) => {
|
||
const key = (n as any).alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
|
||
return key === selectedEntity.id;
|
||
}) as any;
|
||
if (!item) return null;
|
||
|
||
const rs = item.risk_score ?? 0;
|
||
let threatHex = '#eab308';
|
||
let threatColor = 'text-yellow-400';
|
||
let borderColor = 'border-yellow-800';
|
||
let bgHeaderColor = 'bg-yellow-950/50';
|
||
if (rs >= 8) {
|
||
threatHex = '#ef4444'; threatColor = 'text-red-400'; borderColor = 'border-red-700'; bgHeaderColor = 'bg-red-950/50';
|
||
} else if (rs <= 4) {
|
||
threatHex = '#22c55e'; threatColor = 'text-green-400'; borderColor = 'border-green-800'; bgHeaderColor = 'bg-green-950/50';
|
||
}
|
||
|
||
const sent = item.sentiment as number | undefined;
|
||
const oScore = item.oracle_score as number | undefined;
|
||
const oTier = oScore != null ? (oScore >= 8 ? 'CRITICAL' : oScore >= 6 ? 'ELEVATED' : oScore >= 4 ? 'MODERATE' : 'LOW') : null;
|
||
const oTierColor = oScore != null ? (oScore >= 8 ? 'text-red-400' : oScore >= 6 ? 'text-orange-400' : oScore >= 4 ? 'text-yellow-400' : 'text-green-400') : '';
|
||
const oTierBg = oScore != null ? (oScore >= 8 ? 'bg-red-500/10 border-red-500/30' : oScore >= 6 ? 'bg-orange-500/10 border-orange-500/30' : oScore >= 4 ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30') : '';
|
||
const sentColor = sent != null ? (sent < -0.1 ? 'text-red-400' : sent > 0.1 ? 'text-green-400' : 'text-gray-400') : '';
|
||
const sentBg = sent != null ? (sent < -0.1 ? 'bg-red-500/10 border-red-500/30' : sent > 0.1 ? 'bg-green-500/10 border-green-500/30' : 'bg-gray-500/10 border-gray-500/30') : '';
|
||
const sentArrow = sent != null ? (sent < -0.1 ? '▼' : sent > 0.1 ? '▲' : '—') : '';
|
||
const sentLabel = sent != null ? (sent < -0.1 ? 'NEGATIVE' : sent > 0.1 ? 'POSITIVE' : 'NEUTRAL') : '';
|
||
const pred = item.prediction_odds as any;
|
||
const articles = (item.articles as any[]) || [];
|
||
const clusterCount = (item.cluster_count as number) || 1;
|
||
const isBreaking = item.breaking === true;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0, left: 0, right: 0, bottom: 0,
|
||
zIndex: 9999,
|
||
background: 'rgba(0,0,0,0.88)',
|
||
backdropFilter: 'blur(12px)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '40px 20px',
|
||
}}
|
||
onClick={(e) => { if (e.target === e.currentTarget) onEntityClick?.(null); }}
|
||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => { if (e.key === 'Escape') onEntityClick?.(null); }}
|
||
tabIndex={-1}
|
||
ref={(el) => el?.focus()}
|
||
>
|
||
<div
|
||
className={`bg-[#080c12] border ${borderColor} rounded-lg flex flex-col font-mono overflow-hidden`}
|
||
style={{
|
||
width: 'min(700px, calc(100vw - 40px))',
|
||
maxHeight: 'calc(100vh - 80px)',
|
||
boxShadow: `0 0 80px ${threatHex}33, 0 0 200px ${threatHex}11, inset 0 1px 0 rgba(255,255,255,0.05)`,
|
||
}}
|
||
>
|
||
{/* ══════ HEADER ══════ */}
|
||
<div className={`px-5 py-3 border-b ${borderColor}/60 ${bgHeaderColor} flex justify-between items-center shrink-0`}>
|
||
<div className="flex items-center gap-3">
|
||
<AlertTriangle size={18} className={threatColor} />
|
||
<span className={`text-[14px] tracking-[0.25em] font-bold ${threatColor}`}>
|
||
{isBreaking ? 'BREAKING INTERCEPT' : 'THREAT INTERCEPT'}
|
||
</span>
|
||
{isBreaking && <span className="text-[9px] bg-red-500 text-white px-2 py-0.5 rounded-sm font-bold animate-pulse">LIVE</span>}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className={`text-[14px] ${threatColor} font-bold ${rs >= 8 ? 'animate-pulse' : ''}`}>
|
||
ALERT LVL: {rs}/10
|
||
</span>
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[var(--text-secondary)] hover:text-white text-xl leading-none px-1 hover:bg-white/10 rounded transition-colors"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ══════ SCROLLABLE BODY ══════ */}
|
||
<div className="overflow-y-auto styled-scrollbar flex flex-col flex-1">
|
||
|
||
{/* ── HEADLINE ── */}
|
||
<div className="px-5 pt-4 pb-3">
|
||
<h2 className={`text-[18px] font-bold leading-snug ${threatColor}`}>
|
||
{item.title}
|
||
</h2>
|
||
<div className="flex items-center gap-3 mt-2 text-[11px] text-[var(--text-muted)]">
|
||
<span className="text-white font-bold text-[12px]">{item.source || 'UNKNOWN'}</span>
|
||
{item.published && <span>• {item.published}</span>}
|
||
{clusterCount > 1 && <span className="text-cyan-400 font-bold">• {clusterCount} SOURCES REPORTING</span>}
|
||
{item.coords && (
|
||
<span className="ml-auto text-[10px] font-mono text-[var(--text-muted)]">
|
||
{item.coords[0].toFixed(3)}°, {item.coords[1].toFixed(3)}°
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── INTEL GRID: Oracle + Sentiment + Risk ── */}
|
||
<div className="px-5 pb-3">
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{/* Oracle Score */}
|
||
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${oTierBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||
<input type="checkbox" className="peer sr-only" aria-label="Explain Oracle Score" />
|
||
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
|
||
<span>ORACLE SCORE</span>
|
||
<Info size={10} />
|
||
</div>
|
||
<div className={`text-[28px] font-bold leading-none ${oTierColor || 'text-gray-500'}`}>
|
||
{oScore != null ? oScore.toFixed(1) : '—'}
|
||
</div>
|
||
{oTier && <div className={`text-[10px] font-bold ${oTierColor} mt-1`}>{oTier}</div>}
|
||
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
|
||
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
|
||
<p>0-10 weighted signal score combining alert risk and source confidence.</p>
|
||
<p className="mt-1 text-[var(--text-muted)]">0-3 low, 4-5 moderate, 6-7 elevated, 8-10 critical.</p>
|
||
</div>
|
||
</label>
|
||
{/* Sentiment */}
|
||
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
|
||
<input type="checkbox" className="peer sr-only" aria-label="Explain Sentiment" />
|
||
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
|
||
<span>SENTIMENT</span>
|
||
<Info size={10} />
|
||
</div>
|
||
<div className={`text-[28px] font-bold leading-none ${sentColor || 'text-gray-500'}`}>
|
||
{sent != null ? <>{sentArrow} {sent > 0 ? '+' : ''}{sent.toFixed(2)}</> : '—'}
|
||
</div>
|
||
{sentLabel && <div className={`text-[10px] font-bold ${sentColor} mt-1`}>{sentLabel}</div>}
|
||
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
|
||
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
|
||
<p>-1.00 to +1.00 headline tone. Negative reads more adverse; positive reads more constructive.</p>
|
||
<p className="mt-1 text-[var(--text-muted)]">Below -0.10 negative, -0.10 to +0.10 neutral, above +0.10 positive. It measures tone, not truth.</p>
|
||
</div>
|
||
</label>
|
||
{/* Threat Level */}
|
||
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${rs >= 8 ? 'bg-red-500/10 border-red-500/30' : rs >= 6 ? 'bg-orange-500/10 border-orange-500/30' : rs >= 4 ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30'}`}>
|
||
<input type="checkbox" className="peer sr-only" aria-label="Explain Risk Level" />
|
||
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
|
||
<span>RISK LEVEL</span>
|
||
<Info size={10} />
|
||
</div>
|
||
<div className={`text-[28px] font-bold leading-none ${threatColor}`}>{rs}/10</div>
|
||
<div className={`text-[10px] font-bold ${threatColor} mt-1`}>
|
||
{rs >= 9 ? 'CRITICAL' : rs >= 7 ? 'HIGH' : rs >= 4 ? 'MEDIUM' : 'LOW'}
|
||
</div>
|
||
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
|
||
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
|
||
<p>0-10 operational severity estimate based on source, topic, keywords, corroboration, and alert context.</p>
|
||
<p className="mt-1 text-[var(--text-muted)]">0-3 low, 4-6 medium, 7-8 high, 9-10 critical.</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── PREDICTION MARKET ANALYSIS ── */}
|
||
{pred && pred.consensus_pct != null && (
|
||
<div className="px-5 pb-3">
|
||
<div className="bg-purple-950/30 border border-purple-500/40 rounded p-4">
|
||
<div className="flex items-center justify-between gap-3 mb-2">
|
||
<div className="text-[10px] text-purple-400 tracking-[0.2em] font-bold">
|
||
PREDICTION MARKET ANALYSIS
|
||
</div>
|
||
{pred.polymarket_pct != null && (
|
||
<button
|
||
type="button"
|
||
onClick={() => window.open(buildPolymarketUrl(pred), '_blank', 'noopener,noreferrer')}
|
||
className="inline-flex items-center gap-1 text-[10px] text-purple-200 hover:text-white border border-purple-500/30 hover:border-purple-300/70 px-2 py-1 rounded transition-colors"
|
||
title="Open this market on Polymarket"
|
||
>
|
||
POLYMARKET <ExternalLink size={10} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="text-[14px] text-purple-200 font-bold leading-snug mb-3">
|
||
"{pred.title}"
|
||
</div>
|
||
{/* Progress bar */}
|
||
<div className="bg-black/50 rounded overflow-hidden h-8 relative border border-purple-500/20 mb-3">
|
||
<div
|
||
className="h-full bg-gradient-to-r from-purple-700 to-purple-400 transition-all"
|
||
style={{ width: `${pred.consensus_pct}%` }}
|
||
/>
|
||
<span className="absolute inset-0 flex items-center justify-center text-[14px] font-bold text-white drop-shadow-lg">
|
||
{pred.consensus_pct}% CONSENSUS PROBABILITY
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-6 text-[11px]">
|
||
{pred.polymarket_pct != null && (
|
||
<button
|
||
type="button"
|
||
onClick={() => window.open(buildPolymarketUrl(pred), '_blank', 'noopener,noreferrer')}
|
||
className="flex items-center gap-2 hover:text-white transition-colors"
|
||
title="Open this market on Polymarket"
|
||
>
|
||
<span className="text-purple-400/70">Polymarket</span>
|
||
<span className="text-white font-bold text-[13px]">{pred.polymarket_pct}%</span>
|
||
<ExternalLink size={10} className="text-purple-400/70" />
|
||
</button>
|
||
)}
|
||
{pred.kalshi_pct != null && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-purple-400/70">Kalshi</span>
|
||
<span className="text-white font-bold text-[13px]">{pred.kalshi_pct}%</span>
|
||
</div>
|
||
)}
|
||
{pred.match_score != null && (
|
||
<span className="text-purple-400/40 ml-auto text-[10px]">headline match: {(pred.match_score * 100).toFixed(0)}%</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── SYS.ANALYSIS ── */}
|
||
{item.machine_assessment && (
|
||
<div className="px-5 pb-3">
|
||
<div className="p-3 bg-black/60 border border-cyan-800/50 rounded text-[11px] text-cyan-400 font-mono leading-relaxed relative overflow-hidden">
|
||
<div className="absolute top-0 left-0 w-[3px] h-full bg-cyan-500 animate-pulse"></div>
|
||
<span className="font-bold text-white text-[12px]">>_ SYS.ANALYSIS: </span>
|
||
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── CORROBORATING SOURCES ── */}
|
||
{articles.length > 1 && (
|
||
<div className="px-5 pb-3">
|
||
<div className="text-[10px] text-[var(--text-muted)] tracking-[0.2em] font-bold mb-2">
|
||
CORROBORATING SOURCES ({articles.length})
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
{articles.map((sub: any, si: number) => {
|
||
const subRs = sub.risk_score ?? 0;
|
||
const subColor = subRs >= 8 ? 'text-red-400' : subRs >= 6 ? 'text-orange-400' : subRs >= 4 ? 'text-yellow-400' : 'text-green-400';
|
||
return (
|
||
<div
|
||
key={si}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => sub.link && window.open(sub.link, '_blank', 'noopener,noreferrer')}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && sub.link) window.open(sub.link, '_blank', 'noopener,noreferrer'); }}
|
||
className="flex items-start gap-3 py-2 px-3 border-l-2 border-cyan-800/40 bg-black/30 rounded-r hover:bg-cyan-950/30 transition-colors group cursor-pointer"
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 text-[10px]">
|
||
<span className="text-white font-bold">{sub.source}</span>
|
||
<span className={`${subColor} font-bold`}>LVL: {subRs}/10</span>
|
||
{sub.published && <span className="text-[var(--text-muted)] text-[9px]">{sub.published}</span>}
|
||
</div>
|
||
<div className="text-[11px] text-[var(--text-secondary)] leading-snug mt-0.5 group-hover:text-cyan-300 transition-colors">
|
||
{sub.title}
|
||
</div>
|
||
</div>
|
||
<span className="text-[11px] text-cyan-500 group-hover:text-cyan-300 shrink-0 mt-1">↗</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── FOOTER ── */}
|
||
<div className="px-5 py-3 flex justify-between items-center border-t border-[var(--border-primary)] mt-auto shrink-0">
|
||
{item.link ? (
|
||
<button
|
||
onClick={() => window.open(item.link, '_blank', 'noopener,noreferrer')}
|
||
className={`${threatColor} hover:text-white text-[12px] font-bold underline underline-offset-2 cursor-pointer`}
|
||
>
|
||
GO TO ARTICLE ↗
|
||
</button>
|
||
) : <span />}
|
||
<button
|
||
onClick={() => onEntityClick?.(null)}
|
||
className="text-[11px] text-[var(--text-muted)] hover:text-white border border-[var(--border-primary)] hover:border-white/30 px-3 py-1 rounded transition-colors"
|
||
>
|
||
CLOSE DOSSIER
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* REGION DOSSIER — location pin on map (full intel shown in right panel) */}
|
||
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && (
|
||
<Marker
|
||
longitude={selectedEntity.extra.lng}
|
||
latitude={selectedEntity.extra.lat}
|
||
anchor="bottom"
|
||
style={{ zIndex: 10 }}
|
||
>
|
||
<div className="flex flex-col items-center pointer-events-none">
|
||
{/* Pulsing ring */}
|
||
<div className="w-8 h-8 rounded-full border-2 border-emerald-500 animate-ping absolute opacity-30" />
|
||
{/* Pin dot */}
|
||
<div className="w-4 h-4 rounded-full bg-emerald-500 border-2 border-emerald-300 shadow-[0_0_15px_rgba(16,185,129,0.6)]" />
|
||
{/* Label */}
|
||
<div className="mt-2 bg-black/80 border border-emerald-800 rounded px-2 py-1 text-[9px] font-mono text-emerald-400 tracking-widest whitespace-nowrap shadow-[0_0_10px_rgba(16,185,129,0.3)]">
|
||
{regionDossierLoading ? 'COMPILING...' : '▶ INTEL TARGET'}
|
||
</div>
|
||
</div>
|
||
</Marker>
|
||
)}
|
||
|
||
{/* SENTINEL-2 IMAGERY — fullscreen overlay modal */}
|
||
{selectedEntity?.type === 'region_dossier' &&
|
||
selectedEntity.extra &&
|
||
regionDossier?.sentinel2 && (
|
||
<RegionDossierPanel
|
||
sentinel2={regionDossier.sentinel2}
|
||
lat={selectedEntity.extra.lat}
|
||
lng={selectedEntity.extra.lng}
|
||
onClose={() => onEntityClick(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* OPTIC INTERCEPT — fullscreen CCTV camera modal */}
|
||
{selectedEntity?.type === 'cctv' &&
|
||
(() => {
|
||
const props = (selectedEntity.extra || {}) as Record<string, unknown>;
|
||
const rawUrl = String(selectedEntity.media_url || props.media_url || '');
|
||
const mt = String(props.media_type || (
|
||
rawUrl.includes('.mp4') || rawUrl.includes('.webm') ? 'video' :
|
||
rawUrl.includes('.m3u8') || rawUrl.includes('hls') ? 'hls' :
|
||
rawUrl.includes('.mjpg') || rawUrl.includes('.mjpeg') || rawUrl.includes('mjpg') ? 'mjpeg' : 'image'
|
||
));
|
||
// Proxy external URLs through backend to bypass CORS
|
||
const url = buildCctvProxyUrl(rawUrl);
|
||
const isVideo = mt === 'video' || mt === 'hls';
|
||
const cameraName = String(selectedEntity.name || props.name || 'UNKNOWN MOUNT').toUpperCase();
|
||
const sourceAgency = String(props.source_agency || 'CCTV').toUpperCase();
|
||
|
||
return (
|
||
<CctvFullscreenModal
|
||
url={url}
|
||
rawUrl={rawUrl}
|
||
mediaType={mt}
|
||
isVideo={isVideo}
|
||
cameraName={cameraName}
|
||
sourceAgency={sourceAgency}
|
||
cameraId={String(selectedEntity.id || '')}
|
||
onClose={() => onEntityClick(null)}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* ── AI Intel Pin Detail popup ── */}
|
||
{openPinDetailId && (
|
||
<AIIntelPinDetail
|
||
pinId={openPinDetailId}
|
||
onClose={() => setOpenPinDetailId(null)}
|
||
onDeleted={() => {
|
||
setOpenPinDetailId(null);
|
||
setAiIntelRefreshTick((t) => t + 1);
|
||
onPinPlaced?.();
|
||
}}
|
||
onUpdated={() => setAiIntelRefreshTick((t) => t + 1)}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Pin Placement Dialog (offset marker + connecting line) ── */}
|
||
{pendingPin && (
|
||
<Marker
|
||
latitude={pendingPin.lat}
|
||
longitude={pendingPin.lng}
|
||
anchor="center"
|
||
offset={[0, -120]}
|
||
style={{ zIndex: 9990 }}
|
||
>
|
||
<div
|
||
className="relative"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => {
|
||
// Prevent global hotkeys (l/r/m/s/k/f/space) from firing while
|
||
// typing in the pin dialog — the maplibre canvas is still in
|
||
// the document and document-level listeners otherwise fire.
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
}}
|
||
onKeyUp={(e) => {
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
}}
|
||
>
|
||
{/* Connecting line + dot at actual pin location */}
|
||
<svg
|
||
className="absolute pointer-events-none"
|
||
style={{
|
||
left: '50%',
|
||
top: '50%',
|
||
width: 1,
|
||
height: 1,
|
||
overflow: 'visible',
|
||
zIndex: -1,
|
||
}}
|
||
>
|
||
<line
|
||
x1={0}
|
||
y1={0}
|
||
x2={0}
|
||
y2={120}
|
||
stroke="#8b5cf6"
|
||
strokeWidth="1.5"
|
||
strokeDasharray="4,3"
|
||
className="opacity-80"
|
||
/>
|
||
<circle cx={0} cy={120} r="4" fill="#8b5cf6" stroke="#0a0a14" strokeWidth="1.5" />
|
||
</svg>
|
||
|
||
{/* Arrow triangle pointing down to pin */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: '-6px',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
width: 0,
|
||
height: 0,
|
||
borderLeft: '6px solid transparent',
|
||
borderRight: '6px solid transparent',
|
||
borderTop: '6px solid #8b5cf6',
|
||
}}
|
||
/>
|
||
|
||
{/* Dialog box */}
|
||
<div
|
||
className="bg-[#0a0a14] border-2 border-violet-500/60 p-3 font-mono"
|
||
style={{ minWidth: 260, maxWidth: 300, transform: 'translateX(-50%)', marginLeft: '50%' }}
|
||
>
|
||
{/* Close button */}
|
||
<button
|
||
onClick={() => { setPendingPin(null); setPinLabel(''); setPinNotes(''); }}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 4,
|
||
right: 8,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
color: '#8b5cf6',
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
lineHeight: 1,
|
||
opacity: 0.7,
|
||
zIndex: 20,
|
||
}}
|
||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
|
||
>
|
||
×
|
||
</button>
|
||
|
||
<div className="text-[12px] text-violet-400 tracking-widest mb-2 font-bold">
|
||
{pendingPin.entity ? 'PIN TO ENTITY' : 'PIN TO LOCATION'}
|
||
</div>
|
||
{pendingPin.entity && (
|
||
<div className="text-[10px] text-cyan-400 mb-2 px-2 py-1 bg-cyan-500/10 border border-cyan-500/20">
|
||
Tracking: {pendingPin.entity.entity_label || pendingPin.entity.entity_id}
|
||
<span className="text-cyan-600 ml-1">({pendingPin.entity.entity_type})</span>
|
||
</div>
|
||
)}
|
||
{/* Category selector */}
|
||
<select
|
||
title="Pin category"
|
||
aria-label="Pin category"
|
||
value={pinCategory}
|
||
onChange={(e) => setPinCategory(e.target.value as PinCategory)}
|
||
className="w-full px-2 py-1.5 text-[11px] font-mono bg-black/50 border border-violet-500/30 text-white focus:border-violet-500/60 outline-none mb-1.5 border-l-4"
|
||
style={{ borderLeftColor: PIN_CATEGORY_COLORS[pinCategory] }}
|
||
>
|
||
{(Object.keys(PIN_CATEGORY_LABELS) as PinCategory[]).map((cat) => (
|
||
<option key={cat} value={cat} className="bg-[#0a0a14]">
|
||
{PIN_CATEGORY_LABELS[cat]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
ref={pinLabelInputRef}
|
||
type="text"
|
||
value={pinLabel}
|
||
onChange={(e) => setPinLabel(e.target.value)}
|
||
placeholder="Label..."
|
||
autoFocus
|
||
className="w-full px-2 py-1.5 text-[12px] font-mono bg-black/50 border border-violet-500/30 text-white placeholder:text-gray-600 focus:border-violet-500/60 outline-none mb-1.5"
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
if (e.key === 'Enter' && pinLabel.trim()) {
|
||
e.preventDefault();
|
||
handleSavePin();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
setPendingPin(null); setPinLabel(''); setPinNotes(''); setPinCategory('custom');
|
||
}
|
||
}}
|
||
/>
|
||
<textarea
|
||
value={pinNotes}
|
||
onChange={(e) => setPinNotes(e.target.value)}
|
||
placeholder="Notes (optional)..."
|
||
rows={2}
|
||
className="w-full px-2 py-1 text-[11px] font-mono bg-black/50 border border-violet-500/20 text-gray-300 placeholder:text-gray-600 focus:border-violet-500/40 outline-none resize-none mb-2"
|
||
onMouseDown={(e) => {
|
||
// Force-focus the textarea on click — the maplibre canvas
|
||
// otherwise steals focus back and typing gets swallowed.
|
||
e.stopPropagation();
|
||
(e.currentTarget as HTMLTextAreaElement).focus();
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
(e.currentTarget as HTMLTextAreaElement).focus();
|
||
}}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
e.nativeEvent.stopImmediatePropagation();
|
||
if (e.key === 'Escape') {
|
||
setPendingPin(null); setPinLabel(''); setPinNotes(''); setPinCategory('custom');
|
||
}
|
||
}}
|
||
/>
|
||
<div className="text-[9px] text-gray-500 mb-2">
|
||
{pendingPin.lat.toFixed(5)}, {pendingPin.lng.toFixed(5)}
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
type="button"
|
||
disabled={!pinLabel.trim() || pinSaving}
|
||
onClick={handleSavePin}
|
||
className="flex-1 py-1.5 text-[11px] font-mono tracking-wider bg-violet-600/30 border border-violet-500/50 text-violet-300 hover:bg-violet-600/50 transition-colors disabled:opacity-40"
|
||
>
|
||
{pinSaving ? '...' : 'CONFIRM'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setPendingPin(null); setPinLabel(''); setPinNotes(''); }}
|
||
className="px-3 py-1.5 text-[11px] font-mono tracking-wider border border-gray-600/40 text-gray-400 hover:text-gray-300 transition-colors"
|
||
>
|
||
CANCEL
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Marker>
|
||
)}
|
||
|
||
<MeasurementLayers measurePoints={measurePoints} />
|
||
</Map>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
import dynamic from 'next/dynamic';
|
||
|
||
export default dynamic(() => Promise.resolve(MaplibreViewer), {
|
||
ssr: false,
|
||
});
|