diff --git a/frontend/src/components/CulturalStats.tsx b/frontend/src/components/CulturalStats.tsx new file mode 100644 index 0000000..7f3a775 --- /dev/null +++ b/frontend/src/components/CulturalStats.tsx @@ -0,0 +1,119 @@ +import Card from "./Card"; +import StatsStyling from "../styles/stats_styling"; +import type { CulturalAnalysisResponse } from "../types/ApiTypes"; + +const styles = StatsStyling; + +type CulturalStatsProps = { + data: CulturalAnalysisResponse; +}; + +const CulturalStats = ({ data }: CulturalStatsProps) => { + const identity = data.identity_markers; + const stance = data.stance_markers; + const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {}; + const entities = Object.entries(rawEntities) + .sort((a, b) => (b[1].post_count - a[1].post_count)) + .slice(0, 20); + + const topEmotion = (emotionAvg: Record | undefined) => { + const entries = Object.entries(emotionAvg ?? {}); + if (!entries.length) { + return "—"; + } + + entries.sort((a, b) => b[1] - a[1]); + const dominant = entries[0] ?? ["emotion_unknown", 0]; + const dominantLabel = dominant[0].replace("emotion_", ""); + return `${dominantLabel} (${dominant[1].toFixed(3)})`; + }; + + return ( +
+
+ + + + + + + + + + +
+

In-Group Emotion Profile

+

Dominant average emotion where in-group framing is stronger.

+
{topEmotion(identity?.in_group_emotion_avg)}
+
+ +
+

Out-Group Emotion Profile

+

Dominant average emotion where out-group framing is stronger.

+
{topEmotion(identity?.out_group_emotion_avg)}
+
+ +
+

Entity Emotion Averages

+

Most frequent entities and their dominant average emotion signature.

+ {!entities.length ? ( +
No entity-level cultural data available.
+ ) : ( +
+ {entities.map(([entity, aggregate]) => ( +
+
{entity}
+
+ {aggregate.post_count.toLocaleString()} posts • Dominant emotion: {topEmotion(aggregate.emotion_avg)} +
+
+ ))} +
+ )} +
+
+
+ ); +}; + +export default CulturalStats; diff --git a/frontend/src/components/InteractionalStats.tsx b/frontend/src/components/InteractionalStats.tsx new file mode 100644 index 0000000..43567c5 --- /dev/null +++ b/frontend/src/components/InteractionalStats.tsx @@ -0,0 +1,198 @@ +import Card from "./Card"; +import StatsStyling from "../styles/stats_styling"; +import type { InteractionAnalysisResponse } from "../types/ApiTypes"; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + PieChart, + Pie, + Cell, + Legend, +} from "recharts"; + +const styles = StatsStyling; + +type InteractionalStatsProps = { + data: InteractionAnalysisResponse; +}; + +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); + const concentration = data.conversation_concentration; + const topTenCommentShare = typeof concentration?.top_10pct_comment_share === "number" + ? concentration?.top_10pct_comment_share + : null; + const topTenAuthorCount = typeof concentration?.top_10pct_author_count === "number" + ? concentration.top_10pct_author_count + : null; + const totalCommentingAuthors = typeof concentration?.total_commenting_authors === "number" + ? concentration.total_commenting_authors + : null; + const singleCommentAuthorRatio = typeof concentration?.single_comment_author_ratio === "number" + ? concentration.single_comment_author_ratio + : null; + + const topPairs = (data.top_interaction_pairs ?? []) + .filter((item): item is [[string, string], number] => { + if (!Array.isArray(item) || item.length !== 2) { + return false; + } + + const pair = item[0]; + const count = item[1]; + + return Array.isArray(pair) + && pair.length === 2 + && typeof pair[0] === "string" + && typeof pair[1] === "string" + && typeof count === "number"; + }) + .slice(0, 20); + + const topPairChartData = topPairs.slice(0, 8).map(([[source, target], value], index) => ({ + pair: `${source} -> ${target}`, + replies: value, + rank: index + 1, + })); + + const topTenSharePercent = topTenCommentShare === null + ? null + : topTenCommentShare * 100; + const nonTopTenSharePercent = topTenSharePercent === null + ? null + : Math.max(0, 100 - topTenSharePercent); + + let concentrationPieData: { name: string; value: number }[] = []; + if (topTenSharePercent !== null && nonTopTenSharePercent !== null) { + concentrationPieData = [ + { name: "Top 10% authors", value: topTenSharePercent }, + { name: "Other authors", value: nonTopTenSharePercent }, + ]; + } + + const PIE_COLORS = ["#2b6777", "#c8d8e4"]; + + return ( +
+
+ + + + + + + +
+

Interaction Visuals

+

Quick charts for interaction direction and conversation concentration.

+ +
+
+

Top Interaction Pairs

+
+ + + + + `#${value}`} + width={36} + /> + + + + +
+
+ +
+

Top 10% vs Other Comment Share

+
+ + + + {concentrationPieData.map((entry, index) => ( + + ))} + + + + + +
+
+
+
+ +
+

Top Interaction Pairs

+

Most frequent directed reply paths between users.

