From a841c6f6a12f694a2919e696ecd094a19c5cd055 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Tue, 31 Mar 2026 20:15:07 +0100 Subject: [PATCH] perf(stats): memoize derived state and reduce intermediate allocations --- frontend/src/components/EmotionalStats.tsx | 14 +-- .../src/components/InteractionalStats.tsx | 13 ++- frontend/src/components/SummaryStats.tsx | 73 +++++++++--- frontend/src/components/UserStats.tsx | 59 +++++----- frontend/src/pages/Stats.tsx | 110 ++++++++++++------ frontend/src/stats/ActivityHeatmap.tsx | 19 ++- 6 files changed, 186 insertions(+), 102 deletions(-) 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);