diff --git a/frontend/src/components/EmotionalStats.tsx b/frontend/src/components/EmotionalStats.tsx
index 9797ba9..6350e0c 100644
--- a/frontend/src/components/EmotionalStats.tsx
+++ b/frontend/src/components/EmotionalStats.tsx
@@ -1,18 +1,18 @@
-import type { ContentAnalysisResponse } from "../types/ApiTypes";
+import type { EmotionalAnalysisResponse } from "../types/ApiTypes";
import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling;
type EmotionalStatsProps = {
- contentData: ContentAnalysisResponse;
+ emotionalData: EmotionalAnalysisResponse;
};
-const EmotionalStats = ({ contentData }: EmotionalStatsProps) => {
- const rows = contentData.average_emotion_by_topic ?? [];
- const overallEmotionAverage = contentData.overall_emotion_average ?? [];
+const EmotionalStats = ({ emotionalData }: EmotionalStatsProps) => {
+ const rows = emotionalData.average_emotion_by_topic ?? [];
+ const overallEmotionAverage = emotionalData.overall_emotion_average ?? [];
const dominantEmotionDistribution =
- contentData.dominant_emotion_distribution ?? [];
- const emotionBySource = contentData.emotion_by_source ?? [];
+ emotionalData.dominant_emotion_distribution ?? [];
+ const emotionBySource = emotionalData.emotion_by_source ?? [];
const lowSampleThreshold = 20;
const stableSampleThreshold = 50;
const emotionKeys = rows.length
diff --git a/frontend/src/components/InteractionalStats.tsx b/frontend/src/components/InteractionalStats.tsx
index 7ec1539..da82624 100644
--- a/frontend/src/components/InteractionalStats.tsx
+++ b/frontend/src/components/InteractionalStats.tsx
@@ -24,11 +24,14 @@ type InteractionalStatsProps = {
const InteractionalStats = ({ data }: InteractionalStatsProps) => {
const graph = data.interaction_graph ?? {};
const userCount = Object.keys(graph).length;
- const edges = Object.values(graph).flatMap((targets) =>
- Object.values(targets),
- );
- const edgeCount = edges.length;
- const interactionVolume = edges.reduce((sum, value) => sum + value, 0);
+ let edgeCount = 0;
+ let interactionVolume = 0;
+ for (const targets of Object.values(graph)) {
+ for (const value of Object.values(targets)) {
+ edgeCount += 1;
+ interactionVolume += value;
+ }
+ }
const concentration = data.conversation_concentration;
const topTenCommentShare =
typeof concentration?.top_10pct_comment_share === "number"
diff --git a/frontend/src/components/SummaryStats.tsx b/frontend/src/components/SummaryStats.tsx
index 2c8d53e..01cedb3 100644
--- a/frontend/src/components/SummaryStats.tsx
+++ b/frontend/src/components/SummaryStats.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { memo, useMemo, useState } from "react";
import {
LineChart,
Line,
@@ -18,21 +18,37 @@ import UserModal from "../components/UserModal";
import {
type SummaryResponse,
type FrequencyWord,
- type UserAnalysisResponse,
+ type UserEndpointResponse,
type TimeAnalysisResponse,
- type ContentAnalysisResponse,
+ type LinguisticAnalysisResponse,
type User,
} from "../types/ApiTypes";
const styles = StatsStyling;
+const MAX_WORDCLOUD_WORDS = 250;
+
+const WORDCLOUD_OPTIONS = {
+ rotations: 2,
+ rotationAngles: [0, 90] as [number, number],
+ fontSizes: [14, 60] as [number, number],
+ enableTooltip: true,
+};
type SummaryStatsProps = {
- userData: UserAnalysisResponse | null;
+ userData: UserEndpointResponse | null;
timeData: TimeAnalysisResponse | null;
- contentData: ContentAnalysisResponse | null;
+ linguisticData: LinguisticAnalysisResponse | null;
summary: SummaryResponse | null;
};
+type WordCloudPanelProps = {
+ words: { text: string; value: number }[];
+};
+
+const WordCloudPanel = memo(({ words }: WordCloudPanelProps) => (
+
+));
+
function formatDateRange(startUnix: number, endUnix: number) {
const start = new Date(startUnix * 1000);
const end = new Date(endUnix * 1000);
@@ -57,12 +73,34 @@ function convertFrequencyData(data: FrequencyWord[]) {
const SummaryStats = ({
userData,
timeData,
- contentData,
+ linguisticData,
summary,
}: SummaryStatsProps) => {
const [selectedUser, setSelectedUser] = useState(null);
- const selectedUserData: User | null =
- userData?.users.find((u) => u.author === selectedUser) ?? null;
+ const usersByAuthor = useMemo(() => {
+ const nextMap = new Map();
+ for (const user of userData?.users ?? []) {
+ nextMap.set(user.author, user);
+ }
+ return nextMap;
+ }, [userData?.users]);
+
+ const selectedUserData: User | null = selectedUser
+ ? usersByAuthor.get(selectedUser) ?? null
+ : null;
+
+ const wordCloudWords = useMemo(
+ () =>
+ convertFrequencyData(
+ (linguisticData?.word_frequencies ?? []).slice(0, MAX_WORDCLOUD_WORDS),
+ ),
+ [linguisticData?.word_frequencies],
+ );
+
+ const topUsersPreview = useMemo(
+ () => (userData?.top_users ?? []).slice(0, 100),
+ [userData?.top_users],
+ );
return (
@@ -152,7 +190,12 @@ const SummaryStats = ({
-
+
@@ -166,15 +209,7 @@ const SummaryStats = ({
-
+
@@ -186,7 +221,7 @@ const SummaryStats = ({
Who posted the most events.
- {userData?.top_users.slice(0, 100).map((item) => (
+ {topUsersPreview.map((item) => (
({ id: username }));
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);
}
}
- // drop low-value and deleted interactions to reduce clutter
- const filteredLinks = links.filter(
- (link) =>
- link.value >= 2 &&
- link.source !== "[deleted]" &&
- link.target !== "[deleted]",
- );
+ const filteredNodes = Array.from(connectedNodeIds, (id) => ({ id }));
- // also filter out nodes that are no longer connected after link filtering
- const connectedNodeIds = new Set(
- filteredLinks.flatMap((link) => [link.source, link.target]),
- );
- const filteredNodes = nodes.filter((node) => connectedNodeIds.has(node.id));
-
- return { nodes: filteredNodes, links: filteredLinks };
+ return { nodes: filteredNodes, links };
}
-const UserStats = (props: { data: UserAnalysisResponse }) => {
+type UserStatsProps = {
+ topUsers: TopUser[];
+ interactionGraph: InteractionGraph;
+ totalUsers: number;
+ mostCommentHeavyUser: { author: string; commentShare: number } | null;
+};
+
+const UserStats = ({
+ topUsers,
+ interactionGraph,
+ totalUsers,
+ mostCommentHeavyUser,
+}: UserStatsProps) => {
const graphData = useMemo(
- () => ApiToGraphData(props.data.interaction_graph),
- [props.data.interaction_graph],
+ () => ApiToGraphData(interactionGraph),
+ [interactionGraph],
);
const graphContainerRef = useRef(null);
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
@@ -66,7 +68,6 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
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,
@@ -86,11 +87,7 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
null,
);
- const highlyInteractiveUser = [...props.data.users].sort(
- (a, b) => b.comment_share - a.comment_share,
- )[0];
-
- const mostActiveUser = props.data.top_users.find(
+ const mostActiveUser = topUsers.find(
(u) => u.author !== "[deleted]",
);
@@ -142,10 +139,10 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
/>
DELETED_USERS.includes((value ?? "").trim().toLowerCase());
+type ActiveView =
+ | "summary"
+ | "emotional"
+ | "user"
+ | "linguistic"
+ | "interactional"
+ | "cultural";
+
+type UserStatsMeta = {
+ totalUsers: number;
+ mostCommentHeavyUser: { author: string; commentShare: number } | null;
+};
+
const StatPage = () => {
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
- const [activeView, setActiveView] = useState<
- | "summary"
- | "emotional"
- | "user"
- | "linguistic"
- | "interactional"
- | "cultural"
- >("summary");
+ const [activeView, setActiveView] = useState("summary");
- const [userData, setUserData] = useState(null);
+ const [userData, setUserData] = useState(null);
const [timeData, setTimeData] = useState(null);
- const [contentData, setContentData] =
- useState(null);
const [linguisticData, setLinguisticData] =
useState(null);
+ const [emotionalData, setEmotionalData] =
+ useState(null);
const [interactionData, setInteractionData] =
useState(null);
const [culturalData, setCulturalData] =
useState(null);
const [summary, setSummary] = useState(null);
+ const [userStatsMeta, setUserStatsMeta] = useState({
+ totalUsers: 0,
+ mostCommentHeavyUser: null,
+ });
const searchInputRef = useRef(null);
const beforeDateRef = useRef(null);
@@ -185,14 +194,35 @@ const StatPage = () => {
const filteredTopUsers: typeof topUsersList = [];
for (const user of topUsersList) {
- if (isDeletedUser(user.author)) continue;
- filteredTopUsers.push(user);
+ if (isDeletedUser(user.author)) continue;
+ filteredTopUsers.push(user);
}
- const filteredInteractionGraph: Record<
- string,
- Record
- > = {};
+ let mostCommentHeavyUser: UserStatsMeta["mostCommentHeavyUser"] =
+ null;
+ for (const user of filteredUsers) {
+ const currentShare = user.comment_share ?? 0;
+ if (
+ !mostCommentHeavyUser ||
+ currentShare > mostCommentHeavyUser.commentShare
+ ) {
+ mostCommentHeavyUser = {
+ author: user.author,
+ commentShare: currentShare,
+ };
+ }
+ }
+
+ const topAuthors = new Set(filteredTopUsers.map((entry) => entry.author));
+ const summaryUsers: User[] = [];
+ for (const user of filteredUsers) {
+ if (topAuthors.has(user.author)) {
+ summaryUsers.push(user);
+ }
+ }
+
+ const filteredInteractionGraph: Record> =
+ {};
for (const [source, targets] of Object.entries(interactionGraphRaw)) {
if (isDeletedUser(source)) {
continue;
@@ -220,16 +250,9 @@ const StatPage = () => {
filteredTopInteractionPairs.push(pairEntry);
}
- const combinedUserData: UserAnalysisResponse = {
- ...userRes.data,
- users: filteredUsers,
+ const filteredUserData: UserEndpointResponse = {
+ users: summaryUsers,
top_users: filteredTopUsers,
- interaction_graph: filteredInteractionGraph,
- };
-
- const combinedContentData: ContentAnalysisResponse = {
- ...linguisticRes.data,
- ...emotionalRes.data,
};
const filteredInteractionData: InteractionAnalysisResponse = {
@@ -243,10 +266,14 @@ const StatPage = () => {
unique_users: filteredUsers.length,
};
- setUserData(combinedUserData);
+ setUserData(filteredUserData);
+ setUserStatsMeta({
+ totalUsers: filteredUsers.length,
+ mostCommentHeavyUser,
+ });
setTimeData(timeRes.data || null);
- setContentData(combinedContentData);
setLinguisticData(linguisticRes.data || null);
+ setEmotionalData(emotionalRes.data || null);
setInteractionData(filteredInteractionData || null);
setCulturalData(culturalRes.data || null);
setSummary(filteredSummary || null);
@@ -435,22 +462,35 @@ const StatPage = () => {
)}
- {activeView === "emotional" && contentData && (
-
+ {activeView === "emotional" && emotionalData && (
+
)}
- {activeView === "emotional" && !contentData && (
+ {activeView === "emotional" && !emotionalData && (
No emotional data available.
)}
- {activeView === "user" && userData && }
+ {activeView === "user" && userData && interactionData && (
+
+ )}
+
+ {activeView === "user" && (!userData || !interactionData) && (
+
+ No user network data available.
+
+ )}
{activeView === "linguistic" && linguisticData && (
diff --git a/frontend/src/stats/ActivityHeatmap.tsx b/frontend/src/stats/ActivityHeatmap.tsx
index 73398e9..6deba2a 100644
--- a/frontend/src/stats/ActivityHeatmap.tsx
+++ b/frontend/src/stats/ActivityHeatmap.tsx
@@ -1,4 +1,5 @@
import { ResponsiveHeatMap } from "@nivo/heatmap";
+import { memo, useMemo } from "react";
type ApiRow = Record;
type ActivityHeatmapProps = {
@@ -40,11 +41,19 @@ const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
};
const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
- const convertedData = convertWeeklyData(data);
+ const convertedData = useMemo(() => convertWeeklyData(data), [data]);
- const maxValue = Math.max(
- ...convertedData.flatMap((day) => day.data.map((point) => point.y)),
- );
+ const maxValue = useMemo(() => {
+ let max = 0;
+ for (const day of convertedData) {
+ for (const point of day.data) {
+ if (point.y > max) {
+ max = point.y;
+ }
+ }
+ }
+ return max;
+ }, [convertedData]);
return (
{
);
};
-export default ActivityHeatmap;
+export default memo(ActivityHeatmap);