feat(user stats): updated styling and stats in user page
Interaction graph was taking up too much space and was the only thing on the screen. Further statistics were added however these may be removed in favour of more informative statistics
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import ForceGraph3D from "react-force-graph-3d";
|
import ForceGraph3D from "react-force-graph-3d";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,12 +7,19 @@ import {
|
|||||||
} from '../types/ApiTypes';
|
} from '../types/ApiTypes';
|
||||||
|
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
import Card from "./Card";
|
||||||
|
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
|
|
||||||
|
type GraphLink = {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
function ApiToGraphData(apiData: InteractionGraph) {
|
function ApiToGraphData(apiData: InteractionGraph) {
|
||||||
const nodes = Object.keys(apiData).map(username => ({ id: username }));
|
const nodes = Object.keys(apiData).map(username => ({ id: username }));
|
||||||
const links = [];
|
const links: GraphLink[] = [];
|
||||||
|
|
||||||
for (const [source, targets] of Object.entries(apiData)) {
|
for (const [source, targets] of Object.entries(apiData)) {
|
||||||
for (const [target, count] of Object.entries(targets)) {
|
for (const [target, count] of Object.entries(targets)) {
|
||||||
@@ -35,26 +43,105 @@ function ApiToGraphData(apiData: InteractionGraph) {
|
|||||||
|
|
||||||
|
|
||||||
const UserStats = (props: { data: UserAnalysisResponse }) => {
|
const UserStats = (props: { data: UserAnalysisResponse }) => {
|
||||||
const graphData = ApiToGraphData(props.data.interaction_graph);
|
const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]);
|
||||||
|
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 totalUsers = props.data.users.length;
|
||||||
|
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 highlyInteractiveUser = [...props.data.users].sort((a, b) => b.comment_share - a.comment_share)[0];
|
||||||
|
|
||||||
|
const mostActiveUser = props.data.top_users.find(u => u.author !== "[deleted]");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<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="Interactions"
|
||||||
|
value={totalInteractions.toLocaleString()}
|
||||||
|
sublabel="Filtered links (2+ interactions)"
|
||||||
|
style={{ gridColumn: "span 3" }}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Average Intensity"
|
||||||
|
value={avgInteractionsPerConnectedUser.toFixed(1)}
|
||||||
|
sublabel="Interactions per connected user"
|
||||||
|
style={{ gridColumn: "span 3" }}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Most Active User"
|
||||||
|
value={mostActiveUser?.author ?? "—"}
|
||||||
|
sublabel={mostActiveUser ? `${mostActiveUser.count.toLocaleString()} events` : "No user activity found"}
|
||||||
|
style={{ gridColumn: "span 3" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
label="Strongest Connection"
|
||||||
|
value={strongestLink ? `${strongestLink.source} -> ${strongestLink.target}` : "—"}
|
||||||
|
sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} interactions` : "No graph edges after filtering"}
|
||||||
|
style={{ gridColumn: "span 6" }}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Most Reply-Driven User"
|
||||||
|
value={highlyInteractiveUser?.author ?? "—"}
|
||||||
|
sublabel={
|
||||||
|
highlyInteractiveUser
|
||||||
|
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments`
|
||||||
|
: "No user distribution available"
|
||||||
|
}
|
||||||
|
style={{ gridColumn: "span 6" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
|
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
|
||||||
<p style={styles.sectionSubtitle}>
|
<p style={styles.sectionSubtitle}>
|
||||||
This graph visualizes interactions between users based on comments and replies.
|
Nodes represent users and links represent conversation interactions.
|
||||||
Nodes represent users, and edges represent interactions (e.g., comments or replies) between them.
|
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}>
|
||||||
<ForceGraph3D
|
<ForceGraph3D
|
||||||
|
width={graphSize.width}
|
||||||
|
height={graphSize.height}
|
||||||
graphData={graphData}
|
graphData={graphData}
|
||||||
nodeAutoColorBy="id"
|
nodeAutoColorBy="id"
|
||||||
linkDirectionalParticles={2}
|
linkDirectionalParticles={1}
|
||||||
linkDirectionalParticleSpeed={0.005}
|
linkDirectionalParticleSpeed={0.004}
|
||||||
linkWidth={(link) => Math.sqrt(link.value)}
|
linkWidth={(link) => Math.sqrt(Number(link.value))}
|
||||||
nodeLabel={(node) => `${node.id}`}
|
nodeLabel={(node) => `${node.id}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
|
|||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
import SummaryStats from "../components/SummaryStats";
|
import SummaryStats from "../components/SummaryStats";
|
||||||
import EmotionalStats from "../components/EmotionalStats";
|
import EmotionalStats from "../components/EmotionalStats";
|
||||||
import InteractionStats from "../components/UserStats";
|
import UserStats from "../components/UserStats";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type SummaryResponse,
|
type SummaryResponse,
|
||||||
@@ -20,7 +20,7 @@ const StatPage = () => {
|
|||||||
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
|
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeView, setActiveView] = useState<"summary" | "emotional" | "interaction">("summary");
|
const [activeView, setActiveView] = useState<"summary" | "emotional" | "user">("summary");
|
||||||
|
|
||||||
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
|
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
|
||||||
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
|
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
|
||||||
@@ -213,10 +213,10 @@ return (
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveView("interaction")}
|
onClick={() => setActiveView("user")}
|
||||||
style={activeView === "interaction" ? styles.buttonPrimary : styles.buttonSecondary}
|
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
|
||||||
>
|
>
|
||||||
Interaction
|
Users
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,8 +239,8 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeView === "interaction" && userData && (
|
{activeView === "user" && userData && (
|
||||||
<InteractionStats data={userData} />
|
<UserStats data={userData} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user