feat!(frontend): add cultural, interactional and linguistic stat pages
This commit is contained in:
198
frontend/src/components/InteractionalStats.tsx
Normal file
198
frontend/src/components/InteractionalStats.tsx
Normal file
@@ -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 (
|
||||
<div style={styles.page}>
|
||||
<div style={{ ...styles.container, ...styles.grid }}>
|
||||
<Card
|
||||
label="Avg Thread Depth"
|
||||
value={typeof data.average_thread_depth === "number" ? data.average_thread_depth.toFixed(2) : "—"}
|
||||
sublabel="Depth from reply chains"
|
||||
style={{ gridColumn: "span 3" }}
|
||||
/>
|
||||
<Card
|
||||
label="Network Users"
|
||||
value={userCount.toLocaleString()}
|
||||
sublabel="Authors in interaction graph"
|
||||
style={{ gridColumn: "span 3" }}
|
||||
/>
|
||||
<Card
|
||||
label="Unique Links"
|
||||
value={edgeCount.toLocaleString()}
|
||||
sublabel="Directed source-target pairs"
|
||||
style={{ gridColumn: "span 3" }}
|
||||
/>
|
||||
<Card
|
||||
label="Interaction Volume"
|
||||
value={interactionVolume.toLocaleString()}
|
||||
sublabel="Sum of link weights"
|
||||
style={{ gridColumn: "span 3" }}
|
||||
/>
|
||||
<Card
|
||||
label="Top 10% Comment Share"
|
||||
value={topTenSharePercent === null ? "-" : `${topTenSharePercent.toFixed(1)}%`}
|
||||
sublabel={topTenAuthorCount === null || totalCommentingAuthors === null
|
||||
? "Comment volume held by top commenters"
|
||||
: `${topTenAuthorCount.toLocaleString()} of ${totalCommentingAuthors.toLocaleString()} authors`}
|
||||
style={{ gridColumn: "span 6" }}
|
||||
/>
|
||||
<Card
|
||||
label="Single-Comment Authors"
|
||||
value={singleCommentAuthorRatio === null ? "-" : `${(singleCommentAuthorRatio * 100).toFixed(1)}%`}
|
||||
sublabel="Authors who commented exactly once"
|
||||
style={{ gridColumn: "span 6" }}
|
||||
/>
|
||||
|
||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||
<h2 style={styles.sectionTitle}>Interaction Visuals</h2>
|
||||
<p style={styles.sectionSubtitle}>Quick charts for interaction direction and conversation concentration.</p>
|
||||
|
||||
<div style={{ ...styles.grid, marginTop: 12 }}>
|
||||
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
|
||||
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>Top Interaction Pairs</h3>
|
||||
<div style={{ width: "100%", height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={topPairChartData} layout="vertical" margin={{ top: 8, right: 16, left: 16, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#d9e2ec" />
|
||||
<XAxis type="number" allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="rank"
|
||||
tickFormatter={(value) => `#${value}`}
|
||||
width={36}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Bar dataKey="replies" fill="#2b6777" radius={[0, 6, 6, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
|
||||
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>Top 10% vs Other Comment Share</h3>
|
||||
<div style={{ width: "100%", height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={concentrationPieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={56}
|
||||
outerRadius={88}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{concentrationPieData.map((entry, index) => (
|
||||
<Cell key={`${entry.name}-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||
<h2 style={styles.sectionTitle}>Top Interaction Pairs</h2>
|
||||
<p style={styles.sectionSubtitle}>Most frequent directed reply paths between users.</p>
|
||||
{!topPairs.length ? (
|
||||
<div style={styles.topUserMeta}>No interaction pair data available.</div>
|
||||
) : (
|
||||
<div style={{ ...styles.topUsersList, maxHeight: 420, overflowY: "auto" }}>
|
||||
{topPairs.map(([[source, target], value], index) => (
|
||||
<div key={`${source}->${target}-${index}`} style={styles.topUserItem}>
|
||||
<div style={styles.topUserName}>{source} -> {target}</div>
|
||||
<div style={styles.topUserMeta}>{value.toLocaleString()} replies</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractionalStats;
|
||||
Reference in New Issue
Block a user