mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-04-23 19:16:06 +02:00
fix: aircraft categorization, fullscreen satellite imagery, region dossier rate-limit, updated map legend
- Fixed 288+ miscategorized aircraft in plane_alert_db.json (gov/police/medical)
- data_fetcher.py: tracked_names enrichment now assigns blue/lime colors for gov/law/medical operators
- region_dossier.py: fixed Nominatim 429 rate-limiting with retry/backoff
- MaplibreViewer.tsx: Sentinel-2 popup replaced with fullscreen overlay + download/copy buttons
- MapLegend.tsx: updated to show all 9 tracked aircraft color categories + POTUS fleet + wildfires + infrastructure
Former-commit-id: d109434616
This commit is contained in:
@@ -1 +1 @@
|
||||
eb32e2f229fad1d5a14fe81e071e71591f875673
|
||||
38a18cbbf1acbec5eb9266b809c28d31e2941c53
|
||||
@@ -337,10 +337,22 @@ def enrich_with_tracked_names(flight: dict) -> dict:
|
||||
match = _TRACKED_NAMES_DB[callsign]
|
||||
|
||||
if match:
|
||||
name = match["name"]
|
||||
# Let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC).
|
||||
flight["alert_operator"] = match["name"]
|
||||
flight["alert_operator"] = name
|
||||
flight["alert_category"] = match["category"]
|
||||
if "alert_color" not in flight:
|
||||
|
||||
# Override pink default if the name implies a specific function
|
||||
name_lower = name.lower()
|
||||
is_gov = any(w in name_lower for w in ['state of ', 'government', 'republic', 'ministry', 'department', 'federal', 'cia'])
|
||||
is_law = any(w in name_lower for w in ['police', 'marshal', 'sheriff', 'douane', 'customs', 'patrol', 'gendarmerie', 'guardia', 'law enforcement'])
|
||||
is_med = any(w in name_lower for w in ['fire', 'bomberos', 'ambulance', 'paramedic', 'medevac', 'rescue', 'hospital', 'medical', 'lifeflight'])
|
||||
|
||||
if is_gov or is_law:
|
||||
flight["alert_color"] = "blue"
|
||||
elif is_med:
|
||||
flight["alert_color"] = "#32cd32" # lime
|
||||
elif "alert_color" not in flight:
|
||||
flight["alert_color"] = "pink"
|
||||
|
||||
return flight
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
import concurrent.futures
|
||||
from urllib.parse import quote
|
||||
import requests as _requests
|
||||
from cachetools import TTLCache
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
@@ -10,26 +12,46 @@ logger = logging.getLogger(__name__)
|
||||
# Key: rounded lat/lng grid (0.1 degree ≈ 11km)
|
||||
dossier_cache = TTLCache(maxsize=500, ttl=86400)
|
||||
|
||||
# Nominatim requires max 1 req/sec — track last call time
|
||||
_nominatim_last_call = 0.0
|
||||
|
||||
|
||||
def _reverse_geocode(lat: float, lng: float) -> dict:
|
||||
global _nominatim_last_call
|
||||
url = (
|
||||
f"https://nominatim.openstreetmap.org/reverse?"
|
||||
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
|
||||
)
|
||||
try:
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
headers = {"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"}
|
||||
|
||||
for attempt in range(2):
|
||||
# Enforce Nominatim's 1 req/sec policy
|
||||
elapsed = time.time() - _nominatim_last_call
|
||||
if elapsed < 1.1:
|
||||
time.sleep(1.1 - elapsed)
|
||||
_nominatim_last_call = time.time()
|
||||
|
||||
try:
|
||||
# Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
|
||||
res = _requests.get(url, timeout=10, headers=headers)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
elif res.status_code == 429:
|
||||
logger.warning(f"Nominatim 429 rate-limited, retrying after 2s (attempt {attempt+1})")
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Nominatim returned {res.status_code}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -656,13 +656,11 @@ export default function CesiumViewer({ data, activeLayers, activeFilters, effect
|
||||
}
|
||||
if (filters.tracked_owner?.length) {
|
||||
const op = (f.alert_operator || '').toLowerCase();
|
||||
const t1 = (f.alert_tag1 || '').toLowerCase();
|
||||
const t2 = (f.alert_tag2 || '').toLowerCase();
|
||||
const t3 = (f.alert_tag3 || '').toLowerCase();
|
||||
const tags = (f.alert_tags || '').toLowerCase();
|
||||
const cs = (f.callsign || '').toLowerCase();
|
||||
if (!filters.tracked_owner.some(sv => {
|
||||
const q = sv.toLowerCase();
|
||||
return op.includes(q) || t1.includes(q) || t2.includes(q) || t3.includes(q) || cs.includes(q);
|
||||
return op.includes(q) || tags.includes(q) || cs.includes(q);
|
||||
})) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -106,8 +106,7 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
|
||||
const ops = new Set<string>(trackedOperators);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_operator) ops.add(f.alert_operator);
|
||||
if (f.alert_tag1) ops.add(f.alert_tag1);
|
||||
if (f.alert_tag2) ops.add(f.alert_tag2);
|
||||
if (f.alert_tags) ops.add(f.alert_tags);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
@@ -101,10 +101,23 @@ const LEGEND: LegendCategory[] = [
|
||||
name: "TRACKED AIRCRAFT (ALERT)",
|
||||
color: "text-pink-400 border-pink-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" },
|
||||
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" },
|
||||
{ svg: airliner("white"), label: "Alert — General (white)" },
|
||||
{ svg: airliner("#FF1493"), label: "VIP / Celebrity / Bizjet (hot pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Dictator / Oligarch (red)" },
|
||||
{ svg: airliner("#3b82f6"), label: "Government / Police / Customs (blue)" },
|
||||
{ svg: heli("#32CD32"), label: "Medical / Fire / Rescue (lime)" },
|
||||
{ svg: airliner("yellow"), label: "Military / Intelligence (yellow)" },
|
||||
{ svg: airliner("#222"), label: "PIA — Privacy / Stealth (black)" },
|
||||
{ svg: airliner("#FF8C00"), label: "Private Flights / Joe Cool (orange)" },
|
||||
{ svg: airliner("white"), label: "Climate Crisis (white)" },
|
||||
{ svg: airliner("#9B59B6"), label: "Private Jets / Historic / Other (purple)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "POTUS FLEET",
|
||||
color: "text-yellow-400 border-yellow-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,6)"><path d="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" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Air Force One / Two (gold ring)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(8,6)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Marine One (gold ring + heli)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -141,6 +154,14 @@ const LEGEND: LegendCategory[] = [
|
||||
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "WILDFIRES",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 1C8 7 5 10 5 14a7 7 0 0 0 14 0c0-4-3-7-7-13z" fill="#ff6600" stroke="#ffcc00" stroke-width="1"/></svg>`, label: "Active wildfire / hotspot" },
|
||||
{ svg: clusterCircle("#cc0000", "#ff3300"), label: "Fire cluster (grouped hotspots)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INCIDENTS & INTELLIGENCE",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
@@ -166,6 +187,14 @@ const LEGEND: LegendCategory[] = [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INFRASTRUCTURE",
|
||||
color: "text-purple-400 border-purple-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/></svg>`, label: "Data Center" },
|
||||
{ svg: circle("#ef4444"), label: "Internet Outage Zone" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SURVEILLANCE / CCTV",
|
||||
color: "text-green-400 border-green-500/30",
|
||||
|
||||
@@ -2723,64 +2723,209 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* SENTINEL-2 IMAGERY — floating intel card on map near right-click */}
|
||||
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (
|
||||
<Popup
|
||||
longitude={selectedEntity.extra.lng}
|
||||
latitude={selectedEntity.extra.lat}
|
||||
anchor="top-left"
|
||||
offset={[20, -10]}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
className="sentinel-popup"
|
||||
maxWidth="320px"
|
||||
>
|
||||
<div className="bg-black/90 backdrop-blur-md border border-blue-500/50 rounded-lg overflow-hidden shadow-[0_0_25px_rgba(59,130,246,0.3)] pointer-events-auto" style={{ width: 300 }}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-blue-950/60 border-b border-blue-500/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span className="text-[9px] text-blue-400 font-mono tracking-[0.2em] font-bold">SENTINEL-2 IMAGERY</span>
|
||||
{/* SENTINEL-2 IMAGERY — fullscreen overlay modal */}
|
||||
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (() => {
|
||||
const s2 = regionDossier.sentinel2;
|
||||
const imgUrl = s2.fullres_url || s2.thumbnail_url;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px 20px 20px',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onEntityClick(null); }}
|
||||
onKeyDown={(e: any) => { if (e.key === 'Escape') onEntityClick(null); }}
|
||||
tabIndex={-1}
|
||||
ref={(el) => el?.focus()}
|
||||
>
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.95)',
|
||||
border: '1px solid rgba(59,130,246,0.5)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 'calc(100vw - 40px)',
|
||||
maxHeight: 'calc(100vh - 80px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 0 60px rgba(59,130,246,0.3)',
|
||||
}}>
|
||||
{/* Header bar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(30,58,138,0.4)',
|
||||
borderBottom: '1px solid rgba(59,130,246,0.3)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#60a5fa', animation: 'pulse 2s infinite' }} />
|
||||
<span style={{ fontSize: 11, color: '#60a5fa', fontFamily: 'monospace', letterSpacing: '0.2em', fontWeight: 'bold' }}>
|
||||
SENTINEL-2 IMAGERY
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 10, color: 'rgba(147,197,253,0.6)', fontFamily: 'monospace' }}>
|
||||
{selectedEntity.extra.lat.toFixed(4)}, {selectedEntity.extra.lng.toFixed(4)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onEntityClick(null)}
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#ef4444',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
✕ CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[8px] text-blue-300/60 font-mono">{selectedEntity.extra.lat.toFixed(3)}, {selectedEntity.extra.lng.toFixed(3)}</span>
|
||||
|
||||
{s2.found ? (
|
||||
<>
|
||||
{/* Metadata row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
borderBottom: '1px solid rgba(30,58,138,0.4)',
|
||||
}}>
|
||||
<span style={{ color: '#93c5fd' }}>{s2.platform}</span>
|
||||
<span style={{ color: '#22d3ee', fontWeight: 'bold' }}>{s2.datetime?.slice(0, 10)}</span>
|
||||
<span style={{ color: '#93c5fd' }}>{s2.cloud_cover?.toFixed(0)}% cloud</span>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
{imgUrl ? (
|
||||
<div style={{ flex: 1, overflow: 'auto', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt="Sentinel-2 scene"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(147,197,253,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
|
||||
Scene found — no preview available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{imgUrl && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(30,58,138,0.3)',
|
||||
borderTop: '1px solid rgba(59,130,246,0.2)',
|
||||
}}>
|
||||
<a
|
||||
href={imgUrl}
|
||||
download={`sentinel2_${selectedEntity.extra.lat.toFixed(4)}_${selectedEntity.extra.lng.toFixed(4)}.jpg`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
background: 'rgba(59,130,246,0.2)',
|
||||
border: '1px solid rgba(59,130,246,0.5)',
|
||||
borderRadius: 6,
|
||||
color: '#60a5fa',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
⬇ DOWNLOAD
|
||||
</a>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const resp = await fetch(imgUrl);
|
||||
const blob = await resp.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
]);
|
||||
} catch {
|
||||
// fallback: copy URL
|
||||
await navigator.clipboard.writeText(imgUrl);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(34,211,238,0.15)',
|
||||
border: '1px solid rgba(34,211,238,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#22d3ee',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
📋 COPY
|
||||
</button>
|
||||
<a
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
background: 'rgba(16,185,129,0.15)',
|
||||
border: '1px solid rgba(16,185,129,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#10b981',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
↗ OPEN FULL RES
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(147,197,253,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
|
||||
No clear imagery in last 30 days
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{regionDossier.sentinel2.found ? (
|
||||
<>
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[9px] font-mono border-b border-blue-900/40">
|
||||
<span className="text-blue-300">{regionDossier.sentinel2.platform}</span>
|
||||
<span className="text-cyan-400 font-bold">{regionDossier.sentinel2.datetime?.slice(0, 10)}</span>
|
||||
<span className="text-blue-300">{regionDossier.sentinel2.cloud_cover?.toFixed(0)}% cloud</span>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
{regionDossier.sentinel2.thumbnail_url ? (
|
||||
<a href={regionDossier.sentinel2.fullres_url || regionDossier.sentinel2.thumbnail_url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={regionDossier.sentinel2.thumbnail_url}
|
||||
alt="Sentinel-2 scene"
|
||||
className="w-full block hover:brightness-110 transition-all cursor-pointer"
|
||||
style={{ maxHeight: 220, objectFit: 'cover' }}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">Scene found — no preview available</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-1 bg-blue-950/40 text-[7px] text-blue-400/50 font-mono tracking-widest text-center">
|
||||
CLICK IMAGE TO OPEN FULL RESOLUTION
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">
|
||||
No clear imagery in last 30 days
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* MEASUREMENT LINES */}
|
||||
{measurePoints && measurePoints.length >= 2 && (
|
||||
|
||||
Reference in New Issue
Block a user