mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-06 12:28:05 +02:00
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:
@@ -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 50–500 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);
|
||||
}
|
||||
};
|
||||
@@ -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 } },
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user