Files
crosspost/frontend/src/components/UserStats.tsx
Dylan De Faoite b270ed03ae feat(frontend): implement corpus explorer
This allows you to view the posts & comments associated with a specific aggregate.
2026-04-01 00:04:25 +01:00

231 lines
7.0 KiB
TypeScript

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<string>();
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<HTMLDivElement | null>(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<GraphLink | null>(
(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 (
<div style={styles.page}>
<div style={{ ...styles.container, ...styles.grid }}>
<Card
label="Users"
value={totalUsers.toLocaleString()}
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Replies"
value={totalInteractions.toLocaleString()}
sublabel="Links with at least 2 replies"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Replies per Connected User"
value={avgInteractionsPerConnectedUser.toFixed(1)}
sublabel="Average from visible graph links"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Most Active User"
value={mostActiveUser?.author ?? "-"}
sublabel={
mostActiveUser
? `${mostActiveUser.count.toLocaleString()} events`
: "No user activity found"
}
rightSlot={
mostActiveUser ? (
<button
onClick={() => onExplore(buildUserSpec(mostActiveUser.author))}
style={styles.buttonSecondary}
>
Explore
</button>
) : null
}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Strongest User Link"
value={
strongestLinkSource && strongestLinkTarget
? `${strongestLinkSource} -> ${strongestLinkTarget}`
: "-"
}
sublabel={
strongestLink
? `${strongestLink.value.toLocaleString()} replies`
: "No graph links after filtering"
}
rightSlot={
strongestLinkSource && strongestLinkTarget ? (
<button
onClick={() =>
onExplore(buildReplyPairSpec(strongestLinkSource, strongestLinkTarget))
}
style={styles.buttonSecondary}
>
Explore
</button>
) : null
}
style={{ gridColumn: "span 6" }}
/>
<Card
label="Most Comment-Heavy User"
value={mostCommentHeavyUser?.author ?? "-"}
sublabel={
mostCommentHeavyUser
? `${Math.round(mostCommentHeavyUser.commentShare * 100)}% comments`
: "No user distribution available"
}
rightSlot={
mostCommentHeavyUser ? (
<button
onClick={() => onExplore(buildUserSpec(mostCommentHeavyUser.author))}
style={styles.buttonSecondary}
>
Explore
</button>
) : null
}
style={{ gridColumn: "span 6" }}
/>
<div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}>
Each node is a user, and each link shows replies between them.
</p>
<div
ref={graphContainerRef}
style={{ width: "100%", height: graphSize.height }}
>
<ForceGraph3D
width={graphSize.width}
height={graphSize.height}
graphData={graphData}
nodeAutoColorBy="id"
linkDirectionalParticles={1}
linkDirectionalParticleSpeed={0.004}
linkWidth={(link) => 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));
}
}}
/>
</div>
</div>
</div>
</div>
);
};
export default UserStats;