mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-15 12:48:02 +02:00
v0.9.5: The Voltron Update — modular architecture, stable IDs, parallelized boot
- Parallelized startup (60s → 15s) via ThreadPoolExecutor - Adaptive polling engine with ETag caching (no more bbox interrupts) - useCallback optimization for interpolation functions - Sliding LAYERS/INTEL edge panels replace bulky Record Panel - Modular fetcher architecture (flights, geo, infrastructure, financial, earth_observation) - Stable entity IDs for GDELT & News popups (PR #63, credit @csysp) - Admin auth (X-Admin-Key), rate limiting (slowapi), auto-updater - Docker Swarm secrets support, env_check.py validation - 85+ vitest tests, CI pipeline, geoJSON builder extraction - Server-side viewport bbox filtering reduces payloads 80%+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Former-commit-id: f2883150b5bc78ebc139d89cc966a76f7d7c0408
This commit is contained in:
@@ -4,55 +4,55 @@ import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Zap, Ship, Download, Shield, Bug, Heart } from "lucide-react";
|
||||
|
||||
const CURRENT_VERSION = "0.9";
|
||||
const CURRENT_VERSION = "0.9.5";
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Download size={14} className="text-cyan-400" />,
|
||||
title: "In-App Auto-Updater",
|
||||
desc: "One-click updates directly from the dashboard. Downloads the latest release, backs up your files, extracts over the project, and auto-restarts. Manual download fallback included if anything goes wrong.",
|
||||
icon: <Zap size={14} className="text-cyan-400" />,
|
||||
title: "Parallelized Boot (15s Cold Start)",
|
||||
desc: "Backend startup now runs fast-tier, slow-tier, and airport data concurrently via ThreadPoolExecutor. Boot time cut from 60s+ to ~15s.",
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
title: "Granular Ship Layer Controls",
|
||||
desc: "Ships split into 4 independent toggles: Military/Carriers, Cargo/Tankers, Civilian Vessels, and Cruise/Passenger. Each shows its own live count in the sidebar.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-green-400" />,
|
||||
title: "Stable Entity Selection",
|
||||
desc: "Ship and flight markers now use MMSI/callsign IDs instead of volatile array indices. Selecting a ship or plane stays locked on even when data refreshes every 60 seconds.",
|
||||
title: "Adaptive Polling + ETag Caching",
|
||||
desc: "Data polling engine rebuilt with adaptive retry (3s startup, 15s steady state) and ETag conditional caching. Map panning no longer interrupts data flow.",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
icon: <X size={14} className="text-red-400" />,
|
||||
title: "Dismissible Threat Alerts",
|
||||
desc: "Click the X on any threat alert bubble to dismiss it for the session. Uses stable content hashing so dismissed alerts stay hidden across 60-second data refreshes.",
|
||||
color: "red",
|
||||
icon: <Ship size={14} className="text-blue-400" />,
|
||||
title: "Sliding Edge Panels (LAYERS / INTEL)",
|
||||
desc: "Replaced bulky Record Panel with spring-animated side tabs. LAYERS on the left, INTEL (News, Markets, Radio, Find) on the right. Premium tactical HUD feel.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <Zap size={14} className="text-yellow-400" />,
|
||||
title: "Faster Data Loading",
|
||||
desc: "GDELT military incidents now load instantly with background title enrichment instead of blocking for 2+ minutes. Eliminated duplicate startup fetch jobs for faster boot.",
|
||||
icon: <Download size={14} className="text-yellow-400" />,
|
||||
title: "Admin Auth + Rate Limiting + Auto-Updater",
|
||||
desc: "Settings and system endpoints protected by X-Admin-Key. All endpoints rate-limited via slowapi. One-click auto-update from GitHub releases with safe backup/restart.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-purple-400" />,
|
||||
title: "Docker Swarm Secrets Support",
|
||||
desc: "Production deployments can now load API keys from /run/secrets/ instead of environment variables. env_check.py enforces warning tiers for missing keys.",
|
||||
color: "purple",
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"Removed viewport bbox filtering that caused 20-second delays when panning between regions",
|
||||
"Fixed carrier tracker crash on GDELT 429/TypeError responses",
|
||||
"Removed fake intelligence assessment generator — all data is now real OSINT only",
|
||||
"Docker healthcheck start_period increased to 90s to prevent false-negative restarts during data preload",
|
||||
"ETag collision fix — full payload hash instead of first 256 chars",
|
||||
"Concurrent /api/refresh guard prevents duplicate data fetches",
|
||||
"Stable entity IDs for GDELT & News popups — no more wrong popup after data refresh (PR #63)",
|
||||
"useCallback optimization for interpolation functions — eliminates redundant React re-renders on every 1s tick",
|
||||
"Restored missing GDELT and datacenter background refreshes in slow-tier loop",
|
||||
"Server-side viewport bounding box filtering reduces JSON payload size by 80%+",
|
||||
"Modular fetcher architecture sustained over monolithic data_fetcher.py",
|
||||
"CCTV ingestors instantiated once at startup — no more fresh DB connections every 10min tick",
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{ name: "@imqdcr", desc: "Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers" },
|
||||
{ name: "@csysp", desc: "Dismissible threat alert bubbles with stable content hashing + stopPropagation crash fix", pr: "#48" },
|
||||
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
|
||||
{ name: "@csysp", desc: "Dismissible threat alerts + stable entity IDs for GDELT & News popups", pr: "#48, #63" },
|
||||
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min \u2192 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ArrowUpRight, ArrowDownRight, TrendingUp, Droplet, ChevronDown, ChevronUp, Globe } from 'lucide-react';
|
||||
import type { DashboardData } from "@/types/dashboard";
|
||||
|
||||
const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: DashboardData }) {
|
||||
@@ -23,7 +23,10 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: Dashboar
|
||||
className="flex justify-between items-center p-3 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={12} className="text-cyan-500" />
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">GLOBAL MARKETS</span>
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
|
||||
@@ -456,9 +456,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className={`text-xs tracking-widest font-bold ${selectedEntity.type === 'military_flight' ? 'text-red-400' : selectedEntity.type === 'private_flight' ? 'text-orange-400' : selectedEntity.type === 'private_jet' ? 'text-purple-400' : 'text-cyan-400'} flex items-center gap-2`}>
|
||||
{selectedEntity.type === 'military_flight' ? "MILITARY BOGEY INTERCEPT" : selectedEntity.type === 'private_flight' ? "PRIVATE TRANSPONDER" : selectedEntity.type === 'private_jet' ? "PRIVATE JET TRANSPONDER" : "COMMERCIAL TRANSPONDER"}
|
||||
</h2>
|
||||
@@ -576,9 +576,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||
{headerTitle}
|
||||
</h2>
|
||||
@@ -648,7 +648,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'gdelt') {
|
||||
const gdeltItem = data?.gdelt?.[selectedEntity.id as number];
|
||||
const gdeltItem = data?.gdelt?.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
|
||||
if (gdeltItem && gdeltItem.properties) {
|
||||
const props = gdeltItem.properties;
|
||||
return (
|
||||
@@ -810,9 +810,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
AERONAUTICAL HUB
|
||||
</h2>
|
||||
@@ -844,9 +844,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-cyan-800 rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,128,255,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
className="w-full bg-black/60 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-cyan-500/30 bg-cyan-950/40 flex justify-between items-center">
|
||||
<div className="p-3 border-b border-[var(--border-primary)]/30 bg-[var(--bg-secondary)]/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-red-400" /> {selectedEntity.extra?.last_updated
|
||||
? new Date(selectedEntity.extra.last_updated + 'Z').toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }).toUpperCase() + ' — OPTIC INTERCEPT'
|
||||
@@ -936,10 +936,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className={`w-full bg-[var(--bg-panel)] backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
className={`w-full bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,0,0,0.5)] pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
>
|
||||
<div
|
||||
className="p-3 border-b border-cyan-500/20 bg-cyan-950/20 relative overflow-hidden cursor-pointer hover:bg-cyan-900/30 transition-colors"
|
||||
className="p-3 border-b border-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex justify-between items-center relative z-10">
|
||||
@@ -1029,7 +1029,7 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.cluster_count > 1 && (
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-cyan-950/50 hover:text-[var(--text-primary)] hover:bg-cyan-900 border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
{isExpanded ? '[- COLLAPSE]' : `[+${item.cluster_count - 1} SOURCES]`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -250,18 +250,18 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-cyan-900/50 rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
|
||||
className="w-full flex flex-col bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto shadow-[0_4px_30px_rgba(0,0,0,0.2)] relative overflow-hidden max-h-full"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border-b border-cyan-900/50 cursor-pointer bg-cyan-950/20 hover:bg-cyan-900/30 transition-colors"
|
||||
className="flex items-center justify-between p-3 border-b border-[var(--border-primary)]/50 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-cyan-400">
|
||||
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
||||
<RadioReceiver size={14} className={isPlaying ? "animate-pulse" : ""} />
|
||||
<span className="text-[10px] font-mono tracking-widest font-semibold">SIGINT INTERCEPT</span>
|
||||
<span className="text-[10px] font-mono tracking-widest">SIGINT INTERCEPT</span>
|
||||
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
|
||||
</div>
|
||||
<button className="text-cyan-500 hover:text-cyan-300 transition-colors">
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Audio Player Controls */}
|
||||
<div className="p-4 border-b border-cyan-900/40 bg-[var(--bg-primary)]/60">
|
||||
<div className="p-4 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/60">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
||||
@@ -348,36 +348,6 @@ export default function RadioInterceptPanel({ data, isEavesdropping, setIsEavesd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KiwiSDR Tuner — appears when a KiwiSDR node is clicked on the map */}
|
||||
{selectedEntity?.type === 'kiwisdr' && selectedEntity.extra?.url && (
|
||||
<div className="p-3 border-b border-amber-900/40 bg-amber-950/10">
|
||||
<div className="text-[9px] text-amber-400 font-mono tracking-widest mb-2 flex items-center gap-2">
|
||||
<RadioReceiver size={10} />
|
||||
SDR TUNER: {(selectedEntity.extra.name || 'REMOTE RECEIVER').toUpperCase().slice(0, 60)}
|
||||
</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
|
||||
{selectedEntity.extra.location && <span>{selectedEntity.extra.location} · </span>}
|
||||
{selectedEntity.extra.antenna && <span>{selectedEntity.extra.antenna.slice(0, 80)} · </span>}
|
||||
{selectedEntity.extra.users !== undefined && <span>{selectedEntity.extra.users}/{selectedEntity.extra.users_max} users</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<a
|
||||
href={selectedEntity.extra.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center px-4 py-2.5 rounded border border-amber-500/50 bg-amber-950/30 text-amber-400 hover:bg-amber-900/40 hover:border-amber-400 text-[10px] font-mono tracking-widest transition-colors"
|
||||
>
|
||||
OPEN SDR RECEIVER →
|
||||
</a>
|
||||
</div>
|
||||
{selectedEntity.extra.bands && (
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mt-2">
|
||||
BANDS: {(Number(selectedEntity.extra.bands.split('-')[0]) / 1e6).toFixed(0)}-{(Number(selectedEntity.extra.bands.split('-')[1]) / 1e6).toFixed(0)} MHz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed List */}
|
||||
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
||||
{feeds.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
|
||||
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight, Palette } from "lucide-react";
|
||||
import packageJson from "../../package.json";
|
||||
import { useTheme } from "@/lib/ThemeContext";
|
||||
|
||||
@@ -60,11 +60,11 @@ const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
|
||||
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
};
|
||||
import type { DashboardData, ActiveLayers, SelectedEntity } from "@/types/dashboard";
|
||||
import type { DashboardData, ActiveLayers, SelectedEntity, KiwiSDR } from "@/types/dashboard";
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: DashboardData; activeLayers: ActiveLayers; setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: SelectedEntity) => void; onFlyTo?: (lat: number, lng: number) => void }) {
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo, trackedSdr, setTrackedSdr }: { data: DashboardData; activeLayers: ActiveLayers; setActiveLayers: React.Dispatch<React.SetStateAction<ActiveLayers>>; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: SelectedEntity) => void; onFlyTo?: (lat: number, lng: number) => void; trackedSdr?: KiwiSDR | null; setTrackedSdr?: (sdr: KiwiSDR | null) => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { theme, toggleTheme, hudColor, cycleHudColor } = useTheme();
|
||||
const [gibsPlaying, setGibsPlaying] = useState(false);
|
||||
const [potusEnabled, setPotusEnabled] = useState(true);
|
||||
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -172,6 +172,13 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleHudColor}
|
||||
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-cyan-400 hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
|
||||
title={hudColor === 'cyan' ? 'Switch to Matrix HUD' : 'Switch to Cyan HUD'}
|
||||
>
|
||||
<Palette size={14} />
|
||||
</button>
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
@@ -238,6 +245,58 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
className="overflow-y-auto styled-scrollbar"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
|
||||
{/* SDR TRACKER — pinned to TOP when active */}
|
||||
{trackedSdr && (
|
||||
<div className="bg-amber-950/20 border border-amber-500/40 rounded-lg p-3 -mt-1 shadow-[0_0_15px_rgba(245,158,11,0.1)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio size={14} className="text-amber-400" />
|
||||
<span className="text-[10px] text-amber-400 font-mono tracking-widest font-bold">SDR TRACKER</span>
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-amber-500/20 border border-amber-500/40 text-amber-400 animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setTrackedSdr?.(null); }}
|
||||
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-red-400 border border-[var(--border-primary)] hover:border-red-400/40 rounded px-1.5 py-0.5 transition-colors"
|
||||
title="Release SDR and clear tracking"
|
||||
>
|
||||
RELEASE
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col p-2 rounded-lg border border-amber-500/20 bg-amber-950/10">
|
||||
<span className="text-[10px] font-bold font-mono text-amber-300 truncate mb-1">
|
||||
{(trackedSdr.name || 'REMOTE RECEIVER').toUpperCase()}
|
||||
</span>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono mb-2">
|
||||
{trackedSdr.location && <span>{trackedSdr.location} · </span>}
|
||||
{trackedSdr.antenna && <span>{trackedSdr.antenna.slice(0, 40)}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => onFlyTo?.(trackedSdr.lat, trackedSdr.lon)}
|
||||
className="flex-1 text-center px-2 py-1.5 rounded border border-[var(--border-primary)] hover:border-amber-400/50 hover:text-amber-400 text-[var(--text-muted)] text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
|
||||
title="Pan camera to SDR location"
|
||||
>
|
||||
<Globe size={10} /> RE-LOCK
|
||||
</button>
|
||||
{trackedSdr.url && (
|
||||
<a
|
||||
href={trackedSdr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center px-2 py-1.5 rounded border border-amber-500/50 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:border-amber-400 text-[9px] font-mono tracking-widest transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Activity size={10} /> TUNER
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
|
||||
{potusEnabled && potusFlights.length > 0 && (
|
||||
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
|
||||
|
||||
@@ -42,7 +42,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
</div>
|
||||
|
||||
{/* Right side controls box */}
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-cyan-900 flex flex-col relative overflow-hidden h-full">
|
||||
<div className="bg-[var(--bg-primary)]/40 backdrop-blur-md border border-[var(--border-primary)] rounded-xl pointer-events-auto border-r-2 border-r-[var(--border-primary)] flex flex-col relative overflow-hidden h-full">
|
||||
|
||||
{/* Header / Toggle */}
|
||||
<div
|
||||
@@ -71,14 +71,14 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
onClick={() => setEffects({ ...effects, bloom: !effects.bloom })}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-gray-600'}`}>✧</span>
|
||||
<span className={`text-[14px] ${effects.bloom ? 'text-yellow-500' : 'text-[var(--text-muted)]'}`}>✧</span>
|
||||
<span className={`text-xs font-mono tracking-widest ${effects.bloom ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'}`}>BLOOM</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono tracking-wider text-[var(--text-muted)]">{effects.bloom ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
|
||||
{/* Sharpen Slider */}
|
||||
<div className="flex flex-col gap-3 group border border-cyan-900/50 bg-cyan-950/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="flex flex-col gap-3 group border border-[var(--border-primary)]/50 bg-[var(--bg-secondary)]/10 rounded px-4 py-3 pb-4 relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-cyan-400 flex items-center justify-center relative">
|
||||
@@ -98,7 +98,7 @@ const WorldviewRightPanel = React.memo(function WorldviewRightPanel({ effects, s
|
||||
{/* HUD Dropdown */}
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<div className="flex items-center gap-3 border border-[var(--border-primary)] rounded px-4 py-3 text-[var(--text-muted)] cursor-default">
|
||||
<span className="w-3 h-3 border border-gray-500 rounded-full flex items-center justify-center"></span>
|
||||
<span className="w-3 h-3 border border-[var(--border-secondary)] rounded-full flex items-center justify-center"></span>
|
||||
<span className="text-xs font-mono tracking-widest">HUD</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,6 +106,32 @@ export function CarrierLabels({ ships, inView, interpShip }: CarrierLabelsProps)
|
||||
);
|
||||
}
|
||||
|
||||
// -- Tracked yacht labels --
|
||||
interface TrackedYachtLabelsProps {
|
||||
ships: any[];
|
||||
inView: (lat: number, lng: number) => boolean;
|
||||
interpShip: (s: any) => [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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// -- UAV labels --
|
||||
interface UavLabelsProps {
|
||||
uavs: any[];
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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';
|
||||
|
||||
// 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,
|
||||
kiwisdr: false,
|
||||
ships_military: true, ships_cargo: true, ships_civilian: true,
|
||||
ships_passenger: true, ships_tracked_yachts: true,
|
||||
};
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
it('returns null for empty array', () => {
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds valid FeatureCollection', () => {
|
||||
const quakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -74.0, place: 'New York' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(quakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.type).toBe('earthquake');
|
||||
expect(result!.features[0].geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
});
|
||||
|
||||
it('skips entries with null coordinates', () => {
|
||||
const quakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: null as any, lng: 139.0, place: 'Bad' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -74.0, place: 'Good' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(quakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFirmsGeoJSON', () => {
|
||||
it('returns null for empty array', () => {
|
||||
expect(buildFirmsGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
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
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires)!;
|
||||
expect(result.features[0].properties?.iconId).toBe('fire-yellow');
|
||||
expect(result.features[1].properties?.iconId).toBe('fire-orange');
|
||||
expect(result.features[2].properties?.iconId).toBe('fire-red');
|
||||
expect(result.features[3].properties?.iconId).toBe('fire-darkred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShipsGeoJSON', () => {
|
||||
const alwaysInView = () => true;
|
||||
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 ships: Ship[] = [{ name: 'Test', lat: 10, lng: 20, type: 'cargo' } as Ship];
|
||||
expect(buildShipsGeoJSON(ships, layers, alwaysInView, interpIdentity)).toBeNull();
|
||||
});
|
||||
|
||||
it('filters out carriers (handled by buildCarriersGeoJSON)', () => {
|
||||
const ships: Ship[] = [
|
||||
{ name: 'Cargo Ship', lat: 10, lng: 20, type: 'cargo', mmsi: '123' } as Ship,
|
||||
{ name: 'USS Nimitz', lat: 30, lng: 40, type: 'carrier', mmsi: '456' } as Ship,
|
||||
];
|
||||
const result = buildShipsGeoJSON(ships, allShipLayers, alwaysInView, interpIdentity);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Cargo Ship');
|
||||
});
|
||||
|
||||
it('assigns correct icon by ship type', () => {
|
||||
const ships: Ship[] = [
|
||||
{ name: 'Tanker', lat: 10, lng: 20, type: 'tanker', mmsi: '1' } as Ship,
|
||||
{ name: 'Yacht', lat: 10, lng: 21, type: 'yacht', mmsi: '2' } as Ship,
|
||||
{ name: 'Warship', lat: 10, lng: 22, type: 'military_vessel', mmsi: '3' } as Ship,
|
||||
];
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCarriersGeoJSON', () => {
|
||||
it('returns null for empty ships', () => {
|
||||
expect(buildCarriersGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('only includes carriers', () => {
|
||||
const ships: Ship[] = [
|
||||
{ name: 'USS Nimitz', lat: 30, lng: 40, type: 'carrier', mmsi: '456', heading: 90 } as Ship,
|
||||
{ name: 'Cargo Ship', lat: 10, lng: 20, type: 'cargo', mmsi: '123' } as Ship,
|
||||
];
|
||||
const result = buildCarriersGeoJSON(ships)!;
|
||||
expect(result.features).toHaveLength(1);
|
||||
expect(result.features[0].properties?.name).toBe('USS Nimitz');
|
||||
expect(result.features[0].properties?.iconId).toBe('svgCarrier');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
// ─── Pure GeoJSON builder functions ─────────────────────────────────────────
|
||||
// Extracted from MaplibreViewer to reduce component size and enable unit testing.
|
||||
// Each function takes data arrays + optional helpers and returns a GeoJSON FeatureCollection or null.
|
||||
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
|
||||
import { classifyAircraft } from "@/utils/aircraftClassification";
|
||||
import { MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons";
|
||||
|
||||
type FC = GeoJSON.FeatureCollection | null;
|
||||
type InViewFilter = (lat: number, lng: number) => boolean;
|
||||
|
||||
// ─── Earthquakes ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildEarthquakesGeoJSON(earthquakes?: Earthquake[]): FC {
|
||||
if (!earthquakes?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: earthquakes.map((eq, i) => {
|
||||
if (eq.lat == null || eq.lng == null) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'earthquake',
|
||||
name: `[M${eq.mag}]\n${eq.place || 'Unknown Location'}`,
|
||||
title: eq.title,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [eq.lng, eq.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GPS Jamming Zones ──────────────────────────────────────────────────────
|
||||
|
||||
export function buildJammingGeoJSON(zones?: GPSJammingZone[]): FC {
|
||||
if (!zones?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: zones.map((zone, i) => {
|
||||
const halfDeg = 0.5;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
severity: zone.severity,
|
||||
ratio: zone.ratio,
|
||||
degraded: zone.degraded,
|
||||
total: zone.total,
|
||||
opacity: zone.severity === 'high' ? 0.45 : zone.severity === 'medium' ? 0.3 : 0.18
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [[
|
||||
[zone.lng - halfDeg, zone.lat - halfDeg],
|
||||
[zone.lng + halfDeg, zone.lat - halfDeg],
|
||||
[zone.lng + halfDeg, zone.lat + halfDeg],
|
||||
[zone.lng - halfDeg, zone.lat + halfDeg],
|
||||
[zone.lng - halfDeg, zone.lat - halfDeg]
|
||||
]]
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CCTV Cameras ──────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCctvGeoJSON(cameras?: CCTVCamera[], inView?: InViewFilter): FC {
|
||||
if (!cameras?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: cameras.filter(c => c.lat != null && c.lon != null && (!inView || inView(c.lat, c.lon))).map((c, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: c.id || i,
|
||||
type: 'cctv',
|
||||
name: c.direction_facing || 'Camera',
|
||||
source_agency: c.source_agency || 'Unknown',
|
||||
media_url: c.media_url || '',
|
||||
media_type: c.media_type || 'image'
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [c.lon, c.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── KiwiSDR Receivers ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildKiwisdrGeoJSON(receivers?: KiwiSDR[], inView?: InViewFilter): FC {
|
||||
if (!receivers?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: receivers.filter(k => k.lat != null && k.lon != null && (!inView || inView(k.lat, k.lon))).map((k, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'kiwisdr',
|
||||
name: k.name || 'Unknown SDR',
|
||||
url: k.url || '',
|
||||
users: k.users || 0,
|
||||
users_max: k.users_max || 0,
|
||||
bands: k.bands || '',
|
||||
antenna: k.antenna || '',
|
||||
location: k.location || '',
|
||||
lat: k.lat,
|
||||
lon: k.lon,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [k.lon, k.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NASA FIRMS Fires ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildFirmsGeoJSON(fires?: FireHotspot[]): FC {
|
||||
if (!fires?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: fires.map((f, i) => {
|
||||
const frp = f.frp || 0;
|
||||
const iconId = frp >= 100 ? 'fire-darkred' : frp >= 20 ? 'fire-red' : frp >= 5 ? 'fire-orange' : 'fire-yellow';
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'firms_fire',
|
||||
name: `Fire ${frp.toFixed(1)} MW`,
|
||||
frp,
|
||||
iconId,
|
||||
brightness: f.brightness || 0,
|
||||
confidence: f.confidence || '',
|
||||
daynight: f.daynight === 'D' ? 'Day' : 'Night',
|
||||
acq_date: f.acq_date || '',
|
||||
acq_time: f.acq_time || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [f.lng, f.lat] }
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internet Outages ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildInternetOutagesGeoJSON(outages?: InternetOutage[]): FC {
|
||||
if (!outages?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: outages.map((o) => {
|
||||
if (o.lat == null || o.lng == null) return null;
|
||||
const severity = o.severity || 0;
|
||||
const region = o.region_name || o.region_code || '?';
|
||||
const country = o.country_name || o.country_code || '';
|
||||
const label = `${region}, ${country}`;
|
||||
const detail = `${label}\n${severity}% drop · ${o.datasource || 'IODA'}`;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: o.region_code || region,
|
||||
type: 'internet_outage',
|
||||
name: label,
|
||||
country,
|
||||
region,
|
||||
level: o.level,
|
||||
severity,
|
||||
datasource: o.datasource || '',
|
||||
detail,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [o.lng, o.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Data Centers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function buildDataCentersGeoJSON(datacenters?: DataCenter[]): FC {
|
||||
if (!datacenters?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: datacenters.map((dc, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: `dc-${i}`,
|
||||
type: 'datacenter',
|
||||
name: dc.name || 'Unknown',
|
||||
company: dc.company || '',
|
||||
street: dc.street || '',
|
||||
city: dc.city || '',
|
||||
country: dc.country || '',
|
||||
zip: dc.zip || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GDELT Incidents ────────────────────────────────────────────────────────
|
||||
|
||||
export function buildGdeltGeoJSON(gdelt?: GDELTIncident[], inView?: InViewFilter): FC {
|
||||
if (!gdelt?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: gdelt.map((g) => {
|
||||
if (!g.geometry || !g.geometry.coordinates) return null;
|
||||
const [gLng, gLat] = g.geometry.coordinates;
|
||||
if (inView && !inView(gLat, gLng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: { id: g.properties?.name || String(g.geometry.coordinates), type: 'gdelt', title: g.properties?.name || '' },
|
||||
geometry: g.geometry
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── LiveUAMap Incidents ────────────────────────────────────────────────────
|
||||
|
||||
export function buildLiveuaGeoJSON(incidents?: LiveUAmapIncident[], inView?: InViewFilter): FC {
|
||||
if (!incidents?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: incidents.map((incident) => {
|
||||
if (incident.lat == null || incident.lng == null) return null;
|
||||
if (inView && !inView(incident.lat, incident.lng)) return null;
|
||||
const isViolent = /bomb|missil|strike|attack|kill|destroy|fire|shoot|expl|raid/i.test(incident.title || "");
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: incident.id,
|
||||
type: 'liveuamap',
|
||||
title: incident.title || '',
|
||||
iconId: isViolent ? 'icon-liveua-red' : 'icon-liveua-yellow',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [incident.lng, incident.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ukraine Frontline ──────────────────────────────────────────────────────
|
||||
|
||||
export function buildFrontlineGeoJSON(frontlines?: FrontlineGeoJSON | null): FC {
|
||||
if (!frontlines?.features?.length) return null;
|
||||
return frontlines;
|
||||
}
|
||||
|
||||
// ─── Parameterized Flight Layer ─────────────────────────────────────────────
|
||||
// Deduplicates commercial / private / jets / military flight GeoJSON builders.
|
||||
|
||||
export interface FlightLayerConfig {
|
||||
colorMap: Record<string, string>;
|
||||
groundedMap: Record<string, string>;
|
||||
typeLabel: string;
|
||||
idPrefix: string;
|
||||
/** For military flights: special icon overrides by military_type */
|
||||
milSpecialMap?: Record<string, string>;
|
||||
/** If true, prefer true_track over heading for rotation (commercial flights) */
|
||||
useTrackHeading?: boolean;
|
||||
}
|
||||
|
||||
export function buildFlightLayerGeoJSON(
|
||||
flights: any[] | undefined,
|
||||
config: FlightLayerConfig,
|
||||
helpers: {
|
||||
interpFlight: (f: any) => [number, number];
|
||||
inView: InViewFilter;
|
||||
trackedIcaoSet: Set<string>;
|
||||
}
|
||||
): FC {
|
||||
if (!flights?.length) return null;
|
||||
const { colorMap, groundedMap, typeLabel, idPrefix, milSpecialMap, useTrackHeading } = config;
|
||||
const { interpFlight, inView, trackedIcaoSet } = helpers;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: flights.map((f: any, i: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (!inView(f.lat, f.lng)) return null;
|
||||
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
|
||||
let iconId: string;
|
||||
if (milSpecialMap) {
|
||||
const milType = f.military_type || '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);
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: { id: f.icao24 || f.callsign || `${idPrefix}${i}`, type: typeLabel, callsign: f.callsign || f.icao24, rotation, iconId },
|
||||
geometry: { type: 'Point' as const, coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── UAVs / Drones ──────────────────────────────────────────────────────────
|
||||
|
||||
export function buildUavGeoJSON(uavs?: UAV[], inView?: InViewFilter): FC {
|
||||
if (!uavs?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: uavs.map((uav, i) => {
|
||||
if (uav.lat == null || uav.lng == null) return null;
|
||||
if (inView && !inView(uav.lat, uav.lng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: (uav as any).id || `uav-${i}`,
|
||||
type: 'uav',
|
||||
callsign: uav.callsign,
|
||||
rotation: uav.heading || 0,
|
||||
iconId: 'svgDrone',
|
||||
name: uav.aircraft_model || uav.callsign,
|
||||
country: uav.country || '',
|
||||
uav_type: uav.uav_type || '',
|
||||
alt: uav.alt || 0,
|
||||
wiki: uav.wiki || '',
|
||||
speed_knots: uav.speed_knots || 0,
|
||||
icao24: uav.icao24 || '',
|
||||
registration: uav.registration || '',
|
||||
squawk: uav.squawk || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [uav.lng, uav.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
// ─── Satellites ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildSatellitesGeoJSON(
|
||||
satellites: Satellite[] | undefined,
|
||||
inView: InViewFilter,
|
||||
interpSat: (s: Satellite) => [number, number]
|
||||
): FC {
|
||||
if (!satellites?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: satellites
|
||||
.filter((s) => s.lat != null && s.lng != null && inView(s.lat, s.lng))
|
||||
.map((s, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: s.id || i, type: 'satellite', name: s.name, mission: s.mission || 'general',
|
||||
sat_type: s.sat_type || 'Satellite', country: s.country || '', alt_km: s.alt_km || 0,
|
||||
wiki: s.wiki || '', color: MISSION_COLORS[s.mission] || '#aaaaaa',
|
||||
iconId: MISSION_ICON_MAP[s.mission] || 'sat-gen'
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: interpSat(s) }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ships (non-carrier) ────────────────────────────────────────────────────
|
||||
|
||||
export function buildShipsGeoJSON(
|
||||
ships: Ship[] | undefined,
|
||||
activeLayers: ActiveLayers,
|
||||
inView: InViewFilter,
|
||||
interpShip: (s: Ship) => [number, number]
|
||||
): FC {
|
||||
if (!(activeLayers.ships_military || activeLayers.ships_cargo || activeLayers.ships_civilian || activeLayers.ships_passenger || activeLayers.ships_tracked_yachts) || !ships) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: ships.map((s, i) => {
|
||||
if (s.lat == null || s.lng == null) return null;
|
||||
if (!inView(s.lat, s.lng)) return null;
|
||||
const isTrackedYacht = !!s.yacht_alert;
|
||||
const isMilitary = s.type === 'carrier' || s.type === 'military_vessel';
|
||||
const isCargo = s.type === 'tanker' || s.type === 'cargo';
|
||||
const isPassenger = s.type === 'passenger';
|
||||
|
||||
if (s.type === 'carrier') return null; // Handled by buildCarriersGeoJSON
|
||||
|
||||
if (isTrackedYacht) {
|
||||
if (activeLayers?.ships_tracked_yachts === false) return null;
|
||||
} else if (isMilitary && activeLayers?.ships_military === false) return null;
|
||||
else if (isCargo && activeLayers?.ships_cargo === false) return null;
|
||||
else if (isPassenger && activeLayers?.ships_passenger === false) return null;
|
||||
else if (!isMilitary && !isCargo && !isPassenger && activeLayers?.ships_civilian === false) return null;
|
||||
|
||||
let iconId = 'svgShipBlue';
|
||||
if (isTrackedYacht) iconId = 'svgShipPink';
|
||||
else if (isCargo) iconId = 'svgShipRed';
|
||||
else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite';
|
||||
else if (isMilitary) iconId = 'svgShipYellow';
|
||||
|
||||
const [iLng, iLat] = interpShip(s);
|
||||
return {
|
||||
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] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Carriers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCarriersGeoJSON(ships: Ship[] | undefined): FC {
|
||||
if (!ships?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: ships.map((s, i) => {
|
||||
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: s.mmsi || s.name || `carrier-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' },
|
||||
geometry: { type: 'Point', coordinates: [s.lng, s.lat] }
|
||||
};
|
||||
}).filter(Boolean) as GeoJSON.Feature[]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
|
||||
export interface ClusterItem {
|
||||
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.
|
||||
*
|
||||
* @param mapRef - React ref to the MapLibre map instance
|
||||
* @param sourceId - The source ID to query clusters from (e.g. "ships", "earthquakes")
|
||||
* @param geoJSON - The GeoJSON data driving the source (null = no clusters)
|
||||
*/
|
||||
export function useClusterLabels(
|
||||
mapRef: React.RefObject<MapRef | null>,
|
||||
sourceId: string,
|
||||
geoJSON: unknown | null
|
||||
): ClusterItem[] {
|
||||
const [clusters, setClusters] = useState<ClusterItem[]>([]);
|
||||
const handlerRef = useRef<(() => void) | null>(null);
|
||||
|
||||
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("sourcedata", handlerRef.current);
|
||||
}
|
||||
|
||||
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", update);
|
||||
map.on("sourcedata", update);
|
||||
setTimeout(update, 500);
|
||||
|
||||
return () => {
|
||||
map.off("moveend", update);
|
||||
map.off("sourcedata", update);
|
||||
};
|
||||
}, [geoJSON, sourceId]);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import { interpolatePosition } from "@/utils/positioning";
|
||||
import { INTERP_TICK_MS } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useInterpolation() {
|
||||
// Interpolation tick — bumps every INTERP_TICK_MS to animate entity positions
|
||||
const [interpTick, setInterpTick] = useState(0);
|
||||
const dataTimestamp = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setInterpTick((t) => t + 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();
|
||||
}, []);
|
||||
|
||||
// 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] => {
|
||||
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] => {
|
||||
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] => {
|
||||
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 };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xm
|
||||
export const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="34" viewBox="0 0 24 24" fill="none"><path d="M7 22 L7 6 L12 1 L17 6 L17 22 Z" fill="yellow" stroke="#000" stroke-width="1"/><rect x="9" y="8" width="6" height="8" fill="#555" stroke="#000" stroke-width="1"/><circle cx="12" cy="18" r="1.5" fill="#000"/><line x1="12" y1="18" x2="12" y2="24" stroke="#000" stroke-width="1.5"/></svg>`)}`;
|
||||
export const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#3b82f6" stroke="#000" stroke-width="1"/></svg>`)}`;
|
||||
export const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="36" viewBox="0 0 24 24" fill="none"><path d="M5 21 L5 8 L12 2 L19 8 L19 21 C19 23 5 23 5 21 Z" fill="white" stroke="#000" stroke-width="1"/><rect x="7" y="10" width="10" height="8" fill="#90cdf4" stroke="#000" stroke-width="1"/><circle cx="12" cy="14" r="2" fill="yellow" stroke="#000"/></svg>`)}`;
|
||||
export const 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 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>`)}`;
|
||||
|
||||
Reference in New Issue
Block a user