v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors

Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them
through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation
system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery,
killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption
keys and chain state during updates.

New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers,
CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets,
desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing).

Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami,
@chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const,
@Elhard1, @ttulttul
This commit is contained in:
anoracleofra-code
2026-03-26 05:58:04 -06:00
parent d363013742
commit 668ce16dc7
363 changed files with 170456 additions and 23229 deletions
+404 -243
View File
@@ -1,307 +1,468 @@
import React from "react";
import { Marker } from "react-map-gl/maplibre";
import type { ViewState } from "react-map-gl/maplibre";
import React from 'react';
import { Marker } from 'react-map-gl/maplibre';
import type { Earthquake, SelectedEntity, Ship, TrackedFlight, UAV } from '@/types/dashboard';
import type { SpreadAlertItem } from '@/utils/alertSpread';
// Shared monospace label style base
const LABEL_BASE: React.CSSProperties = {
fontFamily: 'monospace',
fontWeight: 'bold',
textShadow: '0 0 3px #000, 0 0 3px #000',
pointerEvents: 'none',
fontFamily: 'monospace',
fontWeight: 'bold',
textShadow: '0 0 3px #000, 0 0 3px #000',
pointerEvents: 'none',
};
const LABEL_SHADOW_EXTRA = '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000';
// -- Cluster count label (ships / earthquakes) --
export function ClusterCountLabels({ clusters, prefix }: { clusters: any[]; prefix: string }) {
return (
<>
{clusters.map((c: any) => (
<Marker key={`${prefix}-${c.id}`} longitude={c.lng} latitude={c.lat} anchor="center" style={{ zIndex: 1 }}>
<div style={{ ...LABEL_BASE, color: '#fff', fontSize: '11px', textAlign: 'center' }}>
{c.count}
</div>
</Marker>
))}
</>
);
type ClusterPoint = {
id: string | number;
lat: number;
lng: number;
count: string | number;
};
export function ClusterCountLabels({ clusters, prefix }: { clusters: ClusterPoint[]; prefix: string }) {
return (
<>
{clusters.map((c) => (
<Marker
key={`${prefix}-${c.id}`}
longitude={c.lng}
latitude={c.lat}
anchor="center"
style={{ zIndex: 1 }}
>
<div style={{ ...LABEL_BASE, color: '#fff', fontSize: '11px', textAlign: 'center' }}>
{c.count}
</div>
</Marker>
))}
</>
);
}
// -- Tracked flights labels --
const TRACKED_LABEL_COLOR_MAP: Record<string, string> = {
'#ff1493': '#ff1493', pink: '#ff1493', red: '#ff4444',
blue: '#3b82f6', orange: '#FF8C00', '#32cd32': '#32cd32',
purple: '#b266ff', white: '#cccccc',
'#ff1493': '#ff1493',
pink: '#ff1493',
red: '#ff4444',
blue: '#3b82f6',
orange: '#FF8C00',
'#32cd32': '#32cd32',
purple: '#b266ff',
white: '#cccccc',
};
interface TrackedFlightLabelsProps {
flights: any[];
viewState: ViewState;
inView: (lat: number, lng: number) => boolean;
interpFlight: (f: any) => [number, number];
flights: TrackedFlight[];
zoom: number;
inView: (lat: number, lng: number) => boolean;
interpFlight: (f: TrackedFlight) => [number, number];
}
export function TrackedFlightLabels({ flights, viewState, inView, interpFlight }: TrackedFlightLabelsProps) {
return (
<>
{flights.map((f: any, i: number) => {
if (f.lat == null || f.lng == null) return null;
if (!inView(f.lat, f.lng)) return null;
export function TrackedFlightLabels({
flights,
zoom,
inView,
interpFlight,
}: TrackedFlightLabelsProps) {
return (
<>
{flights.map((f, i) => {
if (f.lat == null || f.lng == null) return null;
if (!inView(f.lat, f.lng)) return null;
const alertColor = f.alert_color || '#ff1493';
if (alertColor === 'yellow' || alertColor === 'black') return null;
const alertColor = f.alert_color || '#ff1493';
if (alertColor === 'yellow' || alertColor === 'black') return null;
const isHighPriority = alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red';
if (!isHighPriority && viewState.zoom < 5) return null;
const isHighPriority =
alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red';
if (!isHighPriority && zoom < 5) return null;
let displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
if (displayName === 'Private' || displayName === 'private') return null;
const displayName =
f.alert_operator ||
f.operator ||
f.owner ||
f.name ||
f.callsign ||
f.icao24 ||
'UNKNOWN';
if (displayName === 'Private' || displayName === 'private') return null;
const grounded = f.alt != null && f.alt <= 100;
const labelColor = grounded ? '#888' : (TRACKED_LABEL_COLOR_MAP[alertColor] || alertColor);
const [iLng, iLat] = interpFlight(f);
const grounded = f.alt != null && f.alt <= 100;
const labelColor = grounded ? '#888' : TRACKED_LABEL_COLOR_MAP[alertColor] || alertColor;
const [iLng, iLat] = interpFlight(f);
return (
<Marker key={`tf-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}>
<div style={{ ...LABEL_BASE, color: labelColor, fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
{String(displayName)}
</div>
</Marker>
);
})}
</>
);
return (
<Marker
key={`tf-label-${i}`}
longitude={iLng}
latitude={iLat}
anchor="top"
offset={[0, 10]}
style={{ zIndex: 2 }}
>
<div
style={{
...LABEL_BASE,
color: labelColor,
fontSize: '10px',
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
}}
>
{String(displayName)}
</div>
</Marker>
);
})}
</>
);
}
// -- Carrier labels --
interface CarrierLabelsProps {
ships: any[];
inView: (lat: number, lng: number) => boolean;
interpShip: (s: any) => [number, number];
ships: Ship[];
inView: (lat: number, lng: number) => boolean;
interpShip: (s: Ship) => [number, number];
}
export function CarrierLabels({ ships, inView, interpShip }: CarrierLabelsProps) {
return (
<>
{ships.map((s: any, i: number) => {
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const [iLng, iLat] = interpShip(s);
return (
<Marker key={`carrier-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
<div style={{ ...LABEL_BASE, textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap', textAlign: 'center' }}>
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
[[{s.name}]]
</div>
{s.estimated && (
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
EST. POSITION OSINT
</div>
)}
</div>
</Marker>
);
})}
</>
);
return (
<>
{ships.map((s, i) => {
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const [iLng, iLat] = interpShip(s);
return (
<Marker
key={`carrier-label-${i}`}
longitude={iLng}
latitude={iLat}
anchor="top"
offset={[0, 12]}
style={{ zIndex: 2 }}
>
<div
style={{
...LABEL_BASE,
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
<div style={{ color: '#ffaa00', fontSize: '11px', fontWeight: 'bold' }}>
[[{s.name}]]
</div>
{s.estimated && (
<div style={{ color: '#ff6644', fontSize: '8px', letterSpacing: '1.5px' }}>
EST. POSITION OSINT
</div>
)}
</div>
</Marker>
);
})}
</>
);
}
// -- Tracked yacht labels --
interface TrackedYachtLabelsProps {
ships: any[];
inView: (lat: number, lng: number) => boolean;
interpShip: (s: any) => [number, number];
ships: Ship[];
inView: (lat: number, lng: number) => boolean;
interpShip: (s: Ship) => [number, number];
}
export function TrackedYachtLabels({ ships, inView, interpShip }: TrackedYachtLabelsProps) {
return (
<>
{ships.map((s: any, i: number) => {
if (!s.yacht_alert || s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const [iLng, iLat] = interpShip(s);
return (
<Marker key={`yacht-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 12]} style={{ zIndex: 2 }}>
<div style={{ ...LABEL_BASE, color: s.yacht_color || '#FF69B4', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
{s.yacht_owner || s.name || 'TRACKED YACHT'}
</div>
</Marker>
);
})}
</>
);
return (
<>
{ships.map((s, i) => {
if (!s.yacht_alert || s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const [iLng, iLat] = interpShip(s);
return (
<Marker
key={`yacht-label-${i}`}
longitude={iLng}
latitude={iLat}
anchor="top"
offset={[0, 12]}
style={{ zIndex: 2 }}
>
<div
style={{
...LABEL_BASE,
color: s.yacht_color || '#FF69B4',
fontSize: '10px',
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
}}
>
{s.yacht_owner || s.name || 'TRACKED YACHT'}
</div>
</Marker>
);
})}
</>
);
}
// -- UAV labels --
interface UavLabelsProps {
uavs: any[];
inView: (lat: number, lng: number) => boolean;
uavs: UAV[];
inView: (lat: number, lng: number) => boolean;
}
export function UavLabels({ uavs, inView }: UavLabelsProps) {
return (
<>
{uavs.map((uav: any, i: number) => {
if (uav.lat == null || uav.lng == null) return null;
if (!inView(uav.lat, uav.lng)) return null;
const name = uav.aircraft_model ? `[UAV: ${uav.aircraft_model}]` : `[UAV: ${uav.callsign}]`;
return (
<Marker key={`uav-label-${i}`} longitude={uav.lng} latitude={uav.lat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}>
<div style={{ ...LABEL_BASE, color: '#ff8c00', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
{name}
</div>
</Marker>
);
})}
</>
);
return (
<>
{uavs.map((uav, i) => {
if (uav.lat == null || uav.lng == null) return null;
if (!inView(uav.lat, uav.lng)) return null;
const name = uav.aircraft_model ? `[UAV: ${uav.aircraft_model}]` : `[UAV: ${uav.callsign}]`;
return (
<Marker
key={`uav-label-${i}`}
longitude={uav.lng}
latitude={uav.lat}
anchor="top"
offset={[0, 10]}
style={{ zIndex: 2 }}
>
<div
style={{
...LABEL_BASE,
color: '#ff8c00',
fontSize: '10px',
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
}}
>
{name}
</div>
</Marker>
);
})}
</>
);
}
// -- Earthquake labels --
interface EarthquakeLabelsProps {
earthquakes: any[];
inView: (lat: number, lng: number) => boolean;
earthquakes: Earthquake[];
inView: (lat: number, lng: number) => boolean;
}
export function EarthquakeLabels({ earthquakes, inView }: EarthquakeLabelsProps) {
return (
<>
{earthquakes.map((eq: any, i: number) => {
if (eq.lat == null || eq.lng == null) return null;
if (!inView(eq.lat, eq.lng)) return null;
return (
<Marker key={`eq-label-${i}`} longitude={eq.lng} latitude={eq.lat} anchor="top" offset={[0, 14]} style={{ zIndex: 1 }}>
<div style={{ ...LABEL_BASE, color: '#ffcc00', fontSize: '10px', textShadow: LABEL_SHADOW_EXTRA, whiteSpace: 'nowrap' }}>
[M{eq.mag}] {eq.place || ''}
</div>
</Marker>
);
})}
</>
);
return (
<>
{earthquakes.map((eq, i) => {
if (eq.lat == null || eq.lng == null) return null;
if (!inView(eq.lat, eq.lng)) return null;
return (
<Marker
key={`eq-label-${i}`}
longitude={eq.lng}
latitude={eq.lat}
anchor="top"
offset={[0, 14]}
style={{ zIndex: 1 }}
>
<div
style={{
...LABEL_BASE,
color: '#ffcc00',
fontSize: '10px',
textShadow: LABEL_SHADOW_EXTRA,
whiteSpace: 'nowrap',
}}
>
[M{eq.mag}] {eq.place || ''}
</div>
</Marker>
);
})}
</>
);
}
// -- Threat alert markers --
function getRiskColor(score: number): string {
if (score >= 9) return '#ef4444';
if (score >= 7) return '#f97316';
if (score >= 4) return '#eab308';
if (score >= 1) return '#3b82f6';
return '#22c55e';
if (score >= 9) return '#ef4444';
if (score >= 7) return '#f97316';
if (score >= 4) return '#eab308';
if (score >= 1) return '#3b82f6';
return '#22c55e';
}
interface ThreatMarkerProps {
spreadAlerts: any[];
viewState: ViewState;
selectedEntity: any;
onEntityClick?: (entity: { id: string | number; type: string } | null) => void;
onDismiss?: (alertKey: string) => void;
spreadAlerts: SpreadAlertItem[];
zoom: number;
selectedEntity: SelectedEntity | null;
onEntityClick?: (entity: SelectedEntity | null) => void;
onDismiss?: (alertKey: string) => void;
}
export function ThreatMarkers({ spreadAlerts, viewState, selectedEntity, onEntityClick, onDismiss }: ThreatMarkerProps) {
return (
<>
{spreadAlerts.map((n: any) => {
const count = n.cluster_count || 1;
const score = n.risk_score || 0;
const riskColor = getRiskColor(score);
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
export function ThreatMarkers({
spreadAlerts,
zoom,
selectedEntity,
onEntityClick,
onDismiss,
}: ThreatMarkerProps) {
return (
<>
{spreadAlerts.map((n) => {
const count = n.cluster_count || 1;
const score = n.risk_score || 0;
const riskColor = getRiskColor(score);
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
let isVisible = viewState.zoom >= 1;
if (selectedEntity) {
if (selectedEntity.type === 'news') {
if (selectedEntity.id !== alertKey) isVisible = false;
} else {
isVisible = false;
}
}
let isVisible = zoom >= 1;
if (selectedEntity) {
if (selectedEntity.type === 'news') {
if (selectedEntity.id !== alertKey) isVisible = false;
} else {
isVisible = false;
}
}
return (
<Marker
key={`threat-${alertKey}`}
longitude={n.coords[1]}
latitude={n.coords[0]}
anchor="center"
offset={[n.offsetX, n.offsetY]}
style={{ zIndex: 50 + score }}
onClick={(e) => {
e.originalEvent.stopPropagation();
onEntityClick?.({ id: alertKey, type: 'news' });
}}
>
<div className="relative group/alert">
{n.showLine && isVisible && (
<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={-n.offsetX} y2={-n.offsetY} stroke={riskColor} strokeWidth="1.5" strokeDasharray="3,3" className="opacity-80" />
<circle cx={-n.offsetX} cy={-n.offsetY} r="2" fill={riskColor} />
</svg>
)}
return (
<Marker
key={`threat-${alertKey}`}
longitude={n.coords[1]}
latitude={n.coords[0]}
anchor="center"
offset={[n.offsetX, n.offsetY]}
style={{ zIndex: 50 + score }}
onClick={(e) => {
e.originalEvent.stopPropagation();
onEntityClick?.({ id: alertKey, type: 'news' });
}}
>
<div className="relative group/alert">
{n.showLine && isVisible && (
<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={-n.offsetX}
y2={-n.offsetY}
stroke={riskColor}
strokeWidth="1.5"
strokeDasharray="3,3"
className="opacity-80"
/>
<circle cx={-n.offsetX} cy={-n.offsetY} r="2" fill={riskColor} />
</svg>
)}
<div
className="cursor-pointer transition-all duration-300 relative"
style={{
opacity: isVisible ? 1.0 : 0.0,
pointerEvents: isVisible ? 'auto' : 'none',
backgroundColor: 'rgba(5, 5, 5, 0.95)',
border: `1.5px solid ${riskColor}`,
borderRadius: '4px',
padding: '5px 16px 5px 8px',
color: riskColor,
fontFamily: 'monospace',
fontSize: '9px',
fontWeight: 'bold',
textAlign: 'center',
boxShadow: `0 0 12px ${riskColor}60`,
zIndex: 10,
lineHeight: '1.2',
minWidth: '120px'
}}
>
{n.showLine && isVisible && (
<div
className="absolute"
style={{
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: n.offsetY < 0 ? `6px solid ${riskColor}` : 'none',
borderBottom: n.offsetY > 0 ? `6px solid ${riskColor}` : 'none',
left: '50%',
[n.offsetY < 0 ? 'bottom' : 'top']: '-6px',
transform: 'translateX(-50%)'
}}
/>
)}
<div
className="cursor-pointer transition-opacity duration-300 relative"
style={{
opacity: isVisible ? 1.0 : 0.0,
pointerEvents: isVisible ? 'auto' : 'none',
backgroundColor: 'rgba(5, 5, 5, 0.95)',
border: `1.5px solid ${riskColor}`,
borderRadius: '4px',
padding: '5px 16px 5px 8px',
color: riskColor,
fontFamily: 'monospace',
fontSize: '9px',
fontWeight: 'bold',
textAlign: 'center',
boxShadow: `0 0 12px ${riskColor}60`,
zIndex: 10,
lineHeight: '1.2',
minWidth: '120px',
}}
>
{n.showLine && isVisible && (
<div
className="absolute"
style={{
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: n.offsetY < 0 ? `6px solid ${riskColor}` : 'none',
borderBottom: n.offsetY > 0 ? `6px solid ${riskColor}` : 'none',
left: '50%',
[n.offsetY < 0 ? 'bottom' : 'top']: '-6px',
transform: 'translateX(-50%)',
}}
/>
)}
<div className="absolute inset-0 border border-current rounded opacity-50 animate-pulse" style={{ color: riskColor, zIndex: -1 }}></div>
{onDismiss && (
<button
onClick={(e) => { e.stopPropagation(); onDismiss(alertKey); }}
style={{
position: 'absolute', top: '2px', right: '4px',
background: 'transparent', border: 'none', cursor: 'pointer',
color: riskColor, fontSize: '12px', fontWeight: 'bold',
lineHeight: 1, padding: '0 2px', opacity: 0.7, zIndex: 20,
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
>×</button>
)}
<div style={{ fontSize: '10px', letterSpacing: '0.5px' }}>!! ALERT LVL {score} !!</div>
<div style={{ color: '#fff', fontSize: '9px', marginTop: '2px', maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{n.title}
</div>
{count > 1 && (
<div style={{ color: riskColor, opacity: 0.8, fontSize: '8px', marginTop: '2px' }}>
[+{count - 1} ACTIVE THREATS IN AREA]
</div>
)}
</div>
</div>
</Marker>
);
})}
</>
);
<div
className="absolute inset-0 border border-current rounded opacity-50"
style={{ color: riskColor, zIndex: -1 }}
></div>
{onDismiss && (
<button
onClick={(e) => {
e.stopPropagation();
onDismiss(alertKey);
}}
style={{
position: 'absolute',
top: '2px',
right: '4px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: riskColor,
fontSize: '12px',
fontWeight: 'bold',
lineHeight: 1,
padding: '0 2px',
opacity: 0.7,
zIndex: 20,
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.7')}
>
×
</button>
)}
<div style={{ fontSize: '10px', letterSpacing: '0.5px' }}>
!! ALERT LVL {score} !!
</div>
<div
style={{
color: '#fff',
fontSize: '9px',
marginTop: '2px',
maxWidth: '160px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{n.title}
</div>
{count > 1 && (
<div
style={{ color: riskColor, opacity: 0.8, fontSize: '8px', marginTop: '2px' }}
>
[+{count - 1} ACTIVE THREATS IN AREA]
</div>
)}
</div>
</div>
</Marker>
);
})}
</>
);
}
@@ -0,0 +1,585 @@
/// <reference lib="webworker" />
import { interpolatePosition } from '@/utils/positioning';
import { classifyAircraft } from '@/utils/aircraftClassification';
import type { Flight, Ship, SigintSignal } from '@/types/dashboard';
import type { FlightLayerConfig } from '@/components/map/geoJSONBuilders';
type BoundsTuple = [number, number, number, number];
type FC = GeoJSON.FeatureCollection | null;
export type DynamicMapLayersPayload = {
commercialFlights?: Flight[];
privateFlights?: Flight[];
privateJets?: Flight[];
militaryFlights?: Flight[];
trackedFlights?: Flight[];
ships?: Ship[];
sigint?: SigintSignal[];
commConfig: FlightLayerConfig;
privConfig: FlightLayerConfig;
jetsConfig: FlightLayerConfig;
milConfig: FlightLayerConfig;
};
export type DynamicMapLayersDataPayload = DynamicMapLayersPayload;
export type DynamicMapLayersBuildPayload = {
bounds: BoundsTuple;
dtSeconds: number;
trackedIcaos: string[];
activeLayers: {
flights: boolean;
private: boolean;
jets: boolean;
military: boolean;
tracked: boolean;
ships_military: boolean;
ships_cargo: boolean;
ships_civilian: boolean;
ships_passenger: boolean;
ships_tracked_yachts: boolean;
sigint_meshtastic: boolean;
sigint_aprs: boolean;
};
activeFilters?: Record<string, string[]>;
};
export type DynamicMapLayersResult = {
commercialFlightsGeoJSON: FC;
privateFlightsGeoJSON: FC;
privateJetsGeoJSON: FC;
militaryFlightsGeoJSON: FC;
trackedFlightsGeoJSON: FC;
shipsGeoJSON: FC;
meshtasticGeoJSON: FC;
aprsGeoJSON: FC;
};
type SyncRequest = {
id: string;
action: 'sync_dynamic_layers';
payload: DynamicMapLayersDataPayload;
};
type BuildRequest = {
id: string;
action: 'build_dynamic_layers';
payload: DynamicMapLayersBuildPayload;
};
type WorkerRequest = SyncRequest | BuildRequest;
type WorkerResponse = {
id: string;
ok: boolean;
result?: DynamicMapLayersResult;
error?: string;
};
const EMPTY_RESULT: DynamicMapLayersResult = {
commercialFlightsGeoJSON: null,
privateFlightsGeoJSON: null,
privateJetsGeoJSON: null,
militaryFlightsGeoJSON: null,
trackedFlightsGeoJSON: null,
shipsGeoJSON: null,
meshtasticGeoJSON: null,
aprsGeoJSON: null,
};
const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
const TRACKED_GROUNDED_ICON_MAP: Record<string, string> = {
airliner: 'svgAirlinerGrey',
turboprop: 'svgTurbopropGrey',
bizjet: 'svgBizjetGrey',
heli: 'svgHeliGrey',
};
const TRACKED_ICON_MAP: Record<string, Record<string, string>> = {
heli: {
'#ff1493': 'svgHeliPink',
pink: 'svgHeliPink',
red: 'svgHeliAlertRed',
blue: 'svgHeliBlue',
darkblue: 'svgHeliDarkBlue',
yellow: 'svgHeli',
orange: 'svgHeliOrange',
purple: 'svgHeliPurple',
'#32cd32': 'svgHeliLime',
black: 'svgHeliBlack',
white: 'svgHeliWhiteAlert',
},
airliner: {
'#ff1493': 'svgAirlinerPink',
pink: 'svgAirlinerPink',
red: 'svgAirlinerRed',
blue: 'svgAirlinerBlue',
darkblue: 'svgAirlinerDarkBlue',
yellow: 'svgAirlinerYellow',
orange: 'svgAirlinerOrange',
purple: 'svgAirlinerPurple',
'#32cd32': 'svgAirlinerLime',
black: 'svgAirlinerBlack',
white: 'svgAirlinerWhite',
},
turboprop: {
'#ff1493': 'svgTurbopropPink',
pink: 'svgTurbopropPink',
red: 'svgTurbopropRed',
blue: 'svgTurbopropBlue',
darkblue: 'svgTurbopropDarkBlue',
yellow: 'svgTurbopropYellow',
orange: 'svgTurbopropOrange',
purple: 'svgTurbopropPurple',
'#32cd32': 'svgTurbopropLime',
black: 'svgTurbopropBlack',
white: 'svgTurbopropWhite',
},
bizjet: {
'#ff1493': 'svgBizjetPink',
pink: 'svgBizjetPink',
red: 'svgBizjetRed',
blue: 'svgBizjetBlue',
darkblue: 'svgBizjetDarkBlue',
yellow: 'svgBizjetYellow',
orange: 'svgBizjetOrange',
purple: 'svgBizjetPurple',
'#32cd32': 'svgBizjetLime',
black: 'svgBizjetBlack',
white: 'svgBizjetWhite',
},
};
const POTUS_ICAOS = new Set(['adfdf8', 'adfdf9', 'adfdfa', 'adfdfb', 'adfdfc', 'adfdff']);
let dynamicData: DynamicMapLayersDataPayload = {
commConfig: {} as FlightLayerConfig,
privConfig: {} as FlightLayerConfig,
jetsConfig: {} as FlightLayerConfig,
milConfig: {} as FlightLayerConfig,
};
function inView(lat: number, lng: number, bounds: BoundsTuple): boolean {
return lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
}
function interpFlightPosition(f: Flight, dtSeconds: number): [number, number] {
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
if (dtSeconds < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(
f.lat,
f.lng,
heading,
f.speed_knots,
dtSeconds,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
}
function interpShipPosition(s: Ship, dtSeconds: number): [number, number] {
if (typeof s.sog !== 'number' || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
const heading = (typeof s.cog === 'number' ? s.cog : 0) || s.heading || 0;
const [newLat, newLng] = interpolatePosition(
s.lat,
s.lng,
heading,
s.sog,
dtSeconds,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
}
function buildFlightLayerGeoJSONWorker(
flights: Flight[] | undefined,
config: FlightLayerConfig,
bounds: BoundsTuple,
dtSeconds: number,
trackedIcaos: Set<string>,
): FC {
if (!flights?.length) return null;
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
const features: GeoJSON.Feature[] = [];
for (let i = 0; i < flights.length; i += 1) {
const f = flights[i];
if (f.lat == null || f.lng == null) continue;
const [iLng, iLat] = interpFlightPosition(f, dtSeconds);
if (!inView(iLat, iLng, bounds)) continue;
if (f.icao24 && trackedIcaos.has(f.icao24.toLowerCase())) continue;
const acType = classifyAircraft(f.model, f.aircraft_category);
const grounded = f.alt != null && f.alt <= 100;
let iconId: string;
if (milSpecialMap) {
const milType = ('military_type' in f ? f.military_type : undefined) || 'default';
iconId = milSpecialMap[milType] || '';
if (!iconId) {
iconId = grounded ? groundedMap[acType] : colorMap[acType];
} else if (grounded) {
iconId = groundedMap[acType];
}
} else {
iconId = grounded ? groundedMap[acType] : colorMap[acType];
}
const rotation = useTrackHeading ? f.true_track || f.heading || 0 : f.heading || 0;
features.push({
type: 'Feature',
properties: {
id: f.icao24 || f.callsign || `${idPrefix}${i}`,
type: typeLabel,
callsign: f.callsign || f.icao24,
rotation,
iconId,
},
geometry: { type: 'Point', coordinates: [iLng, iLat] },
});
}
return { type: 'FeatureCollection', features };
}
function buildTrackedFlightsGeoJSONWorker(
flights: Flight[] | undefined,
bounds: BoundsTuple,
dtSeconds: number,
): FC {
if (!flights?.length) return null;
const features: GeoJSON.Feature[] = [];
for (let i = 0; i < flights.length; i += 1) {
const f = flights[i];
if (f.lat == null || f.lng == null) continue;
const [lng, lat] = interpFlightPosition(f, dtSeconds);
if (!inView(lat, lng, bounds)) continue;
const alertColor = ('alert_color' in f ? f.alert_color : '') || 'white';
const acType = classifyAircraft(f.model, f.aircraft_category);
const grounded = f.alt != null && f.alt <= 100;
const icaoHex = (f.icao24 || '').toUpperCase();
const isPotus = POTUS_ICAOS.has(icaoHex.toLowerCase());
const potusIcon = acType === 'heli' ? 'svgPotusHeli' : 'svgPotusPlane';
const iconId = isPotus
? potusIcon
: grounded
? TRACKED_GROUNDED_ICON_MAP[acType]
: TRACKED_ICON_MAP[acType]?.[alertColor] ||
TRACKED_ICON_MAP.airliner[alertColor] ||
'svgAirlinerWhite';
const displayName =
('alert_operator' in f ? f.alert_operator : '') ||
('operator' in f ? f.operator : '') ||
('owner' in f ? f.owner : '') ||
('name' in f ? f.name : '') ||
f.callsign ||
f.icao24 ||
'UNKNOWN';
features.push({
type: 'Feature',
properties: {
id: f.icao24 || i,
type: 'tracked_flight',
callsign: String(displayName),
rotation: f.heading || 0,
iconId,
},
geometry: { type: 'Point', coordinates: [lng, lat] },
});
}
return { type: 'FeatureCollection', features };
}
function buildShipsGeoJSONWorker(
ships: Ship[] | undefined,
activeLayers: DynamicMapLayersBuildPayload['activeLayers'],
bounds: BoundsTuple,
dtSeconds: number,
): FC {
if (
!ships?.length ||
!(
activeLayers.ships_military ||
activeLayers.ships_cargo ||
activeLayers.ships_civilian ||
activeLayers.ships_passenger ||
activeLayers.ships_tracked_yachts
)
) {
return null;
}
const features: GeoJSON.Feature[] = [];
for (let i = 0; i < ships.length; i += 1) {
const s = ships[i];
if (s.lat == null || s.lng == null) continue;
const [iLng, iLat] = interpShipPosition(s, dtSeconds);
if (!inView(iLat, iLng, bounds)) continue;
if (s.type === 'carrier') continue;
const isTrackedYacht = Boolean(s.yacht_alert);
const isMilitary = s.type === 'military_vessel';
const isCargo = s.type === 'tanker' || s.type === 'cargo';
const isPassenger = s.type === 'passenger';
if (isTrackedYacht) {
if (!activeLayers.ships_tracked_yachts) continue;
} else if (isMilitary && !activeLayers.ships_military) continue;
else if (isCargo && !activeLayers.ships_cargo) continue;
else if (isPassenger && !activeLayers.ships_passenger) continue;
else if (!isMilitary && !isCargo && !isPassenger && !activeLayers.ships_civilian) continue;
let iconId = 'svgShipBlue';
if (isTrackedYacht) iconId = 'svgShipPink';
else if (isCargo) iconId = 'svgShipRed';
else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite';
else if (isMilitary) iconId = 'svgShipAmber';
features.push({
type: 'Feature',
properties: {
id: s.mmsi || s.name || `ship-${i}`,
type: 'ship',
name: s.name,
rotation: s.heading || 0,
iconId,
},
geometry: { type: 'Point', coordinates: [iLng, iLat] },
});
}
return { type: 'FeatureCollection', features };
}
function buildSigintGeoJSONWorker(
signals: SigintSignal[] | undefined,
source: 'meshtastic' | 'aprs',
bounds: BoundsTuple,
): FC {
if (!signals?.length) return null;
const wanted =
source === 'meshtastic'
? (s: SigintSignal) => s.source === 'meshtastic'
: (s: SigintSignal) => s.source === 'aprs' || s.source === 'js8call';
const features: GeoJSON.Feature[] = [];
for (let i = 0; i < signals.length; i += 1) {
const sig = signals[i];
if (!wanted(sig) || sig.lat == null || sig.lng == null) continue;
if (!inView(sig.lat, sig.lng, bounds)) continue;
features.push({
type: 'Feature',
properties: {
id: `${sig.source || 'unknown'}:${sig.callsign || 'unknown'}`,
type: 'sigint',
name: sig.callsign,
callsign: sig.callsign,
source: sig.source,
confidence: sig.confidence,
raw_message: sig.raw_message || '',
snr: sig.snr ?? null,
frequency: sig.frequency ?? null,
timestamp: sig.timestamp,
region: sig.region ?? null,
channel: sig.channel ?? null,
status: sig.status ?? null,
altitude: sig.altitude ?? null,
emergency: sig.emergency ?? false,
emergency_keyword: sig.emergency_keyword ?? null,
from_api: sig.from_api ?? false,
position_updated_at: sig.position_updated_at ?? null,
long_name: sig.long_name ?? null,
hardware: sig.hardware ?? null,
role: sig.role ?? null,
battery_level: sig.battery_level ?? null,
voltage: sig.voltage ?? null,
},
geometry: { type: 'Point', coordinates: [sig.lng, sig.lat] },
});
}
return features.length ? { type: 'FeatureCollection', features } : null;
}
/** Apply user-selected filters to flight/ship arrays before building GeoJSON. */
function applyFilters(activeFilters: Record<string, string[]> | undefined) {
const f = activeFilters;
if (!f || Object.keys(f).length === 0) {
return {
commercial: dynamicData.commercialFlights,
private_: dynamicData.privateFlights,
jets: dynamicData.privateJets,
military: dynamicData.militaryFlights,
tracked: dynamicData.trackedFlights,
ships: dynamicData.ships,
};
}
const has = (key: string) => f[key] && f[key].length > 0;
const set = (key: string) => new Set(f[key]);
// ── Commercial flights ──
let commercial = dynamicData.commercialFlights;
if (commercial && (has('commercial_departure') || has('commercial_arrival') || has('commercial_airline'))) {
const depSet = has('commercial_departure') ? set('commercial_departure') : null;
const arrSet = has('commercial_arrival') ? set('commercial_arrival') : null;
const airSet = has('commercial_airline') ? set('commercial_airline') : null;
commercial = commercial.filter((fl: any) => {
if (depSet && !depSet.has(fl.origin_name)) return false;
if (arrSet && !arrSet.has(fl.dest_name)) return false;
if (airSet && !airSet.has(fl.airline_code)) return false;
return true;
});
}
// ── Private flights ──
let private_ = dynamicData.privateFlights;
if (private_ && (has('private_callsign') || has('private_aircraft_type'))) {
const csSet = has('private_callsign') ? set('private_callsign') : null;
const typeSet = has('private_aircraft_type') ? set('private_aircraft_type') : null;
private_ = private_.filter((fl: any) => {
if (csSet && !csSet.has(fl.callsign) && !csSet.has(fl.registration)) return false;
if (typeSet && !typeSet.has(fl.model)) return false;
return true;
});
}
// ── Private jets ──
let jets = dynamicData.privateJets;
if (jets && (has('private_callsign') || has('private_aircraft_type'))) {
const csSet = has('private_callsign') ? set('private_callsign') : null;
const typeSet = has('private_aircraft_type') ? set('private_aircraft_type') : null;
jets = jets.filter((fl: any) => {
if (csSet && !csSet.has(fl.callsign) && !csSet.has(fl.registration)) return false;
if (typeSet && !typeSet.has(fl.model)) return false;
return true;
});
}
// ── Military flights ──
let military = dynamicData.militaryFlights;
if (military && (has('military_country') || has('military_aircraft_type'))) {
const countrySet = has('military_country') ? set('military_country') : null;
const typeSet = has('military_aircraft_type') ? set('military_aircraft_type') : null;
military = military.filter((fl: any) => {
if (countrySet && !countrySet.has(fl.country)) return false;
if (typeSet && !typeSet.has(fl.military_type)) return false;
return true;
});
}
// ── Tracked flights ──
let tracked = dynamicData.trackedFlights;
if (tracked && (has('tracked_category') || has('tracked_owner'))) {
const catSet = has('tracked_category') ? set('tracked_category') : null;
const ownSet = has('tracked_owner') ? set('tracked_owner') : null;
tracked = tracked.filter((fl: any) => {
if (catSet && !catSet.has(fl.alert_category)) return false;
if (ownSet && !ownSet.has(fl.alert_operator)) return false;
return true;
});
}
// ── Ships ──
let ships = dynamicData.ships;
if (ships && (has('ship_name') || has('ship_type'))) {
const nameSet = has('ship_name') ? set('ship_name') : null;
const typeSet = has('ship_type') ? set('ship_type') : null;
ships = ships.filter((s: any) => {
if (nameSet && !nameSet.has(s.name)) return false;
if (typeSet && !typeSet.has(s.type)) return false;
return true;
});
}
return { commercial, private_, jets, military, tracked, ships };
}
function buildDynamicLayers(payload: DynamicMapLayersBuildPayload): DynamicMapLayersResult {
const trackedIcaos = new Set(payload.trackedIcaos);
const filtered = applyFilters(payload.activeFilters);
return {
commercialFlightsGeoJSON: payload.activeLayers.flights
? buildFlightLayerGeoJSONWorker(
filtered.commercial,
dynamicData.commConfig,
payload.bounds,
payload.dtSeconds,
trackedIcaos,
)
: null,
privateFlightsGeoJSON: payload.activeLayers.private
? buildFlightLayerGeoJSONWorker(
filtered.private_,
dynamicData.privConfig,
payload.bounds,
payload.dtSeconds,
trackedIcaos,
)
: null,
privateJetsGeoJSON: payload.activeLayers.jets
? buildFlightLayerGeoJSONWorker(
filtered.jets,
dynamicData.jetsConfig,
payload.bounds,
payload.dtSeconds,
trackedIcaos,
)
: null,
militaryFlightsGeoJSON: payload.activeLayers.military
? buildFlightLayerGeoJSONWorker(
filtered.military,
dynamicData.milConfig,
payload.bounds,
payload.dtSeconds,
trackedIcaos,
)
: null,
trackedFlightsGeoJSON: payload.activeLayers.tracked
? buildTrackedFlightsGeoJSONWorker(filtered.tracked, payload.bounds, payload.dtSeconds)
: null,
shipsGeoJSON: buildShipsGeoJSONWorker(
filtered.ships,
payload.activeLayers,
payload.bounds,
payload.dtSeconds,
),
meshtasticGeoJSON: payload.activeLayers.sigint_meshtastic
? buildSigintGeoJSONWorker(dynamicData.sigint, 'meshtastic', payload.bounds)
: null,
aprsGeoJSON: payload.activeLayers.sigint_aprs
? buildSigintGeoJSONWorker(dynamicData.sigint, 'aprs', payload.bounds)
: null,
};
}
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
const { id, action, payload } = event.data;
try {
if (action === 'sync_dynamic_layers') {
dynamicData = payload;
postMessage({ id, ok: true, result: EMPTY_RESULT } satisfies WorkerResponse);
return;
}
if (action !== 'build_dynamic_layers') {
postMessage({ id, ok: false, error: 'unsupported_action' } satisfies WorkerResponse);
return;
}
const result = buildDynamicLayers(payload);
postMessage({ id, ok: true, result } satisfies WorkerResponse);
} catch (error) {
const message = error instanceof Error ? error.message : 'worker_error';
postMessage({ id, ok: false, error: message } satisfies WorkerResponse);
}
};
export {};
@@ -2,22 +2,42 @@ import { describe, it, expect } from 'vitest';
import {
buildEarthquakesGeoJSON,
buildFirmsGeoJSON,
buildInternetOutagesGeoJSON,
buildDataCentersGeoJSON,
buildShipsGeoJSON,
buildCarriersGeoJSON,
} from '@/components/map/geoJSONBuilders';
import type { Earthquake, FireHotspot, InternetOutage, DataCenter, Ship, ActiveLayers } from '@/types/dashboard';
import type {
Earthquake,
FireHotspot,
Ship,
ActiveLayers,
} from '@/types/dashboard';
// Default active layers for ship tests
const allShipLayers: ActiveLayers = {
flights: true, private: true, jets: true, military: true, tracked: true,
satellites: true, earthquakes: true, cctv: false, ukraine_frontline: true,
global_incidents: true, firms_fires: true, jamming: true, internet_outages: true,
datacenters: true, gdelt: false, liveuamap: true, weather: true, uav: true,
flights: true,
private: true,
jets: true,
military: true,
tracked: true,
satellites: true,
earthquakes: true,
cctv: false,
ukraine_frontline: true,
global_incidents: true,
firms_fires: true,
jamming: true,
internet_outages: true,
datacenters: true,
gdelt: false,
liveuamap: true,
weather: true,
uav: true,
kiwisdr: false,
ships_military: true, ships_cargo: true, ships_civilian: true,
ships_passenger: true, ships_tracked_yachts: true,
ships_military: true,
ships_cargo: true,
ships_civilian: true,
ships_passenger: true,
ships_tracked_yachts: true,
};
describe('buildEarthquakesGeoJSON', () => {
@@ -59,10 +79,46 @@ describe('buildFirmsGeoJSON', () => {
it('assigns correct icon by FRP intensity', () => {
const fires: FireHotspot[] = [
{ lat: 10, lng: 20, frp: 2, brightness: 300, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // yellow
{ lat: 10, lng: 21, frp: 10, brightness: 350, confidence: 'high', daynight: 'D', acq_date: '2025-01-01', acq_time: '1200' }, // orange
{ lat: 10, lng: 22, frp: 50, brightness: 400, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // red
{ lat: 10, lng: 23, frp: 200, brightness: 500, confidence: 'high', daynight: 'N', acq_date: '2025-01-01', acq_time: '0000' }, // darkred
{
lat: 10,
lng: 20,
frp: 2,
brightness: 300,
confidence: 'high',
daynight: 'D',
acq_date: '2025-01-01',
acq_time: '1200',
}, // yellow
{
lat: 10,
lng: 21,
frp: 10,
brightness: 350,
confidence: 'high',
daynight: 'D',
acq_date: '2025-01-01',
acq_time: '1200',
}, // orange
{
lat: 10,
lng: 22,
frp: 50,
brightness: 400,
confidence: 'high',
daynight: 'N',
acq_date: '2025-01-01',
acq_time: '0000',
}, // red
{
lat: 10,
lng: 23,
frp: 200,
brightness: 500,
confidence: 'high',
daynight: 'N',
acq_date: '2025-01-01',
acq_time: '0000',
}, // darkred
];
const result = buildFirmsGeoJSON(fires)!;
expect(result.features[0].properties?.iconId).toBe('fire-yellow');
@@ -77,7 +133,14 @@ describe('buildShipsGeoJSON', () => {
const interpIdentity = (s: Ship): [number, number] => [s.lng!, s.lat!];
it('returns null when all ship layers are off', () => {
const layers = { ...allShipLayers, ships_military: false, ships_cargo: false, ships_civilian: false, ships_passenger: false, ships_tracked_yachts: false };
const layers = {
...allShipLayers,
ships_military: false,
ships_cargo: false,
ships_civilian: false,
ships_passenger: false,
ships_tracked_yachts: false,
};
const ships: Ship[] = [{ name: 'Test', lat: 10, lng: 20, type: 'cargo' } as Ship];
expect(buildShipsGeoJSON(ships, layers, alwaysInView, interpIdentity)).toBeNull();
});
@@ -101,7 +164,7 @@ describe('buildShipsGeoJSON', () => {
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity)!;
expect(result.features[0].properties?.iconId).toBe('svgShipRed');
expect(result.features[1].properties?.iconId).toBe('svgShipWhite');
expect(result.features[2].properties?.iconId).toBe('svgShipYellow');
expect(result.features[2].properties?.iconId).toBe('svgShipAmber');
});
});
File diff suppressed because it is too large Load Diff
@@ -1,77 +1,113 @@
"use client";
'use client';
import { useEffect, useRef, useState } from "react";
import type { MapRef } from "react-map-gl/maplibre";
import { useEffect, useRef, useState } from 'react';
import type { MapRef } from 'react-map-gl/maplibre';
import type { MapGeoJSONFeature } from 'maplibre-gl';
export interface ClusterItem {
lng: number;
lat: number;
count: string | number;
id: number;
lng: number;
lat: number;
count: string | number;
id: number;
}
/**
* Extracts cluster label positions from a MapLibre clustered source.
* Listens for moveend/sourcedata events to keep labels in sync.
* Queries only rendered cluster features for a given cluster layer to avoid
* scanning the full clustered source on every update.
*
* @param mapRef - React ref to the MapLibre map instance
* @param sourceId - The source ID to query clusters from (e.g. "ships", "earthquakes")
* @param layerId - The rendered cluster layer ID to query (e.g. "ships-clusters-layer")
* @param geoJSON - The GeoJSON data driving the source (null = no clusters)
*/
export function useClusterLabels(
mapRef: React.RefObject<MapRef | null>,
sourceId: string,
geoJSON: unknown | null
mapRef: React.RefObject<MapRef | null>,
layerId: string,
geoJSON: unknown | null,
): ClusterItem[] {
const [clusters, setClusters] = useState<ClusterItem[]>([]);
const handlerRef = useRef<(() => void) | null>(null);
const [clusters, setClusters] = useState<ClusterItem[]>([]);
const handlerRef = useRef<(() => void) | null>(null);
const rafRef = useRef<number | null>(null);
const signatureRef = useRef('');
useEffect(() => {
const map = mapRef.current?.getMap();
if (!map || !geoJSON) {
setClusters([]);
return;
useEffect(() => {
const map = mapRef.current?.getMap();
if (!map || !geoJSON) {
setClusters([]);
return;
}
// Remove previous handler if it exists
if (handlerRef.current) {
map.off('moveend', handlerRef.current);
map.off('idle', handlerRef.current);
}
const runUpdate = () => {
try {
if (!map.getLayer(layerId)) {
setClusters([]);
signatureRef.current = '';
return;
}
// Remove previous handler if it exists
if (handlerRef.current) {
map.off("moveend", handlerRef.current);
map.off("sourcedata", handlerRef.current);
const features = map.queryRenderedFeatures(undefined, {
layers: [layerId],
}) as MapGeoJSONFeature[];
const raw = features
.filter((f) => f.properties?.cluster)
.map((f) => {
const point = f.geometry as GeoJSON.Point;
return {
lng: point.coordinates[0],
lat: point.coordinates[1],
count: f.properties?.point_count_abbreviated ?? f.properties?.point_count ?? 0,
id: Number(f.properties?.cluster_id ?? 0),
};
});
const seen = new Set<number>();
const unique = raw.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
});
const signature = unique
.map((c) => `${c.id}:${c.count}:${c.lng.toFixed(3)}:${c.lat.toFixed(3)}`)
.join('|');
if (signature !== signatureRef.current) {
signatureRef.current = signature;
setClusters(unique);
}
} catch {
if (signatureRef.current !== '') {
signatureRef.current = '';
setClusters([]);
}
}
};
const scheduleUpdate = () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
runUpdate();
});
};
handlerRef.current = scheduleUpdate;
const update = () => {
try {
const features = map.querySourceFeatures(sourceId);
const raw = features
.filter((f: any) => f.properties?.cluster)
.map((f: any) => ({
lng: (f.geometry as any).coordinates[0],
lat: (f.geometry as any).coordinates[1],
count: f.properties.point_count_abbreviated || f.properties.point_count,
id: f.properties.cluster_id,
}));
const seen = new Set<number>();
const unique = raw.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
});
setClusters(unique);
} catch {
setClusters([]);
}
};
handlerRef.current = update;
map.on('moveend', scheduleUpdate);
map.on('idle', scheduleUpdate);
scheduleUpdate();
map.on("moveend", update);
map.on("sourcedata", update);
setTimeout(update, 500);
return () => {
map.off('moveend', scheduleUpdate);
map.off('idle', scheduleUpdate);
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [geoJSON, layerId, mapRef]);
return () => {
map.off("moveend", update);
map.off("sourcedata", update);
};
}, [geoJSON, sourceId]);
return clusters;
return clusters;
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from 'react';
import type { DependencyList } from 'react';
import type {
DynamicMapLayersBuildPayload,
DynamicMapLayersDataPayload,
DynamicMapLayersResult,
} from '@/components/map/dynamicMapLayers.worker';
type SyncRequest = {
id: string;
action: 'sync_dynamic_layers';
payload: DynamicMapLayersDataPayload;
};
type BuildRequest = {
id: string;
action: 'build_dynamic_layers';
payload: DynamicMapLayersBuildPayload;
};
type WorkerRequest = SyncRequest | BuildRequest;
type WorkerResponse = {
id: string;
ok: boolean;
result?: DynamicMapLayersResult;
error?: string;
};
const EMPTY_RESULT: DynamicMapLayersResult = {
commercialFlightsGeoJSON: null,
privateFlightsGeoJSON: null,
privateJetsGeoJSON: null,
militaryFlightsGeoJSON: null,
trackedFlightsGeoJSON: null,
shipsGeoJSON: null,
meshtasticGeoJSON: null,
aprsGeoJSON: null,
};
let worker: Worker | null = null;
let reqCounter = 0;
const pending = new Map<
string,
{
resolve: (value: DynamicMapLayersResult) => void;
reject: (error: Error) => void;
}
>();
function ensureWorker(): Worker {
if (worker) return worker;
worker = new Worker(new URL('../dynamicMapLayers.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const msg = event.data;
const handler = pending.get(msg.id);
if (!handler) return;
pending.delete(msg.id);
if (msg.ok && msg.result) {
handler.resolve(msg.result);
} else {
handler.reject(new Error(msg.error || 'worker_error'));
}
};
return worker;
}
function callWorker(request: WorkerRequest): Promise<DynamicMapLayersResult> {
return new Promise((resolve, reject) => {
pending.set(request.id, { resolve, reject });
try {
ensureWorker().postMessage(request);
} catch (error) {
pending.delete(request.id);
reject(error as Error);
}
});
}
export function useDynamicMapLayersWorker(
dataPayload: DynamicMapLayersDataPayload,
dataDeps: DependencyList,
buildPayload: DynamicMapLayersBuildPayload,
buildDeps: DependencyList,
): DynamicMapLayersResult {
const [result, setResult] = useState<DynamicMapLayersResult>(EMPTY_RESULT);
const [syncVersion, setSyncVersion] = useState(0);
const syncVersionRef = useRef(0);
const requestVersionRef = useRef(0);
useEffect(() => {
let cancelled = false;
const id = `mapw_sync_${Date.now()}_${reqCounter++}`;
const currentSyncVersion = ++syncVersionRef.current;
callWorker({ id, action: 'sync_dynamic_layers', payload: dataPayload })
.then(() => {
if (!cancelled) {
setSyncVersion(currentSyncVersion);
}
})
.catch((error) => {
if (!cancelled) {
console.error('Dynamic map layer worker sync failed', error);
}
});
return () => {
cancelled = true;
};
}, dataDeps);
useEffect(() => {
let cancelled = false;
const requestVersion = ++requestVersionRef.current;
const id = `mapw_build_${Date.now()}_${reqCounter++}`;
callWorker({ id, action: 'build_dynamic_layers', payload: buildPayload })
.then((next) => {
if (!cancelled && requestVersion === requestVersionRef.current) {
setResult(next);
}
})
.catch((error) => {
if (!cancelled) {
console.error('Dynamic map layer worker build failed', error);
}
});
return () => {
cancelled = true;
};
}, [syncVersion, ...buildDeps]);
return result;
}
@@ -1,25 +1,81 @@
import { useEffect, useRef } from "react";
import type { MapRef } from "react-map-gl/maplibre";
import { EMPTY_FC } from "@/components/map/mapConstants";
import { useEffect, useRef } from 'react';
import type { MapRef } from 'react-map-gl/maplibre';
import type { GeoJSONSource } from 'maplibre-gl';
import { EMPTY_FC } from '@/components/map/mapConstants';
// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation.
// This is critical for high-volume layers (flights, ships, satellites, fires) where
// React's prop diffing on thousands of coordinate arrays causes memory pressure.
export function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!map) return;
const push = () => {
const src = map.getSource(sourceId) as any;
if (src && typeof src.setData === 'function') {
src.setData(geojson || EMPTY_FC);
}
};
if (debounceMs > 0) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(push, debounceMs);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}
push();
}, [map, sourceId, geojson, debounceMs]);
export function useImperativeSource(
map: MapRef | null,
sourceId: string,
geojson: GeoJSON.FeatureCollection | null,
debounceMs = 0,
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevRef = useRef<GeoJSON.FeatureCollection | null>(null);
useEffect(() => {
if (!map) return;
let cancelled = false;
const data = geojson || EMPTY_FC;
const rawMap = map.getMap();
const push = () => {
if (cancelled) return true;
const src = rawMap.getSource(sourceId) as GeoJSONSource | undefined;
if (src && typeof src.setData === 'function') {
src.setData(data);
return true;
}
return false;
};
const pushWhenReady = () => {
let attemptsRemaining = 20;
const tryPush = () => {
if (cancelled) return;
if (push()) return;
if (attemptsRemaining <= 0) return;
attemptsRemaining -= 1;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
retryTimerRef.current = setTimeout(tryPush, 100);
};
tryPush();
};
const schedulePush = () => {
if (cancelled) return;
if (debounceMs > 0) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(pushWhenReady, debounceMs);
return;
}
pushWhenReady();
};
const handleStyleData = () => {
pushWhenReady();
};
rawMap.on('styledata', handleStyleData);
// Skip redundant writes for unchanged references, but keep the styledata
// listener active so sources repopulate after style reloads.
if (geojson !== prevRef.current) {
prevRef.current = geojson;
schedulePush();
}
return () => {
cancelled = true;
rawMap.off('styledata', handleStyleData);
if (timerRef.current) clearTimeout(timerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [map, sourceId, geojson, debounceMs]);
}
@@ -1,68 +1,123 @@
"use client";
'use client';
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { interpolatePosition } from "@/utils/positioning";
import { INTERP_TICK_MS } from "@/lib/constants";
import { useCallback, useRef, useEffect, useState } from 'react';
import { interpolatePosition } from '@/utils/positioning';
import { INTERP_TICK_MS } from '@/lib/constants';
const UNBOUNDED_INTERP_SECONDS = Number.POSITIVE_INFINITY;
/**
* Custom hook that provides position interpolation for flights, ships, and satellites.
* Tracks elapsed time since last data refresh and provides helper functions
* to smoothly animate entity positions between API updates.
*
* The interp functions read dtSeconds from a ref so their references stay stable.
* This prevents 7 GeoJSON useMemos from re-firing every tick GeoJSON only rebuilds
* when source data actually changes (new API fetch), not on every interpolation tick.
*/
export function useInterpolation() {
// Interpolation tick — bumps every INTERP_TICK_MS to animate entity positions
const [interpTick, setInterpTick] = useState(0);
const dataTimestamp = useRef(Date.now());
const dataTimestamp = useRef(Date.now());
const dtRef = useRef(0);
const [interpTick, setInterpTick] = useState(0);
useEffect(() => {
const iv = setInterval(() => setInterpTick((t) => t + 1), INTERP_TICK_MS);
return () => clearInterval(iv);
}, []);
// Update dtSeconds on each tick and bump a lightweight counter so moving
// layers actually rebuild between backend refreshes.
useEffect(() => {
const iv = setInterval(() => {
dtRef.current = (Date.now() - dataTimestamp.current) / 1000;
setInterpTick((tick) => tick + 1);
}, INTERP_TICK_MS);
return () => clearInterval(iv);
}, []);
/** Call this when new data arrives to reset the interpolation baseline */
const resetTimestamp = useCallback(() => {
dataTimestamp.current = Date.now();
}, []);
/** Call this when new data arrives to reset the interpolation baseline */
const resetTimestamp = useCallback(() => {
dataTimestamp.current = Date.now();
dtRef.current = 0;
}, []);
// Elapsed seconds since last data refresh (used for position interpolation)
const dtSeconds = useMemo(() => {
void interpTick; // use the tick to trigger recalc
return (Date.now() - dataTimestamp.current) / 1000;
}, [interpTick]);
/** Interpolate a flight's position if airborne and has speed + heading */
const interpFlight = useCallback(
(f: {
lat: number;
lng: number;
speed_knots?: number | null;
alt?: number | null;
true_track?: number;
heading?: number;
}): [number, number] => {
const dt = dtRef.current;
if (!f.speed_knots || f.speed_knots <= 0 || dt <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
if (dt < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(
f.lat,
f.lng,
heading,
f.speed_knots,
dt,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
},
[],
);
/** Interpolate a flight's position if airborne and has speed + heading */
const interpFlight = useCallback(
(f: { lat: number; lng: number; speed_knots?: number | null; alt?: number | null; true_track?: number; heading?: number }): [number, number] => {
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
if (dtSeconds < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds);
return [newLng, newLat];
},
[dtSeconds]
);
/** Interpolate a ship's position using SOG + COG */
const interpShip = useCallback(
(s: {
lat: number;
lng: number;
sog?: number;
cog?: number;
heading?: number;
}): [number, number] => {
const dt = dtRef.current;
if (typeof s.sog !== 'number' || !s.sog || s.sog <= 0 || dt <= 0)
return [s.lng, s.lat];
const heading = (typeof s.cog === 'number' ? s.cog : 0) || s.heading || 0;
const [newLat, newLng] = interpolatePosition(
s.lat,
s.lng,
heading,
s.sog,
dt,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
},
[],
);
/** Interpolate a ship's position using SOG + COG */
const interpShip = useCallback(
(s: { lat: number; lng: number; sog?: number; cog?: number; heading?: number }): [number, number] => {
if (typeof s.sog !== "number" || !s.sog || s.sog <= 0 || dtSeconds <= 0) return [s.lng, s.lat];
const heading = (typeof s.cog === "number" ? s.cog : 0) || s.heading || 0;
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, heading, s.sog, dtSeconds);
return [newLng, newLat];
},
[dtSeconds]
);
/** Interpolate a satellite's position between API updates */
const interpSat = useCallback(
(s: { lat: number; lng: number; speed_knots?: number; heading?: number }): [number, number] => {
const dt = dtRef.current;
if (!s.speed_knots || s.speed_knots <= 0 || dt < 1) return [s.lng, s.lat];
const [newLat, newLng] = interpolatePosition(
s.lat,
s.lng,
s.heading || 0,
s.speed_knots,
dt,
0,
UNBOUNDED_INTERP_SECONDS,
);
return [newLng, newLat];
},
[],
);
/** Interpolate a satellite's position between API updates */
const interpSat = useCallback(
(s: { lat: number; lng: number; speed_knots?: number; heading?: number }): [number, number] => {
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
return [newLng, newLat];
},
[dtSeconds]
);
return { interpTick, interpFlight, interpShip, interpSat, dtSeconds, resetTimestamp, dataTimestamp };
return {
interpFlight,
interpShip,
interpSat,
interpTick,
dtSeconds: dtRef,
resetTimestamp,
dataTimestamp,
};
}
@@ -0,0 +1,153 @@
import { useEffect, useRef, useState } from 'react';
import type { DependencyList } from 'react';
import type {
StaticMapLayersBuildPayload,
StaticMapLayersDataPayload,
StaticMapLayersResult,
} from '@/components/map/staticMapLayers.worker';
type SyncRequest = {
id: string;
action: 'sync_static_layers';
payload: StaticMapLayersDataPayload;
};
type BuildRequest = {
id: string;
action: 'build_static_layers';
payload: StaticMapLayersBuildPayload;
};
type WorkerRequest = SyncRequest | BuildRequest;
type WorkerResponse = {
id: string;
ok: boolean;
result?: StaticMapLayersResult | true;
error?: string;
};
const EMPTY_RESULT: StaticMapLayersResult = {
cctvGeoJSON: null,
kiwisdrGeoJSON: null,
pskReporterGeoJSON: null,
satnogsGeoJSON: null,
scannerGeoJSON: null,
firmsGeoJSON: null,
internetOutagesGeoJSON: null,
dataCentersGeoJSON: null,
powerPlantsGeoJSON: null,
viirsChangeNodesGeoJSON: null,
militaryBasesGeoJSON: null,
gdeltGeoJSON: null,
liveuaGeoJSON: null,
airQualityGeoJSON: null,
volcanoesGeoJSON: null,
fishingGeoJSON: null,
trainsGeoJSON: null,
};
let worker: Worker | null = null;
let reqCounter = 0;
const pending = new Map<
string,
{
resolve: (value: StaticMapLayersResult | true) => void;
reject: (error: Error) => void;
}
>();
function ensureWorker(): Worker {
if (worker) return worker;
worker = new Worker(new URL('../staticMapLayers.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const msg = event.data;
const handler = pending.get(msg.id);
if (!handler) return;
pending.delete(msg.id);
if (msg.ok && msg.result !== undefined) {
handler.resolve(msg.result);
} else {
handler.reject(new Error(msg.error || 'worker_error'));
}
};
return worker;
}
function callWorker(request: WorkerRequest): Promise<StaticMapLayersResult | true> {
return new Promise((resolve, reject) => {
pending.set(request.id, { resolve, reject });
try {
ensureWorker().postMessage(request);
} catch (error) {
pending.delete(request.id);
reject(error as Error);
}
});
}
function nextRequestId(prefix: string): string {
return `${prefix}_${Date.now()}_${reqCounter++}`;
}
export function useStaticMapLayersWorker(
dataPayload: StaticMapLayersDataPayload,
dataDeps: DependencyList,
buildPayload: StaticMapLayersBuildPayload,
buildDeps: DependencyList,
): StaticMapLayersResult {
const [result, setResult] = useState<StaticMapLayersResult>(EMPTY_RESULT);
const [syncVersion, setSyncVersion] = useState(0);
const syncVersionRef = useRef(0);
const buildRequestVersionRef = useRef(0);
useEffect(() => {
let cancelled = false;
const requestId = nextRequestId('mapsync');
const currentSyncVersion = ++syncVersionRef.current;
callWorker({ id: requestId, action: 'sync_static_layers', payload: dataPayload })
.then(() => {
if (!cancelled) {
setSyncVersion(currentSyncVersion);
}
})
.catch((error) => {
if (!cancelled) {
console.error('Static map layer worker sync failed', error);
}
});
return () => {
cancelled = true;
};
}, dataDeps);
useEffect(() => {
let cancelled = false;
const requestVersion = ++buildRequestVersionRef.current;
const requestId = nextRequestId('mapbuild');
callWorker({ id: requestId, action: 'build_static_layers', payload: buildPayload })
.then((next) => {
if (
!cancelled &&
requestVersion === buildRequestVersionRef.current &&
next !== true
) {
setResult(next);
}
})
.catch((error) => {
if (!cancelled) {
console.error('Static map layer worker build failed', error);
}
});
return () => {
cancelled = true;
};
}, [syncVersion, ...buildDeps]);
return result;
}
@@ -0,0 +1,117 @@
import { useCallback, useRef, useState } from 'react';
import type { RefObject } from 'react';
import type { MapRef } from 'react-map-gl/maplibre';
import { API_BASE } from '@/lib/api';
import {
coarsenViewBounds,
expandBoundsToRadius,
normalizeViewBounds,
type ViewBounds,
} from '@/lib/viewportPrivacy';
const VIEWPORT_POST_DEBOUNCE_MS = 2500;
const VIEWPORT_POST_MIN_INTERVAL_MS = 12000;
const VIEWPORT_CHANGE_EPSILON = 1.5;
export const VIEWPORT_COMMITTED_EVENT = 'shadowbroker:viewport-committed';
function boundsChanged(a: ViewBounds | null, b: ViewBounds): boolean {
if (!a) return true;
return (
Math.abs(a.south - b.south) > VIEWPORT_CHANGE_EPSILON ||
Math.abs(a.west - b.west) > VIEWPORT_CHANGE_EPSILON ||
Math.abs(a.north - b.north) > VIEWPORT_CHANGE_EPSILON ||
Math.abs(a.east - b.east) > VIEWPORT_CHANGE_EPSILON
);
}
export function useViewportBounds(
mapRef: RefObject<MapRef | null>,
viewBoundsRef?: { current: ViewBounds | null },
backendViewportSyncEnabled: boolean = true,
) {
// Viewport bounds for culling off-screen features [west, south, east, north]
const [mapBounds, setMapBounds] = useState<[number, number, number, number]>([
-180, -90, 180, 90,
]);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastPostedBoundsRef = useRef<ViewBounds | null>(null);
const lastPostedAtRef = useRef(0);
const lastCommittedBoundsRef = useRef<ViewBounds | null>(null);
const updateBounds = useCallback(() => {
const map = mapRef.current?.getMap();
if (!map) return;
const b = map.getBounds();
const latRange = b.getNorth() - b.getSouth();
const lngRange = b.getEast() - b.getWest();
const buf = 0.2; // 20% buffer
setMapBounds([
b.getWest() - lngRange * buf,
b.getSouth() - latRange * buf,
b.getEast() + lngRange * buf,
b.getNorth() + latRange * buf,
]);
const normalized = normalizeViewBounds({
south: b.getSouth(),
west: b.getWest(),
north: b.getNorth(),
east: b.getEast(),
});
const preloadBounds = coarsenViewBounds(expandBoundsToRadius(normalized));
if (viewBoundsRef && 'current' in viewBoundsRef) {
viewBoundsRef.current = preloadBounds;
}
if (boundsChanged(lastCommittedBoundsRef.current, preloadBounds)) {
lastCommittedBoundsRef.current = preloadBounds;
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
}
// Debounce POSTing viewport bounds to backend for dynamic AIS stream filtering
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
if (!backendViewportSyncEnabled) {
lastPostedBoundsRef.current = null;
lastPostedAtRef.current = 0;
return;
}
const now = Date.now();
if (
!boundsChanged(lastPostedBoundsRef.current, preloadBounds) &&
now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS
) {
return;
}
if (now - lastPostedAtRef.current < VIEWPORT_POST_MIN_INTERVAL_MS) {
return;
}
lastPostedBoundsRef.current = preloadBounds;
lastPostedAtRef.current = now;
fetch(`${API_BASE}/api/viewport`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
s: preloadBounds.south,
w: preloadBounds.west,
n: preloadBounds.north,
e: preloadBounds.east,
}),
}).catch((e) => console.error('Failed to update backend viewport:', e));
}, VIEWPORT_POST_DEBOUNCE_MS);
}, [backendViewportSyncEnabled, mapRef, viewBoundsRef]);
const inView = useCallback(
(lat: number, lng: number) =>
lng >= mapBounds[0] && lng <= mapBounds[2] && lat >= mapBounds[1] && lat <= mapBounds[3],
[mapBounds],
);
const scheduleBoundsUpdate = useCallback(() => {
updateBounds();
}, [updateBounds]);
return { mapBounds, inView, updateBounds, scheduleBoundsUpdate };
}
@@ -8,8 +8,11 @@ export const svgPlanePurple = `data:image/svg+xml;utf8,${encodeURIComponent(`<sv
export const svgFighter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M12 2L14 8L18 10L14 16L15 22L12 20L9 22L10 16L6 10L10 8L12 2Z"/></svg>`)}`;
export const svgHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="black" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliCyan = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="cyan" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="cyan" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliDimCyan = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#0891b2" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#0891b2" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliOrange = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#FF8C00" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF8C00" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliPurple = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#9B59B6" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#9B59B6" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliSlate = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#94a3b8" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#94a3b8" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliAmber = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#f59e0b" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#f59e0b" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgTanker = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /><line x1="12" y1="20" x2="12" y2="24" stroke="yellow" stroke-width="2" /></svg>`)}`;
export const svgRecon = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /><ellipse cx="12" cy="11" rx="5" ry="3" fill="none" stroke="red" stroke-width="1.5"/></svg>`)}`;
export const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF1493" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
@@ -33,37 +36,47 @@ export const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg
export const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#3b82f6" stroke="#000" stroke-width="1"/></svg>`)}`;
export const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="white" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#90cdf4" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="yellow" stroke="#000"/></svg>`)}`;
export const svgShipPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="#FF69B4" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#ff8dc7" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="white" stroke="#000"/></svg>`)}`;
export const svgShipGreyBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#64748b" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#475569" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
export const svgShipAmber = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="#f59e0b" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
export const svgCarrier = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="orange" stroke="black"><polygon points="3,21 21,21 20,4 16,4 16,3 12,3 12,4 4,4" /><rect x="15" y="6" width="3" height="10" /></svg>`)}`;
export const svgCctv = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="cyan" stroke-width="2"><path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-.894.553H5.652a1 1 0 0 1-.894-.553L2.724 13.447A1 1 0 0 1 3.618 12h3.632M14 12V8a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a4 4 0 1 0 8 0Z" /></svg>`)}`;
export const svgRadioTower = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="1.5"><line x1="12" y1="10" x2="12" y2="23" stroke="#f59e0b" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/><line x1="9" y1="16" x2="15" y2="16" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="9" r="2" fill="#f59e0b" stroke="none"/><path d="M8 6a5.5 5.5 0 0 1 8 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round"/><path d="M5.5 3.5a9 9 0 0 1 13 0" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/></svg>`)}`;
export const svgScannerTower = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="1.5"><line x1="12" y1="10" x2="12" y2="23" stroke="#dc2626" stroke-width="2"/><line x1="8" y1="23" x2="16" y2="23" stroke="#dc2626" stroke-width="2" stroke-linecap="round"/><line x1="9" y1="16" x2="15" y2="16" stroke="#dc2626" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="9" r="2" fill="#dc2626" stroke="none"/><path d="M8 6a5.5 5.5 0 0 1 8 0" fill="none" stroke="#dc2626" stroke-width="1.5" stroke-linecap="round"/><path d="M5.5 3.5a9 9 0 0 1 13 0" fill="none" stroke="#dc2626" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/></svg>`)}`;
export const svgSatDish = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#14b8a6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10a7.31 7.31 0 0 0 10 10Z"/><path d="m9 15 3-3"/><path d="M17 13a6 6 0 0 0-6-6"/><path d="M21 13A10 10 0 0 0 11 3"/><circle cx="12" cy="12" r="1.5" fill="#14b8a6" stroke="none"/></svg>`)}`;
export const svgLoRaSat = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="8" height="8" rx="1" fill="#a855f7" fill-opacity="0.3"/><line x1="4" y1="12" x2="8" y2="12"/><line x1="16" y1="12" x2="20" y2="12"/><line x1="12" y1="4" x2="12" y2="8"/><line x1="12" y1="16" x2="12" y2="20"/><circle cx="12" cy="12" r="2" fill="#a855f7" stroke="none"/></svg>`)}`;
export const svgWarning = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="yellow" stroke="black"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`)}`;
export const svgThreat = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="#ffff00" stroke="#ff0000" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>`)}`;
export const svgTriangleYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#ffaa00" stroke="#000" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
export const svgTriangleRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#ff0000" stroke="#fff" stroke-width="1"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
export const svgTrianglePink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#f472b6" stroke="#1a1a2e" stroke-width="1.5"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
export const svgTriangleGreen = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#22c55e" stroke="#1a1a2e" stroke-width="1.5"><path d="M1 21h22L12 2 1 21z"/></svg>`)}`;
// --- Aircraft type-specific SVG paths (top-down silhouettes) ---
// Airliner: wide swept wings with engine pods, narrow fuselage
export const AIRLINER_PATH = "M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5";
export const AIRLINER_PATH =
'M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5';
// Turboprop: straight high wings, shorter body
export const TURBOPROP_PATH = "M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z";
export const TURBOPROP_PATH =
'M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z';
// Bizjet: sleek, small swept wings, T-tail
export const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z";
export const BIZJET_PATH =
'M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z';
// --- Fire icon SVGs for FIRMS hotspots (multi-tongue flame, unmistakably fire) ---
export function makeFireSvg(fill: string, innerFill: string, size = 18) {
// Multi-forked flame: main body + left tongue + right tongue + inner glow
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 28">` +
// Main flame body (wide base, pointed top)
`<path d="M12 1C12 1 9 5 8 8C7 11 5.5 13 5.5 16.5C5.5 20.5 8 23.5 12 23.5C16 23.5 18.5 20.5 18.5 16.5C18.5 13 17 11 16 8C15 5 12 1 12 1Z" fill="${fill}" stroke="rgba(0,0,0,0.7)" stroke-width="0.7"/>` +
// Left tongue (forks out left from top)
`<path d="M10 8C10 8 7.5 4.5 7 2.5C7 2.5 6 5.5 7 9C7.5 10.5 8.5 11.5 9.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
// Right tongue (forks out right from top)
`<path d="M14 8C14 8 16.5 4.5 17 2.5C17 2.5 18 5.5 17 9C16.5 10.5 15.5 11.5 14.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
// Inner bright core
`<path d="M12 8C12 8 10.5 11 10.5 14.5C10.5 17.5 11 19.5 12 20C13 19.5 13.5 17.5 13.5 14.5C13.5 11 12 8 12 8Z" fill="${innerFill}" opacity="0.85"/>` +
`</svg>`
)}`;
// Multi-forked flame: main body + left tongue + right tongue + inner glow
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 28">` +
// Main flame body (wide base, pointed top)
`<path d="M12 1C12 1 9 5 8 8C7 11 5.5 13 5.5 16.5C5.5 20.5 8 23.5 12 23.5C16 23.5 18.5 20.5 18.5 16.5C18.5 13 17 11 16 8C15 5 12 1 12 1Z" fill="${fill}" stroke="rgba(0,0,0,0.7)" stroke-width="0.7"/>` +
// Left tongue (forks out left from top)
`<path d="M10 8C10 8 7.5 4.5 7 2.5C7 2.5 6 5.5 7 9C7.5 10.5 8.5 11.5 9.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
// Right tongue (forks out right from top)
`<path d="M14 8C14 8 16.5 4.5 17 2.5C17 2.5 18 5.5 17 9C16.5 10.5 15.5 11.5 14.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
// Inner bright core
`<path d="M12 8C12 8 10.5 11 10.5 14.5C10.5 17.5 11 19.5 12 20C13 19.5 13.5 17.5 13.5 14.5C13.5 11 12 8 12 8Z" fill="${innerFill}" opacity="0.85"/>` +
`</svg>`,
)}`;
}
export const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16);
export const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18);
@@ -75,12 +88,26 @@ export const svgFireClusterMed = makeFireSvg('#ff3300', '#ff8800', 40);
export const svgFireClusterLarge = makeFireSvg('#cc0000', '#ff3300', 48);
export const svgFireClusterXL = makeFireSvg('#880000', '#cc0000', 56);
export function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) {
const paths: Record<string, string> = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" };
const p = paths[type] || paths.generic;
// Airliner gets engine pod circles
const extras = type === 'airliner' ? `<circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/>` : '';
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`;
export function makeAircraftSvg(
type: 'airliner' | 'turboprop' | 'bizjet' | 'generic',
fill: string,
stroke = 'black',
size = 20,
) {
const paths: Record<string, string> = {
airliner: AIRLINER_PATH,
turboprop: TURBOPROP_PATH,
bizjet: BIZJET_PATH,
generic:
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z',
};
const p = paths[type] || paths.generic;
// Airliner gets engine pod circles
const extras =
type === 'airliner'
? `<circle cx="7" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="${fill}" stroke="${stroke}" stroke-width="0.5"/>`
: '';
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`;
}
// POTUS fleet — oversized hot pink with yellow halo ring
@@ -89,17 +116,30 @@ export const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg
// POTUS fleet ICAO hex codes (verified FAA registry)
export const POTUS_ICAOS = new Set([
'ADFDF8','ADFDF9', // Air Force One (VC-25A)
'ADFEB7','ADFEB8','ADFEB9','ADFEBA', // Air Force Two (C-32A)
'AE4AE6','AE4AE8','AE4AEA','AE4AEC', // Air Force Two (C-32B)
'AE0865','AE5E76','AE5E77','AE5E79', // Marine One (VH-3D / VH-92A)
'ADFDF8',
'ADFDF9', // Air Force One (VC-25A)
'ADFEB7',
'ADFEB8',
'ADFEB9',
'ADFEBA', // Air Force Two (C-32A)
'AE4AE6',
'AE4AE8',
'AE4AEA',
'AE4AEC', // Air Force Two (C-32B)
'AE0865',
'AE5E76',
'AE5E77',
'AE5E79', // Marine One (VH-3D / VH-92A)
]);
// Pre-built aircraft SVGs by type & color
export const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan');
export const svgAirlinerDimCyan = makeAircraftSvg('airliner', '#0891b2');
export const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00');
export const svgAirlinerPurple = makeAircraftSvg('airliner', '#9B59B6');
export const svgAirlinerSlate = makeAircraftSvg('airliner', '#94a3b8');
export const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow');
export const svgAirlinerAmber = makeAircraftSvg('airliner', '#f59e0b');
export const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22);
export const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22);
export const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22);
@@ -109,9 +149,12 @@ export const svgAirlinerBlack = makeAircraftSvg('airliner', '#222', '#555', 22);
export const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#666', 22);
export const svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan');
export const svgTurbopropDimCyan = makeAircraftSvg('turboprop', '#0891b2');
export const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00');
export const svgTurbopropPurple = makeAircraftSvg('turboprop', '#9B59B6');
export const svgTurbopropSlate = makeAircraftSvg('turboprop', '#94a3b8');
export const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow');
export const svgTurbopropAmber = makeAircraftSvg('turboprop', '#f59e0b');
export const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22);
export const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22);
export const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22);
@@ -121,9 +164,12 @@ export const svgTurbopropBlack = makeAircraftSvg('turboprop', '#222', '#555', 22
export const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#666', 22);
export const svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan');
export const svgBizjetDimCyan = makeAircraftSvg('bizjet', '#0891b2');
export const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00');
export const svgBizjetPurple = makeAircraftSvg('bizjet', '#9B59B6');
export const svgBizjetSlate = makeAircraftSvg('bizjet', '#94a3b8');
export const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow');
export const svgBizjetAmber = makeAircraftSvg('bizjet', '#f59e0b');
export const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22);
export const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22);
export const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22);
@@ -139,11 +185,356 @@ export const svgBizjetGrey = makeAircraftSvg('bizjet', '#555', '#333');
export const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#555" stroke="#333"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#555" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
// Grey icon map for grounded aircraft
export const GROUNDED_ICON_MAP: Record<string, string> = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' };
export const GROUNDED_ICON_MAP: Record<string, string> = {
heli: 'svgHeliGrey',
turboprop: 'svgTurbopropGrey',
bizjet: 'svgBizjetGrey',
airliner: 'svgAirlinerGrey',
};
// Per-layer color maps (module-level to avoid re-allocation every render tick)
export const COLOR_MAP_COMMERCIAL: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
export const COLOR_MAP_PRIVATE: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
export const COLOR_MAP_JETS: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
export const COLOR_MAP_MILITARY: Record<string, string> = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' };
export const MIL_SPECIAL_MAP: Record<string, string> = { fighter: 'svgFighter', tanker: 'svgTanker', recon: 'svgRecon' };
// 3-tier hierarchy: Baseline (dim cyan/slate) → Watch (amber) → Critical (red/gold/pink)
export const COLOR_MAP_COMMERCIAL: Record<string, string> = {
heli: 'svgHeliDimCyan',
turboprop: 'svgTurbopropDimCyan',
bizjet: 'svgBizjetDimCyan',
airliner: 'svgAirlinerDimCyan',
};
export const COLOR_MAP_PRIVATE: Record<string, string> = {
heli: 'svgHeliPurple',
turboprop: 'svgTurbopropPurple',
bizjet: 'svgBizjetPurple',
airliner: 'svgAirlinerPurple',
};
export const COLOR_MAP_JETS: Record<string, string> = {
heli: 'svgHeliPurple',
turboprop: 'svgTurbopropPurple',
bizjet: 'svgBizjetPurple',
airliner: 'svgAirlinerPurple',
};
export const COLOR_MAP_MILITARY: Record<string, string> = {
heli: 'svgHeliAmber',
turboprop: 'svgTurbopropAmber',
bizjet: 'svgBizjetAmber',
airliner: 'svgAirlinerAmber',
};
export const MIL_SPECIAL_MAP: Record<string, string> = {
fighter: 'svgFighter',
tanker: 'svgTanker',
recon: 'svgRecon',
};
// ─── Military Base Icons (square with X or circle) ───────────────────────
/** Generate a square-with-X SVG data URL for military base markers. */
export function makeMilBaseSvg(fill: string, xColor: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
`<rect x="1" y="1" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1.5"/>` +
`<line x1="1" y1="1" x2="19" y2="19" stroke="${xColor}" stroke-width="2.5"/>` +
`<line x1="19" y1="1" x2="1" y2="19" stroke="${xColor}" stroke-width="2.5"/>` +
`</svg>`
)}`;
}
/** Generate a square-with-filled-circle SVG data URL for military base markers. */
export function makeMilBaseCircleSvg(fill: string, circleColor: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
`<rect x="1" y="1" width="18" height="18" fill="${fill}" stroke="#000" stroke-width="1.5"/>` +
`<circle cx="10" cy="10" r="5.5" fill="${circleColor}"/>` +
`</svg>`
)}`;
}
function makeMilBaseCustomSvg(content: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
`<rect x="1" y="1" width="18" height="18" rx="1.5" fill="#111827" stroke="#000" stroke-width="1.5"/>` +
content +
`</svg>`
)}`;
}
function makeMilBaseUSSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#ffffff"/>` +
`<rect x="1" y="1" width="18" height="2.6" fill="#b91c1c"/>` +
`<rect x="1" y="6.2" width="18" height="2.6" fill="#b91c1c"/>` +
`<rect x="1" y="11.4" width="18" height="2.6" fill="#b91c1c"/>` +
`<rect x="1" y="16.6" width="18" height="2.4" fill="#b91c1c"/>` +
`<rect x="1" y="1" width="8.5" height="8.5" fill="#1d4ed8"/>` +
`<circle cx="5.2" cy="5.2" r="1.6" fill="#ffffff"/>`
);
}
function makeMilBaseChinaSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#dc2626"/>` +
`<polygon points="6,3.2 6.8,5.2 9,5.3 7.2,6.7 7.9,8.8 6,7.5 4.1,8.8 4.8,6.7 3,5.3 5.2,5.2" fill="#facc15"/>`
);
}
function makeMilBaseUnionJackSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#1d4ed8"/>` +
`<rect x="8" y="1" width="4" height="18" fill="#ffffff"/>` +
`<rect x="1" y="8" width="18" height="4" fill="#ffffff"/>` +
`<rect x="8.8" y="1" width="2.4" height="18" fill="#dc2626"/>` +
`<rect x="1" y="8.8" width="18" height="2.4" fill="#dc2626"/>`
);
}
function makeMilBaseRussiaSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="6" fill="#ffffff"/>` +
`<rect x="1" y="7" width="18" height="6" fill="#2563eb"/>` +
`<rect x="1" y="13" width="18" height="6" fill="#dc2626"/>`
);
}
function makeMilBaseIndiaSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="6" fill="#f97316"/>` +
`<rect x="1" y="7" width="18" height="6" fill="#ffffff"/>` +
`<rect x="1" y="13" width="18" height="6" fill="#16a34a"/>` +
`<circle cx="10" cy="10" r="2.4" fill="none" stroke="#1d4ed8" stroke-width="1.2"/>`
);
}
function makeMilBaseNorthKoreaSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="3" fill="#1d4ed8"/>` +
`<rect x="1" y="4" width="18" height="1.2" fill="#ffffff"/>` +
`<rect x="1" y="5.2" width="18" height="9.6" fill="#dc2626"/>` +
`<rect x="1" y="14.8" width="18" height="1.2" fill="#ffffff"/>` +
`<rect x="1" y="16" width="18" height="3" fill="#1d4ed8"/>` +
`<circle cx="6.2" cy="10" r="2.5" fill="#ffffff"/>` +
`<polygon points="6.2,7.8 6.8,9.3 8.4,9.4 7.1,10.4 7.6,11.9 6.2,11 4.8,11.9 5.3,10.4 4,9.4 5.6,9.3" fill="#dc2626"/>`
);
}
function makeMilBaseIsraelSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#ffffff"/>` +
`<rect x="1" y="2.2" width="18" height="2.2" fill="#2563eb"/>` +
`<rect x="1" y="15.6" width="18" height="2.2" fill="#2563eb"/>` +
`<line x1="5" y1="5" x2="15" y2="15" stroke="#2563eb" stroke-width="1.8"/>` +
`<line x1="15" y1="5" x2="5" y2="15" stroke="#2563eb" stroke-width="1.8"/>`
);
}
function makeMilBasePakistanSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="4" height="18" fill="#ffffff"/>` +
`<rect x="5" y="1" width="14" height="18" fill="#166534"/>` +
`<circle cx="12" cy="10" r="3.2" fill="#ffffff"/>` +
`<circle cx="13.2" cy="10" r="2.7" fill="#166534"/>`
);
}
function makeMilBaseTaiwanSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#dc2626"/>` +
`<rect x="1" y="1" width="8.5" height="8.5" fill="#1d4ed8"/>` +
`<circle cx="5.2" cy="5.2" r="1.8" fill="#ffffff"/>`
);
}
function makeMilBasePhilippinesSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="9" fill="#2563eb"/>` +
`<rect x="1" y="10" width="18" height="9" fill="#dc2626"/>` +
`<polygon points="1,1 1,19 9.5,10" fill="#ffffff"/>` +
`<circle cx="4.6" cy="10" r="1.5" fill="#facc15"/>`
);
}
function makeMilBaseAustraliaSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#1e3a8a"/>` +
`<circle cx="12.8" cy="12" r="2" fill="#ffffff"/>` +
`<circle cx="6" cy="6" r="1.2" fill="#ffffff"/>`
);
}
function makeMilBaseSouthKoreaSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="18" fill="#ffffff"/>` +
`<path d="M10 6 A4 4 0 0 1 14 10 H6 A4 4 0 0 1 10 6Z" fill="#ef4444"/>` +
`<path d="M6 10 H14 A4 4 0 0 1 10 14 A4 4 0 0 1 6 10Z" fill="#2563eb"/>` +
`<rect x="4" y="4.4" width="2.2" height="0.8" fill="#111827"/>` +
`<rect x="13.8" y="14.8" width="2.2" height="0.8" fill="#111827"/>`
);
}
function makeMilBaseIranSvg(): string {
return makeMilBaseCustomSvg(
`<rect x="1" y="1" width="18" height="6" fill="#16a34a"/>` +
`<rect x="1" y="7" width="18" height="6" fill="#ffffff"/>` +
`<rect x="1" y="13" width="18" height="6" fill="#dc2626"/>` +
`<circle cx="10" cy="10" r="1.6" fill="none" stroke="#dc2626" stroke-width="1.1"/>`
);
}
/** All unique military base icon specs for preloading. */
export const MILBASE_ICON_SPECS: {
id: string;
fill: string;
inner: string;
shape: 'x' | 'circle';
svg?: string;
}[] = [
{ id: 'milbase-us', fill: '#1d4ed8', inner: '#ffffff', shape: 'x', svg: makeMilBaseUSSvg() }, // US — stripes + canton
{ id: 'milbase-cn', fill: '#dc2626', inner: '#facc15', shape: 'x', svg: makeMilBaseChinaSvg() }, // China — red + yellow star
{ id: 'milbase-uk', fill: '#1d4ed8', inner: '#ffffff', shape: 'x', svg: makeMilBaseUnionJackSvg() }, // UK — Union Jack-ish cross
{ id: 'milbase-jp', fill: '#ffffff', inner: '#ef4444', shape: 'circle' }, // Japan — white / red circle
{ id: 'milbase-ru', fill: '#2563eb', inner: '#dc2626', shape: 'x', svg: makeMilBaseRussiaSvg() }, // Russia — tricolor
{ id: 'milbase-in', fill: '#f97316', inner: '#1d4ed8', shape: 'circle', svg: makeMilBaseIndiaSvg() }, // India — tricolor + chakra
{ id: 'milbase-eu-x', fill: '#3b82f6', inner: '#fbbf24', shape: 'circle' }, // EU big 3 — blue / yellow circle
{ id: 'milbase-nk', fill: '#dc2626', inner: '#1d4ed8', shape: 'circle', svg: makeMilBaseNorthKoreaSvg() }, // North Korea — red/blue flag
{ id: 'milbase-il', fill: '#ffffff', inner: '#2563eb', shape: 'x', svg: makeMilBaseIsraelSvg() }, // Israel — white + blue bands
{ id: 'milbase-pk', fill: '#166534', inner: '#ffffff', shape: 'circle', svg: makeMilBasePakistanSvg() }, // Pakistan — green + crescent
{ id: 'milbase-tw', fill: '#dc2626', inner: '#ffffff', shape: 'circle', svg: makeMilBaseTaiwanSvg() }, // Taiwan — red + blue canton
{ id: 'milbase-ph', fill: '#2563eb', inner: '#facc15', shape: 'circle', svg: makeMilBasePhilippinesSvg() }, // Philippines — blue/red + sun
{ id: 'milbase-au', fill: '#1e3a8a', inner: '#ffffff', shape: 'circle', svg: makeMilBaseAustraliaSvg() }, // Australia — navy + stars
{ id: 'milbase-sk', fill: '#ffffff', inner: '#2563eb', shape: 'circle', svg: makeMilBaseSouthKoreaSvg() }, // South Korea — white + taegeuk
{ id: 'milbase-ir', fill: '#16a34a', inner: '#dc2626', shape: 'circle', svg: makeMilBaseIranSvg() }, // Iran — tricolor
{ id: 'milbase-eu', fill: '#3b82f6', inner: '#fbbf24', shape: 'circle' }, // EU others — blue / yellow circle
{ id: 'milbase-default', fill: '#ec4899', inner: '#000000', shape: 'x' }, // Unknown — pink / black X
];
/** Generate a volcano-cone SVG data URL for volcano markers. */
export function makeVolcanoSvg(fill: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">` +
`<polygon points="10,2 18,18 2,18" fill="${fill}" stroke="#000" stroke-width="1.2"/>` +
`<line x1="8" y1="3" x2="6" y2="0" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/>` +
`<line x1="10" y1="2" x2="10" y2="0" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/>` +
`<line x1="12" y1="3" x2="14" y2="0" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/>` +
`</svg>`
)}`;
}
/** Volcano icon specs for preloading. */
export const VOLCANO_ICON_SPECS: { id: string; fill: string }[] = [
{ id: 'volcano-active', fill: '#ef4444' }, // Red — erupted within 50 years
{ id: 'volcano-historical', fill: '#f97316' }, // Orange — erupted 50500 years ago
{ id: 'volcano-dormant', fill: '#6b7280' }, // Grey — dormant (500+ years)
];
// ─── Weather Icons ─────────────────────────────────────────────────────────────
function weatherSvg(inner: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">${inner}</svg>`
)}`;
}
/** Thunderstorm — cloud with lightning bolt */
export const svgWeatherThunderstorm = weatherSvg(
`<ellipse cx="16" cy="12" rx="10" ry="6" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
`<ellipse cx="12" cy="11" rx="6" ry="5" fill="#94a3b8"/>` +
`<polygon points="15,17 12,24 16,22 13,30 19,21 15,23" fill="#facc15" stroke="#a16207" stroke-width="0.5"/>`,
);
/** Rain — cloud with rain drops */
export const svgWeatherRain = weatherSvg(
`<ellipse cx="16" cy="11" rx="10" ry="6" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
`<ellipse cx="12" cy="10" rx="6" ry="5" fill="#94a3b8"/>` +
`<line x1="10" y1="19" x2="8" y2="25" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
`<line x1="16" y1="19" x2="14" y2="25" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
`<line x1="22" y1="19" x2="20" y2="25" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
`<line x1="13" y1="22" x2="11" y2="28" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>` +
`<line x1="19" y1="22" x2="17" y2="28" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round"/>`,
);
/** Snow / Winter — cloud with snowflakes */
export const svgWeatherSnow = weatherSvg(
`<ellipse cx="16" cy="11" rx="10" ry="6" fill="#94a3b8" stroke="#64748b" stroke-width="1"/>` +
`<ellipse cx="12" cy="10" rx="6" ry="5" fill="#cbd5e1"/>` +
`<circle cx="10" cy="21" r="1.5" fill="#e2e8f0"/>` +
`<circle cx="16" cy="23" r="1.5" fill="#e2e8f0"/>` +
`<circle cx="22" cy="21" r="1.5" fill="#e2e8f0"/>` +
`<circle cx="13" cy="27" r="1.5" fill="#e2e8f0"/>` +
`<circle cx="19" cy="27" r="1.5" fill="#e2e8f0"/>`,
);
/** Tornado / Funnel — spinning funnel shape */
export const svgWeatherTornado = weatherSvg(
`<ellipse cx="16" cy="6" rx="10" ry="4" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
`<path d="M8,8 Q10,14 13,18 Q14,22 15,28" fill="none" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>` +
`<path d="M24,8 Q22,14 19,18 Q18,22 17,28" fill="none" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>` +
`<ellipse cx="16" cy="28" rx="2" ry="1" fill="#78716c"/>`,
);
/** Wind — wavy lines */
export const svgWeatherWind = weatherSvg(
`<path d="M4,10 Q8,6 12,10 Q16,14 20,10 Q24,6 28,10" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round"/>` +
`<path d="M4,17 Q8,13 12,17 Q16,21 20,17 Q24,13 28,17" fill="none" stroke="#cbd5e1" stroke-width="2" stroke-linecap="round"/>` +
`<path d="M6,24 Q10,20 14,24 Q18,28 22,24 Q26,20 30,24" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round"/>`,
);
/** Flood — water waves */
export const svgWeatherFlood = weatherSvg(
`<ellipse cx="16" cy="10" rx="10" ry="5" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
`<path d="M2,20 Q6,16 10,20 Q14,24 18,20 Q22,16 26,20 Q30,24 34,20" fill="none" stroke="#3b82f6" stroke-width="2"/>` +
`<path d="M0,26 Q4,22 8,26 Q12,30 16,26 Q20,22 24,26 Q28,30 32,26" fill="none" stroke="#2563eb" stroke-width="2"/>`,
);
/** Heat — sun with radiating lines */
export const svgWeatherHeat = weatherSvg(
`<circle cx="16" cy="16" r="6" fill="#f59e0b" stroke="#d97706" stroke-width="1"/>` +
`<line x1="16" y1="4" x2="16" y2="8" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="16" y1="24" x2="16" y2="28" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="4" y1="16" x2="8" y2="16" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="24" y1="16" x2="28" y2="16" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="7.5" y1="7.5" x2="10.3" y2="10.3" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="21.7" y1="21.7" x2="24.5" y2="24.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="24.5" y1="7.5" x2="21.7" y2="10.3" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>` +
`<line x1="10.3" y1="21.7" x2="7.5" y2="24.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>`,
);
/** Fog — stacked horizontal lines */
export const svgWeatherFog = weatherSvg(
`<line x1="4" y1="10" x2="28" y2="10" stroke="#94a3b8" stroke-width="2.5" stroke-linecap="round"/>` +
`<line x1="6" y1="15" x2="26" y2="15" stroke="#cbd5e1" stroke-width="2.5" stroke-linecap="round"/>` +
`<line x1="4" y1="20" x2="28" y2="20" stroke="#94a3b8" stroke-width="2.5" stroke-linecap="round"/>` +
`<line x1="8" y1="25" x2="24" y2="25" stroke="#cbd5e1" stroke-width="2.5" stroke-linecap="round"/>`,
);
/** Generic weather alert — cloud with exclamation */
export const svgWeatherGeneric = weatherSvg(
`<ellipse cx="16" cy="12" rx="10" ry="6" fill="#64748b" stroke="#334155" stroke-width="1"/>` +
`<ellipse cx="12" cy="11" rx="6" ry="5" fill="#94a3b8"/>` +
`<rect x="14.5" y="19" width="3" height="6" rx="1" fill="#f59e0b"/>` +
`<circle cx="16" cy="28" r="1.8" fill="#f59e0b"/>`,
);
/** Map event name keywords → weather icon ID */
export function weatherIconId(event: string): string {
const e = event.toLowerCase();
if (e.includes('tornado') || e.includes('funnel')) return 'wx-tornado';
if (e.includes('thunder') || e.includes('lightning') || e.includes('tstm')) return 'wx-thunderstorm';
if (e.includes('snow') || e.includes('blizzard') || e.includes('winter') || e.includes('ice') || e.includes('freezing') || e.includes('frost') || e.includes('sleet')) return 'wx-snow';
if (e.includes('flood') || e.includes('surge') || e.includes('tsunami') || e.includes('coastal')) return 'wx-flood';
if (e.includes('wind') || e.includes('gale') || e.includes('hurricane') || e.includes('tropical') || e.includes('typhoon')) return 'wx-wind';
if (e.includes('heat') || e.includes('excessive') || e.includes('fire') || e.includes('red flag')) return 'wx-heat';
if (e.includes('fog') || e.includes('dense') || e.includes('smoke') || e.includes('haze')) return 'wx-fog';
if (e.includes('rain') || e.includes('shower') || e.includes('drizzle')) return 'wx-rain';
return 'wx-generic';
}
/** All weather icon specs for preloading */
export const WEATHER_ICON_SPECS: { id: string; svg: string }[] = [
{ id: 'wx-thunderstorm', svg: svgWeatherThunderstorm },
{ id: 'wx-rain', svg: svgWeatherRain },
{ id: 'wx-snow', svg: svgWeatherSnow },
{ id: 'wx-tornado', svg: svgWeatherTornado },
{ id: 'wx-wind', svg: svgWeatherWind },
{ id: 'wx-flood', svg: svgWeatherFlood },
{ id: 'wx-heat', svg: svgWeatherHeat },
{ id: 'wx-fog', svg: svgWeatherFog },
{ id: 'wx-generic', svg: svgWeatherGeneric },
];
@@ -2,7 +2,7 @@
// Extracted from MaplibreViewer.tsx — pure data, no JSX
export const makeSatSvg = (color: string) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect x="9" y="9" width="6" height="6" rx="1" fill="${color}" stroke="#0a0e1a" stroke-width="0.5"/>
<rect x="1" y="10" width="7" height="4" rx="1" fill="${color}" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
<rect x="16" y="10" width="7" height="4" rx="1" fill="${color}" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
@@ -10,19 +10,54 @@ export const makeSatSvg = (color: string) => {
<line x1="16" y1="12" x2="23" y2="12" stroke="${color}" stroke-width="0.8"/>
<circle cx="12" cy="12" r="1.5" fill="#fff" opacity="0.8"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
export const MISSION_COLORS: Record<string, string> = {
'military_recon': '#ff3333', 'military_sar': '#ff3333',
'sar': '#00e5ff', 'sigint': '#ffffff',
'navigation': '#4488ff', 'early_warning': '#ff00ff',
'commercial_imaging': '#44ff44', 'space_station': '#ffdd00'
military_recon: '#ff3333',
military_sar: '#ff3333',
sar: '#00e5ff',
sigint: '#ffffff',
navigation: '#4488ff',
early_warning: '#ff00ff',
commercial_imaging: '#44ff44',
space_station: '#ffdd00',
};
/** Special ISS icon — larger with built-in golden dashed halo ring */
export const makeISSSvg = () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="none" stroke="#ffdd00" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.9"/>
<rect x="13" y="13" width="6" height="6" rx="1" fill="#ffdd00" stroke="#0a0e1a" stroke-width="0.5"/>
<rect x="3" y="14" width="9" height="4" rx="1" fill="#ffdd00" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
<rect x="20" y="14" width="9" height="4" rx="1" fill="#ffdd00" opacity="0.7" stroke="#0a0e1a" stroke-width="0.3"/>
<line x1="12" y1="16" x2="3" y2="16" stroke="#ffdd00" stroke-width="0.8"/>
<line x1="20" y1="16" x2="29" y2="16" stroke="#ffdd00" stroke-width="0.8"/>
<circle cx="16" cy="16" r="1.5" fill="#fff" opacity="0.9"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
/** Train icon SVG — small locomotive shape */
export const makeTrainSvg = (color: string) => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
<rect x="3" y="4" width="12" height="9" rx="2" fill="${color}" stroke="#0a0e1a" stroke-width="0.5"/>
<rect x="5" y="6" width="3" height="2.5" rx="0.5" fill="#0a0e1a" opacity="0.5"/>
<rect x="10" y="6" width="3" height="2.5" rx="0.5" fill="#0a0e1a" opacity="0.5"/>
<circle cx="6" cy="14.5" r="1.3" fill="${color}" stroke="#0a0e1a" stroke-width="0.3"/>
<circle cx="12" cy="14.5" r="1.3" fill="${color}" stroke="#0a0e1a" stroke-width="0.3"/>
<line x1="9" y1="10" x2="9" y2="12" stroke="#fff" stroke-width="0.8" opacity="0.6"/>
</svg>`;
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
};
export const MISSION_ICON_MAP: Record<string, string> = {
'military_recon': 'sat-mil', 'military_sar': 'sat-mil',
'sar': 'sat-sar', 'sigint': 'sat-sigint',
'navigation': 'sat-nav', 'early_warning': 'sat-ew',
'commercial_imaging': 'sat-com', 'space_station': 'sat-station'
military_recon: 'sat-mil',
military_sar: 'sat-mil',
sar: 'sat-sar',
sigint: 'sat-sigint',
navigation: 'sat-nav',
early_warning: 'sat-ew',
commercial_imaging: 'sat-com',
space_station: 'sat-station',
};
@@ -0,0 +1,55 @@
import { Layer, Marker, Source } from 'react-map-gl/maplibre';
export function MeasurementLayers({
measurePoints,
}: {
measurePoints: { lat: number; lng: number }[] | undefined;
}) {
if (!measurePoints || measurePoints.length === 0) return null;
return (
<>
{measurePoints.length >= 2 && (
<Source
id="measure-lines"
type="geojson"
data={{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: measurePoints.map((p) => [p.lng, p.lat]),
},
},
],
}}
>
<Layer
id="measure-lines-layer"
type="line"
paint={{
'line-color': '#00ffff',
'line-width': 2,
'line-dasharray': [4, 3],
'line-opacity': 0.8,
}}
/>
</Source>
)}
{measurePoints.map((pt, idx) => (
<Marker key={`measure-${idx}`} longitude={pt.lng} latitude={pt.lat} anchor="center">
<div className="flex flex-col items-center pointer-events-none">
<div className="w-6 h-6 rounded-full border-2 border-cyan-400 animate-ping absolute opacity-20" />
<div className="w-4 h-4 rounded-full bg-cyan-500 border-2 border-cyan-300 shadow-[0_0_12px_rgba(0,255,255,0.6)] flex items-center justify-center">
<span className="text-[7px] font-mono font-bold text-black">{idx + 1}</span>
</div>
</div>
</Marker>
))}
</>
);
}
@@ -0,0 +1,371 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import { API_BASE } from '@/lib/api';
import {
derivePublicMeshAddress,
getNodeIdentity,
hasSovereignty,
signEvent,
nextSequence,
getPublicKeyAlgo,
} from '@/mesh/meshIdentity';
import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
import { validateEventPayload } from '@/mesh/meshSchema';
const MESH_NODE_ID_RE = /^![0-9a-f]{8}$/i;
function isMeshtasticNodeId(value: string | undefined | null): boolean {
return !!value && MESH_NODE_ID_RE.test(value.trim());
}
/** Inline send-message form for SIGINT popups — routes via MeshRouter */
export function SigintSendForm({
destination,
source,
region,
channel,
}: {
destination: string;
source: string;
region?: string;
channel?: string;
}) {
const [msg, setMsg] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [detail, setDetail] = useState('');
const [warningAck, setWarningAck] = useState(false);
const [publicMeshAddress, setPublicMeshAddress] = useState('');
const isMesh = source === 'meshtastic';
const isDirectMesh = isMesh && isMeshtasticNodeId(destination);
useEffect(() => {
let cancelled = false;
if (!isMesh) {
setPublicMeshAddress('');
return;
}
const identity = getNodeIdentity();
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(identity.nodeId)
.then((addr) => {
if (!cancelled) setPublicMeshAddress(addr);
})
.catch(() => {
if (!cancelled) setPublicMeshAddress('');
});
return () => {
cancelled = true;
};
}, [isMesh]);
const handleSend = async () => {
if (!msg.trim()) return;
if (isMesh && !warningAck) {
setStatus('error');
setDetail('acknowledge public-mesh notice first');
return;
}
setStatus('sending');
try {
const identity = getNodeIdentity();
if (!identity || !hasSovereignty()) {
setStatus('error');
setDetail('identity required');
return;
}
const sequence = nextSequence();
const payload = {
message: msg.trim(),
destination,
channel: channel || 'LongFast',
priority: 'normal',
ephemeral: false,
transport_lock: isMesh ? 'meshtastic' : '',
};
const v = validateEventPayload('message', payload);
if (!v.ok) {
setStatus('error');
setDetail(`invalid payload: ${v.reason}`);
return;
}
const signature = await signEvent('message', identity.nodeId, sequence, payload);
const res = await fetch(`${API_BASE}/api/mesh/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
destination,
message: msg.trim(),
sender_id: identity.nodeId,
node_id: identity.nodeId,
public_key: identity.publicKey,
public_key_algo: getPublicKeyAlgo(),
signature,
sequence,
protocol_version: PROTOCOL_VERSION,
channel: channel || 'LongFast',
priority: 'normal',
ephemeral: false,
...(isMesh ? { transport_lock: 'meshtastic' } : {}),
...(region ? { credentials: { mesh_region: region } } : {}),
}),
});
const data = await res.json();
if (data.ok) {
setStatus('sent');
setDetail(data.routed_via || 'sent');
setMsg('');
} else {
setStatus('error');
setDetail(data.route_reason || data.detail || 'Failed');
}
} catch {
setStatus('error');
setDetail('Network error');
}
setTimeout(() => setStatus('idle'), 4000);
};
const label = isMesh
? isDirectMesh
? `PUBLIC DIRECT TO ${destination.toUpperCase()}`
: `PUBLIC BROADCAST TO ${(channel || 'LongFast').toUpperCase()} (${(region || '?').toUpperCase()})`
: 'SEND MESSAGE via MESH ROUTER';
const placeholder = isMesh
? isDirectMesh
? `Public direct message to ${destination}...`
: `Broadcast to ${channel || 'LongFast'}...`
: `Message ${destination}...`;
return (
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/30">
<div className="text-[8px] text-[#666] tracking-widest mb-1">{label}</div>
{isMesh && (
<div className="mb-1.5 rounded border border-amber-500/30 bg-amber-950/20 px-2 py-1.5">
<div className="text-[8px] text-amber-300 tracking-widest">
PUBLIC MESH NOTICE
</div>
<div className="text-[8px] text-amber-200/80 mt-0.5 leading-relaxed">
These Meshtastic messages are public/degraded, not private. They may be intercepted,
relayed, logged, or fail to deliver.
</div>
{publicMeshAddress && (
<div className="text-[8px] text-amber-100/70 mt-1 font-mono">
YOUR PUBLIC MESH ADDRESS: {publicMeshAddress.toUpperCase()}
</div>
)}
<label className="mt-1 flex items-start gap-1.5 text-[8px] text-amber-100/80 cursor-pointer">
<input
type="checkbox"
checked={warningAck}
onChange={(e) => setWarningAck(e.target.checked)}
className="mt-[1px]"
/>
<span>I understand this message is public and not private.</span>
</label>
</div>
)}
<div className="flex gap-1">
<input
type="text"
value={msg}
onChange={(e) => setMsg(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={placeholder}
maxLength={200}
className={`flex-1 bg-[#0a0e1a] border border-[var(--border-primary)] rounded px-2 py-1 text-[10px] text-white font-mono placeholder:text-[#444] focus:outline-none ${
isMesh ? 'focus:border-green-500/50' : 'focus:border-cyan-500/50'
}`}
/>
<button
onClick={handleSend}
disabled={status === 'sending' || !msg.trim() || (isMesh && !warningAck)}
className={`px-2 py-1 rounded disabled:opacity-30 disabled:cursor-not-allowed transition-colors ${
isMesh
? 'bg-green-950/60 border border-green-500/30 hover:bg-green-900/60 hover:border-green-400 text-green-400'
: 'bg-cyan-950/60 border border-cyan-500/30 hover:bg-cyan-900/60 hover:border-cyan-400 text-cyan-400'
}`}
title={
isMesh
? isDirectMesh
? `Send public direct message to ${destination}`
: `Broadcast to ${channel} channel`
: 'Send via auto-routed mesh'
}
>
<Send size={10} />
</button>
</div>
{status === 'sent' && (
<div className="text-[8px] text-green-400 mt-0.5">Routed via {detail}</div>
)}
{status === 'error' && <div className="text-[8px] text-red-400 mt-0.5">{detail}</div>}
{status === 'sending' && (
<div className="text-[8px] text-cyan-400 mt-0.5 animate-pulse">Routing...</div>
)}
</div>
);
}
/** Mini feed showing recent Meshtastic text messages + channel population stats */
export function MeshtasticChannelFeed({ region, channel }: { region: string; channel: string }) {
interface MeshtasticMessage {
from?: string;
to?: string;
text?: string;
timestamp?: string | number;
}
interface ChannelStats {
total_nodes?: number;
total_live?: number;
total_api?: number;
regions?: Record<string, { channels?: Record<string, number> }>;
roots?: Record<string, { channels?: Record<string, number> }>;
known_roots?: string[];
}
const [messages, setMessages] = useState<MeshtasticMessage[]>([]);
const [channelStats, setChannelStats] = useState<ChannelStats | null>(null);
const [loading, setLoading] = useState(true);
const [publicMeshAddress, setPublicMeshAddress] = useState('');
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
const identity = getNodeIdentity();
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(identity.nodeId)
.then((addr) => {
if (!cancelled) setPublicMeshAddress(addr);
})
.catch(() => {
if (!cancelled) setPublicMeshAddress('');
});
return () => {
cancelled = true;
};
}, []);
const fetchData = useCallback(async () => {
try {
const params = new URLSearchParams({ limit: '20' });
if (region) params.set('region', region);
if (channel) params.set('channel', channel);
const [msgRes, statsRes] = await Promise.all([
fetch(`${API_BASE}/api/mesh/messages?${params}`),
fetch(`${API_BASE}/api/mesh/channels`),
]);
if (msgRes.ok) setMessages(await msgRes.json());
if (statsRes.ok) setChannelStats(await statsRes.json());
} catch {
/* ignore */
}
setLoading(false);
}, [region, channel]);
useEffect(() => {
fetchData();
intervalRef.current = setInterval(fetchData, 15000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchData]);
// Extract stats for this Meshtastic root/region
const regionData = channelStats?.roots?.[region] || channelStats?.regions?.[region];
const regionChannels = regionData?.channels || {};
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
if (loading)
return <div className="text-[8px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
return (
<div className="mt-1.5 pt-1 border-t border-green-500/20">
{/* Channel population — which channels are active in this region */}
{sortedChannels.length > 0 && (
<div className="mb-1.5">
<div className="text-[8px] text-green-400/60 tracking-widest mb-0.5">
ACTIVE CHANNELS {region}
</div>
<div className="flex flex-wrap gap-1">
{sortedChannels.map(([ch, count]) => (
<span
key={ch}
className={`font-mono text-[8px] px-1.5 py-0.5 rounded border ${
ch === channel
? 'bg-green-900/50 text-green-300 border-green-500/40'
: 'bg-slate-800/50 text-slate-400 border-slate-600/30'
}`}
>
{ch} <span className="text-white/60">{count}</span>
</span>
))}
</div>
{(channelStats?.total_nodes ?? 0) > 0 && (
<div className="text-[8px] text-[#555] mt-0.5">
{channelStats?.total_live} live + {channelStats?.total_api?.toLocaleString()} map nodes
globally
</div>
)}
</div>
)}
{/* Message feed */}
{messages.length > 0 ? (
<>
<div className="text-[8px] text-green-400/60 tracking-widest mb-1">
MESSAGES {channel} ({region})
</div>
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
{messages.map((m: MeshtasticMessage, i: number) => {
const directedToYou =
!!publicMeshAddress &&
typeof m.to === 'string' &&
m.to.toLowerCase() === publicMeshAddress.toLowerCase();
const sentByYou =
!!publicMeshAddress &&
typeof m.from === 'string' &&
m.from.toLowerCase() === publicMeshAddress.toLowerCase();
return (
<div
key={i}
className={`text-[9px] font-mono py-0.5 px-1 rounded hover:bg-green-950/20 ${
directedToYou ? 'bg-amber-950/20 border border-amber-500/20' : ''
}`}
>
<span className="text-green-400">{m.from || '???'}</span>
{m.to && m.to !== 'broadcast' && (
<span className="text-slate-500 ml-1"> {m.to}</span>
)}
{sentByYou && (
<span className="text-cyan-400 ml-1">YOU</span>
)}
{directedToYou && (
<span className="text-amber-300 ml-1">TO YOU</span>
)}
<span className="text-white/70 ml-1.5">{m.text}</span>
{m.timestamp && (
<span className="text-[#444] ml-1">
{new Date(m.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
)}
</div>
);
})}
</div>
</>
) : (
<div className="text-[8px] text-[#555]">No recent messages on {channel}</div>
)}
</div>
);
}
@@ -0,0 +1,203 @@
/// <reference lib="webworker" />
import {
buildAirQualityGeoJSON,
buildCctvGeoJSON,
buildDataCentersGeoJSON,
buildFirmsGeoJSON,
buildFishingActivityGeoJSON,
buildGdeltGeoJSON,
buildInternetOutagesGeoJSON,
buildKiwisdrGeoJSON,
buildLiveuaGeoJSON,
buildPskReporterGeoJSON,
buildMilitaryBasesGeoJSON,
buildPowerPlantsGeoJSON,
buildSatnogsStationsGeoJSON,
buildScannerGeoJSON,
buildTrainsGeoJSON,
buildVIIRSChangeNodesGeoJSON,
buildVolcanoesGeoJSON,
} from '@/components/map/geoJSONBuilders';
import type {
AirQualityStation,
CCTVCamera,
DataCenter,
FireHotspot,
FishingEvent,
GDELTIncident,
InternetOutage,
KiwiSDR,
LiveUAmapIncident,
PSKSpot,
MilitaryBase,
PowerPlant,
SatNOGSStation,
Scanner,
Train,
VIIRSChangeNode,
Volcano,
} from '@/types/dashboard';
type BoundsTuple = [number, number, number, number];
type FC = GeoJSON.FeatureCollection | null;
export type StaticMapLayersDataPayload = {
cctv?: CCTVCamera[];
kiwisdr?: KiwiSDR[];
pskReporter?: PSKSpot[];
satnogsStations?: SatNOGSStation[];
scanners?: Scanner[];
firmsFires?: FireHotspot[];
internetOutages?: InternetOutage[];
datacenters?: DataCenter[];
powerPlants?: PowerPlant[];
viirsChangeNodes?: VIIRSChangeNode[];
militaryBases?: MilitaryBase[];
gdelt?: GDELTIncident[];
liveuamap?: LiveUAmapIncident[];
airQuality?: AirQualityStation[];
volcanoes?: Volcano[];
fishingActivity?: FishingEvent[];
trains?: Train[];
};
export type StaticMapLayersBuildPayload = {
bounds: BoundsTuple;
activeLayers: {
cctv: boolean;
kiwisdr: boolean;
psk_reporter: boolean;
satnogs: boolean;
scanners: boolean;
firms: boolean;
internet_outages: boolean;
datacenters: boolean;
power_plants: boolean;
viirs_nightlights: boolean;
military_bases: boolean;
global_incidents: boolean;
air_quality: boolean;
volcanoes: boolean;
fishing_activity: boolean;
trains: boolean;
};
};
export type StaticMapLayersResult = {
cctvGeoJSON: FC;
kiwisdrGeoJSON: FC;
pskReporterGeoJSON: FC;
satnogsGeoJSON: FC;
scannerGeoJSON: FC;
firmsGeoJSON: FC;
internetOutagesGeoJSON: FC;
dataCentersGeoJSON: FC;
powerPlantsGeoJSON: FC;
viirsChangeNodesGeoJSON: FC;
militaryBasesGeoJSON: FC;
gdeltGeoJSON: FC;
liveuaGeoJSON: FC;
airQualityGeoJSON: FC;
volcanoesGeoJSON: FC;
fishingGeoJSON: FC;
trainsGeoJSON: FC;
};
type SyncRequest = {
id: string;
action: 'sync_static_layers';
payload: StaticMapLayersDataPayload;
};
type BuildRequest = {
id: string;
action: 'build_static_layers';
payload: StaticMapLayersBuildPayload;
};
type WorkerRequest = SyncRequest | BuildRequest;
type WorkerResponse = {
id: string;
ok: boolean;
result?: StaticMapLayersResult | true;
error?: string;
};
let staticData: StaticMapLayersDataPayload = {};
function createInView(bounds: BoundsTuple) {
return (lat: number, lng: number) =>
lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
}
function buildStaticLayers(payload: StaticMapLayersBuildPayload): StaticMapLayersResult {
const inView = createInView(payload.bounds);
return {
cctvGeoJSON: payload.activeLayers.cctv ? buildCctvGeoJSON(staticData.cctv, inView) : null,
kiwisdrGeoJSON: payload.activeLayers.kiwisdr ? buildKiwisdrGeoJSON(staticData.kiwisdr, inView) : null,
pskReporterGeoJSON: payload.activeLayers.psk_reporter
? buildPskReporterGeoJSON(staticData.pskReporter, inView)
: null,
satnogsGeoJSON: payload.activeLayers.satnogs
? buildSatnogsStationsGeoJSON(staticData.satnogsStations, inView)
: null,
scannerGeoJSON: payload.activeLayers.scanners ? buildScannerGeoJSON(staticData.scanners, inView) : null,
firmsGeoJSON: payload.activeLayers.firms ? buildFirmsGeoJSON(staticData.firmsFires) : null,
internetOutagesGeoJSON: payload.activeLayers.internet_outages
? buildInternetOutagesGeoJSON(staticData.internetOutages)
: null,
dataCentersGeoJSON: payload.activeLayers.datacenters
? buildDataCentersGeoJSON(staticData.datacenters)
: null,
powerPlantsGeoJSON: payload.activeLayers.power_plants
? buildPowerPlantsGeoJSON(staticData.powerPlants)
: null,
viirsChangeNodesGeoJSON: payload.activeLayers.viirs_nightlights
? buildVIIRSChangeNodesGeoJSON(staticData.viirsChangeNodes)
: null,
militaryBasesGeoJSON: payload.activeLayers.military_bases
? buildMilitaryBasesGeoJSON(staticData.militaryBases)
: null,
gdeltGeoJSON: payload.activeLayers.global_incidents ? buildGdeltGeoJSON(staticData.gdelt) : null,
liveuaGeoJSON: payload.activeLayers.global_incidents
? buildLiveuaGeoJSON(staticData.liveuamap, inView)
: null,
airQualityGeoJSON: payload.activeLayers.air_quality ? buildAirQualityGeoJSON(staticData.airQuality) : null,
volcanoesGeoJSON: payload.activeLayers.volcanoes ? buildVolcanoesGeoJSON(staticData.volcanoes) : null,
fishingGeoJSON: payload.activeLayers.fishing_activity
? buildFishingActivityGeoJSON(staticData.fishingActivity)
: null,
trainsGeoJSON: payload.activeLayers.trains ? buildTrainsGeoJSON(staticData.trains) : null,
};
}
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
const message = event.data;
try {
if (message.action === 'sync_static_layers') {
staticData = message.payload;
const response: WorkerResponse = { id: message.id, ok: true, result: true };
self.postMessage(response);
return;
}
const result = buildStaticLayers(message.payload);
const response: WorkerResponse = {
id: message.id,
ok: true,
result,
};
self.postMessage(response);
} catch (error) {
const response: WorkerResponse = {
id: message.id,
ok: false,
error: error instanceof Error ? error.message : 'unknown_worker_error',
};
self.postMessage(response);
}
};
+34 -34
View File
@@ -1,41 +1,41 @@
export const darkStyle = {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
'carto-dark': {
type: 'raster',
tiles: [
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png",
"https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png",
"https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png",
"https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png"
],
tileSize: 256
}
version: 8,
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
layers: [
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
},
layers: [
{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } },
],
};
export const lightStyle = {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
'carto-light': {
type: 'raster',
tiles: [
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png",
"https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png"
],
tileSize: 256
}
version: 8,
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: {
'carto-light': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
layers: [
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
},
layers: [
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } },
],
};