GEOINT-Modus: Experimentelle taktische Kartenansicht mit Echtzeit-Datenlayern
Neuer experimenteller GEOINT-Modus per Checkbox auf der Karten-Kachel: - Satellitenbilder (Esri World Imagery) statt OSM-Strassenkarte - Echtzeit-Flugverkehr (airplanes.live via Backend-Proxy, 15s Refresh) - Erdbeben-Layer (USGS M2.5+, pulsierende Kreise nach Magnitude) - GDELT Nachrichten (geokodierte Echtzeit-News, Cluster-Darstellung) - Heatmap-Visualisierung der Artikel-Standorte (Leaflet.heat) - Timeline-Slider fuer zeitliche Filterung der Artikel-Marker - Koordinatenanzeige (Lat/Lon unter Mauszeiger) - Distanzmessung (Klick-zu-Klick mit km-Anzeige) - Taktisches Styling (dunkle Tonung, gruene Akzente, Scanlines) Neue Dateien: geoint.js, geoint.css, routers/geoint.py Inspiriert von WorldView/Gods Eye Konzept, komplett eigenentwickelt.
Dieser Commit ist enthalten in:
@@ -334,6 +334,7 @@ from routers.public_api import router as public_api_router
|
|||||||
from routers.chat import router as chat_router
|
from routers.chat import router as chat_router
|
||||||
from routers.network_analysis import router as network_analysis_router
|
from routers.network_analysis import router as network_analysis_router
|
||||||
from routers.tutorial import router as tutorial_router
|
from routers.tutorial import router as tutorial_router
|
||||||
|
from routers.geoint import router as geoint_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(incidents_router)
|
app.include_router(incidents_router)
|
||||||
@@ -344,6 +345,7 @@ app.include_router(public_api_router)
|
|||||||
app.include_router(chat_router, prefix="/api/chat")
|
app.include_router(chat_router, prefix="/api/chat")
|
||||||
app.include_router(network_analysis_router)
|
app.include_router(network_analysis_router)
|
||||||
app.include_router(tutorial_router)
|
app.include_router(tutorial_router)
|
||||||
|
app.include_router(geoint_router, prefix="/api/geoint")
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/api/ws")
|
@app.websocket("/api/ws")
|
||||||
|
|||||||
100
src/routers/geoint.py
Normale Datei
100
src/routers/geoint.py
Normale Datei
@@ -0,0 +1,100 @@
|
|||||||
|
"""GEOINT-Router: Proxy fuer externe Echtzeit-Datenquellen (Flugverkehr, GDELT)."""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.geoint")
|
||||||
|
|
||||||
|
router = APIRouter(tags=["geoint"])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Einfacher In-Memory-Cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_cache: dict[str, tuple[float, dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cached(key: str, ttl: float) -> Optional[dict]:
|
||||||
|
if key in _cache:
|
||||||
|
ts, data = _cache[key]
|
||||||
|
if time.time() - ts < ttl:
|
||||||
|
return data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cache(key: str, data: dict):
|
||||||
|
_cache[key] = (time.time(), data)
|
||||||
|
# Cache-Groesse begrenzen (max 50 Eintraege)
|
||||||
|
if len(_cache) > 50:
|
||||||
|
oldest = min(_cache, key=lambda k: _cache[k][0])
|
||||||
|
del _cache[oldest]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flugverkehr (airplanes.live)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/flights")
|
||||||
|
async def get_flights(
|
||||||
|
lat: float = Query(..., ge=-90, le=90),
|
||||||
|
lon: float = Query(..., ge=-180, le=180),
|
||||||
|
radius: int = Query(100, ge=10, le=250),
|
||||||
|
_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Proxy fuer airplanes.live API. 10s Cache, max 300 Aircraft."""
|
||||||
|
cache_key = f"flights:{round(lat, 1)}:{round(lon, 1)}:{radius}"
|
||||||
|
cached = _get_cached(cache_key, ttl=10)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
url = f"https://api.airplanes.live/v2/point/{lat:.4f}/{lon:.4f}/{radius}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"airplanes.live Fehler: {e}")
|
||||||
|
return {"ac": []}
|
||||||
|
|
||||||
|
# Auf 300 Aircraft begrenzen
|
||||||
|
if "ac" in data and len(data["ac"]) > 300:
|
||||||
|
data["ac"] = data["ac"][:300]
|
||||||
|
|
||||||
|
_set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GDELT Nachrichten
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/gdelt")
|
||||||
|
async def get_gdelt(
|
||||||
|
query: str = Query("conflict", max_length=200),
|
||||||
|
_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Proxy fuer GDELT GEO 2.0 API. 60s Cache."""
|
||||||
|
cache_key = f"gdelt:{query[:50]}"
|
||||||
|
cached = _get_cached(cache_key, ttl=60)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
url = (
|
||||||
|
"https://api.gdeltproject.org/api/v2/geo/geo"
|
||||||
|
f"?query={query}&mode=PointData&format=GeoJSON"
|
||||||
|
"×pan=24h&maxrows=200"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=12) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GDELT Fehler: {e}")
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
|
||||||
|
_set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
308
src/static/css/geoint.css
Normale Datei
308
src/static/css/geoint.css
Normale Datei
@@ -0,0 +1,308 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
GEOINT-Modus: Taktische Kartenansicht mit Echtzeit-Datenlayern
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* --- Toggle-Checkbox im Card-Header --- */
|
||||||
|
.geoint-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.geoint-toggle input[type="checkbox"] {
|
||||||
|
accent-color: #00ff88;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.geoint-toggle-label {
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.geoint-toggle input:checked + .geoint-toggle-label {
|
||||||
|
color: #00ff88;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Taktisches Styling (aktiv) --- */
|
||||||
|
.geoint-active .leaflet-tile-pane {
|
||||||
|
filter: brightness(0.88) contrast(1.08) saturate(0.85);
|
||||||
|
transition: filter 0.4s ease;
|
||||||
|
}
|
||||||
|
.geoint-active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 800;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 3px,
|
||||||
|
rgba(0, 255, 100, 0.012) 3px,
|
||||||
|
rgba(0, 255, 100, 0.012) 6px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.geoint-active .map-empty { display: none !important; }
|
||||||
|
|
||||||
|
/* Gruener Akzent am Card-Header wenn aktiv */
|
||||||
|
.map-card.geoint-card-active .card-header {
|
||||||
|
border-bottom: 2px solid rgba(0, 255, 136, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sub-Layer Control Panel --- */
|
||||||
|
.geoint-sub-control {
|
||||||
|
background: rgba(11, 17, 33, 0.92);
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 170px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.geoint-sub-control h4 {
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #00ff88;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 136, 0.15);
|
||||||
|
}
|
||||||
|
.geoint-sub-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.geoint-sub-item input[type="checkbox"] {
|
||||||
|
accent-color: #00ff88;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.geoint-sub-item label {
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.geoint-sub-item label .geoint-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.geoint-dot-flights { background: #00ff88; }
|
||||||
|
.geoint-dot-quakes { background: #ff4444; }
|
||||||
|
.geoint-dot-gdelt { background: #44aaff; }
|
||||||
|
.geoint-dot-heatmap { background: #ff8800; }
|
||||||
|
.geoint-dot-coords { background: #aaaaaa; }
|
||||||
|
.geoint-dot-distance { background: #ffdd00; }
|
||||||
|
.geoint-sub-separator {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Flugzeug-Icons --- */
|
||||||
|
.geoint-aircraft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.geoint-aircraft:hover {
|
||||||
|
filter: drop-shadow(0 0 6px #00ff88);
|
||||||
|
}
|
||||||
|
.geoint-aircraft svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Erdbeben Puls-Animation --- */
|
||||||
|
.geoint-quake-marker {
|
||||||
|
animation: geoint-pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes geoint-pulse {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- GDELT Nachrichtenmarker --- */
|
||||||
|
.geoint-gdelt-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(68, 170, 255, 0.85);
|
||||||
|
border: 1.5px solid rgba(68, 170, 255, 1);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 4px rgba(68, 170, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Koordinatenanzeige --- */
|
||||||
|
.geoint-coord-display {
|
||||||
|
background: rgba(11, 17, 33, 0.88);
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #00ff88;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Distanzmessung --- */
|
||||||
|
.geoint-distance-label {
|
||||||
|
background: rgba(11, 17, 33, 0.9);
|
||||||
|
border: 1px solid rgba(255, 221, 0, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ffdd00;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Timeline-Slider --- */
|
||||||
|
.geoint-timeline {
|
||||||
|
display: none;
|
||||||
|
padding: 6px 12px 8px;
|
||||||
|
background: rgba(11, 17, 33, 0.6);
|
||||||
|
border-top: 1px solid rgba(0, 255, 136, 0.1);
|
||||||
|
}
|
||||||
|
.geoint-active .geoint-timeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.geoint-timeline input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.geoint-timeline input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #00ff88;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(11, 17, 33, 0.8);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 6px rgba(0, 255, 136, 0.5);
|
||||||
|
}
|
||||||
|
.geoint-timeline input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #00ff88;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(11, 17, 33, 0.8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.geoint-timeline-label {
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #00ff88;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.geoint-timeline-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #00ff88;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
}
|
||||||
|
.geoint-timeline-btn:hover {
|
||||||
|
background: rgba(0, 255, 136, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Popup-Styling fuer GEOINT-Layer --- */
|
||||||
|
.geoint-popup {
|
||||||
|
font-family: var(--font-mono, 'Courier New', monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.geoint-popup strong {
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
.geoint-popup .geoint-popup-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.geoint-popup .geoint-popup-key {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Light Theme Overrides --- */
|
||||||
|
[data-theme="light"] .geoint-sub-control {
|
||||||
|
background: rgba(240, 243, 248, 0.95);
|
||||||
|
border-color: rgba(0, 160, 80, 0.25);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-sub-control h4 {
|
||||||
|
color: #008844;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-sub-item label {
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-coord-display {
|
||||||
|
background: rgba(240, 243, 248, 0.92);
|
||||||
|
color: #006633;
|
||||||
|
border-color: rgba(0, 160, 80, 0.25);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-timeline {
|
||||||
|
background: rgba(240, 243, 248, 0.7);
|
||||||
|
border-top-color: rgba(0, 160, 80, 0.15);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-timeline input[type="range"]::-webkit-slider-thumb {
|
||||||
|
background: #008844;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-timeline-label {
|
||||||
|
color: #006633;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-toggle input:checked + .geoint-toggle-label {
|
||||||
|
color: #008844;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-active .leaflet-tile-pane {
|
||||||
|
filter: brightness(0.95) contrast(1.05) saturate(0.9);
|
||||||
|
}
|
||||||
|
[data-theme="light"] .geoint-active::after {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 3px,
|
||||||
|
rgba(0, 100, 50, 0.008) 3px,
|
||||||
|
rgba(0, 100, 50, 0.008) 6px
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/network.css?v=20260316a">
|
<link rel="stylesheet" href="/static/css/network.css?v=20260316a">
|
||||||
<link rel="stylesheet" href="/static/css/network-cluster.css?v=20260322b">
|
<link rel="stylesheet" href="/static/css/network-cluster.css?v=20260322b">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
|
||||||
|
<link rel="stylesheet" href="/static/css/geoint.css?v=20260324a">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||||
@@ -406,15 +407,24 @@
|
|||||||
<div class="card-title">Geografische Verteilung</div>
|
<div class="card-title">Geografische Verteilung</div>
|
||||||
<span class="map-stats" id="map-stats"></span>
|
<span class="map-stats" id="map-stats"></span>
|
||||||
<div class="card-header-actions">
|
<div class="card-header-actions">
|
||||||
|
<label class="geoint-toggle" title="GEOINT-Modus aktivieren">
|
||||||
|
<input type="checkbox" id="geoint-mode-cb" onchange="GEOINT.toggle(this.checked, UI._map)">
|
||||||
|
<span class="geoint-toggle-label">GEOINT</span>
|
||||||
|
</label>
|
||||||
<button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button>
|
<button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button>
|
||||||
<button class="btn btn-secondary btn-small map-expand-btn" id="map-expand-btn" onclick="UI.toggleMapFullscreen()" title="Vollbild" aria-label="Karte im Vollbild anzeigen">
|
<button class="btn btn-secondary btn-small map-expand-btn" id="map-expand-btn" onclick="UI.toggleMapFullscreen()" title="Vollbild" aria-label="Karte im Vollbild anzeigen">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="map-container" id="map-container">
|
<div class="map-container" id="map-container" style="position:relative">
|
||||||
<div class="map-empty" id="map-empty">Keine Orte erkannt</div>
|
<div class="map-empty" id="map-empty">Keine Orte erkannt</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="geoint-timeline" id="geoint-timeline" style="display:none">
|
||||||
|
<button class="geoint-timeline-btn" onclick="GEOINT._resetTimeline()" title="Zuruecksetzen">↺</button>
|
||||||
|
<input type="range" id="geoint-timeline-slider" min="0" max="100" value="100" oninput="GEOINT._onTimelineChange(this.value)">
|
||||||
|
<span class="geoint-timeline-label" id="geoint-timeline-label">--</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -776,6 +786,8 @@
|
|||||||
<script src="/static/js/api.js?v=20260316c"></script>
|
<script src="/static/js/api.js?v=20260316c"></script>
|
||||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||||
<script src="/static/js/components.js?v=20260316d"></script>
|
<script src="/static/js/components.js?v=20260316d"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||||
|
<script src="/static/js/geoint.js?v=20260324a"></script>
|
||||||
<script src="/static/js/layout.js?v=20260316b"></script>
|
<script src="/static/js/layout.js?v=20260316b"></script>
|
||||||
<script src="/static/js/app.js?v=20260316b"></script>
|
<script src="/static/js/app.js?v=20260316b"></script>
|
||||||
<script src="/static/js/api_network.js?v=20260316a"></script>
|
<script src="/static/js/api_network.js?v=20260316a"></script>
|
||||||
@@ -792,6 +804,10 @@
|
|||||||
<div class="map-fullscreen-header">
|
<div class="map-fullscreen-header">
|
||||||
<div class="map-fullscreen-title">Geografische Verteilung</div>
|
<div class="map-fullscreen-title">Geografische Verteilung</div>
|
||||||
<span class="map-stats map-fullscreen-stats" id="map-fullscreen-stats"></span>
|
<span class="map-stats map-fullscreen-stats" id="map-fullscreen-stats"></span>
|
||||||
|
<label class="geoint-toggle" title="GEOINT-Modus">
|
||||||
|
<input type="checkbox" id="geoint-mode-cb-fs" onchange="GEOINT.toggle(this.checked, UI._map)">
|
||||||
|
<span class="geoint-toggle-label">GEOINT</span>
|
||||||
|
</label>
|
||||||
<button class="btn btn-secondary btn-small" onclick="UI.toggleMapFullscreen()" title="Vollbild beenden" aria-label="Vollbild beenden">
|
<button class="btn btn-secondary btn-small" onclick="UI.toggleMapFullscreen()" title="Vollbild beenden" aria-label="Vollbild beenden">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -716,6 +716,9 @@ const UI = {
|
|||||||
|
|
||||||
if (emptyEl) emptyEl.style.display = 'none';
|
if (emptyEl) emptyEl.style.display = 'none';
|
||||||
|
|
||||||
|
// Locations fuer GEOINT merken
|
||||||
|
this._lastLocations = locations;
|
||||||
|
|
||||||
// Statistik
|
// Statistik
|
||||||
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
|
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
|
||||||
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
|
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
|
||||||
@@ -742,6 +745,10 @@ const UI = {
|
|||||||
}).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum
|
}).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum
|
||||||
|
|
||||||
this._applyMapTiles();
|
this._applyMapTiles();
|
||||||
|
|
||||||
|
// GEOINT-Modus wiederherstellen
|
||||||
|
if (typeof GEOINT !== 'undefined') GEOINT.restoreState(this._map);
|
||||||
|
|
||||||
this._mapCluster = L.markerClusterGroup({
|
this._mapCluster = L.markerClusterGroup({
|
||||||
maxClusterRadius: 40,
|
maxClusterRadius: 40,
|
||||||
iconCreateFunction: function(cluster) {
|
iconCreateFunction: function(cluster) {
|
||||||
@@ -853,6 +860,11 @@ const UI = {
|
|||||||
this._mapLegendControl = legend;
|
this._mapLegendControl = legend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GEOINT: Timeline mit Artikel-Daten initialisieren
|
||||||
|
if (typeof GEOINT !== 'undefined' && typeof App !== 'undefined') {
|
||||||
|
GEOINT.initTimeline(App._currentArticles || []);
|
||||||
|
}
|
||||||
|
|
||||||
// Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
|
// Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
|
||||||
const self = this;
|
const self = this;
|
||||||
[100, 300, 800].forEach(delay => {
|
[100, 300, 800].forEach(delay => {
|
||||||
@@ -870,6 +882,8 @@ const UI = {
|
|||||||
|
|
||||||
_applyMapTiles() {
|
_applyMapTiles() {
|
||||||
if (!this._map) return;
|
if (!this._map) return;
|
||||||
|
// Im GEOINT-Modus Tiles nicht ueberschreiben
|
||||||
|
if (typeof GEOINT !== 'undefined' && GEOINT._active) return;
|
||||||
// Alte Tile-Layer entfernen
|
// Alte Tile-Layer entfernen
|
||||||
this._map.eachLayer(layer => {
|
this._map.eachLayer(layer => {
|
||||||
if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
|
if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
|
||||||
|
|||||||
589
src/static/js/geoint.js
Normale Datei
589
src/static/js/geoint.js
Normale Datei
@@ -0,0 +1,589 @@
|
|||||||
|
/**
|
||||||
|
* GEOINT-Modus: Taktische Kartenansicht mit Echtzeit-Datenlayern.
|
||||||
|
* Eigenstaendiges Modul — alle GEOINT-Logik gekapselt.
|
||||||
|
*/
|
||||||
|
const GEOINT = {
|
||||||
|
_active: false,
|
||||||
|
_map: null,
|
||||||
|
_sublayers: {},
|
||||||
|
_flightLayer: null,
|
||||||
|
_quakeLayer: null,
|
||||||
|
_gdeltLayer: null,
|
||||||
|
_heatLayer: null,
|
||||||
|
_flightInterval: null,
|
||||||
|
_quakeInterval: null,
|
||||||
|
_gdeltInterval: null,
|
||||||
|
_flightFetching: false,
|
||||||
|
_moveHandler: null,
|
||||||
|
_coordControl: null,
|
||||||
|
_coordHandler: null,
|
||||||
|
_distanceActive: false,
|
||||||
|
_distancePoints: [],
|
||||||
|
_distanceLayers: null,
|
||||||
|
_distanceHandler: null,
|
||||||
|
_subControl: null,
|
||||||
|
_osmTileLayer: null,
|
||||||
|
_satTileLayer: null,
|
||||||
|
_satLabelLayer: null,
|
||||||
|
_timelineData: null,
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hauptschalter
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
toggle(enabled, map) {
|
||||||
|
this._active = enabled;
|
||||||
|
this._map = map;
|
||||||
|
|
||||||
|
// CSS-Klassen
|
||||||
|
var container = document.getElementById('map-container');
|
||||||
|
if (container) container.classList.toggle('geoint-active', enabled);
|
||||||
|
var fsContainer = document.getElementById('map-fullscreen-container');
|
||||||
|
if (fsContainer) fsContainer.classList.toggle('geoint-active', enabled);
|
||||||
|
var card = container ? container.closest('.map-card') : null;
|
||||||
|
if (card) card.classList.toggle('geoint-card-active', enabled);
|
||||||
|
|
||||||
|
// Sync beider Checkboxen
|
||||||
|
var cb1 = document.getElementById('geoint-mode-cb');
|
||||||
|
var cb2 = document.getElementById('geoint-mode-cb-fs');
|
||||||
|
if (cb1) cb1.checked = enabled;
|
||||||
|
if (cb2) cb2.checked = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
this._applySatelliteTiles(map);
|
||||||
|
this._createSubControl(map);
|
||||||
|
this._restoreSublayers(map);
|
||||||
|
// Timeline anzeigen wenn Daten vorhanden
|
||||||
|
var tl = document.getElementById('geoint-timeline');
|
||||||
|
if (tl) tl.style.display = '';
|
||||||
|
} else {
|
||||||
|
this.cleanup();
|
||||||
|
this._restoreOsmTiles(map);
|
||||||
|
var tl = document.getElementById('geoint-timeline');
|
||||||
|
if (tl) tl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
this._saveState();
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Tile-Management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_applySatelliteTiles(map) {
|
||||||
|
if (!map) return;
|
||||||
|
// Bestehende Tile-Layer entfernen
|
||||||
|
map.eachLayer(function(layer) {
|
||||||
|
if (layer instanceof L.TileLayer) map.removeLayer(layer);
|
||||||
|
});
|
||||||
|
this._satTileLayer = L.tileLayer(
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
{ attribution: '© Esri — Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, GIS User Community',
|
||||||
|
maxZoom: 18, noWrap: true }
|
||||||
|
).addTo(map);
|
||||||
|
this._satLabelLayer = L.tileLayer(
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
{ maxZoom: 18, noWrap: true, pane: 'overlayPane' }
|
||||||
|
).addTo(map);
|
||||||
|
},
|
||||||
|
|
||||||
|
_restoreOsmTiles(map) {
|
||||||
|
if (!map) return;
|
||||||
|
map.eachLayer(function(layer) {
|
||||||
|
if (layer instanceof L.TileLayer) map.removeLayer(layer);
|
||||||
|
});
|
||||||
|
// UI._applyMapTiles() wiederherstellen
|
||||||
|
if (typeof UI !== 'undefined' && UI._applyMapTiles) {
|
||||||
|
UI._applyMapTiles();
|
||||||
|
} else {
|
||||||
|
L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
maxZoom: 18, noWrap: true
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Sub-Layer Control Panel
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_createSubControl(map) {
|
||||||
|
if (this._subControl) return;
|
||||||
|
var self = this;
|
||||||
|
var SubControl = L.Control.extend({
|
||||||
|
options: { position: 'topright' },
|
||||||
|
onAdd: function() {
|
||||||
|
var div = L.DomUtil.create('div', 'geoint-sub-control');
|
||||||
|
L.DomEvent.disableClickPropagation(div);
|
||||||
|
L.DomEvent.disableScrollPropagation(div);
|
||||||
|
div.innerHTML =
|
||||||
|
'<h4>GEOINT Layer</h4>' +
|
||||||
|
self._subItemHtml('flights', 'Flugverkehr', 'flights') +
|
||||||
|
self._subItemHtml('quakes', 'Erdbeben', 'quakes') +
|
||||||
|
self._subItemHtml('gdelt', 'Nachrichten', 'gdelt') +
|
||||||
|
'<div class="geoint-sub-separator"></div>' +
|
||||||
|
self._subItemHtml('heatmap', 'Heatmap', 'heatmap') +
|
||||||
|
self._subItemHtml('coords', 'Koordinaten', 'coords') +
|
||||||
|
self._subItemHtml('distance', 'Distanz', 'distance');
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._subControl = new SubControl();
|
||||||
|
map.addControl(this._subControl);
|
||||||
|
|
||||||
|
// Event-Listener fuer Sub-Checkboxen
|
||||||
|
['flights', 'quakes', 'gdelt', 'heatmap', 'coords', 'distance'].forEach(function(id) {
|
||||||
|
var cb = document.getElementById('geoint-sub-' + id);
|
||||||
|
if (cb) {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
self._toggleSublayer(id, this.checked, map);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_subItemHtml(id, label, dotClass) {
|
||||||
|
var checked = this._sublayers[id] ? ' checked' : '';
|
||||||
|
return '<div class="geoint-sub-item">' +
|
||||||
|
'<input type="checkbox" id="geoint-sub-' + id + '"' + checked + '>' +
|
||||||
|
'<label for="geoint-sub-' + id + '"><span class="geoint-dot geoint-dot-' + dotClass + '"></span>' + label + '</label>' +
|
||||||
|
'</div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
_removeSubControl() {
|
||||||
|
if (this._subControl && this._map) {
|
||||||
|
this._map.removeControl(this._subControl);
|
||||||
|
this._subControl = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_toggleSublayer(id, enabled, map) {
|
||||||
|
this._sublayers[id] = enabled;
|
||||||
|
this._saveState();
|
||||||
|
switch (id) {
|
||||||
|
case 'flights': enabled ? this._startFlights(map) : this._stopFlights(); break;
|
||||||
|
case 'quakes': enabled ? this._startQuakes(map) : this._stopQuakes(); break;
|
||||||
|
case 'gdelt': enabled ? this._startGdelt(map) : this._stopGdelt(); break;
|
||||||
|
case 'heatmap': enabled ? this._startHeatmap(map) : this._stopHeatmap(); break;
|
||||||
|
case 'coords': enabled ? this._startCoords(map) : this._stopCoords(); break;
|
||||||
|
case 'distance': enabled ? this._startDistance(map) : this._stopDistance(); break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_restoreSublayers(map) {
|
||||||
|
var self = this;
|
||||||
|
Object.keys(this._sublayers).forEach(function(id) {
|
||||||
|
if (self._sublayers[id]) {
|
||||||
|
self._toggleSublayer(id, true, map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Layer: Flugverkehr
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_startFlights(map) {
|
||||||
|
if (this._flightLayer) return;
|
||||||
|
this._flightLayer = L.layerGroup().addTo(map);
|
||||||
|
var self = this;
|
||||||
|
this._fetchFlights(map);
|
||||||
|
this._flightInterval = setInterval(function() { self._fetchFlights(map); }, 15000);
|
||||||
|
// Bei Kartenbewegung neu laden
|
||||||
|
this._moveHandler = function() {
|
||||||
|
clearTimeout(self._moveDebounce);
|
||||||
|
self._moveDebounce = setTimeout(function() { self._fetchFlights(map); }, 600);
|
||||||
|
};
|
||||||
|
map.on('moveend', this._moveHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopFlights() {
|
||||||
|
if (this._flightInterval) { clearInterval(this._flightInterval); this._flightInterval = null; }
|
||||||
|
if (this._moveHandler && this._map) { this._map.off('moveend', this._moveHandler); this._moveHandler = null; }
|
||||||
|
if (this._flightLayer && this._map) { this._map.removeLayer(this._flightLayer); this._flightLayer = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
_fetchFlights(map) {
|
||||||
|
if (this._flightFetching || !map || map.getZoom() < 5) return;
|
||||||
|
this._flightFetching = true;
|
||||||
|
var center = map.getCenter();
|
||||||
|
var bounds = map.getBounds();
|
||||||
|
// Radius in nm (grob: 1 Grad Lat ≈ 60nm)
|
||||||
|
var latDiff = Math.abs(bounds.getNorth() - bounds.getSouth()) / 2;
|
||||||
|
var radius = Math.min(Math.round(latDiff * 60), 250);
|
||||||
|
var self = this;
|
||||||
|
var token = localStorage.getItem('osint_token') || '';
|
||||||
|
|
||||||
|
fetch('/api/geoint/flights?lat=' + center.lat.toFixed(4) + '&lon=' + center.lng.toFixed(4) + '&radius=' + radius, {
|
||||||
|
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!self._flightLayer) return;
|
||||||
|
self._flightLayer.clearLayers();
|
||||||
|
var ac = data.ac || data.aircraft || [];
|
||||||
|
ac.slice(0, 300).forEach(function(a) {
|
||||||
|
if (!a.lat || !a.lon) return;
|
||||||
|
var heading = a.track || a.true_heading || 0;
|
||||||
|
var icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: '<div class="geoint-aircraft" style="transform:rotate(' + heading + 'deg)">' +
|
||||||
|
'<svg viewBox="0 0 24 24" fill="#00ff88" stroke="#004422" stroke-width="1">' +
|
||||||
|
'<path d="M12 2L8 10h-4l2 4-2 4h4l4 4 4-4h4l-2-4 2-4h-4z"/>' +
|
||||||
|
'</svg></div>',
|
||||||
|
iconSize: [14, 14],
|
||||||
|
iconAnchor: [7, 7],
|
||||||
|
});
|
||||||
|
var callsign = (a.flight || a.callsign || a.hex || '???').trim();
|
||||||
|
var alt = a.alt_baro || a.altitude || '?';
|
||||||
|
var spd = a.gs || a.ground_speed || '?';
|
||||||
|
var typ = a.t || a.type || '';
|
||||||
|
var popup = '<div class="geoint-popup">' +
|
||||||
|
'<strong>' + callsign + '</strong>' +
|
||||||
|
(typ ? ' <span style="opacity:0.5">(' + typ + ')</span>' : '') +
|
||||||
|
'<br><span class="geoint-popup-key">ALT</span> ' + (typeof alt === 'number' ? alt.toLocaleString() + ' ft' : alt) +
|
||||||
|
'<br><span class="geoint-popup-key">SPD</span> ' + (typeof spd === 'number' ? Math.round(spd) + ' kts' : spd) +
|
||||||
|
'<br><span class="geoint-popup-key">HDG</span> ' + Math.round(heading) + '°' +
|
||||||
|
'</div>';
|
||||||
|
L.marker([a.lat, a.lon], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(self._flightLayer);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT flights:', e); })
|
||||||
|
.finally(function() { self._flightFetching = false; });
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Layer: Erdbeben
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_startQuakes(map) {
|
||||||
|
if (this._quakeLayer) return;
|
||||||
|
this._quakeLayer = L.layerGroup().addTo(map);
|
||||||
|
this._fetchQuakes(map);
|
||||||
|
var self = this;
|
||||||
|
this._quakeInterval = setInterval(function() { self._fetchQuakes(map); }, 300000); // 5 min
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopQuakes() {
|
||||||
|
if (this._quakeInterval) { clearInterval(this._quakeInterval); this._quakeInterval = null; }
|
||||||
|
if (this._quakeLayer && this._map) { this._map.removeLayer(this._quakeLayer); this._quakeLayer = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
_fetchQuakes(map) {
|
||||||
|
if (!this._quakeLayer) return;
|
||||||
|
var self = this;
|
||||||
|
fetch('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!self._quakeLayer) return;
|
||||||
|
self._quakeLayer.clearLayers();
|
||||||
|
var now = Date.now();
|
||||||
|
(data.features || []).forEach(function(f) {
|
||||||
|
var coords = f.geometry.coordinates;
|
||||||
|
var p = f.properties;
|
||||||
|
var mag = p.mag || 1;
|
||||||
|
var ageH = (now - p.time) / 3600000;
|
||||||
|
var color = ageH < 1 ? '#ff0000' : ageH < 6 ? '#ff6600' : ageH < 12 ? '#ffaa00' : '#ffdd00';
|
||||||
|
var radius = Math.max(mag * 3.5, 5);
|
||||||
|
var cls = ageH < 2 ? 'geoint-quake-marker' : '';
|
||||||
|
var marker = L.circleMarker([coords[1], coords[0]], {
|
||||||
|
radius: radius,
|
||||||
|
fillColor: color,
|
||||||
|
color: color,
|
||||||
|
weight: 1.5,
|
||||||
|
fillOpacity: 0.6,
|
||||||
|
className: cls
|
||||||
|
});
|
||||||
|
var timeStr = new Date(p.time).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
marker.bindPopup(
|
||||||
|
'<div class="geoint-popup">' +
|
||||||
|
'<strong>M' + mag.toFixed(1) + '</strong> ' + (p.place || '') +
|
||||||
|
'<br><span class="geoint-popup-key">TIEFE</span> ' + (coords[2] || '?') + ' km' +
|
||||||
|
'<br><span class="geoint-popup-key">ZEIT</span> ' + timeStr +
|
||||||
|
'</div>', { className: 'geoint-leaflet-popup' }
|
||||||
|
);
|
||||||
|
marker.addTo(self._quakeLayer);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT quakes:', e); });
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Layer: GDELT Nachrichten
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_startGdelt(map) {
|
||||||
|
if (this._gdeltLayer) return;
|
||||||
|
this._gdeltLayer = L.markerClusterGroup({
|
||||||
|
maxClusterRadius: 30,
|
||||||
|
iconCreateFunction: function(cluster) {
|
||||||
|
var count = cluster.getChildCount();
|
||||||
|
return L.divIcon({
|
||||||
|
html: '<div class="geoint-gdelt-icon">' + (count > 99 ? '99+' : count) + '</div>',
|
||||||
|
className: '',
|
||||||
|
iconSize: [22, 22],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).addTo(map);
|
||||||
|
this._fetchGdelt(map);
|
||||||
|
var self = this;
|
||||||
|
this._gdeltInterval = setInterval(function() { self._fetchGdelt(map); }, 600000); // 10 min
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopGdelt() {
|
||||||
|
if (this._gdeltInterval) { clearInterval(this._gdeltInterval); this._gdeltInterval = null; }
|
||||||
|
if (this._gdeltLayer && this._map) { this._map.removeLayer(this._gdeltLayer); this._gdeltLayer = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
_fetchGdelt(map) {
|
||||||
|
if (!this._gdeltLayer) return;
|
||||||
|
var self = this;
|
||||||
|
// Holt Lage-Kontext fuer GDELT-Suche
|
||||||
|
var query = '';
|
||||||
|
if (typeof App !== 'undefined' && App.currentIncidentId) {
|
||||||
|
var inc = (App.incidents || []).find(function(i) { return i.id === App.currentIncidentId; });
|
||||||
|
if (inc) query = encodeURIComponent((inc.title || '').substring(0, 80));
|
||||||
|
}
|
||||||
|
if (!query) query = 'conflict OR crisis OR disaster';
|
||||||
|
var token = localStorage.getItem('osint_token') || '';
|
||||||
|
|
||||||
|
fetch('/api/geoint/gdelt?query=' + query, {
|
||||||
|
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!self._gdeltLayer) return;
|
||||||
|
self._gdeltLayer.clearLayers();
|
||||||
|
var features = data.features || [];
|
||||||
|
features.slice(0, 200).forEach(function(f) {
|
||||||
|
var coords = f.geometry.coordinates;
|
||||||
|
var p = f.properties || {};
|
||||||
|
var name = p.name || p.title || 'Nachricht';
|
||||||
|
var url = p.url || p.shareimage || '';
|
||||||
|
var icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: '<div class="geoint-gdelt-icon">N</div>',
|
||||||
|
iconSize: [18, 18],
|
||||||
|
iconAnchor: [9, 9],
|
||||||
|
});
|
||||||
|
var popup = '<div class="geoint-popup" style="max-width:240px">' +
|
||||||
|
'<strong>' + name.substring(0, 100) + '</strong>' +
|
||||||
|
(url ? '<br><a href="' + url + '" target="_blank" rel="noopener" style="color:#44aaff;font-size:10px">Quelle</a>' : '') +
|
||||||
|
'</div>';
|
||||||
|
L.marker([coords[1], coords[0]], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(self._gdeltLayer);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT gdelt:', e); });
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Layer: Heatmap
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_startHeatmap(map) {
|
||||||
|
if (this._heatLayer) return;
|
||||||
|
if (typeof L.heatLayer === 'undefined') {
|
||||||
|
if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT: leaflet-heat nicht geladen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Locations aus UI-State holen
|
||||||
|
var locations = (typeof UI !== 'undefined' && UI._lastLocations) ? UI._lastLocations : [];
|
||||||
|
if (!locations.length) return;
|
||||||
|
var maxCount = Math.max.apply(null, locations.map(function(l) { return l.article_count || 1; }));
|
||||||
|
var points = locations.map(function(l) {
|
||||||
|
return [l.lat, l.lon, (l.article_count || 1) / maxCount];
|
||||||
|
});
|
||||||
|
this._heatLayer = L.heatLayer(points, {
|
||||||
|
radius: 30,
|
||||||
|
blur: 20,
|
||||||
|
maxZoom: 12,
|
||||||
|
gradient: { 0.2: '#004400', 0.4: '#00ff88', 0.6: '#ffaa00', 0.8: '#ff4400', 1.0: '#ff0000' }
|
||||||
|
}).addTo(map);
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopHeatmap() {
|
||||||
|
if (this._heatLayer && this._map) { this._map.removeLayer(this._heatLayer); this._heatLayer = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Koordinatenanzeige
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_startCoords(map) {
|
||||||
|
if (this._coordControl) return;
|
||||||
|
var CoordControl = L.Control.extend({
|
||||||
|
options: { position: 'bottomleft' },
|
||||||
|
onAdd: function() {
|
||||||
|
var div = L.DomUtil.create('div', 'geoint-coord-display');
|
||||||
|
div.id = 'geoint-coord-text';
|
||||||
|
div.textContent = 'LAT: -- LON: --';
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._coordControl = new CoordControl();
|
||||||
|
map.addControl(this._coordControl);
|
||||||
|
var el = document.getElementById('geoint-coord-text');
|
||||||
|
this._coordHandler = function(e) {
|
||||||
|
if (el) el.textContent = 'LAT: ' + e.latlng.lat.toFixed(4) + ' LON: ' + e.latlng.lng.toFixed(4);
|
||||||
|
};
|
||||||
|
map.on('mousemove', this._coordHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopCoords() {
|
||||||
|
if (this._coordHandler && this._map) { this._map.off('mousemove', this._coordHandler); this._coordHandler = null; }
|
||||||
|
if (this._coordControl && this._map) { this._map.removeControl(this._coordControl); this._coordControl = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Distanzmessung
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_startDistance(map) {
|
||||||
|
if (this._distanceLayers) return;
|
||||||
|
this._distanceLayers = L.layerGroup().addTo(map);
|
||||||
|
this._distancePoints = [];
|
||||||
|
this._distanceActive = true;
|
||||||
|
map.getContainer().style.cursor = 'crosshair';
|
||||||
|
var self = this;
|
||||||
|
this._distanceHandler = function(e) {
|
||||||
|
self._distancePoints.push(e.latlng);
|
||||||
|
if (self._distancePoints.length >= 2) {
|
||||||
|
var p1 = self._distancePoints[self._distancePoints.length - 2];
|
||||||
|
var p2 = self._distancePoints[self._distancePoints.length - 1];
|
||||||
|
L.polyline([p1, p2], { color: '#ffdd00', weight: 2, dashArray: '6 4' }).addTo(self._distanceLayers);
|
||||||
|
var dist = p1.distanceTo(p2);
|
||||||
|
var totalDist = 0;
|
||||||
|
for (var i = 1; i < self._distancePoints.length; i++) {
|
||||||
|
totalDist += self._distancePoints[i - 1].distanceTo(self._distancePoints[i]);
|
||||||
|
}
|
||||||
|
var label = dist >= 1000
|
||||||
|
? (dist / 1000).toFixed(1) + ' km'
|
||||||
|
: Math.round(dist) + ' m';
|
||||||
|
var totalLabel = totalDist >= 1000
|
||||||
|
? (totalDist / 1000).toFixed(1) + ' km'
|
||||||
|
: Math.round(totalDist) + ' m';
|
||||||
|
var midLat = (p1.lat + p2.lat) / 2;
|
||||||
|
var midLng = (p1.lng + p2.lng) / 2;
|
||||||
|
var text = self._distancePoints.length > 2
|
||||||
|
? label + ' (Σ ' + totalLabel + ')'
|
||||||
|
: label;
|
||||||
|
L.marker([midLat, midLng], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: '<div class="geoint-distance-label">' + text + '</div>',
|
||||||
|
iconSize: [0, 0],
|
||||||
|
iconAnchor: [0, 12],
|
||||||
|
})
|
||||||
|
}).addTo(self._distanceLayers);
|
||||||
|
}
|
||||||
|
// Punkt-Marker
|
||||||
|
L.circleMarker(e.latlng, { radius: 4, fillColor: '#ffdd00', color: '#ffdd00', fillOpacity: 1, weight: 1 }).addTo(self._distanceLayers);
|
||||||
|
};
|
||||||
|
map.on('click', this._distanceHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopDistance() {
|
||||||
|
this._distanceActive = false;
|
||||||
|
this._distancePoints = [];
|
||||||
|
if (this._distanceHandler && this._map) {
|
||||||
|
this._map.off('click', this._distanceHandler);
|
||||||
|
this._distanceHandler = null;
|
||||||
|
this._map.getContainer().style.cursor = '';
|
||||||
|
}
|
||||||
|
if (this._distanceLayers && this._map) { this._map.removeLayer(this._distanceLayers); this._distanceLayers = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Timeline-Slider
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
initTimeline(articles) {
|
||||||
|
if (!articles || !articles.length) return;
|
||||||
|
var dates = articles
|
||||||
|
.map(function(a) { return a.collected_at || a.published_at; })
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(function(d) { return new Date(d).getTime(); })
|
||||||
|
.filter(function(t) { return !isNaN(t); })
|
||||||
|
.sort(function(a, b) { return a - b; });
|
||||||
|
if (dates.length < 2) return;
|
||||||
|
|
||||||
|
this._timelineData = { min: dates[0], max: dates[dates.length - 1], articles: articles };
|
||||||
|
var slider = document.getElementById('geoint-timeline-slider');
|
||||||
|
var label = document.getElementById('geoint-timeline-label');
|
||||||
|
if (!slider) return;
|
||||||
|
slider.min = dates[0];
|
||||||
|
slider.max = dates[dates.length - 1];
|
||||||
|
slider.value = dates[dates.length - 1];
|
||||||
|
if (label) label.textContent = this._formatTimelineDate(dates[dates.length - 1]);
|
||||||
|
},
|
||||||
|
|
||||||
|
_onTimelineChange(value) {
|
||||||
|
var label = document.getElementById('geoint-timeline-label');
|
||||||
|
if (label) label.textContent = this._formatTimelineDate(parseInt(value));
|
||||||
|
this._filterMarkersByTime(parseInt(value));
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatTimelineDate(ts) {
|
||||||
|
var d = new Date(ts);
|
||||||
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ' ' +
|
||||||
|
d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
|
||||||
|
_filterMarkersByTime(maxTime) {
|
||||||
|
// Filtert die bestehenden Marker im Cluster anhand des Zeitstempels
|
||||||
|
if (!this._map || !UI._mapCluster || !this._timelineData) return;
|
||||||
|
var articles = this._timelineData.articles;
|
||||||
|
var visibleIds = new Set();
|
||||||
|
articles.forEach(function(a) {
|
||||||
|
var t = new Date(a.collected_at || a.published_at || 0).getTime();
|
||||||
|
if (t <= maxTime) visibleIds.add(a.id);
|
||||||
|
});
|
||||||
|
// Marker sichtbar/unsichtbar machen ueber Opacity
|
||||||
|
UI._mapCluster.eachLayer(function(marker) {
|
||||||
|
if (marker._articleIds) {
|
||||||
|
var visible = marker._articleIds.some(function(id) { return visibleIds.has(id); });
|
||||||
|
marker.setOpacity(visible ? 1 : 0.08);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_resetTimeline() {
|
||||||
|
var slider = document.getElementById('geoint-timeline-slider');
|
||||||
|
if (slider && this._timelineData) {
|
||||||
|
slider.value = this._timelineData.max;
|
||||||
|
this._onTimelineChange(this._timelineData.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cleanup
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
cleanup() {
|
||||||
|
this._stopFlights();
|
||||||
|
this._stopQuakes();
|
||||||
|
this._stopGdelt();
|
||||||
|
this._stopHeatmap();
|
||||||
|
this._stopCoords();
|
||||||
|
this._stopDistance();
|
||||||
|
this._removeSubControl();
|
||||||
|
// CSS-Klassen entfernen
|
||||||
|
var container = document.getElementById('map-container');
|
||||||
|
if (container) container.classList.remove('geoint-active');
|
||||||
|
var fsContainer = document.getElementById('map-fullscreen-container');
|
||||||
|
if (fsContainer) fsContainer.classList.remove('geoint-active');
|
||||||
|
},
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// State Persistenz
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
_saveState() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('geoint_mode', this._active ? 'true' : 'false');
|
||||||
|
localStorage.setItem('geoint_sublayers', JSON.stringify(this._sublayers));
|
||||||
|
} catch (e) { /* quota exceeded */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreState(map) {
|
||||||
|
if (!map) return;
|
||||||
|
this._map = map;
|
||||||
|
try {
|
||||||
|
var savedSublayers = localStorage.getItem('geoint_sublayers');
|
||||||
|
if (savedSublayers) this._sublayers = JSON.parse(savedSublayers);
|
||||||
|
} catch (e) { this._sublayers = {}; }
|
||||||
|
|
||||||
|
var wasActive = localStorage.getItem('geoint_mode') === 'true';
|
||||||
|
if (wasActive) {
|
||||||
|
this.toggle(true, map);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren