/** * KnowledgeGraph Component * D3.js-based force-directed graph visualization for agent knowledge. */ import React, { useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { Box, Typography, Card, CardContent, CircularProgress, Alert, Chip, IconButton, Tooltip, } from '@mui/material'; import { ZoomIn as ZoomInIcon, ZoomOut as ZoomOutIcon, CenterFocusStrong as CenterIcon, } from '@mui/icons-material'; import { getAgentGraph } from '../api/client'; const KnowledgeGraph = ({ agentName, width = 800, height = 600 }) => { const svgRef = useRef(null); const containerRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [graphData, setGraphData] = useState(null); const [selectedNode, setSelectedNode] = useState(null); // Color scale for node types const colorScale = d3.scaleOrdinal(d3.schemeCategory10); useEffect(() => { loadGraphData(); }, [agentName]); useEffect(() => { if (graphData && svgRef.current) { renderGraph(); } }, [graphData]); const loadGraphData = async () => { try { setLoading(true); setError(null); const data = await getAgentGraph(agentName); setGraphData(data); } catch (err) { setError(err.response?.data?.detail || 'Failed to load knowledge graph'); } finally { setLoading(false); } }; const renderGraph = () => { const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); const { nodes, links } = graphData; if (!nodes || nodes.length === 0) { return; } // Create zoom behavior const zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { container.attr('transform', event.transform); }); svg.call(zoom); // Create container for zoom const container = svg.append('g'); // Create arrow markers for links svg.append('defs').append('marker') .attr('id', 'arrowhead') .attr('viewBox', '-0 -5 10 10') .attr('refX', 20) .attr('refY', 0) .attr('orient', 'auto') .attr('markerWidth', 6) .attr('markerHeight', 6) .append('path') .attr('d', 'M 0,-5 L 10,0 L 0,5') .attr('fill', '#666'); // Create force simulation const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(links) .id(d => d.id) .distance(100)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(40)); // Create links const link = container.append('g') .attr('class', 'links') .selectAll('line') .data(links) .enter() .append('line') .attr('stroke', '#666') .attr('stroke-opacity', 0.6) .attr('stroke-width', d => Math.sqrt(d.weight || 1)) .attr('marker-end', 'url(#arrowhead)'); // Create link labels const linkLabel = container.append('g') .attr('class', 'link-labels') .selectAll('text') .data(links) .enter() .append('text') .attr('font-size', '10px') .attr('fill', '#888') .attr('text-anchor', 'middle') .text(d => d.label || ''); // Create nodes const node = container.append('g') .attr('class', 'nodes') .selectAll('g') .data(nodes) .enter() .append('g') .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)); // Add circles to nodes node.append('circle') .attr('r', 15) .attr('fill', d => colorScale(d.type || d.group)) .attr('stroke', '#fff') .attr('stroke-width', 2) .style('cursor', 'pointer') .on('click', (event, d) => { setSelectedNode(d); }) .on('mouseover', function () { d3.select(this).attr('r', 20); }) .on('mouseout', function () { d3.select(this).attr('r', 15); }); // Add labels to nodes node.append('text') .attr('dx', 20) .attr('dy', 5) .attr('font-size', '12px') .attr('fill', '#fff') .text(d => d.label || d.id); // Update positions on tick simulation.on('tick', () => { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); linkLabel .attr('x', d => (d.source.x + d.target.x) / 2) .attr('y', d => (d.source.y + d.target.y) / 2); node.attr('transform', d => `translate(${d.x},${d.y})`); }); // Drag functions function dragstarted(event) { if (!event.active) simulation.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; } function dragged(event) { event.subject.fx = event.x; event.subject.fy = event.y; } function dragended(event) { if (!event.active) simulation.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; } // Store zoom for controls svgRef.current.zoomBehavior = zoom; svgRef.current.svgSelection = svg; }; const handleZoomIn = () => { const svg = d3.select(svgRef.current); svg.transition().call(svgRef.current.zoomBehavior.scaleBy, 1.5); }; const handleZoomOut = () => { const svg = d3.select(svgRef.current); svg.transition().call(svgRef.current.zoomBehavior.scaleBy, 0.67); }; const handleCenter = () => { const svg = d3.select(svgRef.current); svg.transition().call( svgRef.current.zoomBehavior.transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1) ); }; if (loading) { return ( ); } if (error) { return ( {error} ); } return ( 🕸️ Knowledge Graph {graphData?.stats && ( <> )} {selectedNode && ( Selected Node ID: {selectedNode.id} Label: {selectedNode.label} Type: {selectedNode.type || 'entity'} )} ); }; export default KnowledgeGraph;