Cleanup: Alle Netzwerkanalyse-Reste vollständig entfernt

- 7 JS/CSS-Dateien gelöscht (api_network, app_network, network-graph, network-cluster, cluster-data, network.css, network-cluster.css)
- 2 Backend-Dateien gelöscht (routers/network_analysis.py, models_network.py)
- dashboard.html: Modal Neue Netzwerkanalyse entfernt
- app.js: 15 Netzwerk-Referenzen + kaputte Blöcke bereinigt
- DB-Schema CREATE TABLEs bleiben (geteilte DB mit Netzwerkanalyse-App)
Dieser Commit ist enthalten in:
Claude Dev
2026-03-25 01:00:03 +01:00
Ursprung dd25daa253
Commit 138fdd8594
11 geänderte Dateien mit 0 neuen und 4563 gelöschten Zeilen

Datei anzeigen

@@ -1,59 +0,0 @@
"""Pydantic Models für Netzwerkanalyse Request/Response Schemas."""
from pydantic import BaseModel, Field
from typing import Optional
class NetworkAnalysisCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
incident_ids: list[int] = Field(min_length=1)
class NetworkAnalysisUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
incident_ids: Optional[list[int]] = None
class NetworkEntityResponse(BaseModel):
id: int
name: str
name_normalized: str
entity_type: str
description: str = ""
aliases: list[str] = []
mention_count: int = 0
corrected_by_opus: bool = False
metadata: dict = {}
class NetworkRelationResponse(BaseModel):
id: int
source_entity_id: int
target_entity_id: int
category: str
label: str
description: str = ""
weight: int = 1
status: str = ""
evidence: list[str] = []
class NetworkAnalysisResponse(BaseModel):
id: int
name: str
status: str
entity_count: int = 0
relation_count: int = 0
has_update: bool = False
incident_ids: list[int] = []
incident_titles: list[str] = []
data_hash: Optional[str] = None
last_generated_at: Optional[str] = None
created_by: int = 0
created_by_username: str = ""
created_at: str = ""
class NetworkGraphResponse(BaseModel):
analysis: NetworkAnalysisResponse
entities: list[NetworkEntityResponse] = []
relations: list[NetworkRelationResponse] = []

Datei anzeigen

@@ -1,406 +0,0 @@
"""Router für Netzwerkanalyse CRUD-Operationen."""
import hashlib
import json
import csv
import io
from datetime import datetime
from typing import Optional
import aiosqlite
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from auth import get_current_user
from database import db_dependency, get_db
from middleware.license_check import require_writable_license
from models_network import (
NetworkAnalysisCreate,
NetworkAnalysisUpdate,
)
from config import TIMEZONE
router = APIRouter(prefix="/api/network-analyses", tags=["network-analyses"])
async def _check_analysis_access(
db: aiosqlite.Connection, analysis_id: int, tenant_id: int
) -> dict:
"""Analyse laden und Tenant-Zugriff prüfen."""
cursor = await db.execute(
"SELECT * FROM network_analyses WHERE id = ? AND tenant_id = ?",
(analysis_id, tenant_id),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Netzwerkanalyse nicht gefunden")
return dict(row)
async def _enrich_analysis(db: aiosqlite.Connection, analysis: dict) -> dict:
"""Analyse mit Incident-IDs, Titeln und Ersteller-Name anreichern."""
analysis_id = analysis["id"]
cursor = await db.execute(
"""SELECT nai.incident_id, i.title
FROM network_analysis_incidents nai
LEFT JOIN incidents i ON i.id = nai.incident_id
WHERE nai.network_analysis_id = ?""",
(analysis_id,),
)
rows = await cursor.fetchall()
analysis["incident_ids"] = [r["incident_id"] for r in rows]
analysis["incident_titles"] = [r["title"] or "" for r in rows]
cursor = await db.execute(
"SELECT email FROM users WHERE id = ?", (analysis.get("created_by"),)
)
user_row = await cursor.fetchone()
analysis["created_by_username"] = user_row["email"] if user_row else "Unbekannt"
analysis["created_at"] = analysis.get("created_at", "")
analysis["has_update"] = False
return analysis
async def _calculate_data_hash(db: aiosqlite.Connection, analysis_id: int) -> str:
"""SHA-256 über verknüpfte Artikel- und Factcheck-Daten."""
cursor = await db.execute(
"""SELECT DISTINCT a.id, a.collected_at
FROM network_analysis_incidents nai
JOIN articles a ON a.incident_id = nai.incident_id
WHERE nai.network_analysis_id = ?
ORDER BY a.id""",
(analysis_id,),
)
article_rows = await cursor.fetchall()
cursor = await db.execute(
"""SELECT DISTINCT fc.id, fc.checked_at
FROM network_analysis_incidents nai
JOIN fact_checks fc ON fc.incident_id = nai.incident_id
WHERE nai.network_analysis_id = ?
ORDER BY fc.id""",
(analysis_id,),
)
fc_rows = await cursor.fetchall()
parts = []
for r in article_rows:
parts.append(f"a:{r['id']}:{r['collected_at']}")
for r in fc_rows:
parts.append(f"fc:{r['id']}:{r['checked_at']}")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
# --- Endpoints ---
@router.get("")
async def list_network_analyses(
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Alle Netzwerkanalysen des Tenants auflisten."""
tenant_id = current_user["tenant_id"]
cursor = await db.execute(
"SELECT * FROM network_analyses WHERE tenant_id = ? ORDER BY created_at DESC",
(tenant_id,),
)
rows = await cursor.fetchall()
results = []
for row in rows:
results.append(await _enrich_analysis(db, dict(row)))
return results
@router.post("", status_code=201, dependencies=[Depends(require_writable_license)])
async def create_network_analysis(
body: NetworkAnalysisCreate,
background_tasks: BackgroundTasks,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Neue Netzwerkanalyse erstellen und Generierung starten."""
from agents.entity_extractor import extract_and_relate_entities
from main import ws_manager
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
if not body.incident_ids:
raise HTTPException(status_code=400, detail="Mindestens eine Lage auswählen")
# Prüfen ob alle Incidents dem Tenant gehören
placeholders = ",".join("?" for _ in body.incident_ids)
cursor = await db.execute(
f"SELECT id FROM incidents WHERE id IN ({placeholders}) AND tenant_id = ?",
(*body.incident_ids, tenant_id),
)
found_ids = {r["id"] for r in await cursor.fetchall()}
missing = set(body.incident_ids) - found_ids
if missing:
raise HTTPException(
status_code=400,
detail=f"Lagen nicht gefunden: {', '.join(str(i) for i in missing)}"
)
# Analyse anlegen
cursor = await db.execute(
"""INSERT INTO network_analyses (name, status, tenant_id, created_by)
VALUES (?, 'generating', ?, ?)""",
(body.name, tenant_id, user_id),
)
analysis_id = cursor.lastrowid
for incident_id in body.incident_ids:
await db.execute(
"INSERT INTO network_analysis_incidents (network_analysis_id, incident_id) VALUES (?, ?)",
(analysis_id, incident_id),
)
await db.commit()
# Hintergrund-Generierung starten
background_tasks.add_task(extract_and_relate_entities, analysis_id, tenant_id, ws_manager)
cursor = await db.execute("SELECT * FROM network_analyses WHERE id = ?", (analysis_id,))
row = await cursor.fetchone()
return await _enrich_analysis(db, dict(row))
@router.get("/{analysis_id}")
async def get_network_analysis(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Einzelne Netzwerkanalyse abrufen."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
return await _enrich_analysis(db, analysis)
@router.get("/{analysis_id}/graph")
async def get_network_graph(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Volle Graphdaten (Entities + Relations)."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
analysis = await _enrich_analysis(db, analysis)
cursor = await db.execute(
"SELECT * FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
entities_raw = await cursor.fetchall()
entities = []
for e in entities_raw:
ed = dict(e)
# JSON-Felder parsen
try:
ed["aliases"] = json.loads(ed.get("aliases", "[]"))
except (json.JSONDecodeError, TypeError):
ed["aliases"] = []
try:
ed["metadata"] = json.loads(ed.get("metadata", "{}"))
except (json.JSONDecodeError, TypeError):
ed["metadata"] = {}
ed["corrected_by_opus"] = bool(ed.get("corrected_by_opus", 0))
entities.append(ed)
cursor = await db.execute(
"SELECT * FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
relations_raw = await cursor.fetchall()
relations = []
for r in relations_raw:
rd = dict(r)
try:
rd["evidence"] = json.loads(rd.get("evidence", "[]"))
except (json.JSONDecodeError, TypeError):
rd["evidence"] = []
relations.append(rd)
return {"analysis": analysis, "entities": entities, "relations": relations}
@router.post("/{analysis_id}/regenerate", dependencies=[Depends(require_writable_license)])
async def regenerate_network(
analysis_id: int,
background_tasks: BackgroundTasks,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Analyse neu generieren."""
from agents.entity_extractor import extract_and_relate_entities
from main import ws_manager
tenant_id = current_user["tenant_id"]
await _check_analysis_access(db, analysis_id, tenant_id)
# Bestehende Daten löschen
await db.execute(
"DELETE FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
await db.execute(
"""DELETE FROM network_entity_mentions WHERE entity_id IN
(SELECT id FROM network_entities WHERE network_analysis_id = ?)""",
(analysis_id,),
)
await db.execute(
"DELETE FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
await db.execute(
"""UPDATE network_analyses
SET status = 'generating', entity_count = 0, relation_count = 0, data_hash = NULL
WHERE id = ?""",
(analysis_id,),
)
await db.commit()
background_tasks.add_task(extract_and_relate_entities, analysis_id, tenant_id, ws_manager)
return {"status": "generating", "message": "Neugenerierung gestartet"}
@router.get("/{analysis_id}/check-update")
async def check_network_update(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Prüft ob neue Daten verfügbar (Hash-Vergleich)."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
current_hash = await _calculate_data_hash(db, analysis_id)
stored_hash = analysis.get("data_hash")
has_update = stored_hash is not None and current_hash != stored_hash
return {"has_update": has_update}
@router.put("/{analysis_id}", dependencies=[Depends(require_writable_license)])
async def update_network_analysis(
analysis_id: int,
body: NetworkAnalysisUpdate,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Name oder Lagen aktualisieren."""
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
if analysis["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller kann bearbeiten")
if body.name is not None:
await db.execute(
"UPDATE network_analyses SET name = ? WHERE id = ?",
(body.name, analysis_id),
)
if body.incident_ids is not None:
if len(body.incident_ids) < 1:
raise HTTPException(status_code=400, detail="Mindestens eine Lage auswählen")
placeholders = ",".join("?" for _ in body.incident_ids)
cursor = await db.execute(
f"SELECT id FROM incidents WHERE id IN ({placeholders}) AND tenant_id = ?",
(*body.incident_ids, tenant_id),
)
found_ids = {r["id"] for r in await cursor.fetchall()}
missing = set(body.incident_ids) - found_ids
if missing:
raise HTTPException(status_code=400, detail=f"Lagen nicht gefunden: {', '.join(str(i) for i in missing)}")
await db.execute(
"DELETE FROM network_analysis_incidents WHERE network_analysis_id = ?",
(analysis_id,),
)
for iid in body.incident_ids:
await db.execute(
"INSERT INTO network_analysis_incidents (network_analysis_id, incident_id) VALUES (?, ?)",
(analysis_id, iid),
)
await db.commit()
cursor = await db.execute("SELECT * FROM network_analyses WHERE id = ?", (analysis_id,))
row = await cursor.fetchone()
return await _enrich_analysis(db, dict(row))
@router.delete("/{analysis_id}", dependencies=[Depends(require_writable_license)])
async def delete_network_analysis(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Analyse löschen (CASCADE räumt auf)."""
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
if analysis["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller kann löschen")
await db.execute("DELETE FROM network_analyses WHERE id = ?", (analysis_id,))
await db.commit()
return {"message": "Netzwerkanalyse gelöscht"}
@router.get("/{analysis_id}/export")
async def export_network(
analysis_id: int,
format: str = Query("json", pattern="^(json|csv)$"),
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Export als JSON oder CSV."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
analysis = await _enrich_analysis(db, analysis)
cursor = await db.execute(
"SELECT * FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
entities = [dict(r) for r in await cursor.fetchall()]
cursor = await db.execute(
"SELECT * FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
relations = [dict(r) for r in await cursor.fetchall()]
entity_map = {e["id"]: e for e in entities}
safe_name = (analysis.get("name", "export") or "export").replace(" ", "_")
if format == "json":
content = json.dumps(
{"analysis": analysis, "entities": entities, "relations": relations},
ensure_ascii=False, indent=2, default=str,
)
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="netzwerk_{safe_name}.json"'},
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["source", "target", "category", "label", "weight", "description"])
for rel in relations:
src = entity_map.get(rel.get("source_entity_id"), {})
tgt = entity_map.get(rel.get("target_entity_id"), {})
writer.writerow([
src.get("name", ""), tgt.get("name", ""),
rel.get("category", ""), rel.get("label", ""),
rel.get("weight", 1), rel.get("description", ""),
])
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8")),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="netzwerk_{safe_name}.csv"'},
)

Datei anzeigen

@@ -1,188 +0,0 @@
/* =================================================================
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

@@ -1,710 +0,0 @@
/* === Netzwerkanalyse Styles === */
/* --- Sidebar: Netzwerkanalysen-Sektion --- */
.sidebar-network-item {
display: flex;
align-items: center;
gap: var(--sp-md);
padding: 6px 12px;
cursor: pointer;
border-radius: var(--radius);
transition: background 0.15s;
font-size: 13px;
color: var(--sidebar-text);
}
.sidebar-network-item:hover {
background: var(--sidebar-hover-bg);
}
.sidebar-network-item.active {
background: var(--tint-accent);
color: var(--sidebar-active);
}
.sidebar-network-item .network-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.7;
}
.sidebar-network-item .network-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-network-item .network-item-count {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.sidebar-network-item .network-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sidebar-network-item .network-status-dot.generating {
background: var(--warning);
animation: pulse-dot 1.5s infinite;
}
.sidebar-network-item .network-status-dot.ready {
background: var(--success);
}
.sidebar-network-item .network-status-dot.error {
background: var(--error);
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* --- Typ-Badge für Netzwerk --- */
.incident-type-badge.type-network {
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
border: 1px solid rgba(99, 102, 241, 0.3);
}
/* --- Network View Layout --- */
#network-view {
display: none;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* --- Header Strip --- */
.network-header-strip {
padding: var(--sp-xl) var(--sp-3xl);
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-shrink: 0;
}
.network-header-row1 {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-xl);
}
.network-header-left {
display: flex;
align-items: center;
gap: var(--sp-lg);
min-width: 0;
}
.network-header-title {
font-family: var(--font-title);
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.network-header-actions {
display: flex;
align-items: center;
gap: var(--sp-md);
flex-shrink: 0;
}
.network-header-meta {
display: flex;
align-items: center;
gap: var(--sp-xl);
margin-top: var(--sp-md);
font-size: 12px;
color: var(--text-secondary);
}
.network-header-meta span {
display: flex;
align-items: center;
gap: var(--sp-xs);
}
/* --- Update-Badge --- */
.network-update-badge {
display: inline-flex;
align-items: center;
gap: var(--sp-xs);
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 9999px;
background: rgba(245, 158, 11, 0.15);
color: #F59E0B;
border: 1px solid rgba(245, 158, 11, 0.3);
cursor: pointer;
transition: background 0.15s;
}
.network-update-badge:hover {
background: rgba(245, 158, 11, 0.25);
}
/* --- Progress Bar (3 Schritte) --- */
.network-progress {
padding: var(--sp-lg) var(--sp-3xl);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.network-progress-steps {
display: flex;
align-items: center;
gap: var(--sp-md);
margin-bottom: var(--sp-md);
}
.network-progress-step {
display: flex;
align-items: center;
gap: var(--sp-sm);
font-size: 12px;
color: var(--text-disabled);
transition: color 0.3s;
}
.network-progress-step.active {
color: var(--accent);
font-weight: 600;
}
.network-progress-step.done {
color: var(--success);
}
.network-progress-step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-disabled);
transition: background 0.3s;
}
.network-progress-step.active .network-progress-step-dot {
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.network-progress-step.done .network-progress-step-dot {
background: var(--success);
}
.network-progress-connector {
flex: 1;
height: 2px;
background: var(--border);
position: relative;
}
.network-progress-connector.done {
background: var(--success);
}
.network-progress-track {
height: 3px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.network-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.5s ease;
width: 0%;
}
.network-progress-label {
font-size: 12px;
color: var(--text-secondary);
margin-top: var(--sp-sm);
}
/* --- Main Content Area --- */
.network-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* --- Graph Area --- */
.network-graph-area {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-primary);
}
.network-graph-area svg {
width: 100%;
height: 100%;
display: block;
}
/* --- Rechte Sidebar --- */
.network-sidebar {
width: 300px;
border-left: 1px solid var(--border);
background: var(--bg-card);
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.network-sidebar-section {
padding: var(--sp-lg) var(--sp-xl);
border-bottom: 1px solid var(--border);
}
.network-sidebar-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: var(--sp-md);
}
/* Suche */
.network-search-input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: var(--sp-md) var(--sp-lg);
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-body);
}
.network-search-input:focus {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-color: var(--accent);
}
.network-search-input::placeholder {
color: var(--text-disabled);
}
/* Typ-Filter */
.network-type-filters {
display: flex;
flex-wrap: wrap;
gap: var(--sp-sm);
}
.network-type-filter {
display: flex;
align-items: center;
gap: var(--sp-xs);
padding: 2px 8px;
font-size: 11px;
border-radius: var(--radius);
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
transition: all 0.15s;
}
.network-type-filter.active {
border-color: currentColor;
background: currentColor;
}
.network-type-filter.active span {
color: #fff;
}
.network-type-filter[data-type="person"] { color: #60A5FA; }
.network-type-filter[data-type="organisation"] { color: #C084FC; }
.network-type-filter[data-type="location"] { color: #34D399; }
.network-type-filter[data-type="event"] { color: #FBBF24; }
.network-type-filter[data-type="military"] { color: #F87171; }
.network-type-filter .type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
/* Gewicht-Slider */
.network-weight-slider {
width: 100%;
accent-color: var(--accent);
}
.network-weight-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-disabled);
margin-top: 2px;
}
/* Detail-Panel */
.network-detail-panel {
flex: 1;
overflow-y: auto;
padding: var(--sp-xl);
}
.network-detail-empty {
padding: var(--sp-3xl) var(--sp-xl);
text-align: center;
font-size: 13px;
color: var(--text-disabled);
}
.network-detail-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--sp-sm);
}
.network-detail-type {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 8px;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: var(--sp-lg);
}
.network-detail-type.type-person { background: rgba(96, 165, 250, 0.15); color: #60A5FA; }
.network-detail-type.type-organisation { background: rgba(192, 132, 252, 0.15); color: #C084FC; }
.network-detail-type.type-location { background: rgba(52, 211, 153, 0.15); color: #34D399; }
.network-detail-type.type-event { background: rgba(251, 191, 36, 0.15); color: #FBBF24; }
.network-detail-type.type-military { background: rgba(248, 113, 113, 0.15); color: #F87171; }
.network-detail-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: var(--sp-lg);
}
.network-detail-section {
margin-top: var(--sp-xl);
}
.network-detail-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: var(--sp-md);
}
.network-detail-aliases {
display: flex;
flex-wrap: wrap;
gap: var(--sp-xs);
}
.network-detail-alias {
font-size: 11px;
padding: 1px 6px;
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text-secondary);
}
.network-detail-stat {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: var(--sp-xs) 0;
color: var(--text-secondary);
}
.network-detail-stat strong {
color: var(--text-primary);
}
.network-opus-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
padding: 1px 6px;
border-radius: var(--radius);
background: rgba(99, 102, 241, 0.15);
color: #818CF8;
margin-left: var(--sp-sm);
}
/* Relation-Items im Detail-Panel */
.network-relation-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-md);
border-radius: var(--radius);
background: var(--bg-secondary);
margin-bottom: var(--sp-sm);
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.network-relation-item:hover {
background: var(--bg-hover);
}
.network-relation-header {
display: flex;
align-items: center;
gap: var(--sp-sm);
}
.network-relation-category {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0 5px;
border-radius: 3px;
}
.network-relation-category.cat-alliance { background: rgba(52, 211, 153, 0.15); color: #34D399; }
.network-relation-category.cat-conflict { background: rgba(239, 68, 68, 0.15); color: #EF4444; }
.network-relation-category.cat-diplomacy { background: rgba(251, 191, 36, 0.15); color: #FBBF24; }
.network-relation-category.cat-economic { background: rgba(96, 165, 250, 0.15); color: #60A5FA; }
.network-relation-category.cat-legal { background: rgba(192, 132, 252, 0.15); color: #C084FC; }
.network-relation-category.cat-neutral { background: rgba(107, 114, 128, 0.15); color: #6B7280; }
.network-relation-target {
color: var(--text-primary);
font-weight: 500;
}
.network-relation-label {
color: var(--text-secondary);
font-size: 11px;
}
.network-relation-weight {
font-size: 10px;
color: var(--text-disabled);
}
/* --- Graph Tooltip --- */
.network-tooltip {
position: absolute;
pointer-events: none;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--sp-md) var(--sp-lg);
font-size: 12px;
color: var(--text-primary);
box-shadow: var(--shadow-md);
z-index: 100;
max-width: 300px;
display: none;
}
.network-tooltip-title {
font-weight: 600;
margin-bottom: 2px;
}
.network-tooltip-desc {
color: var(--text-secondary);
font-size: 11px;
}
/* --- Graph SVG Styles --- */
.network-graph-area .node-label {
font-size: 10px;
fill: var(--text-secondary);
text-anchor: middle;
pointer-events: none;
user-select: none;
}
.network-graph-area .node-circle {
cursor: pointer;
stroke: var(--bg-primary);
stroke-width: 2;
transition: stroke-width 0.15s;
}
.network-graph-area .node-circle:hover {
stroke-width: 3;
stroke: var(--text-primary);
}
.network-graph-area .node-circle.selected {
stroke-width: 3;
stroke: var(--accent);
}
.network-graph-area .node-circle.dimmed {
opacity: 0.15;
}
.network-graph-area .node-circle.highlighted {
filter: drop-shadow(0 0 8px currentColor);
}
.network-graph-area .node-label.dimmed {
opacity: 0.1;
}
.network-graph-area .edge-line {
fill: none;
pointer-events: stroke;
cursor: pointer;
}
.network-graph-area .edge-line.dimmed {
opacity: 0.05 !important;
}
.network-graph-area .edge-line:hover {
stroke-width: 3 !important;
}
/* --- Modal: Neue Netzwerkanalyse --- */
.network-incident-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--input-border);
border-radius: var(--radius);
background: var(--input-bg);
}
.network-incident-search {
width: 100%;
padding: var(--sp-md) var(--sp-lg);
border: none;
border-bottom: 1px solid var(--input-border);
background: var(--input-bg);
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-body);
}
.network-incident-search:focus {
outline: none;
border-bottom-color: var(--accent);
}
.network-incident-search::placeholder {
color: var(--text-disabled);
}
.network-incident-option {
display: flex;
align-items: center;
gap: var(--sp-md);
padding: var(--sp-md) var(--sp-lg);
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
color: var(--text-primary);
}
.network-incident-option:hover {
background: var(--bg-hover);
}
.network-incident-option input[type="checkbox"] {
accent-color: var(--accent);
}
.network-incident-option .incident-option-type {
font-size: 10px;
color: var(--text-disabled);
margin-left: auto;
}
/* --- Leerer Graph-Zustand --- */
.network-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--sp-lg);
color: var(--text-disabled);
}
.network-empty-state-icon {
font-size: 48px;
opacity: 0.3;
}
.network-empty-state-text {
font-size: 14px;
}
/* Fix: Lagen-Checkboxen im Netzwerk-Modal */
.network-incident-option {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: var(--sp-md);
padding: var(--sp-md) var(--sp-lg);
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
text-transform: none;
letter-spacing: normal;
font-weight: 400;
margin-bottom: 0;
}
.network-incident-option input[type="checkbox"] {
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent);
cursor: pointer;
}
.network-incident-option span {
text-transform: none;
letter-spacing: normal;
font-weight: 400;
font-size: 13px;
color: var(--text-primary);
}
.network-incident-option .incident-option-type {
font-size: 10px;
font-weight: 500;
color: var(--text-disabled);
margin-left: auto;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}

Datei anzeigen

@@ -628,34 +628,6 @@
<!-- Modal: Neue Netzwerkanalyse --> <!-- Modal: Neue Netzwerkanalyse -->
<div class="modal-overlay" id="modal-network-new" role="dialog" aria-modal="true" aria-labelledby="modal-network-new-title">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-network-new-title">Neue Netzwerkanalyse</div>
<button class="modal-close" onclick="closeModal('modal-network-new')" aria-label="Schließen">&times;</button>
</div>
<form onsubmit="App.submitNetworkAnalysis(event); return false;">
<div class="modal-body">
<div class="form-group">
<label for="network-name">Name der Analyse</label>
<input type="text" id="network-name" required placeholder="z.B. Irankonflikt-Netzwerk">
</div>
<div class="form-group">
<label>Lagen auswählen</label>
<div class="network-incident-list">
<input type="text" class="network-incident-search" id="network-incident-search" placeholder="Lagen durchsuchen...">
<div id="network-incident-options"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-network-new')">Abbrechen</button>
<button type="submit" class="btn btn-primary" id="network-submit-btn">Analyse starten</button>
</div>
</form>
</div>
</div>
<!-- Tutorial --> <!-- Tutorial -->
<div class="tutorial-overlay" id="tutorial-overlay"> <div class="tutorial-overlay" id="tutorial-overlay">
<div class="tutorial-spotlight" id="tutorial-spotlight"></div> <div class="tutorial-spotlight" id="tutorial-spotlight"></div>

Datei anzeigen

@@ -1,43 +0,0 @@
/**
* Netzwerkanalyse API-Methoden — werden zum API-Objekt hinzugefügt.
*/
// Netzwerkanalysen
API.listNetworkAnalyses = function() {
return this._request('GET', '/network-analyses');
};
API.createNetworkAnalysis = function(data) {
return this._request('POST', '/network-analyses', data);
};
API.getNetworkAnalysis = function(id) {
return this._request('GET', '/network-analyses/' + id);
};
API.getNetworkGraph = function(id) {
return this._request('GET', '/network-analyses/' + id + '/graph');
};
API.regenerateNetwork = function(id) {
return this._request('POST', '/network-analyses/' + id + '/regenerate');
};
API.checkNetworkUpdate = function(id) {
return this._request('GET', '/network-analyses/' + id + '/check-update');
};
API.updateNetworkAnalysis = function(id, data) {
return this._request('PUT', '/network-analyses/' + id, data);
};
API.deleteNetworkAnalysis = function(id) {
return this._request('DELETE', '/network-analyses/' + id);
};
API.exportNetworkAnalysis = function(id, format) {
var token = localStorage.getItem('osint_token');
return fetch(this.baseUrl + '/network-analyses/' + id + '/export?format=' + format, {
headers: { 'Authorization': 'Bearer ' + token },
});
};

Datei anzeigen

@@ -546,14 +546,11 @@ const App = {
// Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen) // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open')); document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
document.getElementById('chevron-archived-incidents').classList.remove('open'); document.getElementById('chevron-archived-incidents').classList.remove('open');
var chevronNetwork = document.getElementById('chevron-network-analyses-list');
if (chevronNetwork) chevronNetwork.classList.add('open');
// Lagen laden (frueh, damit Sidebar sofort sichtbar) // Lagen laden (frueh, damit Sidebar sofort sichtbar)
await this.loadIncidents(); await this.loadIncidents();
// Netzwerkanalysen laden // Netzwerkanalysen laden
await this.loadNetworkAnalyses();
// Notification-Center initialisieren // Notification-Center initialisieren
try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); } try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
@@ -565,9 +562,6 @@ const App = {
WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg)); WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
WS.on('refresh_error', (msg) => this.handleRefreshError(msg)); WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg)); WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
WS.on('network_status', (msg) => this._handleNetworkStatus(msg));
WS.on('network_complete', (msg) => this._handleNetworkComplete(msg));
WS.on('network_error', (msg) => this._handleNetworkError(msg));
// Laufende Refreshes wiederherstellen // Laufende Refreshes wiederherstellen
try { try {
@@ -588,17 +582,6 @@ const App = {
} }
} }
// Zuletzt ausgewählte Netzwerkanalyse wiederherstellen
if (!savedId || !this.incidents.some(inc => inc.id === parseInt(savedId, 10))) {
const savedNetworkId = localStorage.getItem('selectedNetworkId');
if (savedNetworkId) {
const nid = parseInt(savedNetworkId, 10);
if (this.networkAnalyses.some(na => na.id === nid)) {
await this.selectNetworkAnalysis(nid);
}
}
}
// Leaflet-Karte nachladen falls CDN langsam war // Leaflet-Karte nachladen falls CDN langsam war
setTimeout(() => UI.retryPendingMap(), 2000); setTimeout(() => UI.retryPendingMap(), 2000);
}, },
@@ -694,10 +677,6 @@ const App = {
document.getElementById('empty-state').style.display = 'none'; document.getElementById('empty-state').style.display = 'none';
document.getElementById('incident-view').style.display = 'flex'; document.getElementById('incident-view').style.display = 'flex';
document.getElementById('network-view').style.display = 'none';
this.currentNetworkId = null;
localStorage.removeItem('selectedNetworkId');
this.renderNetworkSidebar();
// GridStack-Animation deaktivieren und Scroll komplett sperren // GridStack-Animation deaktivieren und Scroll komplett sperren
// bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind // bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind

Datei anzeigen

@@ -1,562 +0,0 @@
/**
* Netzwerkanalyse-Erweiterungen für App-Objekt.
* Wird nach app.js geladen und erweitert App um Netzwerk-Funktionalität.
*/
// State-Erweiterung
App.networkAnalyses = [];
App.currentNetworkId = null;
App._networkGenerating = new Set();
/**
* Netzwerkanalysen laden und Sidebar rendern.
*/
App.loadNetworkAnalyses = async function() {
try {
this.networkAnalyses = await API.listNetworkAnalyses();
} catch (e) {
console.warn('Netzwerkanalysen laden fehlgeschlagen:', e);
this.networkAnalyses = [];
}
this.renderNetworkSidebar();
};
/**
* Netzwerkanalysen-Sektion in der Sidebar rendern.
*/
App.renderNetworkSidebar = function() {
var container = document.getElementById('network-analyses-list');
if (!container) return;
var countEl = document.getElementById('count-network-analyses');
if (countEl) countEl.textContent = '(' + this.networkAnalyses.length + ')';
if (this.networkAnalyses.length === 0) {
container.innerHTML = '<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">Keine Netzwerkanalysen</div>';
return;
}
var self = this;
container.innerHTML = this.networkAnalyses.map(function(na) {
var isActive = na.id === self.currentNetworkId;
var statusClass = na.status === 'generating' ? 'generating' : (na.status === 'error' ? 'error' : 'ready');
var countText = na.status === 'ready' ? (na.entity_count + ' / ' + na.relation_count) : na.status === 'generating' ? '...' : '';
return '<div class="sidebar-network-item' + (isActive ? ' active' : '') + '" onclick="App.selectNetworkAnalysis(' + na.id + ')">' +
'<svg class="network-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/><line x1="12" y1="8" x2="5" y2="16"/><line x1="12" y1="8" x2="19" y2="16"/></svg>' +
'<span class="network-item-name" title="' + _escHtml(na.name) + '">' + _escHtml(na.name) + '</span>' +
'<span class="network-item-count">' + countText + '</span>' +
'<span class="network-status-dot ' + statusClass + '"></span>' +
'</div>';
}).join('');
};
/**
* Netzwerkanalyse auswählen und anzeigen.
*/
App.selectNetworkAnalysis = async function(id) {
this.currentNetworkId = id;
this.currentIncidentId = null;
localStorage.removeItem('selectedIncidentId');
localStorage.setItem('selectedNetworkId', id);
// Views umschalten
document.getElementById('empty-state').style.display = 'none';
document.getElementById('incident-view').style.display = 'none';
document.getElementById('network-view').style.display = 'flex';
// Sidebar aktualisieren
this.renderSidebar();
this.renderNetworkSidebar();
// Analyse laden
try {
var analysis = await API.getNetworkAnalysis(id);
this._renderNetworkHeader(analysis);
if (analysis.status === 'ready') {
this._hideNetworkProgress();
var graphData = await API.getNetworkGraph(id);
document.getElementById('network-graph-area').innerHTML = '';
NetworkGraph.init('network-graph-area', graphData);
this._setupNetworkFilters(graphData);
// Update-Check
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 graphArea = document.getElementById('network-graph-area');
if (graphArea) graphArea.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');
}
};
/**
* Netzwerkanalyse-Header rendern.
*/
App._renderNetworkHeader = function(analysis) {
var el;
el = document.getElementById('network-title');
if (el) el.textContent = analysis.name;
el = document.getElementById('network-entity-count');
if (el) el.textContent = analysis.entity_count + ' Entitäten';
el = document.getElementById('network-relation-count');
if (el) el.textContent = analysis.relation_count + ' Beziehungen';
el = document.getElementById('network-incident-list-text');
if (el) el.textContent = (analysis.incident_titles || []).join(', ') || '-';
el = document.getElementById('network-last-generated');
if (el) {
if (analysis.last_generated_at) {
var d = parseUTC(analysis.last_generated_at) || new Date(analysis.last_generated_at);
el.textContent = 'Generiert: ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE }) + ' ' +
d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
} else {
el.textContent = '';
}
}
};
/**
* Filter-Controls in der Netzwerk-Sidebar aufsetzen.
*/
App._setupNetworkFilters = function(graphData) {
// Typ-Filter-Buttons aktivieren
var types = new Set();
(graphData.entities || []).forEach(function(e) { types.add(e.entity_type); });
var filterContainer = document.getElementById('network-type-filter-container');
if (filterContainer) {
var allTypes = ['person', 'organisation', 'location', 'event', 'military'];
var typeLabels = { person: 'Person', organisation: 'Organisation', location: 'Ort', event: 'Ereignis', military: 'Militär' };
filterContainer.innerHTML = allTypes.map(function(t) {
var hasEntities = types.has(t);
return '<button class="network-type-filter active" data-type="' + t + '" onclick="App.toggleNetworkTypeFilter(this)" ' +
(hasEntities ? '' : 'disabled style="opacity:0.3"') + '>' +
'<span class="type-dot"></span><span>' + typeLabels[t] + '</span></button>';
}).join('');
}
// Gewicht-Slider
var slider = document.getElementById('network-weight-slider');
if (slider) {
slider.value = 1;
slider.oninput = function() {
var label = document.getElementById('network-weight-value');
if (label) label.textContent = this.value;
NetworkGraph.filterByWeight(parseInt(this.value));
};
}
// Suche
var searchInput = document.getElementById('network-search');
if (searchInput) {
searchInput.value = '';
var timer = null;
searchInput.oninput = function() {
clearTimeout(timer);
var val = this.value;
timer = setTimeout(function() {
NetworkGraph.search(val);
}, 250);
};
}
};
/**
* Typ-Filter toggle.
*/
App.toggleNetworkTypeFilter = function(btn) {
btn.classList.toggle('active');
var activeTypes = [];
document.querySelectorAll('.network-type-filter.active').forEach(function(b) {
activeTypes.push(b.dataset.type);
});
NetworkGraph.filterByType(new Set(activeTypes));
};
/**
* Progress-Bar anzeigen.
*/
App._showNetworkProgress = function(phase, progress) {
var bar = document.getElementById('network-progress-bar');
if (bar) bar.style.display = 'block';
var steps = ['entity_extraction', 'relationship_extraction', 'correction'];
var stepEls = document.querySelectorAll('.network-progress-step');
var connectorEls = document.querySelectorAll('.network-progress-connector');
var phaseIndex = steps.indexOf(phase);
stepEls.forEach(function(el, i) {
el.classList.remove('active', 'done');
if (i < phaseIndex) el.classList.add('done');
else if (i === phaseIndex) el.classList.add('active');
});
connectorEls.forEach(function(el, i) {
el.classList.remove('done');
if (i < phaseIndex) el.classList.add('done');
});
var fill = document.getElementById('network-progress-fill');
if (fill) {
var pct = ((phaseIndex / steps.length) * 100) + (progress || 0) * (100 / steps.length) / 100;
fill.style.width = Math.min(100, pct) + '%';
}
var label = document.getElementById('network-progress-label');
if (label) {
var labels = { entity_extraction: 'Entitäten werden extrahiert...', relationship_extraction: 'Beziehungen werden analysiert...', correction: 'Korrekturen werden angewendet...' };
label.textContent = labels[phase] || 'Wird verarbeitet...';
}
};
App._hideNetworkProgress = function() {
var bar = document.getElementById('network-progress-bar');
if (bar) bar.style.display = 'none';
};
/**
* Modal: Neue Netzwerkanalyse öffnen.
*/
App.openNetworkModal = async function() {
var list = document.getElementById('network-incident-options');
if (list) list.innerHTML = '<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Lade Lagen...</div>';
openModal('modal-network-new');
// Lagen laden
try {
var incidents = await API.listIncidents();
// Sortierung: zuerst Live (adhoc) alphabetisch, dann Analyse (research) alphabetisch
incidents.sort(function(a, b) {
var typeA = (a.type === 'research') ? 1 : 0;
var typeB = (b.type === 'research') ? 1 : 0;
if (typeA !== typeB) return typeA - typeB;
return (a.title || '').localeCompare(b.title || '', 'de');
});
if (list) {
list.innerHTML = incidents.map(function(inc) {
var typeLabel = inc.type === 'research' ? 'Analyse' : 'Live';
return '<label class="network-incident-option">' +
'<input type="checkbox" value="' + inc.id + '" class="network-incident-cb">' +
'<span>' + _escHtml(inc.title) + '</span>' +
'<span class="incident-option-type">' + typeLabel + '</span>' +
'</label>';
}).join('');
}
} catch (e) {
if (list) list.innerHTML = '<div style="padding:12px;color:var(--error);font-size:12px;">Fehler beim Laden der Lagen</div>';
}
// Name-Feld leeren
var nameField = document.getElementById('network-name');
if (nameField) nameField.value = '';
// Suchfeld leeren
var searchField = document.getElementById('network-incident-search');
if (searchField) {
searchField.value = '';
searchField.oninput = function() {
var term = this.value.toLowerCase();
document.querySelectorAll('.network-incident-option').forEach(function(opt) {
var text = opt.textContent.toLowerCase();
opt.style.display = text.includes(term) ? '' : 'none';
});
};
}
};
/**
* Netzwerkanalyse erstellen.
*/
App.submitNetworkAnalysis = async function(e) {
if (e) e.preventDefault();
var name = (document.getElementById('network-name').value || '').trim();
if (!name) {
UI.showToast('Bitte einen Namen eingeben.', 'warning');
return;
}
var incidentIds = [];
document.querySelectorAll('.network-incident-cb:checked').forEach(function(cb) {
incidentIds.push(parseInt(cb.value));
});
if (incidentIds.length === 0) {
UI.showToast('Bitte mindestens eine Lage auswählen.', 'warning');
return;
}
var btn = document.getElementById('network-submit-btn');
if (btn) btn.disabled = true;
try {
var result = await API.createNetworkAnalysis({ name: name, incident_ids: incidentIds });
closeModal('modal-network-new');
await this.loadNetworkAnalyses();
await this.selectNetworkAnalysis(result.id);
UI.showToast('Netzwerkanalyse gestartet.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
};
/**
* Netzwerkanalyse neu generieren.
*/
App.regenerateNetwork = async function() {
if (!this.currentNetworkId) return;
if (!await confirmDialog('Netzwerkanalyse neu generieren? Bestehende Daten werden überschrieben.')) return;
try {
await API.regenerateNetwork(this.currentNetworkId);
this._showNetworkProgress('entity_extraction', 0);
await this.loadNetworkAnalyses();
UI.showToast('Neugenerierung gestartet.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
};
/**
* Netzwerkanalyse löschen.
*/
App.deleteNetworkAnalysis = async function() {
if (!this.currentNetworkId) return;
if (!await confirmDialog('Netzwerkanalyse wirklich löschen? Alle Daten gehen verloren.')) return;
try {
await API.deleteNetworkAnalysis(this.currentNetworkId);
this.currentNetworkId = null;
localStorage.removeItem('selectedNetworkId');
NetworkGraph.destroy();
document.getElementById('network-view').style.display = 'none';
document.getElementById('empty-state').style.display = 'flex';
await this.loadNetworkAnalyses();
UI.showToast('Netzwerkanalyse gelöscht.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
};
/**
* Netzwerkanalyse exportieren.
*/
App.exportNetwork = async function(format) {
if (!this.currentNetworkId) return;
if (format === 'png') {
NetworkGraph.exportPNG();
return;
}
try {
var resp = await API.exportNetworkAnalysis(this.currentNetworkId, format);
if (!resp.ok) throw new Error('Export fehlgeschlagen');
var blob = await resp.blob();
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'netzwerk-' + this.currentNetworkId + '.' + format;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
}
};
/**
* WebSocket-Handler für Netzwerk-Events.
*/
App._handleNetworkStatus = function(msg) {
if (msg.analysis_id === this.currentNetworkId) {
this._showNetworkProgress(msg.phase, msg.progress || 0);
}
};
App._handleNetworkComplete = async function(msg) {
this._networkGenerating.delete(msg.analysis_id);
if (msg.analysis_id === this.currentNetworkId) {
this._hideNetworkProgress();
// Graph neu laden
try {
var graphData = await API.getNetworkGraph(msg.analysis_id);
document.getElementById('network-graph-area').innerHTML = '';
NetworkGraph.init('network-graph-area', graphData);
this._setupNetworkFilters(graphData);
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) + ' Entitäten, ' + (msg.relation_count || 0) + ' Beziehungen', 'success');
}
await this.loadNetworkAnalyses();
};
App._handleNetworkError = function(msg) {
this._networkGenerating.delete(msg.analysis_id);
if (msg.analysis_id === this.currentNetworkId) {
this._hideNetworkProgress();
var graphArea = document.getElementById('network-graph-area');
if (graphArea) graphArea.innerHTML = '<div class="network-empty-state"><div class="network-empty-state-icon">&#9888;</div><div class="network-empty-state-text">Fehler: ' + _escHtml(msg.error || 'Unbekannter Fehler') + '</div></div>';
}
UI.showToast('Netzwerkanalyse fehlgeschlagen: ' + (msg.error || 'Unbekannter Fehler'), 'error');
this.loadNetworkAnalyses();
};
/**
* Cluster isolieren (nur verbundene Knoten zeigen).
*/
App.isolateNetworkCluster = function() {
if (NetworkGraph._selectedNode) {
NetworkGraph.isolateCluster(NetworkGraph._selectedNode.id);
}
};
/**
* Graph-Ansicht zurücksetzen.
*/
App.resetNetworkView = function() {
NetworkGraph.resetView();
// Typ-Filter zurücksetzen
document.querySelectorAll('.network-type-filter').forEach(function(btn) {
if (!btn.disabled) btn.classList.add('active');
});
var slider = document.getElementById('network-weight-slider');
if (slider) { slider.value = 1; var lbl = document.getElementById('network-weight-value'); if (lbl) lbl.textContent = '1'; }
var search = document.getElementById('network-search');
if (search) search.value = '';
};
// HTML-Escape Hilfsfunktion (falls nicht global verfügbar)
function _escHtml(text) {
if (typeof UI !== 'undefined' && UI.escape) return UI.escape(text);
var d = document.createElement('div');
d.textContent = text || '';
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();
};
})();

Datei anzeigen

@@ -1,721 +0,0 @@
/**
* 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

@@ -1,993 +0,0 @@
/**
* 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 anzeigen

@@ -1,832 +0,0 @@
/**
* AegisSight OSINT Monitor - Network Graph Visualization
*
* Force-directed graph powered by d3.js v7.
* Expects d3 to be loaded globally from CDN before this script runs.
*
* Usage:
* NetworkGraph.init('network-graph-area', data);
* NetworkGraph.filterByType(new Set(['person', 'organisation']));
* NetworkGraph.search('Russland');
* NetworkGraph.destroy();
*/
/* global d3 */
const NetworkGraph = {
// ---- internal state -------------------------------------------------------
_svg: null,
_simulation: null,
_data: null, // raw data as received
_filtered: null, // currently visible subset
_container: null, // <g> inside SVG that receives zoom transforms
_zoom: null,
_selectedNode: null,
_tooltip: null,
_filters: {
types: new Set(), // empty = all visible
minWeight: 1,
searchTerm: '',
},
_colorMap: {
node: {
person: '#60A5FA',
organisation: '#C084FC',
location: '#34D399',
event: '#FBBF24',
military: '#F87171',
},
edge: {
alliance: '#34D399',
conflict: '#EF4444',
diplomacy: '#FBBF24',
economic: '#60A5FA',
legal: '#C084FC',
neutral: '#6B7280',
},
},
// ---- public API -----------------------------------------------------------
/**
* Initialise the graph inside the given container element.
* @param {string} containerId – DOM id of the wrapper element
* @param {object} data – { entities: [], relations: [] }
*/
init(containerId, data) {
this.destroy();
const wrapper = document.getElementById(containerId);
if (!wrapper) {
console.error('[NetworkGraph] Container #' + containerId + ' not found.');
return;
}
wrapper.innerHTML = '';
this._data = this._prepareData(data);
this._filters = { types: new Set(), minWeight: 1, searchTerm: '' };
this._selectedNode = null;
const rect = wrapper.getBoundingClientRect();
const width = rect.width || 960;
const height = rect.height || 640;
// SVG
this._svg = d3.select(wrapper)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', [0, 0, width, height].join(' '))
.attr('preserveAspectRatio', 'xMidYMid meet')
.style('background', 'transparent');
// Defs: arrow markers per category
this._createMarkers();
// Defs: glow filter for top-connected nodes
this._createGlowFilter();
// Zoom container
this._container = this._svg.append('g').attr('class', 'ng-zoom-layer');
// Zoom behaviour
this._zoom = d3.zoom()
.scaleExtent([0.1, 8])
.on('zoom', (event) => {
this._container.attr('transform', event.transform);
});
this._svg.call(this._zoom);
// Double-click resets zoom
this._svg.on('dblclick.zoom', null);
this._svg.on('dblclick', () => this.resetView());
// Tooltip
this._tooltip = d3.select(wrapper)
.append('div')
.attr('class', 'ng-tooltip')
.style('position', 'absolute')
.style('pointer-events', 'none')
.style('background', 'rgba(15,23,42,0.92)')
.style('color', '#e2e8f0')
.style('border', '1px solid #334155')
.style('border-radius', '6px')
.style('padding', '6px 10px')
.style('font-size', '12px')
.style('max-width', '260px')
.style('z-index', '1000')
.style('display', 'none');
// Simulation
this._simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(d => {
// Inverse weight: higher weight -> closer
return Math.max(40, 200 - d.weight * 25);
}))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide().radius(d => d._radius + 6))
.alphaDecay(0.02);
this.render();
},
/**
* Tear down the graph completely.
*/
destroy() {
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._container = null;
this._data = null;
this._filtered = null;
this._selectedNode = null;
},
/**
* Full re-render based on current filters.
*/
render() {
if (!this._data || !this._container) return;
this._applyFilters();
const nodes = this._filtered.entities;
const links = this._filtered.relations;
// Clear previous drawing
this._container.selectAll('*').remove();
// Determine top-5 most connected node IDs
const connectionCounts = {};
this._data.relations.forEach(r => {
connectionCounts[r.source_entity_id] = (connectionCounts[r.source_entity_id] || 0) + 1;
connectionCounts[r.target_entity_id] = (connectionCounts[r.target_entity_id] || 0) + 1;
});
const top5Ids = new Set(
Object.entries(connectionCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(e => e[0])
);
// Radius scale (sqrt of connection count)
const maxConn = Math.max(1, ...Object.values(connectionCounts));
const rScale = d3.scaleSqrt().domain([0, maxConn]).range([8, 40]);
nodes.forEach(n => {
n._connections = connectionCounts[n.id] || 0;
n._radius = rScale(n._connections);
n._isTop5 = top5Ids.has(n.id);
});
// ---- edges ------------------------------------------------------------
const linkGroup = this._container.append('g').attr('class', 'ng-links');
const linkSel = linkGroup.selectAll('line')
.data(links, d => d.id)
.join('line')
.attr('stroke', d => this._colorMap.edge[d.category] || this._colorMap.edge.neutral)
.attr('stroke-width', d => Math.max(1, d.weight * 0.8))
.attr('stroke-opacity', d => Math.min(1, 0.3 + d.weight * 0.14))
.attr('marker-end', d => 'url(#ng-arrow-' + (d.category || 'neutral') + ')')
.style('cursor', 'pointer')
.on('mouseover', (event, d) => {
const lines = [];
if (d.label) lines.push('<strong>' + this._esc(d.label) + '</strong>');
if (d.description) lines.push(this._esc(d.description));
lines.push('Kategorie: ' + this._esc(d.category) + ' | Gewicht: ' + d.weight);
this._showTooltip(event, lines.join('<br>'));
})
.on('mousemove', (event) => this._moveTooltip(event))
.on('mouseout', () => this._hideTooltip());
// ---- nodes ------------------------------------------------------------
const nodeGroup = this._container.append('g').attr('class', 'ng-nodes');
const nodeSel = nodeGroup.selectAll('g')
.data(nodes, d => d.id)
.join('g')
.attr('class', 'ng-node')
.style('cursor', 'pointer')
.call(this._drag(this._simulation))
.on('mouseover', (event, d) => {
this._showTooltip(event, '<strong>' + this._esc(d.name) + '</strong><br>' +
this._esc(d.entity_type) + ' | Verbindungen: ' + d._connections);
})
.on('mousemove', (event) => this._moveTooltip(event))
.on('mouseout', () => this._hideTooltip())
.on('click', (event, d) => {
event.stopPropagation();
this._onNodeClick(d, linkSel, nodeSel);
});
// Circle
nodeSel.append('circle')
.attr('r', d => d._radius)
.attr('fill', d => this._colorMap.node[d.entity_type] || '#94A3B8')
.attr('stroke', '#0f172a')
.attr('stroke-width', 1.5)
.attr('filter', d => d._isTop5 ? 'url(#ng-glow)' : null);
// Label
nodeSel.append('text')
.text(d => d.name.length > 15 ? d.name.slice(0, 14) + '\u2026' : d.name)
.attr('dy', d => d._radius + 14)
.attr('text-anchor', 'middle')
.attr('fill', '#cbd5e1')
.attr('font-size', '10px')
.attr('pointer-events', 'none');
// ---- simulation -------------------------------------------------------
// Build link data with object references (d3 expects id strings or objects)
const simNodes = nodes;
const simLinks = links.map(l => ({
...l,
source: typeof l.source === 'object' ? l.source.id : l.source_entity_id,
target: typeof l.target === 'object' ? l.target.id : l.target_entity_id,
}));
this._simulation.nodes(simNodes);
this._simulation.force('link').links(simLinks);
this._simulation.force('collide').radius(d => d._radius + 6);
this._simulation.alpha(1).restart();
this._simulation.on('tick', () => {
linkSel
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => {
// Shorten line so arrow doesn't overlap circle
const target = d.target;
const dx = target.x - d.source.x;
const dy = target.y - d.source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
return target.x - (dx / dist) * (target._radius + 4);
})
.attr('y2', d => {
const target = d.target;
const dx = target.x - d.source.x;
const dy = target.y - d.source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
return target.y - (dy / dist) * (target._radius + 4);
});
nodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
});
// Click on background to deselect
this._svg.on('click', () => {
this._selectedNode = null;
nodeSel.select('circle').attr('stroke', '#0f172a').attr('stroke-width', 1.5);
linkSel.attr('stroke-opacity', d => Math.min(1, 0.3 + d.weight * 0.14));
this._clearDetailPanel();
});
// Apply search highlight if active
if (this._filters.searchTerm) {
this._applySearchHighlight(nodeSel);
}
},
// ---- filtering ------------------------------------------------------------
/**
* Compute the visible subset from raw data + current filters.
*/
_applyFilters() {
let entities = this._data.entities.slice();
let relations = this._data.relations.slice();
// Type filter
if (this._filters.types.size > 0) {
const allowed = this._filters.types;
entities = entities.filter(e => allowed.has(e.entity_type));
const visibleIds = new Set(entities.map(e => e.id));
relations = relations.filter(r =>
visibleIds.has(r.source_entity_id) && visibleIds.has(r.target_entity_id)
);
}
// Weight filter
if (this._filters.minWeight > 1) {
relations = relations.filter(r => r.weight >= this._filters.minWeight);
}
// Cluster isolation
if (this._filters._isolateId) {
const centerId = this._filters._isolateId;
const connectedIds = new Set([centerId]);
relations.forEach(r => {
if (r.source_entity_id === centerId) connectedIds.add(r.target_entity_id);
if (r.target_entity_id === centerId) connectedIds.add(r.source_entity_id);
});
entities = entities.filter(e => connectedIds.has(e.id));
relations = relations.filter(r =>
connectedIds.has(r.source_entity_id) && connectedIds.has(r.target_entity_id)
);
}
this._filtered = { entities, relations };
},
/**
* Populate the detail panel (#network-detail-panel) with entity info.
* @param {object} entity
*/
_updateDetailPanel(entity) {
const panel = document.getElementById('network-detail-panel');
if (!panel) return;
const typeColor = this._colorMap.node[entity.entity_type] || '#94A3B8';
// Connected relations
const connected = this._data.relations.filter(
r => r.source_entity_id === entity.id || r.target_entity_id === entity.id
);
// Group by category
const grouped = {};
connected.forEach(r => {
const cat = r.category || 'neutral';
if (!grouped[cat]) grouped[cat] = [];
// Determine the "other" entity
const otherId = r.source_entity_id === entity.id ? r.target_entity_id : r.source_entity_id;
const other = this._data.entities.find(e => e.id === otherId);
grouped[cat].push({ relation: r, other });
});
let html = '';
// Header
html += '<div style="margin-bottom:12px;">';
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;text-transform:uppercase;">' +
this._esc(entity.entity_type) + '</span>';
if (entity.corrected_by_opus) {
html += ' <span style="display:inline-block;background:#FBBF24;color:#0f172a;' +
'padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">Corrected by Opus</span>';
}
html += '</div>';
// Description
if (entity.description) {
html += '<p style="color:#94a3b8;font-size:13px;margin:0 0 10px 0;">' +
this._esc(entity.description) + '</p>';
}
// Aliases
if (entity.aliases && entity.aliases.length > 0) {
html += '<div style="margin-bottom:10px;">';
html += '<strong style="color:#cbd5e1;font-size:12px;">Aliase:</strong><br>';
entity.aliases.forEach(a => {
html += '<span style="display:inline-block;background:#1e293b;color:#94a3b8;' +
'padding:1px 6px;border-radius:3px;font-size:11px;margin:2px 4px 2px 0;">' +
this._esc(a) + '</span>';
});
html += '</div>';
}
// Mention count
html += '<div style="margin-bottom:10px;color:#94a3b8;font-size:12px;">';
html += 'Erw\u00e4hnungen: <strong style="color:#f1f5f9;">' +
(entity.mention_count || 0) + '</strong>';
html += '</div>';
// Relations grouped by category
const categoryLabels = {
alliance: 'Allianz', conflict: 'Konflikt', diplomacy: 'Diplomatie',
economic: '\u00d6konomie', legal: 'Recht', neutral: 'Neutral',
};
if (Object.keys(grouped).length > 0) {
html += '<div style="border-top:1px solid #334155;padding-top:10px;">';
html += '<strong style="color:#cbd5e1;font-size:12px;">Verbindungen (' + connected.length + '):</strong>';
Object.keys(grouped).sort().forEach(cat => {
const catColor = this._colorMap.edge[cat] || this._colorMap.edge.neutral;
const catLabel = categoryLabels[cat] || cat;
html += '<div style="margin-top:8px;">';
html += '<span style="color:' + catColor + ';font-size:11px;font-weight:600;text-transform:uppercase;">' +
this._esc(catLabel) + '</span>';
grouped[cat].forEach(item => {
const r = item.relation;
const otherName = item.other ? item.other.name : '?';
const direction = r.source_entity_id === entity.id ? '\u2192' : '\u2190';
html += '<div style="color:#94a3b8;font-size:12px;padding:2px 0 2px 8px;">';
html += direction + ' <span style="color:#e2e8f0;">' + this._esc(otherName) + '</span>';
if (r.label) html += ' &mdash; ' + this._esc(r.label);
html += ' <span style="color:#64748b;">(G:' + r.weight + ')</span>';
html += '</div>';
});
html += '</div>';
});
html += '</div>';
}
panel.innerHTML = html;
panel.style.display = 'block';
},
/**
* Filter nodes by entity type.
* @param {Set|Array} types – entity_type values to show. Empty = all.
*/
filterByType(types) {
this._filters.types = types instanceof Set ? types : new Set(types);
this._filters._isolateId = null;
this.render();
},
/**
* Filter edges by minimum weight.
* @param {number} minWeight
*/
filterByWeight(minWeight) {
this._filters.minWeight = minWeight;
this.render();
},
/**
* Highlight nodes matching the search term (name, aliases, description).
* @param {string} term
*/
search(term) {
this._filters.searchTerm = (term || '').trim().toLowerCase();
this.render();
},
/**
* Show only the 1-hop neighbourhood of the given entity.
* @param {string} entityId
*/
isolateCluster(entityId) {
this._filters._isolateId = entityId;
this.render();
},
/**
* Reset zoom, filters and selection to initial state.
*/
resetView() {
this._filters = { types: new Set(), minWeight: 1, searchTerm: '' };
this._selectedNode = null;
this._clearDetailPanel();
if (this._svg && this._zoom) {
this._svg.transition().duration(500).call(
this._zoom.transform, d3.zoomIdentity
);
}
this.render();
},
// ---- export ---------------------------------------------------------------
/**
* Export the current graph as a PNG image.
*/
exportPNG() {
if (!this._svg) return;
const svgNode = this._svg.node();
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgNode);
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = function () {
const canvas = document.createElement('canvas');
const bbox = svgNode.getBoundingClientRect();
canvas.width = bbox.width * 2; // 2x for retina
canvas.height = bbox.height * 2;
const ctx = canvas.getContext('2d');
ctx.scale(2, 2);
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, bbox.width, bbox.height);
ctx.drawImage(img, 0, 0, bbox.width, bbox.height);
URL.revokeObjectURL(url);
canvas.toBlob(function (blob) {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aegis-network-' + Date.now() + '.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}, 'image/png');
};
img.src = url;
},
/**
* Export the current relations as CSV.
*/
exportCSV() {
if (!this._data) return;
const entityMap = {};
this._data.entities.forEach(e => { entityMap[e.id] = e.name; });
const rows = [['source', 'target', 'category', 'label', 'weight', 'description'].join(',')];
this._data.relations.forEach(r => {
rows.push([
this._csvField(entityMap[r.source_entity_id] || r.source_entity_id),
this._csvField(entityMap[r.target_entity_id] || r.target_entity_id),
this._csvField(r.category),
this._csvField(r.label),
r.weight,
this._csvField(r.description || ''),
].join(','));
});
const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aegis-network-' + Date.now() + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
/**
* Export the full data as JSON.
*/
exportJSON() {
if (!this._data) return;
const exportData = {
entities: this._data.entities.map(e => ({
id: e.id,
name: e.name,
name_normalized: e.name_normalized,
entity_type: e.entity_type,
description: e.description,
aliases: e.aliases,
mention_count: e.mention_count,
corrected_by_opus: e.corrected_by_opus,
metadata: e.metadata,
})),
relations: this._data.relations.map(r => ({
id: r.id,
source_entity_id: r.source_entity_id,
target_entity_id: r.target_entity_id,
category: r.category,
label: r.label,
description: r.description,
weight: r.weight,
status: r.status,
evidence: r.evidence,
})),
};
const blob = new Blob(
[JSON.stringify(exportData, null, 2)],
{ type: 'application/json;charset=utf-8' }
);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'aegis-network-' + Date.now() + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
// ---- internal helpers -----------------------------------------------------
/**
* Prepare / clone data so we do not mutate the original.
*/
_prepareData(raw) {
return {
entities: (raw.entities || []).map(e => ({ ...e })),
relations: (raw.relations || []).map(r => ({ ...r })),
};
},
/**
* Create SVG arrow markers for each edge category.
*/
_createMarkers() {
const defs = this._svg.append('defs');
const categories = Object.keys(this._colorMap.edge);
categories.forEach(cat => {
defs.append('marker')
.attr('id', 'ng-arrow-' + cat)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 10)
.attr('refY', 0)
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', this._colorMap.edge[cat]);
});
},
/**
* Create SVG glow filter for top-5 nodes.
*/
_createGlowFilter() {
const defs = this._svg.select('defs');
const filter = defs.append('filter')
.attr('id', 'ng-glow')
.attr('x', '-50%')
.attr('y', '-50%')
.attr('width', '200%')
.attr('height', '200%');
filter.append('feGaussianBlur')
.attr('in', 'SourceGraphic')
.attr('stdDeviation', 4)
.attr('result', 'blur');
filter.append('feColorMatrix')
.attr('in', 'blur')
.attr('type', 'matrix')
.attr('values', '0 0 0 0 0.98 0 0 0 0 0.75 0 0 0 0 0.14 0 0 0 0.7 0')
.attr('result', 'glow');
const merge = filter.append('feMerge');
merge.append('feMergeNode').attr('in', 'glow');
merge.append('feMergeNode').attr('in', 'SourceGraphic');
},
/**
* d3 drag behaviour.
*/
_drag(simulation) {
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
},
/**
* Handle node click – highlight edges, show detail panel.
*/
_onNodeClick(d, linkSel, nodeSel) {
this._selectedNode = d;
// Highlight selected node
nodeSel.select('circle')
.attr('stroke', n => n.id === d.id ? '#FBBF24' : '#0f172a')
.attr('stroke-width', n => n.id === d.id ? 3 : 1.5);
// Highlight connected edges
const connectedNodeIds = new Set([d.id]);
linkSel.each(function (l) {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
if (srcId === d.id || tgtId === d.id) {
connectedNodeIds.add(srcId);
connectedNodeIds.add(tgtId);
}
});
linkSel.attr('stroke-opacity', l => {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
if (srcId === d.id || tgtId === d.id) {
return Math.min(1, 0.3 + l.weight * 0.14) + 0.3;
}
return 0.08;
});
nodeSel.select('circle').attr('opacity', n =>
connectedNodeIds.has(n.id) ? 1 : 0.25
);
nodeSel.select('text').attr('opacity', n =>
connectedNodeIds.has(n.id) ? 1 : 0.2
);
// Detail panel
const entity = this._data.entities.find(e => e.id === d.id);
if (entity) {
this._updateDetailPanel(entity);
}
},
/**
* Apply search highlighting (glow matching, dim rest).
*/
_applySearchHighlight(nodeSel) {
const term = this._filters.searchTerm;
if (!term) return;
nodeSel.each(function (d) {
const matches = NetworkGraph._matchesSearch(d, term);
d3.select(this).select('circle')
.attr('opacity', matches ? 1 : 0.15)
.attr('filter', matches ? 'url(#ng-glow)' : null);
d3.select(this).select('text')
.attr('opacity', matches ? 1 : 0.1);
});
},
/**
* Check if entity matches the search term.
*/
_matchesSearch(entity, term) {
if (!term) return true;
if (entity.name && entity.name.toLowerCase().includes(term)) return true;
if (entity.name_normalized && entity.name_normalized.toLowerCase().includes(term)) return true;
if (entity.description && entity.description.toLowerCase().includes(term)) return true;
if (entity.aliases) {
for (let i = 0; i < entity.aliases.length; i++) {
if (entity.aliases[i].toLowerCase().includes(term)) return true;
}
}
return false;
},
/**
* Clear the detail panel.
*/
_clearDetailPanel() {
const panel = document.getElementById('network-detail-panel');
if (panel) {
panel.innerHTML = '<p style="color:#64748b;font-size:13px;padding:16px;">Klicke auf einen Knoten, um Details anzuzeigen.</p>';
}
},
// ---- tooltip helpers ------------------------------------------------------
_showTooltip(event, html) {
if (!this._tooltip) return;
this._tooltip
.style('display', 'block')
.html(html);
this._moveTooltip(event);
},
_moveTooltip(event) {
if (!this._tooltip) return;
this._tooltip
.style('left', (event.offsetX + 14) + 'px')
.style('top', (event.offsetY - 10) + 'px');
},
_hideTooltip() {
if (!this._tooltip) return;
this._tooltip.style('display', 'none');
},
// ---- string helpers -------------------------------------------------------
_esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
},
_csvField(val) {
const s = String(val == null ? '' : val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
},
};