'; // Close filter-container
this.filterPanel.innerHTML = filterHTML;
this.filterPanel.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => this.applyAllFilters());
});
}
applyAllFilters() {
console.log("Applying all filters (robust orphan detection)...");
if (this.nodes.length === 0) return;
// 1. Get filter criteria from the UI
const hiddenNodeTypes = new Set();
this.filterPanel.querySelectorAll('input[data-filter-type="node"]:not(:checked)').forEach(cb => {
hiddenNodeTypes.add(cb.value);
});
const hiddenEdgeTypes = new Set();
this.filterPanel.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => {
hiddenEdgeTypes.add(cb.value);
});
// 2. Build adjacency list for the visible part of the graph
const adj = {};
this.nodes.getIds().forEach(id => adj[id] = []);
this.edges.forEach(edge => {
if (!hiddenEdgeTypes.has(edge.metadata.relationship_type)) {
adj[edge.from].push(edge.to);
}
});
// 3. Traverse from "true roots" to find all reachable nodes
const reachableNodes = new Set();
const queue = [];
// Start the traversal from true roots that aren't hidden by type
this.trueRootIds.forEach(rootId => {
const node = this.nodes.get(rootId);
if (node && !hiddenNodeTypes.has(node.type)) {
if (!reachableNodes.has(rootId)) {
queue.push(rootId);
reachableNodes.add(rootId);
}
}
});
let head = 0;
while (head < queue.length) {
const u = queue[head++];
for (const v of (adj[u] || [])) {
if (!reachableNodes.has(v)) {
const node = this.nodes.get(v);
if (node && !hiddenNodeTypes.has(node.type)) {
reachableNodes.add(v);
queue.push(v);
}
}
}
}
// 4. Create final node and edge visibility updates
const nodeUpdates = this.nodes.map(node => ({
id: node.id,
hidden: !reachableNodes.has(node.id)
}));
const edgeUpdates = this.edges.map(edge => ({
id: edge.id,
hidden: hiddenEdgeTypes.has(edge.metadata.relationship_type) || !reachableNodes.has(edge.from) || !reachableNodes.has(edge.to)
}));
this.nodes.update(nodeUpdates);
this.edges.update(edgeUpdates);
console.log(`Filters applied. Reachable nodes: ${reachableNodes.size}`);
}
/**
* Show context menu for a node
* @param {string} nodeId - The ID of the node
* @param {Event} event - The contextmenu event
*/
showContextMenu(nodeId, event) {
console.log('Showing context menu for node:', nodeId);
// Create menu items
this.contextMenu.innerHTML = `
đ¯Focus on Node
đī¸âđ¨ī¸Hide Node
đī¸Delete Node
âšī¸Show Details
`;
// Position the menu
this.contextMenu.style.left = `${event.clientX}px`;
this.contextMenu.style.top = `${event.clientY}px`;
this.contextMenu.style.display = 'block';
// Ensure menu stays within viewport
const rect = this.contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
this.contextMenu.style.left = `${event.clientX - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
this.contextMenu.style.top = `${event.clientY - rect.height}px`;
}
// Add event listeners to menu items
this.contextMenu.querySelectorAll('li').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const action = e.currentTarget.dataset.action;
const nodeId = e.currentTarget.dataset.nodeId;
console.log('Context menu action:', action, 'for node:', nodeId);
this.performContextMenuAction(action, nodeId);
this.hideContextMenu();
});
});
}
/**
* Hide the context menu
*/
hideContextMenu() {
if (this.contextMenu) {
this.contextMenu.style.display = 'none';
}
}
/**
* Perform action from the context menu
* @param {string} action - The action to perform ('hide' or 'delete')
* @param {string} nodeId - The ID of the node
*/
performContextMenuAction(action, nodeId) {
console.log('Performing action:', action, 'on node:', nodeId);
switch (action) {
case 'focus':
this.focusOnNode(nodeId);
break;
case 'hide':
this.hideNodeAndOrphans(nodeId);
break;
case 'delete':
this.deleteNodeAndOrphans(nodeId);
break;
case 'details':
const node = this.nodes.get(nodeId);
if (node) {
this.showNodeDetails(node);
}
break;
default:
console.warn('Unknown action:', action);
}
}
/**
* Add an operation to the history stack
* @param {string} type - The type of operation ('hide', 'delete')
* @param {Object} data - The data needed to revert the operation
*/
addToHistory(type, data) {
this.history.push({ type, data });
}
/**
* Revert the last action
*/
async revertLastAction() {
const lastAction = this.history.pop();
if (!lastAction) {
console.log('No actions to revert.');
return;
}
switch (lastAction.type) {
case 'hide':
// Revert hiding nodes by un-hiding them
const updates = lastAction.data.nodeIds.map(id => ({ id: id, hidden: false }));
this.nodes.update(updates);
break;
case 'delete':
try {
const response = await fetch('/api/graph/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(lastAction)
});
const result = await response.json();
if (result.success) {
console.log('Delete action reverted successfully on backend.');
// Re-add all nodes and edges from the history to the local view
this.nodes.add(lastAction.data.nodes);
this.edges.add(lastAction.data.edges);
} else {
console.error('Failed to revert delete action on backend:', result.error);
// Push the action back onto the history stack if the API call failed
this.history.push(lastAction);
}
} catch (error) {
console.error('Error during revert API call:', error);
this.history.push(lastAction);
}
break;
}
}
/**
* 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;