import { useEffect, useRef, useState, useCallback, useMemo, Component } from "react"; import { motion } from "motion/react"; import { nodeColor, apiGetGraphData, GraphNode, GraphEdge } from "./mockData"; // ── Types ────────────────────────────────────────────────────────────────────── interface SimNode { id: string; x: number; y: number; vx: number; vy: number; frequency: number; toxicRatio: number; } interface Props { minCooccurrence: number; setMinCooccurrence: (v: number) => void; toxicOnly: boolean; setToxicOnly: (v: boolean) => void; } // ── Constants ────────────────────────────────────────────────────────────────── const W = 820; const H = 540; // WHY increased REPULSION + reduced SPRING for real data: // Mock data had ~6 nodes with low edge weights. Real data has 20-30 nodes // where a hub like "unalive" has 17 edges — the combined spring pull // overwhelmed repulsion and collapsed the graph into one blob. // Higher REPULSION (6000) pushes nodes apart more aggressively, // lower SPRING (0.018) reduces the per-edge pull so a hub with 17 edges // doesn't dominate. EDGE_LEN increased so nodes have more breathing room. const REPULSION = 6000; const SPRING = 0.018; const EDGE_LEN = 180; const GRAVITY = 0.008; const DAMPING = 0.82; const CENTER_X = W / 2; const CENTER_Y = H / 2; // ── Error Boundary ───────────────────────────────────────────────────────────── interface EBState { hasError: boolean; error?: string } class GraphErrorBoundary extends Component<{ children: React.ReactNode }, EBState> { constructor(props: { children: React.ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(err: Error): EBState { return { hasError: true, error: err.message }; } render() { if (this.state.hasError) { return (
⚠ Graph failed to render
{this.state.error || "Unknown error"}
); } return this.props.children; } } // ── Physics hook ─────────────────────────────────────────────────────────────── function safeNum(v: number, fallback = 0): number { return isFinite(v) && !isNaN(v) ? v : fallback; } function useForceSimulation( nodeConfigs: GraphNode[], edges: GraphEdge[], nodeKey: string, ) { const nodesRef = useRef([]); const [positions, setPositions] = useState([]); const rafRef = useRef(0); const edgesRef = useRef(edges); edgesRef.current = edges; const activeRef = useRef(false); const run = useCallback(() => { if (!activeRef.current) return; const ns = nodesRef.current; if (!ns.length) return; const es = edgesRef.current; try { // Repulsion for (let i = 0; i < ns.length; i++) { for (let j = i + 1; j < ns.length; j++) { const dx = (ns[i].x - ns[j].x) || 0.5; const dy = (ns[i].y - ns[j].y) || 0.5; const dist2 = Math.max(0.01, dx * dx + dy * dy); const dist = Math.sqrt(dist2); const force = REPULSION / dist2; const fx = safeNum((dx / dist) * force); const fy = safeNum((dy / dist) * force); ns[i].vx += fx; ns[i].vy += fy; ns[j].vx -= fx; ns[j].vy -= fy; } } // Spring edges for (const e of es) { const s = ns.find(n => n.id === e.source); const t = ns.find(n => n.id === e.target); if (!s || !t) continue; const dx = t.x - s.x; const dy = t.y - s.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; // WHY Math.min cap: with real data edge weights can be 50–500+ // (co-occurrence count across hundreds of posts). Without capping, // naturalLen collapses to ~3px, pulling all nodes into a single blob. // Capping at 8 keeps naturalLen in the range 115–140px regardless of // how large the real-data weights get. const naturalLen = EDGE_LEN / (1 + Math.min(e.weight, 8) * 0.04); const force = (dist - naturalLen) * SPRING; const fx = safeNum((dx / dist) * force); const fy = safeNum((dy / dist) * force); s.vx += fx; s.vy += fy; t.vx -= fx; t.vy -= fy; } // Gravity + integrate for (const n of ns) { n.vx = safeNum(n.vx + (CENTER_X - n.x) * GRAVITY); n.vy = safeNum(n.vy + (CENTER_Y - n.y) * GRAVITY); n.vx *= DAMPING; n.vy *= DAMPING; n.x = safeNum(n.x + n.vx, CENTER_X); n.y = safeNum(n.y + n.vy, CENTER_Y); // WHY log scale: linear sizing (freq * 0.25) lets high-frequency common // words (e.g. "yeah", "his") grow to 10x the size of algospeak terms, // dominating the canvas. Math.log compresses the range so all nodes // stay visually comparable. Min 8px, max ~32px regardless of frequency. const r = 5 + Math.min(Math.log1p(n.frequency ?? 1) * 1.8, 12); n.x = Math.max(r + 40, Math.min(W - r - 40, n.x)); n.y = Math.max(r + 20, Math.min(H - r - 20, n.y)); } if (activeRef.current) { setPositions(ns.map(n => ({ ...n }))); const maxV = ns.reduce((mx, n) => Math.max(mx, Math.abs(n.vx) + Math.abs(n.vy)), 0); if (maxV > 0.15) { rafRef.current = requestAnimationFrame(run); } } } catch { activeRef.current = false; } }, []); useEffect(() => { cancelAnimationFrame(rafRef.current); activeRef.current = true; nodesRef.current = nodeConfigs.map((n, i) => { // WHY circle spread: random init clusters nodes near center, requiring // hundreds of ticks to separate. Evenly spreading in a circle means // repulsion forces are balanced from tick 1 — graph settles readable. const angle = (i / Math.max(nodeConfigs.length, 1)) * 2 * Math.PI; const spread = Math.min(W, H) * 0.32; return { id: n.id, frequency: n.frequency, toxicRatio: n.toxicRatio, x: CENTER_X + Math.cos(angle) * spread, y: CENTER_Y + Math.sin(angle) * spread, vx: (Math.random() - 0.5) * 1.5, vy: (Math.random() - 0.5) * 1.5, }; }); setPositions(nodesRef.current.map(n => ({ ...n }))); rafRef.current = requestAnimationFrame(run); return () => { activeRef.current = false; cancelAnimationFrame(rafRef.current); }; }, [nodeKey, run]); return positions; } // ── Edge colour ─────────────────────────────────────────────────────────────── function edgeColor(sourceRatio: number, targetRatio: number, weight: number): string { const avg = ((sourceRatio ?? 0) + (targetRatio ?? 0)) / 2; const alpha = Math.min(0.9, 0.3 + weight * 0.05); if (avg >= 0.7) return `rgba(255,75,75,${alpha})`; if (avg >= 0.4) return `rgba(255,140,66,${alpha})`; return `rgba(46,204,113,${alpha})`; } // ── Inner graph component ───────────────────────────────────────────────────── function GraphCanvas({ minCooccurrence, toxicOnly, setMinCooccurrence, setToxicOnly, }: { minCooccurrence: number; toxicOnly: boolean; setMinCooccurrence: (v: number) => void; setToxicOnly: (v: boolean) => void; }) { const [built, setBuilt] = useState(false); const [hoveredNode, setHoveredNode] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // WHY state for nodes/edges instead of hardcoded constants: // Previously these were GRAPH_NODES / GRAPH_EDGES imported from mockData. // Now they come from GET /graph-data when the user clicks "Build Graph". // The physics simulation code is exactly unchanged — it just receives real data. const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); // ── Fetch graph data from backend ─────────────────────────────────────────── const handleBuild = useCallback(async () => { setLoading(true); setError(null); try { const data = await apiGetGraphData(minCooccurrence, toxicOnly); setNodes(data.nodes); setEdges(data.edges); setBuilt(true); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load graph data"); } finally { setLoading(false); } }, [minCooccurrence, toxicOnly]); const visibleNodes = useMemo(() => { return nodes.filter(n => { if (toxicOnly && n.toxicRatio < 0.7) return false; const edgeCount = edges.filter( e => (e.source === n.id || e.target === n.id) && e.weight >= minCooccurrence ).length; return edgeCount > 0 || minCooccurrence <= 2; }); }, [nodes, edges, toxicOnly, minCooccurrence]); const visibleEdges = useMemo(() => { const nodeIds = new Set(visibleNodes.map(n => n.id)); return edges.filter( e => e.weight >= minCooccurrence && nodeIds.has(e.source) && nodeIds.has(e.target) ); }, [visibleNodes, edges, minCooccurrence]); const nodeKey = useMemo( () => visibleNodes.map(n => n.id).sort().join(","), [visibleNodes] ); const positions = useForceSimulation(visibleNodes, visibleEdges, nodeKey); const posMap = useMemo(() => { const m: Record = {}; for (const p of positions) m[p.id] = { x: p.x, y: p.y }; return m; }, [positions]); const nodeMap = useMemo(() => { const m: Record = {}; for (const n of visibleNodes) m[n.id] = n; return m; }, [visibleNodes]); return (
{/* Top controls */}
{/* Info card */}
How to read this graph
Words that frequently appear together in algospeak posts are connected. Node size = frequency  |  red >70% toxic {" "}orange 40-70% mixed {" "}green <40% benign
{/* Controls */}
Min co-occurrences
{minCooccurrence}
{ setMinCooccurrence(parseInt(e.target.value)); // Reset graph so user re-clicks Build Graph with new params setBuilt(false); setNodes([]); setEdges([]); }} style={{ width: "100%", accentColor: "#ff4b4b" }} />
{loading ? "Loading…" : built ? "✓ Graph Active" : "Build Graph"}
{/* Error state */} {error && (
⚠ {error}
)} {/* Graph canvas */} {!built ? ( Adjust settings above and click "Build Graph" to visualize word co-occurrences. ) : ( {/* Background grid */} {/* Edges */} {visibleEdges.map(e => { const s = posMap[e.source]; const t = posMap[e.target]; if (!s || !t) return null; const sNode = nodeMap[e.source]; const tNode = nodeMap[e.target]; if (!sNode || !tNode) return null; const isHighlighted = hoveredNode === e.source || hoveredNode === e.target; return ( ); })} {/* Nodes */} {positions.map(node => { const freq = node.frequency ?? 1; const tRatio = node.toxicRatio ?? 0.5; // WHY log1p: same formula as the physics loop so the rendered // circle matches the collision radius used for simulation. const size = 5 + Math.min(Math.log1p(freq) * 1.8, 12); const color = nodeColor(tRatio); const isHovered = hoveredNode === node.id; const isDimmed = !!(hoveredNode && !isHovered); const r = isHovered ? size * 1.25 : size; if (!isFinite(node.x) || !isFinite(node.y)) return null; return ( setHoveredNode(node.id)} onMouseLeave={() => setHoveredNode(null)} style={{ cursor: "pointer" }} > {node.id} ); })} {/* Hover tooltip */} {hoveredNode && (() => { const n = visibleNodes.find(x => x.id === hoveredNode); if (!n) return null; const color = nodeColor(n.toxicRatio); const connections = visibleEdges.filter( e => e.source === n.id || e.target === n.id ).length; return (
{n.id}
Frequency: {n.frequency}
Toxic ratio:{" "} {(n.toxicRatio * 100).toFixed(0)}%
Connections: {connections}
); })()} )}
); } // ── Public component ─────────────────────────────────────────────────────────── export function CoOccurrenceGraph({ minCooccurrence, setMinCooccurrence, toxicOnly, setToxicOnly }: Props) { return ( ); }