Feature: Overpass Turbo + VLM-Bildanalyse
Overpass Turbo Integration: - POST /api/overpass/query: OverpassQL-Proxy mit Caching, Rate-Limiting, Fallback - GET /api/overpass/templates: 30 OSINT-Templates in 6 Kategorien - Frontend: Query-Editor Panel mit Template-Browser, Viewport-BBox - Layer: Nodes/Ways/Relations Rendering auf CesiumJS mit OSM-Tag InfoBox VLM-Bildanalyse: - POST /api/vlm/analyze: Bildupload -> Claude Code headless (Sonnet) -> GEOINT-Analyse - POST /api/vlm/generate-queries: VLM-Erkennungen -> OverpassQL - Frontend: Drag&Drop Upload, Zwei-Stufen-Workflow (Analyse -> Overpass-Suche) - Bild-Resize (Pillow), asyncio Subprocess, Semaphore (max 1 parallel) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
451
src/data_overpass.py
Normale Datei
451
src/data_overpass.py
Normale Datei
@@ -0,0 +1,451 @@
|
|||||||
|
"""Overpass Turbo: OverpassQL-Queries gegen OpenStreetMap ausfuehren."""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger("globe.overpass")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# --- Rate Limiting ---
|
||||||
|
_last_request_time = 0.0
|
||||||
|
_RATE_LIMIT_SECONDS = 10 # Overpass Fair-Use Policy
|
||||||
|
|
||||||
|
# --- Cache ---
|
||||||
|
_cache = {}
|
||||||
|
_CACHE_TTL = 300 # 5 Minuten
|
||||||
|
_MAX_CACHE_ENTRIES = 50
|
||||||
|
|
||||||
|
# --- Overpass API ---
|
||||||
|
_OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
_OVERPASS_FALLBACK = "https://overpass.kumi.systems/api/interpreter"
|
||||||
|
_MAX_ELEMENTS = 5000
|
||||||
|
_TIMEOUT = 60
|
||||||
|
|
||||||
|
|
||||||
|
class OverpassRequest(BaseModel):
|
||||||
|
query: str = Field(..., max_length=4000)
|
||||||
|
bbox: list[float] | None = Field(None, min_length=4, max_length=4)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Template-Bibliothek ---
|
||||||
|
_TEMPLATES = [
|
||||||
|
# === Militaerisch ===
|
||||||
|
{
|
||||||
|
"id": "mil_bases",
|
||||||
|
"name": "Militaerbasen",
|
||||||
|
"category": "military",
|
||||||
|
"description": "Militaerische Einrichtungen und Stuetzpunkte",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"military\"]({{bbox}});\n way[\"military\"]({{bbox}});\n relation[\"military\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mil_airfields",
|
||||||
|
"name": "Militaerflughaefen",
|
||||||
|
"category": "military",
|
||||||
|
"description": "Militaerische Flugplaetze und Luftwaffenstuetzpunkte",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"aeroway\"=\"aerodrome\"][\"military\"]({{bbox}});\n way[\"aeroway\"=\"aerodrome\"][\"military\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mil_naval",
|
||||||
|
"name": "Marinestuetzpunkte",
|
||||||
|
"category": "military",
|
||||||
|
"description": "Marinehaefen und Flottenbasen",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"military\"=\"naval_base\"]({{bbox}});\n way[\"military\"=\"naval_base\"]({{bbox}});\n node[\"landuse\"=\"military\"][\"name\"~\"[Nn]av|[Ff]leet|[Mm]arine|[Ff]lott\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mil_radar",
|
||||||
|
"name": "Radarstationen",
|
||||||
|
"category": "military",
|
||||||
|
"description": "Militaerische und zivile Radaranlagen",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"surveillance\"][\"surveillance:type\"=\"radar\"]({{bbox}});\n node[\"military\"=\"radar\"]({{bbox}});\n node[\"radar\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mil_range",
|
||||||
|
"name": "Uebungsplaetze",
|
||||||
|
"category": "military",
|
||||||
|
"description": "Militaerische Uebungsgelaende und Schiessplaetze",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"military\"=\"range\"]({{bbox}});\n way[\"military\"=\"range\"]({{bbox}});\n relation[\"military\"=\"range\"]({{bbox}});\n node[\"military\"=\"training_area\"]({{bbox}});\n way[\"military\"=\"training_area\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mil_bunker",
|
||||||
|
"name": "Bunker / Munitionslager",
|
||||||
|
"category": "military",
|
||||||
|
"description": "Bunkeranlagen und Munitionsdepots",
|
||||||
|
"color": "#ff4444",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"military\"=\"bunker\"]({{bbox}});\n way[\"military\"=\"bunker\"]({{bbox}});\n node[\"military\"=\"ammunition\"]({{bbox}});\n way[\"military\"=\"ammunition\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
# === Transport ===
|
||||||
|
{
|
||||||
|
"id": "tr_airports",
|
||||||
|
"name": "Flughaefen",
|
||||||
|
"category": "transport",
|
||||||
|
"description": "Zivile und gemischte Flughaefen",
|
||||||
|
"color": "#4499ff",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"aeroway\"=\"aerodrome\"]({{bbox}});\n way[\"aeroway\"=\"aerodrome\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tr_ports",
|
||||||
|
"name": "Haefen",
|
||||||
|
"category": "transport",
|
||||||
|
"description": "See- und Binnenhaefen",
|
||||||
|
"color": "#4499ff",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"harbour\"]({{bbox}});\n way[\"harbour\"]({{bbox}});\n node[\"landuse\"=\"port\"]({{bbox}});\n way[\"landuse\"=\"port\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tr_railways",
|
||||||
|
"name": "Bahnhoefe",
|
||||||
|
"category": "transport",
|
||||||
|
"description": "Bahnhoefe und Haltestellen",
|
||||||
|
"color": "#4499ff",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"railway\"=\"station\"]({{bbox}});\n node[\"railway\"=\"halt\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tr_bridges",
|
||||||
|
"name": "Bruecken",
|
||||||
|
"category": "transport",
|
||||||
|
"description": "Grosse Bruecken und Uebergaenge",
|
||||||
|
"color": "#4499ff",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n way[\"bridge\"=\"yes\"][\"name\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tr_tunnels",
|
||||||
|
"name": "Tunnel",
|
||||||
|
"category": "transport",
|
||||||
|
"description": "Strassen- und Eisenbahntunnel",
|
||||||
|
"color": "#4499ff",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n way[\"tunnel\"=\"yes\"][\"name\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
# === Energie ===
|
||||||
|
{
|
||||||
|
"id": "en_nuclear",
|
||||||
|
"name": "Kernkraftwerke",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Atomkraftwerke und Reaktoren",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"generator:source\"=\"nuclear\"]({{bbox}});\n way[\"generator:source\"=\"nuclear\"]({{bbox}});\n node[\"plant:source\"=\"nuclear\"]({{bbox}});\n way[\"plant:source\"=\"nuclear\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_power",
|
||||||
|
"name": "Kraftwerke",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Alle Arten von Kraftwerken",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"power\"=\"plant\"]({{bbox}});\n way[\"power\"=\"plant\"]({{bbox}});\n relation[\"power\"=\"plant\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_substations",
|
||||||
|
"name": "Umspannwerke",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Elektrische Umspannwerke",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"power\"=\"substation\"]({{bbox}});\n way[\"power\"=\"substation\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_pipelines",
|
||||||
|
"name": "Pipelines",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Oel- und Gas-Pipelines",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n way[\"man_made\"=\"pipeline\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_refineries",
|
||||||
|
"name": "Raffinerien",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Oelraffinerien und Verarbeitungsanlagen",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"petroleum_well\"]({{bbox}});\n way[\"industrial\"=\"refinery\"]({{bbox}});\n node[\"industrial\"=\"refinery\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_dams",
|
||||||
|
"name": "Staudaemme",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Staudaemme und Talsperren",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n way[\"waterway\"=\"dam\"]({{bbox}});\n node[\"waterway\"=\"dam\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_solar",
|
||||||
|
"name": "Solarparks",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Photovoltaik-Grossanlagen",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n way[\"plant:source\"=\"solar\"]({{bbox}});\n way[\"generator:source\"=\"solar\"][\"generator:output:electricity\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_wind",
|
||||||
|
"name": "Windparks",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Windkraftanlagen",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"generator:source\"=\"wind\"]({{bbox}});\n way[\"plant:source\"=\"wind\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "en_storage",
|
||||||
|
"name": "Tanklager",
|
||||||
|
"category": "energy",
|
||||||
|
"description": "Oel- und Gastanklager, Speichertanks",
|
||||||
|
"color": "#ffdd00",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"storage_tank\"]({{bbox}});\n way[\"man_made\"=\"storage_tank\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
# === Kommunikation ===
|
||||||
|
{
|
||||||
|
"id": "com_towers",
|
||||||
|
"name": "Sendemasten",
|
||||||
|
"category": "communication",
|
||||||
|
"description": "Funk- und Fernmeldetuerme",
|
||||||
|
"color": "#e040fb",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"mast\"][\"tower:type\"=\"communication\"]({{bbox}});\n node[\"man_made\"=\"tower\"][\"tower:type\"=\"communication\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "com_datacenters",
|
||||||
|
"name": "Rechenzentren",
|
||||||
|
"category": "communication",
|
||||||
|
"description": "Datenzentren und Serverfarmen",
|
||||||
|
"color": "#e040fb",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"telecom\"=\"data_center\"]({{bbox}});\n way[\"telecom\"=\"data_center\"]({{bbox}});\n node[\"building\"=\"data_centre\"]({{bbox}});\n way[\"building\"=\"data_centre\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "com_satellite",
|
||||||
|
"name": "Satellitenbodenstationen",
|
||||||
|
"category": "communication",
|
||||||
|
"description": "Satellitenkommunikations-Anlagen",
|
||||||
|
"color": "#e040fb",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"antenna\"][\"antenna:type\"=\"dish\"]({{bbox}});\n node[\"landuse\"=\"observatory\"]({{bbox}});\n way[\"landuse\"=\"observatory\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "com_cables_landing",
|
||||||
|
"name": "Seekabel-Landepunkte",
|
||||||
|
"category": "communication",
|
||||||
|
"description": "Unterseekabel-Anlandestationen",
|
||||||
|
"color": "#e040fb",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"telecom\"=\"landing_point\"]({{bbox}});\n node[\"communication\"=\"line\"][\"location\"=\"underwater\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
# === Regierung ===
|
||||||
|
{
|
||||||
|
"id": "gov_embassies",
|
||||||
|
"name": "Botschaften / Konsulate",
|
||||||
|
"category": "government",
|
||||||
|
"description": "Diplomatische Vertretungen",
|
||||||
|
"color": "#ff9800",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"embassy\"]({{bbox}});\n way[\"amenity\"=\"embassy\"]({{bbox}});\n node[\"office\"=\"diplomatic\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gov_buildings",
|
||||||
|
"name": "Regierungsgebaeude",
|
||||||
|
"category": "government",
|
||||||
|
"description": "Parlamente, Ministerien, Regierungssitze",
|
||||||
|
"color": "#ff9800",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"office\"=\"government\"]({{bbox}});\n way[\"office\"=\"government\"]({{bbox}});\n way[\"building\"=\"government\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gov_prisons",
|
||||||
|
"name": "Gefaengnisse",
|
||||||
|
"category": "government",
|
||||||
|
"description": "Justizvollzugsanstalten und Haftzentren",
|
||||||
|
"color": "#ff9800",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"prison\"]({{bbox}});\n way[\"amenity\"=\"prison\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gov_police",
|
||||||
|
"name": "Polizeistationen",
|
||||||
|
"category": "government",
|
||||||
|
"description": "Polizeiwachen und -dienststellen",
|
||||||
|
"color": "#ff9800",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"police\"]({{bbox}});\n way[\"amenity\"=\"police\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
# === Kritische Infrastruktur ===
|
||||||
|
{
|
||||||
|
"id": "crit_hospitals",
|
||||||
|
"name": "Krankenhaeuser",
|
||||||
|
"category": "critical",
|
||||||
|
"description": "Krankenhaeuser und Kliniken",
|
||||||
|
"color": "#00bcd4",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"hospital\"]({{bbox}});\n way[\"amenity\"=\"hospital\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "crit_water",
|
||||||
|
"name": "Wasserwerke",
|
||||||
|
"category": "critical",
|
||||||
|
"description": "Wasseraufbereitung und -versorgung",
|
||||||
|
"color": "#00bcd4",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"man_made\"=\"water_works\"]({{bbox}});\n way[\"man_made\"=\"water_works\"]({{bbox}});\n node[\"man_made\"=\"wastewater_plant\"]({{bbox}});\n way[\"man_made\"=\"wastewater_plant\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "crit_fire",
|
||||||
|
"name": "Feuerwachen",
|
||||||
|
"category": "critical",
|
||||||
|
"description": "Feuerwehrstationen",
|
||||||
|
"color": "#00bcd4",
|
||||||
|
"query": "[out:json][timeout:25];\n(\n node[\"amenity\"=\"fire_station\"]({{bbox}});\n way[\"amenity\"=\"fire_station\"]({{bbox}});\n);\nout center body;",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_cleanup():
|
||||||
|
"""Entfernt aelteste Eintraege wenn Cache zu gross."""
|
||||||
|
if len(_cache) > _MAX_CACHE_ENTRIES:
|
||||||
|
sorted_keys = sorted(_cache, key=lambda k: _cache[k][0])
|
||||||
|
for k in sorted_keys[: len(_cache) - _MAX_CACHE_ENTRIES]:
|
||||||
|
del _cache[k]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overpass/templates")
|
||||||
|
async def get_templates():
|
||||||
|
"""Liefert die Template-Bibliothek."""
|
||||||
|
categories = {}
|
||||||
|
for t in _TEMPLATES:
|
||||||
|
cat = t["category"]
|
||||||
|
if cat not in categories:
|
||||||
|
categories[cat] = []
|
||||||
|
categories[cat].append(
|
||||||
|
{
|
||||||
|
"id": t["id"],
|
||||||
|
"name": t["name"],
|
||||||
|
"description": t["description"],
|
||||||
|
"color": t["color"],
|
||||||
|
"query": t["query"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cat_labels = {
|
||||||
|
"military": "Militaerisch",
|
||||||
|
"transport": "Transport",
|
||||||
|
"energy": "Energie",
|
||||||
|
"communication": "Kommunikation",
|
||||||
|
"government": "Regierung",
|
||||||
|
"critical": "Kritische Infrastruktur",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"categories": [
|
||||||
|
{"id": cid, "label": cat_labels.get(cid, cid), "templates": tpls}
|
||||||
|
for cid, tpls in categories.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/overpass/query")
|
||||||
|
async def execute_query(req: OverpassRequest):
|
||||||
|
"""Fuehrt eine OverpassQL-Query aus (gecacht, rate-limited)."""
|
||||||
|
global _last_request_time
|
||||||
|
|
||||||
|
query = req.query.strip()
|
||||||
|
if not query:
|
||||||
|
raise HTTPException(400, "Query darf nicht leer sein")
|
||||||
|
|
||||||
|
# BBox-Platzhalter ersetzen
|
||||||
|
if req.bbox and "{{bbox}}" in query:
|
||||||
|
bbox_str = f"{req.bbox[0]},{req.bbox[1]},{req.bbox[2]},{req.bbox[3]}"
|
||||||
|
query = query.replace("{{bbox}}", bbox_str)
|
||||||
|
elif "{{bbox}}" in query and not req.bbox:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"Query enthaelt {{bbox}} aber keine BBox wurde mitgesendet. "
|
||||||
|
"Aktiviere 'Viewport als BBox'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache pruefen
|
||||||
|
cache_key = hashlib.md5(query.encode()).hexdigest()
|
||||||
|
if cache_key in _cache and time.time() - _cache[cache_key][0] < _CACHE_TTL:
|
||||||
|
cached = _cache[cache_key][1]
|
||||||
|
logger.info(f"Cache-Hit: {cache_key} ({cached.get('total', '?')} Elemente)")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Rate-Limiting
|
||||||
|
now = time.time()
|
||||||
|
elapsed = now - _last_request_time
|
||||||
|
if elapsed < _RATE_LIMIT_SECONDS:
|
||||||
|
wait = _RATE_LIMIT_SECONDS - elapsed
|
||||||
|
raise HTTPException(
|
||||||
|
429,
|
||||||
|
f"Rate-Limit: Bitte {wait:.0f}s warten (Overpass Fair-Use)",
|
||||||
|
headers={"Retry-After": str(int(wait) + 1)},
|
||||||
|
)
|
||||||
|
|
||||||
|
_last_request_time = time.time()
|
||||||
|
|
||||||
|
# Query ausfuehren
|
||||||
|
data = None
|
||||||
|
for url in [_OVERPASS_URL, _OVERPASS_FALLBACK]:
|
||||||
|
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"Overpass {url}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(502, "Overpass API nicht erreichbar")
|
||||||
|
|
||||||
|
elements = data.get("elements", [])
|
||||||
|
|
||||||
|
# Elemente aufteilen
|
||||||
|
nodes = []
|
||||||
|
ways = []
|
||||||
|
relations = []
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for el in elements:
|
||||||
|
if total >= _MAX_ELEMENTS:
|
||||||
|
break
|
||||||
|
total += 1
|
||||||
|
el_type = el.get("type")
|
||||||
|
tags = el.get("tags", {})
|
||||||
|
name = tags.get("name", "")
|
||||||
|
|
||||||
|
if el_type == "node":
|
||||||
|
nodes.append({
|
||||||
|
"id": el.get("id"),
|
||||||
|
"lat": el.get("lat"),
|
||||||
|
"lon": el.get("lon"),
|
||||||
|
"tags": tags,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
elif el_type == "way":
|
||||||
|
center = el.get("center", {})
|
||||||
|
geom = el.get("geometry", [])
|
||||||
|
ways.append({
|
||||||
|
"id": el.get("id"),
|
||||||
|
"lat": center.get("lat"),
|
||||||
|
"lon": center.get("lon"),
|
||||||
|
"geometry": geom,
|
||||||
|
"tags": tags,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
elif el_type == "relation":
|
||||||
|
center = el.get("center", {})
|
||||||
|
relations.append({
|
||||||
|
"id": el.get("id"),
|
||||||
|
"lat": center.get("lat"),
|
||||||
|
"lon": center.get("lon"),
|
||||||
|
"tags": tags,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"nodes": nodes,
|
||||||
|
"ways": ways,
|
||||||
|
"relations": relations,
|
||||||
|
"total": total,
|
||||||
|
"truncated": len(elements) > _MAX_ELEMENTS,
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cachen
|
||||||
|
_cache[cache_key] = (time.time(), {**result, "cached": True})
|
||||||
|
_cache_cleanup()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Overpass: {total} Elemente "
|
||||||
|
f"({len(nodes)} Nodes, {len(ways)} Ways, {len(relations)} Relations)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
300
src/data_vlm.py
Normale Datei
300
src/data_vlm.py
Normale Datei
@@ -0,0 +1,300 @@
|
|||||||
|
"""VLM-Bildanalyse: Claude Code headless fuer GEOINT-Bildauswertung."""
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger("globe.vlm")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# --- Konfiguration ---
|
||||||
|
_CLAUDE_BIN = "/usr/bin/claude"
|
||||||
|
_TIMEOUT = 90
|
||||||
|
_MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
_MAX_IMAGE_DIMENSION = 1500
|
||||||
|
_ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||||
|
|
||||||
|
# Semaphore: max 1 gleichzeitige Analyse
|
||||||
|
_semaphore = asyncio.Semaphore(1)
|
||||||
|
|
||||||
|
# --- JSON-Schema fuer Claude-Output ---
|
||||||
|
_VLM_SCHEMA = json.dumps({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scene_description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Kurze Beschreibung der gesamten Szene"
|
||||||
|
},
|
||||||
|
"objects": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Objekttyp auf Englisch (z.B. airport, military_base, port, bridge, power_plant, radar_station, fuel_depot, barracks, runway, hangar, dam, solar_farm, wind_farm, antenna, prison, hospital, fire_station, railway_station, tunnel)"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Kurze Beschreibung des erkannten Objekts"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["high", "medium", "low"]
|
||||||
|
},
|
||||||
|
"osm_tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Passende OpenStreetMap-Tags (z.B. aeroway=aerodrome, military=base)"
|
||||||
|
},
|
||||||
|
"military_relevant": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "description", "confidence", "osm_tags", "military_relevant"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terrain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Gelaendetyp (z.B. urban, rural, coastal, desert, forest, mountain)"
|
||||||
|
},
|
||||||
|
"estimated_location_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Geschaetzter Standort-Typ (z.B. Europa, Naher Osten, Nordamerika)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["scene_description", "objects"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Objekt-Typ zu OverpassQL Mapping ---
|
||||||
|
_OBJECT_TO_OVERPASS = {
|
||||||
|
"airport": '["aeroway"="aerodrome"]',
|
||||||
|
"military_base": '["military"]',
|
||||||
|
"military_airfield": '["aeroway"="aerodrome"]["military"]',
|
||||||
|
"naval_base": '["military"="naval_base"]',
|
||||||
|
"port": '["landuse"="port"]',
|
||||||
|
"harbour": '["harbour"]',
|
||||||
|
"bridge": '["bridge"="yes"]["name"]',
|
||||||
|
"tunnel": '["tunnel"="yes"]["name"]',
|
||||||
|
"power_plant": '["power"="plant"]',
|
||||||
|
"nuclear_plant": '["generator:source"="nuclear"]',
|
||||||
|
"radar_station": '["man_made"="surveillance"]["surveillance:type"="radar"]',
|
||||||
|
"fuel_depot": '["man_made"="storage_tank"]',
|
||||||
|
"barracks": '["military"="barracks"]',
|
||||||
|
"runway": '["aeroway"="runway"]',
|
||||||
|
"hangar": '["aeroway"="hangar"]',
|
||||||
|
"dam": '["waterway"="dam"]',
|
||||||
|
"solar_farm": '["plant:source"="solar"]',
|
||||||
|
"wind_farm": '["generator:source"="wind"]',
|
||||||
|
"antenna": '["man_made"="antenna"]',
|
||||||
|
"communication_tower": '["man_made"="tower"]["tower:type"="communication"]',
|
||||||
|
"prison": '["amenity"="prison"]',
|
||||||
|
"hospital": '["amenity"="hospital"]',
|
||||||
|
"fire_station": '["amenity"="fire_station"]',
|
||||||
|
"police_station": '["amenity"="police"]',
|
||||||
|
"embassy": '["amenity"="embassy"]',
|
||||||
|
"government_building": '["office"="government"]',
|
||||||
|
"railway_station": '["railway"="station"]',
|
||||||
|
"substation": '["power"="substation"]',
|
||||||
|
"refinery": '["industrial"="refinery"]',
|
||||||
|
"pipeline": '["man_made"="pipeline"]',
|
||||||
|
"water_works": '["man_made"="water_works"]',
|
||||||
|
"data_center": '["telecom"="data_center"]',
|
||||||
|
"bunker": '["military"="bunker"]',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_image(input_path: str, output_path: str):
|
||||||
|
"""Skaliert Bild auf max _MAX_IMAGE_DIMENSION px."""
|
||||||
|
with Image.open(input_path) as img:
|
||||||
|
w, h = img.size
|
||||||
|
if max(w, h) > _MAX_IMAGE_DIMENSION:
|
||||||
|
ratio = _MAX_IMAGE_DIMENSION / max(w, h)
|
||||||
|
new_size = (int(w * ratio), int(h * ratio))
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
# Immer als PNG speichern
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(output_path, "PNG", optimize=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_claude(image_path: str, viewport_info: str = "") -> dict:
|
||||||
|
"""Ruft Claude Code headless auf um ein Bild zu analysieren."""
|
||||||
|
context = ""
|
||||||
|
if viewport_info:
|
||||||
|
context = f"Kontext: Der Nutzer schaut gerade auf {viewport_info}. "
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"Lies die Bilddatei {image_path} mit dem Read-Tool. "
|
||||||
|
f"{context}"
|
||||||
|
"Analysiere das Bild fuer GEOINT/OSINT-Zwecke. "
|
||||||
|
"Identifiziere alle erkennbaren Objekte, Infrastruktur und militaerisch relevante Strukturen. "
|
||||||
|
"Gib fuer jedes Objekt passende OpenStreetMap-Tags an. "
|
||||||
|
"Antworte ausschliesslich im vorgegebenen JSON-Format."
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
_CLAUDE_BIN,
|
||||||
|
"-p", prompt,
|
||||||
|
"--output-format", "json",
|
||||||
|
"--json-schema", _VLM_SCHEMA,
|
||||||
|
"--model", "sonnet",
|
||||||
|
"--allowedTools", "Read",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
]
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.communicate()
|
||||||
|
raise HTTPException(504, "VLM-Analyse Timeout (>90s). Bitte kleineres Bild verwenden.")
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = stderr.decode()[:500]
|
||||||
|
logger.error(f"Claude Code Fehler (rc={proc.returncode}): {err}")
|
||||||
|
raise HTTPException(502, f"VLM-Analyse fehlgeschlagen: {err[:200]}")
|
||||||
|
|
||||||
|
# JSON parsen
|
||||||
|
try:
|
||||||
|
wrapper = json.loads(stdout.decode())
|
||||||
|
|
||||||
|
# --json-schema liefert structured_output (bevorzugt)
|
||||||
|
structured = wrapper.get("structured_output")
|
||||||
|
if structured and isinstance(structured, dict):
|
||||||
|
return structured
|
||||||
|
|
||||||
|
# Fallback: result als JSON-String parsen
|
||||||
|
result_str = wrapper.get("result", "")
|
||||||
|
if isinstance(result_str, str) and result_str.strip().startswith("{"):
|
||||||
|
return json.loads(result_str)
|
||||||
|
|
||||||
|
# Fallback: Claude hat Klartext statt JSON geliefert
|
||||||
|
logger.warning(f"VLM: Kein strukturiertes JSON, Klartext: {result_str[:200]}")
|
||||||
|
return {
|
||||||
|
"scene_description": result_str[:500] if result_str else "Analyse konnte kein strukturiertes Ergebnis liefern",
|
||||||
|
"objects": [],
|
||||||
|
"terrain": "unknown",
|
||||||
|
"estimated_location_type": "unknown",
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"VLM JSON-Parse-Fehler: {e}, stdout={stdout.decode()[:500]}")
|
||||||
|
raise HTTPException(502, "VLM-Analyse: Unerwartetes Antwortformat")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vlm/analyze")
|
||||||
|
async def analyze_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
viewport_info: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Analysiert ein hochgeladenes Bild mittels Claude Code VLM."""
|
||||||
|
# Validierung
|
||||||
|
if file.content_type not in _ALLOWED_TYPES:
|
||||||
|
raise HTTPException(400, f"Ungültiger Dateityp: {file.content_type}. Erlaubt: PNG, JPG, WEBP")
|
||||||
|
|
||||||
|
# Semaphore: max 1 gleichzeitig
|
||||||
|
if _semaphore.locked():
|
||||||
|
raise HTTPException(429, "Eine Analyse laeuft bereits. Bitte warten.")
|
||||||
|
|
||||||
|
async with _semaphore:
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="vlm_")
|
||||||
|
try:
|
||||||
|
# Bild speichern
|
||||||
|
raw_path = os.path.join(tmp_dir, "raw" + _ext(file.content_type))
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > _MAX_IMAGE_SIZE:
|
||||||
|
raise HTTPException(400, f"Bild zu gross ({len(content) // 1024 // 1024}MB). Max: 10MB")
|
||||||
|
|
||||||
|
with open(raw_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Resize
|
||||||
|
resized_path = os.path.join(tmp_dir, "resized.png")
|
||||||
|
_resize_image(raw_path, resized_path)
|
||||||
|
|
||||||
|
logger.info(f"VLM-Analyse gestartet: {file.filename} ({len(content)} bytes)")
|
||||||
|
|
||||||
|
# Claude Code aufrufen
|
||||||
|
result = await _run_claude(resized_path, viewport_info)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"VLM-Analyse abgeschlossen: {len(result.get('objects', []))} Objekte erkannt"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Temp-Verzeichnis bereinigen
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ext(content_type: str) -> str:
|
||||||
|
return {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}.get(content_type, ".png")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryGenRequest(BaseModel):
|
||||||
|
objects: list[dict]
|
||||||
|
bbox: list[float] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vlm/generate-queries")
|
||||||
|
async def generate_queries(req: QueryGenRequest):
|
||||||
|
"""Generiert OverpassQL-Queries aus VLM-Erkennungen."""
|
||||||
|
if not req.objects:
|
||||||
|
raise HTTPException(400, "Keine Objekte angegeben")
|
||||||
|
|
||||||
|
bbox_str = ""
|
||||||
|
if req.bbox and len(req.bbox) == 4:
|
||||||
|
bbox_str = f"({req.bbox[0]},{req.bbox[1]},{req.bbox[2]},{req.bbox[3]})"
|
||||||
|
|
||||||
|
fragments = []
|
||||||
|
used_types = set()
|
||||||
|
|
||||||
|
for obj in req.objects:
|
||||||
|
obj_type = obj.get("type", "").lower().replace(" ", "_")
|
||||||
|
|
||||||
|
# Direkte Zuordnung
|
||||||
|
if obj_type in _OBJECT_TO_OVERPASS and obj_type not in used_types:
|
||||||
|
tag = _OBJECT_TO_OVERPASS[obj_type]
|
||||||
|
fragments.append(f' node{tag}{bbox_str};')
|
||||||
|
fragments.append(f' way{tag}{bbox_str};')
|
||||||
|
used_types.add(obj_type)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback: OSM-Tags aus VLM-Ergebnis verwenden
|
||||||
|
osm_tags = obj.get("osm_tags", [])
|
||||||
|
for tag_str in osm_tags:
|
||||||
|
if "=" in tag_str and tag_str not in used_types:
|
||||||
|
key, val = tag_str.split("=", 1)
|
||||||
|
frag = f'["{key}"="{val}"]'
|
||||||
|
fragments.append(f' node{frag}{bbox_str};')
|
||||||
|
fragments.append(f' way{frag}{bbox_str};')
|
||||||
|
used_types.add(tag_str)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not fragments:
|
||||||
|
raise HTTPException(400, "Keine passenden OSM-Tags fuer die erkannten Objekte gefunden")
|
||||||
|
|
||||||
|
query = "[out:json][timeout:30];\n(\n" + "\n".join(fragments) + "\n);\nout center body;"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"query": query,
|
||||||
|
"object_count": len(req.objects),
|
||||||
|
"tag_count": len(used_types),
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ from data_disasters import router as disasters_router
|
|||||||
from data_military import router as military_router, start_mil_collector
|
from data_military import router as military_router, start_mil_collector
|
||||||
from data_infra import router as infra_router
|
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_vlm import router as vlm_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
|
||||||
@@ -48,6 +50,8 @@ app.include_router(military_router, prefix="/api", dependencies=[Depends(get_cur
|
|||||||
app.include_router(infra_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
app.include_router(infra_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
app.include_router(disasters_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
app.include_router(disasters_router, prefix="/api", dependencies=[Depends(get_current_user)])
|
||||||
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(vlm_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"
|
||||||
|
|||||||
@@ -589,3 +589,200 @@ html, body { height: 100%; overflow: hidden; background: var(--bg-primary); colo
|
|||||||
background: rgba(255,255,255,0.02);
|
background: rgba(255,255,255,0.02);
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 4px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Overpass Turbo === */
|
||||||
|
.dot-overpass { background: #ff9800; }
|
||||||
|
|
||||||
|
.overpass-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
right: 12px;
|
||||||
|
width: 360px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overpass-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#overpass-editor:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 8px rgba(0,255,136,0.15);
|
||||||
|
}
|
||||||
|
#overpass-editor::placeholder { color: var(--text-dim); opacity: 0.5; }
|
||||||
|
|
||||||
|
.overpass-cat-tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
.overpass-cat-tab {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.overpass-cat-tab:hover { border-color: rgba(0,255,136,0.3); color: var(--text); }
|
||||||
|
.overpass-cat-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(0,255,136,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overpass-template-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px 8px;
|
||||||
|
margin: 1px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.overpass-template-btn:hover {
|
||||||
|
background: rgba(0,255,136,0.06);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overpass-exec-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,255,136,0.12);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.overpass-exec-btn:hover { background: rgba(0,255,136,0.22); }
|
||||||
|
.overpass-exec-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.overpass-panel::-webkit-scrollbar { width: 4px; }
|
||||||
|
.overpass-panel::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.overpass-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* === VLM Bildanalyse === */
|
||||||
|
.dot-vlm { background: #e040fb; }
|
||||||
|
|
||||||
|
.vlm-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
left: 268px;
|
||||||
|
width: 360px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlm-drop-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.vlm-drop-zone:hover,
|
||||||
|
.vlm-drop-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(0,255,136,0.04);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlm-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 180px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlm-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.vlm-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: vlm-spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
}
|
||||||
|
@keyframes vlm-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.vlm-object-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.vlm-object-card:hover { border-color: rgba(0,255,136,0.3); }
|
||||||
|
|
||||||
|
.vlm-confidence-high { color: #00ff88; }
|
||||||
|
.vlm-confidence-medium { color: #ff9800; }
|
||||||
|
.vlm-confidence-low { color: #ff5252; }
|
||||||
|
|
||||||
|
.vlm-badge-mil {
|
||||||
|
background: rgba(255,82,82,0.2);
|
||||||
|
color: #ff5252;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vlm-panel::-webkit-scrollbar { width: 4px; }
|
||||||
|
.vlm-panel::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.vlm-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|||||||
@@ -108,6 +108,19 @@
|
|||||||
<span class="layer-count" id="count-infra">-</span>
|
<span class="layer-count" id="count-infra">-</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="layer-status" id="status-infra"></div>
|
<div class="layer-status" id="status-infra"></div>
|
||||||
|
<label class="layer-toggle">
|
||||||
|
<input type="checkbox" id="layer-overpass" title="Overpass Turbo: Beliebige OSM-Daten abfragen">
|
||||||
|
<span class="layer-dot dot-overpass"></span>
|
||||||
|
<span class="layer-name" title="OverpassQL-Abfragen gegen OpenStreetMap">Overpass</span>
|
||||||
|
<span class="layer-count" id="count-overpass">-</span>
|
||||||
|
</label>
|
||||||
|
<div class="layer-status" id="status-overpass"></div>
|
||||||
|
<label class="layer-toggle">
|
||||||
|
<input type="checkbox" id="layer-vlm" title="VLM-Bildanalyse: Bild hochladen, Claude analysiert, Overpass sucht passende Orte">
|
||||||
|
<span class="layer-dot dot-vlm"></span>
|
||||||
|
<span class="layer-name" title="Satellitenbild-Analyse mit Claude VLM">Bildanalyse</span>
|
||||||
|
<span class="layer-count" id="count-vlm">-</span>
|
||||||
|
</label>
|
||||||
<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>
|
||||||
@@ -195,6 +208,11 @@
|
|||||||
<div id="city-links" class="city-links"></div>
|
<div id="city-links" class="city-links"></div>
|
||||||
|
|
||||||
<!-- Rechte Sidebar: Datenpunkt-Uebersicht -->
|
<!-- Rechte Sidebar: Datenpunkt-Uebersicht -->
|
||||||
|
<!-- Overpass Panel -->
|
||||||
|
<div id="overpass-panel" class="overpass-panel" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- VLM Bildanalyse Panel -->
|
||||||
|
<div id="vlm-panel" class="vlm-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>
|
||||||
@@ -240,6 +258,9 @@
|
|||||||
<script src="/static/js/layers/military.js"></script>
|
<script src="/static/js/layers/military.js"></script>
|
||||||
<script src="/static/js/layers/cables.js"></script>
|
<script src="/static/js/layers/cables.js"></script>
|
||||||
<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/ui/overpass.js"></script>
|
||||||
|
<script src="/static/js/ui/vlm.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>
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ const Globe = {
|
|||||||
'layer-satellites': function(on) { on ? SatellitesLayer.start(Globe.viewer) : SatellitesLayer.stop(); },
|
'layer-satellites': function(on) { on ? SatellitesLayer.start(Globe.viewer) : SatellitesLayer.stop(); },
|
||||||
'layer-cables': function(on) { on ? CablesLayer.start(Globe.viewer) : CablesLayer.stop(); },
|
'layer-cables': function(on) { on ? CablesLayer.start(Globe.viewer) : CablesLayer.stop(); },
|
||||||
'layer-infra': function(on) { on ? InfraLayer.start(Globe.viewer) : InfraLayer.stop(); },
|
'layer-infra': function(on) { on ? InfraLayer.start(Globe.viewer) : InfraLayer.stop(); },
|
||||||
|
'layer-overpass': function(on) {
|
||||||
|
if (on) { OverpassLayer.start(Globe.viewer); OverpassUI.show(); }
|
||||||
|
else { OverpassLayer.stop(); OverpassUI.hide(); }
|
||||||
|
},
|
||||||
|
'layer-vlm': function(on) {
|
||||||
|
if (on) { VlmUI.show(); }
|
||||||
|
else { VlmUI.hide(); }
|
||||||
|
},
|
||||||
'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
static/js/layers/overpass.js
Normale Datei
211
static/js/layers/overpass.js
Normale Datei
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Overpass Layer: Rendert Overpass-API-Ergebnisse auf dem CesiumJS-Globus.
|
||||||
|
* Nodes als Punkte, Ways als Linien, Relations als Punkte (Center).
|
||||||
|
*/
|
||||||
|
const OverpassLayer = {
|
||||||
|
_viewer: null,
|
||||||
|
_points: null,
|
||||||
|
_labels: null,
|
||||||
|
_polylines: null,
|
||||||
|
_count: 0,
|
||||||
|
_data: null,
|
||||||
|
_handler: null,
|
||||||
|
_nodeIndex: [],
|
||||||
|
_currentColor: '#ff9800',
|
||||||
|
|
||||||
|
start(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._polylines = viewer.scene.primitives.add(new Cesium.PolylineCollection());
|
||||||
|
this._setupClickHandler();
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._handler) { this._handler.destroy(); this._handler = 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; }
|
||||||
|
if (this._polylines && this._viewer) { this._viewer.scene.primitives.remove(this._polylines); this._polylines = null; }
|
||||||
|
this._count = 0;
|
||||||
|
this._data = null;
|
||||||
|
this._nodeIndex = [];
|
||||||
|
var countEl = document.getElementById('count-overpass');
|
||||||
|
if (countEl) countEl.textContent = '-';
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (this._points) this._points.removeAll();
|
||||||
|
if (this._labels) this._labels.removeAll();
|
||||||
|
if (this._polylines) this._polylines.removeAll();
|
||||||
|
this._count = 0;
|
||||||
|
this._data = null;
|
||||||
|
this._nodeIndex = [];
|
||||||
|
var countEl = document.getElementById('count-overpass');
|
||||||
|
if (countEl) countEl.textContent = '-';
|
||||||
|
},
|
||||||
|
|
||||||
|
setColor(hexColor) {
|
||||||
|
this._currentColor = hexColor || '#ff9800';
|
||||||
|
},
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
this.clear();
|
||||||
|
if (!this._points) return;
|
||||||
|
this._data = data;
|
||||||
|
var color = Cesium.Color.fromCssColorString(this._currentColor);
|
||||||
|
var colorDim = color.withAlpha(0.6);
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
var nodes = data.nodes || [];
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var n = nodes[i];
|
||||||
|
if (!n.lat || !n.lon) continue;
|
||||||
|
this._points.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(n.lon, n.lat, 0),
|
||||||
|
pixelSize: 7,
|
||||||
|
color: color,
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 1,
|
||||||
|
});
|
||||||
|
this._nodeIndex.push({ lat: n.lat, lon: n.lon, tags: n.tags, name: n.name, type: 'node' });
|
||||||
|
if (n.name) {
|
||||||
|
this._labels.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(n.lon, n.lat, 0),
|
||||||
|
text: n.name,
|
||||||
|
font: '10px monospace',
|
||||||
|
fillColor: color.withAlpha(0.8),
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||||
|
pixelOffset: new Cesium.Cartesian2(6, -4),
|
||||||
|
scale: 0.7,
|
||||||
|
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 500000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ways
|
||||||
|
var ways = data.ways || [];
|
||||||
|
for (var j = 0; j < ways.length; j++) {
|
||||||
|
var w = ways[j];
|
||||||
|
if (w.geometry && w.geometry.length > 1) {
|
||||||
|
var positions = [];
|
||||||
|
for (var k = 0; k < w.geometry.length; k++) {
|
||||||
|
var g = w.geometry[k];
|
||||||
|
if (g.lat && g.lon) {
|
||||||
|
positions.push(Cesium.Cartesian3.fromDegrees(g.lon, g.lat, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (positions.length > 1) {
|
||||||
|
this._polylines.add({
|
||||||
|
positions: positions,
|
||||||
|
width: 2.5,
|
||||||
|
material: Cesium.Material.fromType('Color', { color: colorDim }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (w.lat && w.lon) {
|
||||||
|
this._points.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(w.lon, w.lat, 0),
|
||||||
|
pixelSize: 5,
|
||||||
|
color: colorDim,
|
||||||
|
});
|
||||||
|
this._nodeIndex.push({ lat: w.lat, lon: w.lon, tags: w.tags, name: w.name, type: 'way' });
|
||||||
|
if (w.name) {
|
||||||
|
this._labels.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(w.lon, w.lat, 0),
|
||||||
|
text: w.name,
|
||||||
|
font: '10px monospace',
|
||||||
|
fillColor: color.withAlpha(0.7),
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||||
|
pixelOffset: new Cesium.Cartesian2(6, -4),
|
||||||
|
scale: 0.7,
|
||||||
|
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 500000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relations (Center-Punkt)
|
||||||
|
var rels = data.relations || [];
|
||||||
|
for (var m = 0; m < rels.length; m++) {
|
||||||
|
var r = rels[m];
|
||||||
|
if (!r.lat || !r.lon) continue;
|
||||||
|
this._points.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(r.lon, r.lat, 0),
|
||||||
|
pixelSize: 9,
|
||||||
|
color: color,
|
||||||
|
outlineColor: color.withAlpha(0.3),
|
||||||
|
outlineWidth: 3,
|
||||||
|
});
|
||||||
|
this._nodeIndex.push({ lat: r.lat, lon: r.lon, tags: r.tags, name: r.name, type: 'relation' });
|
||||||
|
if (r.name) {
|
||||||
|
this._labels.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(r.lon, r.lat, 0),
|
||||||
|
text: r.name,
|
||||||
|
font: '11px monospace',
|
||||||
|
fillColor: color,
|
||||||
|
outlineColor: Cesium.Color.BLACK,
|
||||||
|
outlineWidth: 2,
|
||||||
|
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||||
|
pixelOffset: new Cesium.Cartesian2(8, -6),
|
||||||
|
scale: 0.8,
|
||||||
|
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 800000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._count = nodes.length + ways.length + rels.length;
|
||||||
|
var countEl = document.getElementById('count-overpass');
|
||||||
|
if (countEl) countEl.textContent = this._count.toLocaleString('de-DE');
|
||||||
|
},
|
||||||
|
|
||||||
|
_setupClickHandler() {
|
||||||
|
var self = this;
|
||||||
|
this._handler = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
|
||||||
|
this._handler.setInputAction(function(click) {
|
||||||
|
if (!self._nodeIndex.length) return;
|
||||||
|
var cart = self._viewer.scene.pickPosition(click.position);
|
||||||
|
if (!cart) {
|
||||||
|
var ray = self._viewer.scene.camera.getPickRay(click.position);
|
||||||
|
cart = self._viewer.scene.globe.pick(ray, self._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);
|
||||||
|
|
||||||
|
var best = null, bd = 999;
|
||||||
|
for (var i = 0; i < self._nodeIndex.length; i++) {
|
||||||
|
var el = self._nodeIndex[i];
|
||||||
|
var d = Math.abs(el.lat - lat) + Math.abs(el.lon - lon);
|
||||||
|
if (d < bd) { bd = d; best = el; }
|
||||||
|
}
|
||||||
|
if (best && bd < 0.3) {
|
||||||
|
var name = best.name || 'Unbenannt';
|
||||||
|
var typeLabels = { node: 'Node', way: 'Way', relation: 'Relation' };
|
||||||
|
var tagsHtml = '';
|
||||||
|
var tags = best.tags || {};
|
||||||
|
var tagKeys = Object.keys(tags);
|
||||||
|
for (var t = 0; t < tagKeys.length && t < 20; t++) {
|
||||||
|
var key = tagKeys[t];
|
||||||
|
tagsHtml += '<tr><td style="color:#00ff88;padding:2px 8px 2px 0">' + key + '</td>' +
|
||||||
|
'<td style="color:#ccc">' + tags[key] + '</td></tr>';
|
||||||
|
}
|
||||||
|
self._viewer.trackedEntity = undefined;
|
||||||
|
self._viewer.selectedEntity = new Cesium.Entity({
|
||||||
|
name: name,
|
||||||
|
description: '<div style="font-family:monospace;font-size:12px;padding:8px;color:#e8eaf0">' +
|
||||||
|
'<div style="font-size:10px;color:#888;margin-bottom:4px">' + typeLabels[best.type] + ' | OpenStreetMap</div>' +
|
||||||
|
'<strong style="color:' + self._currentColor + ';font-size:14px">' + name + '</strong>' +
|
||||||
|
'<table style="margin-top:8px;border-collapse:collapse;width:100%">' + tagsHtml + '</table></div>',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCount: function() { return this._count; },
|
||||||
|
};
|
||||||
207
static/js/ui/overpass.js
Normale Datei
207
static/js/ui/overpass.js
Normale Datei
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Overpass UI: Query-Editor-Panel mit Template-Browser und BBox-Integration.
|
||||||
|
*/
|
||||||
|
const OverpassUI = {
|
||||||
|
_panel: null,
|
||||||
|
_editor: null,
|
||||||
|
_templates: [],
|
||||||
|
_categories: [],
|
||||||
|
_activeCategory: null,
|
||||||
|
_isLoading: false,
|
||||||
|
_useBbox: true,
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this._createPanel();
|
||||||
|
this._loadTemplates();
|
||||||
|
},
|
||||||
|
|
||||||
|
show: function() {
|
||||||
|
if (!this._panel) this.init();
|
||||||
|
this._panel.style.display = 'block';
|
||||||
|
if (!OverpassLayer._points) OverpassLayer.start(Globe.viewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
hide: function() {
|
||||||
|
if (this._panel) this._panel.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
_createPanel: function() {
|
||||||
|
var panel = document.getElementById('overpass-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
this._panel = panel;
|
||||||
|
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
|
||||||
|
'<div style="font-size:11px;font-weight:700;letter-spacing:2px;color:var(--accent)">OVERPASS QUERY</div>' +
|
||||||
|
'<button onclick="OverpassUI.hide();var cb=document.getElementById(\'layer-overpass\');if(cb){cb.checked=false;}OverpassLayer.stop();" ' +
|
||||||
|
'style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:18px;line-height:1" title="Schliessen">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="overpass-cat-tabs" class="overpass-cat-tabs"></div>' +
|
||||||
|
'<div id="overpass-template-list" style="max-height:160px;overflow-y:auto;margin-bottom:10px"></div>' +
|
||||||
|
'<div class="panel-divider"></div>' +
|
||||||
|
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--text-dim);margin-bottom:4px">OVERPASSQL EDITOR</div>' +
|
||||||
|
'<textarea id="overpass-editor" rows="6" spellcheck="false" placeholder="[out:json][timeout:25];\n(\n node["amenity"]({{bbox}});\n);\nout center body;"></textarea>' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:8px;margin:10px 0">' +
|
||||||
|
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);cursor:pointer;flex:1">' +
|
||||||
|
'<input type="checkbox" id="overpass-bbox" checked onchange="OverpassUI._useBbox=this.checked" style="accent-color:var(--accent);width:14px;height:14px">' +
|
||||||
|
'Viewport als BBox' +
|
||||||
|
'</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="overpass-exec-btn" id="overpass-exec-btn" onclick="OverpassUI._executeQuery()">AUSFUEHREN</button>' +
|
||||||
|
'<div id="overpass-result" style="margin-top:10px;font-size:11px;color:var(--text-dim);display:none"></div>' +
|
||||||
|
'<button id="overpass-clear-btn" onclick="OverpassUI._clearResults()" ' +
|
||||||
|
'style="display:none;width:100%;padding:6px;margin-top:6px;background:none;border:1px solid var(--border);color:var(--text-dim);' +
|
||||||
|
'font-family:var(--font-mono);font-size:11px;border-radius:4px;cursor:pointer;letter-spacing:1px">ERGEBNISSE LOESCHEN</button>';
|
||||||
|
|
||||||
|
this._editor = document.getElementById('overpass-editor');
|
||||||
|
},
|
||||||
|
|
||||||
|
_loadTemplates: function() {
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/overpass/templates')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
self._categories = data.categories || [];
|
||||||
|
if (self._categories.length > 0) {
|
||||||
|
self._activeCategory = self._categories[0].id;
|
||||||
|
self._renderCategoryTabs();
|
||||||
|
self._renderTemplateList();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(e) { console.warn('Overpass Templates:', e); });
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderCategoryTabs: function() {
|
||||||
|
var el = document.getElementById('overpass-cat-tabs');
|
||||||
|
if (!el) return;
|
||||||
|
var self = this;
|
||||||
|
var html = '';
|
||||||
|
this._categories.forEach(function(cat) {
|
||||||
|
var active = cat.id === self._activeCategory ? ' active' : '';
|
||||||
|
html += '<button class="overpass-cat-tab' + active + '" data-cat="' + cat.id + '" ' +
|
||||||
|
'onclick="OverpassUI._selectCategory(\'' + cat.id + '\')">' + cat.label + '</button>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
_selectCategory: function(catId) {
|
||||||
|
this._activeCategory = catId;
|
||||||
|
this._renderCategoryTabs();
|
||||||
|
this._renderTemplateList();
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderTemplateList: function() {
|
||||||
|
var el = document.getElementById('overpass-template-list');
|
||||||
|
if (!el) return;
|
||||||
|
var self = this;
|
||||||
|
var cat = this._categories.find(function(c) { return c.id === self._activeCategory; });
|
||||||
|
if (!cat) { el.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
cat.templates.forEach(function(t) {
|
||||||
|
html += '<button class="overpass-template-btn" onclick="OverpassUI._applyTemplate(\'' + t.id + '\')" title="' + t.description + '">' +
|
||||||
|
'<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + t.color + ';margin-right:8px"></span>' +
|
||||||
|
t.name + '</button>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyTemplate: function(templateId) {
|
||||||
|
var tpl = null;
|
||||||
|
for (var i = 0; i < this._categories.length; i++) {
|
||||||
|
var found = this._categories[i].templates.find(function(t) { return t.id === templateId; });
|
||||||
|
if (found) { tpl = found; break; }
|
||||||
|
}
|
||||||
|
if (tpl && this._editor) {
|
||||||
|
this._editor.value = tpl.query;
|
||||||
|
OverpassLayer.setColor(tpl.color);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_getBboxFromViewport: function() {
|
||||||
|
if (!Globe.viewer) return null;
|
||||||
|
var rect = Globe.viewer.camera.computeViewRectangle();
|
||||||
|
if (!rect) return null;
|
||||||
|
return [
|
||||||
|
Cesium.Math.toDegrees(rect.south),
|
||||||
|
Cesium.Math.toDegrees(rect.west),
|
||||||
|
Cesium.Math.toDegrees(rect.north),
|
||||||
|
Cesium.Math.toDegrees(rect.east),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
_executeQuery: function() {
|
||||||
|
if (this._isLoading || !this._editor) return;
|
||||||
|
var query = this._editor.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
var bbox = null;
|
||||||
|
if (this._useBbox) {
|
||||||
|
bbox = this._getBboxFromViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isLoading = true;
|
||||||
|
var btn = document.getElementById('overpass-exec-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'LADE...'; }
|
||||||
|
var resultEl = document.getElementById('overpass-result');
|
||||||
|
if (resultEl) { resultEl.style.display = 'block'; resultEl.textContent = 'Anfrage wird gesendet...'; resultEl.style.color = 'var(--text-dim)'; }
|
||||||
|
var startTime = Date.now();
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/overpass/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: query, bbox: bbox }),
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 429) {
|
||||||
|
return r.json().then(function(d) { throw new Error(d.detail || 'Rate-Limit erreicht'); });
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().then(function(d) { throw new Error(d.detail || 'Fehler ' + r.status); });
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
OverpassLayer.render(data);
|
||||||
|
if (resultEl) {
|
||||||
|
var text = data.total + ' Objekte (' + elapsed + 's)';
|
||||||
|
if (data.cached) text += ' [Cache]';
|
||||||
|
if (data.truncated) text += ' [max. ' + data.total + ' angezeigt]';
|
||||||
|
resultEl.textContent = text;
|
||||||
|
resultEl.style.color = 'var(--accent)';
|
||||||
|
}
|
||||||
|
var clearBtn = document.getElementById('overpass-clear-btn');
|
||||||
|
if (clearBtn) clearBtn.style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = 'Fehler: ' + e.message;
|
||||||
|
resultEl.style.color = '#ff5252';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
self._isLoading = false;
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'AUSFUEHREN'; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearResults: function() {
|
||||||
|
OverpassLayer.clear();
|
||||||
|
var resultEl = document.getElementById('overpass-result');
|
||||||
|
if (resultEl) { resultEl.style.display = 'none'; resultEl.style.color = 'var(--text-dim)'; }
|
||||||
|
var clearBtn = document.getElementById('overpass-clear-btn');
|
||||||
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
executeQueryDirect: function(query, bbox, color) {
|
||||||
|
if (this._editor) this._editor.value = query;
|
||||||
|
if (color) OverpassLayer.setColor(color);
|
||||||
|
this._useBbox = !!bbox;
|
||||||
|
var bboxCb = document.getElementById('overpass-bbox');
|
||||||
|
if (bboxCb) bboxCb.checked = this._useBbox;
|
||||||
|
this.show();
|
||||||
|
this._executeQuery();
|
||||||
|
},
|
||||||
|
};
|
||||||
328
static/js/ui/vlm.js
Normale Datei
328
static/js/ui/vlm.js
Normale Datei
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* VLM UI: Bildanalyse-Panel mit Upload, Zwei-Stufen-Workflow und Overpass-Kopplung.
|
||||||
|
*/
|
||||||
|
const VlmUI = {
|
||||||
|
_panel: null,
|
||||||
|
_dropZone: null,
|
||||||
|
_currentAnalysis: null,
|
||||||
|
_isAnalyzing: false,
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this._createPanel();
|
||||||
|
},
|
||||||
|
|
||||||
|
show: function() {
|
||||||
|
if (!this._panel) this.init();
|
||||||
|
this._panel.style.display = 'block';
|
||||||
|
},
|
||||||
|
|
||||||
|
hide: function() {
|
||||||
|
if (this._panel) this._panel.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
_createPanel: function() {
|
||||||
|
var panel = document.getElementById('vlm-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
this._panel = panel;
|
||||||
|
|
||||||
|
panel.innerHTML =
|
||||||
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
|
||||||
|
'<div style="font-size:11px;font-weight:700;letter-spacing:2px;color:var(--accent)">BILDANALYSE (VLM)</div>' +
|
||||||
|
'<button onclick="VlmUI.hide();var cb=document.getElementById(\'layer-vlm\');if(cb){cb.checked=false;}" ' +
|
||||||
|
'style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:18px;line-height:1" title="Schliessen">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
// Drop-Zone
|
||||||
|
'<div id="vlm-drop-zone" class="vlm-drop-zone">' +
|
||||||
|
'<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom:8px">' +
|
||||||
|
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>' +
|
||||||
|
'</svg>' +
|
||||||
|
'<div style="font-size:12px">Bild hierher ziehen</div>' +
|
||||||
|
'<div style="font-size:10px;color:var(--text-dim);margin-top:4px">oder klicken zum Waehlen (PNG/JPG/WEBP, max 10MB)</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<input type="file" id="vlm-file-input" accept="image/png,image/jpeg,image/webp" style="display:none" onchange="VlmUI._onFileSelect(this)">' +
|
||||||
|
// Preview
|
||||||
|
'<div id="vlm-preview" class="vlm-preview" style="display:none"></div>' +
|
||||||
|
// Loading
|
||||||
|
'<div id="vlm-loading" class="vlm-loading" style="display:none">' +
|
||||||
|
'<div class="vlm-spinner"></div>' +
|
||||||
|
'<div id="vlm-loading-text">Analysiere Bild...</div>' +
|
||||||
|
'</div>' +
|
||||||
|
// Analyse-Ergebnis
|
||||||
|
'<div id="vlm-results" style="display:none">' +
|
||||||
|
'<div class="panel-divider"></div>' +
|
||||||
|
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--accent);margin-bottom:6px">SZENE</div>' +
|
||||||
|
'<div id="vlm-scene" style="font-size:12px;color:var(--text);margin-bottom:10px;line-height:1.5"></div>' +
|
||||||
|
'<div style="font-size:9px;letter-spacing:1.5px;color:var(--accent);margin-bottom:6px">ERKANNTE OBJEKTE</div>' +
|
||||||
|
'<div id="vlm-objects"></div>' +
|
||||||
|
'<button class="overpass-exec-btn" id="vlm-search-btn" onclick="VlmUI._confirmAndSearch()" style="margin-top:10px">AUF GLOBE SUCHEN</button>' +
|
||||||
|
'<div id="vlm-search-result" style="margin-top:8px;font-size:11px;color:var(--text-dim);display:none"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
// Reset
|
||||||
|
'<button id="vlm-reset-btn" onclick="VlmUI._reset()" ' +
|
||||||
|
'style="display:none;width:100%;padding:6px;margin-top:8px;background:none;border:1px solid var(--border);color:var(--text-dim);' +
|
||||||
|
'font-family:var(--font-mono);font-size:11px;border-radius:4px;cursor:pointer;letter-spacing:1px">NEUES BILD</button>';
|
||||||
|
|
||||||
|
this._dropZone = document.getElementById('vlm-drop-zone');
|
||||||
|
this._setupDragDrop();
|
||||||
|
},
|
||||||
|
|
||||||
|
_setupDragDrop: function() {
|
||||||
|
var zone = this._dropZone;
|
||||||
|
if (!zone) return;
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function(evt) {
|
||||||
|
zone.addEventListener(evt, function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
zone.classList.add('vlm-drop-active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function(evt) {
|
||||||
|
zone.addEventListener(evt, function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
zone.classList.remove('vlm-drop-active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
zone.addEventListener('drop', function(e) {
|
||||||
|
var file = e.dataTransfer.files[0];
|
||||||
|
if (file) self._handleFile(file);
|
||||||
|
});
|
||||||
|
zone.addEventListener('click', function() {
|
||||||
|
document.getElementById('vlm-file-input').click();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_onFileSelect: function(input) {
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
this._handleFile(input.files[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleFile: function(file) {
|
||||||
|
// Validierung
|
||||||
|
var allowed = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
if (allowed.indexOf(file.type) === -1) {
|
||||||
|
alert('Ungültiger Dateityp. Erlaubt: PNG, JPG, WEBP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert('Bild zu gross (max 10MB)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
this._showPreview(file);
|
||||||
|
// Upload + Analyse
|
||||||
|
this._uploadAndAnalyze(file);
|
||||||
|
},
|
||||||
|
|
||||||
|
_showPreview: function(file) {
|
||||||
|
var previewEl = document.getElementById('vlm-preview');
|
||||||
|
if (!previewEl) return;
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
previewEl.innerHTML = '<img src="' + e.target.result + '" alt="Vorschau">' +
|
||||||
|
'<div style="font-size:10px;color:var(--text-dim);margin-top:4px">' + file.name +
|
||||||
|
' (' + (file.size / 1024).toFixed(0) + ' KB)</div>';
|
||||||
|
previewEl.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
|
||||||
|
_getViewportInfo: function() {
|
||||||
|
if (!Globe.viewer) return '';
|
||||||
|
var cam = Globe.viewer.camera;
|
||||||
|
var carto = cam.positionCartographic;
|
||||||
|
if (!carto) return '';
|
||||||
|
var lat = Cesium.Math.toDegrees(carto.latitude).toFixed(2);
|
||||||
|
var lon = Cesium.Math.toDegrees(carto.longitude).toFixed(2);
|
||||||
|
var alt = (carto.height / 1000).toFixed(0);
|
||||||
|
return 'Lat ' + lat + ', Lon ' + lon + ', Hoehe ' + alt + ' km';
|
||||||
|
},
|
||||||
|
|
||||||
|
_uploadAndAnalyze: function(file) {
|
||||||
|
this._isAnalyzing = true;
|
||||||
|
this._dropZone.style.display = 'none';
|
||||||
|
var loadingEl = document.getElementById('vlm-loading');
|
||||||
|
if (loadingEl) loadingEl.style.display = 'block';
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('viewport_info', this._getViewportInfo());
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/vlm/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 429) {
|
||||||
|
throw new Error('Eine Analyse laeuft bereits. Bitte warten.');
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().then(function(d) { throw new Error(d.detail || 'Fehler ' + r.status); });
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
self._currentAnalysis = data;
|
||||||
|
self._showResults(data);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
alert('VLM-Fehler: ' + e.message);
|
||||||
|
self._reset();
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
self._isAnalyzing = false;
|
||||||
|
if (loadingEl) loadingEl.style.display = 'none';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_showResults: function(data) {
|
||||||
|
var resultsEl = document.getElementById('vlm-results');
|
||||||
|
var resetBtn = document.getElementById('vlm-reset-btn');
|
||||||
|
if (!resultsEl) return;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objekte
|
||||||
|
var objectsEl = document.getElementById('vlm-objects');
|
||||||
|
if (objectsEl) {
|
||||||
|
var html = '';
|
||||||
|
var objects = data.objects || [];
|
||||||
|
objects.forEach(function(obj, idx) {
|
||||||
|
var confClass = 'vlm-confidence-' + obj.confidence;
|
||||||
|
var confLabel = { high: 'HIGH', medium: 'MED', low: 'LOW' }[obj.confidence] || '?';
|
||||||
|
var checked = obj.confidence !== 'low' ? ' checked' : '';
|
||||||
|
var milBadge = obj.military_relevant ? '<span class="vlm-badge-mil">MIL</span>' : '';
|
||||||
|
|
||||||
|
html += '<div class="vlm-object-card">' +
|
||||||
|
'<input type="checkbox" id="vlm-obj-' + idx + '"' + checked + ' style="accent-color:var(--accent);width:16px;height:16px;flex-shrink:0">' +
|
||||||
|
'<div style="flex:1;min-width:0">' +
|
||||||
|
'<div style="font-weight:700;color:var(--text)">' + obj.type.replace(/_/g, ' ') + '</div>' +
|
||||||
|
'<div style="font-size:10px;color:var(--text-dim);margin-top:2px">' + obj.description + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="' + confClass + '" style="font-size:10px;font-weight:700;flex-shrink:0">' + confLabel + '</span>' +
|
||||||
|
milBadge +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
if (objects.length === 0) {
|
||||||
|
html = '<div style="color:var(--text-dim);font-size:12px">Keine Objekte erkannt</div>';
|
||||||
|
}
|
||||||
|
objectsEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
if (resetBtn) resetBtn.style.display = 'block';
|
||||||
|
},
|
||||||
|
|
||||||
|
_confirmAndSearch: function() {
|
||||||
|
if (!this._currentAnalysis) return;
|
||||||
|
|
||||||
|
// Ausgewaehlte Objekte sammeln
|
||||||
|
var objects = this._currentAnalysis.objects || [];
|
||||||
|
var selected = [];
|
||||||
|
objects.forEach(function(obj, idx) {
|
||||||
|
var cb = document.getElementById('vlm-obj-' + idx);
|
||||||
|
if (cb && cb.checked) {
|
||||||
|
selected.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
|
alert('Bitte mindestens ein Objekt auswaehlen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BBox vom Viewport
|
||||||
|
var bbox = null;
|
||||||
|
if (Globe.viewer) {
|
||||||
|
var rect = Globe.viewer.camera.computeViewRectangle();
|
||||||
|
if (rect) {
|
||||||
|
bbox = [
|
||||||
|
Cesium.Math.toDegrees(rect.south),
|
||||||
|
Cesium.Math.toDegrees(rect.west),
|
||||||
|
Cesium.Math.toDegrees(rect.north),
|
||||||
|
Cesium.Math.toDegrees(rect.east),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.getElementById('vlm-search-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'SUCHE LAEUFT...'; }
|
||||||
|
var searchResult = document.getElementById('vlm-search-result');
|
||||||
|
if (searchResult) { searchResult.style.display = 'block'; searchResult.textContent = 'Generiere Overpass-Queries...'; searchResult.style.color = 'var(--text-dim)'; }
|
||||||
|
|
||||||
|
// Queries generieren
|
||||||
|
fetch('/api/vlm/generate-queries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ objects: selected, bbox: bbox }),
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) return r.json().then(function(d) { throw new Error(d.detail || 'Fehler'); });
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (searchResult) searchResult.textContent = 'Query generiert (' + data.tag_count + ' Tags). Suche OSM-Daten...';
|
||||||
|
|
||||||
|
// Overpass-Layer aktivieren und Query ausfuehren
|
||||||
|
var overpassCb = document.getElementById('layer-overpass');
|
||||||
|
if (overpassCb && !overpassCb.checked) {
|
||||||
|
overpassCb.checked = true;
|
||||||
|
overpassCb.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query an Overpass senden
|
||||||
|
OverpassLayer.setColor('#e040fb');
|
||||||
|
if (typeof OverpassUI !== 'undefined') {
|
||||||
|
OverpassUI.executeQueryDirect(data.query, bbox, '#e040fb');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResult) {
|
||||||
|
searchResult.textContent = 'Overpass-Suche gestartet';
|
||||||
|
searchResult.style.color = 'var(--accent)';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
if (searchResult) {
|
||||||
|
searchResult.textContent = 'Fehler: ' + e.message;
|
||||||
|
searchResult.style.color = '#ff5252';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'AUF GLOBE SUCHEN'; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_reset: function() {
|
||||||
|
this._currentAnalysis = null;
|
||||||
|
this._isAnalyzing = false;
|
||||||
|
|
||||||
|
if (this._dropZone) this._dropZone.style.display = 'block';
|
||||||
|
|
||||||
|
var els = ['vlm-preview', 'vlm-loading', 'vlm-results', 'vlm-search-result'];
|
||||||
|
els.forEach(function(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
var resetBtn = document.getElementById('vlm-reset-btn');
|
||||||
|
if (resetBtn) resetBtn.style.display = 'none';
|
||||||
|
|
||||||
|
var fileInput = document.getElementById('vlm-file-input');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
|
||||||
|
var countEl = document.getElementById('count-vlm');
|
||||||
|
if (countEl) countEl.textContent = '-';
|
||||||
|
},
|
||||||
|
};
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren