/**
* 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
'' +
'
' +
'
Analysiere Bild...
' +
'
' +
// Analyse-Ergebnis
'' +
'
' +
'
SZENE
' +
'
' +
'
ERKANNTE OBJEKTE
' +
'
' +
'
' +
'
' +
'
' +
'
' +
// 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 = '
' +
'' + 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('
') + '
';
}
}
// Szene
var sceneEl = document.getElementById('vlm-scene');
if (sceneEl) {
sceneEl.innerHTML = exifHtml +
'' + (data.scene_description || 'Keine Beschreibung') +
(data.terrain ? ' | Gelaende: ' + data.terrain : '') +
(data.estimated_location_type ? ' | Region: ' + data.estimated_location_type : '') +
'
' + 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
' +
'
' +
'| Latitude | ' + exif.latitude + ' |
' +
'| Longitude | ' + exif.longitude + ' |
' +
(exif.altitude ? '| Hoehe | ' + exif.altitude + 'm |
' : '') +
(exif.camera_model ? '| Kamera | ' + (exif.camera_make || '') + ' ' + exif.camera_model + ' |
' : '') +
(exif.timestamp ? '| Zeitstempel | ' + exif.timestamp + ' |
' : '') +
(exif.compass_heading ? '| 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 > Viewport-Checkbox > Region ===
var bbox = null;
if (hasExifGps) {
// 0.5 Grad um EXIF-Position
bbox = [
exif.latitude - 0.5,
exif.longitude - 0.5,
exif.latitude + 0.5,
exif.longitude + 0.5,
];
} 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),
];
}
}
// Kein explizites BBox? Region wird vom Backend aus estimated_location_type abgeleitet
}
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,
}),
})
.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();
},
};