GEOINT-Modus aus Monitor entfernt

Wird als eigenstaendige Anwendung auf separater Subdomain neu aufgebaut.
Alle GEOINT-Dateien entfernt, dashboard.html/components.js/main.py
auf pre-GEOINT Stand zurueckgesetzt.
Dieser Commit ist enthalten in:
Claude Dev
2026-03-24 11:06:19 +01:00
Ursprung 8212617276
Commit e64447ab7f
14 geänderte Dateien mit 4112 neuen und 1999 gelöschten Zeilen

Datei anzeigen

@@ -1,4 +1,4 @@
"""Netzwerkanalyse: Entity-Extraktion (Haiku) + Beziehungsanalyse (Batched).""" """Netzwerkanalyse: Entity-Extraktion (Sonnet) + Beziehungsanalyse (Batched) mit Artikel-Deduplizierung."""
import asyncio import asyncio
import hashlib import hashlib
import json import json
@@ -9,7 +9,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
from config import CLAUDE_MODEL_FAST, TIMEZONE from config import CLAUDE_MODEL_FAST, CLAUDE_MODEL_MEDIUM, TIMEZONE
logger = logging.getLogger("osint.entity_extractor") logger = logging.getLogger("osint.entity_extractor")
@@ -194,6 +194,114 @@ def _compute_data_hash(article_ids, factcheck_ids, article_ts, factcheck_ts) ->
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Artikel-Deduplizierung
# ---------------------------------------------------------------------------
def _normalize_headline(headline: str) -> str:
"""Normalisiert eine Headline fuer Vergleiche."""
h = headline.lower().strip()
h = re.sub(r"[^a-z0-9\s]", "", h)
h = re.sub(r"\s+", " ", h).strip()
return h
def _headline_tokens(headline: str) -> set[str]:
"""Extrahiert bedeutungstragende Tokens aus einer Headline."""
tokens = set()
for word in _normalize_headline(headline).split():
if len(word) >= 3 and word not in _STOP_WORDS:
tokens.add(word)
return tokens
def _jaccard_similarity(set_a: set, set_b: set) -> float:
"""Jaccard-Aehnlichkeit zweier Mengen."""
if not set_a or not set_b:
return 0.0
intersection = set_a & set_b
union = set_a | set_b
return len(intersection) / len(union) if union else 0.0
def _content_fingerprint(text: str) -> str:
"""Kurzer Hash des Textinhalts fuer Near-Duplicate-Erkennung."""
normalized = re.sub(r"\s+", " ", text.lower().strip())[:500]
return hashlib.md5(normalized.encode("utf-8")).hexdigest()
def _deduplicate_articles(articles: list[dict], factchecks: list[dict]) -> tuple[list[dict], list[dict]]:
"""Entfernt redundante Artikel basierend auf Headline-Similarity und Content-Hash.
Behaelt pro Duplikat-Gruppe den Artikel mit dem laengsten Content.
Faktenchecks werden nicht dedupliziert (sind bereits einzigartig).
Returns:
Tuple von (deduplizierte_artikel, factchecks_unveraendert)
"""
if len(articles) <= 50:
return articles, factchecks
logger.info(f"Artikel-Dedup: {len(articles)} Artikel pruefen")
# Phase A: Exakte Content-Fingerprint-Dedup
seen_fingerprints: dict[str, int] = {}
for i, art in enumerate(articles):
content = art.get("content_de") or art.get("content_original") or ""
headline = art.get("headline_de") or art.get("headline") or ""
if not content and not headline:
continue
fp = _content_fingerprint(headline + " " + content)
if fp in seen_fingerprints:
existing_idx = seen_fingerprints[fp]
existing_content = articles[existing_idx].get("content_de") or articles[existing_idx].get("content_original") or ""
if len(content) > len(existing_content):
seen_fingerprints[fp] = i
else:
seen_fingerprints[fp] = i
after_fp = list(seen_fingerprints.values())
fp_removed = len(articles) - len(after_fp)
# Phase B: Headline-Similarity-Dedup (Jaccard >= 0.7)
remaining = [articles[i] for i in sorted(after_fp)]
token_sets = []
for art in remaining:
headline = art.get("headline_de") or art.get("headline") or ""
token_sets.append(_headline_tokens(headline))
keep_mask = [True] * len(remaining)
for i in range(len(remaining)):
if not keep_mask[i]:
continue
for j in range(i + 1, len(remaining)):
if not keep_mask[j]:
continue
if _jaccard_similarity(token_sets[i], token_sets[j]) >= 0.7:
content_i = remaining[i].get("content_de") or remaining[i].get("content_original") or ""
content_j = remaining[j].get("content_de") or remaining[j].get("content_original") or ""
if len(content_j) > len(content_i):
keep_mask[i] = False
break
else:
keep_mask[j] = False
deduped = [art for art, keep in zip(remaining, keep_mask) if keep]
headline_removed = len(remaining) - len(deduped)
logger.info(
f"Artikel-Dedup abgeschlossen: {len(articles)} -> {len(deduped)} "
f"({fp_removed} Content-Duplikate, {headline_removed} Headline-Duplikate entfernt)"
)
return deduped, factchecks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Entity-Merge Helper # Entity-Merge Helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -279,8 +387,8 @@ async def _phase1_extract_entities(
headline = art.get("headline_de") or art.get("headline") or "" headline = art.get("headline_de") or art.get("headline") or ""
content = art.get("content_de") or art.get("content_original") or "" content = art.get("content_de") or art.get("content_original") or ""
source = art.get("source") or "" source = art.get("source") or ""
if len(content) > 2000: if len(content) > 800:
content = content[:2000] + "..." content = content[:800] + "..."
all_texts.append(f"[{source}] {headline}\n{content}") all_texts.append(f"[{source}] {headline}\n{content}")
for fc in factchecks: for fc in factchecks:
@@ -293,7 +401,7 @@ async def _phase1_extract_entities(
logger.warning(f"Analyse {analysis_id}: Keine Texte vorhanden") logger.warning(f"Analyse {analysis_id}: Keine Texte vorhanden")
return [] return []
batch_size = 30 batch_size = 50
batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)] batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)]
logger.info(f"{len(all_texts)} Texte in {len(batches)} Batches") logger.info(f"{len(all_texts)} Texte in {len(batches)} Batches")
@@ -304,10 +412,10 @@ async def _phase1_extract_entities(
prompt = ENTITY_EXTRACTION_PROMPT.format(articles_text=articles_text) prompt = ENTITY_EXTRACTION_PROMPT.format(articles_text=articles_text)
try: try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_MEDIUM)
usage_acc.add(usage) usage_acc.add(usage)
except Exception as e: except Exception as e:
logger.error(f"Haiku Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}") logger.error(f"Sonnet Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}")
continue continue
parsed = _parse_json_response(result_text) parsed = _parse_json_response(result_text)
@@ -500,8 +608,8 @@ async def _phase2_analyze_relationships(
headline = art.get("headline_de") or art.get("headline") or "" headline = art.get("headline_de") or art.get("headline") or ""
content = art.get("content_de") or art.get("content_original") or "" content = art.get("content_de") or art.get("content_original") or ""
source = art.get("source") or "" source = art.get("source") or ""
if len(content) > 2000: if len(content) > 800:
content = content[:2000] + "..." content = content[:800] + "..."
all_texts.append(f"[{source}] {headline}\n{content}") all_texts.append(f"[{source}] {headline}\n{content}")
for fc in factchecks: for fc in factchecks:
@@ -514,7 +622,7 @@ async def _phase2_analyze_relationships(
return [] return []
# --- Stufe A: Per-Batch Beziehungsextraktion --- # --- Stufe A: Per-Batch Beziehungsextraktion ---
batch_size = 30 batch_size = 50
batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)] batches = [all_texts[i:i + batch_size] for i in range(0, len(all_texts), batch_size)]
logger.info(f"Stufe A: {len(batches)} Batches für Beziehungsextraktion") logger.info(f"Stufe A: {len(batches)} Batches für Beziehungsextraktion")
@@ -545,7 +653,7 @@ async def _phase2_analyze_relationships(
) )
try: try:
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST) result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_MEDIUM)
usage_acc.add(usage) usage_acc.add(usage)
except Exception as e: except Exception as e:
logger.error(f"Relationship Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}") logger.error(f"Relationship Batch {batch_idx + 1}/{len(batches)} fehlgeschlagen: {e}")
@@ -1067,6 +1175,9 @@ async def extract_and_relate_entities(analysis_id: int, tenant_id: int, ws_manag
logger.info(f"Analyse {analysis_id}: {len(articles)} Artikel, " logger.info(f"Analyse {analysis_id}: {len(articles)} Artikel, "
f"{len(factchecks)} Faktenchecks aus {len(incident_ids)} Lagen") f"{len(factchecks)} Faktenchecks aus {len(incident_ids)} Lagen")
# Artikel-Deduplizierung vor KI-Pipeline
articles, factchecks = _deduplicate_articles(articles, factchecks)
# Phase 1: Entity-Extraktion # Phase 1: Entity-Extraktion
if not await _check_analysis_exists(db, analysis_id): if not await _check_analysis_exists(db, analysis_id):
return return

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -24,6 +24,7 @@ CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/usr/bin/claude")
CLAUDE_TIMEOUT = 1800 # Sekunden (30 Min - Lage-Updates mit vielen Artikeln brauchen mehr Zeit) CLAUDE_TIMEOUT = 1800 # Sekunden (30 Min - Lage-Updates mit vielen Artikeln brauchen mehr Zeit)
# Claude Modelle # Claude Modelle
CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-Selektion) CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-Selektion)
CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse)
# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen) # Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen)
OUTPUT_LANGUAGE = "Deutsch" OUTPUT_LANGUAGE = "Deutsch"

Datei anzeigen

