diff --git a/src/main.py b/src/main.py index de19369..0876040 100644 --- a/src/main.py +++ b/src/main.py @@ -334,6 +334,7 @@ from routers.public_api import router as public_api_router from routers.chat import router as chat_router from routers.network_analysis import router as network_analysis_router from routers.tutorial import router as tutorial_router +from routers.geoint import router as geoint_router app.include_router(auth_router) app.include_router(incidents_router) @@ -344,6 +345,7 @@ app.include_router(public_api_router) app.include_router(chat_router, prefix="/api/chat") app.include_router(network_analysis_router) app.include_router(tutorial_router) +app.include_router(geoint_router, prefix="/api/geoint") @app.websocket("/api/ws") diff --git a/src/routers/geoint.py b/src/routers/geoint.py new file mode 100644 index 0000000..a407211 --- /dev/null +++ b/src/routers/geoint.py @@ -0,0 +1,100 @@ +"""GEOINT-Router: Proxy fuer externe Echtzeit-Datenquellen (Flugverkehr, GDELT).""" +import logging +import time +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, Query + +from auth import get_current_user + +logger = logging.getLogger("osint.geoint") + +router = APIRouter(tags=["geoint"]) + +# --------------------------------------------------------------------------- +# Einfacher In-Memory-Cache +# --------------------------------------------------------------------------- +_cache: dict[str, tuple[float, dict]] = {} + + +def _get_cached(key: str, ttl: float) -> Optional[dict]: + if key in _cache: + ts, data = _cache[key] + if time.time() - ts < ttl: + return data + return None + + +def _set_cache(key: str, data: dict): + _cache[key] = (time.time(), data) + # Cache-Groesse begrenzen (max 50 Eintraege) + if len(_cache) > 50: + oldest = min(_cache, key=lambda k: _cache[k][0]) + del _cache[oldest] + + +# --------------------------------------------------------------------------- +# Flugverkehr (airplanes.live) +# --------------------------------------------------------------------------- +@router.get("/flights") +async def get_flights( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), + radius: int = Query(100, ge=10, le=250), + _user: dict = Depends(get_current_user), +): + """Proxy fuer airplanes.live API. 10s Cache, max 300 Aircraft.""" + cache_key = f"flights:{round(lat, 1)}:{round(lon, 1)}:{radius}" + cached = _get_cached(cache_key, ttl=10) + if cached: + return cached + + url = f"https://api.airplanes.live/v2/point/{lat:.4f}/{lon:.4f}/{radius}" + try: + async with httpx.AsyncClient(timeout=8) as client: + resp = await client.get(url) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning(f"airplanes.live Fehler: {e}") + return {"ac": []} + + # Auf 300 Aircraft begrenzen + if "ac" in data and len(data["ac"]) > 300: + data["ac"] = data["ac"][:300] + + _set_cache(cache_key, data) + return data + + +# --------------------------------------------------------------------------- +# GDELT Nachrichten +# --------------------------------------------------------------------------- +@router.get("/gdelt") +async def get_gdelt( + query: str = Query("conflict", max_length=200), + _user: dict = Depends(get_current_user), +): + """Proxy fuer GDELT GEO 2.0 API. 60s Cache.""" + cache_key = f"gdelt:{query[:50]}" + cached = _get_cached(cache_key, ttl=60) + if cached: + return cached + + url = ( + "https://api.gdeltproject.org/api/v2/geo/geo" + f"?query={query}&mode=PointData&format=GeoJSON" + "×pan=24h&maxrows=200" + ) + try: + async with httpx.AsyncClient(timeout=12) as client: + resp = await client.get(url) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning(f"GDELT Fehler: {e}") + return {"type": "FeatureCollection", "features": []} + + _set_cache(cache_key, data) + return data diff --git a/src/static/css/geoint.css b/src/static/css/geoint.css new file mode 100644 index 0000000..0e5f2d1 --- /dev/null +++ b/src/static/css/geoint.css @@ -0,0 +1,308 @@ +/* ===================================================================== + GEOINT-Modus: Taktische Kartenansicht mit Echtzeit-Datenlayern + ===================================================================== */ + +/* --- Toggle-Checkbox im Card-Header --- */ +.geoint-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; + user-select: none; + margin-right: 8px; +} +.geoint-toggle input[type="checkbox"] { + accent-color: #00ff88; + width: 13px; + height: 13px; + cursor: pointer; +} +.geoint-toggle-label { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-secondary); + transition: color 0.2s; +} +.geoint-toggle input:checked + .geoint-toggle-label { + color: #00ff88; + text-shadow: 0 0 6px rgba(0, 255, 136, 0.4); +} + +/* --- Taktisches Styling (aktiv) --- */ +.geoint-active .leaflet-tile-pane { + filter: brightness(0.88) contrast(1.08) saturate(0.85); + transition: filter 0.4s ease; +} +.geoint-active::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 800; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 3px, + rgba(0, 255, 100, 0.012) 3px, + rgba(0, 255, 100, 0.012) 6px + ); +} +.geoint-active .map-empty { display: none !important; } + +/* Gruener Akzent am Card-Header wenn aktiv */ +.map-card.geoint-card-active .card-header { + border-bottom: 2px solid rgba(0, 255, 136, 0.25); +} + +/* --- Sub-Layer Control Panel --- */ +.geoint-sub-control { + background: rgba(11, 17, 33, 0.92); + border: 1px solid rgba(0, 255, 136, 0.2); + border-radius: 6px; + padding: 10px 12px; + min-width: 170px; + backdrop-filter: blur(8px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} +.geoint-sub-control h4 { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 9px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + color: #00ff88; + margin: 0 0 8px 0; + padding-bottom: 6px; + border-bottom: 1px solid rgba(0, 255, 136, 0.15); +} +.geoint-sub-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; +} +.geoint-sub-item input[type="checkbox"] { + accent-color: #00ff88; + width: 12px; + height: 12px; + cursor: pointer; + flex-shrink: 0; +} +.geoint-sub-item label { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 11px; + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + white-space: nowrap; +} +.geoint-sub-item label .geoint-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} +.geoint-dot-flights { background: #00ff88; } +.geoint-dot-quakes { background: #ff4444; } +.geoint-dot-gdelt { background: #44aaff; } +.geoint-dot-heatmap { background: #ff8800; } +.geoint-dot-coords { background: #aaaaaa; } +.geoint-dot-distance { background: #ffdd00; } +.geoint-sub-separator { + height: 1px; + background: rgba(0, 255, 136, 0.1); + margin: 5px 0; +} + +/* --- Flugzeug-Icons --- */ +.geoint-aircraft { + display: flex; + align-items: center; + justify-content: center; + transition: filter 0.15s; +} +.geoint-aircraft:hover { + filter: drop-shadow(0 0 6px #00ff88); +} +.geoint-aircraft svg { + width: 14px; + height: 14px; +} + +/* --- Erdbeben Puls-Animation --- */ +.geoint-quake-marker { + animation: geoint-pulse 2.5s ease-in-out infinite; +} +@keyframes geoint-pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +/* --- GDELT Nachrichtenmarker --- */ +.geoint-gdelt-icon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: rgba(68, 170, 255, 0.85); + border: 1.5px solid rgba(68, 170, 255, 1); + border-radius: 50%; + font-size: 10px; + color: #fff; + font-weight: 700; + box-shadow: 0 0 4px rgba(68, 170, 255, 0.5); +} + +/* --- Koordinatenanzeige --- */ +.geoint-coord-display { + background: rgba(11, 17, 33, 0.88); + border: 1px solid rgba(0, 255, 136, 0.2); + border-radius: 4px; + padding: 4px 8px; + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 11px; + color: #00ff88; + letter-spacing: 0.5px; + white-space: nowrap; + backdrop-filter: blur(4px); +} + +/* --- Distanzmessung --- */ +.geoint-distance-label { + background: rgba(11, 17, 33, 0.9); + border: 1px solid rgba(255, 221, 0, 0.3); + border-radius: 3px; + padding: 2px 6px; + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 10px; + color: #ffdd00; + white-space: nowrap; +} + +/* --- Timeline-Slider --- */ +.geoint-timeline { + display: none; + padding: 6px 12px 8px; + background: rgba(11, 17, 33, 0.6); + border-top: 1px solid rgba(0, 255, 136, 0.1); +} +.geoint-active .geoint-timeline { + display: flex; + align-items: center; + gap: 10px; +} +.geoint-timeline input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.12); + border-radius: 2px; + outline: none; + cursor: pointer; +} +.geoint-timeline input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: #00ff88; + border-radius: 50%; + border: 2px solid rgba(11, 17, 33, 0.8); + cursor: pointer; + box-shadow: 0 0 6px rgba(0, 255, 136, 0.5); +} +.geoint-timeline input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + background: #00ff88; + border-radius: 50%; + border: 2px solid rgba(11, 17, 33, 0.8); + cursor: pointer; +} +.geoint-timeline-label { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 10px; + color: #00ff88; + min-width: 90px; + text-align: center; +} +.geoint-timeline-btn { + background: none; + border: 1px solid rgba(0, 255, 136, 0.3); + border-radius: 3px; + color: #00ff88; + font-size: 11px; + padding: 2px 6px; + cursor: pointer; + font-family: var(--font-mono, 'Courier New', monospace); +} +.geoint-timeline-btn:hover { + background: rgba(0, 255, 136, 0.1); +} + +/* --- Popup-Styling fuer GEOINT-Layer --- */ +.geoint-popup { + font-family: var(--font-mono, 'Courier New', monospace); + font-size: 11px; + line-height: 1.5; + color: #e0e0e0; +} +.geoint-popup strong { + color: #00ff88; +} +.geoint-popup .geoint-popup-row { + display: flex; + gap: 8px; +} +.geoint-popup .geoint-popup-key { + color: rgba(255, 255, 255, 0.5); + min-width: 40px; +} + +/* --- Light Theme Overrides --- */ +[data-theme="light"] .geoint-sub-control { + background: rgba(240, 243, 248, 0.95); + border-color: rgba(0, 160, 80, 0.25); +} +[data-theme="light"] .geoint-sub-control h4 { + color: #008844; +} +[data-theme="light"] .geoint-sub-item label { + color: rgba(0, 0, 0, 0.75); +} +[data-theme="light"] .geoint-coord-display { + background: rgba(240, 243, 248, 0.92); + color: #006633; + border-color: rgba(0, 160, 80, 0.25); +} +[data-theme="light"] .geoint-timeline { + background: rgba(240, 243, 248, 0.7); + border-top-color: rgba(0, 160, 80, 0.15); +} +[data-theme="light"] .geoint-timeline input[type="range"]::-webkit-slider-thumb { + background: #008844; +} +[data-theme="light"] .geoint-timeline-label { + color: #006633; +} +[data-theme="light"] .geoint-toggle input:checked + .geoint-toggle-label { + color: #008844; + text-shadow: none; +} +[data-theme="light"] .geoint-active .leaflet-tile-pane { + filter: brightness(0.95) contrast(1.05) saturate(0.9); +} +[data-theme="light"] .geoint-active::after { + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 3px, + rgba(0, 100, 50, 0.008) 3px, + rgba(0, 100, 50, 0.008) 6px + ); +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 0400388..ab0b136 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -19,6 +19,7 @@ + @@ -406,15 +407,24 @@
Geografische Verteilung
+
-
+
Keine Orte erkannt
+
@@ -776,6 +786,8 @@ + + @@ -792,6 +804,10 @@
Geografische Verteilung
+ diff --git a/src/static/js/components.js b/src/static/js/components.js index 801f84b..cfffa0a 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -716,6 +716,9 @@ const UI = { if (emptyEl) emptyEl.style.display = 'none'; + // Locations fuer GEOINT merken + this._lastLocations = locations; + // Statistik const totalArticles = locations.reduce((s, l) => s + l.article_count, 0); if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`; @@ -742,6 +745,10 @@ const UI = { }).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum this._applyMapTiles(); + + // GEOINT-Modus wiederherstellen + if (typeof GEOINT !== 'undefined') GEOINT.restoreState(this._map); + this._mapCluster = L.markerClusterGroup({ maxClusterRadius: 40, iconCreateFunction: function(cluster) { @@ -853,6 +860,11 @@ const UI = { this._mapLegendControl = legend; } + // GEOINT: Timeline mit Artikel-Daten initialisieren + if (typeof GEOINT !== 'undefined' && typeof App !== 'undefined') { + GEOINT.initTimeline(App._currentArticles || []); + } + // Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht) const self = this; [100, 300, 800].forEach(delay => { @@ -870,6 +882,8 @@ const UI = { _applyMapTiles() { if (!this._map) return; + // Im GEOINT-Modus Tiles nicht ueberschreiben + if (typeof GEOINT !== 'undefined' && GEOINT._active) return; // Alte Tile-Layer entfernen this._map.eachLayer(layer => { if (layer instanceof L.TileLayer) this._map.removeLayer(layer); diff --git a/src/static/js/geoint.js b/src/static/js/geoint.js new file mode 100644 index 0000000..c18451b --- /dev/null +++ b/src/static/js/geoint.js @@ -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: '© 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); }, 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: '
' + + '' + + '' + + '
', + 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(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( + '
' + + '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: '#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: '
' + text + '
', + 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); + } + }, +};