alexchilton
Initial deployment: Sentiment & Topic Analysis Dashboard
6242ddb
import { useEffect, useRef, useState } from 'react';
import type { TopicGraph, TopicCluster } from '../../types';
interface ForceGraphProps {
graph: TopicGraph;
width?: number;
height?: number;
onNodeClick?: (topic: TopicCluster) => void;
}
interface SimNode extends TopicCluster {
x: number;
y: number;
vx: number;
vy: number;
}
interface SimLink {
source: SimNode;
target: SimNode;
weight: number;
}
export function ForceGraph({ graph, width = 600, height = 400, onNodeClick }: ForceGraphProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [nodes, setNodes] = useState<SimNode[]>([]);
const [links, setLinks] = useState<SimLink[]>([]);
const [hoveredNode, setHoveredNode] = useState<number | null>(null);
const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 });
const animRef = useRef<number>(0);
useEffect(() => {
if (!graph.nodes.length) return;
const simNodes: SimNode[] = graph.nodes
.filter((n) => n.topic_id !== -1)
.map((n) => ({
...n,
x: width / 2 + (Math.random() - 0.5) * 200,
y: height / 2 + (Math.random() - 0.5) * 200,
vx: 0,
vy: 0,
}));
const nodeMap = new Map(simNodes.map((n) => [n.topic_id, n]));
const simLinks: SimLink[] = graph.links
.filter((l) => nodeMap.has(l.source) && nodeMap.has(l.target))
.map((l) => ({
source: nodeMap.get(l.source)!,
target: nodeMap.get(l.target)!,
weight: l.weight,
}));
// Simple force simulation
const alpha = { value: 1 };
const centerX = width / 2;
const centerY = height / 2;
function tick() {
if (alpha.value < 0.001) return;
alpha.value *= 0.99;
// Repulsion
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
const a = simNodes[i]!;
const b = simNodes[j]!;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (500 * alpha.value) / (dist * dist);
a.vx -= (dx / dist) * force;
a.vy -= (dy / dist) * force;
b.vx += (dx / dist) * force;
b.vy += (dy / dist) * force;
}
}
// Attraction (links)
for (const link of simLinks) {
const dx = link.target.x - link.source.x;
const dy = link.target.y - link.source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 100) * 0.01 * alpha.value * link.weight;
link.source.vx += (dx / dist) * force;
link.source.vy += (dy / dist) * force;
link.target.vx -= (dx / dist) * force;
link.target.vy -= (dy / dist) * force;
}
// Center gravity
for (const node of simNodes) {
node.vx += (centerX - node.x) * 0.01 * alpha.value;
node.vy += (centerY - node.y) * 0.01 * alpha.value;
node.vx *= 0.9;
node.vy *= 0.9;
node.x += node.vx;
node.y += node.vy;
}
setNodes([...simNodes]);
setLinks([...simLinks]);
animRef.current = requestAnimationFrame(tick);
}
animRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animRef.current);
}, [graph, width, height]);
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1;
setTransform((prev) => ({
...prev,
k: Math.max(0.2, Math.min(3, prev.k * scaleFactor)),
}));
};
const getNodeRadius = (size: number) => Math.max(8, Math.min(30, Math.sqrt(size) * 3));
const getSentimentColor = (sentiment: number) => {
if (sentiment > 0.6) return 'var(--success)';
if (sentiment < 0.4) return 'var(--danger)';
return 'var(--warning)';
};
if (!graph.nodes.length || nodes.length === 0) {
return (
<div style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
No topic graph data available
</div>
);
}
return (
<svg
ref={svgRef}
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
onWheel={handleWheel}
style={{ cursor: 'grab' }}
role="img"
aria-label="Topic cluster force-directed graph"
>
<g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
{/* Links */}
{links.map((link, i) => (
<line
key={`link-${i}`}
x1={link.source.x}
y1={link.source.y}
x2={link.target.x}
y2={link.target.y}
stroke="var(--border)"
strokeWidth={Math.max(1, link.weight * 3)}
strokeOpacity={0.4}
/>
))}
{/* Nodes */}
{nodes.map((node) => {
const r = getNodeRadius(node.size);
const isHovered = hoveredNode === node.topic_id;
return (
<g key={node.topic_id}>
<circle
cx={node.x}
cy={node.y}
r={r}
fill={getSentimentColor(node.avg_sentiment)}
fillOpacity={isHovered ? 1 : 0.7}
stroke={isHovered ? 'var(--text-primary)' : 'var(--bg-card)'}
strokeWidth={isHovered ? 2 : 1}
onMouseEnter={() => setHoveredNode(node.topic_id)}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => onNodeClick?.(node)}
style={{ cursor: 'pointer', transition: 'fill-opacity 0.2s' }}
role="button"
tabIndex={0}
aria-label={`Topic: ${node.label}, Size: ${node.size}`}
/>
{(isHovered || node.size > 20) && (
<text
x={node.x}
y={node.y + r + 14}
textAnchor="middle"
fill="var(--text-secondary)"
fontSize={10}
fontWeight={isHovered ? 600 : 400}
>
{node.label.length > 20 ? node.label.slice(0, 20) + '…' : node.label}
</text>
)}
{isHovered && (
<text
x={node.x}
y={node.y + r + 26}
textAnchor="middle"
fill="var(--text-muted)"
fontSize={9}
>
{node.size} entries • sentiment: {node.avg_sentiment.toFixed(2)}
</text>
)}
</g>
);
})}
</g>
</svg>
);
}