/** * 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 in separatem Array sammeln, dann entfernen var toRemove = []; map.eachLayer(function(layer) { if (layer instanceof L.TileLayer) toRemove.push(layer); }); toRemove.forEach(function(layer) { map.removeLayer(layer); }); // Esri World Imagery this._satTileLayer = L.tileLayer( 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles © Esri', maxZoom: 19, noWrap: true } ).addTo(map); // Ortsnamen-Overlay this._satLabelLayer = L.tileLayer( 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', { maxZoom: 19, noWrap: true } ).addTo(map); // Satellite nach hinten, damit Marker darueber liegen if (this._satTileLayer.bringToBack) this._satTileLayer.bringToBack(); }, _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: '© OpenStreetMap', 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 = '

GEOINT Layer

' + self._subItemHtml('flights', 'Flugverkehr', 'flights') + self._subItemHtml('quakes', 'Erdbeben', 'quakes') + self._subItemHtml('gdelt', 'Nachrichten', 'gdelt') + '
' + 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 '
' + '' + '' + '
'; }, _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); }, 30000); // Bei Kartenbewegung neu laden this._moveHandler = function() { clearTimeout(self._moveDebounce); self._moveDebounce = setTimeout(function() { self._fetchFlights(map); }, 2000); }; 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() < 3) return; this._flightFetching = true; var center = map.getCenter(); var self = this; var token = localStorage.getItem('osint_token') || ''; var headers = token ? { 'Authorization': 'Bearer ' + token } : {}; fetch('/api/geoint/flights?lat=' + center.lat.toFixed(2) + '&lon=' + center.lng.toFixed(2) + '&radius=250', { headers: headers }) .then(function(r) { return r.ok ? r.json() : { ac: [] }; }) .then(function(data) { if (!self._flightLayer) return; // Neue Marker in temporaerem Layer bauen, dann atomar swappen var newLayer = L.layerGroup(); var ac = data.ac || data.aircraft || []; ac.slice(0, 500).forEach(function(a) { if (!a.lat || !a.lon) return; var heading = a.track || a.true_heading || 0; var icon = L.divIcon({ className: '', html: '
' + '' + '' + '
', 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 = '
' + '' + callsign + '' + (typ ? ' (' + typ + ')' : '') + '
ALT ' + (typeof alt === 'number' ? alt.toLocaleString() + ' ft' : alt) + '
SPD ' + (typeof spd === 'number' ? Math.round(spd) + ' kts' : spd) + '
HDG ' + Math.round(heading) + '°' + '
'; L.marker([a.lat, a.lon], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(newLayer); }); // Atomar swappen: alte Marker entfernen, neue hinzufuegen if (self._map && self._flightLayer) { self._map.removeLayer(self._flightLayer); self._flightLayer = newLayer.addTo(self._map); } }) .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( '
' + 'M' + mag.toFixed(1) + ' ' + (p.place || '') + '
TIEFE ' + (coords[2] || '?') + ' km' + '
ZEIT ' + timeStr + '
', { 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: '
' + (count > 99 ? '99+' : count) + '
', 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: '
N
', iconSize: [18, 18], iconAnchor: [9, 9], }); var popup = '
' + '' + name.substring(0, 100) + '' + (url ? '
Quelle' : '') + '
'; 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: '#000000', weight: 5, opacity: 0.5 }).addTo(self._distanceLayers); L.polyline([p1, p2], { color: '#ff2222', weight: 3, dashArray: '8 5' }).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: '
' + text + '
', iconSize: [0, 0], iconAnchor: [0, 12], }) }).addTo(self._distanceLayers); } // Punkt-Marker L.circleMarker(e.latlng, { radius: 6, fillColor: '#ff2222', color: '#ffffff', fillOpacity: 1, weight: 2 }).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); } }, // Berechnet Grid-Punkte fuer vollstaendige Kartenabdeckung _calcGridPoints(bounds) { var n = bounds.getNorth(), s = bounds.getSouth(); var e = bounds.getEast(), w = bounds.getWest(); var latSpan = Math.abs(n - s); var lonSpan = Math.abs(e - w); // Ein Punkt deckt ~250nm (~4.2 Grad) ab var step = 3.5; // Grad pro Grid-Zelle (etwas Overlap) var latSteps = Math.max(1, Math.ceil(latSpan / step)); var lonSteps = Math.max(1, Math.ceil(lonSpan / step)); // Maximal 4 Punkte (2x2 Grid) um API-Last zu begrenzen if (latSteps > 2) latSteps = 2; if (lonSteps > 2) lonSteps = 2; var points = []; var latStep = latSpan / latSteps; var lonStep = lonSpan / lonSteps; for (var i = 0; i < latSteps; i++) { for (var j = 0; j < lonSteps; j++) { points.push({ lat: s + latStep * (i + 0.5), lon: w + lonStep * (j + 0.5), radius: Math.min(Math.round(Math.max(latStep, lonStep) / 2 * 60), 250) }); } } return points; }, // ----------------------------------------------------------------------- // 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); } }, };