GEOINT-Modus: Experimentelle taktische Kartenansicht mit Echtzeit-Datenlayern

Neuer experimenteller GEOINT-Modus per Checkbox auf der Karten-Kachel:
- Satellitenbilder (Esri World Imagery) statt OSM-Strassenkarte
- Echtzeit-Flugverkehr (airplanes.live via Backend-Proxy, 15s Refresh)
- Erdbeben-Layer (USGS M2.5+, pulsierende Kreise nach Magnitude)
- GDELT Nachrichten (geokodierte Echtzeit-News, Cluster-Darstellung)
- Heatmap-Visualisierung der Artikel-Standorte (Leaflet.heat)
- Timeline-Slider fuer zeitliche Filterung der Artikel-Marker
- Koordinatenanzeige (Lat/Lon unter Mauszeiger)
- Distanzmessung (Klick-zu-Klick mit km-Anzeige)
- Taktisches Styling (dunkle Tonung, gruene Akzente, Scanlines)

Neue Dateien: geoint.js, geoint.css, routers/geoint.py
Inspiriert von WorldView/Gods Eye Konzept, komplett eigenentwickelt.
Dieser Commit ist enthalten in:
Claude Dev
2026-03-24 09:29:19 +01:00
Ursprung fdbffa7e00
Commit b2be1358ab
6 geänderte Dateien mit 1030 neuen und 1 gelöschten Zeilen

589
src/static/js/geoint.js Normale Datei
Datei anzeigen

