1302 lines
44 KiB
JavaScript
1302 lines
44 KiB
JavaScript
/**
|
|
* Graph visualization module for DNSRecon
|
|
* Handles network graph rendering using vis.js
|
|
*/
|
|
|
|
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();
|
|
|
|
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
|
|
}
|
|
};
|
|
|
|
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() {
|
|
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);
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 = `
|
|
<button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button>
|
|
<button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button>
|
|
<button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button>
|
|
<button class="graph-control-btn" id="graph-unhide" title="Unhide All">[UNHIDE]</button>
|
|
<button class="graph-control-btn" id="graph-revert" title="Revert Last Action">[REVERT]</button>
|
|
`;
|
|
|
|
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;
|
|
|
|
// Use a standard DOM event listener for the context menu for better reliability
|
|
this.container.addEventListener('contextmenu', (event) => {
|
|
event.preventDefault();
|
|
|
|
// Get coordinates relative to the canvas
|
|
const pointer = {
|
|
x: event.offsetX,
|
|
y: event.offsetY
|
|
};
|
|
|
|
const nodeId = this.network.getNodeAt(pointer);
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @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();
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
});
|
|
|
|
const processedNodes = graphData.nodes.map(node => {
|
|
const processed = this.processNode(node);
|
|
if (largeEntityMap.has(node.id)) {
|
|
processed.hidden = true;
|
|
}
|
|
return processed;
|
|
});
|
|
|
|
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)`);
|
|
} 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 = `<div style="font-family: 'Roboto Mono', monospace; font-size: 11px;">`;
|
|
tooltip += `<div style="color: #00ff41; font-weight: bold; margin-bottom: 4px;">${edge.label || 'Relationship'}</div>`;
|
|
tooltip += `<div style="color: #999; margin-bottom: 2px;">Confidence: ${(edge.confidence_score * 100).toFixed(1)}%</div>`;
|
|
|
|
if (edge.source_provider) {
|
|
tooltip += `<div style="color: #999; margin-bottom: 2px;">Provider: ${edge.source_provider}</div>`;
|
|
}
|
|
|
|
if (edge.discovery_timestamp) {
|
|
const date = new Date(edge.discovery_timestamp);
|
|
tooltip += `<div style="color: #666; font-size: 10px;">Discovered: ${date.toLocaleString()}</div>`;
|
|
}
|
|
|
|
tooltip += `</div>`;
|
|
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 = [];
|
|
|
|
// 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,
|
|
//isStabilized: this.network ? this.network.isStabilized() : false
|
|
};
|
|
}
|
|
|
|
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 = '<div class="filter-container">';
|
|
|
|
// Nodes section
|
|
filterHTML += '<div class="filter-column"><h4>Nodes</h4><div class="checkbox-group">';
|
|
nodeTypes.forEach(type => {
|
|
const label = type === 'correlation_object' ? 'latent correlations' : type;
|
|
const isChecked = type !== 'correlation_object';
|
|
filterHTML += `<label><input type="checkbox" data-filter-type="node" value="${type}" ${isChecked ? 'checked' : ''}> ${label}</label>`;
|
|
});
|
|
filterHTML += '</div></div>';
|
|
|
|
// Edges section
|
|
filterHTML += '<div class="filter-column"><h4>Edges</h4><div class="checkbox-group">';
|
|
edgeTypes.forEach(type => {
|
|
filterHTML += `<label><input type="checkbox" data-filter-type="edge" value="${type}" checked> ${type}</label>`;
|
|
});
|
|
filterHTML += '</div></div>';
|
|
|
|
filterHTML += '</div>'; // 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) {
|
|
this.contextMenu.innerHTML = `
|
|
<ul>
|
|
<li data-action="hide" data-node-id="${nodeId}">Hide Node</li>
|
|
<li data-action="delete" data-node-id="${nodeId}">Delete Node</li>
|
|
</ul>
|
|
`;
|
|
this.contextMenu.style.left = `${event.clientX}px`;
|
|
this.contextMenu.style.top = `${event.clientY}px`;
|
|
this.contextMenu.style.display = 'block';
|
|
|
|
this.contextMenu.querySelectorAll('li').forEach(item => {
|
|
item.addEventListener('click', (e) => {
|
|
const action = e.target.dataset.action;
|
|
const nodeId = e.target.dataset.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) {
|
|
switch (action) {
|
|
case 'hide':
|
|
this.hideNodeAndOrphans(nodeId);
|
|
break;
|
|
case 'delete':
|
|
this.deleteNodeAndOrphans(nodeId);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide a node and recursively hide any neighbors that become disconnected.
|
|
* @param {string} nodeId - The ID of the node to start hiding from.
|
|
*/
|
|
hideNodeAndOrphans(nodeId) {
|
|
const historyData = { nodeIds: [] };
|
|
const queue = [nodeId];
|
|
const visited = new Set([nodeId]);
|
|
|
|
while (queue.length > 0) {
|
|
const currentId = queue.shift();
|
|
const node = this.nodes.get(currentId);
|
|
if (!node || node.hidden) continue;
|
|
|
|
// 1. Hide the current node and add to history
|
|
this.nodes.update({ id: currentId, hidden: true });
|
|
historyData.nodeIds.push(currentId);
|
|
|
|
// 2. Check its neighbors
|
|
const neighbors = this.network.getConnectedNodes(currentId);
|
|
for (const neighborId of neighbors) {
|
|
if (visited.has(neighborId)) continue;
|
|
|
|
const connectedEdges = this.network.getConnectedEdges(neighborId);
|
|
let hasVisibleEdge = false;
|
|
// 3. See if the neighbor still has any visible connections
|
|
for (const edgeId of connectedEdges) {
|
|
const edge = this.edges.get(edgeId);
|
|
const sourceNode = this.nodes.get(edge.from);
|
|
const targetNode = this.nodes.get(edge.to);
|
|
if ((sourceNode && !sourceNode.hidden) && (targetNode && !targetNode.hidden)) {
|
|
hasVisibleEdge = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 4. If no visible connections, add to queue to be hidden
|
|
if (!hasVisibleEdge) {
|
|
queue.push(neighborId);
|
|
visited.add(neighborId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (historyData.nodeIds.length > 0) {
|
|
this.addToHistory('hide', historyData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a node and recursively delete any neighbors that become disconnected.
|
|
* @param {string} nodeId - The ID of the node to start deleting from.
|
|
*/
|
|
async deleteNodeAndOrphans(nodeId) {
|
|
const deletionQueue = [nodeId];
|
|
const processedForDeletion = new Set([nodeId]);
|
|
const historyData = { nodes: [], edges: [] };
|
|
let operationFailed = false;
|
|
|
|
while (deletionQueue.length > 0) {
|
|
const currentId = deletionQueue.shift();
|
|
const node = this.nodes.get(currentId);
|
|
if (!node) continue;
|
|
|
|
const neighbors = this.network.getConnectedNodes(currentId);
|
|
const connectedEdgeIds = this.network.getConnectedEdges(currentId);
|
|
const edges = this.edges.get(connectedEdgeIds);
|
|
|
|
// Store state for potential revert
|
|
historyData.nodes.push(node);
|
|
historyData.edges.push(...edges);
|
|
|
|
try {
|
|
const response = await fetch(`/api/graph/node/${currentId}`, { method: 'DELETE' });
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
console.error(`Failed to delete node ${currentId} from backend:`, result.error);
|
|
operationFailed = true;
|
|
break;
|
|
}
|
|
|
|
console.log(`Node ${currentId} deleted from backend.`);
|
|
this.nodes.remove({ id: currentId }); // Remove from view
|
|
|
|
// Check if former neighbors are now orphans
|
|
neighbors.forEach(neighborId => {
|
|
if (!processedForDeletion.has(neighborId) && this.nodes.get(neighborId)) {
|
|
if (this.network.getConnectedEdges(neighborId).length === 0) {
|
|
deletionQueue.push(neighborId);
|
|
processedForDeletion.add(neighborId);
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error during node deletion API call:', error);
|
|
operationFailed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Add to history only if the entire operation was successful
|
|
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.");
|
|
// If any part of the chain failed, restore the UI to its 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; |