mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-10 08:13:58 +02:00
e1060193d0
Prioritize cached first-paint data, defer heavyweight feed synthesis, make MeshChat activation explicit, improve CCTV media handling, and tighten desktop runtime packaging filters.
6187 lines
249 KiB
TypeScript
6187 lines
249 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 {
|
||
ClusterCountLabels,
|
||
TrackedFlightLabels,
|
||
CarrierLabels,
|
||
TrackedYachtLabels,
|
||
UavLabels,
|
||
EarthquakeLabels,
|
||
ThreatMarkers,
|
||
} from '@/components/map/MapMarkers';
|
||
import type { DashboardData, KiwiSDR, MaplibreViewerProps, Scanner, 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 { useViewportBounds } from '@/components/map/hooks/useViewportBounds';
|
||
import { MeasurementLayers } from '@/components/map/layers/MeasurementLayers';
|
||
import { buildCctvProxyUrl } from '@/lib/cctvProxy';
|
||
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 {
|
||
buildSentinelTileUrl,
|
||
hasSentinelCredentials,
|
||
getSentinelToken,
|
||
registerSentinelProtocol,
|
||
} from '@/lib/sentinelHub';
|
||
import {
|
||
buildEarthquakesGeoJSON,
|
||
buildJammingGeoJSON,
|
||
buildCorrelationsGeoJSON,
|
||
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;
|
||
|
||
const MAP_EXTRA_DATA_KEYS = [
|
||
'air_quality',
|
||
'cctv',
|
||
'commercial_flights',
|
||
'correlations',
|
||
'crowdthreat',
|
||
'datacenters',
|
||
'firms_fires',
|
||
'fishing_activity',
|
||
'frontlines',
|
||
'gps_jamming',
|
||
'internet_outages',
|
||
'kiwisdr',
|
||
'military_bases',
|
||
'military_flights',
|
||
'power_plants',
|
||
'private_flights',
|
||
'private_jets',
|
||
'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',
|
||
'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 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;
|
||
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]);
|
||
|
||
// 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 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 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,
|
||
dtSeconds: dtSeconds.current,
|
||
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,
|
||
interpTick,
|
||
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 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,
|
||
},
|
||
[
|
||
staticCctv,
|
||
staticKiwisdr,
|
||
staticPskReporter,
|
||
staticSatnogsStations,
|
||
staticScanners,
|
||
staticFirmsFires,
|
||
staticInternetOutages,
|
||
staticDatacenters,
|
||
staticPowerPlants,
|
||
staticViirsChangeNodes,
|
||
staticMilitaryBases,
|
||
staticGdelt,
|
||
staticLiveuamap,
|
||
staticAirQuality,
|
||
staticVolcanoes,
|
||
staticFishingActivity,
|
||
data?.ships,
|
||
staticTrains,
|
||
staticUapSightings,
|
||
staticWastewater,
|
||
staticCrowdthreat,
|
||
],
|
||
{
|
||
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,
|
||
},
|
||
},
|
||
[
|
||
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,
|
||
],
|
||
);
|
||
|
||
const {
|
||
commercialFlightsGeoJSON: commFlightsGeoJSON,
|
||
privateFlightsGeoJSON: privFlightsGeoJSON,
|
||
privateJetsGeoJSON: privJetsGeoJSON,
|
||
militaryFlightsGeoJSON: milFlightsGeoJSON,
|
||
trackedFlightsGeoJSON,
|
||
shipsGeoJSON,
|
||
meshtasticGeoJSON,
|
||
aprsGeoJSON,
|
||
} = dynamicMapLayers;
|
||
|
||
const {
|
||
cctvGeoJSON,
|
||
kiwisdrGeoJSON,
|
||
pskReporterGeoJSON,
|
||
satnogsGeoJSON,
|
||
scannerGeoJSON,
|
||
firmsGeoJSON,
|
||
internetOutagesGeoJSON,
|
||
dataCentersGeoJSON,
|
||
powerPlantsGeoJSON,
|
||
viirsChangeNodesGeoJSON,
|
||
militaryBasesGeoJSON,
|
||
gdeltGeoJSON,
|
||
liveuaGeoJSON,
|
||
airQualityGeoJSON,
|
||
volcanoesGeoJSON,
|
||
fishingGeoJSON,
|
||
trainsGeoJSON,
|
||
uapSightingsGeoJSON,
|
||
wastewaterGeoJSON,
|
||
crowdthreatGeoJSON,
|
||
} = staticMapLayers;
|
||
|
||
// 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 aircraft has been
|
||
const trailGeoJSON = useMemo(() => {
|
||
void interpTick;
|
||
const entity = findSelectedEntity(selectedEntity, data);
|
||
if (!entity || !('trail' in entity) || !entity.trail || entity.trail.length < 2) return null;
|
||
|
||
// Parse trail points — backend sends [lat, lng, alt, ts] arrays
|
||
type TrailPt = { lng: number; lat: number; alt: number; ts: number };
|
||
const points: TrailPt[] = (
|
||
entity.trail as Array<{ lat?: number; lng?: number; alt?: number; ts?: number } | number[]>
|
||
).map((p) => {
|
||
if (Array.isArray(p)) {
|
||
return { lat: p[0] as number, lng: p[1] as number, alt: (p[2] as number) || 0, ts: (p[3] as number) || 0 };
|
||
}
|
||
return { lat: p.lat ?? 0, lng: p.lng ?? 0, alt: p.alt ?? 0, ts: p.ts ?? 0 };
|
||
}).filter((p) => p.lat !== 0 || p.lng !== 0);
|
||
|
||
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, 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: altToColor((a.alt + b.alt) / 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, 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',
|
||
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',
|
||
].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, '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);
|
||
|
||
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'].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 && nightGeoJSON && (
|
||
<Source id="night-overlay" type="geojson" data={nightGeoJSON}>
|
||
<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={(frontlineGeoJSON ?? 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={(earthquakesGeoJSON ?? 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={(jammingGeoJSON ?? 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>
|
||
|
||
{/* Correlation Alerts — Emergent Intelligence grid squares */}
|
||
<Source id="correlations" type="geojson" data={(correlationsGeoJSON ?? 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={(shodanGeoJSON ?? 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 */}
|
||
{aiIntelGeoJSON && (
|
||
<Source
|
||
id="ai-intel-source"
|
||
type="geojson"
|
||
data={aiIntelGeoJSON}
|
||
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={(ukraineAlertsGeoJSON ?? 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={(ukraineAlertLabelsGeoJSON ?? 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={(weatherAlertsGeoJSON ?? 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={(weatherAlertLabelsGeoJSON ?? 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>
|
||
|
||
{/* 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={(carriersGeoJSON ?? 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={(activeRouteGeoJSON ?? 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={(trailGeoJSON ?? 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={(predictiveGeoJSON ?? 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={(proximityRingsGeoJSON ?? 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);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 !== '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,
|
||
});
|