/** * VLM UI: Bildanalyse-Panel mit Upload, Zwei-Stufen-Workflow und Overpass-Kopplung. */ const VlmUI = { _panel: null, _dropZone: null, _currentAnalysis: null, _isAnalyzing: false, _searchAreaEntity: null, _exifMarkerEntity: null, 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 = '
' + '
BILDANALYSE (VLM)
' + '' + '
' + // Drop-Zone '
' + '' + '' + '' + '
Bild hierher ziehen
' + '
oder klicken zum Waehlen (PNG/JPG/WEBP, max 10MB)
' + '
' + '' + // Preview '' + // Loading '' + // Analyse-Ergebnis '' + // Reset ''; 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 = 'Vorschau' + '
' + file.name + ' (' + (file.size / 1024).toFixed(0) + ' KB)
'; 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 === 413) { throw new Error('Bild zu gross fuer Upload. Bitte kleineres Bild verwenden.'); } if (r.status === 429) { throw new Error('Eine Analyse laeuft bereits. Bitte warten.'); } if (!r.ok) { return r.text().then(function(txt) { try { var d = JSON.parse(txt); throw new Error(d.detail || 'Fehler ' + r.status); } catch(e) { if (e.message && !e.message.startsWith('Unexpected')) throw e; throw new Error('Server-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; // EXIF-Metadaten var exifHtml = ''; if (data.exif) { var e = data.exif; var ep = []; if (e.has_gps) ep.push('GPS: ' + e.latitude + ', ' + e.longitude + ''); 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 = '
EXIF METADATEN
' + '
' + ep.join(' · ') + '
'; if (e.has_gps) { exifHtml += ''; } } else { exifHtml = '
EXIF
Keine Metadaten gefunden
'; } } // Landscape Clues (Reverse Geolocation) var cluesHtml = ''; if (data.landscape_clues) { var lc = data.landscape_clues; var cl = []; if (lc.vegetation) cl.push('Vegetation: ' + lc.vegetation); if (lc.soil_color) cl.push('Boden: ' + lc.soil_color); if (lc.road_markings) cl.push('Strassen: ' + lc.road_markings); if (lc.architecture_style) cl.push('Architektur: ' + lc.architecture_style); if (lc.signage_language) cl.push('Schilder: ' + lc.signage_language); if (lc.vehicle_types) cl.push('Fahrzeuge: ' + lc.vehicle_types); if (lc.climate_indicators) cl.push('Klima: ' + lc.climate_indicators); if (lc.sun_shadow_direction) cl.push('Schatten: ' + lc.sun_shadow_direction); if (cl.length > 0) { cluesHtml = '
LANDSCHAFTSMERKMALE
' + '
' + cl.join('
') + '
'; } } // Geschaetzte Koordinaten var coordsHtml = ''; if (data.estimated_coordinates && data.estimated_coordinates.latitude) { var ec = data.estimated_coordinates; var rKm = ec.confidence_radius_km || '?'; coordsHtml = '
' + '
GESCHAETZTE POSITION
' + '
' + ec.latitude.toFixed(4) + ', ' + ec.longitude.toFixed(4) + ' (±' + rKm + ' km)
' + (ec.reasoning ? '
' + ec.reasoning + '
' : '') + '
'; } // Identifizierte Features var featHtml = ''; if (data.identified_features) { var if_ = data.identified_features; var fp = []; if (if_.water_body) fp.push('Gewaesser: ' + if_.water_body); if (if_.specific_region) fp.push('Region: ' + if_.specific_region); if (if_.nearby_landmarks) fp.push('Landmarken: ' + if_.nearby_landmarks); if (fp.length > 0) { featHtml = '
IDENTIFIZIERTE MERKMALE
' + '
' + fp.join('
') + '
'; } } // Szene var sceneEl = document.getElementById('vlm-scene'); if (sceneEl) { sceneEl.innerHTML = exifHtml + coordsHtml + '
' + (data.scene_description || 'Keine Beschreibung') + (data.terrain ? ' | Gelaende: ' + data.terrain : '') + (data.estimated_location_type ? ' | Region: ' + data.estimated_location_type : '') + '
' + featHtml + cluesHtml; } // 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 ? 'MIL' : ''; html += '
' + '' + '
' + '
' + obj.type.replace(/_/g, ' ') + '
' + '
' + obj.description + '
' + '
' + '' + confLabel + '' + milBadge + '
'; }); if (objects.length === 0) { html = '
Keine Objekte erkannt
'; } objectsEl.innerHTML = html; } resultsEl.style.display = 'block'; if (resetBtn) resetBtn.style.display = 'block'; }, _confirmAndSearch: function() { if (!this._currentAnalysis) return; var analysis = this._currentAnalysis; // Ausgewaehlte Objekte sammeln var objects = analysis.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; } // === EXIF-GPS: Marker setzen + dorthin fliegen === var exif = analysis.exif; var hasExifGps = exif && exif.has_gps && exif.latitude && exif.longitude; if (hasExifGps) { // EXIF-Position als hervorgehobener Marker auf dem Globus OverpassLayer.start(Globe.viewer); OverpassLayer.setColor('#e040fb'); this._exifMarkerEntity = Globe.viewer.entities.add({ name: 'EXIF GPS-Position', position: Cesium.Cartesian3.fromDegrees(exif.longitude, exif.latitude, 0), point: { pixelSize: 12, color: Cesium.Color.fromCssColorString('#e040fb'), outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, ellipse: { semiMinorAxis: 500, semiMajorAxis: 500, material: Cesium.Color.fromCssColorString('#e040fb').withAlpha(0.15), outline: true, outlineColor: Cesium.Color.fromCssColorString('#e040fb').withAlpha(0.5), }, label: { text: 'EXIF GPS', font: '12px monospace', fillColor: Cesium.Color.fromCssColorString('#e040fb'), outlineColor: Cesium.Color.BLACK, outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE, pixelOffset: new Cesium.Cartesian2(0, -20), disableDepthTestDistance: Number.POSITIVE_INFINITY, }, description: '
' + '
EXIF GPS-Position
' + '' + '' + '' + (exif.altitude ? '' : '') + (exif.camera_model ? '' : '') + (exif.timestamp ? '' : '') + (exif.compass_heading ? '' : '') + '
Latitude' + exif.latitude + '
Longitude' + exif.longitude + '
Hoehe' + exif.altitude + 'm
Kamera' + (exif.camera_make || '') + ' ' + exif.camera_model + '
Zeitstempel' + exif.timestamp + '
Heading' + exif.compass_heading + '\u00B0
', }); // Zur EXIF-Position fliegen Globe.viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(exif.longitude, exif.latitude, 50000), duration: 2, }); } // === BBox bestimmen: EXIF-GPS > estimated_coordinates > Viewport > Region (Backend) === var bbox = null; var ec = analysis.estimated_coordinates; var hasEstCoords = ec && ec.latitude && ec.longitude; if (hasExifGps) { bbox = [ exif.latitude - 0.5, exif.longitude - 0.5, exif.latitude + 0.5, exif.longitude + 0.5, ]; } else if (hasEstCoords) { // VLM-geschaetzte Koordinaten: Radius in Grad var rKm = ec.confidence_radius_km || 50; var rDeg = Math.max(0.1, Math.min(rKm / 111, 10)); bbox = [ ec.latitude - rDeg, ec.longitude - rDeg, ec.latitude + rDeg, ec.longitude + rDeg, ]; // Zur geschaetzten Position fliegen Globe.viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(ec.longitude, ec.latitude, Math.max(50000, rKm * 2000)), duration: 2, }); } else { var bboxCb = document.getElementById('vlm-use-bbox'); if (bboxCb && bboxCb.checked && 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'); var statusParts = []; if (hasExifGps) statusParts.push('EXIF GPS-Marker gesetzt'); if (searchResult) { searchResult.style.display = 'block'; searchResult.textContent = statusParts.join(' | ') + (statusParts.length ? ' | ' : '') + 'Generiere Overpass-Queries...'; searchResult.style.color = 'var(--text-dim)'; } // === Overpass-Suche mit allen verfuegbaren Daten === var startTime = Date.now(); var self = this; fetch('/api/vlm/generate-queries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ objects: selected, bbox: bbox, estimated_location_type: analysis.estimated_location_type || null, estimated_coordinates: analysis.estimated_coordinates || null, identified_features: analysis.identified_features || null, }), }) .then(function(r) { if (!r.ok) return r.json().then(function(d) { throw new Error(d.detail || 'Fehler'); }); return r.json(); }) .then(function(data) { var src = hasExifGps ? 'EXIF BBox' : (data.region ? 'Region: ' + data.region : 'weltweit'); if (searchResult) searchResult.textContent = statusParts.join(' | ') + (statusParts.length ? ' | ' : '') + 'Query generiert (' + data.tag_count + ' Tags, ' + src + '). Suche OSM-Daten...'; // Suchbereich auf dem Globus zeichnen var drawBbox = data.effective_bbox || (bbox ? {south: bbox[0], west: bbox[1], north: bbox[2], east: bbox[3]} : null); if (drawBbox) { self._drawSearchArea(drawBbox, hasExifGps ? 'EXIF' : (data.region || 'Analyse')); } OverpassLayer.setColor('#e040fb'); return fetch('/api/overpass/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: data.query, bbox: bbox }), }); }) .then(function(r) { if (r.status === 429) { return r.json().then(function(d) { throw new Error(d.detail || 'Rate-Limit'); }); } if (!r.ok) { return r.text().then(function(txt) { try { var d = JSON.parse(txt); throw new Error(d.detail || 'Overpass-Fehler'); } catch(e) { if (e.message && !e.message.startsWith('Unexpected')) throw e; throw new Error('Overpass-Fehler ' + r.status); } }); } return r.json(); }) .then(function(data) { var elapsed = ((Date.now() - startTime) / 1000).toFixed(1); // Overpass-Ergebnisse rendern (EXIF-Marker ist separate Entity) OverpassLayer.render(data); if (searchResult) { var parts = []; if (hasExifGps) parts.push('EXIF-Marker'); if (self._searchAreaEntity) parts.push('Suchbereich'); parts.push(data.total + ' OSM-Treffer'); var text = parts.join(' + ') + ' (' + elapsed + 's)'; if (data.cached) text += ' [Cache]'; if (data.truncated) text += ' [Limit]'; searchResult.textContent = text; searchResult.style.color = 'var(--accent)'; } var countEl = document.getElementById('count-vlm'); if (countEl) countEl.textContent = data.total; }) .catch(function(e) { if (searchResult) { var prefix = hasExifGps ? 'EXIF GPS-Marker gesetzt | ' : ''; searchResult.textContent = prefix + 'Overpass-Fehler: ' + e.message; searchResult.style.color = hasExifGps ? '#ff9800' : '#ff5252'; } }) .finally(function() { if (btn) { btn.disabled = false; btn.textContent = 'AUF GLOBE SUCHEN'; } }); }, // === Suchbereich auf dem Globus visualisieren === _drawSearchArea: function(bbox, label) { this._clearSearchArea(); if (!Globe.viewer || !bbox) return; var isSmall = (Math.abs(bbox.north - bbox.south) < 2 && Math.abs(bbox.east - bbox.west) < 2); // Helle, klar sichtbare Fuellung this._searchAreaEntity = Globe.viewer.entities.add({ name: 'Suchbereich: ' + label, rectangle: { coordinates: Cesium.Rectangle.fromDegrees(bbox.west, bbox.south, bbox.east, bbox.north), material: Cesium.Color.fromCssColorString('#e040fb').withAlpha(isSmall ? 0.25 : 0.15), outline: true, outlineColor: Cesium.Color.fromCssColorString('#e040fb'), outlineWidth: 3, height: 0, }, }); // Zweites Rechteck leicht versetzt als Glow-Effekt this._searchAreaGlow = Globe.viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees( bbox.west - 0.05, bbox.south - 0.05, bbox.east + 0.05, bbox.north + 0.05 ), material: Cesium.Color.TRANSPARENT, outline: true, outlineColor: Cesium.Color.fromCssColorString('#e040fb').withAlpha(0.35), outlineWidth: 1, height: 0, }, }); // Diagonale Linien fuer taktisches Schraffur-Gefuehl this._searchAreaDiags = []; var steps = isSmall ? 6 : 12; for (var s = 1; s < steps; s++) { var t = s / steps; // Horizontale Linien var lineLat = bbox.south + t * (bbox.north - bbox.south); this._searchAreaDiags.push(Globe.viewer.entities.add({ polyline: { positions: Cesium.Cartesian3.fromDegreesArray([ bbox.west, lineLat, bbox.east, lineLat ]), width: 1, material: Cesium.Color.fromCssColorString('#e040fb').withAlpha(0.08), clampToGround: true, }, })); } // Eckpunkte - groesser und heller this._searchAreaCorners = []; var corners = [ [bbox.west, bbox.south], [bbox.east, bbox.south], [bbox.east, bbox.north], [bbox.west, bbox.north], ]; for (var i = 0; i < corners.length; i++) { this._searchAreaCorners.push(Globe.viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(corners[i][0], corners[i][1], 0), point: { pixelSize: 8, color: Cesium.Color.fromCssColorString('#e040fb'), outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, }, })); } // Kantenlinien explizit als Polylines (deutlich sichtbarer als rectangle outline) this._searchAreaEdges = []; var edgePositions = [ [bbox.west, bbox.south, bbox.east, bbox.south], [bbox.east, bbox.south, bbox.east, bbox.north], [bbox.east, bbox.north, bbox.west, bbox.north], [bbox.west, bbox.north, bbox.west, bbox.south], ]; for (var e = 0; e < edgePositions.length; e++) { this._searchAreaEdges.push(Globe.viewer.entities.add({ polyline: { positions: Cesium.Cartesian3.fromDegreesArray(edgePositions[e]), width: 3, material: Cesium.Color.fromCssColorString('#e040fb').withAlpha(0.9), clampToGround: true, }, })); } // Label - groesser und deutlicher var centerLat = (bbox.south + bbox.north) / 2; var centerLon = (bbox.west + bbox.east) / 2; this._searchAreaLabel = Globe.viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0), label: { text: '\u25A0 SUCHBEREICH', font: 'bold 13px monospace', fillColor: Cesium.Color.fromCssColorString('#e040fb'), outlineColor: Cesium.Color.BLACK, outlineWidth: 4, style: Cesium.LabelStyle.FILL_AND_OUTLINE, verticalOrigin: Cesium.VerticalOrigin.CENTER, horizontalOrigin: Cesium.HorizontalOrigin.CENTER, distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, isSmall ? 800000 : 12000000), disableDepthTestDistance: Number.POSITIVE_INFINITY, backgroundColor: Cesium.Color.fromCssColorString('#e040fb').withAlpha(0.15), showBackground: true, backgroundPadding: new Cesium.Cartesian2(8, 4), }, }); // Kamera auf den Bereich ausrichten wenn kein EXIF-GPS if (!isSmall) { Globe.viewer.camera.flyTo({ destination: Cesium.Rectangle.fromDegrees(bbox.west - 2, bbox.south - 2, bbox.east + 2, bbox.north + 2), duration: 2, }); } }, _clearSearchArea: function() { var v = Globe.viewer; if (!v) return; var self = this; ['_searchAreaEntity', '_searchAreaGlow', '_searchAreaLabel', '_exifMarkerEntity'].forEach(function(k) { if (self[k]) { v.entities.remove(self[k]); self[k] = null; } }); ['_searchAreaCorners', '_searchAreaEdges', '_searchAreaDiags'].forEach(function(k) { if (self[k]) { for (var i = 0; i < self[k].length; i++) { v.entities.remove(self[k][i]); } self[k] = null; } }); }, _reset: function() { this._currentAnalysis = null; this._isAnalyzing = false; this._clearSearchArea(); 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 = '-'; // Overpass-Ergebnisse loeschen if (typeof OverpassLayer !== 'undefined') OverpassLayer.clear(); }, };