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:
387
src/data_geoint.py
Normale Datei
387
src/data_geoint.py
Normale Datei
@@ -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
|
||||||
@@ -8,6 +8,7 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from data_geoint import extract_exif
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -68,6 +69,19 @@ _VLM_SCHEMA = json.dumps({
|
|||||||
"estimated_location_type": {
|
"estimated_location_type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Geschaetzter Standort-Typ (z.B. Europa, Naher Osten, Nordamerika)"
|
"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"]
|
"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. "
|
"Analysiere das Bild fuer GEOINT/OSINT-Zwecke. "
|
||||||
"Identifiziere alle erkennbaren Objekte, Infrastruktur und militaerisch relevante Strukturen. "
|
"Identifiziere alle erkennbaren Objekte, Infrastruktur und militaerisch relevante Strukturen. "
|
||||||
"Gib fuer jedes Objekt passende OpenStreetMap-Tags an. "
|
"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."
|
"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)")
|
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
|
# Claude Code aufrufen
|
||||||
result = await _run_claude(resized_path, viewport_info)
|
result = await _run_claude(resized_path, viewport_info)
|
||||||
|
|
||||||
|
# EXIF-Daten zum Ergebnis hinzufuegen
|
||||||
|
result["exif"] = exif_data
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte erkannt"
|
f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte erkannt"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from data_infra import router as infra_router
|
|||||||
from data_monitor import router as monitor_router
|
from data_monitor import router as monitor_router
|
||||||
from data_overpass import router as overpass_router
|
from data_overpass import router as overpass_router
|
||||||
from data_vlm import router as vlm_router
|
from data_vlm import router as vlm_router
|
||||||
|
from data_geoint import router as geoint_router
|
||||||
from data_push import start_push_service
|
from data_push import start_push_service
|
||||||
|
|
||||||
# Alle Daten-APIs hinter Auth
|
# 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(monitor_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
app.include_router(overpass_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(vlm_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
|
app.include_router(geoint_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
# --- Static files ---
|
# --- Static files ---
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
|
|||||||
@@ -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 { width: 4px; }
|
||||||
.vlm-panel-right::-webkit-scrollbar-track { background: transparent; }
|
.vlm-panel-right::-webkit-scrollbar-track { background: transparent; }
|
||||||
.vlm-panel-right::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,6 +114,25 @@
|
|||||||
<span class="layer-name" title="Satellitenbild-Analyse mit Claude VLM">Bildanalyse</span>
|
<span class="layer-name" title="Satellitenbild-Analyse mit Claude VLM">Bildanalyse</span>
|
||||||
<span class="layer-count" id="count-vlm">-</span>
|
<span class="layer-count" id="count-vlm">-</span>
|
||||||
</label>
|
</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">
|
<label class="layer-toggle">
|
||||||
<input type="checkbox" id="layer-iss" title="ISS Echtzeit-Position (5s Refresh)">
|
<input type="checkbox" id="layer-iss" title="ISS Echtzeit-Position (5s Refresh)">
|
||||||
<span class="layer-dot dot-iss"></span>
|
<span class="layer-dot dot-iss"></span>
|
||||||
@@ -204,6 +223,8 @@
|
|||||||
|
|
||||||
<!-- VLM Bildanalyse Panel -->
|
<!-- VLM Bildanalyse Panel -->
|
||||||
<div id="vlm-panel" class="vlm-panel-right" style="display:none"></div>
|
<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">
|
<aside id="sidebar-right" class="sidebar-right">
|
||||||
<button id="sidebar-toggle" class="sidebar-toggle" title="Seitenleiste ein-/ausblenden">
|
<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>
|
<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/infra.js"></script>
|
||||||
<script src="/static/js/layers/overpass.js"></script>
|
<script src="/static/js/layers/overpass.js"></script>
|
||||||
<script src="/static/js/ui/vlm.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/iss.js"></script>
|
||||||
<script src="/static/js/layers/terminator.js"></script>
|
<script src="/static/js/layers/terminator.js"></script>
|
||||||
<script src="/static/js/layers/timezones.js"></script>
|
<script src="/static/js/layers/timezones.js"></script>
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ const Globe = {
|
|||||||
if (sidebar) sidebar.style.display = '';
|
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-iss': function(on) { on ? ISSLayer.start(Globe.viewer) : ISSLayer.stop(); },
|
||||||
'layer-disasters': function(on) { on ? DisastersLayer.start(Globe.viewer) : DisastersLayer.stop(); },
|
'layer-disasters': function(on) { on ? DisastersLayer.start(Globe.viewer) : DisastersLayer.stop(); },
|
||||||
'layer-weather': function(on) { on ? WeatherLayer.start(Globe.viewer) : WeatherLayer.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) {
|
if (typeof GdeltLayer !== 'undefined' && GdeltLayer._count > 0) {
|
||||||
document.getElementById('count-gdelt').textContent = GdeltLayer._count.toLocaleString('de-DE');
|
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) {
|
if (parts.length) {
|
||||||
document.getElementById('bottom-stats').textContent = parts.join(' | ');
|
document.getElementById('bottom-stats').textContent = parts.join(' | ');
|
||||||
}
|
}
|
||||||
|
|||||||
120
static/js/layers/celltowers.js
Normale Datei
120
static/js/layers/celltowers.js
Normale Datei
@@ -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'); }
|
||||||
|
},
|
||||||
|
};
|
||||||
36
static/js/layers/nightlights.js
Normale Datei
36
static/js/layers/nightlights.js
Normale Datei
@@ -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
221
static/js/ui/geoint.js
Normale Datei
@@ -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">×</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 + '°</span></div>' +
|
||||||
|
'<div class="geoint-result-item"><span class="geoint-label">Elevation</span><span class="geoint-value">' + data.elevation + '° ' + dayIcon + '</span></div>' +
|
||||||
|
'<div class="geoint-result-item"><span class="geoint-label">Schattenrichtung</span><span class="geoint-value">' + data.shadow_direction + '°</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">×</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 = ''; }
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -189,13 +189,55 @@ const VlmUI = {
|
|||||||
var resetBtn = document.getElementById('vlm-reset-btn');
|
var resetBtn = document.getElementById('vlm-reset-btn');
|
||||||
if (!resultsEl) return;
|
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(' · ') + '</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
|
// Szene
|
||||||
var sceneEl = document.getElementById('vlm-scene');
|
var sceneEl = document.getElementById('vlm-scene');
|
||||||
if (sceneEl) {
|
if (sceneEl) {
|
||||||
var text = data.scene_description || 'Keine Beschreibung';
|
sceneEl.innerHTML = exifHtml +
|
||||||
if (data.terrain) text += ' | Gelände: ' + data.terrain;
|
'<div style="margin-top:8px">' + (data.scene_description || 'Keine Beschreibung') +
|
||||||
if (data.estimated_location_type) text += ' | Region: ' + data.estimated_location_type;
|
(data.terrain ? ' | Gelaende: ' + data.terrain : '') +
|
||||||
sceneEl.textContent = text;
|
(data.estimated_location_type ? ' | Region: ' + data.estimated_location_type : '') +
|
||||||
|
'</div>' + cluesHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Objekte
|
// Objekte
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren