diff --git a/src/models_network.py b/src/models_network.py deleted file mode 100644 index b03e357..0000000 --- a/src/models_network.py +++ /dev/null @@ -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] = [] diff --git a/src/routers/network_analysis.py b/src/routers/network_analysis.py deleted file mode 100644 index 60a72c9..0000000 --- a/src/routers/network_analysis.py +++ /dev/null @@ -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"'}, - ) diff --git a/src/static/css/network-cluster.css b/src/static/css/network-cluster.css deleted file mode 100644 index c7244bc..0000000 --- a/src/static/css/network-cluster.css +++ /dev/null @@ -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; - } -} diff --git a/src/static/css/network.css b/src/static/css/network.css deleted file mode 100644 index 3653df2..0000000 --- a/src/static/css/network.css +++ /dev/null @@ -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; -} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index f1edd0f..6ad0949 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -628,34 +628,6 @@ - -
diff --git a/src/static/js/api_network.js b/src/static/js/api_network.js deleted file mode 100644 index 0ddddc6..0000000 --- a/src/static/js/api_network.js +++ /dev/null @@ -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 }, - }); -}; diff --git a/src/static/js/app.js b/src/static/js/app.js index 1ebd72c..3fc38f8 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -546,14 +546,11 @@ const App = { // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen) document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('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) await this.loadIncidents(); // Netzwerkanalysen laden - await this.loadNetworkAnalyses(); // Notification-Center initialisieren 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_error', (msg) => this.handleRefreshError(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 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 setTimeout(() => UI.retryPendingMap(), 2000); }, @@ -694,10 +677,6 @@ const App = { document.getElementById('empty-state').style.display = 'none'; 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 // bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind diff --git a/src/static/js/app_network.js b/src/static/js/app_network.js deleted file mode 100644 index 3c70a92..0000000 --- a/src/static/js/app_network.js +++ /dev/null @@ -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 = '
Keine Netzwerkanalysen
'; - 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 ''; - }).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 = '
Fehler bei der Generierung. Versuche es erneut.
'; - } - } 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 ''; - }).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 = '
Lade Lagen...
'; - - 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 ''; - }).join(''); - } - } catch (e) { - if (list) list.innerHTML = '
Fehler beim Laden der Lagen
'; - } - - // 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 = '
Fehler: ' + _escHtml(msg.error || 'Unbekannter Fehler') + '
'; - } - - 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 = '
Fehler bei der Generierung. Versuche es erneut.
'; - } - } 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(); - }; -})(); diff --git a/src/static/js/cluster-data.js b/src/static/js/cluster-data.js deleted file mode 100644 index c5ec7cf..0000000 --- a/src/static/js/cluster-data.js +++ /dev/null @@ -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); - } -}; diff --git a/src/static/js/network-cluster.js b/src/static/js/network-cluster.js deleted file mode 100644 index 403a272..0000000 --- a/src/static/js/network-cluster.js +++ /dev/null @@ -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 = ['' + self._esc(d.source) + ' \u2194 ' + self._esc(d.target) + '']; - lines.push('' + d.count + ' Beziehungen'); - 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('\u25CF ' + - (self._categoryLabels[c] || c) + ': ' + d.categories[c]); - } - self._showTooltip(event, lines.join('
')); - }) - .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 = ['' + self._esc(d.name) + '']; - 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('
'); - 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('\u25CF ' + - self._esc(te.name)); - } - } - self._showTooltip(event, lines.join('
')); - }); - 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 = ['' + self._esc(d.name) + '']; - lines.push(self._typeLabels[d.entity_type] || d.entity_type); - if (d.description) { - lines.push('' + - self._esc(d.description.length > 100 ? d.description.slice(0, 97) + '...' : d.description) + - ''); - } - lines.push('Verbindungen: ' + d._connections); - self._showTooltip(event, lines.join('
')); - }); - 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 += '
'; - html += ''; - html += '
'; - - // Category filter - html += '
'; - html += '
Beziehungsfilter
'; - html += '
'; - 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 += ''; - } - html += '
'; - - // Summary - html += '
'; - html += '

' + - countries.length + ' Akteure

'; - var unassigned = this._clusterData.countries.find(function(c) { return c.isUnassigned; }); - if (unassigned && unassigned.entityCount > 0) { - html += '
' + - unassigned.entityCount + ' ohne Zuordnung
'; - } - html += '
'; - - // Top edges - var topEdges = this._clusterData.edges.slice(0, 6); - if (topEdges.length > 0) { - html += '
'; - html += '
Top-Beziehungen
'; - for (var ei = 0; ei < topEdges.length; ei++) { - var edge = topEdges[ei]; - var eColor = self._categoryColors[edge.dominantCategory] || '#6B7280'; - html += '
'; - html += '\u25CF'; - html += '' + - self._esc(edge.source) + ' \u2194 ' + self._esc(edge.target) + ''; - html += '' + edge.count + ''; - html += '
'; - } - html += '
'; - } - - // Country list - html += '
Akteure
'; - for (var ci = 0; ci < countries.length; ci++) { - var c = countries[ci]; - html += '
'; - html += '' + self._esc(c.name) + ''; - html += '' + c.entityCount + ''; - html += '
'; - } - - 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 += '

' + - self._esc(countryData.name) + '

'; - html2 += '
' + - countryData.entityCount + ' Entitäten
'; - - var tc = countryData.typeCounts; - var types = ['person', 'organisation', 'military', 'event', 'location']; - html2 += '
'; - 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 += '
'; - html2 += ''; - html2 += '' + (self._typeLabels[t] || t) + ''; - html2 += '' + cnt + ''; - html2 += '
'; - } - html2 += '
'; - - // Top entities - if (countryData.topEntities && countryData.topEntities.length > 0) { - html2 += '
Top-Akteure
'; - for (var tei = 0; tei < countryData.topEntities.length; tei++) { - var te = countryData.topEntities[tei]; - var teColor = self._entityTypeColors[te.entity_type] || '#94a3b8'; - html2 += '
'; - html2 += '\u25CF '; - html2 += '' + self._esc(te.name) + ''; - html2 += '
'; - } - } - } - - html2 += '
Klicke auf einen Knoten für Details.
'; - 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 = '

' + this._esc(entity.name) + '

'; - html += '' + this._esc(entity.entity_type) + ''; - if (entity.description) html += '

' + this._esc(entity.description) + '

'; - html += '
Verbindungen: ' + (entity._connections || 0) + '
'; - 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); - } -}; diff --git a/src/static/js/network-graph.js b/src/static/js/network-graph.js deleted file mode 100644 index b14a7c5..0000000 --- a/src/static/js/network-graph.js +++ /dev/null @@ -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, // 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('' + this._esc(d.label) + ''); - if (d.description) lines.push(this._esc(d.description)); - lines.push('Kategorie: ' + this._esc(d.category) + ' | Gewicht: ' + d.weight); - this._showTooltip(event, lines.join('
')); - }) - .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, '' + this._esc(d.name) + '
' + - 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 += '
'; - html += '

' + this._esc(entity.name) + '

'; - html += '' + - this._esc(entity.entity_type) + ''; - if (entity.corrected_by_opus) { - html += ' Corrected by Opus'; - } - html += '
'; - - // Description - if (entity.description) { - html += '

' + - this._esc(entity.description) + '

'; - } - - // Aliases - if (entity.aliases && entity.aliases.length > 0) { - html += '
'; - html += 'Aliase:
'; - entity.aliases.forEach(a => { - html += '' + - this._esc(a) + ''; - }); - html += '
'; - } - - // Mention count - html += '
'; - html += 'Erw\u00e4hnungen: ' + - (entity.mention_count || 0) + ''; - html += '
'; - - // 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 += '
'; - html += 'Verbindungen (' + connected.length + '):'; - - Object.keys(grouped).sort().forEach(cat => { - const catColor = this._colorMap.edge[cat] || this._colorMap.edge.neutral; - const catLabel = categoryLabels[cat] || cat; - html += '
'; - html += '' + - this._esc(catLabel) + ''; - 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 += '
'; - html += direction + ' ' + this._esc(otherName) + ''; - if (r.label) html += ' — ' + this._esc(r.label); - html += ' (G:' + r.weight + ')'; - html += '
'; - }); - html += '
'; - }); - - html += '
'; - } - - 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 = '

Klicke auf einen Knoten, um Details anzuzeigen.

'; - } - }, - - // ---- 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; - }, -};