diff --git a/src/main.py b/src/main.py index de19369..0876040 100644 --- a/src/main.py +++ b/src/main.py @@ -334,6 +334,7 @@ from routers.public_api import router as public_api_router from routers.chat import router as chat_router from routers.network_analysis import router as network_analysis_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(incidents_router) @@ -344,6 +345,7 @@ app.include_router(public_api_router) app.include_router(chat_router, prefix="/api/chat") app.include_router(network_analysis_router) app.include_router(tutorial_router) +app.include_router(geoint_router, prefix="/api/geoint") @app.websocket("/api/ws") diff --git a/src/routers/geoint.py b/src/routers/geoint.py new file mode 100644 index 0000000..a407211 --- /dev/null +++ b/src/routers/geoint.py @@ -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 diff --git a/src/static/css/geoint.css b/src/static/css/geoint.css new file mode 100644 index 0000000..0e5f2d1 --- /dev/null +++ b/src/static/css/geoint.css @@ -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 + ); +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 0400388..ab0b136 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -19,6 +19,7 @@ +
Zum Hauptinhalt springen @@ -406,15 +407,24 @@