From 30410f95dc082ece3529cd49e00bd875f07ed37e Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Tue, 24 Mar 2026 11:21:27 +0100 Subject: [PATCH] AegisSight Globe: Initiales Release Eigenstaendige GEOINT-Anwendung mit CesiumJS 3D-Globus. Echtzeit-Datenlayer: Flugverkehr (airplanes.live, 64 Stuetzpunkte), Schiffsverkehr (AISStream.io WebSocket), Erdbeben (USGS), Nachrichten (GDELT GEO). FastAPI Backend, taktisches Dark-UI. --- .gitignore | 3 + requirements.txt | 4 ++ src/data_flights.py | 89 ++++++++++++++++++++++++++ src/data_gdelt.py | 32 ++++++++++ src/data_quakes.py | 29 +++++++++ src/data_ships.py | 89 ++++++++++++++++++++++++++ src/main.py | 49 ++++++++++++++ static/css/globe.css | 94 +++++++++++++++++++++++++++ static/index.html | 93 +++++++++++++++++++++++++++ static/js/app.js | 124 ++++++++++++++++++++++++++++++++++++ static/js/layers/flights.js | 79 +++++++++++++++++++++++ static/js/layers/gdelt.js | 62 ++++++++++++++++++ static/js/layers/quakes.js | 71 +++++++++++++++++++++ static/js/layers/ships.js | 79 +++++++++++++++++++++++ 14 files changed, 897 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 src/data_flights.py create mode 100644 src/data_gdelt.py create mode 100644 src/data_quakes.py create mode 100644 src/data_ships.py create mode 100644 src/main.py create mode 100644 static/css/globe.css create mode 100644 static/index.html create mode 100644 static/js/app.js create mode 100644 static/js/layers/flights.js create mode 100644 static/js/layers/gdelt.js create mode 100644 static/js/layers/quakes.js create mode 100644 static/js/layers/ships.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e12c1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +logs/ +__pycache__/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ea3951c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx>=0.27 +websockets>=13.0 diff --git a/src/data_flights.py b/src/data_flights.py new file mode 100644 index 0000000..1721ffb --- /dev/null +++ b/src/data_flights.py @@ -0,0 +1,89 @@ +"""Flugverkehr-Collector: Globaler Snapshot via airplanes.live.""" +import asyncio +import logging +import time + +import httpx +from fastapi import APIRouter + +logger = logging.getLogger("globe.flights") +router = APIRouter() + +# 64 Stuetzpunkte fuer globale Abdeckung (je 250nm Radius) +_GRID = [ + (48,2),(48,16),(55,10),(40,-4),(41,12),(38,24),(55,25),(60,25),(52,30),(45,37), + (54,-2),(63,-19), + (33,36),(30,31),(25,45),(26.5,56),(25,51.5),(33,44),(33,52),(15,45),(21,40), + (34,2),(33,-7),(32,13),(41,69),(39,63), + (40,-74),(33,-84),(42,-88),(26,-80),(45,-74),(34,-118),(47,-122),(37,-122),(30,-97),(39,-105), + (35,140),(37,127),(31,121),(40,117),(22,114),(25,121), + (19,73),(28,77),(13,80),(7,80),(1,104),(14,101),(-6,107),(10,107), + (-34,151),(-37,175),(-1,37),(-34,18),(6,3),(9,39), + (-23,-43),(-34,-58),(-12,-77),(4,-74), +] + +_cache: dict = {"data": None, "ts": 0} +_lock = asyncio.Lock() +_task = None + + +async def _fetch_all(): + """Holt Flugdaten fuer alle Stuetzpunkte.""" + now = time.time() + if _cache["data"] and now - _cache["ts"] < 25: + return _cache["data"] + + async with _lock: + if _cache["data"] and time.time() - _cache["ts"] < 25: + return _cache["data"] + + seen = {} + errors = 0 + async with httpx.AsyncClient(timeout=10) as client: + for i in range(0, len(_GRID), 10): + batch = _GRID[i:i+10] + tasks = [client.get(f"https://api.airplanes.live/v2/point/{lat:.2f}/{lon:.2f}/250") + for lat, lon in batch] + results = await asyncio.gather(*tasks, return_exceptions=True) + for r in results: + if isinstance(r, Exception): + errors += 1 + continue + try: + for ac in r.json().get("ac", []): + h = ac.get("hex") + if h and h not in seen: + seen[h] = ac + except Exception: + errors += 1 + if i + 10 < len(_GRID): + await asyncio.sleep(0.2) + + _cache["data"] = {"ac": list(seen.values()), "total": len(seen), "errors": errors} + _cache["ts"] = time.time() + logger.info(f"Flights: {len(seen)} Flugzeuge ({errors} Fehler)") + return _cache["data"] + + +async def _collector_loop(): + """Background-Loop: Flugdaten alle 30s vorladen.""" + await asyncio.sleep(5) + while True: + try: + await _fetch_all() + except Exception as e: + logger.warning(f"Flight collector error: {e}") + await asyncio.sleep(30) + + +def start_flight_collector(): + global _task + if _task is None or _task.done(): + _task = asyncio.create_task(_collector_loop()) + logger.info("Flight collector gestartet") + + +@router.get("/flights") +async def get_flights(): + data = await _fetch_all() + return data diff --git a/src/data_gdelt.py b/src/data_gdelt.py new file mode 100644 index 0000000..20986cf --- /dev/null +++ b/src/data_gdelt.py @@ -0,0 +1,32 @@ +"""GDELT GEO 2.0: Geokodierte Echtzeit-Nachrichten.""" +import logging +import time + +import httpx +from fastapi import APIRouter, Query + +logger = logging.getLogger("globe.gdelt") +router = APIRouter() + +_cache: dict[str, tuple] = {} + + +@router.get("/gdelt") +async def get_gdelt(query: str = Query("conflict OR crisis", max_length=200)): + key = query[:50] + if key in _cache and time.time() - _cache[key][0] < 60: + return _cache[key][1] + url = f"https://api.gdeltproject.org/api/v2/geo/geo?query={query}&mode=PointData&format=GeoJSON×pan=24h&maxrows=250" + try: + async with httpx.AsyncClient(timeout=12) as client: + r = await client.get(url) + r.raise_for_status() + data = r.json() + _cache[key] = (time.time(), data) + if len(_cache) > 30: + oldest = min(_cache, key=lambda k: _cache[k][0]) + del _cache[oldest] + return data + except Exception as e: + logger.warning(f"GDELT Fehler: {e}") + return {"type": "FeatureCollection", "features": []} diff --git a/src/data_quakes.py b/src/data_quakes.py new file mode 100644 index 0000000..12558a3 --- /dev/null +++ b/src/data_quakes.py @@ -0,0 +1,29 @@ +"""Erdbeben-Daten: USGS GeoJSON API.""" +import logging +import time + +import httpx +from fastapi import APIRouter + +logger = logging.getLogger("globe.quakes") +router = APIRouter() + +_cache: dict = {"data": None, "ts": 0} + + +@router.get("/quakes") +async def get_quakes(): + now = time.time() + if _cache["data"] and now - _cache["ts"] < 300: + return _cache["data"] + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get("https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson") + r.raise_for_status() + data = r.json() + _cache["data"] = data + _cache["ts"] = time.time() + return data + except Exception as e: + logger.warning(f"USGS Fehler: {e}") + return _cache["data"] or {"type": "FeatureCollection", "features": []} diff --git a/src/data_ships.py b/src/data_ships.py new file mode 100644 index 0000000..af855c9 --- /dev/null +++ b/src/data_ships.py @@ -0,0 +1,89 @@ +"""Schiffsverkehr-Collector: AISStream.io WebSocket (global, Echtzeit).""" +import asyncio +import json +import logging +import os +import time + +import websockets +from fastapi import APIRouter + +logger = logging.getLogger("globe.ships") +router = APIRouter() + +_AISSTREAM_KEY = os.getenv("AISSTREAM_KEY", "1a56b078db829727abd4d617937bae51c6f9973e") +_AISSTREAM_URL = "wss://stream.aisstream.io/v0/stream" + +# {mmsi: {mmsi, lat, lon, sog, cog, heading, name, ts}} +_store: dict[int, dict] = {} +_connected = False +_task = None + + +async def _listener(): + """Dauerhafter WebSocket-Client fuer AISStream.""" + global _connected + while True: + try: + logger.info("AISStream: Verbinde...") + async with websockets.connect( + _AISSTREAM_URL, ping_interval=30, ping_timeout=10, close_timeout=5 + ) as ws: + sub = { + "APIKey": _AISSTREAM_KEY, + "BoundingBoxes": [[[-90, -180], [90, 180]]], + "FilterMessageTypes": ["PositionReport"], + } + await ws.send(json.dumps(sub)) + _connected = True + logger.info("AISStream: Verbunden") + + async for raw in ws: + try: + text = raw.decode("utf-8") if isinstance(raw, bytes) else raw + msg = json.loads(text) + meta = msg.get("MetaData", {}) + mmsi = meta.get("MMSI") + if not mmsi: + continue + pos = msg.get("Message", {}).get("PositionReport", {}) + lat = meta.get("latitude") or pos.get("Latitude") + lon = meta.get("longitude") or pos.get("Longitude") + if not lat or not lon or not (-90 <= lat <= 90 and -180 <= lon <= 180): + continue + _store[mmsi] = { + "mmsi": mmsi, + "lat": round(lat, 5), + "lon": round(lon, 5), + "sog": round(pos.get("Sog", 0), 1), + "cog": round(pos.get("Cog", 0), 1), + "heading": pos.get("TrueHeading", 0), + "name": (meta.get("ShipName") or "").strip(), + "ts": time.time(), + } + # Stale-Cleanup alle 1000 Updates + if len(_store) % 1000 == 0: + cutoff = time.time() - 900 + stale = [k for k, v in _store.items() if v["ts"] < cutoff] + for k in stale: + del _store[k] + if len(_store) % 5000 == 0: + logger.info(f"AISStream: {len(_store)} Schiffe") + except Exception: + continue + except Exception as e: + _connected = False + logger.warning(f"AISStream Fehler: {e}. Reconnect in 10s...") + await asyncio.sleep(10) + + +def start_ais_collector(): + global _task + if _task is None or _task.done(): + _task = asyncio.create_task(_listener()) + logger.info("AIS collector gestartet") + + +@router.get("/ships") +async def get_ships(): + return {"ships": list(_store.values()), "total": len(_store), "connected": _connected} diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..b1daac7 --- /dev/null +++ b/src/main.py @@ -0,0 +1,49 @@ +"""AegisSight Globe — GEOINT 3D-Globus mit Echtzeit-Datenfusion.""" +import logging +import os +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +# Logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + handlers=[ + logging.FileHandler(Path(__file__).parent.parent / "logs" / "globe.log"), + logging.StreamHandler(), + ], +) +logger = logging.getLogger("globe") + +app = FastAPI(title="AegisSight Globe", docs_url=None, redoc_url=None) + +# --- Data modules --- +from data_flights import router as flights_router, start_flight_collector +from data_ships import router as ships_router, start_ais_collector +from data_quakes import router as quakes_router +from data_gdelt import router as gdelt_router + +app.include_router(flights_router, prefix="/api") +app.include_router(ships_router, prefix="/api") +app.include_router(quakes_router, prefix="/api") +app.include_router(gdelt_router, prefix="/api") + +# --- Static files --- +static_dir = Path(__file__).parent.parent / "static" +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + +@app.get("/") +async def index(): + return FileResponse(str(static_dir / "index.html")) + + +# --- Startup --- +@app.on_event("startup") +async def startup(): + logger.info("AegisSight Globe gestartet") + start_ais_collector() + start_flight_collector() diff --git a/static/css/globe.css b/static/css/globe.css new file mode 100644 index 0000000..d130923 --- /dev/null +++ b/static/css/globe.css @@ -0,0 +1,94 @@ +/* AegisSight Globe — Taktisches Dark Theme */ +:root { + --bg-primary: #0b1121; + --bg-panel: rgba(11, 17, 33, 0.92); + --border: rgba(0, 255, 136, 0.15); + --accent: #00ff88; + --accent-dim: rgba(0, 255, 136, 0.4); + --text: #e8eaf0; + --text-dim: rgba(255,255,255,0.5); + --font-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; overflow: hidden; background: var(--bg-primary); color: var(--text); font-family: var(--font-mono); } + +/* === Cesium Container === */ +#cesiumContainer { position: absolute; inset: 0; z-index: 1; } +/* Cesium UI ausblenden */ +.cesium-viewer-toolbar, +.cesium-viewer-animationContainer, +.cesium-viewer-timelineContainer, +.cesium-viewer-fullscreenContainer, +.cesium-viewer-infoBoxContainer, +.cesium-viewer-geocoderContainer, +.cesium-viewer-bottom { display: none !important; } +.cesium-credit-logoContainer { opacity: 0.3; } + +/* === Header === */ +#header { + position: fixed; top: 0; left: 0; right: 0; height: 44px; z-index: 100; + display: flex; align-items: center; padding: 0 16px; gap: 16px; + background: var(--bg-panel); border-bottom: 1px solid var(--border); + backdrop-filter: blur(12px); +} +.header-brand { display: flex; align-items: center; gap: 8px; } +.header-title { font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--accent); } +.header-stats { flex: 1; text-align: center; font-size: 11px; color: var(--text-dim); letter-spacing: 1px; } +.header-actions { display: flex; gap: 8px; } +.header-btn { + background: none; border: 1px solid var(--border); border-radius: 4px; + color: var(--text); padding: 4px 8px; cursor: pointer; transition: all 0.2s; +} +.header-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* === Layer Panel === */ +.layer-panel { + position: fixed; top: 52px; left: 12px; z-index: 100; + width: 200px; background: var(--bg-panel); border: 1px solid var(--border); + border-radius: 8px; padding: 12px; backdrop-filter: blur(12px); + box-shadow: 0 8px 32px rgba(0,0,0,0.4); +} +.panel-title { + font-size: 9px; font-weight: 700; letter-spacing: 3px; color: var(--accent); + margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); +} +.panel-section { display: flex; flex-direction: column; gap: 4px; } +.panel-divider { height: 1px; background: var(--border); margin: 8px 0; } + +.layer-toggle { + display: flex; align-items: center; gap: 6px; padding: 4px 6px; + border-radius: 4px; cursor: pointer; transition: background 0.15s; +} +.layer-toggle:hover { background: rgba(255,255,255,0.04); } +.layer-toggle input[type="checkbox"] { + accent-color: var(--accent); width: 12px; height: 12px; cursor: pointer; flex-shrink: 0; +} +.layer-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} +.dot-flights { background: var(--accent); } +.dot-ships { background: #4499ff; } +.dot-quakes { background: #ff4444; } +.dot-gdelt { background: #ff8800; } +.dot-daynight { background: #8866cc; } +.dot-labels { background: #aaaaaa; } +.layer-name { font-size: 11px; color: var(--text); flex: 1; } +.layer-count { font-size: 10px; color: var(--text-dim); } + +.panel-coords { + font-size: 10px; color: var(--accent); letter-spacing: 0.5px; + padding: 6px 0 0; text-align: center; +} + +/* === Bottom Bar === */ +#bottom-bar { + position: fixed; bottom: 0; left: 0; right: 0; height: 28px; z-index: 100; + display: flex; align-items: center; justify-content: center; + background: var(--bg-panel); border-top: 1px solid var(--border); + backdrop-filter: blur(8px); +} +.bottom-stats { font-size: 10px; color: var(--text-dim); letter-spacing: 1px; } + +/* === Cesium InfoBox Override === */ +.cesium-infoBox { background: var(--bg-panel) !important; border: 1px solid var(--border) !important; } +.cesium-infoBox-title { color: var(--accent) !important; font-family: var(--font-mono) !important; } diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..93b8731 --- /dev/null +++ b/static/index.html @@ -0,0 +1,93 @@ + + + + + + AegisSight Globe + + + + + + + + + + + + +
+ + + + + + + + + + + + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..dc2ffbe --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,124 @@ +/** + * AegisSight Globe — Haupt-App: CesiumJS Viewer, Layer-Management, UI. + */ +const Globe = { + viewer: null, + layers: {}, + _statsInterval: null, + + init() { + // Cesium Ion Token + Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIyZTRlZTk4ZC01YjBjLTQ0Y2EtYjdlYi1kNDAwYTIwZjA3YjkiLCJpZCI6NDA4MzI4LCJpYXQiOjE3NzQzNDcyMzR9.eXzrtBDzNsBCbvUHu_Hc-A5gTcBDkspS7IrPDbc5y8M'; + + // Viewer erstellen + this.viewer = new Cesium.Viewer('cesiumContainer', { + terrain: Cesium.Terrain.fromWorldTerrain(), + animation: false, + timeline: false, + fullscreenButton: false, + geocoder: false, + homeButton: false, + infoBox: true, + selectionIndicator: true, + navigationHelpButton: false, + sceneModePicker: false, + baseLayerPicker: false, + skyBox: new Cesium.SkyBox({ + sources: { + positiveX: Cesium.buildModuleUrl('Assets/Textures/SkyBox/tycho2t3_80_px.jpg'), + negativeX: Cesium.buildModuleUrl('Assets/Textures/SkyBox/tycho2t3_80_mx.jpg'), + positiveY: Cesium.buildModuleUrl('Assets/Textures/SkyBox/tycho2t3_80_py.jpg'), + negativeY: Cesium.buildModuleUrl('Assets/Textures/SkyBox/tycho2t3_80_my.jpg'), + positiveZ: Cesium.buildModuleUrl('Assets/Textures/SkyBox/tycho2t3_80_pz.jpg'), + negativeZ: Cesium.buildModuleUrl('Assets/Textures/SkyBox/tycho2t3_80_mz.jpg'), + } + }), + }); + + // Atmosphere und Beleuchtung + this.viewer.scene.globe.enableLighting = true; + this.viewer.scene.fog.enabled = true; + this.viewer.scene.globe.showGroundAtmosphere = true; + this.viewer.clock.shouldAnimate = true; + + // Kamera auf Europa + this.viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(10.0, 48.0, 8000000), + duration: 0, + }); + + // Koordinaten-Anzeige bei Mausbewegung + var coordsEl = document.getElementById('coords-display'); + var scene = this.viewer.scene; + var handler = new Cesium.ScreenSpaceEventHandler(scene.canvas); + handler.setInputAction(function(movement) { + var cartesian = scene.pickPosition(movement.endPosition); + if (!cartesian) { + var ray = scene.camera.getPickRay(movement.endPosition); + cartesian = scene.globe.pick(ray, scene); + } + if (cartesian) { + var carto = Cesium.Cartographic.fromCartesian(cartesian); + var lat = Cesium.Math.toDegrees(carto.latitude).toFixed(4); + var lon = Cesium.Math.toDegrees(carto.longitude).toFixed(4); + coordsEl.textContent = 'LAT: ' + lat + ' LON: ' + lon; + } + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + + // Layer Checkboxen + this._setupLayerToggles(); + + // Fullscreen + document.getElementById('btn-fullscreen').addEventListener('click', function() { + if (!document.fullscreenElement) { document.documentElement.requestFullscreen(); } + else { document.exitFullscreen(); } + }); + + // Stats Update + this._statsInterval = setInterval(function() { Globe._updateStats(); }, 5000); + + // Layer starten (die mit checked) + if (typeof FlightsLayer !== 'undefined') FlightsLayer.start(this.viewer); + if (typeof ShipsLayer !== 'undefined') ShipsLayer.start(this.viewer); + + document.getElementById('bottom-stats').textContent = 'Globe initialisiert — Lade Daten...'; + }, + + _setupLayerToggles() { + var toggles = { + 'layer-flights': function(on) { on ? FlightsLayer.start(Globe.viewer) : FlightsLayer.stop(); }, + 'layer-ships': function(on) { on ? ShipsLayer.start(Globe.viewer) : ShipsLayer.stop(); }, + 'layer-quakes': function(on) { on ? QuakesLayer.start(Globe.viewer) : QuakesLayer.stop(); }, + 'layer-gdelt': function(on) { on ? GdeltLayer.start(Globe.viewer) : GdeltLayer.stop(); }, + 'layer-daynight': function(on) { Globe.viewer.scene.globe.enableLighting = on; }, + 'layer-labels': function(on) { /* Phase 2 */ }, + }; + Object.keys(toggles).forEach(function(id) { + var cb = document.getElementById(id); + if (cb) cb.addEventListener('change', function() { toggles[id](this.checked); }); + }); + }, + + _updateStats() { + var parts = []; + if (typeof FlightsLayer !== 'undefined' && FlightsLayer._count > 0) { + parts.push(FlightsLayer._count.toLocaleString('de-DE') + ' Flugzeuge'); + document.getElementById('count-flights').textContent = FlightsLayer._count.toLocaleString('de-DE'); + } + if (typeof ShipsLayer !== 'undefined' && ShipsLayer._count > 0) { + parts.push(ShipsLayer._count.toLocaleString('de-DE') + ' Schiffe'); + document.getElementById('count-ships').textContent = ShipsLayer._count.toLocaleString('de-DE'); + } + if (typeof QuakesLayer !== 'undefined' && QuakesLayer._count > 0) { + document.getElementById('count-quakes').textContent = QuakesLayer._count.toLocaleString('de-DE'); + } + if (typeof GdeltLayer !== 'undefined' && GdeltLayer._count > 0) { + document.getElementById('count-gdelt').textContent = GdeltLayer._count.toLocaleString('de-DE'); + } + if (parts.length) { + document.getElementById('bottom-stats').textContent = parts.join(' | '); + } + }, +}; + +document.addEventListener('DOMContentLoaded', function() { Globe.init(); }); diff --git a/static/js/layers/flights.js b/static/js/layers/flights.js new file mode 100644 index 0000000..1c1b9a5 --- /dev/null +++ b/static/js/layers/flights.js @@ -0,0 +1,79 @@ +/** + * Flugverkehr-Layer: Grüne Punkte mit Höhenprofil auf dem 3D-Globus. + */ +const FlightsLayer = { + _viewer: null, + _dataSource: null, + _interval: null, + _count: 0, + + start(viewer) { + if (this._dataSource) return; + this._viewer = viewer; + this._dataSource = new Cesium.CustomDataSource('flights'); + viewer.dataSources.add(this._dataSource); + this._fetch(); + var self = this; + this._interval = setInterval(function() { self._fetch(); }, 30000); + }, + + stop() { + if (this._interval) { clearInterval(this._interval); this._interval = null; } + if (this._dataSource && this._viewer) { + this._viewer.dataSources.remove(this._dataSource); + this._dataSource = null; + } + this._count = 0; + }, + + _fetch() { + var self = this; + fetch('/api/flights') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!self._dataSource) return; + self._dataSource.entities.removeAll(); + var ac = data.ac || []; + self._count = ac.length; + ac.forEach(function(a) { + if (!a.lat || !a.lon) return; + var alt = (a.alt_baro || a.alt_geom || 10000) * 0.3048; // ft -> m + if (alt < 100) alt = 10000; // ground = default + var cs = (a.flight || a.callsign || a.hex || '???').trim(); + var spd = a.gs || 0; + self._dataSource.entities.add({ + position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, alt), + point: { + pixelSize: 4, + color: Cesium.Color.fromCssColorString('#00ff88'), + outlineColor: Cesium.Color.fromCssColorString('#004422'), + outlineWidth: 1, + heightReference: Cesium.HeightReference.NONE, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + label: { + text: cs, + font: '10px monospace', + fillColor: Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.8), + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + pixelOffset: new Cesium.Cartesian2(8, -4), + scale: 0.8, + showBackground: false, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 2000000), + }, + description: '
' + + '' + cs + '
' + + 'ALT: ' + (a.alt_baro || '?') + ' ft
' + + 'SPD: ' + Math.round(spd) + ' kts
' + + 'HDG: ' + Math.round(a.track || 0) + '°' + + (a.t ? '
TYP: ' + a.t : '') + + '
', + }); + }); + }) + .catch(function(e) { console.warn('Flights fetch error:', e); }); + }, +}; diff --git a/static/js/layers/gdelt.js b/static/js/layers/gdelt.js new file mode 100644 index 0000000..73f156b --- /dev/null +++ b/static/js/layers/gdelt.js @@ -0,0 +1,62 @@ +/** + * GDELT Nachrichten-Layer: Geokodierte Echtzeit-News. + */ +const GdeltLayer = { + _viewer: null, + _dataSource: null, + _interval: null, + _count: 0, + + start(viewer) { + if (this._dataSource) return; + this._viewer = viewer; + this._dataSource = new Cesium.CustomDataSource('gdelt'); + viewer.dataSources.add(this._dataSource); + this._fetch(); + var self = this; + this._interval = setInterval(function() { self._fetch(); }, 600000); + }, + + stop() { + if (this._interval) { clearInterval(this._interval); this._interval = null; } + if (this._dataSource && this._viewer) { + this._viewer.dataSources.remove(this._dataSource); + this._dataSource = null; + } + this._count = 0; + }, + + _fetch() { + var self = this; + fetch('/api/gdelt') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!self._dataSource) return; + self._dataSource.entities.removeAll(); + var features = data.features || []; + self._count = features.length; + features.forEach(function(f) { + var c = f.geometry.coordinates; + var p = f.properties || {}; + var title = (p.name || p.title || 'Nachricht').substring(0, 80); + var url = p.url || ''; + self._dataSource.entities.add({ + position: Cesium.Cartesian3.fromDegrees(c[0], c[1]), + point: { + pixelSize: 5, + color: Cesium.Color.fromCssColorString('#ff8800'), + outlineColor: Cesium.Color.fromCssColorString('#663300'), + outlineWidth: 1, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + description: '
' + + '' + title + '' + + (url ? '
Quelle' : '') + + '
', + }); + }); + }) + .catch(function(e) { console.warn('GDELT fetch error:', e); }); + }, +}; diff --git a/static/js/layers/quakes.js b/static/js/layers/quakes.js new file mode 100644 index 0000000..b23b982 --- /dev/null +++ b/static/js/layers/quakes.js @@ -0,0 +1,71 @@ +/** + * Erdbeben-Layer: Farbige Kreise auf der Erdoberfläche. + */ +const QuakesLayer = { + _viewer: null, + _dataSource: null, + _interval: null, + _count: 0, + + start(viewer) { + if (this._dataSource) return; + this._viewer = viewer; + this._dataSource = new Cesium.CustomDataSource('quakes'); + viewer.dataSources.add(this._dataSource); + this._fetch(); + var self = this; + this._interval = setInterval(function() { self._fetch(); }, 300000); + }, + + stop() { + if (this._interval) { clearInterval(this._interval); this._interval = null; } + if (this._dataSource && this._viewer) { + this._viewer.dataSources.remove(this._dataSource); + this._dataSource = null; + } + this._count = 0; + }, + + _fetch() { + var self = this; + fetch('/api/quakes') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!self._dataSource) return; + self._dataSource.entities.removeAll(); + var features = data.features || []; + self._count = features.length; + var now = Date.now(); + features.forEach(function(f) { + var c = f.geometry.coordinates; + var p = f.properties; + var mag = p.mag || 1; + var ageH = (now - p.time) / 3600000; + var color = ageH < 1 + ? Cesium.Color.RED.withAlpha(0.8) + : ageH < 6 ? Cesium.Color.ORANGE.withAlpha(0.7) + : ageH < 12 ? Cesium.Color.YELLOW.withAlpha(0.6) + : Cesium.Color.YELLOW.withAlpha(0.4); + var radius = Math.max(mag * 15000, 20000); // Meter + self._dataSource.entities.add({ + position: Cesium.Cartesian3.fromDegrees(c[0], c[1]), + ellipse: { + semiMinorAxis: radius, + semiMajorAxis: radius, + material: color, + outline: true, + outlineColor: color.withAlpha(1.0), + outlineWidth: 1, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + description: '
' + + 'M' + mag.toFixed(1) + ' ' + (p.place || '') + '
' + + 'Tiefe: ' + (c[2] || '?') + ' km
' + + 'Zeit: ' + new Date(p.time).toLocaleString('de-DE') + + '
', + }); + }); + }) + .catch(function(e) { console.warn('Quakes fetch error:', e); }); + }, +}; diff --git a/static/js/layers/ships.js b/static/js/layers/ships.js new file mode 100644 index 0000000..4810f9e --- /dev/null +++ b/static/js/layers/ships.js @@ -0,0 +1,79 @@ +/** + * Schiffsverkehr-Layer: Blaue Punkte auf Meereshöhe. + */ +const ShipsLayer = { + _viewer: null, + _dataSource: null, + _interval: null, + _count: 0, + + start(viewer) { + if (this._dataSource) return; + this._viewer = viewer; + this._dataSource = new Cesium.CustomDataSource('ships'); + viewer.dataSources.add(this._dataSource); + this._fetch(); + var self = this; + this._interval = setInterval(function() { self._fetch(); }, 60000); + }, + + stop() { + if (this._interval) { clearInterval(this._interval); this._interval = null; } + if (this._dataSource && this._viewer) { + this._viewer.dataSources.remove(this._dataSource); + this._dataSource = null; + } + this._count = 0; + }, + + _fetch() { + var self = this; + fetch('/api/ships') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!self._dataSource) return; + self._dataSource.entities.removeAll(); + var ships = data.ships || []; + self._count = ships.length; + ships.forEach(function(s) { + if (!s.lat || !s.lon) return; + var sog = s.sog || 0; + var color = sog > 0.5 + ? Cesium.Color.fromCssColorString('#4499ff') + : Cesium.Color.fromCssColorString('#556688'); + var name = s.name || ('MMSI ' + (s.mmsi || '?')); + self._dataSource.entities.add({ + position: Cesium.Cartesian3.fromDegrees(s.lon, s.lat, 0), + point: { + pixelSize: 3, + color: color, + outlineColor: Cesium.Color.fromCssColorString('#112244'), + outlineWidth: 0.5, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + label: { + text: name, + font: '9px monospace', + fillColor: Cesium.Color.fromCssColorString('#4499ff').withAlpha(0.7), + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + pixelOffset: new Cesium.Cartesian2(6, -3), + scale: 0.7, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 500000), + }, + description: '
' + + '' + name + '
' + + 'MMSI: ' + (s.mmsi || '?') + '
' + + 'SOG: ' + sog.toFixed(1) + ' kn
' + + 'COG: ' + Math.round(s.cog || 0) + '°
' + + 'HDG: ' + (s.heading || '?') + '°' + + '
', + }); + }); + }) + .catch(function(e) { console.warn('Ships fetch error:', e); }); + }, +};