| // src/components/gap-analysis/KnowledgeGraphCanvas.tsx | |
| import { useCallback, useRef, useEffect, useMemo, useState } from "react"; | |
| import ForceGraph2D, { | |
| type ForceGraphMethods, | |
| type NodeObject, | |
| type LinkObject, | |
| } from "react-force-graph-2d"; | |
| import type { GraphNode, GraphEdge, GraphNodeType } from "@/types/gap-analysis"; | |
| import { GraphLegend } from "./GraphLegend"; | |
| import { GraphSearchBar } from "./GraphSearchBar"; | |
| import { | |
| ZoomIn, ZoomOut, Maximize2, RotateCcw, | |
| } from "lucide-react"; | |
| // Color palette by node type | |
| const NODE_COLORS: Record<GraphNodeType, string> = { | |
| paper: "#6366f1", // indigo | |
| concept: "#06b6d4", // cyan | |
| method: "#f59e0b", // amber | |
| finding: "#10b981", // emerald | |
| gap: "#ef4444", // red | |
| hypothesis: "#8b5cf6", // violet | |
| }; | |
| const NODE_SIZES: Record<GraphNodeType, number> = { | |
| paper: 6, | |
| concept: 8, | |
| method: 7, | |
| finding: 9, | |
| gap: 10, | |
| hypothesis: 12, | |
| }; | |
| const EDGE_COLORS: Record<string, string> = { | |
| cites: "#94a3b8", | |
| related_to: "#a78bfa", | |
| contradicts: "#f87171", | |
| supports: "#34d399", | |
| methodology: "#fbbf24", | |
| gap_bridge: "#f97316", | |
| }; | |
| interface KnowledgeGraphCanvasProps { | |
| nodes: GraphNode[]; | |
| edges: GraphEdge[]; | |
| onNodeClick: (nodeId: string) => void; | |
| onNodeHover: (nodeId: string | null) => void; | |
| filterType: GraphNodeType | "all"; | |
| onFilterChange?: (type: GraphNodeType | "all") => void; | |
| searchQuery: string; | |
| onSearchChange?: (query: string) => void; | |
| selectedNodeId: string | null; | |
| } | |
| export function KnowledgeGraphCanvas({ | |
| nodes: rawNodes, | |
| edges: rawEdges, | |
| onNodeClick, | |
| onNodeHover, | |
| filterType, | |
| onFilterChange, | |
| searchQuery, | |
| onSearchChange, | |
| selectedNodeId, | |
| }: KnowledgeGraphCanvasProps) { | |
| const fgRef = useRef<ForceGraphMethods<any, any> | null>(null); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); | |
| const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set()); | |
| const [highlightLinks, setHighlightLinks] = useState<Set<string>>(new Set()); | |
| const [hoverNode, setHoverNode] = useState<string | null>(null); | |
| // Filter + search | |
| const graphData = useMemo(() => { | |
| let filtered = rawNodes; | |
| if (filterType !== "all") { | |
| filtered = filtered.filter((n) => n.type === filterType); | |
| } | |
| if (searchQuery.trim()) { | |
| const q = searchQuery.toLowerCase(); | |
| filtered = filtered.filter( | |
| (n) => | |
| n.label.toLowerCase().includes(q) || | |
| n.description?.toLowerCase().includes(q) | |
| ); | |
| } | |
| const nodeIds = new Set(filtered.map((n) => n.id)); | |
| const filteredEdges = rawEdges.filter( | |
| (e) => nodeIds.has(e.source as string) && nodeIds.has(e.target as string) | |
| ); | |
| return { | |
| nodes: filtered.map((n) => ({ | |
| ...n, | |
| color: NODE_COLORS[n.type], | |
| val: NODE_SIZES[n.type], | |
| fx: n.fx ?? undefined, | |
| fy: n.fy ?? undefined, | |
| })) as any, | |
| links: filteredEdges.map((e) => ({ | |
| ...e, | |
| color: EDGE_COLORS[e.type] ?? "#94a3b8", | |
| })) as any, | |
| }; | |
| }, [rawNodes, rawEdges, filterType, searchQuery]); | |
| // Resize handler | |
| useEffect(() => { | |
| const resize = () => { | |
| if (containerRef.current) { | |
| setDimensions({ | |
| width: containerRef.current.offsetWidth, | |
| height: containerRef.current.offsetHeight, | |
| }); | |
| } | |
| }; | |
| resize(); | |
| window.addEventListener("resize", resize); | |
| return () => window.removeEventListener("resize", resize); | |
| }, []); | |
| // Highlight connected nodes on hover | |
| const handleNodeHover = useCallback( | |
| (node: NodeObject | null) => { | |
| if (!node) { | |
| setHighlightNodes(new Set()); | |
| setHighlightLinks(new Set()); | |
| setHoverNode(null); | |
| onNodeHover(null); | |
| return; | |
| } | |
| const n = node as unknown as GraphNode; | |
| setHoverNode(n.id); | |
| onNodeHover(n.id); | |
| // Highlight neighbors | |
| const neighbors = new Set<string>(); | |
| const neighborLinks = new Set<string>(); | |
| graphData.links.forEach((link: any) => { | |
| const src = link.source as any; | |
| const tgt = link.target as any; | |
| const srcId = typeof src === "object" ? src.id : src; | |
| const tgtId = typeof tgt === "object" ? tgt.id : tgt; | |
| if (srcId === n.id) { | |
| neighbors.add(tgtId); | |
| neighborLinks.add(link.id); | |
| } | |
| if (tgtId === n.id) { | |
| neighbors.add(srcId); | |
| neighborLinks.add(link.id); | |
| } | |
| }); | |
| neighbors.add(n.id); | |
| setHighlightNodes(neighbors); | |
| setHighlightLinks(neighborLinks); | |
| }, | |
| [graphData.links, onNodeHover] | |
| ); | |
| // Paint node with highlight logic | |
| const nodeCanvasObject = useCallback( | |
| (node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { | |
| const n = node as unknown as GraphNode; | |
| const isHighlighted = highlightNodes.size === 0 || highlightNodes.has(n.id); | |
| const isSelected = selectedNodeId === n.id; | |
| const opacity = isHighlighted ? 1 : 0.15; | |
| // Draw glow for selected | |
| if (isSelected) { | |
| ctx.beginPath(); | |
| ctx.arc(node.x!, node.y!, NODE_SIZES[n.type] * 1.8, 0, 2 * Math.PI); | |
| ctx.fillStyle = `${n.color}33`; | |
| ctx.fill(); | |
| } | |
| // Draw node circle | |
| ctx.beginPath(); | |
| ctx.arc(node.x!, node.y!, NODE_SIZES[n.type], 0, 2 * Math.PI); | |
| ctx.fillStyle = n.color ?? NODE_COLORS[n.type]; | |
| ctx.globalAlpha = opacity; | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| // Draw label for larger nodes or selected | |
| if ((isHighlighted && globalScale > 1.2) || isSelected) { | |
| const fontSize = Math.max(3, 12 / globalScale); | |
| ctx.font = `${fontSize}px Inter, sans-serif`; | |
| ctx.fillStyle = isSelected ? "#0f172a" : "#64748b"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText( | |
| n.label.length > 20 ? n.label.slice(0, 20) + "…" : n.label, | |
| node.x!, | |
| node.y! + NODE_SIZES[n.type] + fontSize + 2 | |
| ); | |
| } | |
| }, | |
| [highlightNodes, selectedNodeId] | |
| ); | |
| // Link width based on strength | |
| const linkWidth = useCallback((link: LinkObject) => { | |
| const l = link as unknown as GraphEdge; | |
| return Math.max(0.5, (l.strength ?? 0.5) * 3); | |
| }, []); | |
| // Controls | |
| const zoomIn = () => fgRef.current?.zoom(1.5); | |
| const zoomOut = () => fgRef.current?.zoom(0.75); | |
| const fitToScreen = () => fgRef.current?.zoomToFit(400, 60); | |
| const resetCamera = () => { | |
| fgRef.current?.zoomToFit(400, 60); | |
| fgRef.current?.centerAt(0, 0, 0); | |
| }; | |
| return ( | |
| <div ref={containerRef} className="relative w-full h-full bg-gray-50 dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 overflow-hidden"> | |
| {/* Top bar */} | |
| <div className="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 flex-wrap"> | |
| <GraphSearchBar | |
| value={searchQuery} | |
| onChange={onSearchChange ?? (() => {})} | |
| filterType={filterType} | |
| onFilterChange={onFilterChange ?? (() => {})} | |
| /> | |
| </div> | |
| {/* Graph */} | |
| <ForceGraph2D | |
| ref={fgRef as any} | |
| graphData={graphData} | |
| width={dimensions.width} | |
| height={dimensions.height} | |
| nodeLabel={(node) => { | |
| const n = node as unknown as GraphNode; | |
| return `${n.type.toUpperCase()}: ${n.label}\n${n.description ?? ""}`; | |
| }} | |
| nodeCanvasObject={nodeCanvasObject} | |
| linkWidth={linkWidth} | |
| linkColor={(link) => { | |
| const l = link as unknown as GraphEdge; | |
| const isHighlighted = | |
| highlightLinks.size === 0 || highlightLinks.has(l.id); | |
| return isHighlighted ? EDGE_COLORS[l.type] ?? "#94a3b8" : "#e2e8f0"; | |
| }} | |
| onNodeClick={(node) => onNodeClick((node as unknown as GraphNode).id)} | |
| onNodeHover={handleNodeHover} | |
| cooldownTicks={100} | |
| d3AlphaDecay={0.02} | |
| d3VelocityDecay={0.3} | |
| enableNodeDrag={true} | |
| enableZoomInteraction={true} | |
| minZoom={0.3} | |
| maxZoom={5} | |
| /> | |
| {/* Bottom controls */} | |
| <div className="absolute bottom-3 right-3 z-10 flex items-center gap-1"> | |
| <button onClick={zoomIn} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700"> | |
| <ZoomIn className="w-4 h-4 text-gray-600 dark:text-gray-300" /> | |
| </button> | |
| <button onClick={zoomOut} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700"> | |
| <ZoomOut className="w-4 h-4 text-gray-600 dark:text-gray-300" /> | |
| </button> | |
| <button onClick={fitToScreen} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700"> | |
| <Maximize2 className="w-4 h-4 text-gray-600 dark:text-gray-300" /> | |
| </button> | |
| <button onClick={resetCamera} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700"> | |
| <RotateCcw className="w-4 h-4 text-gray-600 dark:text-gray-300" /> | |
| </button> | |
| </div> | |
| {/* Legend */} | |
| <div className="absolute bottom-3 left-3 z-10"> | |
| <GraphLegend /> | |
| </div> | |
| {/* Node detail tooltip */} | |
| {hoverNode && ( | |
| <div className="absolute top-12 left-1/2 -translate-x-1/2 z-10 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 px-4 py-2 text-xs max-w-xs"> | |
| {(() => { | |
| const node = rawNodes.find((n) => n.id === hoverNode); | |
| if (!node) return null; | |
| return ( | |
| <> | |
| <span | |
| className="inline-block w-2 h-2 rounded-full mr-1.5" | |
| style={{ backgroundColor: NODE_COLORS[node.type] }} | |
| /> | |
| <span className="font-semibold">{node.type.toUpperCase()}</span>:{" "} | |
| {node.label} | |
| </> | |
| ); | |
| })()} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 10.1 kB
- Xet hash:
- f9e5006c7206e5899081a889a46e30e8fbb0b5fbc2db6c2885ae1dcf33ed1dde
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.