';
- }
-
- 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 += '