Neues Feature zur Visualisierung von Entitäten und Beziehungen aus ausgewählten Lagen als interaktiver d3.js-Netzwerkgraph. - Haiku extrahiert Entitäten (Person, Organisation, Ort, Ereignis, Militär) - Opus analysiert Beziehungen und korrigiert Haiku-Fehler - 6 neue DB-Tabellen (network_analyses, _entities, _relations, etc.) - REST-API: CRUD + Generierung + Export (JSON/CSV) - d3.js Force-Directed Graph mit Zoom, Filter, Suche, Export - WebSocket-Events für Live-Progress während Generierung - Sidebar-Integration mit Netzwerkanalysen-Sektion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
832 Zeilen
30 KiB
JavaScript
832 Zeilen
30 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
|
|
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 += ' — ' + 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;
|
|
},
|
|
};
|