import { useEffect, useMemo, useRef, useState } from "react"; import ForceGraph3D from "react-force-graph-3d"; import { type TopUser, type InteractionGraph } from "../types/ApiTypes"; import StatsStyling from "../styles/stats_styling"; import Card from "./Card"; import { buildReplyPairSpec, toText, buildUserSpec, type CorpusExplorerSpec, } from "../utils/corpusExplorer"; const styles = StatsStyling; type GraphLink = { source: string; target: string; value: number; }; function ApiToGraphData(apiData: InteractionGraph) { const links: GraphLink[] = []; const connectedNodeIds = new Set(); for (const [source, targets] of Object.entries(apiData)) { for (const [target, count] of Object.entries(targets)) { if (count < 2 || source === "[deleted]" || target === "[deleted]") { continue; } links.push({ source, target, value: count }); connectedNodeIds.add(source); connectedNodeIds.add(target); } } const filteredNodes = Array.from(connectedNodeIds, (id) => ({ id })); return { nodes: filteredNodes, links }; } type UserStatsProps = { topUsers: TopUser[]; interactionGraph: InteractionGraph; totalUsers: number; mostCommentHeavyUser: { author: string; commentShare: number } | null; onExplore: (spec: CorpusExplorerSpec) => void; }; const UserStats = ({ topUsers, interactionGraph, totalUsers, mostCommentHeavyUser, onExplore, }: UserStatsProps) => { const graphData = useMemo( () => ApiToGraphData(interactionGraph), [interactionGraph], ); const graphContainerRef = useRef(null); const [graphSize, setGraphSize] = useState({ width: 720, height: 540 }); useEffect(() => { const updateGraphSize = () => { const containerWidth = graphContainerRef.current?.clientWidth ?? 720; const nextWidth = Math.max(320, Math.floor(containerWidth)); const nextHeight = nextWidth < 700 ? 300 : 540; setGraphSize({ width: nextWidth, height: nextHeight }); }; updateGraphSize(); window.addEventListener("resize", updateGraphSize); return () => window.removeEventListener("resize", updateGraphSize); }, []); const connectedUsers = graphData.nodes.length; const totalInteractions = graphData.links.reduce( (sum, link) => sum + link.value, 0, ); const avgInteractionsPerConnectedUser = connectedUsers ? totalInteractions / connectedUsers : 0; const strongestLink = graphData.links.reduce( (best, current) => { if (!best || current.value > best.value) { return current; } return best; }, null, ); const mostActiveUser = topUsers.find((u) => u.author !== "[deleted]"); const strongestLinkSource = strongestLink ? toText(strongestLink.source) : ""; const strongestLinkTarget = strongestLink ? toText(strongestLink.target) : ""; return (
onExplore(buildUserSpec(mostActiveUser.author))} style={styles.buttonSecondary} > Explore ) : null } style={{ gridColumn: "span 3" }} /> ${strongestLinkTarget}` : "-" } sublabel={ strongestLink ? `${strongestLink.value.toLocaleString()} replies` : "No graph links after filtering" } rightSlot={ strongestLinkSource && strongestLinkTarget ? ( ) : null } style={{ gridColumn: "span 6" }} /> onExplore(buildUserSpec(mostCommentHeavyUser.author))} style={styles.buttonSecondary} > Explore ) : null } style={{ gridColumn: "span 6" }} />

User Interaction Graph

Each node is a user, and each link shows replies between them.

Math.sqrt(Number(link.value))} nodeLabel={(node) => `${node.id}`} onNodeClick={(node) => { const userId = toText(node.id); if (userId) { onExplore(buildUserSpec(userId)); } }} onLinkClick={(link) => { const source = toText(link.source); const target = toText(link.target); if (source && target) { onExplore(buildReplyPairSpec(source, target)); } }} />
); }; export default UserStats;