From 7f09375aede04654317e9bdc32b66c707f8ed466 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Tue, 24 Mar 2026 10:25:46 +0100 Subject: [PATCH] GEOINT: Globaler Flugverkehr + Schiffsverkehr-Layer Flugverkehr: Globaler Snapshot ueber 29 Stuetzpunkte weltweit. Backend aggregiert parallel, 30s Cache, kein Flackern (atomarer Swap). Keine regionale Begrenzung mehr. Schiffsverkehr: Neuer Layer via Digitraffic AIS API (kostenlos, kein Key). 18.000+ Schiffe global, 60s Refresh. Blaue Schiffs-Icons mit Heading-Rotation. Popup zeigt MMSI, SOG, COG, Navigationsstatus. Backend: Batch-Fetching mit asyncio.Lock gegen Race Conditions. --- src/routers/geoint.py | 196 +++++++++++++++++++++++++++++++++----- src/static/css/geoint.css | 12 +++ src/static/js/geoint.js | 86 ++++++++++++++--- 3 files changed, 256 insertions(+), 38 deletions(-) diff --git a/src/routers/geoint.py b/src/routers/geoint.py index 65dd7ce..40fd76a 100644 --- a/src/routers/geoint.py +++ b/src/routers/geoint.py @@ -1,4 +1,5 @@ """GEOINT-Router: Proxy fuer externe Echtzeit-Datenquellen (Flugverkehr, GDELT).""" +import asyncio import logging import time from typing import Optional @@ -28,44 +29,187 @@ def _get_cached(key: str, ttl: float) -> Optional[dict]: 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) +# Flugverkehr: Globaler Snapshot (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*2)/2:.1f}:{round(lon*2)/2:.1f}:{radius}" - cached = _get_cached(cache_key, ttl=20) + +# Stuetzpunkte fuer globale Abdeckung (je 250nm Radius ≈ 460km) +# Abdeckt: Europa, Naher Osten, Nordafrika, Nordamerika, Ostasien +_FLIGHT_GRID = [ + # Europa + (48.0, 2.0), # Westeuropa (Paris) + (48.0, 16.0), # Mitteleuropa (Wien) + (55.0, 10.0), # Nordeuropa (Daenemark) + (40.0, -4.0), # Iberische Halbinsel + (41.0, 12.0), # Suedeuropa (Rom) + (38.0, 24.0), # Suedosteuropa (Griechenland) + (55.0, 25.0), # Baltikum + (60.0, 25.0), # Skandinavien-Ost + (52.0, 30.0), # Osteuropa + # Naher Osten + (33.0, 36.0), # Levante + (25.0, 45.0), # Arabien + (33.0, 52.0), # Iran + # Nordafrika + (34.0, 2.0), # Maghreb + (30.0, 31.0), # Aegypten + # UK / Island + (54.0, -2.0), # UK + (63.0, -19.0), # Island + # Nordamerika Ostkueste + (40.0, -74.0), # New York + (33.0, -84.0), # Atlanta + (42.0, -88.0), # Chicago + # Nordamerika Westkueste + (34.0, -118.0), # Los Angeles + (47.0, -122.0), # Seattle + # Ostasien + (35.0, 140.0), # Japan + (37.0, 127.0), # Korea + (31.0, 121.0), # Shanghai + (22.0, 114.0), # Hongkong + # Suedasien + (19.0, 73.0), # Mumbai + (28.0, 77.0), # Delhi + # Suedostasien + (1.0, 104.0), # Singapur + (14.0, 101.0), # Bangkok +] + +_flight_lock = asyncio.Lock() + + +async def _fetch_global_flights() -> dict: + """Holt Flugdaten fuer alle Stuetzpunkte parallel.""" + cached = _get_cached("flights_global", ttl=30) 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": []} + async with _flight_lock: + # Doppelcheck nach Lock + cached = _get_cached("flights_global", ttl=30) + if cached: + return cached - # Auf 300 Aircraft begrenzen - if "ac" in data and len(data["ac"]) > 300: - data["ac"] = data["ac"][:300] + seen: dict[str, dict] = {} + errors = 0 - _set_cache(cache_key, data) - return data + async with httpx.AsyncClient(timeout=10) as client: + # In Batches von 8 um Rate-Limits zu vermeiden + for i in range(0, len(_FLIGHT_GRID), 8): + batch = _FLIGHT_GRID[i:i + 8] + tasks = [] + for lat, lon in batch: + url = f"https://api.airplanes.live/v2/point/{lat:.2f}/{lon:.2f}/250" + tasks.append(client.get(url)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for r in results: + if isinstance(r, Exception): + errors += 1 + continue + try: + data = r.json() + for ac in data.get("ac", []): + hex_id = ac.get("hex") + if hex_id and hex_id not in seen: + seen[hex_id] = ac + except Exception: + errors += 1 + + # Kurze Pause zwischen Batches + if i + 8 < len(_FLIGHT_GRID): + await asyncio.sleep(0.3) + + result = {"ac": list(seen.values()), "total": len(seen), "errors": errors} + logger.info( + f"GEOINT Flights: {len(seen)} Flugzeuge aus {len(_FLIGHT_GRID)} Punkten" + f" ({errors} Fehler)" + ) + _set_cache("flights_global", result) + return result + + +@router.get("/flights") +async def get_flights( + _user: dict = Depends(get_current_user), +): + """Globaler Flugverkehr-Snapshot. 30s Cache, dedupliziert.""" + return await _fetch_global_flights() + + +# --------------------------------------------------------------------------- +# Schiffsverkehr: Digitraffic AIS (kostenlos, global, kein API-Key) +# --------------------------------------------------------------------------- + +_ships_lock = asyncio.Lock() + + +async def _fetch_global_ships() -> dict: + """Holt globale AIS-Schiffspositionen von Digitraffic.""" + cached = _get_cached("ships_global", ttl=60) + if cached: + return cached + + async with _ships_lock: + cached = _get_cached("ships_global", ttl=60) + if cached: + return cached + + url = "https://meri.digitraffic.fi/api/ais/v1/locations" + try: + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.get( + url, + headers={ + "Digitraffic-User": "AegisSight-GEOINT", + "Accept-Encoding": "gzip", + }, + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning(f"Digitraffic AIS Fehler: {e}") + return {"features": [], "total": 0} + + features = data.get("features", []) + # Nur Schiffe mit gueltigen Koordinaten und Bewegung (sog > 0.5 kn) + ships = [] + for f in features: + geom = f.get("geometry") + props = f.get("properties", {}) + if not geom or not geom.get("coordinates"): + continue + lon, lat = geom["coordinates"] + if not (-180 <= lon <= 180 and -90 <= lat <= 90): + continue + ships.append({ + "mmsi": props.get("mmsi"), + "lat": lat, + "lon": lon, + "sog": props.get("sog", 0), + "cog": props.get("cog", 0), + "heading": props.get("heading", 0), + "navStat": props.get("navStat", 0), + }) + + result = {"ships": ships, "total": len(ships)} + logger.info(f"GEOINT Ships: {len(ships)} Schiffe geladen") + _set_cache("ships_global", result) + return result + + +@router.get("/ships") +async def get_ships( + _user: dict = Depends(get_current_user), +): + """Globaler Schiffsverkehr-Snapshot. 60s Cache.""" + return await _fetch_global_ships() # --------------------------------------------------------------------------- diff --git a/src/static/css/geoint.css b/src/static/css/geoint.css index bbe08b7..b8d9c2c 100644 --- a/src/static/css/geoint.css +++ b/src/static/css/geoint.css @@ -95,6 +95,7 @@ vertical-align: middle; } .geoint-dot-flights { background: #00ff88; } +.geoint-dot-ships { background: #4499ff; } .geoint-dot-quakes { background: #ff4444; } .geoint-dot-gdelt { background: #44aaff; } .geoint-dot-heatmap { background: #ff8800; } @@ -121,6 +122,17 @@ height: 14px; } +/* --- Schiffs-Icons --- */ +.geoint-ship { + display: flex; + align-items: center; + justify-content: center; + transition: filter 0.15s; +} +.geoint-ship:hover { + filter: drop-shadow(0 0 4px #4499ff); +} + /* --- Erdbeben Puls-Animation --- */ .geoint-quake-marker { animation: geoint-pulse 2.5s ease-in-out infinite; diff --git a/src/static/js/geoint.js b/src/static/js/geoint.js index 69c9503..1979acc 100644 --- a/src/static/js/geoint.js +++ b/src/static/js/geoint.js @@ -13,6 +13,8 @@ const GEOINT = { _flightInterval: null, _quakeInterval: null, _gdeltInterval: null, + _shipsLayer: null, + _shipsInterval: null, _flightFetching: false, _moveHandler: null, _coordControl: null, @@ -121,6 +123,7 @@ const GEOINT = { div.innerHTML = '