+ {!topPairs.length ? ( +
No interaction pair data available.
+ ) : ( +
+ {topPairs.map(([[source, target], value], index) => ( +
${target}-${index}`} style={styles.topUserItem}> +
{source} -> {target}
+
{value.toLocaleString()} replies
+
+ ))} +
+ )} +
+
+
+ ); +}; + +export default InteractionalStats; diff --git a/frontend/src/components/LinguisticStats.tsx b/frontend/src/components/LinguisticStats.tsx new file mode 100644 index 0000000..3569511 --- /dev/null +++ b/frontend/src/components/LinguisticStats.tsx @@ -0,0 +1,86 @@ +import Card from "./Card"; +import StatsStyling from "../styles/stats_styling"; +import type { LinguisticAnalysisResponse } from "../types/ApiTypes"; + +const styles = StatsStyling; + +type LinguisticStatsProps = { + data: LinguisticAnalysisResponse; +}; + +const LinguisticStats = ({ data }: LinguisticStatsProps) => { + const lexical = data.lexical_diversity; + const words = data.word_frequencies ?? []; + const bigrams = data.common_two_phrases ?? []; + const trigrams = data.common_three_phrases ?? []; + + const topWords = words.slice(0, 20); + const topBigrams = bigrams.slice(0, 10); + const topTrigrams = trigrams.slice(0, 10); + + return ( +
+
+ + + + +
+

Top Words

+

Most frequent filtered terms.

+
+ {topWords.map((item) => ( +
+
{item.word}
+
{item.count.toLocaleString()} uses
+
+ ))} +
+
+ +
+

Top Bigrams

+

Most frequent 2-word phrases.

+
+ {topBigrams.map((item) => ( +
+
{item.ngram}
+
{item.count.toLocaleString()} uses
+
+ ))} +
+
+ +
+

Top Trigrams

+

Most frequent 3-word phrases.

+
+ {topTrigrams.map((item) => ( +
+
{item.ngram}
+
{item.count.toLocaleString()} uses
+
+ ))} +
+
+
+
+ ); +}; + +export default LinguisticStats; diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index 683584f..910b0a0 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -5,12 +5,20 @@ import StatsStyling from "../styles/stats_styling"; import SummaryStats from "../components/SummaryStats"; import EmotionalStats from "../components/EmotionalStats"; import UserStats from "../components/UserStats"; +import LinguisticStats from "../components/LinguisticStats"; +import InteractionalStats from "../components/InteractionalStats"; +import CulturalStats from "../components/CulturalStats"; import { type SummaryResponse, type UserAnalysisResponse, type TimeAnalysisResponse, - type ContentAnalysisResponse + type ContentAnalysisResponse, + type UserEndpointResponse, + type LinguisticAnalysisResponse, + type EmotionalAnalysisResponse, + type InteractionAnalysisResponse, + type CulturalAnalysisResponse } from '../types/ApiTypes' const API_BASE_URL = import.meta.env.VITE_BACKEND_URL @@ -20,11 +28,14 @@ const StatPage = () => { const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>(); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const [activeView, setActiveView] = useState<"summary" | "emotional" | "user">("summary"); + const [activeView, setActiveView] = useState<"summary" | "emotional" | "user" | "linguistic" | "interactional" | "cultural">("summary"); const [userData, setUserData] = useState(null); const [timeData, setTimeData] = useState(null); const [contentData, setContentData] = useState(null); + const [linguisticData, setLinguisticData] = useState(null); + const [interactionData, setInteractionData] = useState(null); + const [culturalData, setCulturalData] = useState(null); const [summary, setSummary] = useState(null); @@ -83,15 +94,23 @@ const StatPage = () => { setLoading(true); Promise.all([ - axios.get(`${API_BASE_URL}/dataset/${datasetId}/time`, { + axios.get(`${API_BASE_URL}/dataset/${datasetId}/temporal`, { params, headers: authHeaders, }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/user`, { + axios.get(`${API_BASE_URL}/dataset/${datasetId}/user`, { params, headers: authHeaders, }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/content`, { + axios.get(`${API_BASE_URL}/dataset/${datasetId}/linguistic`, { + params, + headers: authHeaders, + }), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/emotional`, { + params, + headers: authHeaders, + }), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/interactional`, { params, headers: authHeaders, }), @@ -99,11 +118,28 @@ const StatPage = () => { params, headers: authHeaders, }), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/cultural`, { + params, + headers: authHeaders, + }), ]) - .then(([timeRes, userRes, contentRes, summaryRes]) => { - setUserData(userRes.data || null); + .then(([timeRes, userRes, linguisticRes, emotionalRes, interactionRes, summaryRes, culturalRes]) => { + const combinedUserData: UserAnalysisResponse = { + ...userRes.data, + interaction_graph: interactionRes.data?.interaction_graph ?? {}, + }; + + const combinedContentData: ContentAnalysisResponse = { + ...linguisticRes.data, + ...emotionalRes.data, + }; + + setUserData(combinedUserData); setTimeData(timeRes.data || null); - setContentData(contentRes.data || null); + setContentData(combinedContentData); + setLinguisticData(linguisticRes.data || null); + setInteractionData(interactionRes.data || null); + setCulturalData(culturalRes.data || null); setSummary(summaryRes.data || null); }) .catch((e) => setError("Failed to load statistics: " + String(e))) @@ -218,6 +254,24 @@ return ( > Users + + + {activeView === "summary" && ( @@ -243,6 +297,36 @@ return ( )} + {activeView === "linguistic" && linguisticData && ( + + )} + + {activeView === "linguistic" && !linguisticData && ( +
+ No linguistic data available. +
+ )} + + {activeView === "interactional" && interactionData && ( + + )} + + {activeView === "interactional" && !interactionData && ( +
+ No interactional data available. +
+ )} + + {activeView === "cultural" && culturalData && ( + + )} + + {activeView === "cultural" && !culturalData && ( +
+ No cultural data available. +
+ )} + ); }