/**
* Graph visualization module for DNSRecon
* Handles network graph rendering using vis.js with proper large entity node hiding
*/
const contextMenuCSS = `
.graph-context-menu {
position: fixed;
z-index: 1000;
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 8px 25px rgba(0,0,0,0.6);
display: none;
font-family: 'Roboto Mono', monospace;
font-size: 0.9rem;
color: #c7c7c7;
min-width: 180px;
overflow: hidden;
}
.graph-context-menu ul {
list-style: none;
padding: 0.5rem 0;
margin: 0;
}
.graph-context-menu ul li {
padding: 0.75rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.graph-context-menu ul li:hover {
background: linear-gradient(135deg, #3a3a3a 0%, #2e2e2e 100%);
color: #00ff41;
}
.graph-context-menu .menu-icon {
font-size: 0.9rem;
width: 1.2rem;
text-align: center;
}
.graph-context-menu ul li:first-child {
border-top: none;
}
.graph-context-menu ul li:last-child {
border-bottom: none;
}
`;
class GraphManager {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.network = null;
this.nodes = new vis.DataSet();
this.edges = new vis.DataSet();
this.isInitialized = false;
this.currentLayout = 'physics';
this.nodeInfoPopup = null;
this.contextMenu = null;
this.history = [];
this.filterPanel = null;
this.trueRootIds = new Set();
// Track large entity members for proper hiding
this.largeEntityMembers = new Set();
this.options = {
nodes: {
shape: 'dot',
size: 15,
font: {
size: 12,
color: '#c7c7c7',
face: 'Roboto Mono, monospace',
background: 'rgba(26, 26, 26, 0.9)',
strokeWidth: 2,
strokeColor: '#000000'
},
borderWidth: 2,
borderColor: '#444',
scaling: {
min: 10,
max: 30,
label: {
enabled: true,
min: 8,
max: 16
}
},
chosen: {
node: (values, id, selected, hovering) => {
values.borderColor = '#00ff41';
values.borderWidth = 3;
}
}
},
edges: {
width: 2,
color: {
color: '#555',
highlight: '#00ff41',
hover: '#ff9900',
inherit: false
},
font: {
size: 10,
color: '#999',
face: 'Roboto Mono, monospace',
background: 'rgba(26, 26, 26, 0.8)',
strokeWidth: 1,
strokeColor: '#000000'
},
arrows: {
to: {
enabled: true,
scaleFactor: 1,
type: 'arrow'
}
},
smooth: {
enabled: true,
type: 'dynamic',
roundness: 0.6
},
chosen: {
edge: (values, id, selected, hovering) => {
values.color = '#00ff41';
values.width = 4;
}
}
},
physics: {
enabled: true,
stabilization: {
enabled: true,
iterations: 150,
updateInterval: 50
},
barnesHut: {
gravitationalConstant: -3000,
centralGravity: 0.4,
springLength: 120,
springConstant: 0.05,
damping: 0.1,
avoidOverlap: 0.2
},
maxVelocity: 30,
minVelocity: 0.1,
solver: 'barnesHut',
timestep: 0.4,
adaptiveTimestep: true
},
interaction: {
hover: true,
hoverConnectedEdges: true,
selectConnectedEdges: true,
tooltipDelay: 300,
hideEdgesOnDrag: false,
hideNodesOnDrag: false,
zoomView: true,
dragView: true,
multiselect: true
},
layout: {
improvedLayout: true,
randomSeed: 2
}
};
if (typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = contextMenuCSS;
document.head.appendChild(style);
}
this.createNodeInfoPopup();
this.createContextMenu();
document.body.addEventListener('click', () => this.hideContextMenu());
}
/**
* Create floating node info popup
*/
createNodeInfoPopup() {
this.nodeInfoPopup = document.createElement('div');
this.nodeInfoPopup.className = 'node-info-popup';
this.nodeInfoPopup.style.display = 'none';
document.body.appendChild(this.nodeInfoPopup);
}
/**
* Create context menu
*/
createContextMenu() {
// Remove existing context menu if it exists
const existing = document.getElementById('graph-context-menu');
if (existing) {
existing.remove();
}
this.contextMenu = document.createElement('div');
this.contextMenu.id = 'graph-context-menu';
this.contextMenu.className = 'graph-context-menu';
this.contextMenu.style.display = 'none';
// Prevent body click listener from firing when clicking the menu itself
this.contextMenu.addEventListener('click', (event) => {
event.stopPropagation();
});
document.body.appendChild(this.contextMenu);
console.log('Context menu created and added to body');
}
/**
* Initialize the network graph
*/
initialize() {
if (this.isInitialized) {
return;
}
try {
const data = {
nodes: this.nodes,
edges: this.edges
};
this.network = new vis.Network(this.container, data, this.options);
this.setupNetworkEvents();
this.isInitialized = true;
// Hide placeholder
const placeholder = this.container.querySelector('.graph-placeholder');
if (placeholder) {
placeholder.style.display = 'none';
}
// Add graph controls
this.addGraphControls();
this.addFilterPanel();
console.log('Graph initialized successfully');
} catch (error) {
console.error('Failed to initialize graph:', error);
this.showError('Failed to initialize visualization');
}
}
/**
* Add interactive graph controls
*/
addGraphControls() {
const controlsContainer = document.createElement('div');
controlsContainer.className = 'graph-controls';
controlsContainer.innerHTML = `
`;
this.container.appendChild(controlsContainer);
// Add control event listeners
document.getElementById('graph-fit').addEventListener('click', () => this.fitView());
document.getElementById('graph-physics').addEventListener('click', () => this.togglePhysics());
document.getElementById('graph-cluster').addEventListener('click', () => this.toggleClustering());
document.getElementById('graph-unhide').addEventListener('click', () => this.unhideAll());
document.getElementById('graph-revert').addEventListener('click', () => this.revertLastAction());
}
addFilterPanel() {
this.filterPanel = document.createElement('div');
this.filterPanel.className = 'graph-filter-panel';
this.container.appendChild(this.filterPanel);
}
/**
* Setup network event handlers
*/
setupNetworkEvents() {
if (!this.network) return;
// FIXED: Right-click context menu
this.container.addEventListener('contextmenu', (event) => {
event.preventDefault();
console.log('Right-click detected at:', event.offsetX, event.offsetY);
// Get coordinates relative to the canvas
const pointer = {
x: event.offsetX,
y: event.offsetY
};
const nodeId = this.network.getNodeAt(pointer);
console.log('Node at pointer:', nodeId);
if (nodeId) {
// Pass the original client event for positioning
this.showContextMenu(nodeId, event);
} else {
this.hideContextMenu();
}
});
// Node click event with details
this.network.on('click', (params) => {
this.hideContextMenu();
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
if (this.network.isCluster(nodeId)) {
this.network.openCluster(nodeId);
} else {
const node = this.nodes.get(nodeId);
if (node) {
this.showNodeDetails(node);
this.highlightNodeConnections(nodeId);
}
}
} else {
this.clearHighlights();
}
});
// Hover events
this.network.on('hoverNode', (params) => {
const nodeId = params.node;
const node = this.nodes.get(nodeId);
if (node) {
this.highlightConnectedNodes(nodeId, true);
}
});
// Stabilization events with progress
this.network.on('stabilizationProgress', (params) => {
const progress = params.iterations / params.total;
this.updateStabilizationProgress(progress);
});
this.network.on('stabilizationIterationsDone', () => {
this.onStabilizationComplete();
});
// Selection events
this.network.on('select', (params) => {
console.log('Selected nodes:', params.nodes);
console.log('Selected edges:', params.edges);
});
// Click away to hide context menu
document.addEventListener('click', (e) => {
if (!this.contextMenu.contains(e.target)) {
this.hideContextMenu();
}
});
}
/**
* @param {Object} graphData - Graph data from backend
*/
updateGraph(graphData) {
if (!graphData || !graphData.nodes || !graphData.edges) {
console.warn('Invalid graph data received');
return;
}
try {
// Initialize if not already done
if (!this.isInitialized) {
this.initialize();
}
this.largeEntityMembers.clear();
const largeEntityMap = new Map();
graphData.nodes.forEach(node => {
if (node.type === 'large_entity' && node.attributes && Array.isArray(node.attributes.nodes)) {
node.attributes.nodes.forEach(nodeId => {
largeEntityMap.set(nodeId, node.id);
this.largeEntityMembers.add(nodeId);
});
}
});
const filteredNodes = graphData.nodes.filter(node => {
// Only include nodes that are NOT members of large entities
return !this.largeEntityMembers.has(node.id);
});
console.log(`Filtered ${graphData.nodes.length - filteredNodes.length} large entity member nodes from visualization`);
// Process only the filtered nodes
const processedNodes = filteredNodes.map(node => {
return this.processNode(node);
});
const mergedEdges = {};
graphData.edges.forEach(edge => {
const fromNode = largeEntityMap.has(edge.from) ? largeEntityMap.get(edge.from) : edge.from;
const toNode = largeEntityMap.has(edge.to) ? largeEntityMap.get(edge.to) : edge.to;
const mergeKey = `${fromNode}-${toNode}-${edge.label}`;
if (!mergedEdges[mergeKey]) {
mergedEdges[mergeKey] = {
...edge,
from: fromNode,
to: toNode,
count: 0,
confidence_score: 0
};
}
mergedEdges[mergeKey].count++;
if (edge.confidence_score > mergedEdges[mergeKey].confidence_score) {
mergedEdges[mergeKey].confidence_score = edge.confidence_score;
}
});
const processedEdges = Object.values(mergedEdges).map(edge => {
const processed = this.processEdge(edge);
if (edge.count > 1) {
processed.label = `${edge.label} (${edge.count})`;
}
return processed;
});
// Update datasets with animation
const existingNodeIds = this.nodes.getIds();
const existingEdgeIds = this.edges.getIds();
// Add new nodes with fade-in animation
const newNodes = processedNodes.filter(node => !existingNodeIds.includes(node.id));
const newEdges = processedEdges.filter(edge => !existingEdgeIds.includes(edge.id));
// Update existing data
this.nodes.update(processedNodes);
this.edges.update(processedEdges);
// After data is loaded, compute roots and apply filters
this.computeTrueRoots();
this.updateFilterControls();
this.applyAllFilters();
// Highlight new additions briefly
if (newNodes.length > 0 || newEdges.length > 0) {
setTimeout(() => this.highlightNewElements(newNodes, newEdges), 100);
}
// Auto-fit view for small graphs or first update
if (processedNodes.length <= 10 || existingNodeIds.length === 0) {
setTimeout(() => this.fitView(), 800);
}
console.log(`Graph updated: ${processedNodes.length} nodes, ${processedEdges.length} edges (${newNodes.length} new nodes, ${newEdges.length} new edges)`);
console.log(`Large entity members hidden: ${this.largeEntityMembers.size}`);
} catch (error) {
console.error('Failed to update graph:', error);
this.showError('Failed to update visualization');
}
}
/**
* Process node data with styling and metadata
* @param {Object} node - Raw node data
* @returns {Object} Processed node data
*/
processNode(node) {
const processedNode = {
id: node.id,
label: this.formatNodeLabel(node.id, node.type),
color: this.getNodeColor(node.type),
size: this.getNodeSize(node.type),
borderColor: this.getNodeBorderColor(node.type),
shape: this.getNodeShape(node.type),
attributes: node.attributes || {},
description: node.description || '',
metadata: node.metadata || {},
type: node.type,
incoming_edges: node.incoming_edges || [],
outgoing_edges: node.outgoing_edges || []
};
// Add confidence-based styling
if (node.confidence) {
processedNode.borderWidth = Math.max(2, Math.floor(node.confidence * 5));
}
// Style based on certificate validity
if (node.type === 'domain') {
if (node.attributes && node.attributes.certificates && node.attributes.certificates.has_valid_cert === false) {
processedNode.color = { background: '#888888', border: '#666666' };
}
}
// Handle merged correlation objects (similar to large entities)
if (node.type === 'correlation_object') {
const metadata = node.metadata || {};
const values = metadata.values || [];
const mergeCount = metadata.merge_count || 1;
if (mergeCount > 1) {
// Display as merged correlation container
processedNode.label = `Correlations (${mergeCount})`;
processedNode.title = `Merged correlation container with ${mergeCount} values: ${values.slice(0, 3).join(', ')}${values.length > 3 ? '...' : ''}`;
processedNode.borderWidth = 3; // Thicker border for merged nodes
} else {
// Single correlation value
const value = Array.isArray(values) && values.length > 0 ? values[0] : (metadata.value || 'Unknown');
const displayValue = typeof value === 'string' && value.length > 20 ? value.substring(0, 17) + '...' : value;
processedNode.label = `${displayValue}`;
processedNode.title = `Correlation: ${value}`;
}
}
return processedNode;
}
/**
* Process edge data with styling and metadata
* @param {Object} edge - Raw edge data
* @returns {Object} Processed edge data
*/
processEdge(edge) {
const confidence = edge.confidence_score || 0;
const processedEdge = {
id: `${edge.from}-${edge.to}`,
from: edge.from,
to: edge.to,
label: this.formatEdgeLabel(edge.label, confidence),
title: this.createEdgeTooltip(edge),
width: this.getEdgeWidth(confidence),
color: this.getEdgeColor(confidence),
dashes: confidence < 0.6 ? [5, 5] : false,
metadata: {
relationship_type: edge.label,
confidence_score: confidence,
source_provider: edge.source_provider,
discovery_timestamp: edge.discovery_timestamp
}
};
return processedEdge;
}
/**
* Format node label for display
* @param {string} nodeId - Node identifier
* @param {string} nodeType - Node type
* @returns {string} Formatted label
*/
formatNodeLabel(nodeId, nodeType) {
if (typeof nodeId !== 'string') return '';
if (nodeId.length > 20) {
return nodeId.substring(0, 17) + '...';
}
return nodeId;
}
/**
* Format edge label for display
* @param {string} relationshipType - Type of relationship
* @param {number} confidence - Confidence score
* @returns {string} Formatted label
*/
formatEdgeLabel(relationshipType, confidence) {
if (!relationshipType) return '';
const confidenceText = confidence >= 0.8 ? 'â' : confidence >= 0.6 ? 'â' : 'â';
return `${relationshipType} ${confidenceText}`;
}
/**
* Get node color based on type
* @param {string} nodeType - Node type
* @returns {string} Color value
*/
getNodeColor(nodeType) {
const colors = {
'domain': '#00ff41', // Green
'ip': '#ff9900', // Amber
'asn': '#00aaff', // Blue
'large_entity': '#ff6b6b', // Red for large entities
'correlation_object': '#9620c0ff'
};
return colors[nodeType] || '#ffffff';
}
/**
* Get node border color based on type
* @param {string} nodeType - Node type
* @returns {string} Border color value
*/
getNodeBorderColor(nodeType) {
const borderColors = {
'domain': '#00aa2e',
'ip': '#cc7700',
'asn': '#0088cc',
'correlation_object': '#c235c9ff'
};
return borderColors[nodeType] || '#666666';
}
/**
* Get node size based on type
* @param {string} nodeType - Node type
* @returns {number} Node size
*/
getNodeSize(nodeType) {
const sizes = {
'domain': 12,
'ip': 14,
'asn': 16,
'correlation_object': 8,
'large_entity': 5
};
return sizes[nodeType] || 12;
}
/**
* Get node shape based on type
* @param {string} nodeType - Node type
* @returns {string} Shape name
*/
getNodeShape(nodeType) {
const shapes = {
'domain': 'dot',
'ip': 'square',
'asn': 'triangle',
'correlation_object': 'hexagon',
'large_entity': 'database'
};
return shapes[nodeType] || 'dot';
}
/**
* Get edge color based on confidence
* @param {number} confidence - Confidence score
* @returns {string} Edge color
*/
getEdgeColor(confidence) {
if (confidence >= 0.8) {
return '#00ff41'; // High confidence - green
} else if (confidence >= 0.6) {
return '#ff9900'; // Medium confidence - amber
} else {
return '#666666'; // Low confidence - gray
}
}
/**
* Get edge width based on confidence
* @param {number} confidence - Confidence score
* @returns {number} Edge width
*/
getEdgeWidth(confidence) {
if (confidence >= 0.8) {
return 3;
} else if (confidence >= 0.6) {
return 2;
} else {
return 1;
}
}
/**
* Create edge tooltip with correct provider information
* @param {Object} edge - Edge data
* @returns {string} HTML tooltip content
*/
createEdgeTooltip(edge) {
let tooltip = `
`;
tooltip += `
${edge.label || 'Relationship'}
`;
tooltip += `
Confidence: ${(edge.confidence_score * 100).toFixed(1)}%
`;
if (edge.source_provider) {
tooltip += `
Provider: ${edge.source_provider}
`;
}
if (edge.discovery_timestamp) {
const date = new Date(edge.discovery_timestamp);
tooltip += `
Discovered: ${date.toLocaleString()}
`;
}
tooltip += `
`;
return tooltip;
}
/**
* Determine if node is important based on connections or metadata
* @param {Object} node - Node data
* @returns {boolean} True if node is important
*/
isImportantNode(node) {
// Mark nodes as important based on criteria
if (node.type === 'domain' && node.id.includes('www.')) return false;
if (node.metadata && node.metadata.connection_count > 3) return true;
if (node.type === 'asn') return true;
return false;
}
/**
* Show node details in modal
* @param {Object} node - Node object
*/
showNodeDetails(node) {
// Trigger custom event for main application to handle
const event = new CustomEvent('nodeSelected', {
detail: { node }
});
document.dispatchEvent(event);
}
/**
* Hide node info popup
*/
hideNodeInfoPopup() {
if (this.nodeInfoPopup) {
this.nodeInfoPopup.style.display = 'none';
}
}
/**
* Highlight node connections
* @param {string} nodeId - Node to highlight
*/
highlightNodeConnections(nodeId) {
const connectedNodes = this.network.getConnectedNodes(nodeId);
const connectedEdges = this.network.getConnectedEdges(nodeId);
// Update node colors
const nodeUpdates = connectedNodes.map(id => ({
id: id,
borderColor: '#ff9900',
borderWidth: 3
}));
nodeUpdates.push({
id: nodeId,
borderColor: '#00ff41',
borderWidth: 4
});
// Update edge colors
const edgeUpdates = connectedEdges.map(id => ({
id: id,
color: { color: '#ff9900' },
width: 3
}));
this.nodes.update(nodeUpdates);
this.edges.update(edgeUpdates);
// Store for cleanup
this.highlightedElements = {
nodes: connectedNodes.concat([nodeId]),
edges: connectedEdges
};
}
/**
* Highlight connected nodes on hover
* @param {string} nodeId - Node ID
* @param {boolean} highlight - Whether to highlight or unhighlight
*/
highlightConnectedNodes(nodeId, highlight) {
const connectedNodes = this.network.getConnectedNodes(nodeId);
const connectedEdges = this.network.getConnectedEdges(nodeId);
if (highlight) {
// Dim all other elements
this.dimUnconnectedElements([nodeId, ...connectedNodes], connectedEdges);
}
}
/**
* Dim elements not connected to the specified nodes
* @param {Array} nodeIds - Node IDs to keep highlighted
* @param {Array} edgeIds - Edge IDs to keep highlighted
*/
dimUnconnectedElements(nodeIds, edgeIds) {
const allNodes = this.nodes.get();
const allEdges = this.edges.get();
const nodeUpdates = allNodes.map(node => ({
id: node.id,
opacity: nodeIds.includes(node.id) ? 1 : 0.3
}));
const edgeUpdates = allEdges.map(edge => ({
id: edge.id,
opacity: edgeIds.includes(edge.id) ? 1 : 0.1
}));
this.nodes.update(nodeUpdates);
this.edges.update(edgeUpdates);
}
/**
* Clear all highlights
*/
clearHighlights() {
if (this.highlightedElements) {
// Reset highlighted nodes
const nodeUpdates = this.highlightedElements.nodes.map(id => {
const originalNode = this.nodes.get(id);
return {
id: id,
borderColor: this.getNodeBorderColor(originalNode.type),
borderWidth: 2
};
});
// Reset highlighted edges
const edgeUpdates = this.highlightedElements.edges.map(id => {
const originalEdge = this.edges.get(id);
return {
id: id,
color: this.getEdgeColor(originalEdge.metadata ? originalEdge.metadata.confidence_score : 0.5),
width: this.getEdgeWidth(originalEdge.metadata ? originalEdge.metadata.confidence_score : 0.5)
};
});
this.nodes.update(nodeUpdates);
this.edges.update(edgeUpdates);
this.highlightedElements = null;
}
}
/**
* Clear hover highlights
*/
clearHoverHighlights() {
const allNodes = this.nodes.get();
const allEdges = this.edges.get();
const nodeUpdates = allNodes.map(node => ({ id: node.id, opacity: 1 }));
const edgeUpdates = allEdges.map(edge => ({ id: edge.id, opacity: 1 }));
this.nodes.update(nodeUpdates);
this.edges.update(edgeUpdates);
}
/**
* Highlight newly added elements
* @param {Array} newNodes - New nodes
* @param {Array} newEdges - New edges
*/
highlightNewElements(newNodes, newEdges) {
// Briefly highlight new nodes
const nodeHighlights = newNodes.map(node => ({
id: node.id,
borderColor: '#00ff41',
borderWidth: 4
}));
// Briefly highlight new edges
const edgeHighlights = newEdges.map(edge => ({
id: edge.id,
color: '#00ff41',
width: 4
}));
this.nodes.update(nodeHighlights);
this.edges.update(edgeHighlights);
// Reset after animation
setTimeout(() => {
const nodeResets = newNodes.map(node => ({
id: node.id,
borderColor: this.getNodeBorderColor(node.type),
borderWidth: 2,
}));
const edgeResets = newEdges.map(edge => ({
id: edge.id,
color: this.getEdgeColor(edge.metadata ? edge.metadata.confidence_score : 0.5),
width: this.getEdgeWidth(edge.metadata ? edge.metadata.confidence_score : 0.5)
}));
this.nodes.update(nodeResets);
this.edges.update(edgeResets);
}, 2000);
}
/**
* Update stabilization progress
* @param {number} progress - Progress value (0-1)
*/
updateStabilizationProgress(progress) {
// Could show a progress indicator if needed
console.log(`Graph stabilization: ${(progress * 100).toFixed(1)}%`);
}
/**
* Handle stabilization completion
*/
onStabilizationComplete() {
console.log('Graph stabilization complete');
}
/**
* Focus view on specific node
* @param {string} nodeId - Node to focus on
*/
focusOnNode(nodeId) {
const nodePosition = this.network.getPositions([nodeId]);
if (nodePosition[nodeId]) {
this.network.moveTo({
position: nodePosition[nodeId],
scale: 1.5,
animation: {
duration: 1000,
easingFunction: 'easeInOutQuart'
}
});
}
}
/**
* Toggle physics simulation
*/
togglePhysics() {
const currentPhysics = this.network.physics.physicsEnabled;
this.network.setOptions({ physics: !currentPhysics });
const button = document.getElementById('graph-physics');
if (button) {
button.textContent = currentPhysics ? '[PHYSICS OFF]' : '[PHYSICS ON]';
button.style.color = currentPhysics ? '#ff9900' : '#00ff41';
}
}
/**
* Toggle node clustering
*/
toggleClustering() {
if (this.network.isCluster('domain-cluster')) {
this.network.openCluster('domain-cluster');
} else {
const clusterOptions = {
joinCondition: (nodeOptions) => {
return nodeOptions.type === 'domain';
},
clusterNodeProperties: {
id: 'domain-cluster',
label: 'Domains',
shape: 'database',
color: '#00ff41',
borderWidth: 3,
}
};
this.network.cluster(clusterOptions);
}
}
/**
* Fit the view to show all nodes
*/
fitView() {
if (this.network) {
this.network.fit({
animation: {
duration: 1000,
easingFunction: 'easeInOutQuad'
}
});
}
}
/**
* Clear the graph
*/
clear() {
this.nodes.clear();
this.edges.clear();
this.history = [];
this.largeEntityMembers.clear(); // Clear large entity tracking
// Show placeholder
const placeholder = this.container.querySelector('.graph-placeholder');
if (placeholder) {
placeholder.style.display = 'flex';
}
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
const placeholder = this.container.querySelector('.graph-placeholder .placeholder-text');
if (placeholder) {
placeholder.textContent = `Error: ${message}`;
placeholder.style.color = '#ff6b6b';
}
}
/**
* Get network statistics
* @returns {Object} Statistics object
*/
getStatistics() {
return {
nodeCount: this.nodes.length,
edgeCount: this.edges.length,
largeEntityMembersHidden: this.largeEntityMembers.size
};
}
computeTrueRoots() {
this.trueRootIds.clear();
const allNodes = this.nodes.get({ returnType: 'Object' });
const allEdges = this.edges.get();
const inDegrees = {};
for (const nodeId in allNodes) {
inDegrees[nodeId] = 0;
}
allEdges.forEach(edge => {
if (inDegrees[edge.to] !== undefined) {
inDegrees[edge.to]++;
}
});
for (const nodeId in allNodes) {
if (inDegrees[nodeId] === 0) {
this.trueRootIds.add(nodeId);
}
}
console.log("Computed true roots:", this.trueRootIds);
}
updateFilterControls() {
if (!this.filterPanel) return;
const nodeTypes = new Set(this.nodes.get().map(n => n.type));
const edgeTypes = new Set(this.edges.get().map(e => e.metadata.relationship_type));
// Wrap both columns in a single container with vertical layout
let filterHTML = '';
// Nodes section
filterHTML += '
';
// Edges section
filterHTML += '
';
filterHTML += '
'; // Close filter-container
this.filterPanel.innerHTML = filterHTML;
this.filterPanel.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => this.applyAllFilters());
});
}
applyAllFilters() {
console.log("Applying all filters (robust orphan detection)...");
if (this.nodes.length === 0) return;
// 1. Get filter criteria from the UI
const hiddenNodeTypes = new Set();
this.filterPanel.querySelectorAll('input[data-filter-type="node"]:not(:checked)').forEach(cb => {
hiddenNodeTypes.add(cb.value);
});
const hiddenEdgeTypes = new Set();
this.filterPanel.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => {
hiddenEdgeTypes.add(cb.value);
});
// 2. Build adjacency list for the visible part of the graph
const adj = {};
this.nodes.getIds().forEach(id => adj[id] = []);
this.edges.forEach(edge => {
if (!hiddenEdgeTypes.has(edge.metadata.relationship_type)) {
adj[edge.from].push(edge.to);
}
});
// 3. Traverse from "true roots" to find all reachable nodes
const reachableNodes = new Set();
const queue = [];
// Start the traversal from true roots that aren't hidden by type
this.trueRootIds.forEach(rootId => {
const node = this.nodes.get(rootId);
if (node && !hiddenNodeTypes.has(node.type)) {
if (!reachableNodes.has(rootId)) {
queue.push(rootId);
reachableNodes.add(rootId);
}
}
});
let head = 0;
while (head < queue.length) {
const u = queue[head++];
for (const v of (adj[u] || [])) {
if (!reachableNodes.has(v)) {
const node = this.nodes.get(v);
if (node && !hiddenNodeTypes.has(node.type)) {
reachableNodes.add(v);
queue.push(v);
}
}
}
}
// 4. Create final node and edge visibility updates
const nodeUpdates = this.nodes.map(node => ({
id: node.id,
hidden: !reachableNodes.has(node.id)
}));
const edgeUpdates = this.edges.map(edge => ({
id: edge.id,
hidden: hiddenEdgeTypes.has(edge.metadata.relationship_type) || !reachableNodes.has(edge.from) || !reachableNodes.has(edge.to)
}));
this.nodes.update(nodeUpdates);
this.edges.update(edgeUpdates);
console.log(`Filters applied. Reachable nodes: ${reachableNodes.size}`);
}
/**
* Show context menu for a node
* @param {string} nodeId - The ID of the node
* @param {Event} event - The contextmenu event
*/
showContextMenu(nodeId, event) {
console.log('Showing context menu for node:', nodeId);
// Create menu items
this.contextMenu.innerHTML = `
-
Focus on Node
-
Hide Node
-
Delete Node
-
Show Details
`;
// Position the menu
this.contextMenu.style.left = `${event.clientX}px`;
this.contextMenu.style.top = `${event.clientY}px`;
this.contextMenu.style.display = 'block';
// Ensure menu stays within viewport
const rect = this.contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
this.contextMenu.style.left = `${event.clientX - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
this.contextMenu.style.top = `${event.clientY - rect.height}px`;
}
// Add event listeners to menu items
this.contextMenu.querySelectorAll('li').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const action = e.currentTarget.dataset.action;
const nodeId = e.currentTarget.dataset.nodeId;
console.log('Context menu action:', action, 'for node:', nodeId);
this.performContextMenuAction(action, nodeId);
this.hideContextMenu();
});
});
}
/**
* Hide the context menu
*/
hideContextMenu() {
if (this.contextMenu) {
this.contextMenu.style.display = 'none';
}
}
/**
* Perform action from the context menu
* @param {string} action - The action to perform ('hide' or 'delete')
* @param {string} nodeId - The ID of the node
*/
performContextMenuAction(action, nodeId) {
console.log('Performing action:', action, 'on node:', nodeId);
switch (action) {
case 'focus':
this.focusOnNode(nodeId);
break;
case 'hide':
this.hideNodeAndOrphans(nodeId);
break;
case 'delete':
this.deleteNodeAndOrphans(nodeId);
break;
case 'details':
const node = this.nodes.get(nodeId);
if (node) {
this.showNodeDetails(node);
}
break;
default:
console.warn('Unknown action:', action);
}
}
/**
* Add an operation to the history stack
* @param {string} type - The type of operation ('hide', 'delete')
* @param {Object} data - The data needed to revert the operation
*/
addToHistory(type, data) {
this.history.push({ type, data });
}
/**
* Revert the last action
*/
async revertLastAction() {
const lastAction = this.history.pop();
if (!lastAction) {
console.log('No actions to revert.');
return;
}
switch (lastAction.type) {
case 'hide':
// Revert hiding nodes by un-hiding them
const updates = lastAction.data.nodeIds.map(id => ({ id: id, hidden: false }));
this.nodes.update(updates);
break;
case 'delete':
try {
const response = await fetch('/api/graph/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(lastAction)
});
const result = await response.json();
if (result.success) {
console.log('Delete action reverted successfully on backend.');
// Re-add all nodes and edges from the history to the local view
this.nodes.add(lastAction.data.nodes);
this.edges.add(lastAction.data.edges);
} else {
console.error('Failed to revert delete action on backend:', result.error);
// Push the action back onto the history stack if the API call failed
this.history.push(lastAction);
}
} catch (error) {
console.error('Error during revert API call:', error);
this.history.push(lastAction);
}
break;
}
}
/**
* Enhanced hide operation using true root detection
* @param {string} nodeId - The ID of the node to start hiding from
*/
hideNodeAndOrphans(nodeId) {
const historyData = { nodeIds: [] };
// Step 1: Hide the target node
this.nodes.update({ id: nodeId, hidden: true });
historyData.nodeIds.push(nodeId);
// Step 2: Recompute what should remain visible using true root detection
const remainingVisible = this.computeReachableFromRoots();
// Step 3: Hide any nodes that are no longer reachable
const allNodes = this.nodes.get();
allNodes.forEach(node => {
if (!node.hidden && !remainingVisible.has(node.id) && node.id !== nodeId) {
this.nodes.update({ id: node.id, hidden: true });
historyData.nodeIds.push(node.id);
}
});
if (historyData.nodeIds.length > 0) {
this.addToHistory('hide', historyData);
console.log(`Hidden ${historyData.nodeIds.length} nodes using true root detection`);
}
}
/**
* Enhanced delete operation using true root detection
* @param {string} nodeId - The ID of the node to start deleting from
*/
async deleteNodeAndOrphans(nodeId) {
const historyData = { nodes: [], edges: [] };
let operationFailed = false;
// Step 1: Store current state and delete target node
const targetNode = this.nodes.get(nodeId);
const connectedEdgeIds = this.network.getConnectedEdges(nodeId);
const connectedEdges = this.edges.get(connectedEdgeIds);
if (targetNode) {
historyData.nodes.push(targetNode);
historyData.edges.push(...connectedEdges);
}
try {
// Delete from backend first
const response = await fetch(`/api/graph/node/${nodeId}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to delete node from backend');
}
// Remove from frontend
this.nodes.remove({ id: nodeId });
console.log(`Node ${nodeId} deleted from backend and frontend`);
// Step 2: Identify orphaned nodes using true root detection
const reachableNodes = this.computeReachableFromRoots();
const allRemainingNodes = this.nodes.get().filter(node => node.id !== nodeId);
// Step 3: Delete orphaned nodes
const orphanedNodes = allRemainingNodes.filter(node => !reachableNodes.has(node.id));
for (const orphanNode of orphanedNodes) {
try {
const orphanEdgeIds = this.network.getConnectedEdges(orphanNode.id);
const orphanEdges = this.edges.get(orphanEdgeIds);
// Store for history
historyData.nodes.push(orphanNode);
historyData.edges.push(...orphanEdges);
// Delete from backend
const orphanResponse = await fetch(`/api/graph/node/${orphanNode.id}`, { method: 'DELETE' });
const orphanResult = await orphanResponse.json();
if (!orphanResult.success) {
console.warn(`Failed to delete orphaned node ${orphanNode.id}:`, orphanResult.error);
// Continue with other orphans rather than failing entirely
} else {
// Remove from frontend
this.nodes.remove({ id: orphanNode.id });
console.log(`Orphaned node ${orphanNode.id} deleted`);
}
} catch (error) {
console.error(`Error deleting orphaned node ${orphanNode.id}:`, error);
// Continue with other orphans
}
}
console.log(`Deleted ${orphanedNodes.length + 1} nodes using true root detection`);
} catch (error) {
console.error('Error during node deletion:', error);
operationFailed = true;
}
// Add to history only if primary deletion succeeded
if (!operationFailed && historyData.nodes.length > 0) {
// Ensure edges in history are unique
historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values());
this.addToHistory('delete', historyData);
} else if (operationFailed) {
console.log("Reverting UI changes due to failed delete operation");
// Restore the UI to original state
this.nodes.add(historyData.nodes);
this.edges.add(historyData.edges);
}
}
/**
* Unhide all hidden nodes
*/
unhideAll() {
const allNodes = this.nodes.get({
filter: (node) => node.hidden === true
});
const updates = allNodes.map(node => ({ id: node.id, hidden: false }));
this.nodes.update(updates);
}
}
// Export for use in main.js
window.GraphManager = GraphManager;