Spaces:
Running
Running
| // Graph visualization class | |
| class DirectedGraph { | |
| constructor(canvasId) { | |
| this.canvas = document.getElementById(canvasId); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.nodes = []; | |
| this.edges = []; | |
| this.selectedNode = null; | |
| this.hoveredNode = null; | |
| this.offset = { x: 0, y: 0 }; | |
| this.scale = 1; | |
| this.isDragging = false; | |
| this.dragStart = { x: 0, y: 0 }; | |
| this.editMode = false; | |
| this.connectingMode = false; | |
| this.connectingFrom = null; | |
| this.tempConnection = null; | |
| this.nodeIdCounter = 1; | |
| this.setupCanvas(); | |
| this.createNodes(); | |
| this.createEdges(); | |
| this.setupEventListeners(); | |
| this.animate(); | |
| } | |
| setupCanvas() { | |
| const rect = this.canvas.parentElement.getBoundingClientRect(); | |
| this.canvas.width = rect.width; | |
| this.canvas.height = rect.height; | |
| } | |
| createNodes() { | |
| // Document nodes | |
| this.nodes.push({ | |
| id: 'doc1', | |
| x: 100, | |
| y: 100, | |
| radius: 40, | |
| color: '#3b82f6', | |
| label: 'Tree123.json', | |
| data: { person: 'John Dow', type: 'document' } | |
| }); | |
| this.nodes.push({ | |
| id: 'doc2', | |
| x: 100, | |
| y: 250, | |
| radius: 40, | |
| color: '#a855f7', | |
| label: 'Tree456.json', | |
| data: { person: 'John Dowe', type: 'document' } | |
| }); | |
| // Extracted nodes | |
| this.nodes.push({ | |
| id: 'extracted1', | |
| x: 300, | |
| y: 100, | |
| radius: 35, | |
| color: '#60a5fa', | |
| label: 'Extracted 1', | |
| data: { operation: 'extract_person', source: 'doc1' } | |
| }); | |
| this.nodes.push({ | |
| id: 'extracted2', | |
| x: 300, | |
| y: 250, | |
| radius: 35, | |
| color: '#c084fc', | |
| label: 'Extracted 2', | |
| data: { operation: 'extract_person', source: 'doc2' } | |
| }); | |
| // Comparison node | |
| this.nodes.push({ | |
| id: 'comparison', | |
| x: 500, | |
| y: 175, | |
| radius: 45, | |
| color: '#34d399', | |
| label: 'Comparison', | |
| data: { operation: 'compare_names', threshold: 0.8, result: 0.85 } | |
| }); | |
| // Classification node | |
| this.nodes.push({ | |
| id: 'classification', | |
| x: 700, | |
| y: 175, | |
| radius: 50, | |
| color: '#fbbf24', | |
| label: 'Classification', | |
| data: { | |
| operation: 'classify_same_person', | |
| method: 'fact_based', | |
| result: 'High probability match', | |
| confidence: 85 | |
| } | |
| }); | |
| } | |
| createEdges() { | |
| // Document to extraction | |
| this.edges.push({ from: 'doc1', to: 'extracted1', label: 'extract' }); | |
| this.edges.push({ from: 'doc2', to: 'extracted2', label: 'extract' }); | |
| // Extraction to comparison | |
| this.edges.push({ from: 'extracted1', to: 'comparison', label: 'input' }); | |
| this.edges.push({ from: 'extracted2', to: 'comparison', label: 'input' }); | |
| // Comparison to classification | |
| this.edges.push({ from: 'comparison', to: 'classification', label: 'result' }); | |
| } | |
| setupEventListeners() { | |
| this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e)); | |
| this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); | |
| this.canvas.addEventListener('mouseup', () => this.handleMouseUp()); | |
| this.canvas.addEventListener('wheel', (e) => this.handleWheel(e)); | |
| this.canvas.addEventListener('click', (e) => this.handleClick(e)); | |
| window.addEventListener('resize', () => { | |
| this.setupCanvas(); | |
| }); | |
| } | |
| handleMouseDown(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| if (this.editMode && this.hoveredNode) { | |
| if (this.connectingMode) { | |
| if (this.connectingFrom && this.connectingFrom !== this.hoveredNode) { | |
| // Create connection | |
| this.edges.push({ | |
| from: this.connectingFrom.id, | |
| to: this.hoveredNode.id, | |
| label: 'connection' | |
| }); | |
| this.connectingFrom = null; | |
| this.connectingMode = false; | |
| this.canvas.classList.remove('edit-mode-cursor'); | |
| } | |
| } else { | |
| this.selectedNode = this.hoveredNode; | |
| this.showEditPanel(this.selectedNode); | |
| } | |
| } else if (!this.editMode) { | |
| this.isDragging = true; | |
| this.dragStart = { x: x - this.offset.x, y: y - this.offset.y }; | |
| this.canvas.style.cursor = 'grabbing'; | |
| } else if (this.editMode && !this.hoveredNode) { | |
| // Add new node at click position | |
| const worldX = (x - this.offset.x) / this.scale; | |
| const worldY = (y - this.offset.y) / this.scale; | |
| this.addNodeAt(worldX, worldY); | |
| } | |
| } | |
| handleMouseMove(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = (e.clientX - rect.left - this.offset.x) / this.scale; | |
| const y = (e.clientY - rect.top - this.offset.y) / this.scale; | |
| if (this.isDragging && !this.editMode) { | |
| this.offset.x = e.clientX - rect.left - this.dragStart.x; | |
| this.offset.y = e.clientY - rect.top - this.dragStart.y; | |
| } else if (this.connectingMode && this.connectingFrom) { | |
| this.tempConnection = { x: (e.clientX - rect.left - this.offset.x) / this.scale, y: (e.clientY - rect.top - this.offset.y) / this.scale }; | |
| } else { | |
| // Check for hover over nodes | |
| this.hoveredNode = null; | |
| for (const node of this.nodes) { | |
| const dist = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2); | |
| if (dist <= node.radius) { | |
| this.hoveredNode = node; | |
| if (this.editMode) { | |
| if (this.connectingMode) { | |
| this.canvas.style.cursor = 'crosshair'; | |
| } else { | |
| this.canvas.style.cursor = 'pointer'; | |
| } | |
| } else { | |
| this.canvas.style.cursor = 'pointer'; | |
| } | |
| break; | |
| } | |
| } | |
| if (!this.hoveredNode) { | |
| this.canvas.style.cursor = this.editMode ? (this.connectingMode ? 'crosshair' : 'crosshair') : 'move'; | |
| } | |
| } | |
| } | |
| handleMouseUp() { | |
| this.isDragging = false; | |
| this.canvas.style.cursor = this.editMode ? 'crosshair' : 'move'; | |
| } | |
| handleWheel(e) { | |
| e.preventDefault(); | |
| const delta = e.deltaY > 0 ? 0.9 : 1.1; | |
| this.scale *= delta; | |
| this.scale = Math.max(0.5, Math.min(2, this.scale)); | |
| } | |
| handleClick(e) { | |
| if (this.hoveredNode) { | |
| this.selectedNode = this.hoveredNode; | |
| this.showNodeInfo(this.selectedNode); | |
| } | |
| } | |
| showNodeInfo(node) { | |
| const infoPanel = document.getElementById('infoPanel'); | |
| const nodeInfo = document.getElementById('nodeInfo'); | |
| infoPanel.classList.remove('hidden'); | |
| document.getElementById('editPanel').classList.add('hidden'); | |
| let html = ` | |
| <div class="space-y-2"> | |
| <p><span class="text-gray-400">ID:</span> <span class="font-medium">${node.id}</span></p> | |
| <p><span class="text-gray-400">Label:</span> <span class="font-medium">${node.label}</span></p> | |
| <p><span class="text-gray-400">Type:</span> <span class="font-medium">${node.data.type || 'custom'}</span></p> | |
| `; | |
| if (node.data.person) { | |
| html += `<p><span class="text-gray-400">Person:</span> <span class="font-medium">${node.data.person}</span></p>`; | |
| } | |
| if (node.data.operation) { | |
| html += `<p><span class="text-gray-400">Operation:</span> <span class="font-medium">${node.data.operation}</span></p>`; | |
| } | |
| if (node.data.result) { | |
| html += `<p><span class="text-gray-400">Result:</span> <span class="font-medium text-green-400">${node.data.result}</span></p>`; | |
| } | |
| if (node.data.confidence) { | |
| html += `<p><span class="text-gray-400">Confidence:</span> <span class="font-medium">${node.data.confidence}%</span></p>`; | |
| } | |
| html += `</div>`; | |
| nodeInfo.innerHTML = html; | |
| } | |
| showEditPanel(node) { | |
| const editPanel = document.getElementById('editPanel'); | |
| document.getElementById('infoPanel').classList.add('hidden'); | |
| editPanel.classList.remove('hidden'); | |
| document.getElementById('nodeLabel').value = node.label; | |
| document.getElementById('nodeType').value = node.data.type || 'custom'; | |
| document.getElementById('nodeColor').value = node.color; | |
| } | |
| addNodeAt(x, y) { | |
| const newNode = { | |
| id: 'node_' + this.nodeIdCounter++, | |
| x: x, | |
| y: y, | |
| radius: 35, | |
| color: '#' + Math.floor(Math.random()*16777215).toString(16), | |
| label: 'New Node', | |
| data: { type: 'custom' } | |
| }; | |
| this.nodes.push(newNode); | |
| this.selectedNode = newNode; | |
| this.showEditPanel(newNode); | |
| } | |
| deleteNode(nodeId) { | |
| this.nodes = this.nodes.filter(n => n.id !== nodeId); | |
| this.edges = this.edges.filter(e => e.from !== nodeId && e.to !== nodeId); | |
| if (this.selectedNode && this.selectedNode.id === nodeId) { | |
| this.selectedNode = null; | |
| document.getElementById('editPanel').classList.add('hidden'); | |
| } | |
| } | |
| draw() { | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| this.ctx.save(); | |
| this.ctx.translate(this.offset.x, this.offset.y); | |
| this.ctx.scale(this.scale, this.scale); | |
| // Draw edges | |
| this.ctx.strokeStyle = 'rgba(168, 85, 247, 0.3)'; | |
| this.ctx.lineWidth = 2; | |
| for (const edge of this.edges) { | |
| const fromNode = this.nodes.find(n => n.id === edge.from); | |
| const toNode = this.nodes.find(n => n.id === edge.to); | |
| if (fromNode && toNode) { | |
| this.drawArrow(fromNode, toNode, edge.label); | |
| } | |
| } | |
| // Draw temporary connection line | |
| if (this.connectingMode && this.connectingFrom && this.tempConnection) { | |
| this.ctx.strokeStyle = '#10b981'; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.setLineDash([5, 5]); | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.connectingFrom.x, this.connectingFrom.y); | |
| this.ctx.lineTo(this.tempConnection.x, this.tempConnection.y); | |
| this.ctx.stroke(); | |
| this.ctx.setLineDash([]); | |
| } | |
| // Draw nodes | |
| for (const node of this.nodes) { | |
| this.drawNode(node); | |
| } | |
| this.ctx.restore(); | |
| } | |
| drawNode(node) { | |
| const isHovered = this.hoveredNode === node; | |
| const isSelected = this.selectedNode === node; | |
| const isConnectingFrom = this.connectingFrom === node; | |
| // Node glow effect | |
| if (isHovered || isSelected || isConnectingFrom) { | |
| const gradient = this.ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, node.radius * 2); | |
| gradient.addColorStop(0, (isConnectingFrom ? '#10b981' : node.color) + '60'); | |
| gradient.addColorStop(1, 'transparent'); | |
| this.ctx.fillStyle = gradient; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(node.x, node.y, node.radius * 2, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| } | |
| // Node circle | |
| this.ctx.fillStyle = node.color; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(node.x, node.y, isHovered ? node.radius * 1.1 : node.radius, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Node border | |
| if (isConnectingFrom) { | |
| this.ctx.strokeStyle = '#10b981'; | |
| this.ctx.lineWidth = 4; | |
| } else if (isSelected) { | |
| this.ctx.strokeStyle = '#fff'; | |
| this.ctx.lineWidth = 3; | |
| } else { | |
| this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; | |
| this.ctx.lineWidth = 2; | |
| } | |
| this.ctx.stroke(); | |
| // Node label | |
| this.ctx.fillStyle = '#fff'; | |
| this.ctx.font = 'bold 12px system-ui'; | |
| this.ctx.textAlign = 'center'; | |
| this.ctx.textBaseline = 'middle'; | |
| this.ctx.fillText(node.label, node.x, node.y); | |
| } | |
| drawArrow(fromNode, toNode, label) { | |
| const dx = toNode.x - fromNode.x; | |
| const dy = toNode.y - fromNode.y; | |
| const angle = Math.atan2(dy, dx); | |
| const startX = fromNode.x + fromNode.radius * Math.cos(angle); | |
| const startY = fromNode.y + fromNode.radius * Math.sin(angle); | |
| const endX = toNode.x - toNode.radius * Math.cos(angle); | |
| const endY = toNode.y - toNode.radius * Math.sin(angle); | |
| // Draw line | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(startX, startY); | |
| this.ctx.lineTo(endX, endY); | |
| this.ctx.stroke(); | |
| // Draw arrowhead | |
| const arrowLength = 15; | |
| const arrowAngle = Math.PI / 6; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(endX, endY); | |
| this.ctx.lineTo( | |
| endX - arrowLength * Math.cos(angle - arrowAngle), | |
| endY - arrowLength * Math.sin(angle - arrowAngle) | |
| ); | |
| this.ctx.moveTo(endX, endY); | |
| this.ctx.lineTo( | |
| endX - arrowLength * Math.cos(angle + arrowAngle), | |
| endY - arrowLength * Math.sin(angle + arrowAngle) | |
| ); | |
| this.ctx.stroke(); | |
| // Draw label | |
| const midX = (startX + endX) / 2; | |
| const midY = (startY + endY) / 2; | |
| this.ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; | |
| this.ctx.font = '10px system-ui'; | |
| this.ctx.textAlign = 'center'; | |
| this.ctx.fillText(label, midX, midY - 10); | |
| } | |
| animate() { | |
| this.draw(); | |
| requestAnimationFrame(() => this.animate()); | |
| } | |
| } | |
| // Initialize graph | |
| let graph; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| graph = new DirectedGraph('graphCanvas'); | |
| }); | |
| // Control functions | |
| function zoomIn() { | |
| if (graph) { | |
| graph.scale *= 1.2; | |
| graph.scale = Math.min(2, graph.scale); | |
| } | |
| } | |
| function zoomOut() { | |
| if (graph) { | |
| graph.scale *= 0.8; | |
| graph.scale = Math.max(0.5, graph.scale); | |
| } | |
| } | |
| function fitToScreen() { | |
| if (graph) { | |
| graph.scale = 1; | |
| graph.offset = { x: 0, y: 0 }; | |
| } | |
| } | |
| function resetGraph() { | |
| if (graph) { | |
| graph.selectedNode = null; | |
| graph.hoveredNode = null; | |
| graph.connectingMode = false; | |
| graph.connectingFrom = null; | |
| document.getElementById('infoPanel').classList.add('hidden'); | |
| document.getElementById('editPanel').classList.add('hidden'); | |
| fitToScreen(); | |
| } | |
| } | |
| function toggleFullscreen() { | |
| const graphContainer = document.getElementById('graphCanvas').parentElement; | |
| if (!document.fullscreenElement) { | |
| graphContainer.requestFullscreen(); | |
| } else { | |
| document.exitFullscreen(); | |
| } | |
| } | |
| function toggleEditMode() { | |
| if (!graph) return; | |
| graph.editMode = !graph.editMode; | |
| const editBtn = document.getElementById('editModeBtn'); | |
| const editIndicator = document.getElementById('editIndicator'); | |
| if (graph.editMode) { | |
| editBtn.classList.remove('bg-purple-600', 'hover:bg-purple-700'); | |
| editBtn.classList.add('bg-orange-600', 'hover:bg-orange-700'); | |
| editBtn.querySelector('span').textContent = 'Exit Edit'; | |
| editIndicator.classList.remove('hidden'); | |
| graph.canvas.classList.add('edit-mode-cursor'); | |
| } else { | |
| editBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700'); | |
| editBtn.classList.add('bg-purple-600', 'hover:bg-purple-700'); | |
| editBtn.querySelector('span').textContent = 'Edit Mode'; | |
| editIndicator.classList.add('hidden'); | |
| graph.canvas.classList.remove('edit-mode-cursor'); | |
| graph.connectingMode = false; | |
| graph.connectingFrom = null; | |
| document.getElementById('editPanel').classList.add('hidden'); | |
| } | |
| } | |
| function addNode() { | |
| if (graph && graph.editMode) { | |
| const centerX = (graph.canvas.width / 2 - graph.offset.x) / graph.scale; | |
| const centerY = (graph.canvas.height / 2 - graph.offset.y) / graph.scale; | |
| graph.addNodeAt(centerX, centerY); | |
| } | |
| } | |
| function deleteSelectedNode() { | |
| if (graph && graph.selectedNode && graph.editMode) { | |
| graph.deleteNode(graph.selectedNode.id); | |
| } | |
| } | |
| function connectNodes() { | |
| if (graph && graph.editMode) { | |
| graph.connectingMode = !graph.connectingMode; | |
| if (graph.connectingMode) { | |
| graph.canvas.style.cursor = 'crosshair'; | |
| } else { | |
| graph.connectingFrom = null; | |
| graph.canvas.style.cursor = 'crosshair'; | |
| } | |
| } | |
| } | |
| function saveNodeChanges() { | |
| if (graph && graph.selectedNode) { | |
| graph.selectedNode.label = document.getElementById('nodeLabel').value; | |
| graph.selectedNode.data.type = document.getElementById('nodeType').value; | |
| graph.selectedNode.color = document.getElementById('nodeColor').value; | |
| document.getElementById('editPanel').classList.add('hidden'); | |
| } | |
| } | |
| function cancelEdit() { | |
| document.getElementById('editPanel').classList.add('hidden'); | |
| if (graph) { | |
| graph.selectedNode = null; | |
| } | |
| } | |
| function highlightNode(nodeId) { | |
| if (graph) { | |
| const node = graph.nodes.find(n => n.id === nodeId || | |
| (n.data && n.data.source === nodeId) || | |
| (n.id === 'extracted1' && nodeId === 'doc1') || | |
| (n.id === 'extracted2' && nodeId === 'doc2')); | |
| if (node) { | |
| graph.selectedNode = node; | |
| graph.showNodeInfo(node); | |
| // Pan to node | |
| graph.offset.x = -node.x * graph.scale + graph.canvas.width / 2; | |
| graph.offset.y = -node.y * graph.scale + graph.canvas.height / 2; | |
| } | |
| } | |
| } | |