import { useEffect, useRef, useState, useCallback } from 'react';
import { GraphNode, GraphLink } from '../types';
import { useGraph } from '../hooks/useGraph';
import { typeColors } from '../styles/theme';
interface GraphProps {
onReset?: () => void;
}
interface SimNode extends GraphNode {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
export function Graph({ onReset }: GraphProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>();
const nodesRef = useRef<SimNode[]>([]);
const linksRef = useRef<GraphLink[]>([]);
const [selectedNode, setSelectedNode] = useState<SimNode | null>(null);
const [hoverNode, setHoverNode] = useState<SimNode | null>(null);
const { data, loading, error, loadGraph } = useGraph();
// Load graph data on mount
useEffect(() => {
loadGraph();
}, [loadGraph]);
// Initialize simulation when data loads
useEffect(() => {
if (!data) return;
const width = 1160;
const height = 600;
const centerX = width / 2;
const centerY = height / 2;
// Position nodes in circle initially
nodesRef.current = data.nodes.map((node, i) => {
const angle = (i / data.nodes.length) * Math.PI * 2;
const radius = Math.min(width, height) * 0.3;
return {
...node,
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
vx: 0,
vy: 0,
radius: 8,
};
});
linksRef.current = data.links;
}, [data]);
// Animation loop
const animate = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const nodes = nodesRef.current;
const links = linksRef.current;
// Physics parameters
const repulsion = 800;
const attraction = 0.02;
const damping = 0.85;
// Apply forces
for (let i = 0; i < nodes.length; i++) {
const nodeA = nodes[i];
// Repulsion from all nodes
for (let j = 0; j < nodes.length; j++) {
if (i === j) continue;
const nodeB = nodes[j];
const dx = nodeA.x - nodeB.x;
const dy = nodeA.y - nodeB.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsion / (dist * dist);
nodeA.vx += (dx / dist) * force;
nodeA.vy += (dy / dist) * force;
}
// Attraction from connected nodes
links.forEach((link) => {
let other: SimNode | undefined;
if (link.source === nodeA.id) {
other = nodes.find((n) => n.id === link.target);
} else if (link.target === nodeA.id) {
other = nodes.find((n) => n.id === link.source);
}
if (other) {
const dx = other.x - nodeA.x;
const dy = other.y - nodeA.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = dist * attraction * link.weight;
nodeA.vx += dx * force;
nodeA.vy += dy * force;
}
});
// Center gravity
const dx = 580 - nodeA.x;
const dy = 300 - nodeA.y;
nodeA.vx += dx * 0.008;
nodeA.vy += dy * 0.008;
// Apply velocity
nodeA.x += nodeA.vx;
nodeA.y += nodeA.vy;
nodeA.vx *= damping;
nodeA.vy *= damping;
// Bounds
nodeA.x = Math.max(20, Math.min(1140, nodeA.x));
nodeA.y = Math.max(20, Math.min(580, nodeA.y));
}
// Draw
ctx.clearRect(0, 0, 1160, 600);
// Draw links
ctx.strokeStyle = '#555';
ctx.lineWidth = 0.5;
links.forEach((link) => {
const source = nodes.find((n) => n.id === link.source);
const target = nodes.find((n) => n.id === link.target);
if (source && target) {
ctx.globalAlpha = 0.15 + link.weight * 0.05;
ctx.beginPath();
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
}
});
// Draw nodes
ctx.globalAlpha = 1;
nodes.forEach((node) => {
ctx.fillStyle = typeColors[node.type] || '#666';
ctx.beginPath();
ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
ctx.fill();
if (node === selectedNode) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
}
});
animationRef.current = requestAnimationFrame(animate);
}, [selectedNode]);
// Start animation when canvas is ready
useEffect(() => {
if (!data) return;
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [data, animate]);
// Mouse handlers
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nodes = nodesRef.current;
let clicked: SimNode | null = null;
for (const node of nodes) {
const dx = x - node.x;
const dy = y - node.y;
if (dx * dx + dy * dy < node.radius * node.radius) {
clicked = node;
break;
}
}
setSelectedNode(clicked);
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nodes = nodesRef.current;
let hover: SimNode | null = null;
for (const node of nodes) {
const dx = x - node.x;
const dy = y - node.y;
if (dx * dx + dy * dy < node.radius * node.radius) {
hover = node;
break;
}
}
setHoverNode(hover);
canvas.style.cursor = hover ? 'pointer' : 'default';
};
const handleReset = () => {
setSelectedNode(null);
onReset?.();
};
const displayNode = selectedNode || hoverNode;
if (loading) {
return <div className="loading">Loading graph...</div>;
}
if (error) {
return <div className="error">Error: {error}</div>;
}
return (
<div className="graph-container">
<canvas
ref={canvasRef}
className="graph-canvas"
width={1160}
height={600}
onClick={handleClick}
onMouseMove={handleMouseMove}
/>
<div className="graph-controls">
<button className="btn-secondary" onClick={handleReset}>
Reset
</button>
<span style={{ color: '#666', marginLeft: 10 }}>
{nodesRef.current.length} nodes, {linksRef.current.length} connections
</span>
</div>
<div className="graph-legend">
<div className="legend-item">
<div className="legend-dot" style={{ background: typeColors.principle }} />
<span>Principle</span>
</div>
<div className="legend-item">
<div className="legend-dot" style={{ background: typeColors.learning }} />
<span>Learning</span>
</div>
<div className="legend-item">
<div className="legend-dot" style={{ background: typeColors.retro }} />
<span>Retro</span>
</div>
</div>
{displayNode && (
<div className="graph-details active">
<div className="result-header">
<span className={`result-type type-${displayNode.type}`}>{displayNode.type}</span>
<span className="result-source">{displayNode.source_file}</span>
</div>
<div className="concepts" style={{ marginTop: 10 }}>
{displayNode.concepts.map((c, i) => (
<span key={i} className="concept">
{c}
</span>
))}
</div>
</div>
)}
</div>
);
}