import React, { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import * as d3 from 'd3'; import { ZoomIn, ZoomOut, Maximize2, Search, Download, Image, X, MessageSquare, Upload, Share2 } from 'lucide-react'; import { useAppState, type KGNode } from '../../store'; import { TYPE_COLORS } from '../../mock-data'; const ENTITY_TYPES = ['构件', '设备', '设施', '指标', '场所', '材料', '措施', '条款', 'TECHNOLOGY', 'CONCEPT', 'PERSON', 'ORGANIZATION', 'LOCATION'] as const; const CONFIDENCE_LEVELS = ['match_exact', 'match_greater', 'match_lesser', 'match_fuzzy'] as const; export function KGExplorer() { const { nodes, edges, documents, selectedNode, setSelectedNode, getNeighbors } = useAppState(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const svgRef = useRef(null); const containerRef = useRef(null); const simulationRef = useRef>(); const zoomRef = useRef>(); const [filterTypes, setFilterTypes] = useState>(new Set(ENTITY_TYPES)); const [filterConfidence, setFilterConfidence] = useState>(new Set(CONFIDENCE_LEVELS)); const [filterDoc, setFilterDoc] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [showFilter, setShowFilter] = useState(true); const [tooltip, setTooltip] = useState<{ x: number; y: number; node: KGNode } | null>(null); const indexedDocs = documents.filter(d => d.status === 'indexed'); // Filtered nodes/edges const visibleNodes = nodes.filter(n => { if (!filterTypes.has(n.type)) return false; if (!filterConfidence.has(n.confidence)) return false; if (filterDoc !== 'all' && n.doc_id !== filterDoc) return false; if (searchQuery && !n.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; return true; }); const visibleNodeIds = new Set(visibleNodes.map(n => n.id)); const visibleEdges = edges.filter(e => visibleNodeIds.has(e.source as string) && visibleNodeIds.has(e.target as string)); // Neighbors of selected const neighborInfo = selectedNode ? getNeighbors(selectedNode.id) : null; // D3 rendering useEffect(() => { if (!svgRef.current || !containerRef.current) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); if (visibleNodes.length === 0) return; const rect = containerRef.current.getBoundingClientRect(); const width = rect.width; const height = rect.height; svg.attr('width', width).attr('height', height); const g = svg.append('g'); const zoom = d3.zoom() .scaleExtent([0.1, 8]) .on('zoom', (event) => g.attr('transform', event.transform)); zoomRef.current = zoom; svg.call(zoom); // Create simulation data copies const simNodes = visibleNodes.map(n => ({ ...n, x: width / 2 + (Math.random() - 0.5) * 200, y: height / 2 + (Math.random() - 0.5) * 200 })); const simEdges = visibleEdges.map(e => ({ ...e, source: e.source, target: e.target })); const simulation = d3.forceSimulation(simNodes) .force('link', d3.forceLink(simEdges).id((d: any) => d.id).distance(60).strength(0.3)) .force('charge', d3.forceManyBody().strength(-120)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collide', d3.forceCollide().radius((d: any) => getRadius(d.degree) + 4)) .alphaDecay(0.02); simulationRef.current = simulation; // Edges const link = g.append('g') .selectAll('line') .data(simEdges) .join('line') .attr('stroke', '#30363d') .attr('stroke-width', 1) .attr('stroke-opacity', 0.25); // Nodes const node = g.append('g') .selectAll('circle') .data(simNodes) .join('circle') .attr('r', (d: any) => getRadius(d.degree)) .attr('fill', (d: any) => TYPE_COLORS[d.type] || '#8b949e') .attr('stroke', '#0f1117') .attr('stroke-width', 1.5) .attr('opacity', 0.9) .attr('cursor', 'pointer') .on('mouseover', function(event, d: any) { d3.select(this).attr('stroke', '#ffffff').attr('stroke-width', 2.5); setTooltip({ x: event.clientX + 8, y: event.clientY + 8, node: d }); }) .on('mouseout', function() { d3.select(this).attr('stroke', '#0f1117').attr('stroke-width', 1.5); setTooltip(null); }) .on('click', (_, d: any) => { setSelectedNode(d); // Highlight logic node.attr('opacity', (n: any) => { if (n.id === d.id) return 0.9; const isNeighbor = simEdges.some((e: any) => (e.source.id === d.id && e.target.id === n.id) || (e.target.id === d.id && e.source.id === n.id) ); return isNeighbor ? 0.9 : 0.1; }); d3.select(node.nodes()[simNodes.indexOf(d)]) .attr('r', getRadius(d.degree) * 1.5); link.attr('stroke-opacity', (e: any) => e.source.id === d.id || e.target.id === d.id ? 0.8 : 0.05 ); }) .call(d3.drag() .on('start', (event, d: any) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d: any) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d: any) => { if (!event.active) simulation.alphaTarget(0); }) ); // Labels for high-degree nodes const label = g.append('g') .selectAll('text') .data(simNodes.filter(n => n.degree >= 12)) .join('text') .text((d: any) => d.name) .attr('font-size', 10) .attr('fill', 'var(--text-3)') .attr('text-anchor', 'middle') .attr('dy', (d: any) => -(getRadius(d.degree) + 6)) .attr('pointer-events', 'none'); // Click blank to reset svg.on('click', (event) => { if (event.target === svgRef.current) { setSelectedNode(null); node.attr('opacity', 0.9).attr('r', (d: any) => getRadius(d.degree)); link.attr('stroke-opacity', 0.25); } }); simulation.on('tick', () => { link .attr('x1', (d: any) => d.source.x) .attr('y1', (d: any) => d.source.y) .attr('x2', (d: any) => d.target.x) .attr('y2', (d: any) => d.target.y); node .attr('cx', (d: any) => d.x) .attr('cy', (d: any) => d.y); label .attr('x', (d: any) => d.x) .attr('y', (d: any) => d.y); }); // Handle URL params const nodeParam = searchParams.get('node'); if (nodeParam) { const target = simNodes.find(n => n.id === nodeParam); if (target) { setTimeout(() => { const nd = nodes.find(n => n.id === nodeParam); if (nd) setSelectedNode(nd); }, 500); } } const docParam = searchParams.get('doc_id'); if (docParam) { setFilterDoc(docParam); } return () => { simulation.stop(); }; }, [visibleNodes.length, visibleEdges.length, searchQuery, filterDoc]); const handleZoomIn = () => { if (svgRef.current && zoomRef.current) { d3.select(svgRef.current).transition().duration(300).call(zoomRef.current.scaleBy, 1.3); } }; const handleZoomOut = () => { if (svgRef.current && zoomRef.current) { d3.select(svgRef.current).transition().duration(300).call(zoomRef.current.scaleBy, 0.7); } }; const handleFitAll = () => { if (svgRef.current && zoomRef.current) { d3.select(svgRef.current).transition().duration(500).call(zoomRef.current.transform, d3.zoomIdentity); } }; const toggleType = (t: string) => { const next = new Set(filterTypes); if (next.has(t)) next.delete(t); else next.add(t); setFilterTypes(next); }; const toggleConfidence = (c: string) => { const next = new Set(filterConfidence); if (next.has(c)) next.delete(c); else next.add(c); setFilterConfidence(next); }; return (
{/* Filter Panel */} {showFilter && (

来源文档

实体类型

{ENTITY_TYPES.map(t => { const count = nodes.filter(n => n.type === t).length; return ( ); })}

置信度

{CONFIDENCE_LEVELS.map(c => ( ))}
)} {/* Graph Area */}
{/* Toolbar */}
setSearchQuery(e.target.value)} placeholder="搜索..." className="pl-7 pr-2 py-1 rounded" style={{ width: 120, background: 'var(--bg-s2)', border: '1px solid var(--border-main)', color: 'var(--text-1)', fontSize: 12, outline: 'none' }} />
{/* Legend */}
{ENTITY_TYPES.map(t => (
{t}
))}
{/* Stats */}
{visibleNodes.length} 个节点 · {visibleEdges.length} 条边
{visibleNodes.length === 0 ? (
暂无知识图谱
) : ( )} {/* Tooltip */} {tooltip && (
{tooltip.node.name} {tooltip.node.type}
页码: {tooltip.node.page}
置信度: {tooltip.node.confidence}
度数: {tooltip.node.degree}
)}
{/* Detail Panel */} {selectedNode && (

{selectedNode.name}

{selectedNode.type} {selectedNode.description && (

{selectedNode.description}

)}
{[ { label: '页码', value: selectedNode.page }, { label: '置信度', value: selectedNode.confidence.replace('match_', '') }, { label: '度数', value: selectedNode.degree }, { label: '中心性', value: selectedNode.centrality.toFixed(2) }, ].map(p => (
{p.label} {p.value}
))}

邻居节点 ({neighborInfo?.nodes.length ?? 0})

{neighborInfo?.nodes.slice(0, 5).map(n => ( ))} {(neighborInfo?.nodes.length ?? 0) > 5 && ( 查看全部 {neighborInfo?.nodes.length} 个邻居 → )}
)}
); } function getRadius(degree: number): number { return Math.max(4, Math.log(degree + 1) * 4); }