Cleanup: Alle Netzwerkanalyse-Reste vollständig entfernt

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

Datei anzeigen

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

Datei anzeigen

@@ -546,14 +546,11 @@ const App = {
// Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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