GEOINT Layer

' + self._subItemHtml('flights', 'Flugverkehr', 'flights') + + self._subItemHtml('ships', 'Schiffsverkehr', 'ships') + self._subItemHtml('quakes', 'Erdbeben', 'quakes') + self._subItemHtml('gdelt', 'Nachrichten', 'gdelt') + '
' + @@ -134,7 +137,7 @@ const GEOINT = { map.addControl(this._subControl); // Event-Listener fuer Sub-Checkboxen - ['flights', 'quakes', 'gdelt', 'heatmap', 'coords', 'distance'].forEach(function(id) { + ['flights', 'ships', 'quakes', 'gdelt', 'heatmap', 'coords', 'distance'].forEach(function(id) { var cb = document.getElementById('geoint-sub-' + id); if (cb) { cb.addEventListener('change', function() { @@ -163,7 +166,9 @@ const GEOINT = { this._sublayers[id] = enabled; this._saveState(); switch (id) { - case 'flights': enabled ? this._startFlights(map) : this._stopFlights(); break; + case 'flights': enabled ? this._startFlights(map) : this._stopFlights(); + this._stopShips(); break; + case 'ships': enabled ? this._startShips(map) : this._stopShips(); 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; @@ -189,13 +194,8 @@ const GEOINT = { this._flightLayer = L.layerGroup().addTo(map); var self = this; this._fetchFlights(map); - this._flightInterval = setInterval(function() { self._fetchFlights(map); }, 30000); - // Bei Kartenbewegung neu laden - this._moveHandler = function() { - clearTimeout(self._moveDebounce); - self._moveDebounce = setTimeout(function() { self._fetchFlights(map); }, 2000); - }; - map.on('moveend', this._moveHandler); + this._flightInterval = setInterval(function() { self._fetchFlights(map); }, 30000); // 30s global refresh + // Globale Daten - kein moveend-Handler noetig }, _stopFlights() { @@ -205,14 +205,13 @@ const GEOINT = { }, _fetchFlights(map) { - if (this._flightFetching || !map || map.getZoom() < 3) return; + if (this._flightFetching || !map) return; this._flightFetching = true; - var center = map.getCenter(); var self = this; var token = localStorage.getItem('osint_token') || ''; var headers = token ? { 'Authorization': 'Bearer ' + token } : {}; - fetch('/api/geoint/flights?lat=' + center.lat.toFixed(2) + '&lon=' + center.lng.toFixed(2) + '&radius=250', { headers: headers }) + fetch('/api/geoint/flights', { headers: headers }) .then(function(r) { return r.ok ? r.json() : { ac: [] }; }) .then(function(data) { if (!self._flightLayer) return; @@ -254,6 +253,69 @@ const GEOINT = { .finally(function() { self._flightFetching = false; }); }, + + // ----------------------------------------------------------------------- + // Layer: Schiffsverkehr (Digitraffic AIS) + // ----------------------------------------------------------------------- + _startShips(map) { + if (this._shipsLayer) return; + this._shipsLayer = L.layerGroup().addTo(map); + var self = this; + this._fetchShips(map); + this._shipsInterval = setInterval(function() { self._fetchShips(map); }, 60000); // 60s + }, + + _stopShips() { + if (this._shipsInterval) { clearInterval(this._shipsInterval); this._shipsInterval = null; } + if (this._shipsLayer && this._map) { this._map.removeLayer(this._shipsLayer); this._shipsLayer = null; } + }, + + _fetchShips(map) { + if (!map) return; + var self = this; + var token = localStorage.getItem('osint_token') || ''; + var headers = token ? { 'Authorization': 'Bearer ' + token } : {}; + + fetch('/api/geoint/ships', { headers: headers }) + .then(function(r) { return r.ok ? r.json() : { ships: [] }; }) + .then(function(data) { + if (!self._shipsLayer) return; + var newLayer = L.layerGroup(); + var ships = data.ships || []; + ships.forEach(function(s) { + if (!s.lat || !s.lon) return; + var heading = s.heading || s.cog || 0; + var sog = s.sog || 0; + // Nur Schiffe mit Bewegung oder in Hafennaehe anzeigen + var icon = L.divIcon({ + className: '', + html: '
' + + '' + + '' + + '
', + iconSize: [10, 10], + iconAnchor: [5, 5], + }); + var mmsi = s.mmsi || '?'; + var navLabels = {0:'Motorbetrieb', 1:'Vor Anker', 2:'Nicht steuerbar', 3:'Eingeschraenkt', 5:'Festgemacht', 7:'Fischfang', 8:'Unter Segel'}; + var navText = navLabels[s.navStat] || 'Status ' + s.navStat; + var popup = '
' + + 'MMSI ' + mmsi + '' + + '
SOG ' + sog.toFixed(1) + ' kn' + + '
COG ' + Math.round(s.cog || 0) + '\u00b0' + + '
NAV ' + navText + + '
'; + L.marker([s.lat, s.lon], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(newLayer); + }); + // Atomar swappen + if (self._map && self._shipsLayer) { + self._map.removeLayer(self._shipsLayer); + self._shipsLayer = newLayer.addTo(self._map); + } + }) + .catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT ships:', e); }); + }, + // ----------------------------------------------------------------------- // Layer: Erdbeben // -----------------------------------------------------------------------