Spaces:
Sleeping
Sleeping
feat: improve graph visualization, stabilize multi-hop path reasoning, and update multi-hop page styling
f14eb69 | 'use client'; | |
| import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; | |
| import dynamic from 'next/dynamic'; | |
| import { useGraphStore } from '@/store/graph'; | |
| import { entityColor } from '@/lib/constants'; | |
| import { GraphNode, GraphLink } from '@/store/graph'; | |
| import { graphController } from './graphController'; | |
| import { GraphControls } from './GraphControls'; | |
| import { GraphLegend } from './GraphLegend'; | |
| import { GraphSearch } from './GraphSearch'; | |
| const ForceGraph2D = dynamic(() => import('./ForceGraphWrapper'), { ssr: false }) as any; | |
| interface FGNode extends GraphNode { | |
| x?: number; | |
| y?: number; | |
| fx?: number; | |
| fy?: number; | |
| } | |
| interface FGLink { | |
| source: any; | |
| target: any; | |
| type: string; | |
| confidence?: number; | |
| } | |
| export function GraphVisualization({ height = '100%' }: { height?: string | number }) { | |
| const data = useGraphStore((s) => s.data); | |
| const highlighted = useGraphStore((s) => s.highlightedEntities); | |
| const paths = useGraphStore((s) => s.highlightedPaths); | |
| const searchTerm = useGraphStore((s) => s.searchTerm); | |
| const visibleTypes = useGraphStore((s) => s.visibleTypes); | |
| const visibleRelationships = useGraphStore((s) => s.visibleRelationships); | |
| const selectEntity = useGraphStore((s) => s.selectEntity); | |
| const setHighlighted = useGraphStore((s) => s.setHighlighted); | |
| const dim = useGraphStore((s) => s.dim); | |
| const fgRef = useRef<any>(null); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const [hoverNode, setHoverNode] = useState<FGNode | null>(null); | |
| const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: GraphNode } | null>(null); | |
| const [initialized, setInitialized] = useState(false); | |
| const closeContextMenu = useCallback(() => setContextMenu(null), []); | |
| // Set up zoom controls | |
| useEffect(() => { | |
| graphController.zoomIn = () => { | |
| if (dim === 3) { | |
| const cam = fgRef.current?.camera?.(); | |
| if (cam && fgRef.current?.cameraPosition) { | |
| const { x, y, z } = cam.position; | |
| fgRef.current.cameraPosition({ x: x * 0.75, y: y * 0.75, z: z * 0.75 }, undefined, 300); | |
| } | |
| } else { | |
| fgRef.current?.zoom?.(fgRef.current.zoom() * 1.4, 300); | |
| } | |
| }; | |
| graphController.zoomOut = () => { | |
| if (dim === 3) { | |
| const cam = fgRef.current?.camera?.(); | |
| if (cam && fgRef.current?.cameraPosition) { | |
| const { x, y, z } = cam.position; | |
| fgRef.current.cameraPosition({ x: x * 1.33, y: y * 1.33, z: z * 1.33 }, undefined, 300); | |
| } | |
| } else { | |
| fgRef.current?.zoom?.(fgRef.current.zoom() / 1.4, 300); | |
| } | |
| }; | |
| graphController.resetView = () => { | |
| fgRef.current?.zoomToFit?.(400, 60); | |
| }; | |
| }, [dim]); | |
| // Auto-center on data load | |
| useEffect(() => { | |
| if (data.nodes.length > 0) { | |
| setInitialized(false); | |
| } | |
| }, [data.nodes.length]); | |
| const pathSet = useMemo(() => new Set(paths), [paths]); | |
| const highlightSet = useMemo(() => new Set(highlighted), [highlighted]); | |
| const graphData = useMemo(() => { | |
| const typeHidden = (t: string) => visibleTypes[t] === false; | |
| const relHidden = (r: string) => visibleRelationships[r] === false; | |
| const nodes = data.nodes | |
| .filter((n) => !typeHidden(n.type)) | |
| .map((n) => ({ ...n })); | |
| const idSet = new Set(nodes.map((n) => n.id)); | |
| const links = data.links | |
| .filter((l) => idSet.has(l.source) && idSet.has(l.target)) | |
| .filter((l) => !relHidden(l.type)) | |
| .map((l) => ({ ...l })); | |
| return { nodes, links }; | |
| }, [data, visibleTypes, visibleRelationships]); | |
| const isPathEdge = (l: FGLink) => { | |
| if (pathSet.size === 0) return false; | |
| const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| const seq = paths; | |
| for (let i = 0; i < seq.length - 1; i++) { | |
| if ( | |
| (seq[i] === s && seq[i + 1] === t) || | |
| (seq[i] === t && seq[i + 1] === s) | |
| ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| const isDimmed = (n: FGNode) => { | |
| if (highlightSet.size === 0 && pathSet.size === 0) return false; | |
| if (pathSet.size > 0) return !pathSet.has(n.id); | |
| return !highlightSet.has(n.name) && !highlightSet.has(n.id); | |
| }; | |
| const [searchHit, setSearchHit] = useState<string | null>(null); | |
| useEffect(() => { | |
| if (!searchTerm) { | |
| setSearchHit(null); | |
| return; | |
| } | |
| const match = graphData.nodes.find((n) => | |
| n.name.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| setSearchHit(match ? (match as any).id : null); | |
| if (match && fgRef.current) { | |
| if (fgRef.current.centerAt) { | |
| fgRef.current.centerAt((match as any).x, (match as any).y, 600); | |
| } | |
| if (fgRef.current.zoom) { | |
| fgRef.current.zoom(3, 600); | |
| } | |
| } | |
| }, [searchTerm, graphData.nodes]); | |
| // Custom node painter for vivid colors and glow effects | |
| const nodeCanvasObject = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { | |
| const n = node as FGNode; | |
| const x = n.x ?? 0; | |
| const y = n.y ?? 0; | |
| const baseR = Math.sqrt(Math.max((n.val || 1), 1)) * 5; | |
| const r = Math.max(baseR, 4); | |
| const color = entityColor(n.type); | |
| const isHighlighted = highlightSet.has(n.name) || highlightSet.has(n.id) || pathSet.has(n.id); | |
| const isSearch = searchHit && n.id === searchHit; | |
| const dimmed = isDimmed(n); | |
| const isHovered = hoverNode?.id === n.id; | |
| const alpha = dimmed ? 0.2 : 1; | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| // Glow / pulse for highlighted or hovered nodes | |
| if (isHighlighted || isSearch || isHovered) { | |
| const glowColor = isSearch ? '#ffffff' : isHighlighted ? 'hsl(188, 94%, 52%)' : color; | |
| ctx.shadowBlur = 20; | |
| ctx.shadowColor = glowColor; | |
| // Outer ring | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r + 4, 0, 2 * Math.PI); | |
| ctx.strokeStyle = glowColor; | |
| ctx.lineWidth = 2.5; | |
| ctx.stroke(); | |
| } | |
| // Node fill with radial gradient for depth | |
| const gradient = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r); | |
| gradient.addColorStop(0, lightenColor(color, 40)); | |
| gradient.addColorStop(1, color); | |
| ctx.shadowBlur = 0; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r, 0, 2 * Math.PI); | |
| ctx.fillStyle = gradient; | |
| ctx.fill(); | |
| // Subtle border | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r, 0, 2 * Math.PI); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.25)'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| // Label — show when zoomed in enough or when hovered/highlighted | |
| const fontSize = Math.max(10 / globalScale, 2); | |
| const showLabel = globalScale > 0.8 || isHighlighted || isSearch || isHovered; | |
| if (showLabel) { | |
| ctx.shadowBlur = 0; | |
| ctx.font = `${isHighlighted || isHovered ? 'bold ' : ''}${fontSize}px Inter, sans-serif`; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| // Text background | |
| const textWidth = ctx.measureText(n.name).width; | |
| const bPad = 2 / globalScale; | |
| ctx.fillStyle = 'rgba(10, 12, 20, 0.85)'; | |
| ctx.fillRect(x - textWidth / 2 - bPad, y + r + 1 / globalScale, textWidth + bPad * 2, fontSize + bPad * 2); | |
| // Text | |
| ctx.fillStyle = isHighlighted || isHovered ? '#ffffff' : 'rgba(220,220,240,0.9)'; | |
| ctx.fillText(n.name, x, y + r + fontSize / 2 + 3 / globalScale); | |
| } | |
| ctx.restore(); | |
| }, [highlightSet, pathSet, searchHit, hoverNode, isDimmed]); | |
| const nodePointerAreaPaint = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D) => { | |
| const n = node as FGNode; | |
| const r = Math.max(Math.sqrt(Math.max((n.val || 1), 1)) * 5, 4) + 4; | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, 2 * Math.PI); | |
| ctx.fill(); | |
| }, []); | |
| return ( | |
| <div ref={containerRef} className="graph-canvas relative h-full w-full" style={{ height }}> | |
| <ForceGraph2D | |
| fgRef={fgRef} | |
| dim={dim} | |
| graphData={graphData} | |
| width={undefined} | |
| backgroundColor="rgba(0,0,0,0)" | |
| nodeId="id" | |
| nodeLabel={(n: any) => `${n.name} · ${n.type}`} | |
| nodeVal={(n: any) => (n.val || 1) * 1.4} | |
| nodeRelSize={5} | |
| nodeCanvasObject={dim === 2 ? nodeCanvasObject : undefined} | |
| nodeCanvasObjectMode={() => dim === 2 ? 'replace' : 'after'} | |
| nodePointerAreaPaint={dim === 2 ? nodePointerAreaPaint : undefined} | |
| // 3D fallback colors | |
| nodeColor={(n: any) => | |
| searchHit && (n as any).id === searchHit | |
| ? '#ffffff' | |
| : entityColor(n.type) | |
| } | |
| nodeOpacity={(n: any) => (isDimmed(n as FGNode) ? 0.18 : 1)} | |
| linkColor={(l: any) => | |
| isPathEdge(l as FGLink) ? 'hsl(188, 94%, 52%)' : 'rgba(148,163,184,0.35)' | |
| } | |
| linkWidth={(l: any) => { | |
| if (isPathEdge(l as FGLink)) return 3; | |
| const conf = (l as any).confidence ?? 0.8; | |
| return 0.5 + conf * 1.2; | |
| }} | |
| linkLabel={(l: any) => { | |
| const rel = (l as any).type || (l as any).label || ''; | |
| const conf = (l as any).confidence; | |
| const confStr = conf != null ? ` (${Math.round(conf * 100)}%)` : ''; | |
| return rel.replace(/_/g, ' ') + confStr; | |
| }} | |
| linkOpacity={(l: any) => { | |
| if (pathSet.size > 0) { | |
| return isPathEdge(l as FGLink) ? 0.9 : 0.08; | |
| } | |
| if (highlightSet.size > 0) { | |
| const s = typeof l.source === 'object' ? l.source.id : l.source; | |
| const t = typeof l.target === 'object' ? l.target.id : l.target; | |
| const sName = typeof l.source === 'object' ? l.source.name : undefined; | |
| const tName = typeof l.target === 'object' ? l.target.name : undefined; | |
| const sHighlighted = highlightSet.has(s) || (sName && highlightSet.has(sName)); | |
| const tHighlighted = highlightSet.has(t) || (tName && highlightSet.has(tName)); | |
| return sHighlighted && tHighlighted ? 0.7 : 0.08; | |
| } | |
| return 0.5; | |
| }} | |
| linkDirectionalParticles={(l: any) => (isPathEdge(l as FGLink) ? 4 : 0)} | |
| linkDirectionalParticleColor={() => 'hsl(188, 94%, 60%)'} | |
| linkDirectionalParticleSpeed={0.008} | |
| linkDirectionalArrowLength={4} | |
| linkDirectionalArrowRelPos={1} | |
| linkDirectionalArrowColor={(l: any) => isPathEdge(l as FGLink) ? 'hsl(188,94%,52%)' : 'rgba(148,163,184,0.5)'} | |
| onNodeClick={(n: any) => { | |
| closeContextMenu(); | |
| selectEntity(n as GraphNode); | |
| }} | |
| onNodeDoubleClick={(n: any) => { | |
| const node = n as GraphNode; | |
| setHighlighted([node.id], []); | |
| if (fgRef.current) { | |
| fgRef.current.centerAt?.((n as any).x, (n as any).y, 600); | |
| fgRef.current.zoom?.(3, 600); | |
| } | |
| }} | |
| onNodeHover={(n: any) => setHoverNode(n as FGNode)} | |
| onNodeRightClick={(n: any, e: any) => { | |
| e.preventDefault(); | |
| const node = n as GraphNode; | |
| setContextMenu({ x: e.clientX, y: e.clientY, node }); | |
| }} | |
| onBackgroundClick={() => { | |
| closeContextMenu(); | |
| setHighlighted([], []); | |
| }} | |
| cooldownTicks={120} | |
| cooldownTime={3000} | |
| d3VelocityDecay={0.25} | |
| d3AlphaDecay={0.02} | |
| onEngineStop={() => { | |
| if (!initialized && fgRef.current && graphData.nodes.length > 0) { | |
| fgRef.current.zoomToFit?.(400, 60); | |
| setInitialized(true); | |
| } | |
| }} | |
| /> | |
| <GraphControls /> | |
| <GraphLegend /> | |
| <GraphSearch /> | |
| {hoverNode && !contextMenu && ( | |
| <div className="pointer-events-none absolute left-3 top-3 z-20 max-w-xs rounded-lg border border-border bg-bg-elevated/95 p-3 text-xs shadow-xl backdrop-blur"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <div className="h-3 w-3 rounded-full flex-shrink-0" style={{ backgroundColor: entityColor(hoverNode.type) }} /> | |
| <p className="font-bold text-text-primary">{hoverNode.name}</p> | |
| </div> | |
| <p className="text-text-muted text-[10px] uppercase tracking-wider">{hoverNode.type}</p> | |
| {hoverNode.description && ( | |
| <p className="mt-1.5 text-text-secondary leading-relaxed">{hoverNode.description}</p> | |
| )} | |
| </div> | |
| )} | |
| {contextMenu && ( | |
| <div | |
| className="fixed z-50 min-w-[180px] rounded-md border border-border bg-bg-elevated shadow-xl" | |
| style={{ left: contextMenu.x, top: contextMenu.y }} | |
| > | |
| <button | |
| onClick={() => { | |
| selectEntity(contextMenu.node); | |
| closeContextMenu(); | |
| }} | |
| className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-secondary hover:bg-bg-surface hover:text-text-primary" | |
| > | |
| View Details | |
| </button> | |
| <button | |
| onClick={() => { | |
| setHighlighted([contextMenu.node.id], []); | |
| closeContextMenu(); | |
| }} | |
| className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-secondary hover:bg-bg-surface hover:text-text-primary" | |
| > | |
| Highlight on Graph | |
| </button> | |
| <button | |
| onClick={() => { | |
| if (typeof window !== 'undefined') { | |
| window.dispatchEvent(new CustomEvent('graphrag:ask-entity', { detail: contextMenu.node.name })); | |
| } | |
| closeContextMenu(); | |
| }} | |
| className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-accent-cyan hover:bg-bg-surface" | |
| > | |
| Ask about this entity | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /** Lighten a hex color by `amount` (0–255) */ | |
| function lightenColor(hex: string, amount: number): string { | |
| try { | |
| const num = parseInt(hex.replace('#', ''), 16); | |
| const r = Math.min(255, (num >> 16) + amount); | |
| const g = Math.min(255, ((num >> 8) & 0xff) + amount); | |
| const b = Math.min(255, (num & 0xff) + amount); | |
| return `rgb(${r},${g},${b})`; | |
| } catch { | |
| return hex; | |
| } | |
| } | |