Files
Media.JoshHeaps.Net/Media.JoshHeaps.Net/wwwroot/js/network-graph.js
T
2026-01-02 17:13:39 -07:00

1252 lines
40 KiB
JavaScript

// Network Graph Visualization and Management with Community Detection
// State
let currentGraphId = selectedGraphId;
let graphData = { nodes: [], edges: [] };
let selectedNodes = new Set();
let editingNodeId = null;
let editingGraphId = null;
let contextMenuNodeId = null;
// Canvas and rendering
let canvas, ctx;
let scale = 1;
let offsetX = 0, offsetY = 0;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let dragNodeId = null;
// Physics simulation with community detection
let nodes = [];
let edges = [];
let communities = [];
const REPULSION = 10000;
const ATTRACTION = 0.0004;
const COMMUNITY_ATTRACTION = 0.0012;
const DAMPING = 0.85;
const NODE_RADIUS_BASE = 15;
const MIN_NODE_RADIUS = 12;
const MAX_SIMULATION_ITERATIONS = 500;
const MAX_VELOCITY = 10; // Cap maximum velocity to prevent explosive movements
const MIN_DISTANCE = 8; // Minimum distance between node centers
const MIN_VELOCITY = 0.01; // Threshold below which to stop the node completely
let simulationIterations = 0;
let simulationRunning = false;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initializeCanvas();
initializeEventListeners();
if (currentGraphId) {
loadGraphData(currentGraphId);
showWorkspace();
}
});
// Canvas Setup
function initializeCanvas() {
canvas = document.getElementById('graph-canvas');
if (!canvas) return;
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas() {
if (!canvas) return;
const container = canvas.parentElement;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
render();
}
// Event Listeners
function initializeEventListeners() {
// Graph management
document.getElementById('new-graph-btn')?.addEventListener('click', showNewGraphModal);
document.getElementById('graph-form')?.addEventListener('submit', handleGraphSubmit);
// Node management
document.getElementById('add-node-btn')?.addEventListener('click', showNewNodeModal);
document.getElementById('link-nodes-btn')?.addEventListener('click', linkSelectedNodes);
document.getElementById('node-form')?.addEventListener('submit', handleNodeSubmit);
// Search and filter
document.getElementById('search-nodes')?.addEventListener('input', handleSearch);
document.getElementById('apply-filter-btn')?.addEventListener('click', applyDegreeFilter);
document.getElementById('clear-filter-btn')?.addEventListener('click', clearFilter);
// Canvas controls
document.getElementById('zoom-in-btn')?.addEventListener('click', () => zoomCanvas(1.2));
document.getElementById('zoom-out-btn')?.addEventListener('click', () => zoomCanvas(0.8));
document.getElementById('reset-view-btn')?.addEventListener('click', resetView);
// Canvas interactions
if (canvas) {
canvas.addEventListener('mousedown', handleCanvasMouseDown);
canvas.addEventListener('mousemove', handleCanvasMouseMove);
canvas.addEventListener('mouseup', handleCanvasMouseUp);
canvas.addEventListener('wheel', handleCanvasWheel);
canvas.addEventListener('dblclick', handleCanvasDoubleClick);
canvas.addEventListener('contextmenu', handleCanvasContextMenu);
}
// Close context menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) {
hideContextMenu();
}
});
// Context menu item clicks
document.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (menuItem && menuItem.dataset.action) {
e.preventDefault();
e.stopPropagation();
const action = menuItem.dataset.action;
switch (action) {
case 'edit':
handleContextEdit();
break;
case 'link':
handleContextLinkTo();
break;
case 'select':
handleContextSelect();
break;
case 'delete':
handleContextDelete();
break;
}
}
});
}
// Graph Management
function selectGraph(graphId) {
currentGraphId = graphId;
window.location.href = `/NetworkGraph?graphId=${graphId}`;
}
async function loadGraphData(graphId) {
try {
const response = await fetch(`/api/graph/${graphId}/data`);
if (!response.ok) throw new Error('Failed to load graph');
graphData = await response.json();
initializePhysics();
showWorkspace();
startSimulation();
} catch (error) {
console.error('Error loading graph:', error);
alert('Failed to load graph data');
}
}
function showWorkspace() {
const workspace = document.getElementById('graph-workspace');
if (workspace) {
workspace.style.display = 'flex';
requestAnimationFrame(() => {
resizeCanvas();
centerGraph();
render();
});
}
}
function showNewGraphModal() {
editingGraphId = null;
document.getElementById('graph-modal-title').textContent = 'New Graph';
document.getElementById('graph-name').value = '';
document.getElementById('graph-description').value = '';
document.getElementById('save-graph-btn').textContent = 'Create Graph';
document.getElementById('graph-modal').style.display = 'flex';
}
function editGraph(id, name, description) {
editingGraphId = id;
document.getElementById('graph-modal-title').textContent = 'Edit Graph';
document.getElementById('graph-name').value = name;
document.getElementById('graph-description').value = description;
document.getElementById('save-graph-btn').textContent = 'Save Changes';
document.getElementById('graph-modal').style.display = 'flex';
}
async function handleGraphSubmit(e) {
e.preventDefault();
const name = document.getElementById('graph-name').value.trim();
const description = document.getElementById('graph-description').value.trim();
if (!name) return;
try {
let response;
if (editingGraphId) {
response = await fetch(`/api/graph/${editingGraphId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
} else {
response = await fetch('/api/graph/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
}
if (!response.ok) throw new Error('Failed to save graph');
closeGraphModal();
window.location.reload();
} catch (error) {
console.error('Error saving graph:', error);
alert('Failed to save graph');
}
}
async function deleteGraph(id, name) {
if (!confirm(`Are you sure you want to delete "${name}"? This cannot be undone.`)) return;
try {
const response = await fetch(`/api/graph/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete graph');
window.location.href = '/NetworkGraph';
} catch (error) {
console.error('Error deleting graph:', error);
alert('Failed to delete graph');
}
}
async function cloneGraph(id, name) {
const newName = prompt(`Enter name for cloned graph:`, `${name} (Copy)`);
if (!newName) return;
try {
const response = await fetch(`/api/graph/${id}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newName })
});
if (!response.ok) throw new Error('Failed to clone graph');
const newGraph = await response.json();
window.location.href = `/NetworkGraph?graphId=${newGraph.id}`;
} catch (error) {
console.error('Error cloning graph:', error);
alert('Failed to clone graph');
}
}
function closeGraphModal() {
document.getElementById('graph-modal').style.display = 'none';
}
// Node Management
function showNewNodeModal() {
if (!currentGraphId) return;
editingNodeId = null;
document.getElementById('node-modal-title').textContent = 'New Node';
document.getElementById('node-label').value = '';
document.getElementById('node-notes').value = '';
document.getElementById('save-node-btn').textContent = 'Create Node';
// Hide connections section for new nodes
document.getElementById('node-connections-section').style.display = 'none';
document.getElementById('node-modal').style.display = 'flex';
}
async function editNode(node) {
editingNodeId = node.id;
document.getElementById('node-modal-title').textContent = 'Edit Node';
document.getElementById('node-label').value = node.label;
document.getElementById('node-notes').value = node.notes || '';
document.getElementById('save-node-btn').textContent = 'Save Changes';
// Show connections section when editing
document.getElementById('node-connections-section').style.display = 'block';
// Load connections for this node
await loadNodeConnections(node.id);
document.getElementById('node-modal').style.display = 'flex';
}
async function handleNodeSubmit(e) {
e.preventDefault();
const label = document.getElementById('node-label').value.trim();
const notes = document.getElementById('node-notes').value.trim();
if (!label) return;
try {
let response;
if (editingNodeId) {
response = await fetch(`/api/graph/nodes/${editingNodeId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, notes })
});
} else {
response = await fetch(`/api/graph/${currentGraphId}/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, notes })
});
}
if (!response.ok) throw new Error('Failed to save node');
closeNodeModal();
await loadGraphData(currentGraphId);
} catch (error) {
console.error('Error saving node:', error);
alert('Failed to save node');
}
}
async function deleteNode(nodeId) {
if (!confirm('Delete this node and all its connections?')) return;
try {
const response = await fetch(`/api/graph/nodes/${nodeId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete node');
await loadGraphData(currentGraphId);
} catch (error) {
console.error('Error deleting node:', error);
alert('Failed to delete node');
}
}
function closeNodeModal() {
document.getElementById('node-modal').style.display = 'none';
// Clear connection search
document.getElementById('connection-search').value = '';
document.getElementById('connection-search-results').style.display = 'none';
}
// Node Connection Management (in modal)
let nodeConnections = [];
let connectionSearchTimeout = null;
async function loadNodeConnections(nodeId) {
if (!currentGraphId || !nodeId) return;
try {
// Get all edges for this graph
const edgesResponse = await fetch(`/api/graph/${currentGraphId}/edges`);
if (!edgesResponse.ok) throw new Error('Failed to load edges');
const allEdges = await edgesResponse.json();
// Get all nodes for the graph
const nodesResponse = await fetch(`/api/graph/${currentGraphId}/nodes`);
if (!nodesResponse.ok) throw new Error('Failed to load nodes');
const allNodes = await nodesResponse.json();
// Find connections where this node is the source or target (bidirectional)
nodeConnections = [];
allEdges.forEach(edge => {
if (edge.sourceNodeId === nodeId) {
const targetNode = allNodes.find(n => n.id === edge.targetNodeId);
if (targetNode) {
nodeConnections.push({
edgeId: edge.id,
nodeId: targetNode.id,
label: targetNode.label
});
}
} else if (edge.targetNodeId === nodeId) {
const sourceNode = allNodes.find(n => n.id === edge.sourceNodeId);
if (sourceNode) {
nodeConnections.push({
edgeId: edge.id,
nodeId: sourceNode.id,
label: sourceNode.label
});
}
}
});
renderNodeConnections();
} catch (error) {
console.error('Error loading connections:', error);
}
}
function renderNodeConnections() {
const listEl = document.getElementById('node-connections-list');
if (!listEl) return;
if (nodeConnections.length === 0) {
listEl.innerHTML = '<div class="connections-empty">No connections yet</div>';
return;
}
listEl.innerHTML = nodeConnections.map(conn => `
<div class="connection-item">
<div class="connection-info">
<div class="connection-label">${escapeHtml(conn.label)}</div>
</div>
<button type="button" class="connection-remove" onclick="removeConnection(${conn.edgeId})" title="Remove connection">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`).join('');
}
async function removeConnection(edgeId) {
if (!confirm('Remove this connection?')) return;
try {
const response = await fetch(`/api/graph/edges/${edgeId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to remove connection');
// Reload connections
await loadNodeConnections(editingNodeId);
} catch (error) {
console.error('Error removing connection:', error);
alert('Failed to remove connection');
}
}
// Connection search functionality
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('connection-search');
if (searchInput) {
searchInput.addEventListener('input', handleConnectionSearch);
searchInput.addEventListener('blur', () => {
// Delay hiding to allow click on result
setTimeout(() => {
document.getElementById('connection-search-results').style.display = 'none';
}, 200);
});
}
});
async function handleConnectionSearch(e) {
const query = e.target.value.trim();
if (!query) {
document.getElementById('connection-search-results').style.display = 'none';
return;
}
// Debounce search
clearTimeout(connectionSearchTimeout);
connectionSearchTimeout = setTimeout(async () => {
await performConnectionSearch(query);
}, 300);
}
async function performConnectionSearch(query) {
if (!currentGraphId || !editingNodeId) return;
try {
const response = await fetch(`/api/graph/${currentGraphId}/nodes/search?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Search failed');
const results = await response.json();
// Filter out the current node and already connected nodes
const connectedNodeIds = nodeConnections.map(c => c.nodeId);
const availableResults = results.filter(n =>
n.id !== editingNodeId && !connectedNodeIds.includes(n.id)
);
renderConnectionSearchResults(availableResults);
} catch (error) {
console.error('Connection search error:', error);
}
}
function renderConnectionSearchResults(results) {
const resultsEl = document.getElementById('connection-search-results');
if (!resultsEl) return;
if (results.length === 0) {
resultsEl.innerHTML = '<div class="search-result-item" style="text-align: center; color: var(--text-secondary);">No nodes found</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = results.map(node => `
<div class="search-result-item" onclick="addConnection(${node.id}, '${escapeHtml(node.label)}')">
<div class="search-result-label">${escapeHtml(node.label)}</div>
${node.notes ? `<div class="search-result-notes">${escapeHtml(node.notes)}</div>` : ''}
</div>
`).join('');
resultsEl.style.display = 'block';
}
async function addConnection(targetNodeId, targetLabel) {
if (!currentGraphId || !editingNodeId) return;
try {
const response = await fetch(`/api/graph/${currentGraphId}/edges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceNodeId: editingNodeId,
targetNodeId: targetNodeId
})
});
if (!response.ok) throw new Error('Failed to create connection');
// Clear search and reload connections
document.getElementById('connection-search').value = '';
document.getElementById('connection-search-results').style.display = 'none';
await loadNodeConnections(editingNodeId);
} catch (error) {
console.error('Error adding connection:', error);
alert('Failed to add connection');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Edge Management
async function linkSelectedNodes() {
if (selectedNodes.size < 2) {
alert('Please select at least 2 nodes to link');
return;
}
const nodeIds = Array.from(selectedNodes);
const sourceId = nodeIds[0];
try {
for (let i = 1; i < nodeIds.length; i++) {
await fetch(`/api/graph/${currentGraphId}/edges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceNodeId: sourceId, targetNodeId: nodeIds[i] })
});
}
selectedNodes.clear();
await loadGraphData(currentGraphId);
} catch (error) {
console.error('Error creating edges:', error);
alert('Failed to create connections');
}
}
async function deleteEdge(edgeId) {
try {
const response = await fetch(`/api/graph/edges/${edgeId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete edge');
await loadGraphData(currentGraphId);
} catch (error) {
console.error('Error deleting edge:', error);
}
}
// Search and Filter
async function handleSearch(e) {
const query = e.target.value.trim();
// Show filter controls when there's a selected node
const hasSelection = selectedNodes.size > 0;
document.getElementById('degrees-input').style.display = hasSelection ? 'inline-block' : 'none';
document.getElementById('filter-mode-select').style.display = hasSelection ? 'inline-block' : 'none';
document.getElementById('apply-filter-btn').style.display = hasSelection ? 'inline-block' : 'none';
document.getElementById('clear-filter-btn').style.display = hasSelection ? 'inline-block' : 'none';
if (!query) {
clearFilter();
return;
}
try {
const response = await fetch(`/api/graph/${currentGraphId}/nodes/search?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Search failed');
const searchNodes = await response.json();
highlightNodes(searchNodes.map(n => n.id));
} catch (error) {
console.error('Search error:', error);
}
}
async function applyDegreeFilter() {
if (selectedNodes.size === 0) {
alert('Please select a node to filter by (Ctrl+Click on a node)');
return;
}
const targetNodeId = Array.from(selectedNodes)[0];
const degrees = parseInt(document.getElementById('degrees-input').value) || 1;
const mode = document.getElementById('filter-mode-select').value;
// Calculate degrees of separation using BFS
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const distances = new Map();
const queue = [{ nodeId: targetNodeId, distance: 0 }];
const visited = new Set([targetNodeId]);
distances.set(targetNodeId, 0);
while (queue.length > 0) {
const { nodeId, distance } = queue.shift();
// Find all edges connected to this node
for (const edge of edges) {
let neighborId = null;
if (edge.source.id === nodeId) {
neighborId = edge.target.id;
} else if (edge.target.id === nodeId) {
neighborId = edge.source.id;
}
if (neighborId && !visited.has(neighborId)) {
visited.add(neighborId);
distances.set(neighborId, distance + 1);
queue.push({ nodeId: neighborId, distance: distance + 1 });
}
}
}
// Filter nodes based on mode
const filteredNodeIds = [];
for (const node of nodes) {
if (node.id === targetNodeId) continue; // Exclude the target node itself
const dist = distances.get(node.id);
const hasConnection = dist !== undefined;
switch (mode) {
case 'exact':
if (dist === degrees) {
filteredNodeIds.push(node.id);
}
break;
case 'within':
if (hasConnection && dist <= degrees) {
filteredNodeIds.push(node.id);
}
break;
case 'outside':
if (!hasConnection || dist > degrees) {
filteredNodeIds.push(node.id);
}
break;
}
}
highlightNodes(filteredNodeIds);
}
function clearFilter() {
// Clear highlights
nodes.forEach(n => n.highlighted = false);
render();
}
function highlightNodes(nodeIds) {
nodes.forEach(n => n.highlighted = nodeIds.includes(n.id));
render();
}
// Community Detection using Louvain-inspired algorithm
function detectCommunities() {
if (nodes.length === 0) return [];
// Build adjacency map
const adjacency = new Map();
nodes.forEach(n => adjacency.set(n.id, new Set()));
edges.forEach(e => {
adjacency.get(e.source.id).add(e.target.id);
adjacency.get(e.target.id).add(e.source.id);
});
// Initialize each node in its own community
const nodeToCommunity = new Map();
nodes.forEach((n, idx) => nodeToCommunity.set(n.id, idx));
let improved = true;
let iterations = 0;
const maxIterations = 10;
// Iteratively optimize modularity
while (improved && iterations < maxIterations) {
improved = false;
iterations++;
for (const node of nodes) {
const currentCommunity = nodeToCommunity.get(node.id);
const neighbors = adjacency.get(node.id);
if (neighbors.size === 0) continue;
// Count connections to each neighboring community
const communityCounts = new Map();
neighbors.forEach(neighborId => {
const neighborComm = nodeToCommunity.get(neighborId);
communityCounts.set(neighborComm, (communityCounts.get(neighborComm) || 0) + 1);
});
// Find best community (most connections)
let bestCommunity = currentCommunity;
let bestCount = communityCounts.get(currentCommunity) || 0;
communityCounts.forEach((count, community) => {
if (count > bestCount) {
bestCount = count;
bestCommunity = community;
}
});
if (bestCommunity !== currentCommunity) {
nodeToCommunity.set(node.id, bestCommunity);
improved = true;
}
}
}
// Group nodes by community
const communityMap = new Map();
nodeToCommunity.forEach((community, nodeId) => {
if (!communityMap.has(community)) {
communityMap.set(community, []);
}
const node = nodes.find(n => n.id === nodeId);
if (node) {
communityMap.get(community).push(node);
}
});
return Array.from(communityMap.values());
}
// Physics Simulation with Community-Aware Layout
function initializePhysics() {
nodes = graphData.nodes.map(n => ({
id: n.id,
label: n.label,
notes: n.notes,
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: 0,
vy: 0,
connections: n.connectionCount,
selected: false,
highlighted: false,
community: null,
communityCenter: { x: 0, y: 0 }
}));
edges = graphData.edges.map(e => ({
id: e.id,
source: nodes.find(n => n.id === e.sourceNodeId),
target: nodes.find(n => n.id === e.targetNodeId)
})).filter(e => e.source && e.target);
// Detect communities
communities = detectCommunities();
// Assign community centers in a circular pattern
const radius = Math.min(canvas.width, canvas.height) * 0.3;
communities.forEach((community, idx) => {
const angle = (idx / communities.length) * Math.PI * 2;
const centerX = canvas.width / 2 + Math.cos(angle) * radius;
const centerY = canvas.height / 2 + Math.sin(angle) * radius;
community.forEach(node => {
node.community = idx;
node.communityCenter = { x: centerX, y: centerY };
});
});
simulationIterations = 0;
simulationRunning = true;
centerGraph();
}
function startSimulation() {
const animate = () => {
if (simulationRunning) {
applyForces();
updatePositions();
}
render();
requestAnimationFrame(animate);
};
animate();
}
function applyForces() {
// Reset velocities slightly
nodes.forEach(node => {
node.vx *= 0.4;
node.vy *= 0.4;
});
// Repulsion between all nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
// Get node radii
const radiusI = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (nodes[i].connections * 4));
const radiusJ = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (nodes[j].connections * 4));
const minSeparation = radiusI + radiusJ + MIN_DISTANCE;
// Adjust repulsion based on community
let repulsionMultiplier;
if (nodes[i].community === nodes[j].community) {
// Moderate repulsion within same community
repulsionMultiplier = 0.5;
} else {
// Stronger repulsion between different communities
repulsionMultiplier = 1.0;
}
const force = (REPULSION * repulsionMultiplier) / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].vx -= fx;
nodes[i].vy -= fy;
nodes[j].vx += fx;
nodes[j].vy += fy;
}
}
// Attraction along edges (stronger within community)
edges.forEach(edge => {
const dx = edge.target.x - edge.source.x;
const dy = edge.target.y - edge.source.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const attractionStrength = edge.source.community === edge.target.community
? ATTRACTION * 1.2
: ATTRACTION * 0.4;
const force = dist * attractionStrength;
const fx = dx * force;
const fy = dy * force;
edge.source.vx += fx;
edge.source.vy += fy;
edge.target.vx -= fx;
edge.target.vy -= fy;
});
// Pull nodes toward their community center
nodes.forEach(node => {
const dx = node.communityCenter.x - node.x;
const dy = node.communityCenter.y - node.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// High-degree nodes pulled more strongly toward center
const centerPull = COMMUNITY_ATTRACTION * (1 + node.connections * 0.08);
const fx = dx * centerPull;
const fy = dy * centerPull;
node.vx += fx;
node.vy += fy;
});
// Cap velocities to prevent explosive movements and stop tiny vibrations
nodes.forEach(node => {
const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
// Stop very small movements to prevent vibration
if (speed < MIN_VELOCITY) {
node.vx = 0;
node.vy = 0;
} else if (speed > MAX_VELOCITY) {
node.vx = (node.vx / speed) * MAX_VELOCITY;
node.vy = (node.vy / speed) * MAX_VELOCITY;
}
});
}
function updatePositions() {
nodes.forEach(node => {
if (dragNodeId === node.id) return; // Don't update position of dragged node
node.vx *= DAMPING;
node.vy *= DAMPING;
node.x += node.vx;
node.y += node.vy;
});
// Apply position constraints to prevent overlap
resolveCollisions();
}
function resolveCollisions() {
// Multiple passes to resolve all collisions
for (let pass = 0; pass < 3; pass++) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Calculate required minimum distance
const radiusI = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (nodes[i].connections * 4));
const radiusJ = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (nodes[j].connections * 4));
const minDist = radiusI + radiusJ + MIN_DISTANCE;
// If overlapping, push them apart to exactly the minimum distance
if (dist < minDist && dist > 0.1) {
const overlap = minDist - dist;
const angle = Math.atan2(dy, dx);
// Push each node half the overlap distance
const pushDist = overlap / 2;
nodes[i].x -= Math.cos(angle) * pushDist;
nodes[i].y -= Math.sin(angle) * pushDist;
nodes[j].x += Math.cos(angle) * pushDist;
nodes[j].y += Math.sin(angle) * pushDist;
// Zero out velocities in the collision direction to stop vibration
const relVelX = nodes[j].vx - nodes[i].vx;
const relVelY = nodes[j].vy - nodes[i].vy;
const relVelDotNormal = (relVelX * dx + relVelY * dy) / dist;
if (relVelDotNormal < 0) {
// Moving toward each other, stop that component of velocity
const normalX = dx / dist;
const normalY = dy / dist;
nodes[i].vx += normalX * relVelDotNormal * 0.5;
nodes[i].vy += normalY * relVelDotNormal * 0.5;
nodes[j].vx -= normalX * relVelDotNormal * 0.5;
nodes[j].vy -= normalY * relVelDotNormal * 0.5;
}
}
}
}
}
}
// Rendering
function render() {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
// Apply transformations
ctx.scale(scale, scale);
ctx.translate(offsetX, offsetY);
// Draw edges
edges.forEach(edge => {
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border-primary');
ctx.lineWidth = 2 / scale;
ctx.beginPath();
ctx.moveTo(edge.source.x, edge.source.y);
ctx.lineTo(edge.target.x, edge.target.y);
ctx.stroke();
});
// Draw nodes
nodes.forEach(node => {
// Size based on connections (degree centrality)
const radius = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (node.connections * 4));
// Node circle
ctx.fillStyle = node.selected
? getComputedStyle(document.documentElement).getPropertyValue('--accent-primary')
: node.highlighted
? getComputedStyle(document.documentElement).getPropertyValue('--success')
: getComputedStyle(document.documentElement).getPropertyValue('--bg-tertiary');
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border-primary');
ctx.lineWidth = 2 / scale;
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Label
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary');
ctx.font = `${14 / scale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.label, node.x, node.y);
});
ctx.restore();
}
// Canvas Interactions
function handleCanvasMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - offsetX * scale) / scale;
const y = (e.clientY - rect.top - offsetY * scale) / scale;
dragNodeId = null;
for (const node of nodes) {
const radius = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (node.connections * 4));
const dist = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2);
if (dist < radius) {
dragNodeId = node.id;
if (!e.ctrlKey && !e.metaKey) {
selectedNodes.clear();
}
selectedNodes.add(node.id);
node.selected = true;
updateLinkButtonState();
render();
return;
}
}
if (!e.ctrlKey && !e.metaKey) {
selectedNodes.clear();
nodes.forEach(n => n.selected = false);
updateLinkButtonState();
}
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
}
function handleCanvasMouseMove(e) {
if (dragNodeId) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - offsetX * scale) / scale;
const y = (e.clientY - rect.top - offsetY * scale) / scale;
const node = nodes.find(n => n.id === dragNodeId);
if (node) {
node.x = x;
node.y = y;
node.vx = 0;
node.vy = 0;
render();
}
return;
}
if (isDragging) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
offsetX += dx / scale;
offsetY += dy / scale;
dragStartX = e.clientX;
dragStartY = e.clientY;
render();
}
}
function handleCanvasMouseUp() {
dragNodeId = null;
isDragging = false;
}
function handleCanvasWheel(e) {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
zoomCanvas(zoomFactor);
}
function handleCanvasDoubleClick(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - offsetX * scale) / scale;
const y = (e.clientY - rect.top - offsetY * scale) / scale;
for (const node of nodes) {
const radius = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (node.connections * 4));
const dist = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2);
if (dist < radius) {
editNode(node);
return;
}
}
}
function zoomCanvas(factor) {
scale *= factor;
scale = Math.max(0.1, Math.min(5, scale));
render();
}
function resetView() {
scale = 1;
centerGraph();
render();
}
function centerGraph() {
if (nodes.length === 0) {
offsetX = 0;
offsetY = 0;
return;
}
const minX = Math.min(...nodes.map(n => n.x));
const maxX = Math.max(...nodes.map(n => n.x));
const minY = Math.min(...nodes.map(n => n.y));
const maxY = Math.max(...nodes.map(n => n.y));
const graphCenterX = (minX + maxX) / 2;
const graphCenterY = (minY + maxY) / 2;
offsetX = canvas.width / 2 / scale - graphCenterX;
offsetY = canvas.height / 2 / scale - graphCenterY;
}
function updateLinkButtonState() {
const btn = document.getElementById('link-nodes-btn');
if (btn) {
btn.disabled = selectedNodes.size < 2;
}
// Show/hide filter controls based on selection
const hasSelection = selectedNodes.size > 0;
document.getElementById('degrees-input').style.display = hasSelection ? 'inline-block' : 'none';
document.getElementById('filter-mode-select').style.display = hasSelection ? 'inline-block' : 'none';
document.getElementById('apply-filter-btn').style.display = hasSelection ? 'inline-block' : 'none';
document.getElementById('clear-filter-btn').style.display = hasSelection ? 'inline-block' : 'none';
}
// Context Menu
function handleCanvasContextMenu(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - offsetX * scale) / scale;
const y = (e.clientY - rect.top - offsetY * scale) / scale;
// Find clicked node
let clickedNode = null;
for (const node of nodes) {
const radius = Math.max(MIN_NODE_RADIUS, NODE_RADIUS_BASE + (node.connections * 4));
const dist = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2);
if (dist < radius) {
clickedNode = node;
break;
}
}
if (clickedNode) {
contextMenuNodeId = clickedNode.id;
showContextMenu(e.clientX, e.clientY);
} else {
hideContextMenu();
}
}
function showContextMenu(x, y) {
const menu = document.getElementById('node-context-menu');
if (!menu) return;
menu.style.display = 'block';
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Adjust if menu goes off screen
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = (x - rect.width) + 'px';
}
if (rect.bottom > window.innerHeight) {
menu.style.top = (y - rect.height) + 'px';
}
}
function hideContextMenu() {
const menu = document.getElementById('node-context-menu');
if (menu) {
menu.style.display = 'none';
}
contextMenuNodeId = null;
}
function handleContextEdit() {
hideContextMenu();
if (contextMenuNodeId) {
const node = nodes.find(n => n.id === contextMenuNodeId);
if (node) {
editNode(node);
}
}
}
async function handleContextLinkTo() {
hideContextMenu();
if (!contextMenuNodeId) return;
if (selectedNodes.size === 0) {
alert('Please select at least one other node first (Ctrl+Click on nodes)');
return;
}
// Create edges from all selected nodes to the context menu node
try {
for (const selectedId of selectedNodes) {
if (selectedId !== contextMenuNodeId) {
await fetch(`/api/graph/${currentGraphId}/edges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceNodeId: selectedId,
targetNodeId: contextMenuNodeId
})
});
}
}
selectedNodes.clear();
await loadGraphData(currentGraphId);
} catch (error) {
console.error('Error creating edges:', error);
alert('Failed to create connections');
}
}
function handleContextSelect() {
hideContextMenu();
if (contextMenuNodeId) {
selectedNodes.add(contextMenuNodeId);
const node = nodes.find(n => n.id === contextMenuNodeId);
if (node) {
node.selected = true;
}
updateLinkButtonState();
render();
}
}
async function handleContextDelete() {
hideContextMenu();
if (contextMenuNodeId) {
await deleteNode(contextMenuNodeId);
}
}