@@ -0,0 +1,589 @@
/**
* GEOINT-Modus: Taktische Kartenansicht mit Echtzeit-Datenlayern.
* Eigenstaendiges Modul — alle GEOINT-Logik gekapselt.
*/
const GEOINT = {
_active: false,
_map: null,
_sublayers: {},
_flightLayer: null,
_quakeLayer: null,
_gdeltLayer: null,
_heatLayer: null,
_flightInterval: null,
_quakeInterval: null,
_gdeltInterval: null,
_flightFetching: false,
_moveHandler: null,
_coordControl: null,
_coordHandler: null,
_distanceActive: false,
_distancePoints: [],
_distanceLayers: null,
_distanceHandler: null,
_subControl: null,
_osmTileLayer: null,
_satTileLayer: null,
_satLabelLayer: null,
_timelineData: null,
// -----------------------------------------------------------------------
// Hauptschalter
// -----------------------------------------------------------------------
toggle(enabled, map) {
this._active = enabled;
this._map = map;
// CSS-Klassen
var container = document.getElementById('map-container');
if (container) container.classList.toggle('geoint-active', enabled);
var fsContainer = document.getElementById('map-fullscreen-container');
if (fsContainer) fsContainer.classList.toggle('geoint-active', enabled);
var card = container ? container.closest('.map-card') : null;
if (card) card.classList.toggle('geoint-card-active', enabled);
// Sync beider Checkboxen
var cb1 = document.getElementById('geoint-mode-cb');
var cb2 = document.getElementById('geoint-mode-cb-fs');
if (cb1) cb1.checked = enabled;
if (cb2) cb2.checked = enabled;
if (enabled) {
this._applySatelliteTiles(map);
this._createSubControl(map);
this._restoreSublayers(map);
// Timeline anzeigen wenn Daten vorhanden
var tl = document.getElementById('geoint-timeline');
if (tl) tl.style.display = '';
} else {
this.cleanup();
this._restoreOsmTiles(map);
var tl = document.getElementById('geoint-timeline');
if (tl) tl.style.display = 'none';
}
this._saveState();
},
// -----------------------------------------------------------------------
// Tile-Management
// -----------------------------------------------------------------------
_applySatelliteTiles(map) {
if (!map) return;
// Bestehende Tile-Layer entfernen
map.eachLayer(function(layer) {
if (layer instanceof L.TileLayer) map.removeLayer(layer);
});
this._satTileLayer = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: '© Esri — Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, GIS User Community',
maxZoom: 18, noWrap: true }
).addTo(map);
this._satLabelLayer = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 18, noWrap: true, pane: 'overlayPane' }
).addTo(map);
},
_restoreOsmTiles(map) {
if (!map) return;
map.eachLayer(function(layer) {
if (layer instanceof L.TileLayer) map.removeLayer(layer);
});
// UI._applyMapTiles() wiederherstellen
if (typeof UI !== 'undefined' && UI._applyMapTiles) {
UI._applyMapTiles();
} else {
L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18, noWrap: true
}).addTo(map);
}
},
// -----------------------------------------------------------------------
// Sub-Layer Control Panel
// -----------------------------------------------------------------------
_createSubControl(map) {
if (this._subControl) return;
var self = this;
var SubControl = L.Control.extend({
options: { position: 'topright' },
onAdd: function() {
var div = L.DomUtil.create('div', 'geoint-sub-control');
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
div.innerHTML =
'<h4>GEOINT Layer</h4>' +
self._subItemHtml('flights', 'Flugverkehr', 'flights') +
self._subItemHtml('quakes', 'Erdbeben', 'quakes') +
self._subItemHtml('gdelt', 'Nachrichten', 'gdelt') +
'<div class="geoint-sub-separator"></div>' +
self._subItemHtml('heatmap', 'Heatmap', 'heatmap') +
self._subItemHtml('coords', 'Koordinaten', 'coords') +
self._subItemHtml('distance', 'Distanz', 'distance');
return div;
}
});
this._subControl = new SubControl();
map.addControl(this._subControl);
// Event-Listener fuer Sub-Checkboxen
['flights', 'quakes', 'gdelt', 'heatmap', 'coords', 'distance'].forEach(function(id) {
var cb = document.getElementById('geoint-sub-' + id);
if (cb) {
cb.addEventListener('change', function() {
self._toggleSublayer(id, this.checked, map);
});
}
});
},
_subItemHtml(id, label, dotClass) {
var checked = this._sublayers[id] ? ' checked' : '';
return '<div class="geoint-sub-item">' +
'<input type="checkbox" id="geoint-sub-' + id + '"' + checked + '>' +
'<label for="geoint-sub-' + id + '"><span class="geoint-dot geoint-dot-' + dotClass + '"></span>' + label + '</label>' +
'</div>';
},
_removeSubControl() {
if (this._subControl && this._map) {
this._map.removeControl(this._subControl);
this._subControl = null;
}
},
_toggleSublayer(id, enabled, map) {
this._sublayers[id] = enabled;
this._saveState();
switch (id) {
case 'flights': enabled ? this._startFlights(map) : this._stopFlights(); break;
case 'quakes': enabled ? this._startQuakes(map) : this._stopQuakes(); break;
case 'gdelt': enabled ? this._startGdelt(map) : this._stopGdelt(); break;
case 'heatmap': enabled ? this._startHeatmap(map) : this._stopHeatmap(); break;
case 'coords': enabled ? this._startCoords(map) : this._stopCoords(); break;
case 'distance': enabled ? this._startDistance(map) : this._stopDistance(); break;
}
},
_restoreSublayers(map) {
var self = this;
Object.keys(this._sublayers).forEach(function(id) {
if (self._sublayers[id]) {
self._toggleSublayer(id, true, map);
}
});
},
// -----------------------------------------------------------------------
// Layer: Flugverkehr
// -----------------------------------------------------------------------
_startFlights(map) {
if (this._flightLayer) return;
this._flightLayer = L.layerGroup().addTo(map);
var self = this;
this._fetchFlights(map);
this._flightInterval = setInterval(function() { self._fetchFlights(map); }, 15000);
// Bei Kartenbewegung neu laden
this._moveHandler = function() {
clearTimeout(self._moveDebounce);
self._moveDebounce = setTimeout(function() { self._fetchFlights(map); }, 600);
};
map.on('moveend', this._moveHandler);
},
_stopFlights() {
if (this._flightInterval) { clearInterval(this._flightInterval); this._flightInterval = null; }
if (this._moveHandler && this._map) { this._map.off('moveend', this._moveHandler); this._moveHandler = null; }
if (this._flightLayer && this._map) { this._map.removeLayer(this._flightLayer); this._flightLayer = null; }
},
_fetchFlights(map) {
if (this._flightFetching || !map || map.getZoom() < 5) return;
this._flightFetching = true;
var center = map.getCenter();
var bounds = map.getBounds();
// Radius in nm (grob: 1 Grad Lat ≈ 60nm)
var latDiff = Math.abs(bounds.getNorth() - bounds.getSouth()) / 2;
var radius = Math.min(Math.round(latDiff * 60), 250);
var self = this;
var token = localStorage.getItem('osint_token') || '';
fetch('/api/geoint/flights?lat=' + center.lat.toFixed(4) + '&lon=' + center.lng.toFixed(4) + '&radius=' + radius, {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
})
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function(data) {
if (!self._flightLayer) return;
self._flightLayer.clearLayers();
var ac = data.ac || data.aircraft || [];
ac.slice(0, 300).forEach(function(a) {
if (!a.lat || !a.lon) return;
var heading = a.track || a.true_heading || 0;
var icon = L.divIcon({
className: '',
html: '<div class="geoint-aircraft" style="transform:rotate(' + heading + 'deg)">' +
'<svg viewBox="0 0 24 24" fill="#00ff88" stroke="#004422" stroke-width="1">' +
'<path d="M12 2L8 10h-4l2 4-2 4h4l4 4 4-4h4l-2-4 2-4h-4z"/>' +
'</svg></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
});
var callsign = (a.flight || a.callsign || a.hex || '???').trim();
var alt = a.alt_baro || a.altitude || '?';
var spd = a.gs || a.ground_speed || '?';
var typ = a.t || a.type || '';
var popup = '<div class="geoint-popup">' +
'<strong>' + callsign + '</strong>' +
(typ ? ' <span style="opacity:0.5">(' + typ + ')</span>' : '') +
'<br><span class="geoint-popup-key">ALT</span> ' + (typeof alt === 'number' ? alt.toLocaleString() + ' ft' : alt) +
'<br><span class="geoint-popup-key">SPD</span> ' + (typeof spd === 'number' ? Math.round(spd) + ' kts' : spd) +
'<br><span class="geoint-popup-key">HDG</span> ' + Math.round(heading) + '°' +
'</div>';
L.marker([a.lat, a.lon], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(self._flightLayer);
});
})
.catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT flights:', e); })
.finally(function() { self._flightFetching = false; });
},
// -----------------------------------------------------------------------
// Layer: Erdbeben
// -----------------------------------------------------------------------
_startQuakes(map) {
if (this._quakeLayer) return;
this._quakeLayer = L.layerGroup().addTo(map);
this._fetchQuakes(map);
var self = this;
this._quakeInterval = setInterval(function() { self._fetchQuakes(map); }, 300000); // 5 min
},
_stopQuakes() {
if (this._quakeInterval) { clearInterval(this._quakeInterval); this._quakeInterval = null; }
if (this._quakeLayer && this._map) { this._map.removeLayer(this._quakeLayer); this._quakeLayer = null; }
},
_fetchQuakes(map) {
if (!this._quakeLayer) return;
var self = this;
fetch('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!self._quakeLayer) return;
self._quakeLayer.clearLayers();
var now = Date.now();
(data.features || []).forEach(function(f) {
var coords = f.geometry.coordinates;
var p = f.properties;
var mag = p.mag || 1;
var ageH = (now - p.time) / 3600000;
var color = ageH < 1 ? '#ff0000' : ageH < 6 ? '#ff6600' : ageH < 12 ? '#ffaa00' : '#ffdd00';
var radius = Math.max(mag * 3.5, 5);
var cls = ageH < 2 ? 'geoint-quake-marker' : '';
var marker = L.circleMarker([coords[1], coords[0]], {
radius: radius,
fillColor: color,
color: color,
weight: 1.5,
fillOpacity: 0.6,
className: cls
});
var timeStr = new Date(p.time).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
marker.bindPopup(
'<div class="geoint-popup">' +
'<strong>M' + mag.toFixed(1) + '</strong> ' + (p.place || '') +
'<br><span class="geoint-popup-key">TIEFE</span> ' + (coords[2] || '?') + ' km' +
'<br><span class="geoint-popup-key">ZEIT</span> ' + timeStr +
'</div>', { className: 'geoint-leaflet-popup' }
);
marker.addTo(self._quakeLayer);
});
})
.catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT quakes:', e); });
},
// -----------------------------------------------------------------------
// Layer: GDELT Nachrichten
// -----------------------------------------------------------------------
_startGdelt(map) {
if (this._gdeltLayer) return;
this._gdeltLayer = L.markerClusterGroup({
maxClusterRadius: 30,
iconCreateFunction: function(cluster) {
var count = cluster.getChildCount();
return L.divIcon({
html: '<div class="geoint-gdelt-icon">' + (count > 99 ? '99+' : count) + '</div>',
className: '',
iconSize: [22, 22],
});
}
}).addTo(map);
this._fetchGdelt(map);
var self = this;
this._gdeltInterval = setInterval(function() { self._fetchGdelt(map); }, 600000); // 10 min
},
_stopGdelt() {
if (this._gdeltInterval) { clearInterval(this._gdeltInterval); this._gdeltInterval = null; }
if (this._gdeltLayer && this._map) { this._map.removeLayer(this._gdeltLayer); this._gdeltLayer = null; }
},
_fetchGdelt(map) {
if (!this._gdeltLayer) return;
var self = this;
// Holt Lage-Kontext fuer GDELT-Suche
var query = '';
if (typeof App !== 'undefined' && App.currentIncidentId) {
var inc = (App.incidents || []).find(function(i) { return i.id === App.currentIncidentId; });
if (inc) query = encodeURIComponent((inc.title || '').substring(0, 80));
}
if (!query) query = 'conflict OR crisis OR disaster';
var token = localStorage.getItem('osint_token') || '';
fetch('/api/geoint/gdelt?query=' + query, {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
})
.then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function(data) {
if (!self._gdeltLayer) return;
self._gdeltLayer.clearLayers();
var features = data.features || [];
features.slice(0, 200).forEach(function(f) {
var coords = f.geometry.coordinates;
var p = f.properties || {};
var name = p.name || p.title || 'Nachricht';
var url = p.url || p.shareimage || '';
var icon = L.divIcon({
className: '',
html: '<div class="geoint-gdelt-icon">N</div>',
iconSize: [18, 18],
iconAnchor: [9, 9],
});
var popup = '<div class="geoint-popup" style="max-width:240px">' +
'<strong>' + name.substring(0, 100) + '</strong>' +
(url ? '<br><a href="' + url + '" target="_blank" rel="noopener" style="color:#44aaff;font-size:10px">Quelle</a>' : '') +
'</div>';
L.marker([coords[1], coords[0]], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(self._gdeltLayer);
});
})
.catch(function(e) { if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT gdelt:', e); });
},
// -----------------------------------------------------------------------
// Layer: Heatmap
// -----------------------------------------------------------------------
_startHeatmap(map) {
if (this._heatLayer) return;
if (typeof L.heatLayer === 'undefined') {
if (typeof DEV_MODE !== 'undefined' && DEV_MODE) console.warn('GEOINT: leaflet-heat nicht geladen');
return;
}
// Locations aus UI-State holen
var locations = (typeof UI !== 'undefined' && UI._lastLocations) ? UI._lastLocations : [];
if (!locations.length) return;
var maxCount = Math.max.apply(null, locations.map(function(l) { return l.article_count || 1; }));
var points = locations.map(function(l) {
return [l.lat, l.lon, (l.article_count || 1) / maxCount];
});
this._heatLayer = L.heatLayer(points, {
radius: 30,
blur: 20,
maxZoom: 12,
gradient: { 0.2: '#004400', 0.4: '#00ff88', 0.6: '#ffaa00', 0.8: '#ff4400', 1.0: '#ff0000' }
}).addTo(map);
},
_stopHeatmap() {
if (this._heatLayer && this._map) { this._map.removeLayer(this._heatLayer); this._heatLayer = null; }
},
// -----------------------------------------------------------------------
// Koordinatenanzeige
// -----------------------------------------------------------------------
_startCoords(map) {
if (this._coordControl) return;
var CoordControl = L.Control.extend({
options: { position: 'bottomleft' },
onAdd: function() {
var div = L.DomUtil.create('div', 'geoint-coord-display');
div.id = 'geoint-coord-text';
div.textContent = 'LAT: -- LON: --';
return div;
}
});
this._coordControl = new CoordControl();
map.addControl(this._coordControl);
var el = document.getElementById('geoint-coord-text');
this._coordHandler = function(e) {
if (el) el.textContent = 'LAT: ' + e.latlng.lat.toFixed(4) + ' LON: ' + e.latlng.lng.toFixed(4);
};
map.on('mousemove', this._coordHandler);
},
_stopCoords() {
if (this._coordHandler && this._map) { this._map.off('mousemove', this._coordHandler); this._coordHandler = null; }
if (this._coordControl && this._map) { this._map.removeControl(this._coordControl); this._coordControl = null; }
},
// -----------------------------------------------------------------------
// Distanzmessung
// -----------------------------------------------------------------------
_startDistance(map) {
if (this._distanceLayers) return;
this._distanceLayers = L.layerGroup().addTo(map);
this._distancePoints = [];
this._distanceActive = true;
map.getContainer().style.cursor = 'crosshair';
var self = this;
this._distanceHandler = function(e) {
self._distancePoints.push(e.latlng);
if (self._distancePoints.length >= 2) {
var p1 = self._distancePoints[self._distancePoints.length - 2];
var p2 = self._distancePoints[self._distancePoints.length - 1];
L.polyline([p1, p2], { color: '#ffdd00', weight: 2, dashArray: '6 4' }).addTo(self._distanceLayers);
var dist = p1.distanceTo(p2);
var totalDist = 0;
for (var i = 1; i < self._distancePoints.length; i++) {
totalDist += self._distancePoints[i - 1].distanceTo(self._distancePoints[i]);
}
var label = dist >= 1000
? (dist / 1000).toFixed(1) + ' km'
: Math.round(dist) + ' m';
var totalLabel = totalDist >= 1000
? (totalDist / 1000).toFixed(1) + ' km'
: Math.round(totalDist) + ' m';
var midLat = (p1.lat + p2.lat) / 2;
var midLng = (p1.lng + p2.lng) / 2;
var text = self._distancePoints.length > 2
? label + ' (Σ ' + totalLabel + ')'
: label;
L.marker([midLat, midLng], {
icon: L.divIcon({
className: '',
html: '<div class="geoint-distance-label">' + text + '</div>',
iconSize: [0, 0],
iconAnchor: [0, 12],
})
}).addTo(self._distanceLayers);
}
// Punkt-Marker
L.circleMarker(e.latlng, { radius: 4, fillColor: '#ffdd00', color: '#ffdd00', fillOpacity: 1, weight: 1 }).addTo(self._distanceLayers);
};
map.on('click', this._distanceHandler);
},
_stopDistance() {
this._distanceActive = false;
this._distancePoints = [];
if (this._distanceHandler && this._map) {
this._map.off('click', this._distanceHandler);
this._distanceHandler = null;
this._map.getContainer().style.cursor = '';
}
if (this._distanceLayers && this._map) { this._map.removeLayer(this._distanceLayers); this._distanceLayers = null; }
},
// -----------------------------------------------------------------------
// Timeline-Slider
// -----------------------------------------------------------------------
initTimeline(articles) {
if (!articles || !articles.length) return;
var dates = articles
.map(function(a) { return a.collected_at || a.published_at; })
.filter(Boolean)
.map(function(d) { return new Date(d).getTime(); })
.filter(function(t) { return !isNaN(t); })
.sort(function(a, b) { return a - b; });
if (dates.length < 2) return;
this._timelineData = { min: dates[0], max: dates[dates.length - 1], articles: articles };
var slider = document.getElementById('geoint-timeline-slider');
var label = document.getElementById('geoint-timeline-label');
if (!slider) return;
slider.min = dates[0];
slider.max = dates[dates.length - 1];
slider.value = dates[dates.length - 1];
if (label) label.textContent = this._formatTimelineDate(dates[dates.length - 1]);
},
_onTimelineChange(value) {
var label = document.getElementById('geoint-timeline-label');
if (label) label.textContent = this._formatTimelineDate(parseInt(value));
this._filterMarkersByTime(parseInt(value));
},
_formatTimelineDate(ts) {
var d = new Date(ts);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ' ' +
d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
},
_filterMarkersByTime(maxTime) {
// Filtert die bestehenden Marker im Cluster anhand des Zeitstempels
if (!this._map || !UI._mapCluster || !this._timelineData) return;
var articles = this._timelineData.articles;
var visibleIds = new Set();
articles.forEach(function(a) {
var t = new Date(a.collected_at || a.published_at || 0).getTime();
if (t <= maxTime) visibleIds.add(a.id);
});
// Marker sichtbar/unsichtbar machen ueber Opacity
UI._mapCluster.eachLayer(function(marker) {
if (marker._articleIds) {
var visible = marker._articleIds.some(function(id) { return visibleIds.has(id); });
marker.setOpacity(visible ? 1 : 0.08);
}
});
},
_resetTimeline() {
var slider = document.getElementById('geoint-timeline-slider');
if (slider && this._timelineData) {
slider.value = this._timelineData.max;
this._onTimelineChange(this._timelineData.max);
}
},
// -----------------------------------------------------------------------
// Cleanup
// -----------------------------------------------------------------------
cleanup() {
this._stopFlights();
this._stopQuakes();
this._stopGdelt();
this._stopHeatmap();
this._stopCoords();
this._stopDistance();
this._removeSubControl();
// CSS-Klassen entfernen
var container = document.getElementById('map-container');
if (container) container.classList.remove('geoint-active');
var fsContainer = document.getElementById('map-fullscreen-container');
if (fsContainer) fsContainer.classList.remove('geoint-active');
},
// -----------------------------------------------------------------------
// State Persistenz
// -----------------------------------------------------------------------
_saveState() {
try {
localStorage.setItem('geoint_mode', this._active ? 'true' : 'false');
localStorage.setItem('geoint_sublayers', JSON.stringify(this._sublayers));
} catch (e) { /* quota exceeded */ }
},
restoreState(map) {
if (!map) return;
this._map = map;
try {
var savedSublayers = localStorage.getItem('geoint_sublayers');
if (savedSublayers) this._sublayers = JSON.parse(savedSublayers);
} catch (e) { this._sublayers = {}; }
var wasActive = localStorage.getItem('geoint_mode') === 'true';
if (wasActive) {
this.toggle(true, map);
}
},
};