@@ -298,11 +298,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response = await call_next(request) response = await call_next(request)
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"font-src 'self' https://fonts.gstatic.com; " "font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: https://tile.openstreetmap.de https://server.arcgisonline.com; " "img-src 'self' data: https://tile.openstreetmap.de; "
"connect-src 'self' wss: ws: https://earthquake.usgs.gov https://api.gdeltproject.org; " "connect-src 'self' wss: ws:; "
"frame-ancestors 'none'" "frame-ancestors 'none'"
) )
response.headers["Permissions-Policy"] = ( response.headers["Permissions-Policy"] = (
@@ -334,7 +334,6 @@ from routers.public_api import router as public_api_router
from routers.chat import router as chat_router from routers.chat import router as chat_router
from routers.network_analysis import router as network_analysis_router from routers.network_analysis import router as network_analysis_router
from routers.tutorial import router as tutorial_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(auth_router)
app.include_router(incidents_router) app.include_router(incidents_router)
@@ -345,16 +344,6 @@ app.include_router(public_api_router)
app.include_router(chat_router, prefix="/api/chat") app.include_router(chat_router, prefix="/api/chat")
app.include_router(network_analysis_router) app.include_router(network_analysis_router)
app.include_router(tutorial_router) app.include_router(tutorial_router)
app.include_router(geoint_router, prefix="/api/geoint")
@app.on_event("startup")
@app.on_event("startup")
async def _start_aisstream_on_startup():
import asyncio
from routers.geoint import _start_aisstream
await asyncio.sleep(3)
_start_aisstream()
@app.websocket("/api/ws") @app.websocket("/api/ws")

Datei anzeigen

@@ -1,300 +0,0 @@
"""GEOINT-Router: Proxy fuer externe Echtzeit-Datenquellen (Flugverkehr, Schiffsverkehr, GDELT)."""
import asyncio
import json as _json
import logging
import time
from typing import Optional
import httpx
import websockets
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)
if len(_cache) > 50:
oldest = min(_cache, key=lambda k: _cache[k][0])
del _cache[oldest]
# ---------------------------------------------------------------------------
# Flugverkehr: Globaler Snapshot (airplanes.live)
# ---------------------------------------------------------------------------
_FLIGHT_GRID = [
# Europa
(48.0, 2.0), # Westeuropa (Paris)
(48.0, 16.0), # Mitteleuropa (Wien)
(55.0, 10.0), # Nordeuropa (Daenemark)
(40.0, -4.0), # Iberische Halbinsel
(41.0, 12.0), # Suedeuropa (Rom)
(38.0, 24.0), # Suedosteuropa (Griechenland)
(55.0, 25.0), # Baltikum
(60.0, 25.0), # Skandinavien-Ost
(52.0, 30.0), # Osteuropa
(45.0, 37.0), # Schwarzes Meer / Tuerkei Ost
# UK / Island
(54.0, -2.0), # UK
(63.0, -19.0), # Island
# Naher Osten (erweitert)
(33.0, 36.0), # Levante (Syrien/Libanon/Israel)
(30.0, 31.0), # Aegypten / Kairo
(25.0, 45.0), # Saudi-Arabien Zentral
(26.5, 56.0), # Strasse von Hormuz / VAE
(25.0, 51.5), # Katar / Bahrain
(33.0, 44.0), # Irak (Bagdad)
(33.0, 52.0), # Iran (Teheran)
(15.0, 45.0), # Jemen / Rotes Meer
(21.0, 40.0), # Saudi-Arabien West (Dschidda)
# Nordafrika
(34.0, 2.0), # Maghreb (Algier)
(33.0, -7.0), # Marokko (Casablanca)
(32.0, 13.0), # Libyen (Tripolis)
# Zentralasien
(41.0, 69.0), # Usbekistan (Taschkent)
(39.0, 63.0), # Turkmenistan
# Nordamerika Ostkueste
(40.0, -74.0), # New York
(33.0, -84.0), # Atlanta
(42.0, -88.0), # Chicago
(26.0, -80.0), # Florida (Miami)
(45.0, -74.0), # Montreal
# Nordamerika Westkueste
(34.0, -118.0), # Los Angeles
(47.0, -122.0), # Seattle
(37.0, -122.0), # San Francisco
# Nordamerika Zentral
(30.0, -97.0), # Texas (Austin)
(39.0, -105.0), # Denver
# Ostasien
(35.0, 140.0), # Japan (Tokio)
(37.0, 127.0), # Korea (Seoul)
(31.0, 121.0), # Shanghai
(40.0, 117.0), # Peking
(22.0, 114.0), # Hongkong
(25.0, 121.0), # Taiwan
# Suedasien
(19.0, 73.0), # Mumbai
(28.0, 77.0), # Delhi
(13.0, 80.0), # Chennai
(7.0, 80.0), # Sri Lanka
# Suedostasien
(1.0, 104.0), # Singapur
(14.0, 101.0), # Bangkok
(-6.0, 107.0), # Jakarta
(10.0, 107.0), # Ho-Chi-Minh
# Ozeanien
(-34.0, 151.0), # Sydney
(-37.0, 175.0), # Neuseeland
# Afrika
(-1.0, 37.0), # Nairobi
(-34.0, 18.0), # Kapstadt
(6.0, 3.0), # Lagos
(9.0, 39.0), # Addis Abeba
# Suedamerika
(-23.0, -43.0), # Rio de Janeiro
(-34.0, -58.0), # Buenos Aires
(-12.0, -77.0), # Lima
(4.0, -74.0), # Bogota
]
_flight_lock = asyncio.Lock()
async def _fetch_global_flights() -> dict:
"""Holt Flugdaten fuer alle Stuetzpunkte parallel."""
cached = _get_cached("flights_global", ttl=30)
if cached:
return cached
async with _flight_lock:
cached = _get_cached("flights_global", ttl=30)
if cached:
return cached
seen: dict[str, dict] = {}
errors = 0
async with httpx.AsyncClient(timeout=10) as client:
for i in range(0, len(_FLIGHT_GRID), 8):
batch = _FLIGHT_GRID[i:i + 8]
tasks = [client.get(f"https://api.airplanes.live/v2/point/{lat:.2f}/{lon:.2f}/250")
for lat, lon in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
errors += 1
continue
try:
data = r.json()
for ac in data.get("ac", []):
hex_id = ac.get("hex")
if hex_id and hex_id not in seen:
seen[hex_id] = ac
except Exception:
errors += 1
if i + 8 < len(_FLIGHT_GRID):
await asyncio.sleep(0.3)
result = {"ac": list(seen.values()), "total": len(seen), "errors": errors}
logger.info(f"GEOINT Flights: {len(seen)} Flugzeuge ({errors} Fehler)")
_set_cache("flights_global", result)
return result
@router.get("/flights")
async def get_flights(_user: dict = Depends(get_current_user)):
"""Globaler Flugverkehr-Snapshot. 30s Cache."""
return await _fetch_global_flights()
# ---------------------------------------------------------------------------
# Schiffsverkehr: AISStream.io (globales Echtzeit-AIS via WebSocket)
# ---------------------------------------------------------------------------
_AISSTREAM_KEY = "1a56b078db829727abd4d617937bae51c6f9973e"
_AISSTREAM_URL = "wss://stream.aisstream.io/v0/stream"
# Globaler Schiffs-Store: {mmsi: {lat, lon, sog, cog, heading, name, ship_type, ts}}
_ships_store: dict[int, dict] = {}
_ships_lock = asyncio.Lock()
_ships_ws_task: Optional[asyncio.Task] = None
_ships_connected = False
async def _aisstream_listener():
"""Dauerhafter WebSocket-Client fuer AISStream. Sammelt Schiffspositionen."""
global _ships_connected
while True:
try:
logger.info("AISStream: Verbinde...")
async with websockets.connect(_AISSTREAM_URL, ping_interval=30, ping_timeout=10,
close_timeout=5) as ws:
# Subscription: globale BoundingBox, nur Positionsberichte
sub = {
"APIKey": _AISSTREAM_KEY,
"BoundingBoxes": [[[-90, -180], [90, 180]]],
"FilterMessageTypes": ["PositionReport"],
}
await ws.send(_json.dumps(sub))
_ships_connected = True
logger.info("AISStream: Verbunden, empfange Schiffsdaten...")
async for raw in ws:
try:
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
msg = _json.loads(text)
meta = msg.get("MetaData", {})
mmsi = meta.get("MMSI")
if not mmsi:
continue
pos = msg.get("Message", {}).get("PositionReport", {})
lat = meta.get("latitude") or pos.get("Latitude")
lon = meta.get("longitude") or pos.get("Longitude")
if not lat or not lon or not (-90 <= lat <= 90 and -180 <= lon <= 180):
continue
_ships_store[mmsi] = {
"mmsi": mmsi,
"lat": round(lat, 5),
"lon": round(lon, 5),
"sog": round(pos.get("Sog", 0), 1),
"cog": round(pos.get("Cog", 0), 1),
"heading": pos.get("TrueHeading", 0),
"name": (meta.get("ShipName") or "").strip(),
"ts": time.time(),
}
if len(_ships_store) % 1000 == 0:
logger.info(f"AISStream: {len(_ships_store)} Schiffe gesammelt")
# Alte Eintraege alle 60s bereinigen (>15 Min alt)
if len(_ships_store) % 500 == 0:
cutoff = time.time() - 900
stale = [k for k, v in _ships_store.items() if v["ts"] < cutoff]
for k in stale:
del _ships_store[k]
except Exception as parse_err:
if len(_ships_store) < 5:
logger.warning(f"AISStream Parse-Fehler: {parse_err}, raw type: {type(raw)}, first 100: {str(raw)[:100]}")
continue
except Exception as e:
_ships_connected = False
logger.warning(f"AISStream Fehler: {e}. Reconnect in 10s...")
await asyncio.sleep(10)
def _start_aisstream():
"""Startet den AISStream-Listener als Background-Task."""
global _ships_ws_task
if _ships_ws_task is None or _ships_ws_task.done():
_ships_ws_task = asyncio.create_task(_aisstream_listener())
logger.info("AISStream Background-Task gestartet")
@router.get("/ships")
async def get_ships(_user: dict = Depends(get_current_user)):
"""Globaler Schiffsverkehr aus AISStream. Echtzeit-Positionen."""
# Lazy-Start: WebSocket-Listener beim ersten Abruf starten
_start_aisstream()
ships = list(_ships_store.values())
return {
"ships": ships,
"total": len(ships),
"connected": _ships_connected,
}
# ---------------------------------------------------------------------------
# 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"
"&timespan=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

Datei anzeigen

@@ -1,321 +0,0 @@
/* =====================================================================
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: 12px;
font-weight: 700;
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;
}
/* Scanline-Overlay (subtiler Effekt, kein Blocking) */
.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-ships { background: #4499ff; }
.geoint-dot-quakes { background: #ff4444; }
.geoint-dot-gdelt { background: #44aaff; }
.geoint-dot-heatmap { background: #ff8800; }
.geoint-dot-coords { background: #aaaaaa; }
.geoint-dot-distance { background: #ff2222; }
.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;
}
/* --- Schiffs-Icons --- */
.geoint-ship {
display: flex;
align-items: center;
justify-content: center;
transition: filter 0.15s;
}
.geoint-ship:hover {
filter: drop-shadow(0 0 4px #4499ff);
}
/* --- 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: 12px;
font-weight: 700;
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 {
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
background: rgba(11, 17, 33, 0.9);
border: 1px solid rgba(255, 34, 34, 0.6);
border-radius: 3px;
padding: 2px 6px;
font-family: var(--font-mono, 'Courier New', monospace);
font-size: 12px;
font-weight: 700;
color: #ffffff;
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: 12px;
font-weight: 700;
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 --- */
/* Dunkler Popup-Hintergrund fuer GEOINT-Layer */
.geoint-leaflet-popup .leaflet-popup-content-wrapper {
background: rgba(11, 17, 33, 0.95);
border: 1px solid rgba(0, 255, 136, 0.25);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.geoint-leaflet-popup .leaflet-popup-tip {
background: rgba(11, 17, 33, 0.95);
}
.geoint-leaflet-popup .leaflet-popup-close-button {
color: rgba(255, 255, 255, 0.6);
}
.geoint-leaflet-popup .leaflet-popup-close-button:hover {
color: #00ff88;
}
.geoint-popup {
font-family: var(--font-mono, 'Courier New', monospace);
font-size: 12px;
line-height: 1.6;
color: #ffffff;
}
.geoint-popup strong {
color: #00ff88;
font-size: 13px;
}
.geoint-popup .geoint-popup-row {
display: flex;
gap: 8px;
}
.geoint-popup .geoint-popup-key {
color: rgba(255, 255, 255, 0.55);
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);
}
/* Light theme scanline overlay disabled */

Datei anzeigen

@@ -0,0 +1,188 @@
/* =================================================================
AegisSight OSINT Monitor - Cluster Graph Styles
Hierarchical country-based network visualization
================================================================= */
/* ---- Breadcrumb ---- */
.cluster-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(15, 23, 42, 0.6);
border-bottom: 1px solid var(--border, #1e293b);
font-size: 13px;
min-height: 36px;
flex-shrink: 0;
}
.breadcrumb-item {
color: #94a3b8;
font-size: 13px;
}
.breadcrumb-item.active {
color: #f1f5f9;
font-weight: 600;
}
.breadcrumb-item.clickable {
cursor: pointer;
color: #60a5fa;
transition: color 0.15s;
}
.breadcrumb-item.clickable:hover {
color: #93bbfc;
text-decoration: underline;
}
.breadcrumb-separator {
color: #475569;
font-size: 14px;
user-select: none;
}
.cluster-back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: 1px solid #334155;
color: #94a3b8;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
transition: all 0.15s;
white-space: nowrap;
}
.cluster-back-btn:hover {
border-color: #60a5fa;
color: #60a5fa;
background: rgba(96, 165, 250, 0.08);
}
/* ---- View Toggle Button ---- */
.network-view-toggle {
display: inline-flex;
align-items: center;
gap: 0;
background: rgba(30, 41, 59, 0.6);
border: 1px solid #334155;
border-radius: 6px;
padding: 2px;
margin-right: 8px;
}
.network-view-toggle-btn {
padding: 5px 12px;
background: transparent;
border: none;
color: #94a3b8;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
white-space: nowrap;
}
.network-view-toggle-btn.active {
background: #334155;
color: #f1f5f9;
font-weight: 600;
}
.network-view-toggle-btn:hover:not(.active) {
color: #e2e8f0;
background: rgba(51, 65, 85, 0.4);
}
/* ---- Cluster Graph SVG ---- */
.cg-zoom-layer {
/* Smooth transitions handled by d3 */
}
/* Country nodes */
.cg-country-node {
transition: filter 0.2s;
}
.cg-country-circle {
transition: stroke-width 0.2s, opacity 0.2s;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
}
.cg-country-node:hover .cg-country-circle {
filter: drop-shadow(0 4px 16px rgba(241, 245, 249, 0.15));
}
.cg-country-label {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
user-select: none;
}
.cg-country-count {
user-select: none;
}
/* Detail nodes */
.cg-detail-node circle {
transition: stroke 0.15s, stroke-width 0.15s, opacity 0.15s;
}
.cg-detail-node text {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
user-select: none;
}
/* Links */
.cg-links line,
.cg-detail-links line {
pointer-events: none;
}
/* Legend */
.cg-legend text {
user-select: none;
}
/* ---- Tooltip ---- */
.cg-tooltip {
pointer-events: none;
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
line-height: 1.5;
}
.cg-tooltip hr {
border: none;
border-top: 1px solid #334155;
margin: 4px 0;
}
/* ---- Responsive ---- */
@media (max-width: 768px) {
.cluster-breadcrumb {
padding: 6px 12px;
font-size: 12px;
}
.cluster-back-btn {
font-size: 11px;
padding: 2px 8px;
}
.network-view-toggle-btn {
padding: 4px 8px;
font-size: 11px;
}
}

Datei anzeigen

@@ -19,7 +19,6 @@
<link rel="stylesheet" href="/static/css/network.css?v=20260316a"> <link rel="stylesheet" href="/static/css/network.css?v=20260316a">
<link rel="stylesheet" href="/static/css/network-cluster.css?v=20260322b"> <link rel="stylesheet" href="/static/css/network-cluster.css?v=20260322b">
<link rel="stylesheet" href="/static/css/style.css?v=20260316k"> <link rel="stylesheet" href="/static/css/style.css?v=20260316k">
<link rel="stylesheet" href="/static/css/geoint.css?v=20260324a">
</head> </head>
<body> <body>
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a> <a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
@@ -407,10 +406,6 @@
<div class="card-title">Geografische Verteilung</div> <div class="card-title">Geografische Verteilung</div>
<span class="map-stats" id="map-stats"></span> <span class="map-stats" id="map-stats"></span>
<div class="card-header-actions"> <div class="card-header-actions">
<label class="geoint-toggle" title="GEOINT-Modus aktivieren">
<input type="checkbox" id="geoint-mode-cb" onchange="GEOINT.toggle(this.checked, UI._map || GEOINT._map)">
<span class="geoint-toggle-label">GEOINT</span>
</label>
<button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button> <button class="btn btn-secondary btn-small" id="geoparse-btn" onclick="App.triggerGeoparse()" title="Orte aus Artikeln einlesen">Orte einlesen</button>
<button class="btn btn-secondary btn-small map-expand-btn" id="map-expand-btn" onclick="UI.toggleMapFullscreen()" title="Vollbild" aria-label="Karte im Vollbild anzeigen"> <button class="btn btn-secondary btn-small map-expand-btn" id="map-expand-btn" onclick="UI.toggleMapFullscreen()" title="Vollbild" aria-label="Karte im Vollbild anzeigen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
@@ -420,11 +415,6 @@
<div class="map-container" id="map-container"> <div class="map-container" id="map-container">
<div class="map-empty" id="map-empty">Keine Orte erkannt</div> <div class="map-empty" id="map-empty">Keine Orte erkannt</div>
</div> </div>
<div class="geoint-timeline" id="geoint-timeline" style="display:none">
<button class="geoint-timeline-btn" onclick="GEOINT._resetTimeline()" title="Zuruecksetzen">&#8634;</button>
<input type="range" id="geoint-timeline-slider" min="0" max="100" value="100" oninput="GEOINT._onTimelineChange(this.value)">
<span class="geoint-timeline-label" id="geoint-timeline-label">--</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -786,8 +776,6 @@
<script src="/static/js/api.js?v=20260316c"></script> <script src="/static/js/api.js?v=20260316c"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260316d"></script> <script src="/static/js/components.js?v=20260316d"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="/static/js/geoint.js?v=20260324a"></script>
<script src="/static/js/layout.js?v=20260316b"></script> <script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/app.js?v=20260316b"></script> <script src="/static/js/app.js?v=20260316b"></script>
<script src="/static/js/api_network.js?v=20260316a"></script> <script src="/static/js/api_network.js?v=20260316a"></script>
@@ -804,10 +792,6 @@
<div class="map-fullscreen-header"> <div class="map-fullscreen-header">
<div class="map-fullscreen-title">Geografische Verteilung</div> <div class="map-fullscreen-title">Geografische Verteilung</div>
<span class="map-stats map-fullscreen-stats" id="map-fullscreen-stats"></span> <span class="map-stats map-fullscreen-stats" id="map-fullscreen-stats"></span>
<label class="geoint-toggle" title="GEOINT-Modus">
<input type="checkbox" id="geoint-mode-cb-fs" onchange="GEOINT.toggle(this.checked, UI._map || GEOINT._map)">
<span class="geoint-toggle-label">GEOINT</span>
</label>
<button class="btn btn-secondary btn-small" onclick="UI.toggleMapFullscreen()" title="Vollbild beenden" aria-label="Vollbild beenden"> <button class="btn btn-secondary btn-small" onclick="UI.toggleMapFullscreen()" title="Vollbild beenden" aria-label="Vollbild beenden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button> </button>

Datei anzeigen

@@ -76,6 +76,7 @@ App.selectNetworkAnalysis = async function(id) {
if (analysis.status === 'ready') { if (analysis.status === 'ready') {
this._hideNetworkProgress(); this._hideNetworkProgress();
var graphData = await API.getNetworkGraph(id); var graphData = await API.getNetworkGraph(id);
document.getElementById('network-graph-area').innerHTML = '';
NetworkGraph.init('network-graph-area', graphData); NetworkGraph.init('network-graph-area', graphData);
this._setupNetworkFilters(graphData); this._setupNetworkFilters(graphData);
@@ -394,6 +395,7 @@ App._handleNetworkComplete = async function(msg) {
// Graph neu laden // Graph neu laden
try { try {
var graphData = await API.getNetworkGraph(msg.analysis_id); var graphData = await API.getNetworkGraph(msg.analysis_id);
document.getElementById('network-graph-area').innerHTML = '';
NetworkGraph.init('network-graph-area', graphData); NetworkGraph.init('network-graph-area', graphData);
this._setupNetworkFilters(graphData); this._setupNetworkFilters(graphData);
@@ -452,3 +454,109 @@ function _escHtml(text) {
d.textContent = text || ''; d.textContent = text || '';
return d.innerHTML; return d.innerHTML;
} }
// ==========================================================================
// Cluster View Integration
// ==========================================================================
// Cluster Graph Integration (replaces flat NetworkGraph view)
// ==========================================================================
App._cachedGraphData = null;
/**
* Hide sidebar filter controls that dont apply to cluster view.
*/
App._hideNetworkSidebarFilters = function() {
var sidebar = document.querySelector('.network-sidebar');
if (!sidebar) return;
var sections = sidebar.querySelectorAll('.network-sidebar-section');
// Hide ALL old filter sections — ClusterGraph uses the detail panel directly
for (var i = 0; i < sections.length; i++) {
sections[i].style.display = 'none';
}
};
// Override selectNetworkAnalysis to use ClusterGraph
(function() {
App.selectNetworkAnalysis = async function(id) {
this.currentNetworkId = id;
this.currentIncidentId = null;
localStorage.removeItem('selectedIncidentId');
localStorage.setItem('selectedNetworkId', id);
document.getElementById('empty-state').style.display = 'none';
document.getElementById('incident-view').style.display = 'none';
document.getElementById('network-view').style.display = 'flex';
this.renderSidebar();
this.renderNetworkSidebar();
try {
var analysis = await API.getNetworkAnalysis(id);
this._renderNetworkHeader(analysis);
if (analysis.status === 'ready') {
this._hideNetworkProgress();
var graphData = await API.getNetworkGraph(id);
this._cachedGraphData = graphData;
var graphArea = document.getElementById('network-graph-area');
graphArea.innerHTML = '';
var breadcrumb = document.getElementById('cluster-breadcrumb');
if (breadcrumb) breadcrumb.style.display = 'flex';
ClusterGraph.init('network-graph-area', graphData.entities, graphData.relations);
this._hideNetworkSidebarFilters();
try {
var updateCheck = await API.checkNetworkUpdate(id);
var badge = document.getElementById('network-update-badge');
if (badge) badge.style.display = updateCheck.has_update ? 'inline-flex' : 'none';
} catch (e) { /* ignorieren */ }
} else if (analysis.status === 'generating') {
this._showNetworkProgress('entity_extraction', 0);
} else if (analysis.status === 'error') {
this._hideNetworkProgress();
var errArea = document.getElementById('network-graph-area');
if (errArea) errArea.innerHTML = '<div class="network-empty-state"><div class="network-empty-state-icon">&#9888;</div><div class="network-empty-state-text">Fehler bei der Generierung. Versuche es erneut.</div></div>';
}
} catch (err) {
UI.showToast('Fehler beim Laden der Netzwerkanalyse: ' + err.message, 'error');
}
};
})();
// Override _handleNetworkComplete to use ClusterGraph
(function() {
App._handleNetworkComplete = async function(msg) {
this._networkGenerating.delete(msg.analysis_id);
if (msg.analysis_id === this.currentNetworkId) {
this._hideNetworkProgress();
try {
var graphData = await API.getNetworkGraph(msg.analysis_id);
this._cachedGraphData = graphData;
var graphArea = document.getElementById('network-graph-area');
graphArea.innerHTML = '';
var breadcrumb = document.getElementById('cluster-breadcrumb');
if (breadcrumb) breadcrumb.style.display = 'flex';
ClusterGraph.init('network-graph-area', graphData.entities, graphData.relations);
this._hideNetworkSidebarFilters();
var analysis = await API.getNetworkAnalysis(msg.analysis_id);
this._renderNetworkHeader(analysis);
} catch (e) {
console.error('Graph nach Generierung laden fehlgeschlagen:', e);
}
UI.showToast('Netzwerkanalyse fertig: ' + (msg.entity_count || 0) + ' Entitaeten, ' + (msg.relation_count || 0) + ' Beziehungen', 'success');
}
await this.loadNetworkAnalyses();
};
})();

721
src/static/js/cluster-data.js Normale Datei
Datei anzeigen

@@ -0,0 +1,721 @@
/**
* AegisSight OSINT Monitor - Cluster Data Transformation
*
* Transforms flat entity/relation data into hierarchical country-based clusters.
* Used by ClusterGraph for the hierarchical network visualization.
*
* Usage:
* const result = ClusterData.buildClusterData(entities, relations);
* // result = { countries: [...], edges: [...], assignments: Map, entityToCountry: Map }
*/
/* exported ClusterData */
const ClusterData = {
/**
* Canonical country names with all known aliases (lowercase).
* Maps alias -> canonical name (German UI labels).
*/
COUNTRY_ALIASES: {
// Hauptakteure Irankonflikt
'iran': 'Iran',
'islamic republic of iran': 'Iran',
'islamische republik iran': 'Iran',
'persia': 'Iran',
'persien': 'Iran',
'israel': 'Israel',
'state of israel': 'Israel',
'staat israel': 'Israel',
'united states': 'USA',
'united states of america': 'USA',
'usa': 'USA',
'us': 'USA',
'u.s.': 'USA',
'u.s.a.': 'USA',
'amerika': 'USA',
'vereinigte staaten': 'USA',
// Naher Osten
'lebanon': 'Libanon',
'libanon': 'Libanon',
'lebanese republic': 'Libanon',
'syria': 'Syrien',
'syrien': 'Syrien',
'syrian arab republic': 'Syrien',
'iraq': 'Irak',
'irak': 'Irak',
'republic of iraq': 'Irak',
'yemen': 'Jemen',
'jemen': 'Jemen',
'republic of yemen': 'Jemen',
'saudi arabia': 'Saudi-Arabien',
'saudi-arabien': 'Saudi-Arabien',
'kingdom of saudi arabia': 'Saudi-Arabien',
'ksa': 'Saudi-Arabien',
'united arab emirates': 'VAE',
'uae': 'VAE',
'vae': 'VAE',
'vereinigte arabische emirate': 'VAE',
'jordan': 'Jordanien',
'jordanien': 'Jordanien',
'egypt': 'Ägypten',
'ägypten': 'Ägypten',
'aegypten': 'Ägypten',
'bahrain': 'Bahrain',
'kingdom of bahrain': 'Bahrain',
'kuwait': 'Kuwait',
'state of kuwait': 'Kuwait',
'qatar': 'Katar',
'katar': 'Katar',
'oman': 'Oman',
'sultanate of oman': 'Oman',
'palestine': 'Palästina',
'palästina': 'Palästina',
'palestinian territories': 'Palästina',
'state of palestine': 'Palästina',
'gaza': 'Palästina',
'gaza strip': 'Palästina',
'west bank': 'Palästina',
// Großmächte
'russia': 'Russland',
'russland': 'Russland',
'russian federation': 'Russland',
'russische föderation': 'Russland',
'china': 'China',
'people\'s republic of china': 'China',
'volksrepublik china': 'China',
'prc': 'China',
'united kingdom': 'Großbritannien',
'uk': 'Großbritannien',
'großbritannien': 'Großbritannien',
'grossbritannien': 'Großbritannien',
'great britain': 'Großbritannien',
'britain': 'Großbritannien',
'england': 'Großbritannien',
'france': 'Frankreich',
'frankreich': 'Frankreich',
'french republic': 'Frankreich',
'germany': 'Deutschland',
'deutschland': 'Deutschland',
'federal republic of germany': 'Deutschland',
'bundesrepublik deutschland': 'Deutschland',
// Weitere relevante Staaten
'turkey': 'Türkei',
'türkei': 'Türkei',
'turkei': 'Türkei',
'republic of turkey': 'Türkei',
'türkiye': 'Türkei',
'india': 'Indien',
'indien': 'Indien',
'republic of india': 'Indien',
'pakistan': 'Pakistan',
'islamic republic of pakistan': 'Pakistan',
'afghanistan': 'Afghanistan',
'ukraine': 'Ukraine',
'north korea': 'Nordkorea',
'nordkorea': 'Nordkorea',
'dprk': 'Nordkorea',
'south korea': 'Südkorea',
'südkorea': 'Südkorea',
'republic of korea': 'Südkorea',
'japan': 'Japan',
'italy': 'Italien',
'italien': 'Italien',
'spain': 'Spanien',
'spanien': 'Spanien',
'netherlands': 'Niederlande',
'niederlande': 'Niederlande',
'holland': 'Niederlande',
'poland': 'Polen',
'polen': 'Polen',
'canada': 'Kanada',
'kanada': 'Kanada',
'australia': 'Australien',
'australien': 'Australien',
'brazil': 'Brasilien',
'brasilien': 'Brasilien',
'mexico': 'Mexiko',
'mexiko': 'Mexiko',
'south africa': 'Südafrika',
'südafrika': 'Südafrika',
'nigeria': 'Nigeria',
'ethiopia': 'Äthiopien',
'äthiopien': 'Äthiopien',
'somalia': 'Somalia',
'sudan': 'Sudan',
'libya': 'Libyen',
'libyen': 'Libyen',
'tunisia': 'Tunesien',
'tunesien': 'Tunesien',
'morocco': 'Marokko',
'marokko': 'Marokko',
'algeria': 'Algerien',
'algerien': 'Algerien',
'sweden': 'Schweden',
'schweden': 'Schweden',
'norway': 'Norwegen',
'norwegen': 'Norwegen',
'switzerland': 'Schweiz',
'schweiz': 'Schweiz',
'austria': 'Österreich',
'österreich': 'Österreich',
'oesterreich': 'Österreich',
},
/**
* Country keyword patterns for name/description matching.
* Each entry: [regex, canonical country name]
* Order matters: more specific patterns first.
*/
COUNTRY_PATTERNS: [
[/\biran/i, 'Iran'],
[/\bpersi/i, 'Iran'],
[/\bisrael/i, 'Israel'],
[/\bjewish state/i, 'Israel'],
[/\bunited states/i, 'USA'],
[/\bamerican?\b/i, 'USA'],
[/\bu\.?s\.?\b(?![\w-])/i, 'USA'],
[/\bpentagon/i, 'USA'],
[/\bwhite house/i, 'USA'],
[/\bcongress\b/i, 'USA'],
[/\bleban/i, 'Libanon'],
[/\bhezbollah/i, 'Libanon'],
[/\bhisbollah/i, 'Libanon'],
[/\bsyri/i, 'Syrien'],
[/\biraq/i, 'Irak'],
[/\birak/i, 'Irak'],
[/\byemen/i, 'Jemen'],
[/\bjemen/i, 'Jemen'],
[/\bhouthi/i, 'Jemen'],
[/\bsaudi/i, 'Saudi-Arabien'],
[/\bemira/i, 'VAE'],
[/\bdubai/i, 'VAE'],
[/\bjordan/i, 'Jordanien'],
[/\begypt/i, 'Ägypten'],
[/\bägypt/i, 'Ägypten'],
[/\bbahrain/i, 'Bahrain'],
[/\bkuwait/i, 'Kuwait'],
[/\bqatar/i, 'Katar'],
[/\bkatar/i, 'Katar'],
[/\bpalesti/i, 'Palästina'],
[/\bgaza/i, 'Palästina'],
[/\bhamas\b/i, 'Palästina'],
[/\brussi/i, 'Russland'],
[/\bkreml/i, 'Russland'],
[/\bputin/i, 'Russland'],
[/\bmoscow/i, 'Russland'],
[/\bmoskau/i, 'Russland'],
[/\bchines/i, 'China'],
[/\bchinai/i, 'China'],
[/\bchina/i, 'China'],
[/\bbeijing/i, 'China'],
[/\bpeking/i, 'China'],
[/\bbriti/i, 'Großbritannien'],
[/\bengland/i, 'Großbritannien'],
[/\blondon\b/i, 'Großbritannien'],
[/\bfrench/i, 'Frankreich'],
[/\bfranz/i, 'Frankreich'],
[/\bfrance/i, 'Frankreich'],
[/\bgerman/i, 'Deutschland'],
[/\bdeutsch/i, 'Deutschland'],
[/\bturk/i, 'Türkei'],
[/\btürk/i, 'Türkei'],
[/\bankara/i, 'Türkei'],
[/\bindia/i, 'Indien'],
[/\bindisch/i, 'Indien'],
[/\bpakistan/i, 'Pakistan'],
[/\bafghan/i, 'Afghanistan'],
[/\bukrain/i, 'Ukraine'],
[/\bnorth.?korea/i, 'Nordkorea'],
[/\bnordkorea/i, 'Nordkorea'],
[/\bpjöngjang/i, 'Nordkorea'],
[/\bpyongyang/i, 'Nordkorea'],
[/\bjapan/i, 'Japan'],
[/\boman\b/i, 'Oman'],
],
/**
* Main entry: transform flat entity/relation data into clustered structure.
*
* @param {Array} entities - All entities from getNetworkGraph
* @param {Array} relations - All relations from getNetworkGraph
* @returns {Object} { countries, edges, assignments, entityToCountry }
*/
buildClusterData(entities, relations) {
// 1. Identify which entities are countries and merge duplicates
var countryMap = this._identifyCountries(entities);
// 2. Build adjacency for fast lookup
var adjacency = this._buildAdjacency(relations);
// 3. Multi-strategy assignment:
// a) Relation-based (direct country connections)
// b) Name/Description keyword matching
// c) Propagation through assigned neighbors (multiple passes)
var result = this._assignEntities(entities, relations, countryMap, adjacency);
// 4. Aggregate cross-country relations
var edges = this._aggregateEdges(relations, result.entityToCountry);
// 5. Build country node objects for rendering
var countries = this._buildCountryNodes(countryMap, result.assignments, entities);
return {
countries: countries,
edges: edges,
assignments: result.assignments,
entityToCountry: result.entityToCountry
};
},
// ---- Step 1: Identify countries ------------------------------------------
_identifyCountries(entities) {
// Map: canonical country name -> [entity_id, ...]
var countryMap = new Map();
for (var i = 0; i < entities.length; i++) {
var entity = entities[i];
var normalized = (entity.name_normalized || entity.name || '')
.toLowerCase().trim();
// Strip common suffixes/brackets for matching
var cleaned = normalized
.replace(/\s*\(als organisation\)/i, '')
.replace(/\s*\(organisation\)/i, '')
.replace(/^the\s+/, '')
.replace(/\s+republic$/, '')
.replace(/\s+federation$/, '');
// Try direct alias match first (exact match in COUNTRY_ALIASES)
var directMatch = this.COUNTRY_ALIASES[normalized];
var cleanedMatch = !directMatch ? this.COUNTRY_ALIASES[cleaned] : null;
var canonical = directMatch || cleanedMatch;
// For non-location entities: only accept direct alias matches
// (prevents "Iranian Drones" from being a country, but allows
// "Islamic Republic of Iran" which is a direct alias)
if (canonical && entity.entity_type !== 'location' && !directMatch) {
// Match came from cleaning — apply length check
if (cleaned.length > canonical.length + 15) continue;
}
if (canonical) {
if (!countryMap.has(canonical)) {
countryMap.set(canonical, []);
}
countryMap.get(canonical).push(entity.id);
}
}
return countryMap;
},
// ---- Step 2: Build adjacency ---------------------------------------------
_buildAdjacency(relations) {
var adj = new Map();
for (var i = 0; i < relations.length; i++) {
var r = relations[i];
var src = r.source_entity_id;
var tgt = r.target_entity_id;
if (!adj.has(src)) adj.set(src, []);
if (!adj.has(tgt)) adj.set(tgt, []);
adj.get(src).push(r);
adj.get(tgt).push(r);
}
return adj;
},
// ---- Step 3: Assign entities to countries (multi-strategy) ----------------
_assignEntities(entities, relations, countryMap, adjacency) {
var self = this;
var entityToCountry = new Map();
var countryEntityIds = new Set();
// Build entity lookup
var entityMap = new Map();
for (var i = 0; i < entities.length; i++) {
entityMap.set(entities[i].id, entities[i]);
}
// Mark all country entity IDs
countryMap.forEach(function(ids, canonical) {
for (var i = 0; i < ids.length; i++) {
entityToCountry.set(ids[i], canonical);
countryEntityIds.add(ids[i]);
}
});
// Ensure all country keys exist in assignments
var assignments = new Map();
countryMap.forEach(function(_, canonical) {
assignments.set(canonical, []);
});
assignments.set('__unassigned__', []);
// Collect unassigned entity IDs
var unassigned = [];
for (var i = 0; i < entities.length; i++) {
if (!countryEntityIds.has(entities[i].id)) {
unassigned.push(entities[i].id);
}
}
// --- Strategy A: Relation-based (direct connection to country entity) ---
var stillUnassigned = [];
for (var a = 0; a < unassigned.length; a++) {
var eid = unassigned[a];
var country = this._findByRelation(eid, adjacency, entityToCountry, countryEntityIds);
if (country) {
entityToCountry.set(eid, country);
if (!assignments.has(country)) assignments.set(country, []);
assignments.get(country).push(eid);
} else {
stillUnassigned.push(eid);
}
}
// --- Strategy B: Name + Description keyword matching ---
var afterKeyword = [];
for (var b = 0; b < stillUnassigned.length; b++) {
var eid2 = stillUnassigned[b];
var entity = entityMap.get(eid2);
var country2 = this._findByKeywords(entity);
if (country2) {
entityToCountry.set(eid2, country2);
if (!assignments.has(country2)) assignments.set(country2, []);
assignments.get(country2).push(eid2);
} else {
afterKeyword.push(eid2);
}
}
// --- Strategy C: Propagation through assigned neighbors (max 5 passes) ---
var remaining = afterKeyword;
for (var pass = 0; pass < 5 && remaining.length > 0; pass++) {
var nextRemaining = [];
for (var c = 0; c < remaining.length; c++) {
var eid3 = remaining[c];
var country3 = this._findByNeighborPropagation(eid3, adjacency, entityToCountry);
if (country3) {
entityToCountry.set(eid3, country3);
if (!assignments.has(country3)) assignments.set(country3, []);
assignments.get(country3).push(eid3);
} else {
nextRemaining.push(eid3);
}
}
if (nextRemaining.length === remaining.length) break; // No progress
remaining = nextRemaining;
}
// Everything still unassigned goes to "Sonstige"
for (var u = 0; u < remaining.length; u++) {
assignments.get('__unassigned__').push(remaining[u]);
}
return { entityToCountry: entityToCountry, assignments: assignments };
},
/**
* Strategy A: Direct relation to a country entity.
*/
_findByRelation: function(entityId, adjacency, entityToCountry, countryEntityIds) {
var rels = adjacency.get(entityId);
if (!rels || rels.length === 0) return null;
var scores = new Map();
for (var i = 0; i < rels.length; i++) {
var r = rels[i];
var otherId = r.source_entity_id === entityId
? r.target_entity_id : r.source_entity_id;
if (countryEntityIds.has(otherId)) {
var country = entityToCountry.get(otherId);
scores.set(country, (scores.get(country) || 0) + (r.weight || 1));
}
}
return this._bestFromScores(scores);
},
/**
* Strategy B: Match country keywords in entity name, aliases and description.
* For events mentioning multiple countries, uses first-mentioned country in name
* with a bonus, so "Iran-Israel-US War" → Iran.
*/
_findByKeywords: function(entity) {
if (!entity) return null;
var scores = new Map();
var patterns = this.COUNTRY_PATTERNS;
var name = entity.name || '';
var desc = entity.description || '';
// For name matches: track position to boost first-mentioned country
var firstMatchPos = Infinity;
var firstMatchCountry = null;
for (var i = 0; i < patterns.length; i++) {
var pattern = patterns[i][0];
var country = patterns[i][1];
// Check name (stronger signal)
var nameMatch = pattern.exec(name);
if (nameMatch) {
scores.set(country, (scores.get(country) || 0) + 3);
// Track first-mentioned country by position in name
if (nameMatch.index < firstMatchPos) {
firstMatchPos = nameMatch.index;
firstMatchCountry = country;
}
}
// Reset regex lastIndex (stateless)
pattern.lastIndex = 0;
// Check description (weaker signal)
if (desc && pattern.test(desc)) {
scores.set(country, (scores.get(country) || 0) + 1);
}
pattern.lastIndex = 0;
}
// Check aliases
if (entity.aliases && entity.aliases.length > 0) {
var aliasText = entity.aliases.join(' ');
for (var j = 0; j < patterns.length; j++) {
if (patterns[j][0].test(aliasText)) {
var c = patterns[j][1];
scores.set(c, (scores.get(c) || 0) + 1);
}
patterns[j][0].lastIndex = 0;
}
}
// Boost first-mentioned country in name (important for multi-country events)
if (firstMatchCountry && scores.size > 1) {
scores.set(firstMatchCountry, (scores.get(firstMatchCountry) || 0) + 2);
}
return this._bestFromScores(scores);
},
/**
* Strategy C: Propagate from already-assigned neighbors.
*/
_findByNeighborPropagation: function(entityId, adjacency, entityToCountry) {
var rels = adjacency.get(entityId);
if (!rels || rels.length === 0) return null;
var scores = new Map();
for (var i = 0; i < rels.length; i++) {
var r = rels[i];
var otherId = r.source_entity_id === entityId
? r.target_entity_id : r.source_entity_id;
if (entityToCountry.has(otherId)) {
var country = entityToCountry.get(otherId);
scores.set(country, (scores.get(country) || 0) + (r.weight || 1));
}
}
return this._bestFromScores(scores);
},
/**
* Helper: return country with highest score, or null.
*/
_bestFromScores: function(scores) {
if (scores.size === 0) return null;
var best = null;
var bestScore = 0;
scores.forEach(function(score, country) {
if (score > bestScore) {
best = country;
bestScore = score;
}
});
return best;
},
// ---- Step 4: Aggregate cross-country edges -------------------------------
_aggregateEdges(relations, entityToCountry) {
var edgeMap = new Map(); // "A|B" -> { source, target, count, categories, totalWeight }
for (var i = 0; i < relations.length; i++) {
var r = relations[i];
var c1 = entityToCountry.get(r.source_entity_id);
var c2 = entityToCountry.get(r.target_entity_id);
// Skip if same country, or either entity unassigned
if (!c1 || !c2 || c1 === c2) continue;
var key = c1 < c2 ? c1 + '|' + c2 : c2 + '|' + c1;
if (!edgeMap.has(key)) {
edgeMap.set(key, {
source: c1 < c2 ? c1 : c2,
target: c1 < c2 ? c2 : c1,
count: 0,
totalWeight: 0,
categories: {}
});
}
var edge = edgeMap.get(key);
edge.count += 1;
edge.totalWeight += (r.weight || 1);
var cat = r.category || 'neutral';
edge.categories[cat] = (edge.categories[cat] || 0) + 1;
}
// Determine dominant category per edge
var edges = [];
edgeMap.forEach(function(edge) {
var bestCat = 'neutral';
var bestCount = 0;
for (var cat in edge.categories) {
if (edge.categories[cat] > bestCount) {
bestCat = cat;
bestCount = edge.categories[cat];
}
}
edge.dominantCategory = bestCat;
edges.push(edge);
});
// Sort by count descending
edges.sort(function(a, b) { return b.count - a.count; });
return edges;
},
// ---- Step 5: Build country node objects -----------------------------------
_buildCountryNodes(countryMap, assignments, entities) {
var entityMap = new Map();
for (var i = 0; i < entities.length; i++) {
entityMap.set(entities[i].id, entities[i]);
}
var countries = [];
assignments.forEach(function(entityIds, countryName) {
if (countryName === '__unassigned__') {
if (entityIds.length > 0) {
countries.push({
name: 'Sonstige',
canonicalName: '__unassigned__',
entityCount: entityIds.length,
isUnassigned: true,
typeCounts: ClusterData._countTypes(entityIds, entityMap),
topEntities: ClusterData._getTopEntities(entityIds, entityMap, 5)
});
}
return;
}
// Count includes the country entity IDs themselves? No — only affiliated entities
var totalCount = entityIds.length;
if (totalCount === 0) return; // Skip countries with no affiliated entities
countries.push({
name: countryName,
canonicalName: countryName,
entityCount: totalCount,
isUnassigned: false,
countryEntityIds: countryMap.get(countryName) || [],
typeCounts: ClusterData._countTypes(entityIds, entityMap),
topEntities: ClusterData._getTopEntities(entityIds, entityMap, 5)
});
});
// Sort by entity count descending
countries.sort(function(a, b) { return b.entityCount - a.entityCount; });
return countries;
},
/**
* Count entities by type within a set of IDs.
*/
_countTypes(entityIds, entityMap) {
var counts = { person: 0, organisation: 0, location: 0, event: 0, military: 0 };
for (var i = 0; i < entityIds.length; i++) {
var e = entityMap.get(entityIds[i]);
if (e && counts.hasOwnProperty(e.entity_type)) {
counts[e.entity_type]++;
}
}
return counts;
},
/**
* Get top N entities by mention_count from a set of IDs.
*/
_getTopEntities(entityIds, entityMap, n) {
var ents = [];
for (var i = 0; i < entityIds.length; i++) {
var e = entityMap.get(entityIds[i]);
if (e) ents.push(e);
}
ents.sort(function(a, b) {
return (b.mention_count || 0) - (a.mention_count || 0);
});
return ents.slice(0, n);
}
};

Datei anzeigen

@@ -716,9 +716,6 @@ const UI = {
if (emptyEl) emptyEl.style.display = 'none'; if (emptyEl) emptyEl.style.display = 'none';
// Locations fuer GEOINT merken
this._lastLocations = locations;
// Statistik // Statistik
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0); const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`; if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
@@ -745,10 +742,6 @@ const UI = {
}).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum }).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum
this._applyMapTiles(); this._applyMapTiles();
// GEOINT-Modus wiederherstellen
if (typeof GEOINT !== 'undefined') GEOINT.restoreState(this._map);
this._mapCluster = L.markerClusterGroup({ this._mapCluster = L.markerClusterGroup({
maxClusterRadius: 40, maxClusterRadius: 40,
iconCreateFunction: function(cluster) { iconCreateFunction: function(cluster) {
@@ -860,11 +853,6 @@ const UI = {
this._mapLegendControl = legend; 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) // Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
const self = this; const self = this;
[100, 300, 800].forEach(delay => { [100, 300, 800].forEach(delay => {
@@ -882,8 +870,6 @@ const UI = {
_applyMapTiles() { _applyMapTiles() {
if (!this._map) return; if (!this._map) return;
// Im GEOINT-Modus Tiles nicht ueberschreiben
if (typeof GEOINT !== 'undefined' && GEOINT._active) return;
// Alte Tile-Layer entfernen // Alte Tile-Layer entfernen
this._map.eachLayer(layer => { this._map.eachLayer(layer => {
if (layer instanceof L.TileLayer) this._map.removeLayer(layer); if (layer instanceof L.TileLayer) this._map.removeLayer(layer);

Datei anzeigen

@@ -1,492 +0,0 @@
/**
* GEOINT-Modus: Taktische Kartenansicht mit Echtzeit-Datenlayern.
*/
const GEOINT = {
_active: false,
_map: null,
_sublayers: {},
_canvasRenderer: null,
// Layer references
_flightLayer: null, _quakeLayer: null, _gdeltLayer: null,
_heatLayer: null, _shipsLayer: null,
// Data caches
_flightsData: null, _shipsData: null,
// Intervals
_flightInterval: null, _quakeInterval: null, _gdeltInterval: null, _shipsInterval: null,
_flightFetching: false,
// UI controls
_moveHandler: null, _subControl: null,
_coordControl: null, _coordHandler: null,
_distanceLayers: null, _distancePoints: [], _distanceHandler: null,
_satTileLayer: null, _satLabelLayer: null,
_timelineData: null,
// === HAUPTSCHALTER =====================================================
toggle(enabled, map) {
if (!map) map = this._map;
if (!map && typeof UI !== 'undefined') map = UI._map;
if (!map) return;
this._active = enabled;
this._map = map;
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);
document.querySelectorAll('#geoint-mode-cb, #geoint-mode-cb-fs').forEach(function(cb) { cb.checked = enabled; });
if (enabled) {
if (!this._canvasRenderer) this._canvasRenderer = L.canvas({ padding: 0.5 });
this._applySatelliteTiles(map);
this._createSubControl(map);
this._restoreSublayers(map);
} else {
this.cleanup();
this._restoreOsmTiles(map);
}
this._saveState();
},
// === TILES ==============================================================
_applySatelliteTiles(map) {
if (!map) return;
var toRemove = [];
map.eachLayer(function(l) { if (l instanceof L.TileLayer) toRemove.push(l); });
toRemove.forEach(function(l) { map.removeLayer(l); });
this._satTileLayer = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: 'Tiles &copy; Esri', maxZoom: 19, 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: 19, noWrap: true }
).addTo(map);
if (this._satTileLayer.bringToBack) this._satTileLayer.bringToBack();
},
_restoreOsmTiles(map) {
if (!map) return;
var toRemove = [];
map.eachLayer(function(l) { if (l instanceof L.TileLayer) toRemove.push(l); });
toRemove.forEach(function(l) { map.removeLayer(l); });
if (typeof UI !== 'undefined' && UI._applyMapTiles) {
UI._applyMapTiles();
} else {
L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap', maxZoom: 18, noWrap: true
}).addTo(map);
}
},
// === SUB-LAYER CONTROL ==================================================
_createSubControl(map) {
if (this._subControl) return;
var self = this;
var items = [
['flights', 'Flugverkehr', 'flights'],
['ships', 'Schiffsverkehr', 'ships'],
['quakes', 'Erdbeben', 'quakes'],
['gdelt', 'Nachrichten', 'gdelt'],
['_sep'],
['heatmap', 'Heatmap', 'heatmap'],
['coords', 'Koordinaten', 'coords'],
['distance', 'Distanz', 'distance'],
];
var SubCtrl = 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);
var html = '<h4>GEOINT Layer</h4>';
items.forEach(function(it) {
if (it[0] === '_sep') { html += '<div class="geoint-sub-separator"></div>'; return; }
var checked = self._sublayers[it[0]] ? ' checked' : '';
html += '<div class="geoint-sub-item">' +
'<input type="checkbox" id="geoint-sub-' + it[0] + '"' + checked + '>' +
'<label for="geoint-sub-' + it[0] + '"><span class="geoint-dot geoint-dot-' + it[2] + '"></span>' + it[1] + '</label></div>';
});
div.innerHTML = html;
return div;
}
});
this._subControl = new SubCtrl();
map.addControl(this._subControl);
items.forEach(function(it) {
if (it[0] === '_sep') return;
var cb = document.getElementById('geoint-sub-' + it[0]);
if (cb) cb.addEventListener('change', function() { self._toggleSub(it[0], this.checked, map); });
});
},
_removeSubControl() {
if (this._subControl && this._map) { this._map.removeControl(this._subControl); this._subControl = null; }
},
_toggleSub(id, on, map) {
this._sublayers[id] = on;
this._saveState();
var m = { flights: ['_startFlights','_stopFlights'], ships: ['_startShips','_stopShips'],
quakes: ['_startQuakes','_stopQuakes'], gdelt: ['_startGdelt','_stopGdelt'],
heatmap: ['_startHeatmap','_stopHeatmap'], coords: ['_startCoords','_stopCoords'],
distance: ['_startDistance','_stopDistance'] };
if (m[id]) this[m[id][on ? 0 : 1]](map);
},
_restoreSublayers(map) {
var self = this;
Object.keys(this._sublayers).forEach(function(id) {
if (self._sublayers[id]) self._toggleSub(id, true, map);
});
},
// === 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);
this._moveHandler = function() {
clearTimeout(self._moveDebounce);
self._moveDebounce = setTimeout(function() {
self._renderFlights(map);
self._renderShips(map);
}, 500);
};
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) return;
this._flightFetching = true;
var self = this;
var token = localStorage.getItem('osint_token') || '';
fetch('/api/geoint/flights', { headers: token ? { 'Authorization': 'Bearer ' + token } : {} })
.then(function(r) { return r.ok ? r.json() : { ac: [] }; })
.then(function(data) {
self._flightsData = data.ac || data.aircraft || [];
self._renderFlights(map);
})
.catch(function() {})
.finally(function() { self._flightFetching = false; });
},
_renderFlights(map) {
if (!map || !this._flightLayer || !this._flightsData) return;
var newLayer = L.layerGroup();
var bounds = map.getBounds();
var zoom = map.getZoom();
var max = zoom >= 10 ? 600 : zoom >= 7 ? 400 : zoom >= 5 ? 200 : 80;
var r = zoom >= 10 ? 4 : zoom >= 7 ? 3 : 2;
var count = 0;
for (var i = 0; i < this._flightsData.length && count < max; i++) {
var a = this._flightsData[i];
if (!a.lat || !a.lon || !bounds.contains([a.lat, a.lon])) continue;
count++;
var cs = (a.flight || a.callsign || a.hex || '???').trim();
var alt = a.alt_baro || a.altitude || '?';
var spd = a.gs || a.ground_speed || '?';
var popup = '<div class="geoint-popup"><strong>' + cs + '</strong>' +
'<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) + '</div>';
L.circleMarker([a.lat, a.lon], {
radius: r, fillColor: '#00ff88', color: '#004422',
fillOpacity: 0.9, weight: 1, renderer: this._canvasRenderer
}).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(newLayer);
}
this._map.removeLayer(this._flightLayer);
this._flightLayer = newLayer.addTo(this._map);
},
// === SCHIFFSVERKEHR =====================================================
_startShips(map) {
if (this._shipsLayer) return;
this._shipsLayer = L.layerGroup().addTo(map);
var self = this;
this._fetchShips(map);
this._shipsInterval = setInterval(function() { self._fetchShips(map); }, 60000);
},
_stopShips() {
if (this._shipsInterval) { clearInterval(this._shipsInterval); this._shipsInterval = null; }
if (this._shipsLayer && this._map) { this._map.removeLayer(this._shipsLayer); this._shipsLayer = null; }
},
_fetchShips(map) {
var self = this;
var token = localStorage.getItem('osint_token') || '';
fetch('/api/geoint/ships', { headers: token ? { 'Authorization': 'Bearer ' + token } : {} })
.then(function(r) { return r.ok ? r.json() : { ships: [] }; })
.then(function(data) {
self._shipsData = data.ships || [];
self._renderShips(map);
})
.catch(function() {});
},
_renderShips(map) {
if (!map || !this._shipsLayer || !this._shipsData) return;
var newLayer = L.layerGroup();
var bounds = map.getBounds();
var zoom = map.getZoom();
var max = zoom >= 10 ? 800 : zoom >= 7 ? 400 : zoom >= 5 ? 150 : 50;
var minSog = zoom >= 8 ? 0 : zoom >= 5 ? 0.3 : 1.0;
var r = zoom >= 10 ? 3.5 : zoom >= 7 ? 2.5 : 2;
var count = 0;
for (var i = 0; i < this._shipsData.length && count < max; i++) {
var s = this._shipsData[i];
if (!s.lat || !s.lon || !bounds.contains([s.lat, s.lon])) continue;
if ((s.sog || 0) < minSog) continue;
count++;
var color = (s.sog || 0) > 0.5 ? '#4499ff' : '#556688';
var navLabels = {0:'Motor',1:'Anker',2:'N.steuerb.',3:'Eingeschr.',5:'Festgemacht',7:'Fischfang',8:'Segel'};
var shipName = s.name || ('MMSI ' + (s.mmsi||'?'));
var popup = '<div class="geoint-popup"><strong>' + shipName + '</strong>' +
(s.name ? '<br><span class="geoint-popup-key">MMSI</span> ' + (s.mmsi||'?') : '') +
'<br><span class="geoint-popup-key">SOG</span> ' + (s.sog||0).toFixed(1) + ' kn' +
'<br><span class="geoint-popup-key">COG</span> ' + Math.round(s.cog||0) + '\u00b0' + '</div>';
L.circleMarker([s.lat, s.lon], {
radius: r, fillColor: color, color: '#223355',
fillOpacity: 0.85, weight: 0.5, renderer: this._canvasRenderer
}).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(newLayer);
}
this._map.removeLayer(this._shipsLayer);
this._shipsLayer = newLayer.addTo(this._map);
},
// === 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);
},
_stopQuakes() {
if (this._quakeInterval) { clearInterval(this._quakeInterval); this._quakeInterval = null; }
if (this._quakeLayer && this._map) { this._map.removeLayer(this._quakeLayer); this._quakeLayer = null; }
},
_fetchQuakes() {
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 c = f.geometry.coordinates, 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 cls = ageH < 2 ? 'geoint-quake-marker' : '';
L.circleMarker([c[1], c[0]], {
radius: Math.max(mag * 3.5, 5), fillColor: color, color: color,
weight: 1.5, fillOpacity: 0.6, className: cls
}).bindPopup('<div class="geoint-popup"><strong>M' + mag.toFixed(1) + '</strong> ' + (p.place||'') +
'<br><span class="geoint-popup-key">TIEFE</span> ' + (c[2]||'?') + ' km</div>',
{ className: 'geoint-leaflet-popup' }
).addTo(self._quakeLayer);
});
})
.catch(function() {});
},
// === GDELT NACHRICHTEN ===================================================
_startGdelt(map) {
if (this._gdeltLayer) return;
this._gdeltLayer = L.markerClusterGroup({
maxClusterRadius: 30,
iconCreateFunction: function(cluster) {
var n = cluster.getChildCount();
return L.divIcon({ html: '<div class="geoint-gdelt-icon">' + (n > 99 ? '99+' : n) + '</div>', className: '', iconSize: [22, 22] });
}
}).addTo(map);
this._fetchGdelt(map);
var self = this;
this._gdeltInterval = setInterval(function() { self._fetchGdelt(map); }, 600000);
},
_stopGdelt() {
if (this._gdeltInterval) { clearInterval(this._gdeltInterval); this._gdeltInterval = null; }
if (this._gdeltLayer && this._map) { this._map.removeLayer(this._gdeltLayer); this._gdeltLayer = null; }
},
_fetchGdelt() {
if (!this._gdeltLayer) return;
var self = this;
var query = 'conflict OR crisis OR disaster';
if (typeof App !== 'undefined' && App.currentIncidentId) {
var inc = (App.incidents || []).find(function(i) { return i.id === App.currentIncidentId; });
if (inc && inc.title) query = encodeURIComponent(inc.title.substring(0, 80));
}
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() : { features: [] }; })
.then(function(data) {
if (!self._gdeltLayer) return;
self._gdeltLayer.clearLayers();
(data.features || []).slice(0, 200).forEach(function(f) {
var c = f.geometry.coordinates, p = f.properties || {};
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>' + (p.name || p.title || 'Nachricht').substring(0, 100) + '</strong>' +
(p.url ? '<br><a href="' + p.url + '" target="_blank" rel="noopener" style="color:#44aaff;font-size:10px">Quelle</a>' : '') + '</div>';
L.marker([c[1], c[0]], { icon: icon }).bindPopup(popup, { className: 'geoint-leaflet-popup' }).addTo(self._gdeltLayer);
});
})
.catch(function() {});
},
// === HEATMAP =============================================================
_startHeatmap(map) {
if (this._heatLayer || typeof L.heatLayer === 'undefined') return;
var locs = (typeof UI !== 'undefined' && UI._lastLocations) ? UI._lastLocations : [];
if (!locs.length) return;
var maxC = Math.max.apply(null, locs.map(function(l) { return l.article_count || 1; }));
var pts = locs.map(function(l) { return [l.lat, l.lon, (l.article_count || 1) / maxC]; });
this._heatLayer = L.heatLayer(pts, {
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 Ctrl = 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 Ctrl();
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 = [];
map.getContainer().style.cursor = 'crosshair';
var self = this;
this._distanceHandler = function(e) {
self._distancePoints.push(e.latlng);
L.circleMarker(e.latlng, { radius: 6, fillColor: '#ff2222', color: '#ffffff', fillOpacity: 1, weight: 2 }).addTo(self._distanceLayers);
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 total = 0;
for (var i = 1; i < self._distancePoints.length; i++) total += self._distancePoints[i-1].distanceTo(self._distancePoints[i]);
var label = dist >= 1000 ? (dist/1000).toFixed(1) + ' km' : Math.round(dist) + ' m';
var tLabel = total >= 1000 ? (total/1000).toFixed(1) + ' km' : Math.round(total) + ' m';
var text = self._distancePoints.length > 2 ? label + ' (\u03a3 ' + tLabel + ')' : label;
L.marker([(p1.lat+p2.lat)/2, (p1.lng+p2.lng)/2], {
icon: L.divIcon({ className: '', html: '<div class="geoint-distance-label">' + text + '</div>', iconSize: [0,0], iconAnchor: [0,12] })
}).addTo(self._distanceLayers);
}
};
map.on('click', this._distanceHandler);
},
_stopDistance() {
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 ============================================================
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');
if (slider) { slider.min = dates[0]; slider.max = dates[dates.length-1]; slider.value = dates[dates.length-1]; }
var label = document.getElementById('geoint-timeline-label');
if (label) label.textContent = this._fmtDate(dates[dates.length-1]);
},
_onTimelineChange(val) {
var label = document.getElementById('geoint-timeline-label');
if (label) label.textContent = this._fmtDate(parseInt(val));
if (!this._map || !UI._mapCluster || !this._timelineData) return;
var maxT = parseInt(val), arts = this._timelineData.articles;
var vis = new Set();
arts.forEach(function(a) { if (new Date(a.collected_at || a.published_at || 0).getTime() <= maxT) vis.add(a.id); });
UI._mapCluster.eachLayer(function(m) {
if (m._articleIds) m.setOpacity(m._articleIds.some(function(id) { return vis.has(id); }) ? 1 : 0.08);
});
},
_resetTimeline() {
if (this._timelineData) {
var slider = document.getElementById('geoint-timeline-slider');
if (slider) { slider.value = this._timelineData.max; this._onTimelineChange(this._timelineData.max); }
}
},
_fmtDate(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' });
},
// === CLEANUP =============================================================
cleanup() {
this._stopFlights(); this._stopShips(); this._stopQuakes();
this._stopGdelt(); this._stopHeatmap(); this._stopCoords(); this._stopDistance();
this._removeSubControl();
document.querySelectorAll('.geoint-active').forEach(function(el) { el.classList.remove('geoint-active'); });
},
// === STATE ===============================================================
_saveState() {
try {
localStorage.setItem('geoint_mode', this._active ? 'true' : 'false');
localStorage.setItem('geoint_sublayers', JSON.stringify(this._sublayers));
} catch(e) {}
},
restoreState(map) {
if (!map) return;
this._map = map;
try { var s = localStorage.getItem('geoint_sublayers'); if (s) this._sublayers = JSON.parse(s); } catch(e) { this._sublayers = {}; }
if (localStorage.getItem('geoint_mode') === 'true') this.toggle(true, map);
},
};

Datei anzeigen

@@ -0,0 +1,993 @@
/**
* AegisSight OSINT Monitor - Cluster Graph Visualization v2
*
* Hierarchical country-based network visualization powered by d3.js v7.
* Level 1: Country overview with prominent inter-country edges
* Level 2: Country drill-down (entities within a country)
*
* Requires: d3 (global), ClusterData (cluster-data.js)
*/
/* global d3, ClusterData, NetworkGraph */
var ClusterGraph = {
_svg: null,
_g: null,
_zoom: null,
_simulation: null,
_tooltip: null,
_container: null,
_allEntities: null,
_allRelations: null,
_clusterData: null,
_entityMap: null,
_currentLevel: 'overview',
_currentCountry: null,
_width: 960,
_height: 640,
_categoryColors: {
conflict: '#EF4444',
alliance: '#22C55E',
diplomacy: '#3B82F6',
economic: '#FBBF24',
neutral: '#6B7280',
legal: '#A855F7'
},
_entityTypeColors: {
person: '#60A5FA',
organisation: '#C084FC',
location: '#34D399',
event: '#FBBF24',
military: '#F87171'
},
_categoryLabels: {
conflict: 'Konflikt', alliance: 'Allianz', diplomacy: 'Diplomatie',
economic: 'Ökonomie', neutral: 'Neutral', legal: 'Recht'
},
_typeLabels: {
person: 'Personen', organisation: 'Organisationen',
location: 'Orte', event: 'Ereignisse', military: 'Militär'
},
// ---- public API -----------------------------------------------------------
init: function(containerId, entities, relations) {
this.destroy();
var wrapper = document.getElementById(containerId);
if (!wrapper) return;
wrapper.innerHTML = '';
this._container = wrapper;
this._allEntities = entities;
this._allRelations = relations;
this._entityMap = new Map();
for (var i = 0; i < entities.length; i++) {
this._entityMap.set(entities[i].id, entities[i]);
}
var rect = wrapper.getBoundingClientRect();
this._width = rect.width || 960;
this._height = rect.height || 640;
this._svg = d3.select(wrapper)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', '0 0 ' + this._width + ' ' + this._height)
.attr('preserveAspectRatio', 'xMidYMid meet')
.style('background', 'transparent');
this._createDefs();
this._g = this._svg.append('g').attr('class', 'cg-zoom-layer');
this._zoom = d3.zoom()
.scaleExtent([0.2, 6])
.on('zoom', function(event) {
ClusterGraph._g.attr('transform', event.transform);
});
this._svg.call(this._zoom);
this._svg.on('dblclick.zoom', null);
this._tooltip = d3.select(wrapper)
.append('div')
.attr('class', 'cg-tooltip')
.style('position', 'absolute')
.style('pointer-events', 'none')
.style('background', 'rgba(15,23,42,0.95)')
.style('color', '#e2e8f0')
.style('border', '1px solid #334155')
.style('border-radius', '8px')
.style('padding', '10px 14px')
.style('font-size', '12px')
.style('max-width', '320px')
.style('z-index', '1000')
.style('display', 'none')
.style('line-height', '1.6');
this._clusterData = ClusterData.buildClusterData(entities, relations);
this._currentLevel = 'overview';
this._currentCountry = null;
this._renderOverview();
this._updateBreadcrumb();
this._renderCountrySidebar();
},
destroy: function() {
if (this._simulation) { this._simulation.stop(); this._simulation = null; }
if (this._svg) { this._svg.remove(); this._svg = null; }
if (this._tooltip) { this._tooltip.remove(); this._tooltip = null; }
this._g = null;
this._clusterData = null;
this._allEntities = null;
this._allRelations = null;
this._entityMap = null;
this._currentLevel = 'overview';
this._currentCountry = null;
},
// ---- LEVEL 1: Country Overview -------------------------------------------
_renderOverview: function() {
var self = this;
if (this._simulation) this._simulation.stop();
this._g.selectAll('*').remove();
// Filter: no "Sonstige", no empty, minimum 10 entities
var countries = this._clusterData.countries.filter(function(c) {
return c.entityCount >= 10 && !c.isUnassigned;
});
var edges = this._clusterData.edges.slice();
// Radius scale
var maxCount = 1;
for (var i = 0; i < countries.length; i++) {
if (countries[i].entityCount > maxCount) maxCount = countries[i].entityCount;
}
var rScale = d3.scaleSqrt().domain([0, maxCount]).range([22, 65]);
for (var ci = 0; ci < countries.length; ci++) {
countries[ci]._radius = rScale(countries[ci].entityCount);
countries[ci].id = countries[ci].canonicalName;
}
// Visible edges only
var countryNames = new Set(countries.map(function(c) { return c.canonicalName; }));
var visibleEdges = edges.filter(function(e) {
return countryNames.has(e.source) && countryNames.has(e.target) && e.count >= 3;
});
// Edge scale
var maxEdgeCount = 1;
for (var ei = 0; ei < visibleEdges.length; ei++) {
if (visibleEdges[ei].count > maxEdgeCount) maxEdgeCount = visibleEdges[ei].count;
}
var edgeScale = d3.scaleSqrt().domain([1, maxEdgeCount]).range([2, 18]);
// ---- EDGES (drawn first = behind nodes) ----
var linkGroup = this._g.append('g').attr('class', 'cg-links');
var linkSel = linkGroup.selectAll('line')
.data(visibleEdges)
.join('line')
.attr('stroke', function(d) {
return self._categoryColors[d.dominantCategory] || '#6B7280';
})
.attr('stroke-width', function(d) { return edgeScale(d.count); })
.attr('stroke-opacity', 0.6)
.attr('stroke-linecap', 'round')
.style('cursor', 'pointer')
.on('mouseover', function(event, d) {
d3.select(this).attr('stroke-opacity', 1);
var lines = ['<strong>' + self._esc(d.source) + ' \u2194 ' + self._esc(d.target) + '</strong>'];
lines.push('<span style="font-size:14px;font-weight:600;">' + d.count + ' Beziehungen</span>');
var cats = Object.keys(d.categories).sort(function(a, b) {
return d.categories[b] - d.categories[a];
});
for (var ci = 0; ci < Math.min(cats.length, 4); ci++) {
var c = cats[ci];
var color = self._categoryColors[c] || '#6B7280';
lines.push('<span style="color:' + color + ';">\u25CF</span> ' +
(self._categoryLabels[c] || c) + ': ' + d.categories[c]);
}
self._showTooltip(event, lines.join('<br>'));
})
.on('mousemove', function(event) { self._moveTooltip(event); })
.on('mouseout', function() {
d3.select(this).attr('stroke-opacity', 0.6);
self._hideTooltip();
});
// Edge labels (count) on top edges
var topEdges = visibleEdges.filter(function(e) { return e.count >= 10; });
var edgeLabelGroup = this._g.append('g').attr('class', 'cg-edge-labels');
var edgeLabelSel = edgeLabelGroup.selectAll('text')
.data(topEdges)
.join('text')
.attr('text-anchor', 'middle')
.attr('fill', function(d) {
return self._categoryColors[d.dominantCategory] || '#94a3b8';
})
.attr('font-size', '11px')
.attr('font-weight', '700')
.attr('pointer-events', 'none')
.text(function(d) { return d.count; });
// ---- NODES ----
var nodeGroup = this._g.append('g').attr('class', 'cg-nodes');
var nodeSel = nodeGroup.selectAll('g')
.data(countries)
.join('g')
.attr('class', 'cg-country-node')
.style('cursor', 'pointer')
.call(this._drag());
// Main circle
nodeSel.append('circle')
.attr('class', 'cg-country-circle')
.attr('r', function(d) { return d._radius; })
.attr('fill', function(d) { return self._getCountryFill(d); })
.attr('stroke', '#e2e8f0')
.attr('stroke-width', 2)
.attr('opacity', 0.9);
// Mini donut
nodeSel.each(function(d) {
self._renderMiniDonut(d3.select(this), d);
});
// Country name
nodeSel.append('text')
.attr('class', 'cg-country-label')
.text(function(d) { return d.name; })
.attr('text-anchor', 'middle')
.attr('dy', -6)
.attr('fill', '#f1f5f9')
.attr('font-size', function(d) {
return Math.max(10, Math.min(15, d._radius / 3.5)) + 'px';
})
.attr('font-weight', '700')
.attr('pointer-events', 'none');
// Entity count
nodeSel.append('text')
.attr('text-anchor', 'middle')
.attr('dy', 8)
.attr('fill', '#cbd5e1')
.attr('font-size', '10px')
.attr('pointer-events', 'none')
.text(function(d) { return d.entityCount; });
// Top actor name below circle
nodeSel.append('text')
.attr('text-anchor', 'middle')
.attr('dy', function(d) { return d._radius + 16; })
.attr('fill', '#94a3b8')
.attr('font-size', '9px')
.attr('font-style', 'italic')
.attr('pointer-events', 'none')
.text(function(d) {
if (!d.topEntities || d.topEntities.length === 0) return '';
var top = d.topEntities[0];
var name = top.name.length > 22 ? top.name.slice(0, 20) + '\u2026' : top.name;
return name;
});
// Click -> drill down
nodeSel.on('click', function(event, d) {
event.stopPropagation();
self._drillDown(d.canonicalName);
});
// Hover
nodeSel.on('mouseover', function(event, d) {
d3.select(this).select('.cg-country-circle')
.transition().duration(150)
.attr('stroke-width', 4).attr('opacity', 1);
// Highlight connected edges
linkSel.attr('stroke-opacity', function(e) {
return (e.source === d.canonicalName || e.target === d.canonicalName ||
(e.source.id && e.source.id === d.canonicalName) ||
(e.target.id && e.target.id === d.canonicalName)) ? 0.9 : 0.15;
});
var lines = ['<strong style="font-size:14px;">' + self._esc(d.name) + '</strong>'];
lines.push(d.entityCount + ' Entitäten');
var tc = d.typeCounts;
var parts = [];
if (tc.person) parts.push(tc.person + ' Pers.');
if (tc.organisation) parts.push(tc.organisation + ' Org.');
if (tc.military) parts.push(tc.military + ' Mil.');
if (tc.event) parts.push(tc.event + ' Ereig.');
if (parts.length) lines.push(parts.join(' \u00B7 '));
if (d.topEntities && d.topEntities.length > 0) {
lines.push('<hr style="border-color:#334155;margin:4px 0;">');
for (var ti = 0; ti < Math.min(d.topEntities.length, 4); ti++) {
var te = d.topEntities[ti];
var typeColor = self._entityTypeColors[te.entity_type] || '#94a3b8';
lines.push('<span style="color:' + typeColor + ';">\u25CF</span> ' +
self._esc(te.name));
}
}
self._showTooltip(event, lines.join('<br>'));
});
nodeSel.on('mousemove', function(event) { self._moveTooltip(event); });
nodeSel.on('mouseout', function(event, d) {
d3.select(this).select('.cg-country-circle')
.transition().duration(150)
.attr('stroke-width', 2).attr('opacity', 0.9);
linkSel.attr('stroke-opacity', 0.6);
self._hideTooltip();
});
// ---- Force simulation ----
var simLinks = visibleEdges.map(function(e) {
return { source: e.source, target: e.target, count: e.count };
});
this._simulation = d3.forceSimulation(countries)
.force('link', d3.forceLink(simLinks)
.id(function(d) { return d.id; })
.distance(function(d) { return 180; })
.strength(0.5))
.force('charge', d3.forceManyBody()
.strength(function(d) { return -400 - d._radius * 6; }))
.force('center', d3.forceCenter(self._width / 2, self._height / 2))
.force('collide', d3.forceCollide()
.radius(function(d) { return d._radius + 30; })
.strength(0.9))
.alphaDecay(0.025);
this._simulation.on('tick', function() {
linkSel
.attr('x1', function(d) { return d.source.x; })
.attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; })
.attr('y2', function(d) { return d.target.y; });
edgeLabelSel
.attr('x', function(d) { return (d.source.x + d.target.x) / 2; })
.attr('y', function(d) { return (d.source.y + d.target.y) / 2 - 4; });
nodeSel.attr('transform', function(d) {
return 'translate(' + d.x + ',' + d.y + ')';
});
});
// Background click
this._svg.on('click', function() {
linkSel.attr('stroke-opacity', 0.6);
});
// Zoom-to-fit after simulation stabilizes
var tickCount = 0;
this._simulation.on('tick.zoomfit', function() {
tickCount++;
if (tickCount === 120) {
self._zoomToFit(countries, 40);
self._simulation.on('tick.zoomfit', null); // Remove this listener
}
});
},
_zoomToFit: function(nodes, padding) {
if (!nodes || nodes.length === 0 || !this._svg || !this._zoom) return;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (n.x === undefined) continue;
var r = n._radius || 30;
if (n.x - r < minX) minX = n.x - r;
if (n.y - r < minY) minY = n.y - r;
if (n.x + r > maxX) maxX = n.x + r;
if (n.y + r > maxY) maxY = n.y + r;
}
var graphWidth = maxX - minX + padding * 2;
var graphHeight = maxY - minY + padding * 2;
var scale = Math.min(
this._width / graphWidth,
this._height / graphHeight,
1.5 // Max zoom
);
scale = Math.max(scale, 0.3); // Min zoom
var cx = (minX + maxX) / 2;
var cy = (minY + maxY) / 2;
var tx = this._width / 2 - cx * scale;
var ty = this._height / 2 - cy * scale;
this._svg.transition().duration(600).call(
this._zoom.transform,
d3.zoomIdentity.translate(tx, ty).scale(scale)
);
},
// ---- LEVEL 2: Country Drill-down -----------------------------------------
_drillDown: function(countryName) {
var self = this;
this._currentLevel = 'country';
this._currentCountry = countryName;
this._updateBreadcrumb();
this._renderCountrySidebar();
this._g.transition().duration(350).style('opacity', 0)
.on('end', function() {
if (self._simulation) self._simulation.stop();
self._g.selectAll('*').remove();
self._renderCountryDetail(countryName);
self._g.style('opacity', 0)
.transition().duration(350).style('opacity', 1);
});
this._svg.transition().duration(350).call(
this._zoom.transform, d3.zoomIdentity
);
},
_renderCountryDetail: function(countryName) {
var self = this;
var entityIds = this._clusterData.assignments.get(countryName) || [];
if (entityIds.length === 0) {
this._g.append('text')
.attr('x', this._width / 2).attr('y', this._height / 2)
.attr('text-anchor', 'middle').attr('fill', '#94a3b8')
.attr('font-size', '16px')
.text('Keine Entitäten für ' + countryName);
return;
}
var idSet = new Set(entityIds);
var entities = [];
for (var i = 0; i < entityIds.length; i++) {
var e = this._entityMap.get(entityIds[i]);
if (e) entities.push({ ...e });
}
var internalRelations = [];
for (var ri = 0; ri < this._allRelations.length; ri++) {
var r = this._allRelations[ri];
if (idSet.has(r.source_entity_id) && idSet.has(r.target_entity_id)) {
internalRelations.push(r);
}
}
// Connection counts for sizing
var connCounts = {};
for (var ii = 0; ii < internalRelations.length; ii++) {
var ir = internalRelations[ii];
connCounts[ir.source_entity_id] = (connCounts[ir.source_entity_id] || 0) + 1;
connCounts[ir.target_entity_id] = (connCounts[ir.target_entity_id] || 0) + 1;
}
var maxConn = 1;
for (var k in connCounts) {
if (connCounts[k] > maxConn) maxConn = connCounts[k];
}
var rScale = d3.scaleSqrt().domain([0, maxConn]).range([4, 26]);
entities.forEach(function(n) {
n._connections = connCounts[n.id] || 0;
n._radius = rScale(n._connections);
});
// Show labels for top 30 or nodes with radius >= 10
var sorted = entities.slice().sort(function(a, b) { return b._connections - a._connections; });
var labelThreshold = sorted.length > 30 ? sorted[29]._connections : 0;
// Links
var linkGroup = this._g.append('g');
var simLinks = internalRelations.map(function(r) {
return { source: r.source_entity_id, target: r.target_entity_id,
category: r.category, weight: r.weight || 1 };
});
var linkSel = linkGroup.selectAll('line')
.data(simLinks).join('line')
.attr('stroke', function(d) { return self._categoryColors[d.category] || '#6B7280'; })
.attr('stroke-width', function(d) { return Math.max(0.5, Math.min(3, d.weight * 0.6)); })
.attr('stroke-opacity', 0.25);
// Nodes
var nodeGroup = this._g.append('g');
var nodeSel = nodeGroup.selectAll('g')
.data(entities, function(d) { return d.id; })
.join('g').style('cursor', 'pointer').call(this._drag());
nodeSel.append('circle')
.attr('r', function(d) { return d._radius; })
.attr('fill', function(d) { return self._entityTypeColors[d.entity_type] || '#94A3B8'; })
.attr('stroke', '#0f172a').attr('stroke-width', 1.5).attr('opacity', 0.85);
nodeSel.filter(function(d) {
return d._connections >= labelThreshold || d._radius >= 10;
}).append('text')
.text(function(d) { return d.name.length > 20 ? d.name.slice(0, 18) + '\u2026' : d.name; })
.attr('dy', function(d) { return d._radius + 13; })
.attr('text-anchor', 'middle').attr('fill', '#cbd5e1')
.attr('font-size', '10px').attr('pointer-events', 'none');
// Hover
nodeSel.on('mouseover', function(event, d) {
d3.select(this).select('circle')
.transition().duration(100)
.attr('stroke', '#FBBF24').attr('stroke-width', 3).attr('opacity', 1);
var lines = ['<strong>' + self._esc(d.name) + '</strong>'];
lines.push(self._typeLabels[d.entity_type] || d.entity_type);
if (d.description) {
lines.push('<span style="color:#94a3b8;">' +
self._esc(d.description.length > 100 ? d.description.slice(0, 97) + '...' : d.description) +
'</span>');
}
lines.push('Verbindungen: ' + d._connections);
self._showTooltip(event, lines.join('<br>'));
});
nodeSel.on('mousemove', function(event) { self._moveTooltip(event); });
nodeSel.on('mouseout', function() {
d3.select(this).select('circle')
.transition().duration(100)
.attr('stroke', '#0f172a').attr('stroke-width', 1.5).attr('opacity', 0.85);
self._hideTooltip();
});
// Click: highlight neighborhood
nodeSel.on('click', function(event, d) {
event.stopPropagation();
var connIds = new Set([d.id]);
linkSel.each(function(l) {
var s = typeof l.source === 'object' ? l.source.id : l.source;
var t = typeof l.target === 'object' ? l.target.id : l.target;
if (s === d.id || t === d.id) { connIds.add(s); connIds.add(t); }
});
linkSel.attr('stroke-opacity', function(l) {
var s = typeof l.source === 'object' ? l.source.id : l.source;
var t = typeof l.target === 'object' ? l.target.id : l.target;
return (s === d.id || t === d.id) ? 0.8 : 0.04;
});
nodeSel.select('circle').attr('opacity', function(n) { return connIds.has(n.id) ? 1 : 0.12; });
nodeSel.select('text').attr('opacity', function(n) { return connIds.has(n.id) ? 1 : 0.08; });
self._updateDetailPanel(d);
});
this._svg.on('click', function() {
nodeSel.select('circle').attr('stroke', '#0f172a').attr('stroke-width', 1.5).attr('opacity', 0.85);
linkSel.attr('stroke-opacity', 0.25);
self._clearDetailPanel();
});
// Force
this._simulation = d3.forceSimulation(entities)
.force('link', d3.forceLink(simLinks).id(function(d) { return d.id; })
.distance(function(d) { return Math.max(30, 100 - d.weight * 10); }))
.force('charge', d3.forceManyBody().strength(function(d) { return -60 - d._radius * 3; }))
.force('center', d3.forceCenter(self._width / 2, self._height / 2))
.force('collide', d3.forceCollide().radius(function(d) { return d._radius + 3; }))
.alphaDecay(0.02);
this._simulation.on('tick', function() {
linkSel.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; });
nodeSel.attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
});
// Zoom-to-fit for detail view
var detailTickCount = 0;
this._simulation.on('tick.zoomfit', function() {
detailTickCount++;
if (detailTickCount === 100) {
self._zoomToFit(entities, 30);
self._simulation.on('tick.zoomfit', null);
}
});
},
// ---- Sidebar: Country List -----------------------------------------------
// ---- Filter state --------------------------------------------------------
_activeCategories: null, // null = all active
_searchTerm: '',
_initFilters: function() {
this._activeCategories = new Set(['conflict', 'alliance', 'diplomacy', 'economic', 'neutral', 'legal']);
this._searchTerm = '';
},
_applyEdgeFilter: function() {
if (!this._g) return;
var active = this._activeCategories;
this._g.selectAll('.cg-links line').attr('display', function(d) {
return active.has(d.dominantCategory) ? null : 'none';
});
this._g.selectAll('.cg-edge-labels text').attr('display', function(d) {
return active.has(d.dominantCategory) ? null : 'none';
});
},
_applySearch: function(term) {
this._searchTerm = (term || '').toLowerCase().trim();
if (!this._g || !this._clusterData) return;
if (!this._searchTerm) {
// Reset all nodes
this._g.selectAll('.cg-country-node').select('.cg-country-circle')
.attr('opacity', 0.9).attr('stroke-width', 2).attr('stroke', '#e2e8f0');
this._g.selectAll('.cg-links line').attr('stroke-opacity', 0.6);
return;
}
// Find which countries contain matching entities
var matchingCountries = new Set();
var self = this;
this._allEntities.forEach(function(e) {
var text = (e.name || '') + ' ' + (e.description || '');
if (e.aliases) text += ' ' + e.aliases.join(' ');
if (text.toLowerCase().indexOf(self._searchTerm) !== -1) {
var country = self._clusterData.entityToCountry.get(e.id);
if (country) matchingCountries.add(country);
}
});
// Highlight matching country nodes
this._g.selectAll('.cg-country-node').select('.cg-country-circle')
.attr('opacity', function(d) {
return matchingCountries.has(d.canonicalName) ? 1 : 0.15;
})
.attr('stroke', function(d) {
return matchingCountries.has(d.canonicalName) ? '#FBBF24' : '#e2e8f0';
})
.attr('stroke-width', function(d) {
return matchingCountries.has(d.canonicalName) ? 4 : 2;
});
this._g.selectAll('.cg-links line').attr('stroke-opacity', 0.15);
},
toggleCategory: function(cat) {
if (!this._activeCategories) this._initFilters();
if (this._activeCategories.has(cat)) {
this._activeCategories.delete(cat);
} else {
this._activeCategories.add(cat);
}
this._applyEdgeFilter();
// Update button inline styles
var btn = document.querySelector('.cg-cat-btn[data-cat="' + cat + '"]');
if (btn) {
var isActive = this._activeCategories.has(cat);
var color = this._categoryColors[cat] || '#6B7280';
btn.style.border = '1px solid ' + (isActive ? color : '#334155');
btn.style.background = isActive ? color + '22' : 'transparent';
btn.style.color = isActive ? color : '#64748b';
}
},
// ---- Sidebar: Country List -----------------------------------------------
_renderCountrySidebar: function() {
var panel = document.getElementById('network-detail-panel');
if (!panel) return;
var self = this;
if (!this._activeCategories) this._initFilters();
if (this._currentLevel === 'overview') {
var countries = this._clusterData.countries.filter(function(c) {
return c.entityCount >= 10 && !c.isUnassigned;
});
var html = '';
// Search
html += '<div style="margin-bottom:10px;">';
html += '<input type="text" id="cg-search" placeholder="Entität suchen..." ' +
'style="width:100%;padding:7px 10px;background:#1e293b;border:1px solid #334155;' +
'border-radius:4px;color:#e2e8f0;font-size:12px;outline:none;box-sizing:border-box;" ' +
'oninput="ClusterGraph._applySearch(this.value)">';
html += '</div>';
// Category filter
html += '<div style="margin-bottom:12px;">';
html += '<div style="color:#94a3b8;font-size:11px;font-weight:600;margin-bottom:6px;text-transform:uppercase;">Beziehungsfilter</div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:4px;">';
var cats = ['conflict', 'alliance', 'diplomacy', 'economic', 'neutral', 'legal'];
for (var fi = 0; fi < cats.length; fi++) {
var cat = cats[fi];
var color = self._categoryColors[cat];
var label = self._categoryLabels[cat];
var isActive = self._activeCategories.has(cat);
html += '<button class="cg-cat-btn' + (isActive ? ' active' : '') + '" data-cat="' + cat + '" ' +
'onclick="ClusterGraph.toggleCategory(\'' + cat + '\')" ' +
'style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;' +
'border-radius:4px;border:1px solid ' + (isActive ? color : '#334155') + ';' +
'background:' + (isActive ? color + '22' : 'transparent') + ';' +
'color:' + (isActive ? color : '#64748b') + ';font-size:11px;cursor:pointer;' +
'font-family:inherit;transition:all 0.15s;">' +
'<span style="font-size:14px;">\u25CF</span>' + label + '</button>';
}
html += '</div></div>';
// Summary
html += '<div style="margin-bottom:8px;">';
html += '<h3 style="margin:0 0 4px 0;color:#f1f5f9;font-size:14px;">' +
countries.length + ' Akteure</h3>';
var unassigned = this._clusterData.countries.find(function(c) { return c.isUnassigned; });
if (unassigned && unassigned.entityCount > 0) {
html += '<div style="color:#64748b;font-size:11px;">' +
unassigned.entityCount + ' ohne Zuordnung</div>';
}
html += '</div>';
// Top edges
var topEdges = this._clusterData.edges.slice(0, 6);
if (topEdges.length > 0) {
html += '<div style="border-bottom:1px solid #1e293b;padding-bottom:8px;margin-bottom:8px;">';
html += '<div style="color:#94a3b8;font-size:11px;font-weight:600;margin-bottom:6px;text-transform:uppercase;">Top-Beziehungen</div>';
for (var ei = 0; ei < topEdges.length; ei++) {
var edge = topEdges[ei];
var eColor = self._categoryColors[edge.dominantCategory] || '#6B7280';
html += '<div style="display:flex;align-items:center;gap:5px;padding:2px 0;font-size:11px;">';
html += '<span style="color:' + eColor + ';font-size:14px;line-height:1;">\u25CF</span>';
html += '<span style="color:#e2e8f0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
self._esc(edge.source) + ' \u2194 ' + self._esc(edge.target) + '</span>';
html += '<span style="color:#64748b;">' + edge.count + '</span>';
html += '</div>';
}
html += '</div>';
}
// Country list
html += '<div style="color:#94a3b8;font-size:11px;font-weight:600;margin-bottom:6px;text-transform:uppercase;">Akteure</div>';
for (var ci = 0; ci < countries.length; ci++) {
var c = countries[ci];
html += '<div style="display:flex;align-items:center;gap:6px;' +
'padding:4px 6px;border-radius:4px;cursor:pointer;margin-bottom:1px;" ' +
'onmouseover="this.style.background=\'rgba(51,65,85,0.5)\'" ' +
'onmouseout="this.style.background=\'transparent\'" ' +
'onclick="ClusterGraph._drillDown(\'' + self._esc(c.canonicalName) + '\')">';
html += '<span style="color:#f1f5f9;font-size:12px;flex:1;">' + self._esc(c.name) + '</span>';
html += '<span style="color:#64748b;font-size:11px;">' + c.entityCount + '</span>';
html += '</div>';
}
panel.innerHTML = html;
panel.style.display = 'block';
} else if (this._currentLevel === 'country') {
// Show type legend for detail view
var countryData = null;
for (var fi = 0; fi < this._clusterData.countries.length; fi++) {
if (this._clusterData.countries[fi].canonicalName === this._currentCountry) {
countryData = this._clusterData.countries[fi]; break;
}
}
var html2 = '';
if (countryData) {
html2 += '<h3 style="margin:0 0 8px 0;color:#f1f5f9;font-size:14px;">' +
self._esc(countryData.name) + '</h3>';
html2 += '<div style="color:#94a3b8;font-size:12px;margin-bottom:12px;">' +
countryData.entityCount + ' Entitäten</div>';
var tc = countryData.typeCounts;
var types = ['person', 'organisation', 'military', 'event', 'location'];
html2 += '<div style="border-bottom:1px solid #1e293b;padding-bottom:8px;margin-bottom:8px;">';
for (var ti = 0; ti < types.length; ti++) {
var t = types[ti];
var cnt = tc[t] || 0;
if (cnt === 0) continue;
var tColor = self._entityTypeColors[t];
html2 += '<div style="display:flex;align-items:center;gap:6px;padding:2px 0;font-size:12px;">';
html2 += '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + tColor + ';"></span>';
html2 += '<span style="color:#cbd5e1;">' + (self._typeLabels[t] || t) + '</span>';
html2 += '<span style="color:#64748b;margin-left:auto;">' + cnt + '</span>';
html2 += '</div>';
}
html2 += '</div>';
// Top entities
if (countryData.topEntities && countryData.topEntities.length > 0) {
html2 += '<div style="color:#94a3b8;font-size:11px;font-weight:600;margin-bottom:6px;text-transform:uppercase;">Top-Akteure</div>';
for (var tei = 0; tei < countryData.topEntities.length; tei++) {
var te = countryData.topEntities[tei];
var teColor = self._entityTypeColors[te.entity_type] || '#94a3b8';
html2 += '<div style="padding:3px 0;font-size:12px;">';
html2 += '<span style="color:' + teColor + ';">\u25CF</span> ';
html2 += '<span style="color:#e2e8f0;">' + self._esc(te.name) + '</span>';
html2 += '</div>';
}
}
}
html2 += '<div style="margin-top:16px;padding-top:8px;border-top:1px solid #1e293b;color:#64748b;font-size:12px;">Klicke auf einen Knoten für Details.</div>';
panel.innerHTML = html2;
panel.style.display = 'block';
}
},
// ---- Detail panel for entity click ---------------------------------------
_updateDetailPanel: function(entity) {
if (typeof NetworkGraph !== 'undefined' && NetworkGraph._updateDetailPanel) {
var tempData = NetworkGraph._data;
NetworkGraph._data = { entities: this._allEntities, relations: this._allRelations };
NetworkGraph._updateDetailPanel(entity);
NetworkGraph._data = tempData;
return;
}
var panel = document.getElementById('network-detail-panel');
if (!panel) return;
var typeColor = this._entityTypeColors[entity.entity_type] || '#94A3B8';
var html = '<h3 style="margin:0 0 6px 0;color:#f1f5f9;font-size:16px;">' + this._esc(entity.name) + '</h3>';
html += '<span style="display:inline-block;background:' + typeColor + ';color:#0f172a;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">' + this._esc(entity.entity_type) + '</span>';
if (entity.description) html += '<p style="color:#94a3b8;font-size:13px;margin:10px 0;">' + this._esc(entity.description) + '</p>';
html += '<div style="color:#94a3b8;font-size:12px;">Verbindungen: <strong style="color:#f1f5f9;">' + (entity._connections || 0) + '</strong></div>';
panel.innerHTML = html;
},
_clearDetailPanel: function() {
this._renderCountrySidebar();
},
// ---- Navigation ----------------------------------------------------------
goBack: function() {
var self = this;
if (this._currentLevel !== 'country') return;
this._currentLevel = 'overview';
this._currentCountry = null;
this._updateBreadcrumb();
this._g.transition().duration(300).style('opacity', 0)
.on('end', function() {
if (self._simulation) self._simulation.stop();
self._g.selectAll('*').remove();
self._clusterData = ClusterData.buildClusterData(self._allEntities, self._allRelations);
self._renderOverview();
self._renderCountrySidebar();
self._g.style('opacity', 0).transition().duration(300).style('opacity', 1);
});
this._svg.transition().duration(300).call(this._zoom.transform, d3.zoomIdentity);
},
_updateBreadcrumb: function() {
var container = document.getElementById('cluster-breadcrumb');
if (!container) return;
var self = this;
container.innerHTML = '';
container.style.display = 'flex';
if (this._currentLevel === 'country') {
var backBtn = document.createElement('button');
backBtn.className = 'cluster-back-btn';
backBtn.innerHTML = '\u2190 Zurück';
backBtn.onclick = function() { self.goBack(); };
container.appendChild(backBtn);
var sep = document.createElement('span');
sep.className = 'breadcrumb-separator';
sep.textContent = ' / ';
container.appendChild(sep);
}
var overviewSpan = document.createElement('span');
overviewSpan.textContent = 'Länder-Übersicht';
overviewSpan.className = 'breadcrumb-item' + (this._currentLevel === 'overview' ? ' active' : ' clickable');
if (this._currentLevel !== 'overview') overviewSpan.onclick = function() { self.goBack(); };
container.appendChild(overviewSpan);
if (this._currentCountry) {
var sep2 = document.createElement('span');
sep2.className = 'breadcrumb-separator';
sep2.textContent = ' \u203A ';
container.appendChild(sep2);
var cd = null;
for (var i = 0; i < this._clusterData.countries.length; i++) {
if (this._clusterData.countries[i].canonicalName === this._currentCountry) { cd = this._clusterData.countries[i]; break; }
}
var cs = document.createElement('span');
cs.className = 'breadcrumb-item active';
cs.textContent = this._currentCountry + (cd ? ' (' + cd.entityCount + ')' : '');
container.appendChild(cs);
}
},
// ---- Visual helpers -------------------------------------------------------
_getCountryFill: function(d) {
// Subtle gradient based on dominant relationship
var edges = this._clusterData.edges;
var catCounts = {};
for (var i = 0; i < edges.length; i++) {
var e = edges[i];
if (e.source === d.canonicalName || e.target === d.canonicalName) {
for (var cat in e.categories) catCounts[cat] = (catCounts[cat] || 0) + e.categories[cat];
}
}
var bestCat = 'neutral', bestCount = 0;
for (var c in catCounts) { if (catCounts[c] > bestCount) { bestCat = c; bestCount = catCounts[c]; } }
return this._darken(this._categoryColors[bestCat] || '#6B7280', 0.45);
},
_renderMiniDonut: function(gSel, d) {
var types = ['person', 'organisation', 'military', 'event', 'location'];
var counts = [], colors = [];
for (var i = 0; i < types.length; i++) {
var c = d.typeCounts[types[i]] || 0;
if (c > 0) { counts.push(c); colors.push(this._entityTypeColors[types[i]]); }
}
if (counts.length === 0) return;
var outerR = d._radius + 5, innerR = d._radius + 1;
var arc = d3.arc().innerRadius(innerR).outerRadius(outerR);
var pie = d3.pie().sort(null).value(function(v) { return v; });
var arcs = pie(counts);
for (var ai = 0; ai < arcs.length; ai++) {
gSel.append('path').attr('d', arc(arcs[ai])).attr('fill', colors[ai])
.attr('opacity', 0.8).attr('pointer-events', 'none');
}
},
_createDefs: function() {
var defs = this._svg.append('defs');
var filter = defs.append('filter')
.attr('id', 'cg-glow').attr('x', '-50%').attr('y', '-50%')
.attr('width', '200%').attr('height', '200%');
filter.append('feGaussianBlur').attr('in', 'SourceGraphic').attr('stdDeviation', 6).attr('result', 'blur');
filter.append('feColorMatrix').attr('in', 'blur').attr('type', 'matrix')
.attr('values', '0 0 0 0 0.24 0 0 0 0 0.51 0 0 0 0 0.96 0 0 0 0.5 0').attr('result', 'glow');
var merge = filter.append('feMerge');
merge.append('feMergeNode').attr('in', 'glow');
merge.append('feMergeNode').attr('in', 'SourceGraphic');
},
_drag: function() {
var self = this;
return d3.drag()
.on('start', function(event, d) {
if (!event.active && self._simulation) self._simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
})
.on('drag', function(event, d) { d.fx = event.x; d.fy = event.y; })
.on('end', function(event, d) {
if (!event.active && self._simulation) self._simulation.alphaTarget(0);
d.fx = null; d.fy = null;
});
},
_showTooltip: function(event, html) {
if (!this._tooltip) return;
this._tooltip.style('display', 'block').html(html);
this._moveTooltip(event);
},
_moveTooltip: function(event) {
if (!this._tooltip) return;
this._tooltip.style('left', (event.offsetX + 16) + 'px').style('top', (event.offsetY - 10) + 'px');
},
_hideTooltip: function() {
if (this._tooltip) this._tooltip.style('display', 'none');
},
_esc: function(str) {
if (!str) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
},
_darken: function(hex, amount) {
var r = parseInt(hex.slice(1, 3), 16);
var g = parseInt(hex.slice(3, 5), 16);
var b = parseInt(hex.slice(5, 7), 16);
r = Math.round(r * (1 - amount));
g = Math.round(g * (1 - amount));
b = Math.round(b * (1 - amount));
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
};

Datei-Diff unterdrückt, da er zu groß ist Diff laden