1624 lines
		
	
	
		
			56 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1624 lines
		
	
	
		
			56 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/**
 | 
						||
 * Graph visualization module for DNSRecon
 | 
						||
 * Handles network graph rendering using vis.js with proper large entity node hiding
 | 
						||
 * UPDATED: Now compatible with a strictly flat, unified data model for attributes.
 | 
						||
 */
 | 
						||
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.initialTargetIds = new Set();
 | 
						||
        // Track large entity members for proper hiding
 | 
						||
        this.largeEntityMembers = new Set();
 | 
						||
        this.isScanning = false;
 | 
						||
 | 
						||
        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);
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * 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;
 | 
						||
 | 
						||
        // FIXED: Right-click context menu
 | 
						||
        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.network.on('stabilizationIterationsDone', () => {
 | 
						||
            this.onStabilizationComplete();
 | 
						||
        });
 | 
						||
 | 
						||
        // 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.initialTargetIds = new Set(graphData.initial_targets || []);
 | 
						||
            // Check if we have actual data to display
 | 
						||
            const hasData = graphData.nodes.length > 0 || graphData.edges.length > 0;
 | 
						||
            
 | 
						||
            // Handle placeholder visibility
 | 
						||
            const placeholder = this.container.querySelector('.graph-placeholder');
 | 
						||
            if (placeholder) {
 | 
						||
                if (hasData) {
 | 
						||
                    placeholder.style.display = 'none';
 | 
						||
                } else {
 | 
						||
                    placeholder.style.display = 'flex';
 | 
						||
                    // Early return if no data to process
 | 
						||
                    return;
 | 
						||
                }
 | 
						||
            }
 | 
						||
 | 
						||
            this.largeEntityMembers.clear();
 | 
						||
            const largeEntityMap = new Map();
 | 
						||
            
 | 
						||
            graphData.nodes.forEach(node => {
 | 
						||
                if (node.type === 'large_entity' && node.attributes) {
 | 
						||
                    const nodesAttribute = this.findAttributeByName(node.attributes, 'nodes');
 | 
						||
                    if (nodesAttribute && Array.isArray(nodesAttribute.value)) {
 | 
						||
                        nodesAttribute.value.forEach(nodeId => {
 | 
						||
                            largeEntityMap.set(nodeId, node.id);
 | 
						||
                            this.largeEntityMembers.add(nodeId);
 | 
						||
                        });
 | 
						||
                    }
 | 
						||
                }
 | 
						||
            });
 | 
						||
 | 
						||
            const filteredNodes = graphData.nodes.filter(node => {
 | 
						||
                return !this.largeEntityMembers.has(node.id) || node.type === 'large_entity';
 | 
						||
            });
 | 
						||
 | 
						||
            console.log(`Filtered ${graphData.nodes.length - filteredNodes.length} large entity member nodes from visualization`);
 | 
						||
 | 
						||
            // Process nodes with proper certificate coloring
 | 
						||
            const processedNodes = filteredNodes.map(node => {
 | 
						||
                const processed = this.processNode(node);
 | 
						||
                
 | 
						||
                // Apply certificate-based coloring here in frontend
 | 
						||
                if (node.type === 'domain' && Array.isArray(node.attributes)) {
 | 
						||
                    const certInfo = this.analyzeCertificateInfo(node.attributes);
 | 
						||
                    
 | 
						||
                    if (certInfo.hasExpiredOnly) {
 | 
						||
                        // Red for domains with only expired/invalid certificates
 | 
						||
                        processed.color = { background: '#ff6b6b', border: '#cc5555' };
 | 
						||
                    } else if (!certInfo.hasCertificates) {
 | 
						||
                        // Grey for domains with no certificates
 | 
						||
                        processed.color = { background: '#c7c7c7', border: '#999999' };
 | 
						||
                    }
 | 
						||
                    // Valid certificates use default green (handled by processNode)
 | 
						||
                }
 | 
						||
                
 | 
						||
                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();
 | 
						||
 | 
						||
            const newNodes = processedNodes.filter(node => !existingNodeIds.includes(node.id));
 | 
						||
            const newEdges = processedEdges.filter(edge => !existingEdgeIds.includes(edge.id));
 | 
						||
 | 
						||
            this.nodes.update(processedNodes);
 | 
						||
            this.edges.update(processedEdges);
 | 
						||
            
 | 
						||
            this.updateFilterControls();
 | 
						||
            this.applyAllFilters();
 | 
						||
 | 
						||
            if (newNodes.length > 0 || newEdges.length > 0) {
 | 
						||
                setTimeout(() => this.highlightNewElements(newNodes, newEdges), 100);
 | 
						||
            }
 | 
						||
 | 
						||
            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');
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    analyzeCertificateInfo(attributes) {
 | 
						||
        let hasCertificates = false;
 | 
						||
        let hasValidCertificates = false;
 | 
						||
        let hasExpiredCertificates = false;
 | 
						||
        
 | 
						||
        for (const attr of attributes) {
 | 
						||
            const attrName = (attr.name || '').toLowerCase();
 | 
						||
            const attrProvider = (attr.provider || '').toLowerCase();
 | 
						||
            const attrValue = attr.value;
 | 
						||
            
 | 
						||
            // Look for certificate attributes from crtsh provider
 | 
						||
            if (attrProvider === 'crtsh' || attrName.startsWith('cert_')) {
 | 
						||
                hasCertificates = true;
 | 
						||
                
 | 
						||
                // Check certificate validity using raw attribute names
 | 
						||
                if (attrName === 'cert_is_currently_valid') {
 | 
						||
                    if (attrValue === true) {
 | 
						||
                        hasValidCertificates = true;
 | 
						||
                    } else if (attrValue === false) {
 | 
						||
                        hasExpiredCertificates = true;
 | 
						||
                    }
 | 
						||
                }
 | 
						||
                // Check for expiry indicators
 | 
						||
                else if (attrName === 'cert_expires_soon' && attrValue === true) {
 | 
						||
                    hasExpiredCertificates = true;
 | 
						||
                }
 | 
						||
                else if (attrName.includes('expired') && attrValue === true) {
 | 
						||
                    hasExpiredCertificates = true;
 | 
						||
                }
 | 
						||
            }
 | 
						||
        }
 | 
						||
        
 | 
						||
        return {
 | 
						||
            hasCertificates,
 | 
						||
            hasValidCertificates,
 | 
						||
            hasExpiredCertificates,
 | 
						||
            hasExpiredOnly: hasExpiredCertificates && !hasValidCertificates
 | 
						||
        };
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * UPDATED: Helper method to find an attribute by name in the standardized attributes list
 | 
						||
     * @param {Array} attributes - List of StandardAttribute objects
 | 
						||
     * @param {string} name - Attribute name to find
 | 
						||
     * @returns {Object|null} The attribute object if found, null otherwise
 | 
						||
     */
 | 
						||
    findAttributeByName(attributes, name) {
 | 
						||
        if (!Array.isArray(attributes)) {
 | 
						||
            return null;
 | 
						||
        }
 | 
						||
        return attributes.find(attr => attr.name === name) || null;
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * UPDATED: Process node data with styling and metadata for the flat data model
 | 
						||
     * @param {Object} node - Raw node data with standardized attributes
 | 
						||
     * @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));
 | 
						||
        }
 | 
						||
        
 | 
						||
        // FIXED: Certificate-based domain coloring
 | 
						||
        if (node.type === 'domain' && Array.isArray(node.attributes)) {
 | 
						||
            const certInfo = this.analyzeCertificateInfo(node.attributes);
 | 
						||
            
 | 
						||
            if (certInfo.hasExpiredOnly) {
 | 
						||
                // Red for domains with only expired/invalid certificates
 | 
						||
                processedNode.color = '#ff6b6b';
 | 
						||
                processedNode.borderColor = '#cc5555';
 | 
						||
            } else if (!certInfo.hasCertificates) {
 | 
						||
                // Grey for domains with no certificates
 | 
						||
                processedNode.color = '#c7c7c7';
 | 
						||
                processedNode.borderColor = '#999999';
 | 
						||
            }
 | 
						||
            // Green for valid certificates (default color)
 | 
						||
        }
 | 
						||
        
 | 
						||
        // Handle merged correlation objects
 | 
						||
        if (node.type === 'correlation_object') {
 | 
						||
            const metadata = node.metadata || {};
 | 
						||
            const values = metadata.values || [];
 | 
						||
            const mergeCount = metadata.merge_count || 1;
 | 
						||
            
 | 
						||
            if (mergeCount > 1) {
 | 
						||
                processedNode.label = `Correlations (${mergeCount})`;
 | 
						||
                processedNode.title = `Merged correlation container with ${mergeCount} values: ${values.slice(0, 3).join(', ')}${values.length > 3 ? '...' : ''}`;
 | 
						||
                processedNode.borderWidth = 3;
 | 
						||
            } else {
 | 
						||
                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);
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * 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
 | 
						||
        this.initialTargetIds.clear();
 | 
						||
 | 
						||
        // 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';
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
     /* * @param {Set} excludedNodeIds - Node IDs to exclude from analysis (for simulation)
 | 
						||
     * @param {Set} excludedEdgeTypes - Edge types to exclude from traversal
 | 
						||
     * @param {Set} excludedNodeTypes - Node types to exclude from traversal
 | 
						||
     * @returns {Object} Analysis results with reachable/unreachable nodes
 | 
						||
     */
 | 
						||
    analyzeGraphReachability(excludedNodeIds = new Set(), excludedEdgeTypes = new Set(), excludedNodeTypes = new Set()) {
 | 
						||
        console.log("Performing comprehensive reachability analysis...");
 | 
						||
        
 | 
						||
        const analysis = {
 | 
						||
            reachableNodes: new Set(),
 | 
						||
            unreachableNodes: new Set(),
 | 
						||
            isolatedClusters: [],
 | 
						||
            affectedNodes: new Set()
 | 
						||
        };
 | 
						||
 | 
						||
        if (this.nodes.length === 0) return analysis;
 | 
						||
 | 
						||
        // Build adjacency list excluding specified elements
 | 
						||
        const adjacencyList = {};
 | 
						||
        this.nodes.getIds().forEach(id => {
 | 
						||
            if (!excludedNodeIds.has(id)) {
 | 
						||
                adjacencyList[id] = [];
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        this.edges.forEach(edge => {
 | 
						||
            const edgeType = edge.metadata?.relationship_type || '';
 | 
						||
            if (!excludedEdgeTypes.has(edgeType) && 
 | 
						||
                !excludedNodeIds.has(edge.from) && 
 | 
						||
                !excludedNodeIds.has(edge.to)) {
 | 
						||
                
 | 
						||
                if (adjacencyList[edge.from]) {
 | 
						||
                    adjacencyList[edge.from].push(edge.to);
 | 
						||
                }
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        // BFS traversal from initial targets
 | 
						||
        const traversalQueue = [];
 | 
						||
        
 | 
						||
        // Start from initial targets that aren't excluded
 | 
						||
        this.initialTargetIds.forEach(rootId => {
 | 
						||
            if (!excludedNodeIds.has(rootId)) {
 | 
						||
                const node = this.nodes.get(rootId);
 | 
						||
                if (node && !excludedNodeTypes.has(node.type)) {
 | 
						||
                    if (!analysis.reachableNodes.has(rootId)) {
 | 
						||
                        traversalQueue.push(rootId);
 | 
						||
                        analysis.reachableNodes.add(rootId);
 | 
						||
                    }
 | 
						||
                }
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        // BFS to find all reachable nodes
 | 
						||
        let queueIndex = 0;
 | 
						||
        while (queueIndex < traversalQueue.length) {
 | 
						||
            const currentNode = traversalQueue[queueIndex++];
 | 
						||
            
 | 
						||
            for (const neighbor of (adjacencyList[currentNode] || [])) {
 | 
						||
                if (!analysis.reachableNodes.has(neighbor)) {
 | 
						||
                    const node = this.nodes.get(neighbor);
 | 
						||
                    if (node && !excludedNodeTypes.has(node.type)) {
 | 
						||
                        analysis.reachableNodes.add(neighbor);
 | 
						||
                        traversalQueue.push(neighbor);
 | 
						||
                    }
 | 
						||
                }
 | 
						||
            }
 | 
						||
        }
 | 
						||
 | 
						||
        // Identify unreachable nodes (maintaining forensic integrity)
 | 
						||
        Object.keys(adjacencyList).forEach(nodeId => {
 | 
						||
            if (!analysis.reachableNodes.has(nodeId)) {
 | 
						||
                analysis.unreachableNodes.add(nodeId);
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        // Find isolated clusters among unreachable nodes
 | 
						||
        analysis.isolatedClusters = this.findIsolatedClusters(
 | 
						||
            Array.from(analysis.unreachableNodes), 
 | 
						||
            adjacencyList
 | 
						||
        );
 | 
						||
 | 
						||
        /*console.log(`Reachability analysis complete:`, {
 | 
						||
            reachable: analysis.reachableNodes.size,
 | 
						||
            unreachable: analysis.unreachableNodes.size,
 | 
						||
            clusters: analysis.isolatedClusters.length
 | 
						||
        });*/
 | 
						||
 | 
						||
        return analysis;
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * Find isolated clusters within a set of nodes
 | 
						||
     * Used for forensic analysis to identify disconnected subgraphs
 | 
						||
     */
 | 
						||
    findIsolatedClusters(nodeIds, adjacencyList) {
 | 
						||
        const visited = new Set();
 | 
						||
        const clusters = [];
 | 
						||
 | 
						||
        for (const nodeId of nodeIds) {
 | 
						||
            if (!visited.has(nodeId)) {
 | 
						||
                const cluster = [];
 | 
						||
                const stack = [nodeId];
 | 
						||
                
 | 
						||
                while (stack.length > 0) {
 | 
						||
                    const current = stack.pop();
 | 
						||
                    if (!visited.has(current)) {
 | 
						||
                        visited.add(current);
 | 
						||
                        cluster.push(current);
 | 
						||
                        
 | 
						||
                        // Add unvisited neighbors within the unreachable set
 | 
						||
                        for (const neighbor of (adjacencyList[current] || [])) {
 | 
						||
                            if (nodeIds.includes(neighbor) && !visited.has(neighbor)) {
 | 
						||
                                stack.push(neighbor);
 | 
						||
                            }
 | 
						||
                        }
 | 
						||
                    }
 | 
						||
                }
 | 
						||
                
 | 
						||
                if (cluster.length > 0) {
 | 
						||
                    clusters.push(cluster);
 | 
						||
                }
 | 
						||
            }
 | 
						||
        }
 | 
						||
 | 
						||
        return clusters;
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * ENHANCED: Get comprehensive graph statistics with forensic information
 | 
						||
     * Updates the existing getStatistics() method
 | 
						||
     */
 | 
						||
    getStatistics() {
 | 
						||
        const basicStats = {
 | 
						||
            nodeCount: this.nodes.length,
 | 
						||
            edgeCount: this.edges.length,
 | 
						||
            largeEntityMembersHidden: this.largeEntityMembers.size
 | 
						||
        };
 | 
						||
 | 
						||
        // Add forensic statistics
 | 
						||
        const visibleNodes = this.nodes.get({ filter: node => !node.hidden });
 | 
						||
        const hiddenNodes = this.nodes.get({ filter: node => node.hidden });
 | 
						||
        
 | 
						||
        return {
 | 
						||
            ...basicStats,
 | 
						||
            forensicStatistics: {
 | 
						||
                visibleNodes: visibleNodes.length,
 | 
						||
                hiddenNodes: hiddenNodes.length,
 | 
						||
                initialTargets: this.initialTargetIds.size,
 | 
						||
                integrityStatus: visibleNodes.length > 0 && this.initialTargetIds.size > 0 ? 'INTACT' : 'COMPROMISED'
 | 
						||
            }
 | 
						||
        };
 | 
						||
    }
 | 
						||
    
 | 
						||
    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());
 | 
						||
        });
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * ENHANCED: Apply filters using consolidated reachability analysis
 | 
						||
     * Replaces the existing applyAllFilters() method
 | 
						||
     */
 | 
						||
    applyAllFilters() {
 | 
						||
        if (this.nodes.length === 0) return;
 | 
						||
 | 
						||
        // Get filter criteria from UI
 | 
						||
        const excludedNodeTypes = new Set();
 | 
						||
        this.filterPanel?.querySelectorAll('input[data-filter-type="node"]:not(:checked)').forEach(cb => {
 | 
						||
            excludedNodeTypes.add(cb.value);
 | 
						||
        });
 | 
						||
 | 
						||
        const excludedEdgeTypes = new Set();
 | 
						||
        this.filterPanel?.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => {
 | 
						||
            excludedEdgeTypes.add(cb.value);
 | 
						||
        });
 | 
						||
 | 
						||
        // Perform comprehensive analysis
 | 
						||
        const analysis = this.analyzeGraphReachability(new Set(), excludedEdgeTypes, excludedNodeTypes);
 | 
						||
 | 
						||
        // Apply visibility updates
 | 
						||
        const nodeUpdates = this.nodes.map(node => ({
 | 
						||
            id: node.id,
 | 
						||
            hidden: !analysis.reachableNodes.has(node.id)
 | 
						||
        }));
 | 
						||
 | 
						||
        const edgeUpdates = this.edges.map(edge => ({
 | 
						||
            id: edge.id,
 | 
						||
            hidden: excludedEdgeTypes.has(edge.metadata?.relationship_type || '') ||
 | 
						||
                   !analysis.reachableNodes.has(edge.from) ||
 | 
						||
                   !analysis.reachableNodes.has(edge.to)
 | 
						||
        }));
 | 
						||
 | 
						||
        this.nodes.update(nodeUpdates);
 | 
						||
        this.edges.update(edgeUpdates);
 | 
						||
 | 
						||
        console.log(`Enhanced filters applied. Visible nodes: ${analysis.reachableNodes.size}`);
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * ENHANCED: Hide node with forensic integrity using reachability analysis
 | 
						||
     * Replaces the existing hideNodeAndOrphans() method
 | 
						||
     */
 | 
						||
    hideNodeWithReachabilityAnalysis(nodeId) {
 | 
						||
        console.log(`Hiding node ${nodeId} with reachability analysis...`);
 | 
						||
        
 | 
						||
        // Simulate hiding this node and analyze impact
 | 
						||
        const excludedNodes = new Set([nodeId]);
 | 
						||
        const analysis = this.analyzeGraphReachability(excludedNodes);
 | 
						||
        
 | 
						||
        // Nodes that will become unreachable (should be hidden)
 | 
						||
        const nodesToHide = [nodeId, ...Array.from(analysis.unreachableNodes)];
 | 
						||
        
 | 
						||
        // Store history for potential revert
 | 
						||
        const historyData = { 
 | 
						||
            nodeIds: nodesToHide,
 | 
						||
            operation: 'hide_with_reachability',
 | 
						||
            timestamp: Date.now()
 | 
						||
        };
 | 
						||
        
 | 
						||
        const updates = nodesToHide.map(id => ({ id: id, hidden: true }));
 | 
						||
        this.nodes.update(updates);
 | 
						||
        this.addToHistory('hide', historyData);
 | 
						||
 | 
						||
        return {
 | 
						||
            hiddenNodes: nodesToHide,
 | 
						||
            isolatedClusters: analysis.isolatedClusters
 | 
						||
        };
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * ENHANCED: Delete node with forensic integrity using reachability analysis
 | 
						||
     * Replaces the existing deleteNodeAndOrphans() method
 | 
						||
     */
 | 
						||
    async deleteNodeWithReachabilityAnalysis(nodeId) {
 | 
						||
        console.log(`Deleting node ${nodeId} with reachability analysis...`);
 | 
						||
        
 | 
						||
        // Simulate deletion and analyze impact
 | 
						||
        const excludedNodes = new Set([nodeId]);
 | 
						||
        const analysis = this.analyzeGraphReachability(excludedNodes);
 | 
						||
        
 | 
						||
        // Nodes that will become unreachable (should be deleted)
 | 
						||
        const nodesToDelete = [nodeId, ...Array.from(analysis.unreachableNodes)];
 | 
						||
        
 | 
						||
        // Collect forensic data before deletion
 | 
						||
        const historyData = {
 | 
						||
            nodes: nodesToDelete.map(id => this.nodes.get(id)).filter(Boolean),
 | 
						||
            edges: [],
 | 
						||
            operation: 'delete_with_reachability',
 | 
						||
            timestamp: Date.now(),
 | 
						||
            forensicAnalysis: {
 | 
						||
                originalTarget: nodeId,
 | 
						||
                cascadeNodes: nodesToDelete.length - 1,
 | 
						||
                isolatedClusters: analysis.isolatedClusters.length,
 | 
						||
                clusterSizes: analysis.isolatedClusters.map(cluster => cluster.length)
 | 
						||
            }
 | 
						||
        };
 | 
						||
 | 
						||
        // Collect affected edges
 | 
						||
        nodesToDelete.forEach(id => {
 | 
						||
            const connectedEdgeIds = this.network.getConnectedEdges(id);
 | 
						||
            const edges = this.edges.get(connectedEdgeIds);
 | 
						||
            historyData.edges.push(...edges);
 | 
						||
        });
 | 
						||
 | 
						||
        // Remove duplicates from edges
 | 
						||
        historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values());
 | 
						||
 | 
						||
        // Perform backend deletion with forensic logging
 | 
						||
        let operationFailed = false;
 | 
						||
        
 | 
						||
        for (const targetNodeId of nodesToDelete) {
 | 
						||
            try {
 | 
						||
                const response = await fetch(`/api/graph/node/${targetNodeId}`, {
 | 
						||
                    method: 'DELETE',
 | 
						||
                    headers: {
 | 
						||
                        'Content-Type': 'application/json',
 | 
						||
                    },
 | 
						||
                    body: JSON.stringify({
 | 
						||
                        forensicContext: {
 | 
						||
                            operation: 'reachability_cascade_delete',
 | 
						||
                            originalTarget: nodeId,
 | 
						||
                            analysisTimestamp: historyData.timestamp
 | 
						||
                        }
 | 
						||
                    })
 | 
						||
                });
 | 
						||
 | 
						||
                const result = await response.json();
 | 
						||
                if (!result.success) {
 | 
						||
                    console.error(`Backend deletion failed for node ${targetNodeId}:`, result.error);
 | 
						||
                    operationFailed = true;
 | 
						||
                    break;
 | 
						||
                }
 | 
						||
                
 | 
						||
                console.log(`Node ${targetNodeId} deleted from backend with forensic context`);
 | 
						||
                this.nodes.remove({ id: targetNodeId });
 | 
						||
                
 | 
						||
            } catch (error) {
 | 
						||
                console.error(`API error during deletion of node ${targetNodeId}:`, error);
 | 
						||
                operationFailed = true;
 | 
						||
                break;
 | 
						||
            }
 | 
						||
        }
 | 
						||
 | 
						||
        // Handle operation results
 | 
						||
        if (!operationFailed) {
 | 
						||
            this.addToHistory('delete', historyData);            
 | 
						||
            return {
 | 
						||
                success: true,
 | 
						||
                deletedNodes: nodesToDelete,
 | 
						||
                forensicAnalysis: historyData.forensicAnalysis
 | 
						||
            };
 | 
						||
        } else {
 | 
						||
            // Revert UI changes if backend operations failed - use update instead of add
 | 
						||
            console.log("Reverting UI changes due to backend failure");
 | 
						||
            this.nodes.update(historyData.nodes);
 | 
						||
            this.edges.update(historyData.edges);
 | 
						||
            
 | 
						||
            return {
 | 
						||
                success: false,
 | 
						||
                error: "Backend deletion failed, UI reverted"
 | 
						||
            };
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * 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);
 | 
						||
        const node = this.nodes.get(nodeId);
 | 
						||
        
 | 
						||
        // Create menu items
 | 
						||
        let menuItems = `
 | 
						||
            <ul>
 | 
						||
                <li data-action="focus" data-node-id="${nodeId}">
 | 
						||
                    <span class="menu-icon">🎯</span>
 | 
						||
                    <span>Focus on Node</span>
 | 
						||
                </li>
 | 
						||
        `;
 | 
						||
 | 
						||
        // Add "Iterate Scan" option only for domain or IP nodes
 | 
						||
        if (node && (node.type === 'domain' || node.type === 'ip')) {
 | 
						||
            const disabled = this.isScanning ? 'disabled' : ''; // Check if scanning
 | 
						||
            const title = this.isScanning ? 'A scan is already in progress' : 'Iterate Scan (Add to Graph)'; // Add a title for disabled state
 | 
						||
            menuItems += `
 | 
						||
                <li data-action="iterate" data-node-id="${nodeId}" ${disabled} title="${title}">
 | 
						||
                    <span class="menu-icon">➕</span>
 | 
						||
                    <span>Iterate Scan (Add to Graph)</span>
 | 
						||
                </li>
 | 
						||
            `;
 | 
						||
        }
 | 
						||
 | 
						||
        menuItems += `
 | 
						||
                <li data-action="hide" data-node-id="${nodeId}">
 | 
						||
                    <span class="menu-icon">👁️🗨️</span>
 | 
						||
                    <span>Hide Node</span>
 | 
						||
                </li>
 | 
						||
                <li data-action="delete" data-node-id="${nodeId}">
 | 
						||
                    <span class="menu-icon">🗑️</span>
 | 
						||
                    <span>Delete Node</span>
 | 
						||
                </li>
 | 
						||
                <li data-action="details" data-node-id="${nodeId}">
 | 
						||
                    <span class="menu-icon">ℹ️</span>
 | 
						||
                    <span>Show Details</span>
 | 
						||
                </li>
 | 
						||
            </ul>
 | 
						||
        `;
 | 
						||
        
 | 
						||
        this.contextMenu.innerHTML = menuItems;
 | 
						||
        
 | 
						||
        // 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) => {
 | 
						||
                if (e.currentTarget.hasAttribute('disabled')) { // Prevent action if disabled
 | 
						||
                    e.stopPropagation();
 | 
						||
                    return;
 | 
						||
                }
 | 
						||
                e.stopPropagation();
 | 
						||
                const action = e.currentTarget.dataset.action;
 | 
						||
                const nodeId = e.currentTarget.dataset.nodeId;
 | 
						||
                this.performContextMenuAction(action, nodeId);
 | 
						||
                this.hideContextMenu();
 | 
						||
            });
 | 
						||
        });
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * Hide the context menu
 | 
						||
     */
 | 
						||
    hideContextMenu() {
 | 
						||
        if (this.contextMenu) {
 | 
						||
            this.contextMenu.style.display = 'none';
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * UPDATED: Enhanced context menu actions using new methods
 | 
						||
     * Updates the existing performContextMenuAction() method
 | 
						||
     */
 | 
						||
    performContextMenuAction(action, nodeId) {        
 | 
						||
        switch (action) {
 | 
						||
            case 'focus':
 | 
						||
                this.focusOnNode(nodeId);
 | 
						||
                break;
 | 
						||
                
 | 
						||
            case 'iterate':
 | 
						||
                const event = new CustomEvent('iterateScan', {
 | 
						||
                    detail: { nodeId }
 | 
						||
                });
 | 
						||
                document.dispatchEvent(event);
 | 
						||
                break;
 | 
						||
                
 | 
						||
            case 'hide':
 | 
						||
                // Use enhanced method with reachability analysis
 | 
						||
                this.hideNodeWithReachabilityAnalysis(nodeId);
 | 
						||
                break;
 | 
						||
                
 | 
						||
            case 'delete':
 | 
						||
                // Use enhanced method with reachability analysis
 | 
						||
                this.deleteNodeWithReachabilityAnalysis(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 - use update instead of add
 | 
						||
                        this.nodes.update(lastAction.data.nodes);
 | 
						||
                        this.edges.update(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;
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * 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; |