/** * AegisSight OSINT Monitor - Network Graph Visualization * * Force-directed graph powered by d3.js v7. * Expects d3 to be loaded globally from CDN before this script runs. * * Usage: * NetworkGraph.init('network-graph-area', data); * NetworkGraph.filterByType(new Set(['person', 'organisation'])); * NetworkGraph.search('Russland'); * NetworkGraph.destroy(); */ /* global d3 */ const NetworkGraph = { // ---- internal state ------------------------------------------------------- _svg: null, _simulation: null, _data: null, // raw data as received _filtered: null, // currently visible subset _container: null, // inside SVG that receives zoom transforms _zoom: null, _selectedNode: null, _tooltip: null, _filters: { types: new Set(), // empty = all visible minWeight: 1, searchTerm: '', }, _colorMap: { node: { person: '#60A5FA', organisation: '#C084FC', location: '#34D399', event: '#FBBF24', military: '#F87171', }, edge: { alliance: '#34D399', conflict: '#EF4444', diplomacy: '#FBBF24', economic: '#60A5FA', legal: '#C084FC', neutral: '#6B7280', }, }, // ---- public API ----------------------------------------------------------- /** * Initialise the graph inside the given container element. * @param {string} containerId – DOM id of the wrapper element * @param {object} data – { entities: [], relations: [] } */ init(containerId, data) { this.destroy(); const wrapper = document.getElementById(containerId); if (!wrapper) { console.error('[NetworkGraph] Container #' + containerId + ' not found.'); return; } this._data = this._prepareData(data); this._filters = { types: new Set(), minWeight: 1, searchTerm: '' }; this._selectedNode = null; const rect = wrapper.getBoundingClientRect(); const width = rect.width || 960; const height = rect.height || 640; // SVG this._svg = d3.select(wrapper) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', [0, 0, width, height].join(' ')) .attr('preserveAspectRatio', 'xMidYMid meet') .style('background', 'transparent'); // Defs: arrow markers per category this._createMarkers(); // Defs: glow filter for top-connected nodes this._createGlowFilter(); // Zoom container this._container = this._svg.append('g').attr('class', 'ng-zoom-layer'); // Zoom behaviour this._zoom = d3.zoom() .scaleExtent([0.1, 8]) .on('zoom', (event) => { this._container.attr('transform', event.transform); }); this._svg.call(this._zoom); // Double-click resets zoom this._svg.on('dblclick.zoom', null); this._svg.on('dblclick', () => this.resetView()); // Tooltip this._tooltip = d3.select(wrapper) .append('div') .attr('class', 'ng-tooltip') .style('position', 'absolute') .style('pointer-events', 'none') .style('background', 'rgba(15,23,42,0.92)') .style('color', '#e2e8f0') .style('border', '1px solid #334155') .style('border-radius', '6px') .style('padding', '6px 10px') .style('font-size', '12px') .style('max-width', '260px') .style('z-index', '1000') .style('display', 'none'); // Simulation this._simulation = d3.forceSimulation() .force('link', d3.forceLink().id(d => d.id).distance(d => { // Inverse weight: higher weight -> closer return Math.max(40, 200 - d.weight * 25); })) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collide', d3.forceCollide().radius(d => d._radius + 6)) .alphaDecay(0.02); this.render(); }, /** * Tear down the graph completely. */ destroy() { if (this._simulation) { this._simulation.stop(); this._simulation = null; } if (this._svg) { this._svg.remove(); this._svg = null; } if (this._tooltip) { this._tooltip.remove(); this._tooltip = null; } this._container = null; this._data = null; this._filtered = null; this._selectedNode = null; }, /** * Full re-render based on current filters. */ render() { if (!this._data || !this._container) return; this._applyFilters(); const nodes = this._filtered.entities; const links = this._filtered.relations; // Clear previous drawing this._container.selectAll('*').remove(); // Determine top-5 most connected node IDs const connectionCounts = {}; this._data.relations.forEach(r => { connectionCounts[r.source_entity_id] = (connectionCounts[r.source_entity_id] || 0) + 1; connectionCounts[r.target_entity_id] = (connectionCounts[r.target_entity_id] || 0) + 1; }); const top5Ids = new Set( Object.entries(connectionCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(e => e[0]) ); // Radius scale (sqrt of connection count) const maxConn = Math.max(1, ...Object.values(connectionCounts)); const rScale = d3.scaleSqrt().domain([0, maxConn]).range([8, 40]); nodes.forEach(n => { n._connections = connectionCounts[n.id] || 0; n._radius = rScale(n._connections); n._isTop5 = top5Ids.has(n.id); }); // ---- edges ------------------------------------------------------------ const linkGroup = this._container.append('g').attr('class', 'ng-links'); const linkSel = linkGroup.selectAll('line') .data(links, d => d.id) .join('line') .attr('stroke', d => this._colorMap.edge[d.category] || this._colorMap.edge.neutral) .attr('stroke-width', d => Math.max(1, d.weight * 0.8)) .attr('stroke-opacity', d => Math.min(1, 0.3 + d.weight * 0.14)) .attr('marker-end', d => 'url(#ng-arrow-' + (d.category || 'neutral') + ')') .style('cursor', 'pointer') .on('mouseover', (event, d) => { const lines = []; if (d.label) lines.push('' + this._esc(d.label) + ''); if (d.description) lines.push(this._esc(d.description)); lines.push('Kategorie: ' + this._esc(d.category) + ' | Gewicht: ' + d.weight); this._showTooltip(event, lines.join('
')); }) .on('mousemove', (event) => this._moveTooltip(event)) .on('mouseout', () => this._hideTooltip()); // ---- nodes ------------------------------------------------------------ const nodeGroup = this._container.append('g').attr('class', 'ng-nodes'); const nodeSel = nodeGroup.selectAll('g') .data(nodes, d => d.id) .join('g') .attr('class', 'ng-node') .style('cursor', 'pointer') .call(this._drag(this._simulation)) .on('mouseover', (event, d) => { this._showTooltip(event, '' + this._esc(d.name) + '
' + this._esc(d.entity_type) + ' | Verbindungen: ' + d._connections); }) .on('mousemove', (event) => this._moveTooltip(event)) .on('mouseout', () => this._hideTooltip()) .on('click', (event, d) => { event.stopPropagation(); this._onNodeClick(d, linkSel, nodeSel); }); // Circle nodeSel.append('circle') .attr('r', d => d._radius) .attr('fill', d => this._colorMap.node[d.entity_type] || '#94A3B8') .attr('stroke', '#0f172a') .attr('stroke-width', 1.5) .attr('filter', d => d._isTop5 ? 'url(#ng-glow)' : null); // Label nodeSel.append('text') .text(d => d.name.length > 15 ? d.name.slice(0, 14) + '\u2026' : d.name) .attr('dy', d => d._radius + 14) .attr('text-anchor', 'middle') .attr('fill', '#cbd5e1') .attr('font-size', '10px') .attr('pointer-events', 'none'); // ---- simulation ------------------------------------------------------- // Build link data with object references (d3 expects id strings or objects) const simNodes = nodes; const simLinks = links.map(l => ({ ...l, source: typeof l.source === 'object' ? l.source.id : l.source_entity_id, target: typeof l.target === 'object' ? l.target.id : l.target_entity_id, })); this._simulation.nodes(simNodes); this._simulation.force('link').links(simLinks); this._simulation.force('collide').radius(d => d._radius + 6); this._simulation.alpha(1).restart(); this._simulation.on('tick', () => { linkSel .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => { // Shorten line so arrow doesn't overlap circle const target = d.target; const dx = target.x - d.source.x; const dy = target.y - d.source.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; return target.x - (dx / dist) * (target._radius + 4); }) .attr('y2', d => { const target = d.target; const dx = target.x - d.source.x; const dy = target.y - d.source.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; return target.y - (dy / dist) * (target._radius + 4); }); nodeSel.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); }); // Click on background to deselect this._svg.on('click', () => { this._selectedNode = null; nodeSel.select('circle').attr('stroke', '#0f172a').attr('stroke-width', 1.5); linkSel.attr('stroke-opacity', d => Math.min(1, 0.3 + d.weight * 0.14)); this._clearDetailPanel(); }); // Apply search highlight if active if (this._filters.searchTerm) { this._applySearchHighlight(nodeSel); } }, // ---- filtering ------------------------------------------------------------ /** * Compute the visible subset from raw data + current filters. */ _applyFilters() { let entities = this._data.entities.slice(); let relations = this._data.relations.slice(); // Type filter if (this._filters.types.size > 0) { const allowed = this._filters.types; entities = entities.filter(e => allowed.has(e.entity_type)); const visibleIds = new Set(entities.map(e => e.id)); relations = relations.filter(r => visibleIds.has(r.source_entity_id) && visibleIds.has(r.target_entity_id) ); } // Weight filter if (this._filters.minWeight > 1) { relations = relations.filter(r => r.weight >= this._filters.minWeight); } // Cluster isolation if (this._filters._isolateId) { const centerId = this._filters._isolateId; const connectedIds = new Set([centerId]); relations.forEach(r => { if (r.source_entity_id === centerId) connectedIds.add(r.target_entity_id); if (r.target_entity_id === centerId) connectedIds.add(r.source_entity_id); }); entities = entities.filter(e => connectedIds.has(e.id)); relations = relations.filter(r => connectedIds.has(r.source_entity_id) && connectedIds.has(r.target_entity_id) ); } this._filtered = { entities, relations }; }, /** * Populate the detail panel (#network-detail-panel) with entity info. * @param {object} entity */ _updateDetailPanel(entity) { const panel = document.getElementById('network-detail-panel'); if (!panel) return; const typeColor = this._colorMap.node[entity.entity_type] || '#94A3B8'; // Connected relations const connected = this._data.relations.filter( r => r.source_entity_id === entity.id || r.target_entity_id === entity.id ); // Group by category const grouped = {}; connected.forEach(r => { const cat = r.category || 'neutral'; if (!grouped[cat]) grouped[cat] = []; // Determine the "other" entity const otherId = r.source_entity_id === entity.id ? r.target_entity_id : r.source_entity_id; const other = this._data.entities.find(e => e.id === otherId); grouped[cat].push({ relation: r, other }); }); let html = ''; // Header html += '
'; html += '

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

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

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

'; } // Aliases if (entity.aliases && entity.aliases.length > 0) { html += '
'; html += 'Aliase:
'; entity.aliases.forEach(a => { html += '' + this._esc(a) + ''; }); html += '
'; } // Mention count html += '
'; html += 'Erw\u00e4hnungen: ' + (entity.mention_count || 0) + ''; html += '
'; // Relations grouped by category const categoryLabels = { alliance: 'Allianz', conflict: 'Konflikt', diplomacy: 'Diplomatie', economic: '\u00d6konomie', legal: 'Recht', neutral: 'Neutral', }; if (Object.keys(grouped).length > 0) { html += '
'; html += 'Verbindungen (' + connected.length + '):'; Object.keys(grouped).sort().forEach(cat => { const catColor = this._colorMap.edge[cat] || this._colorMap.edge.neutral; const catLabel = categoryLabels[cat] || cat; html += '
'; html += '' + this._esc(catLabel) + ''; grouped[cat].forEach(item => { const r = item.relation; const otherName = item.other ? item.other.name : '?'; const direction = r.source_entity_id === entity.id ? '\u2192' : '\u2190'; html += '
'; html += direction + ' ' + this._esc(otherName) + ''; if (r.label) html += ' — ' + this._esc(r.label); html += ' (G:' + r.weight + ')'; html += '
'; }); html += '
'; }); html += '
'; } panel.innerHTML = html; panel.style.display = 'block'; }, /** * Filter nodes by entity type. * @param {Set|Array} types – entity_type values to show. Empty = all. */ filterByType(types) { this._filters.types = types instanceof Set ? types : new Set(types); this._filters._isolateId = null; this.render(); }, /** * Filter edges by minimum weight. * @param {number} minWeight */ filterByWeight(minWeight) { this._filters.minWeight = minWeight; this.render(); }, /** * Highlight nodes matching the search term (name, aliases, description). * @param {string} term */ search(term) { this._filters.searchTerm = (term || '').trim().toLowerCase(); this.render(); }, /** * Show only the 1-hop neighbourhood of the given entity. * @param {string} entityId */ isolateCluster(entityId) { this._filters._isolateId = entityId; this.render(); }, /** * Reset zoom, filters and selection to initial state. */ resetView() { this._filters = { types: new Set(), minWeight: 1, searchTerm: '' }; this._selectedNode = null; this._clearDetailPanel(); if (this._svg && this._zoom) { this._svg.transition().duration(500).call( this._zoom.transform, d3.zoomIdentity ); } this.render(); }, // ---- export --------------------------------------------------------------- /** * Export the current graph as a PNG image. */ exportPNG() { if (!this._svg) return; const svgNode = this._svg.node(); const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(svgNode); const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const img = new Image(); img.onload = function () { const canvas = document.createElement('canvas'); const bbox = svgNode.getBoundingClientRect(); canvas.width = bbox.width * 2; // 2x for retina canvas.height = bbox.height * 2; const ctx = canvas.getContext('2d'); ctx.scale(2, 2); ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, bbox.width, bbox.height); ctx.drawImage(img, 0, 0, bbox.width, bbox.height); URL.revokeObjectURL(url); canvas.toBlob(function (blob) { if (!blob) return; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'aegis-network-' + Date.now() + '.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 'image/png'); }; img.src = url; }, /** * Export the current relations as CSV. */ exportCSV() { if (!this._data) return; const entityMap = {}; this._data.entities.forEach(e => { entityMap[e.id] = e.name; }); const rows = [['source', 'target', 'category', 'label', 'weight', 'description'].join(',')]; this._data.relations.forEach(r => { rows.push([ this._csvField(entityMap[r.source_entity_id] || r.source_entity_id), this._csvField(entityMap[r.target_entity_id] || r.target_entity_id), this._csvField(r.category), this._csvField(r.label), r.weight, this._csvField(r.description || ''), ].join(',')); }); const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'aegis-network-' + Date.now() + '.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); }, /** * Export the full data as JSON. */ exportJSON() { if (!this._data) return; const exportData = { entities: this._data.entities.map(e => ({ id: e.id, name: e.name, name_normalized: e.name_normalized, entity_type: e.entity_type, description: e.description, aliases: e.aliases, mention_count: e.mention_count, corrected_by_opus: e.corrected_by_opus, metadata: e.metadata, })), relations: this._data.relations.map(r => ({ id: r.id, source_entity_id: r.source_entity_id, target_entity_id: r.target_entity_id, category: r.category, label: r.label, description: r.description, weight: r.weight, status: r.status, evidence: r.evidence, })), }; const blob = new Blob( [JSON.stringify(exportData, null, 2)], { type: 'application/json;charset=utf-8' } ); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'aegis-network-' + Date.now() + '.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); }, // ---- internal helpers ----------------------------------------------------- /** * Prepare / clone data so we do not mutate the original. */ _prepareData(raw) { return { entities: (raw.entities || []).map(e => ({ ...e })), relations: (raw.relations || []).map(r => ({ ...r })), }; }, /** * Create SVG arrow markers for each edge category. */ _createMarkers() { const defs = this._svg.append('defs'); const categories = Object.keys(this._colorMap.edge); categories.forEach(cat => { defs.append('marker') .attr('id', 'ng-arrow-' + cat) .attr('viewBox', '0 -5 10 10') .attr('refX', 10) .attr('refY', 0) .attr('markerWidth', 8) .attr('markerHeight', 8) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-4L10,0L0,4') .attr('fill', this._colorMap.edge[cat]); }); }, /** * Create SVG glow filter for top-5 nodes. */ _createGlowFilter() { const defs = this._svg.select('defs'); const filter = defs.append('filter') .attr('id', 'ng-glow') .attr('x', '-50%') .attr('y', '-50%') .attr('width', '200%') .attr('height', '200%'); filter.append('feGaussianBlur') .attr('in', 'SourceGraphic') .attr('stdDeviation', 4) .attr('result', 'blur'); filter.append('feColorMatrix') .attr('in', 'blur') .attr('type', 'matrix') .attr('values', '0 0 0 0 0.98 0 0 0 0 0.75 0 0 0 0 0.14 0 0 0 0.7 0') .attr('result', 'glow'); const merge = filter.append('feMerge'); merge.append('feMergeNode').attr('in', 'glow'); merge.append('feMergeNode').attr('in', 'SourceGraphic'); }, /** * d3 drag behaviour. */ _drag(simulation) { function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } return d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended); }, /** * Handle node click – highlight edges, show detail panel. */ _onNodeClick(d, linkSel, nodeSel) { this._selectedNode = d; // Highlight selected node nodeSel.select('circle') .attr('stroke', n => n.id === d.id ? '#FBBF24' : '#0f172a') .attr('stroke-width', n => n.id === d.id ? 3 : 1.5); // Highlight connected edges const connectedNodeIds = new Set([d.id]); linkSel.each(function (l) { const srcId = typeof l.source === 'object' ? l.source.id : l.source; const tgtId = typeof l.target === 'object' ? l.target.id : l.target; if (srcId === d.id || tgtId === d.id) { connectedNodeIds.add(srcId); connectedNodeIds.add(tgtId); } }); linkSel.attr('stroke-opacity', l => { const srcId = typeof l.source === 'object' ? l.source.id : l.source; const tgtId = typeof l.target === 'object' ? l.target.id : l.target; if (srcId === d.id || tgtId === d.id) { return Math.min(1, 0.3 + l.weight * 0.14) + 0.3; } return 0.08; }); nodeSel.select('circle').attr('opacity', n => connectedNodeIds.has(n.id) ? 1 : 0.25 ); nodeSel.select('text').attr('opacity', n => connectedNodeIds.has(n.id) ? 1 : 0.2 ); // Detail panel const entity = this._data.entities.find(e => e.id === d.id); if (entity) { this._updateDetailPanel(entity); } }, /** * Apply search highlighting (glow matching, dim rest). */ _applySearchHighlight(nodeSel) { const term = this._filters.searchTerm; if (!term) return; nodeSel.each(function (d) { const matches = NetworkGraph._matchesSearch(d, term); d3.select(this).select('circle') .attr('opacity', matches ? 1 : 0.15) .attr('filter', matches ? 'url(#ng-glow)' : null); d3.select(this).select('text') .attr('opacity', matches ? 1 : 0.1); }); }, /** * Check if entity matches the search term. */ _matchesSearch(entity, term) { if (!term) return true; if (entity.name && entity.name.toLowerCase().includes(term)) return true; if (entity.name_normalized && entity.name_normalized.toLowerCase().includes(term)) return true; if (entity.description && entity.description.toLowerCase().includes(term)) return true; if (entity.aliases) { for (let i = 0; i < entity.aliases.length; i++) { if (entity.aliases[i].toLowerCase().includes(term)) return true; } } return false; }, /** * Clear the detail panel. */ _clearDetailPanel() { const panel = document.getElementById('network-detail-panel'); if (panel) { panel.innerHTML = '

Klicke auf einen Knoten, um Details anzuzeigen.

'; } }, // ---- tooltip helpers ------------------------------------------------------ _showTooltip(event, html) { if (!this._tooltip) return; this._tooltip .style('display', 'block') .html(html); this._moveTooltip(event); }, _moveTooltip(event) { if (!this._tooltip) return; this._tooltip .style('left', (event.offsetX + 14) + 'px') .style('top', (event.offsetY - 10) + 'px'); }, _hideTooltip() { if (!this._tooltip) return; this._tooltip.style('display', 'none'); }, // ---- string helpers ------------------------------------------------------- _esc(str) { if (!str) return ''; const div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; }, _csvField(val) { const s = String(val == null ? '' : val); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; }, };