perf(stats): memoize derived state and reduce intermediate allocations

This commit is contained in:
2026-03-31 20:15:07 +01:00
parent 2045ccebb5
commit a841c6f6a1
6 changed files with 186 additions and 102 deletions

View File

@@ -1,18 +1,18 @@
import type { ContentAnalysisResponse } from "../types/ApiTypes"; import type { EmotionalAnalysisResponse } from "../types/ApiTypes";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling; const styles = StatsStyling;
type EmotionalStatsProps = { type EmotionalStatsProps = {
contentData: ContentAnalysisResponse; emotionalData: EmotionalAnalysisResponse;
}; };
const EmotionalStats = ({ contentData }: EmotionalStatsProps) => { const EmotionalStats = ({ emotionalData }: EmotionalStatsProps) => {
const rows = contentData.average_emotion_by_topic ?? []; const rows = emotionalData.average_emotion_by_topic ?? [];
const overallEmotionAverage = contentData.overall_emotion_average ?? []; const overallEmotionAverage = emotionalData.overall_emotion_average ?? [];
const dominantEmotionDistribution = const dominantEmotionDistribution =
contentData.dominant_emotion_distribution ?? []; emotionalData.dominant_emotion_distribution ?? [];
const emotionBySource = contentData.emotion_by_source ?? []; const emotionBySource = emotionalData.emotion_by_source ?? [];
const lowSampleThreshold = 20; const lowSampleThreshold = 20;
const stableSampleThreshold = 50; const stableSampleThreshold = 50;
const emotionKeys = rows.length const emotionKeys = rows.length

View File

@@ -24,11 +24,14 @@ type InteractionalStatsProps = {
const InteractionalStats = ({ data }: InteractionalStatsProps) => { const InteractionalStats = ({ data }: InteractionalStatsProps) => {
const graph = data.interaction_graph ?? {}; const graph = data.interaction_graph ?? {};
const userCount = Object.keys(graph).length; const userCount = Object.keys(graph).length;
const edges = Object.values(graph).flatMap((targets) => let edgeCount = 0;
Object.values(targets), let interactionVolume = 0;
); for (const targets of Object.values(graph)) {
const edgeCount = edges.length; for (const value of Object.values(targets)) {
const interactionVolume = edges.reduce((sum, value) => sum + value, 0); edgeCount += 1;
interactionVolume += value;
}
}
const concentration = data.conversation_concentration; const concentration = data.conversation_concentration;
const topTenCommentShare = const topTenCommentShare =
typeof concentration?.top_10pct_comment_share === "number" typeof concentration?.top_10pct_comment_share === "number"

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { memo, useMemo, useState } from "react";
import { import {
LineChart, LineChart,
Line, Line,
@@ -18,21 +18,37 @@ import UserModal from "../components/UserModal";
import { import {
type SummaryResponse, type SummaryResponse,
type FrequencyWord, type FrequencyWord,
type UserAnalysisResponse, type UserEndpointResponse,
type TimeAnalysisResponse, type TimeAnalysisResponse,
type ContentAnalysisResponse, type LinguisticAnalysisResponse,
type User, type User,
} from "../types/ApiTypes"; } from "../types/ApiTypes";
const styles = StatsStyling; 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 = { type SummaryStatsProps = {
userData: UserAnalysisResponse | null; userData: UserEndpointResponse | null;
timeData: TimeAnalysisResponse | null; timeData: TimeAnalysisResponse | null;
contentData: ContentAnalysisResponse | null; linguisticData: LinguisticAnalysisResponse | null;
summary: SummaryResponse | null; summary: SummaryResponse | null;
}; };
type WordCloudPanelProps = {
words: { text: string; value: number }[];
};
const WordCloudPanel = memo(({ words }: WordCloudPanelProps) => (
<ReactWordcloud words={words} options={WORDCLOUD_OPTIONS} />
));
function formatDateRange(startUnix: number, endUnix: number) { function formatDateRange(startUnix: number, endUnix: number) {
const start = new Date(startUnix * 1000); const start = new Date(startUnix * 1000);
const end = new Date(endUnix * 1000); const end = new Date(endUnix * 1000);
@@ -57,12 +73,34 @@ function convertFrequencyData(data: FrequencyWord[]) {
const SummaryStats = ({ const SummaryStats = ({
userData, userData,
timeData, timeData,
contentData, linguisticData,
summary, summary,
}: SummaryStatsProps) => { }: SummaryStatsProps) => {
const [selectedUser, setSelectedUser] = useState<string | null>(null); const [selectedUser, setSelectedUser] = useState<string | null>(null);
const selectedUserData: User | null = const usersByAuthor = useMemo(() => {
userData?.users.find((u) => u.author === selectedUser) ?? null; const nextMap = new Map<string, User>();
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 ( return (
<div style={styles.page}> <div style={styles.page}>
@@ -152,7 +190,12 @@ const SummaryStats = ({
<XAxis dataKey="date" /> <XAxis dataKey="date" />
<YAxis /> <YAxis />
<Tooltip /> <Tooltip />
<Line type="monotone" dataKey="count" name="Events" /> <Line
type="monotone"
dataKey="count"
name="Events"
isAnimationActive={false}
/>
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -166,15 +209,7 @@ const SummaryStats = ({
</p> </p>
<div style={styles.chartWrapper}> <div style={styles.chartWrapper}>
<ReactWordcloud <WordCloudPanel words={wordCloudWords} />
words={convertFrequencyData(contentData?.word_frequencies ?? [])}
options={{
rotations: 2,
rotationAngles: [0, 90],
fontSizes: [14, 60],
enableTooltip: true,
}}
/>
</div> </div>
</div> </div>
@@ -186,7 +221,7 @@ const SummaryStats = ({
<p style={styles.sectionSubtitle}>Who posted the most events.</p> <p style={styles.sectionSubtitle}>Who posted the most events.</p>
<div style={styles.topUsersList}> <div style={styles.topUsersList}>
{userData?.top_users.slice(0, 100).map((item) => ( {topUsersPreview.map((item) => (
<div <div
key={`${item.author}-${item.source}`} key={`${item.author}-${item.source}`}
style={{ ...styles.topUserItem, cursor: "pointer" }} style={{ ...styles.topUserItem, cursor: "pointer" }}

View File

@@ -1,10 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D from "react-force-graph-3d"; import ForceGraph3D from "react-force-graph-3d";
import { import { type TopUser, type InteractionGraph } from "../types/ApiTypes";
type UserAnalysisResponse,
type InteractionGraph,
} from "../types/ApiTypes";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
import Card from "./Card"; import Card from "./Card";
@@ -18,36 +15,41 @@ type GraphLink = {
}; };
function ApiToGraphData(apiData: InteractionGraph) { function ApiToGraphData(apiData: InteractionGraph) {
const nodes = Object.keys(apiData).map((username) => ({ id: username }));
const links: GraphLink[] = []; const links: GraphLink[] = [];
const connectedNodeIds = new Set<string>();
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)) {
if (count < 2 || source === "[deleted]" || target === "[deleted]") {
continue;
}
links.push({ source, target, value: count }); links.push({ source, target, value: count });
connectedNodeIds.add(source);
connectedNodeIds.add(target);
} }
} }
// drop low-value and deleted interactions to reduce clutter const filteredNodes = Array.from(connectedNodeIds, (id) => ({ id }));
const filteredLinks = links.filter(
(link) =>
link.value >= 2 &&
link.source !== "[deleted]" &&
link.target !== "[deleted]",
);
// also filter out nodes that are no longer connected after link filtering return { nodes: filteredNodes, links };
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 };
} }
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( const graphData = useMemo(
() => ApiToGraphData(props.data.interaction_graph), () => ApiToGraphData(interactionGraph),
[props.data.interaction_graph], [interactionGraph],
); );
const graphContainerRef = useRef<HTMLDivElement | null>(null); const graphContainerRef = useRef<HTMLDivElement | null>(null);
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 }); const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
@@ -66,7 +68,6 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
return () => window.removeEventListener("resize", updateGraphSize); return () => window.removeEventListener("resize", updateGraphSize);
}, []); }, []);
const totalUsers = props.data.users.length;
const connectedUsers = graphData.nodes.length; const connectedUsers = graphData.nodes.length;
const totalInteractions = graphData.links.reduce( const totalInteractions = graphData.links.reduce(
(sum, link) => sum + link.value, (sum, link) => sum + link.value,
@@ -86,11 +87,7 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
null, null,
); );
const highlyInteractiveUser = [...props.data.users].sort( const mostActiveUser = topUsers.find(
(a, b) => b.comment_share - a.comment_share,
)[0];
const mostActiveUser = props.data.top_users.find(
(u) => u.author !== "[deleted]", (u) => u.author !== "[deleted]",
); );
@@ -142,10 +139,10 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
/> />
<Card <Card
label="Most Comment-Heavy User" label="Most Comment-Heavy User"
value={highlyInteractiveUser?.author ?? "—"} value={mostCommentHeavyUser?.author ?? "—"}
sublabel={ sublabel={
highlyInteractiveUser mostCommentHeavyUser
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments` ? `${Math.round(mostCommentHeavyUser.commentShare * 100)}% comments`
: "No user distribution available" : "No user distribution available"
} }
style={{ gridColumn: "span 6" }} style={{ gridColumn: "span 6" }}

View File

@@ -11,9 +11,8 @@ import CulturalStats from "../components/CulturalStats";
import { import {
type SummaryResponse, type SummaryResponse,
type UserAnalysisResponse,
type TimeAnalysisResponse, type TimeAnalysisResponse,
type ContentAnalysisResponse, type User,
type UserEndpointResponse, type UserEndpointResponse,
type LinguisticAnalysisResponse, type LinguisticAnalysisResponse,
type EmotionalAnalysisResponse, type EmotionalAnalysisResponse,
@@ -28,30 +27,40 @@ const DELETED_USERS = ["[deleted]"];
const isDeletedUser = (value: string | null | undefined) => const isDeletedUser = (value: string | null | undefined) =>
DELETED_USERS.includes((value ?? "").trim().toLowerCase()); 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 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< const [activeView, setActiveView] = useState<ActiveView>("summary");
| "summary"
| "emotional"
| "user"
| "linguistic"
| "interactional"
| "cultural"
>("summary");
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null); const [userData, setUserData] = useState<UserEndpointResponse | null>(null);
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null); const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
const [contentData, setContentData] =
useState<ContentAnalysisResponse | null>(null);
const [linguisticData, setLinguisticData] = const [linguisticData, setLinguisticData] =
useState<LinguisticAnalysisResponse | null>(null); useState<LinguisticAnalysisResponse | null>(null);
const [emotionalData, setEmotionalData] =
useState<EmotionalAnalysisResponse | null>(null);
const [interactionData, setInteractionData] = const [interactionData, setInteractionData] =
useState<InteractionAnalysisResponse | null>(null); useState<InteractionAnalysisResponse | null>(null);
const [culturalData, setCulturalData] = const [culturalData, setCulturalData] =
useState<CulturalAnalysisResponse | null>(null); useState<CulturalAnalysisResponse | null>(null);
const [summary, setSummary] = useState<SummaryResponse | null>(null); const [summary, setSummary] = useState<SummaryResponse | null>(null);
const [userStatsMeta, setUserStatsMeta] = useState<UserStatsMeta>({
totalUsers: 0,
mostCommentHeavyUser: null,
});
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const beforeDateRef = useRef<HTMLInputElement>(null); const beforeDateRef = useRef<HTMLInputElement>(null);
@@ -185,14 +194,35 @@ const StatPage = () => {
const filteredTopUsers: typeof topUsersList = []; const filteredTopUsers: typeof topUsersList = [];
for (const user of topUsersList) { for (const user of topUsersList) {
if (isDeletedUser(user.author)) continue; if (isDeletedUser(user.author)) continue;
filteredTopUsers.push(user); filteredTopUsers.push(user);
} }
const filteredInteractionGraph: Record< let mostCommentHeavyUser: UserStatsMeta["mostCommentHeavyUser"] =
string, null;
Record<string, number> 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<string, Record<string, number>> =
{};
for (const [source, targets] of Object.entries(interactionGraphRaw)) { for (const [source, targets] of Object.entries(interactionGraphRaw)) {
if (isDeletedUser(source)) { if (isDeletedUser(source)) {
continue; continue;
@@ -220,16 +250,9 @@ const StatPage = () => {
filteredTopInteractionPairs.push(pairEntry); filteredTopInteractionPairs.push(pairEntry);
} }
const combinedUserData: UserAnalysisResponse = { const filteredUserData: UserEndpointResponse = {
...userRes.data, users: summaryUsers,
users: filteredUsers,
top_users: filteredTopUsers, top_users: filteredTopUsers,
interaction_graph: filteredInteractionGraph,
};
const combinedContentData: ContentAnalysisResponse = {
...linguisticRes.data,
...emotionalRes.data,
}; };
const filteredInteractionData: InteractionAnalysisResponse = { const filteredInteractionData: InteractionAnalysisResponse = {
@@ -243,10 +266,14 @@ const StatPage = () => {
unique_users: filteredUsers.length, unique_users: filteredUsers.length,
}; };
setUserData(combinedUserData); setUserData(filteredUserData);
setUserStatsMeta({
totalUsers: filteredUsers.length,
mostCommentHeavyUser,
});
setTimeData(timeRes.data || null); setTimeData(timeRes.data || null);
setContentData(combinedContentData);
setLinguisticData(linguisticRes.data || null); setLinguisticData(linguisticRes.data || null);
setEmotionalData(emotionalRes.data || null);
setInteractionData(filteredInteractionData || null); setInteractionData(filteredInteractionData || null);
setCulturalData(culturalRes.data || null); setCulturalData(culturalRes.data || null);
setSummary(filteredSummary || null); setSummary(filteredSummary || null);
@@ -435,22 +462,35 @@ const StatPage = () => {
<SummaryStats <SummaryStats
userData={userData} userData={userData}
timeData={timeData} timeData={timeData}
contentData={contentData} linguisticData={linguisticData}
summary={summary} summary={summary}
/> />
)} )}
{activeView === "emotional" && contentData && ( {activeView === "emotional" && emotionalData && (
<EmotionalStats contentData={contentData} /> <EmotionalStats emotionalData={emotionalData} />
)} )}
{activeView === "emotional" && !contentData && ( {activeView === "emotional" && !emotionalData && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}> <div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No emotional data available. No emotional data available.
</div> </div>
)} )}
{activeView === "user" && userData && <UserStats data={userData} />} {activeView === "user" && userData && interactionData && (
<UserStats
topUsers={userData.top_users}
interactionGraph={interactionData.interaction_graph}
totalUsers={userStatsMeta.totalUsers}
mostCommentHeavyUser={userStatsMeta.mostCommentHeavyUser}
/>
)}
{activeView === "user" && (!userData || !interactionData) && (
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
No user network data available.
</div>
)}
{activeView === "linguistic" && linguisticData && ( {activeView === "linguistic" && linguisticData && (
<LinguisticStats data={linguisticData} /> <LinguisticStats data={linguisticData} />

View File

@@ -1,4 +1,5 @@
import { ResponsiveHeatMap } from "@nivo/heatmap"; import { ResponsiveHeatMap } from "@nivo/heatmap";
import { memo, useMemo } from "react";
type ApiRow = Record<number, number>; type ApiRow = Record<number, number>;
type ActivityHeatmapProps = { type ActivityHeatmapProps = {
@@ -40,11 +41,19 @@ const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
}; };
const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => { const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
const convertedData = convertWeeklyData(data); const convertedData = useMemo(() => convertWeeklyData(data), [data]);
const maxValue = Math.max( const maxValue = useMemo(() => {
...convertedData.flatMap((day) => day.data.map((point) => point.y)), 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 ( return (
<ResponsiveHeatMap <ResponsiveHeatMap
@@ -64,4 +73,4 @@ const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
); );
}; };
export default ActivityHeatmap; export default memo(ActivityHeatmap);