v0.9.0: in-app auto-updater, ship toggle split, stable entity IDs, performance fixes

New features:
- In-app auto-updater with confirmation dialog, manual download fallback,
  restart polling, and protected file safety net
- Ship layers split into 4 independent toggles (Military/Carriers, Cargo/Tankers,
  Civilian, Cruise/Passenger) with per-category counts
- Stable entity IDs using MMSI/callsign instead of volatile array indices
- Dismissible threat alert bubbles (session-scoped, survives data refresh)

Performance:
- GDELT title fetching is now non-blocking (background enrichment)
- Removed duplicate startup fetch jobs
- Docker healthcheck start_period 15s → 90s

Bug fixes:
- Removed fake intelligence assessment generator (OSINT-only policy)
- Fixed carrier tracker GDELT 429/TypeError crash
- Fixed ETag collision (full payload hash)
- Added concurrent /api/refresh guard

Contributors: @imqdcr (ship split + stable IDs), @csysp (dismissible alerts, PR #48)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: a2c4c67da54345393f70a9b33b52e7e4fd6c049f
This commit is contained in:
anoracleofra-code
2026-03-13 11:32:16 -06:00
parent 1eb2b21647
commit fc9eff865e
62 changed files with 4930 additions and 4852 deletions
+283
View File
@@ -0,0 +1,283 @@
import React from "react";
import { Marker } from "react-map-gl/maplibre";
import type { ViewState } from "react-map-gl/maplibre";
// 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',
};
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>
))}
</>
);
}
// -- 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',
};
interface TrackedFlightLabelsProps {
flights: any[];
viewState: ViewState;
inView: (lat: number, lng: number) => boolean;
interpFlight: (f: any) => [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;
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;
let 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);
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];
}
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>
);
})}
</>
);
}
// -- UAV labels --
interface UavLabelsProps {
uavs: any[];
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>
);
})}
</>
);
}
// -- Earthquake labels --
interface EarthquakeLabelsProps {
earthquakes: any[];
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>
);
})}
</>
);
}
// -- 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';
}
interface ThreatMarkerProps {
spreadAlerts: any[];
viewState: ViewState;
selectedEntity: any;
onEntityClick?: (entity: { id: number; type: string } | null) => void;
onDismiss?: (alertKey: string) => void;
}
export function ThreatMarkers({ spreadAlerts, viewState, selectedEntity, onEntityClick, onDismiss }: ThreatMarkerProps) {
return (
<>
{spreadAlerts.map((n: any) => {
const idx = n.originalIdx;
const count = n.cluster_count || 1;
const score = n.risk_score || 0;
const riskColor = getRiskColor(score);
let isVisible = viewState.zoom >= 1;
if (selectedEntity) {
if (selectedEntity.type === 'news') {
if (selectedEntity.id !== idx) isVisible = false;
} else {
isVisible = false;
}
}
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
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: idx, 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="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>
);
})}
</>
);
}
@@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
import type { MapRef } from "react-map-gl/maplibre";
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]);
}
@@ -0,0 +1,146 @@
// --- SVG icon data URIs for all map markers ---
// Extracted from MaplibreViewer.tsx — pure data, no JSX
export const svgPlaneCyan = `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="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>`)}`;
export const svgPlaneYellow = `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 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>`)}`;
export const svgPlaneOrange = `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="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>`)}`;
export const svgPlanePurple = `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="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>`)}`;
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 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 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>`)}`;
export const svgPlaneAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF2020" 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>`)}`;
export const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#1A3A8A" stroke="#4A80D0"><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>`)}`;
export const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#ff0000" stroke-width="2"><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>`)}`;
export const svgHeliPink = `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="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="#FF1493" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#ff0000" 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="#ff0000" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#000080" stroke="#4A80D0"><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="#4A80D0" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#3b82f6" 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="#3b82f6" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliLime = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#32CD32" 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="#32CD32" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#666"><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="#999" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><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>`)}`;
export const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><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="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
export const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/><line x1="11" y1="6" x2="17" y2="6" stroke="#a78bfa" stroke-width="1"/><line x1="11" y1="14" x2="17" y2="14" stroke="#a78bfa" stroke-width="1"/><line x1="12" y1="19" x2="12" y2="22" stroke="#a78bfa" stroke-width="1.5"/></svg>`)}`;
export const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 20 L6 8 L12 2 L18 8 L18 20 C18 22 6 22 6 20 Z" fill="gray" stroke="#000" stroke-width="1"/><polygon points="12,6 16,16 8,16" fill="#fff" stroke="#000" stroke-width="1"/></svg>`)}`;
export const svgShipRed = `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="#ff2222" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#880000" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
export const svgShipYellow = `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="yellow" 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 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 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 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>`)}`;
// --- 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";
// 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";
// 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";
// --- 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>`
)}`;
}
export const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16);
export const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18);
export const svgFireRed = makeFireSvg('#ff2200', '#ff8800', 20);
export const svgFireDarkRed = makeFireSvg('#cc0000', '#ff2200', 22);
// Larger fire icons for cluster markers (visually distinct from Global Incidents circles)
export const svgFireClusterSmall = makeFireSvg('#ff6600', '#ffcc00', 32);
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>`)}`;
}
// POTUS fleet — oversized hot pink with yellow halo ring
export const svgPotusPlane = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(4,4)"><path d="${AIRLINER_PATH}" fill="#FF1493" stroke="black"/><circle cx="7" cy="12.5" r="1.2" fill="#FF1493" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`)}`;
export const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,4)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF1493" stroke-dasharray="2 2" stroke-width="1"/></g></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)
]);
// Pre-built aircraft SVGs by type & color
export const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan');
export const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00');
export const svgAirlinerPurple = makeAircraftSvg('airliner', '#9B59B6');
export const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow');
export const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22);
export const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22);
export const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22);
export const svgAirlinerBlue = makeAircraftSvg('airliner', '#3b82f6', 'black', 22);
export const svgAirlinerLime = makeAircraftSvg('airliner', '#32CD32', 'black', 22);
export const svgAirlinerBlack = makeAircraftSvg('airliner', '#222', '#555', 22);
export const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#666', 22);
export const svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan');
export const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00');
export const svgTurbopropPurple = makeAircraftSvg('turboprop', '#9B59B6');
export const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow');
export const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22);
export const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22);
export const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22);
export const svgTurbopropBlue = makeAircraftSvg('turboprop', '#3b82f6', 'black', 22);
export const svgTurbopropLime = makeAircraftSvg('turboprop', '#32CD32', 'black', 22);
export const svgTurbopropBlack = makeAircraftSvg('turboprop', '#222', '#555', 22);
export const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#666', 22);
export const svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan');
export const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00');
export const svgBizjetPurple = makeAircraftSvg('bizjet', '#9B59B6');
export const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow');
export const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22);
export const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22);
export const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22);
export const svgBizjetBlue = makeAircraftSvg('bizjet', '#3b82f6', 'black', 22);
export const svgBizjetLime = makeAircraftSvg('bizjet', '#32CD32', 'black', 22);
export const svgBizjetBlack = makeAircraftSvg('bizjet', '#222', '#555', 22);
export const svgBizjetWhite = makeAircraftSvg('bizjet', 'white', '#666', 22);
// Grey variants for grounded/parked aircraft (altitude 0)
export const svgAirlinerGrey = makeAircraftSvg('airliner', '#555', '#333');
export const svgTurbopropGrey = makeAircraftSvg('turboprop', '#555', '#333');
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' };
// 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' };
@@ -0,0 +1,28 @@
// Satellite icon SVG builder and mission-type mappings
// 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">
<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"/>
<line x1="8" y1="12" x2="1" y2="12" stroke="${color}" stroke-width="0.8"/>
<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);
};
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'
};
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'
};
@@ -0,0 +1,5 @@
// Shared map constants
// Extracted from MaplibreViewer.tsx
// Empty GeoJSON constant — avoids recreating empty objects on every render
export const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
@@ -0,0 +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
}
},
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
}
},
layers: [
{ id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 },
{ id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }
]
};