feat: GEOINT-Toolkit mit 6 neuen Features

- EXIF-Extraktion: Automatische GPS/Kamera/Zeitstempel-Analyse bei Bildupload
- Sonnenstand-Rechner: Azimut, Elevation, Schattenverhaeltnis fuer beliebige Position/Zeit
- Reverse Geolocation: Erweiterte VLM-Analyse mit Landschaftsmerkmalen (Vegetation, Architektur, Strassen, Schilder)
- Nachtlichter: NASA VIIRS Black Marble Layer
- Hoehenprofil: Interaktives 2-Punkte-Tool mit SVG-Chart und Sichtlinienanalyse
- Funkmasten: Mobilfunkinfrastruktur via Overpass (zoomabhaengig)

Backend: data_geoint.py (EXIF, Sun, Elevation, Celltowers)
Frontend: GEOINT Tools Section im Layer Panel
Dieser Commit ist enthalten in:
Claude Dev
2026-03-26 08:58:05 +01:00
Ursprung 1b74c95bac
Commit c7cb19d584
10 geänderte Dateien mit 965 neuen und 4 gelöschten Zeilen

387
src/data_geoint.py Normale Datei
Datei anzeigen

@@ -0,0 +1,387 @@
"""GEOINT-Toolkit: EXIF-Extraktion, Sonnenstand, Hoehenprofil, Funkmasten."""
import logging
import math
import time
import hashlib
import io
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from PIL import Image, ExifTags
logger = logging.getLogger("globe.geoint")
router = APIRouter()
# ============================================================
# 1. EXIF / Metadaten-Extraktion
# ============================================================
def _dms_to_decimal(dms, ref):
"""Konvertiert GPS DMS zu Dezimalgrad."""
try:
degrees = float(dms[0])
minutes = float(dms[1])
seconds = float(dms[2])
decimal = degrees + minutes / 60 + seconds / 3600
if ref in ("S", "W"):
decimal = -decimal
return round(decimal, 6)
except (TypeError, IndexError, ValueError, ZeroDivisionError):
return None
def extract_exif(image_bytes: bytes) -> dict:
"""Extrahiert EXIF-Metadaten aus Bilddaten."""
result = {
"has_gps": False,
"latitude": None,
"longitude": None,
"altitude": None,
"timestamp": None,
"camera_make": None,
"camera_model": None,
"focal_length": None,
"compass_heading": None,
"image_width": None,
"image_height": None,
}
try:
img = Image.open(io.BytesIO(image_bytes))
result["image_width"] = img.width
result["image_height"] = img.height
exif_data = img._getexif()
if not exif_data:
return result
exif = {}
for tag_id, value in exif_data.items():
tag = ExifTags.TAGS.get(tag_id, tag_id)
exif[tag] = value
result["camera_make"] = exif.get("Make", "").strip() if isinstance(exif.get("Make"), str) else None
result["camera_model"] = exif.get("Model", "").strip() if isinstance(exif.get("Model"), str) else None
fl = exif.get("FocalLength")
if fl:
if isinstance(fl, tuple) and len(fl) == 2:
result["focal_length"] = round(float(fl[0]) / float(fl[1]), 1)
elif not isinstance(fl, tuple):
result["focal_length"] = round(float(fl), 1)
dt_str = exif.get("DateTimeOriginal") or exif.get("DateTime")
if dt_str and isinstance(dt_str, str):
try:
result["timestamp"] = datetime.strptime(dt_str, "%Y:%m:%d %H:%M:%S").isoformat()
except ValueError:
pass
gps_info = exif.get("GPSInfo")
if gps_info and isinstance(gps_info, dict):
gps_tags = {}
for key, val in gps_info.items():
tag = ExifTags.GPSTAGS.get(key, key)
gps_tags[tag] = val
lat_dms = gps_tags.get("GPSLatitude")
lat_ref = gps_tags.get("GPSLatitudeRef", "N")
lon_dms = gps_tags.get("GPSLongitude")
lon_ref = gps_tags.get("GPSLongitudeRef", "E")
if lat_dms and lon_dms:
lat = _dms_to_decimal(lat_dms, lat_ref)
lon = _dms_to_decimal(lon_dms, lon_ref)
if lat is not None and lon is not None:
result["has_gps"] = True
result["latitude"] = lat
result["longitude"] = lon
alt = gps_tags.get("GPSAltitude")
if alt:
try:
result["altitude"] = round(float(alt), 1)
except (TypeError, ValueError):
pass
heading = gps_tags.get("GPSImgDirection")
if heading:
try:
result["compass_heading"] = round(float(heading), 1)
except (TypeError, ValueError):
pass
except Exception as e:
logger.warning(f"EXIF-Extraktion fehlgeschlagen: {e}")
return result
@router.post("/geoint/exif")
async def api_extract_exif(file: UploadFile = File(...)):
"""Extrahiert EXIF/GPS-Metadaten aus einem Bild."""
content = await file.read()
if len(content) > 15 * 1024 * 1024:
raise HTTPException(400, "Datei zu gross (max 15MB)")
return extract_exif(content)
# ============================================================
# 2. Sonnenstand-Berechnung (Jean Meeus, vereinfacht)
# ============================================================
class SunRequest(BaseModel):
latitude: float = Field(..., ge=-90, le=90)
longitude: float = Field(..., ge=-180, le=180)
datetime_iso: str
def _calc_sun_position(lat, lon, dt):
"""Berechnet Sonnenazimut und -elevation."""
y, m = dt.year, dt.month
d = dt.day + dt.hour / 24 + dt.minute / 1440 + dt.second / 86400
if m <= 2:
y -= 1
m += 12
A = int(y / 100)
B = 2 - A + int(A / 4)
JD = int(365.25 * (y + 4716)) + int(30.6001 * (m + 1)) + d + B - 1524.5
T = (JD - 2451545.0) / 36525.0
L0 = (280.46646 + T * (36000.76983 + 0.0003032 * T)) % 360
M = (357.52911 + T * (35999.05029 - 0.0001537 * T)) % 360
M_rad = math.radians(M)
C = ((1.914602 - T * (0.004817 + 0.000014 * T)) * math.sin(M_rad) +
(0.019993 - 0.000101 * T) * math.sin(2 * M_rad) +
0.000289 * math.sin(3 * M_rad))
sun_lon = L0 + C
obliquity = 23.439291 - 0.0130042 * T
obliquity_rad = math.radians(obliquity)
sun_lon_rad = math.radians(sun_lon)
RA = math.atan2(math.cos(obliquity_rad) * math.sin(sun_lon_rad), math.cos(sun_lon_rad))
dec = math.asin(math.sin(obliquity_rad) * math.sin(sun_lon_rad))
GMST = (280.46061837 + 360.98564736629 * (JD - 2451545.0) + 0.000387933 * T * T) % 360
LST = math.radians(GMST + lon)
HA = LST - RA
lat_rad = math.radians(lat)
sin_alt = math.sin(lat_rad) * math.sin(dec) + math.cos(lat_rad) * math.cos(dec) * math.cos(HA)
altitude = math.degrees(math.asin(max(-1, min(1, sin_alt))))
cos_alt = math.cos(math.asin(max(-1, min(1, sin_alt))))
if cos_alt == 0:
azimuth = 0
else:
cos_az = (math.sin(dec) - math.sin(lat_rad) * sin_alt) / (math.cos(lat_rad) * cos_alt)
cos_az = max(-1, min(1, cos_az))
azimuth = math.degrees(math.acos(cos_az))
if math.sin(HA) > 0:
azimuth = 360 - azimuth
shadow_ratio = None
if altitude > 0:
shadow_ratio = round(1.0 / math.tan(math.radians(altitude)), 2)
return {
"azimuth": round(azimuth, 2),
"elevation": round(altitude, 2),
"shadow_ratio": shadow_ratio,
"shadow_direction": round((azimuth + 180) % 360, 2),
"is_daylight": altitude > -0.833,
"golden_hour": 0 < altitude < 10,
}
@router.post("/geoint/sun-position")
async def api_sun_position(req: SunRequest):
"""Berechnet den Sonnenstand fuer eine Position und Zeit."""
try:
dt = datetime.fromisoformat(req.datetime_iso.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(400, "Ungueltiges Datumsformat (ISO 8601)")
result = _calc_sun_position(req.latitude, req.longitude, dt)
result["input"] = {"latitude": req.latitude, "longitude": req.longitude, "datetime": dt.isoformat()}
return result
# ============================================================
# 3. Hoehenprofil + Sichtlinienanalyse
# ============================================================
class ElevationRequest(BaseModel):
start_lat: float = Field(..., ge=-90, le=90)
start_lon: float = Field(..., ge=-180, le=180)
end_lat: float = Field(..., ge=-90, le=90)
end_lon: float = Field(..., ge=-180, le=180)
samples: int = Field(50, ge=10, le=100)
def _haversine(lat1, lon1, lat2, lon2):
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _check_line_of_sight(elevations):
if len(elevations) < 3:
return {"clear": True, "blocked_at_index": None}
start_h = elevations[0]
end_h = elevations[-1]
n = len(elevations) - 1
for i in range(1, n):
t = i / n
expected = start_h + t * (end_h - start_h)
if elevations[i] > expected + 2:
return {"clear": False, "blocked_at_index": i}
return {"clear": True, "blocked_at_index": None}
@router.post("/geoint/elevation-profile")
async def api_elevation_profile(req: ElevationRequest):
"""Berechnet ein Hoehenprofil zwischen zwei Punkten."""
lats, lons = [], []
for i in range(req.samples):
t = i / (req.samples - 1)
lats.append(round(req.start_lat + t * (req.end_lat - req.start_lat), 6))
lons.append(round(req.start_lon + t * (req.end_lon - req.start_lon), 6))
lat_str = ",".join(str(l) for l in lats)
lon_str = ",".join(str(l) for l in lons)
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(
"https://api.open-meteo.com/v1/elevation",
params={"latitude": lat_str, "longitude": lon_str},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Elevation API Fehler: {e}")
raise HTTPException(502, "Elevation API nicht erreichbar")
elevations = data.get("elevation", [])
if not elevations:
raise HTTPException(502, "Keine Hoehendaten erhalten")
distances = [0.0]
total_dist = 0.0
for i in range(1, len(lats)):
d = _haversine(lats[i - 1], lons[i - 1], lats[i], lons[i])
total_dist += d
distances.append(round(total_dist, 1))
elev_min = min(elevations)
elev_max = max(elevations)
total_ascent = sum(max(0, elevations[i] - elevations[i - 1]) for i in range(1, len(elevations)))
total_descent = sum(max(0, elevations[i - 1] - elevations[i]) for i in range(1, len(elevations)))
los = _check_line_of_sight(elevations)
points = []
for i in range(len(lats)):
points.append({
"lat": lats[i], "lon": lons[i],
"elevation": elevations[i] if i < len(elevations) else 0,
"distance_m": distances[i],
})
return {
"points": points,
"stats": {
"distance_m": round(total_dist, 1),
"distance_km": round(total_dist / 1000, 2),
"elevation_min": elev_min,
"elevation_max": elev_max,
"elevation_diff": round(elev_max - elev_min, 1),
"total_ascent": round(total_ascent, 1),
"total_descent": round(total_descent, 1),
},
"line_of_sight": los,
}
# ============================================================
# 4. Funkmasten (via Overpass API)
# ============================================================
class CelltowerRequest(BaseModel):
south: float = Field(..., ge=-90, le=90)
west: float = Field(..., ge=-180, le=180)
north: float = Field(..., ge=-90, le=90)
east: float = Field(..., ge=-180, le=180)
_CT_CACHE = {}
_CT_CACHE_TTL = 600
@router.post("/geoint/celltowers")
async def api_celltowers(req: CelltowerRequest):
"""Liefert Funkmasten im Viewport via Overpass API."""
from data_overpass import _OVERPASS_URLS, _TIMEOUT
bbox = f"{req.south},{req.west},{req.north},{req.east}"
cache_key = hashlib.md5(bbox.encode()).hexdigest()
if cache_key in _CT_CACHE:
ts, cached = _CT_CACHE[cache_key]
if time.time() - ts < _CT_CACHE_TTL:
return cached
query = (
"[out:json][timeout:25];"
"("
f'node["man_made"="mast"]["tower:type"="communication"]({bbox});'
f'node["man_made"="tower"]["tower:type"="communication"]({bbox});'
f'node["telecom"="antenna"]({bbox});'
f'node["communication:mobile_phone"="yes"]({bbox});'
");"
"out body;"
)
data = None
for url in _OVERPASS_URLS:
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.post(url, data={"data": query})
r.raise_for_status()
data = r.json()
break
except Exception as e:
logger.warning(f"Celltower Overpass {url}: {e}")
continue
if data is None:
raise HTTPException(502, "Overpass API nicht erreichbar")
towers = []
for el in data.get("elements", []):
if not el.get("lat") or not el.get("lon"):
continue
tags = el.get("tags", {})
towers.append({
"lat": el["lat"],
"lon": el["lon"],
"operator": tags.get("operator", tags.get("communication:mobile_phone:operator", "")),
"ref": tags.get("ref", ""),
"height": tags.get("height", ""),
"name": tags.get("name", ""),
})
result = {"towers": towers, "total": len(towers)}
_CT_CACHE[cache_key] = (time.time(), result)
logger.info(f"Funkmasten: {len(towers)} im Viewport")
return result

Datei anzeigen

@@ -8,6 +8,7 @@ import shutil
from pathlib import Path
from PIL import Image
from data_geoint import extract_exif
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
@@ -68,6 +69,19 @@ _VLM_SCHEMA = json.dumps({
"estimated_location_type": {
"type": "string",
"description": "Geschaetzter Standort-Typ (z.B. Europa, Naher Osten, Nordamerika)"
},
"landscape_clues": {
"type": "object",
"properties": {
"vegetation": {"type": "string", "description": "Vegetationstyp (z.B. temperate deciduous, tropical, tundra, arid scrub)"},
"soil_color": {"type": "string", "description": "Bodenfarbe (z.B. red laterite, dark alluvial, sandy, grey clay)"},
"road_markings": {"type": "string", "description": "Strassenmarkierungen (z.B. yellow center lines=USA, white dashed=Europe, none=developing)"},
"architecture_style": {"type": "string", "description": "Architekturstil (z.B. Soviet bloc, Mediterranean, Middle Eastern, East Asian)"},
"signage_language": {"type": "string", "description": "Sprache auf Schildern falls erkennbar"},
"vehicle_types": {"type": "string", "description": "Fahrzeugtypen und Fahrtrichtung (links/rechts)"},
"climate_indicators": {"type": "string", "description": "Klimaindikatoren (Schnee, Wueste, tropisch, etc.)"},
"sun_shadow_direction": {"type": "string", "description": "Schattenrichtung falls erkennbar (z.B. N, NW)"}
}
}
},
"required": ["scene_description", "objects"]
@@ -218,6 +232,10 @@ async def _run_claude(image_path: str, viewport_info: str = "") -> dict:
"Analysiere das Bild fuer GEOINT/OSINT-Zwecke. "
"Identifiziere alle erkennbaren Objekte, Infrastruktur und militaerisch relevante Strukturen. "
"Gib fuer jedes Objekt passende OpenStreetMap-Tags an. "
"Analysiere ausserdem Landschaftsmerkmale fuer Reverse Geolocation: "
"Vegetation, Bodenfarbe, Strassenmarkierungen, Architekturstil, Schildersprache, "
"Fahrzeugtypen und Fahrtrichtung, Klimaindikatoren, Schattenrichtung. "
"Fuelle das landscape_clues Objekt so detailliert wie moeglich. "
"Antworte ausschliesslich im vorgegebenen JSON-Format."
)
@@ -308,9 +326,17 @@ async def analyze_image(
logger.info(f"VLM-Analyse gestartet: {file.filename} ({len(content)} bytes)")
# EXIF-Metadaten extrahieren
exif_data = extract_exif(content)
if exif_data.get("has_gps"):
logger.info(f"EXIF GPS: {exif_data['latitude']}, {exif_data['longitude']}")
# Claude Code aufrufen
result = await _run_claude(resized_path, viewport_info)
# EXIF-Daten zum Ergebnis hinzufuegen
result["exif"] = exif_data
logger.info(
f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte erkannt"
)

Datei anzeigen

@@ -38,6 +38,7 @@ from data_infra import router as infra_router
from data_monitor import router as monitor_router
from data_overpass import router as overpass_router
from data_vlm import router as vlm_router
from data_geoint import router as geoint_router
from data_push import start_push_service
# Alle Daten-APIs hinter Auth
@@ -52,6 +53,7 @@ app.include_router(disasters_router, prefix="/api", dependencies=[Depends(get_cu
app.include_router(monitor_router, prefix="/api", dependencies=[Depends(get_current_user)])
app.include_router(overpass_router, prefix="/api", dependencies=[Depends(get_current_user)])
app.include_router(vlm_router, prefix="/api", dependencies=[Depends(get_current_user)])
app.include_router(geoint_router, prefix="/api", dependencies=[Depends(get_current_user)])
# --- Static files ---
static_dir = Path(__file__).parent.parent / "static"

Datei anzeigen

@@ -786,3 +786,100 @@ html, body { height: 100%; overflow: hidden; background: var(--bg-primary); colo
.vlm-panel-right::-webkit-scrollbar { width: 4px; }
.vlm-panel-right::-webkit-scrollbar-track { background: transparent; }
.vlm-panel-right::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* === GEOINT Tools === */
.dot-nightlights { background: #ffd740; box-shadow: 0 0 4px rgba(255,215,64,0.4); }
.dot-celltowers { background: #e040fb; }
.geoint-btn-row {
display: flex;
gap: 4px;
padding: 4px 8px;
}
.geoint-tool-btn {
flex: 1;
padding: 5px 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
background: rgba(224, 64, 251, 0.08);
border: 1px solid rgba(224, 64, 251, 0.3);
border-radius: 3px;
color: #e040fb;
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
}
.geoint-tool-btn:hover {
background: rgba(224, 64, 251, 0.18);
border-color: #e040fb;
}
.geoint-panel {
position: fixed;
top: 56px;
right: 12px;
width: 320px;
max-height: calc(100vh - 100px);
overflow-y: auto;
background: var(--bg-panel);
border: 1px solid rgba(224, 64, 251, 0.3);
border-radius: 8px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 100;
padding: 14px;
}
.geoint-panel::-webkit-scrollbar { width: 4px; }
.geoint-panel::-webkit-scrollbar-thumb { background: rgba(224,64,251,0.3); border-radius: 2px; }
.geoint-input {
width: 100%;
padding: 6px 8px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
margin-top: 2px;
}
.geoint-input:focus { border-color: #e040fb; }
.geoint-result-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
.geoint-result-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 4px;
}
.geoint-label {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.geoint-value {
font-size: 13px;
color: var(--text);
font-weight: 600;
}
/* === VLM EXIF Card === */
.vlm-exif-card {
padding: 8px 10px;
background: rgba(224, 64, 251, 0.06);
border: 1px solid rgba(224, 64, 251, 0.2);
border-radius: 4px;
margin-bottom: 8px;
}

Datei anzeigen

@@ -114,6 +114,25 @@
<span class="layer-name" title="Satellitenbild-Analyse mit Claude VLM">Bildanalyse</span>
<span class="layer-count" id="count-vlm">-</span>
</label>
<div class="panel-divider"></div>
<div style="font-size:9px;letter-spacing:1.5px;color:#e040fb;margin-bottom:4px">GEOINT TOOLS</div>
<label class="layer-toggle">
<input type="checkbox" id="layer-nightlights" title="NASA VIIRS Nachtlicht-Karte (Black Marble)">
<span class="layer-dot dot-nightlights"></span>
<span class="layer-name" title="Globale Nachtlicht-Aktivitaet (Besiedlung, Stromausfaelle)">Nachtlichter</span>
</label>
<label class="layer-toggle">
<input type="checkbox" id="layer-celltowers" title="Mobilfunkmasten aus OpenStreetMap (zoomabhaengig)">
<span class="layer-dot dot-celltowers"></span>
<span class="layer-name" title="Sendemasten und Mobilfunkinfrastruktur">Funkmasten</span>
<span class="layer-count" id="count-celltowers">-</span>
</label>
<div class="layer-status" id="status-celltowers"></div>
<div class="geoint-btn-row">
<button class="geoint-tool-btn" onclick="GeointTools.showSunCalc()" title="Sonnenstand und Schattenanalyse berechnen">Sonne</button>
<button class="geoint-tool-btn" onclick="GeointTools.startElevation()" title="Hoehenprofil und Sichtlinienanalyse zwischen zwei Punkten">Hoehe</button>
</div>
<div class="panel-divider"></div>
<label class="layer-toggle">
<input type="checkbox" id="layer-iss" title="ISS Echtzeit-Position (5s Refresh)">
<span class="layer-dot dot-iss"></span>
@@ -204,6 +223,8 @@
<!-- VLM Bildanalyse Panel -->
<div id="vlm-panel" class="vlm-panel-right" style="display:none"></div>
<!-- GEOINT Tools Panel -->
<div id="geoint-panel" class="geoint-panel" style="display:none"></div>
<aside id="sidebar-right" class="sidebar-right">
<button id="sidebar-toggle" class="sidebar-toggle" title="Seitenleiste ein-/ausblenden">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
@@ -251,6 +272,9 @@
<script src="/static/js/layers/infra.js"></script>
<script src="/static/js/layers/overpass.js"></script>
<script src="/static/js/ui/vlm.js"></script>
<script src="/static/js/layers/nightlights.js"></script>
<script src="/static/js/layers/celltowers.js"></script>
<script src="/static/js/ui/geoint.js"></script>
<script src="/static/js/layers/iss.js"></script>
<script src="/static/js/layers/terminator.js"></script>
<script src="/static/js/layers/timezones.js"></script>

Datei anzeigen

@@ -171,6 +171,8 @@ const Globe = {
if (sidebar) sidebar.style.display = '';
}
},
'layer-nightlights': function(on) { on ? NightlightsLayer.start(Globe.viewer) : NightlightsLayer.stop(); },
'layer-celltowers': function(on) { on ? CelltowersLayer.start(Globe.viewer) : CelltowersLayer.stop(); },
'layer-iss': function(on) { on ? ISSLayer.start(Globe.viewer) : ISSLayer.stop(); },
'layer-disasters': function(on) { on ? DisastersLayer.start(Globe.viewer) : DisastersLayer.stop(); },
'layer-weather': function(on) { on ? WeatherLayer.start(Globe.viewer) : WeatherLayer.stop(); },
@@ -211,6 +213,10 @@ const Globe = {
if (typeof GdeltLayer !== 'undefined' && GdeltLayer._count > 0) {
document.getElementById('count-gdelt').textContent = GdeltLayer._count.toLocaleString('de-DE');
}
if (typeof CelltowersLayer !== 'undefined' && CelltowersLayer._count > 0) {
var ctEl = document.getElementById('count-celltowers');
if (ctEl) ctEl.textContent = CelltowersLayer._count.toLocaleString('de-DE');
}
if (parts.length) {
document.getElementById('bottom-stats').textContent = parts.join(' | ');
}

120
static/js/layers/celltowers.js Normale Datei
Datei anzeigen

@@ -0,0 +1,120 @@
/**
* Funkmasten-Layer: Mobilfunkmasten aus OpenStreetMap via Overpass.
*/
const CelltowersLayer = {
_viewer: null,
_points: null,
_labels: null,
_count: 0,
_interval: null,
_lastBbox: null,
start: function(viewer) {
if (this._points) return;
this._viewer = viewer;
this._points = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
this._labels = viewer.scene.primitives.add(new Cesium.LabelCollection());
this._fetch();
var self = this;
this._interval = setInterval(function() { self._fetchIfMoved(); }, 8000);
},
stop: function() {
if (this._interval) { clearInterval(this._interval); this._interval = null; }
if (this._points && this._viewer) { this._viewer.scene.primitives.remove(this._points); this._points = null; }
if (this._labels && this._viewer) { this._viewer.scene.primitives.remove(this._labels); this._labels = null; }
this._count = 0;
this._lastBbox = null;
var c = document.getElementById('count-celltowers');
if (c) c.textContent = '-';
},
_getBbox: function() {
var rect = this._viewer.camera.computeViewRectangle();
if (!rect) return null;
return {
south: Cesium.Math.toDegrees(rect.south),
west: Cesium.Math.toDegrees(rect.west),
north: Cesium.Math.toDegrees(rect.north),
east: Cesium.Math.toDegrees(rect.east),
};
},
_fetchIfMoved: function() {
var bbox = this._getBbox();
if (!bbox) return;
// Nur bei deutlicher Bewegung neu laden
if (this._lastBbox) {
var dLat = Math.abs(bbox.south - this._lastBbox.south) + Math.abs(bbox.north - this._lastBbox.north);
var dLon = Math.abs(bbox.west - this._lastBbox.west) + Math.abs(bbox.east - this._lastBbox.east);
if (dLat + dLon < 0.5) return;
}
// Nur bei Zoom nah genug (< ~500 km Sichtfeld)
var cam = this._viewer.camera.positionCartographic;
if (cam && cam.height > 600000) return;
this._fetch();
},
_fetch: function() {
var bbox = this._getBbox();
if (!bbox) return;
// Viewport zu gross -> nicht laden
if (Math.abs(bbox.north - bbox.south) > 5 || Math.abs(bbox.east - bbox.west) > 5) {
var s = document.getElementById('status-celltowers');
if (s) { s.textContent = 'Naeher heranzoomen'; s.classList.add('active'); }
return;
}
this._lastBbox = bbox;
var self = this;
fetch('/api/geoint/celltowers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bbox),
})
.then(function(r) { return r.json(); })
.then(function(data) {
self._render(data.towers || []);
})
.catch(function() {});
},
_render: function(towers) {
if (!this._points) return;
this._points.removeAll();
this._labels.removeAll();
var color = Cesium.Color.fromCssColorString('#e040fb');
var colorDim = color.withAlpha(0.6);
for (var i = 0; i < towers.length; i++) {
var t = towers[i];
this._points.add({
position: Cesium.Cartesian3.fromDegrees(t.lon, t.lat, 0),
pixelSize: 6,
color: color,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
});
var label = t.operator || t.name || '';
if (label && towers.length < 200) {
this._labels.add({
position: Cesium.Cartesian3.fromDegrees(t.lon, t.lat, 0),
text: label,
font: '9px monospace',
fillColor: colorDim,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(6, -4),
scale: 0.6,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 100000),
});
}
}
this._count = towers.length;
var c = document.getElementById('count-celltowers');
if (c) c.textContent = this._count.toLocaleString('de-DE');
var s = document.getElementById('status-celltowers');
if (s) { s.textContent = ''; s.classList.remove('active'); }
},
};

Datei anzeigen

@@ -0,0 +1,36 @@
/**
* Nachtlicht-Layer: NASA VIIRS Black Marble Nighttime Lights.
*/
const NightlightsLayer = {
_viewer: null,
_layer: null,
start: function(viewer) {
if (this._layer) return;
this._viewer = viewer;
this._layer = viewer.imageryLayers.addImageryProvider(
new Cesium.WebMapTileServiceImageryProvider({
url: 'https://gibs.earthdata.nasa.gov/wmts/epsg4326/best/VIIRS_Black_Marble/default/2024-01-01/500m/{TileMatrix}/{TileRow}/{TileCol}.png',
layer: 'VIIRS_Black_Marble',
style: 'default',
tileMatrixSetID: '500m',
tileMatrixLabels: [
'0', '1', '2', '3', '4', '5', '6', '7', '8',
],
format: 'image/png',
tilingScheme: new Cesium.GeographicTilingScheme(),
tileWidth: 512,
tileHeight: 512,
credit: 'NASA VIIRS Black Marble',
})
);
this._layer.alpha = 0.85;
},
stop: function() {
if (this._layer && this._viewer) {
this._viewer.imageryLayers.remove(this._layer);
this._layer = null;
}
},
};

221
static/js/ui/geoint.js Normale Datei
Datei anzeigen

@@ -0,0 +1,221 @@
/**
* GEOINT Tools: Sonnenstand-Rechner, Hoehenprofil, EXIF-Anzeige im VLM-Workflow.
*/
const GeointTools = {
_panel: null,
_elevMode: false,
_elevStart: null,
_elevHandler: null,
_elevLine: null,
_elevLabels: null,
// ============================================================
// Sonnenstand-Rechner
// ============================================================
showSunCalc: function() {
var panel = document.getElementById('geoint-panel');
if (!panel) return;
// Default: aktuelle Cursor-Position oder Kartenmitte
var cam = Globe.viewer.camera.positionCartographic;
var lat = cam ? Cesium.Math.toDegrees(cam.latitude).toFixed(4) : '50.0';
var lon = cam ? Cesium.Math.toDegrees(cam.longitude).toFixed(4) : '10.0';
var now = new Date().toISOString().slice(0, 16);
panel.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">' +
'<div style="font-size:11px;font-weight:700;letter-spacing:2px;color:var(--accent)">SONNENSTAND</div>' +
'<button onclick="GeointTools.closePanel()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:18px">&times;</button>' +
'</div>' +
'<div style="display:flex;flex-direction:column;gap:6px;font-size:11px">' +
'<div style="display:flex;gap:6px">' +
'<div style="flex:1"><label style="color:var(--text-dim)">Breitengrad</label><input type="number" id="sun-lat" value="' + lat + '" step="0.01" class="geoint-input"></div>' +
'<div style="flex:1"><label style="color:var(--text-dim)">Laengengrad</label><input type="number" id="sun-lon" value="' + lon + '" step="0.01" class="geoint-input"></div>' +
'</div>' +
'<div><label style="color:var(--text-dim)">Datum / Uhrzeit (UTC)</label><input type="datetime-local" id="sun-datetime" value="' + now + '" class="geoint-input"></div>' +
'<button class="overpass-exec-btn" onclick="GeointTools._calcSun()" style="margin-top:4px">BERECHNEN</button>' +
'</div>' +
'<div id="sun-result" style="margin-top:10px"></div>';
panel.style.display = 'block';
},
_calcSun: function() {
var lat = parseFloat(document.getElementById('sun-lat').value);
var lon = parseFloat(document.getElementById('sun-lon').value);
var dt = document.getElementById('sun-datetime').value;
if (isNaN(lat) || isNaN(lon) || !dt) return;
var resultEl = document.getElementById('sun-result');
resultEl.innerHTML = '<div style="color:var(--text-dim);font-size:11px">Berechne...</div>';
fetch('/api/geoint/sun-position', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude: lat, longitude: lon, datetime_iso: dt + ':00Z' }),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.detail) { resultEl.innerHTML = '<div style="color:#ff5252">' + data.detail + '</div>'; return; }
var dayIcon = data.is_daylight ? '(Tag)' : '(Nacht)';
var golden = data.golden_hour ? ' | Golden Hour' : '';
resultEl.innerHTML =
'<div class="geoint-result-grid">' +
'<div class="geoint-result-item"><span class="geoint-label">Azimut</span><span class="geoint-value">' + data.azimuth + '&deg;</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Elevation</span><span class="geoint-value">' + data.elevation + '&deg; ' + dayIcon + '</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Schattenrichtung</span><span class="geoint-value">' + data.shadow_direction + '&deg;</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Schattenverhaeltnis</span><span class="geoint-value">' + (data.shadow_ratio !== null ? data.shadow_ratio + 'x Objekthoehe' : 'Kein Schatten') + '</span></div>' +
(golden ? '<div class="geoint-result-item" style="grid-column:1/-1"><span class="geoint-label" style="color:#ff9800">Golden Hour aktiv</span></div>' : '') +
'</div>';
})
.catch(function(e) { resultEl.innerHTML = '<div style="color:#ff5252">Fehler: ' + e.message + '</div>'; });
},
// ============================================================
// Hoehenprofil (2-Punkte-Tool)
// ============================================================
startElevation: function() {
if (this._elevMode) { this.stopElevation(); return; }
this._elevMode = true;
this._elevStart = null;
var panel = document.getElementById('geoint-panel');
if (panel) {
panel.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">' +
'<div style="font-size:11px;font-weight:700;letter-spacing:2px;color:var(--accent)">HOEHENPROFIL</div>' +
'<button onclick="GeointTools.stopElevation()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:18px">&times;</button>' +
'</div>' +
'<div id="elev-status" style="font-size:12px;color:var(--accent)">Klicke Startpunkt auf den Globus</div>' +
'<div id="elev-result" style="margin-top:10px"></div>';
panel.style.display = 'block';
}
// Polyline + Labels fuer Visualisierung
this._elevLine = Globe.viewer.scene.primitives.add(new Cesium.PolylineCollection());
this._elevLabels = Globe.viewer.scene.primitives.add(new Cesium.LabelCollection());
var self = this;
this._elevHandler = new Cesium.ScreenSpaceEventHandler(Globe.viewer.scene.canvas);
this._elevHandler.setInputAction(function(click) {
var cart = Globe.viewer.scene.pickPosition(click.position);
if (!cart) {
var ray = Globe.viewer.scene.camera.getPickRay(click.position);
cart = Globe.viewer.scene.globe.pick(ray, Globe.viewer.scene);
}
if (!cart) return;
var c = Cesium.Cartographic.fromCartesian(cart);
var lat = Cesium.Math.toDegrees(c.latitude);
var lon = Cesium.Math.toDegrees(c.longitude);
if (!self._elevStart) {
self._elevStart = { lat: lat, lon: lon };
// Marker setzen
self._elevLabels.add({
position: cart, text: 'A',
font: '14px monospace', fillColor: Cesium.Color.fromCssColorString('#00ff88'),
outlineColor: Cesium.Color.BLACK, outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -14),
});
var statusEl = document.getElementById('elev-status');
if (statusEl) statusEl.textContent = 'Klicke Endpunkt auf den Globus';
} else {
// Endpunkt
self._elevLabels.add({
position: cart, text: 'B',
font: '14px monospace', fillColor: Cesium.Color.fromCssColorString('#ff5252'),
outlineColor: Cesium.Color.BLACK, outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -14),
});
// Linie
var startCart = Cesium.Cartesian3.fromDegrees(self._elevStart.lon, self._elevStart.lat, 100);
var endCart = Cesium.Cartesian3.fromDegrees(lon, lat, 100);
self._elevLine.add({
positions: [startCart, endCart], width: 2,
material: Cesium.Material.fromType('Color', { color: Cesium.Color.fromCssColorString('#00ff88').withAlpha(0.6) }),
});
self._fetchElevation(self._elevStart.lat, self._elevStart.lon, lat, lon);
if (self._elevHandler) { self._elevHandler.destroy(); self._elevHandler = null; }
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
_fetchElevation: function(sLat, sLon, eLat, eLon) {
var statusEl = document.getElementById('elev-status');
if (statusEl) statusEl.textContent = 'Berechne Hoehenprofil...';
fetch('/api/geoint/elevation-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_lat: sLat, start_lon: sLon, end_lat: eLat, end_lon: eLon, samples: 80 }),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.detail) { if (statusEl) statusEl.textContent = 'Fehler: ' + data.detail; return; }
if (statusEl) statusEl.textContent = '';
GeointTools._renderElevation(data);
})
.catch(function(e) { if (statusEl) statusEl.textContent = 'Fehler: ' + e.message; });
},
_renderElevation: function(data) {
var resultEl = document.getElementById('elev-result');
if (!resultEl) return;
var s = data.stats;
var los = data.line_of_sight;
var losColor = los.clear ? '#00ff88' : '#ff5252';
var losText = los.clear ? 'Frei' : 'Blockiert';
// SVG Chart
var points = data.points;
var maxElev = s.elevation_max + 10;
var minElev = Math.max(0, s.elevation_min - 10);
var range = maxElev - minElev || 1;
var w = 280, h = 100;
var pathD = '';
for (var i = 0; i < points.length; i++) {
var x = (i / (points.length - 1)) * w;
var y = h - ((points[i].elevation - minElev) / range) * h;
pathD += (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1);
}
// LOS line
var losY1 = h - ((points[0].elevation - minElev) / range) * h;
var losY2 = h - ((points[points.length - 1].elevation - minElev) / range) * h;
resultEl.innerHTML =
'<svg width="' + w + '" height="' + (h + 20) + '" style="display:block;margin-bottom:8px">' +
'<defs><linearGradient id="elev-grad" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#00ff88" stop-opacity="0.3"/>' +
'<stop offset="1" stop-color="#00ff88" stop-opacity="0.02"/>' +
'</linearGradient></defs>' +
'<path d="' + pathD + 'L' + w + ',' + (h + 5) + 'L0,' + (h + 5) + 'Z" fill="url(#elev-grad)" stroke="none"/>' +
'<path d="' + pathD + '" fill="none" stroke="#00ff88" stroke-width="1.5"/>' +
'<line x1="0" y1="' + losY1.toFixed(1) + '" x2="' + w + '" y2="' + losY2.toFixed(1) + '" stroke="' + losColor + '" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>' +
'<text x="2" y="' + (h + 14) + '" fill="var(--text-dim)" font-size="9" font-family="monospace">0 km</text>' +
'<text x="' + (w - 2) + '" y="' + (h + 14) + '" fill="var(--text-dim)" font-size="9" font-family="monospace" text-anchor="end">' + s.distance_km + ' km</text>' +
'</svg>' +
'<div class="geoint-result-grid">' +
'<div class="geoint-result-item"><span class="geoint-label">Distanz</span><span class="geoint-value">' + s.distance_km + ' km</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Sichtlinie</span><span class="geoint-value" style="color:' + losColor + '">' + losText + '</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Min / Max</span><span class="geoint-value">' + s.elevation_min + ' / ' + s.elevation_max + ' m</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Hoehendiff.</span><span class="geoint-value">' + s.elevation_diff + ' m</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Anstieg</span><span class="geoint-value">+' + s.total_ascent + ' m</span></div>' +
'<div class="geoint-result-item"><span class="geoint-label">Abstieg</span><span class="geoint-value">-' + s.total_descent + ' m</span></div>' +
'</div>' +
'<button class="overpass-exec-btn" onclick="GeointTools.startElevation()" style="margin-top:8px;font-size:10px;padding:6px">NEUE MESSUNG</button>';
},
stopElevation: function() {
this._elevMode = false;
this._elevStart = null;
if (this._elevHandler) { this._elevHandler.destroy(); this._elevHandler = null; }
if (this._elevLine && Globe.viewer) { Globe.viewer.scene.primitives.remove(this._elevLine); this._elevLine = null; }
if (this._elevLabels && Globe.viewer) { Globe.viewer.scene.primitives.remove(this._elevLabels); this._elevLabels = null; }
this.closePanel();
},
closePanel: function() {
var panel = document.getElementById('geoint-panel');
if (panel) { panel.style.display = 'none'; panel.innerHTML = ''; }
},
};

Datei anzeigen

@@ -189,13 +189,55 @@ const VlmUI = {
var resetBtn = document.getElementById('vlm-reset-btn');
if (!resultsEl) return;
// EXIF-Metadaten
var exifHtml = '';
if (data.exif) {
var e = data.exif;
var ep = [];
if (e.has_gps) ep.push('<span style="color:var(--accent)">GPS: ' + e.latitude + ', ' + e.longitude + '</span>');
if (e.altitude) ep.push('Hoehe: ' + e.altitude + 'm');
if (e.camera_model) ep.push(e.camera_make ? e.camera_make + ' ' + e.camera_model : e.camera_model);
if (e.focal_length) ep.push(e.focal_length + 'mm');
if (e.timestamp) ep.push(e.timestamp.replace('T', ' '));
if (e.compass_heading) ep.push('Heading: ' + e.compass_heading + '\u00B0');
if (ep.length > 0) {
exifHtml = '<div class="vlm-exif-card"><div style="font-size:9px;letter-spacing:1.5px;color:#e040fb;margin-bottom:4px">EXIF METADATEN</div>' +
'<div style="font-size:11px;color:var(--text);line-height:1.6">' + ep.join(' &middot; ') + '</div></div>';
if (e.has_gps) {
exifHtml += '<button class="geoint-tool-btn" style="width:100%;margin-top:4px" onclick="Globe.viewer.camera.flyTo({destination:Cesium.Cartesian3.fromDegrees(' + e.longitude + ',' + e.latitude + ',50000),duration:2})">GPS-Position anfliegen</button>';
}
} else {
exifHtml = '<div class="vlm-exif-card"><div style="font-size:9px;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:2px">EXIF</div><div style="font-size:11px;color:var(--text-dim)">Keine Metadaten gefunden</div></div>';
}
}
// Landscape Clues (Reverse Geolocation)
var cluesHtml = '';
if (data.landscape_clues) {
var lc = data.landscape_clues;
var cl = [];
if (lc.vegetation) cl.push('<b>Vegetation:</b> ' + lc.vegetation);
if (lc.soil_color) cl.push('<b>Boden:</b> ' + lc.soil_color);
if (lc.road_markings) cl.push('<b>Strassen:</b> ' + lc.road_markings);
if (lc.architecture_style) cl.push('<b>Architektur:</b> ' + lc.architecture_style);
if (lc.signage_language) cl.push('<b>Schilder:</b> ' + lc.signage_language);
if (lc.vehicle_types) cl.push('<b>Fahrzeuge:</b> ' + lc.vehicle_types);
if (lc.climate_indicators) cl.push('<b>Klima:</b> ' + lc.climate_indicators);
if (lc.sun_shadow_direction) cl.push('<b>Schatten:</b> ' + lc.sun_shadow_direction);
if (cl.length > 0) {
cluesHtml = '<div style="font-size:9px;letter-spacing:1.5px;color:#ff9800;margin:8px 0 4px">LANDSCHAFTSMERKMALE</div>' +
'<div style="font-size:11px;color:var(--text);line-height:1.8">' + cl.join('<br>') + '</div>';
}
}
// Szene
var sceneEl = document.getElementById('vlm-scene');
if (sceneEl) {
var text = data.scene_description || 'Keine Beschreibung';
if (data.terrain) text += ' | Gelände: ' + data.terrain;
if (data.estimated_location_type) text += ' | Region: ' + data.estimated_location_type;
sceneEl.textContent = text;
sceneEl.innerHTML = exifHtml +
'<div style="margin-top:8px">' + (data.scene_description || 'Keine Beschreibung') +
(data.terrain ? ' | Gelaende: ' + data.terrain : '') +
(data.estimated_location_type ? ' | Region: ' + data.estimated_location_type : '') +
'</div>' + cluesHtml;
}
// Objekte