import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import type { DomainKey, Entity, GraphNode, OntologyType, Relationship } from "../../types"; import { ZoomControls } from "./ZoomControls"; import { border } from "../../theme"; interface GraphCanvasSVGProps { entities: Entity[]; relationships: Relationship[]; ontology: OntologyType; highlightedEntities: string[]; onNodeClick: (node: GraphNode) => void; activeFilter: DomainKey | null; } const SETTLE_TIME = 10000; // 10 seconds until nodes settle export function GraphCanvasSVG({ entities, relationships, ontology, highlightedEntities, onNodeClick, activeFilter }: GraphCanvasSVGProps) { const containerRef = useRef(null); const svgRef = useRef(null); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const [hovered, setHovered] = useState(null); const [settled, setSettled] = useState(false); const [time, setTime] = useState(0); const animRef = useRef(0); const startTimeRef = useRef(0); const lastFrameTimeRef = useRef(0); // Zoom and pan state const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const isPanningRef = useRef(false); const lastPanPosRef = useRef({ x: 0, y: 0 }); // Track container size useEffect(() => { const container = containerRef.current; if (!container) return; const resizeObserver = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { setContainerSize({ width: entry.contentRect.width, height: entry.contentRect.height, }); } }); resizeObserver.observe(container); return () => resizeObserver.disconnect(); }, []); // Calculate node positions const { nodes, domainPositions } = useMemo(() => { if (containerSize.width === 0) return { nodes: [], domainPositions: {} }; const width = containerSize.width; const height = containerSize.height; const cx = width / 2; const cy = height / 2; const domainKeys = Object.keys(ontology); const domainPositions: Record = {}; domainKeys.forEach((domain, i) => { const angle = (Math.PI * 2 * i) / domainKeys.length - Math.PI / 2; const radius = Math.min(cx, cy) * 0.45; domainPositions[domain] = { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius, }; }); const nodes: GraphNode[] = entities.map((e) => { const dp = domainPositions[e.domain]; const subIdx = ontology[e.domain].subclasses.findIndex((s) => s.id === e.id); const total = ontology[e.domain].subclasses.length; const angle = ((Math.PI * 2) / total) * subIdx - Math.PI / 2; const radius = Math.min(width, height) * 0.1; const x = dp.x + Math.cos(angle) * radius; const y = dp.y + Math.sin(angle) * radius; return { ...e, x, y, vx: 0, vy: 0, targetX: x, targetY: y, r: 9, // Half size since we're not doing 2x canvas scaling }; }); return { nodes, domainPositions }; }, [entities, ontology, containerSize]); // Animation loop for breathing effect useEffect(() => { if (containerSize.width === 0) return; startTimeRef.current = performance.now(); setSettled(false); setTime(0); const frameInterval = 1000 / 30; // 30fps function animate(currentTime: number) { if (currentTime - lastFrameTimeRef.current < frameInterval) { animRef.current = requestAnimationFrame(animate); return; } lastFrameTimeRef.current = currentTime; // Check if should settle if (currentTime - startTimeRef.current > SETTLE_TIME) { setSettled(true); // Continue animation only if there are highlights if (highlightedEntities && highlightedEntities.length > 0) { setTime(t => t + 0.01); animRef.current = requestAnimationFrame(animate); } return; } setTime(t => t + 0.01); animRef.current = requestAnimationFrame(animate); } animRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(animRef.current); }, [containerSize, entities, ontology]); // Restart animation when highlights change useEffect(() => { if (highlightedEntities && highlightedEntities.length > 0 && settled && animRef.current === 0) { const frameInterval = 1000 / 30; function animate(currentTime: number) { if (currentTime - lastFrameTimeRef.current < frameInterval) { animRef.current = requestAnimationFrame(animate); return; } lastFrameTimeRef.current = currentTime; setTime(t => t + 0.01); if (highlightedEntities && highlightedEntities.length > 0) { animRef.current = requestAnimationFrame(animate); } else { animRef.current = 0; } } animRef.current = requestAnimationFrame(animate); } }, [highlightedEntities, settled]); // Generate grid lines const gridLines = useMemo(() => { const lines: React.ReactElement[] = []; const { width, height } = containerSize; if (width === 0) return lines; for (let x = 0; x < width; x += 30) { lines.push( ); } for (let y = 0; y < height; y += 30) { lines.push( ); } return lines; }, [containerSize]); // Calculate edge path with curve const getEdgePath = useCallback((fromNode: GraphNode, toNode: GraphNode, time: number, isSettled: boolean) => { const driftX1 = isSettled ? 0 : Math.sin(time + fromNode.targetX * 0.01) * 0.3; const driftY1 = isSettled ? 0 : Math.cos(time + fromNode.targetY * 0.01) * 0.3; const driftX2 = isSettled ? 0 : Math.sin(time + toNode.targetX * 0.01) * 0.3; const driftY2 = isSettled ? 0 : Math.cos(time + toNode.targetY * 0.01) * 0.3; const x1 = fromNode.x + driftX1; const y1 = fromNode.y + driftY1; const x2 = toNode.x + driftX2; const y2 = toNode.y + driftY2; const mx = (x1 + x2) / 2 + (y1 - y2) * 0.1; const my = (y1 + y2) / 2 + (x2 - x1) * 0.1; return { path: `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`, mx, my, x1, y1, x2, y2 }; }, []); // Get node position with drift const getNodePosition = useCallback((node: GraphNode, time: number, isSettled: boolean) => { if (isSettled) { return { x: node.x, y: node.y }; } const driftX = Math.sin(time + node.targetX * 0.01) * 0.3; const driftY = Math.cos(time + node.targetY * 0.01) * 0.3; return { x: node.x + driftX, y: node.y + driftY }; }, []); const handleNodeClick = useCallback((node: GraphNode) => { onNodeClick(node); }, [onNodeClick]); // Zoom handler - zoom towards cursor position const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.min(4, Math.max(0.25, zoom * delta)); // Get cursor position relative to SVG const svg = svgRef.current; if (!svg) return; const rect = svg.getBoundingClientRect(); const cursorX = e.clientX - rect.left; const cursorY = e.clientY - rect.top; // Adjust pan to zoom towards cursor const zoomRatio = newZoom / zoom; const newPanX = cursorX - (cursorX - pan.x) * zoomRatio; const newPanY = cursorY - (cursorY - pan.y) * zoomRatio; setZoom(newZoom); setPan({ x: newPanX, y: newPanY }); }, [zoom, pan]); // Pan handlers const handleMouseDown = useCallback((e: React.MouseEvent) => { // Only pan with middle mouse or when holding space (we'll just use middle mouse for now) if (e.button === 1 || e.button === 0 && e.shiftKey) { e.preventDefault(); isPanningRef.current = true; lastPanPosRef.current = { x: e.clientX, y: e.clientY }; } }, []); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isPanningRef.current) return; const dx = e.clientX - lastPanPosRef.current.x; const dy = e.clientY - lastPanPosRef.current.y; lastPanPosRef.current = { x: e.clientX, y: e.clientY }; setPan(p => ({ x: p.x + dx, y: p.y + dy })); }, []); const handleMouseUp = useCallback(() => { isPanningRef.current = false; }, []); // Reset zoom/pan const handleResetView = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, []); if (containerSize.width === 0) { return
; } const filteredRels = activeFilter ? relationships.filter((r) => r.domain.includes(activeFilter)) : relationships; return (
{/* Grid - outside transform so it stays fixed */} {gridLines} {/* Transformed content */} {/* Domain labels */} {(Object.entries(domainPositions) as [DomainKey, { x: number; y: number }][]).map(([domain, pos]) => { const data = ontology[domain]; return ( {data.label.toUpperCase()} ); })} {/* Edges */} {filteredRels.map((rel, i) => { const fromNode = nodes.find((n) => n.id === rel.from); const toNode = nodes.find((n) => n.id === rel.to); if (!fromNode || !toNode) return null; const isHighlighted = highlightedEntities && highlightedEntities.includes(rel.from) && highlightedEntities.includes(rel.to); const { path, mx, my, x1, y1, x2, y2 } = getEdgePath(fromNode, toNode, time, settled); const baseAlpha = isHighlighted ? 0.7 : 0.12; const pulse = isHighlighted ? Math.sin(time * 4) * 0.15 + 0.15 : 0; const alpha = Math.min(1, baseAlpha + pulse); // Particle position on curve (quadratic bezier) const t = (time * 2) % 1; const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * mx + t * t * x2; const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * my + t * t * y2; return ( {isHighlighted && ( )} ); })} {/* Nodes */} {nodes.map((node) => { const isHighlighted = highlightedEntities && highlightedEntities.includes(node.id); const isHovered = hovered === node.id; const isDimmed = highlightedEntities && highlightedEntities.length > 0 && !isHighlighted; const isFiltered = activeFilter && node.domain !== activeFilter && !relationships.some( r => r.domain.includes(activeFilter) && (r.from === node.id || r.to === node.id) ); const alpha = isFiltered ? 0.15 : isDimmed ? 0.3 : 1; const r = isHighlighted || isHovered ? node.r * 1.4 : node.r; const pulseR = isHighlighted && !settled ? Math.sin(time * 3) * 1.5 : 0; const { x, y } = getNodePosition(node, time, settled); return ( handleNodeClick(node)} onMouseEnter={() => setHovered(node.id)} onMouseLeave={() => setHovered(null)} > {/* Glow */} {(isHighlighted || isHovered) && !isFiltered && ( )} {/* Node circle */} {/* Label */} {node.label} ); })} {/* Close transform group */} setZoom(z => Math.min(4, z * 1.2))} onZoomOut={() => setZoom(z => Math.max(0.25, z / 1.2))} onReset={handleResetView} /> {/* Tooltip */} {hovered && (() => { const node = nodes.find((n) => n.id === hovered); if (!node) return null; const { x, y } = getNodePosition(node, time, settled); return (
{node.icon} {node.label}
{Object.entries(node.props || {}).map(([k, v]) => (
{k}: {String(v)}
))}
); })()}
); }